// ============================================================================ // public/app.jsx — VoAIs SaaS App Shell (Phase 2) // ---------------------------------------------------------------------------- // On boot: // 1. Apply persisted tweaks (theme/density/accent) instantly to avoid FOUC. // 2. Read localStorage session — if a token exists, verify it with /me. // 3. If authed, hydrate server-side prefs into VoaisTweaks and render shell. // 4. If not, render and route to shell on successful auth. // // The shell composes Sidebar + TopBar + main content + MobileTabBar. // Routing is hash-based (/#dashboard, /#campaigns) so the back button works // and refreshes preserve the current screen. // ============================================================================ const { useState, useEffect, useCallback, useMemo } = React; // ============================================================================ // Navigation config — exact mirror of voais_ui/app.jsx so existing CSS works. // ============================================================================ const USER_NAV = [ { label: "Workspace", items: [ { id: "dashboard", label: "Dashboard", icon: I.dashboard }, { id: "inbox", label: "Unified Inbox", icon: I.inbox }, { id: "monitor", label: "Live Monitor", icon: I.monitor, live: true }, ]}, { label: "Outreach", items: [ { id: "campaigns", label: "Campaigns", icon: I.campaigns }, { id: "whatsapp", label: "WhatsApp", icon: I.whatsapp }, { id: "agents", label: "AI Agents", icon: I.agents }, { id: "flow", label: "Flow Builder", icon: I.flow }, { id: "contacts", label: "Contacts", icon: I.contacts }, { id: "kb", label: "Knowledge Base", icon: I.kb }, ]}, { label: "Intelligence", items: [ { id: "history", label: "Call History", icon: I.history }, { id: "analytics", label: "Analytics", icon: I.analytics }, ]}, { label: "Account", items: [ { id: "integrations", label: "Integrations", icon: I.integrations }, { id: "pricing", label: "Plans & Billing", icon: I.billing }, { id: "settings", label: "Settings", icon: I.settings }, ]}, ]; const ADMIN_NAV = [ { label: "Platform", items: [ { id: "admin_overview", label: "Overview", icon: I.dashboard }, { id: "admin_health", label: "System Health", icon: I.server, live: true }, ]}, { label: "Tenants", items: [ { id: "admin_clients", label: "Clients", icon: I.users }, { id: "admin_sip", label: "SIP Trunks", icon: I.network }, { id: "admin_agents", label: "Agent Library", icon: I.agents }, ]}, { label: "Operations", items: [ { id: "admin_billing", label: "Billing & Usage", icon: I.billing }, { id: "admin_compliance", label: "Compliance", icon: I.shield }, { id: "admin_audit", label: "Audit Logs", icon: I.history }, ]}, { label: "Account", items: [ { id: "settings", label: "Settings", icon: I.settings }, ]}, ]; const ROUTE_META = { dashboard: { title: "Dashboard", subtitle: (s) => `Welcome back${s?.user?.name ? ", " + s.user.name.split(" ")[0] : ""} — here's what's happening today.` }, monitor: { title: "Live Call Monitor", subtitle: "Real-time view of active calls. Listen, barge, or take over." }, campaigns: { title: "Campaigns", subtitle: "Manage outbound and inbound calling campaigns." }, agents: { title: "AI Agents", subtitle: "Configure personas, voices, and conversation behavior." }, flow: { title: "Flow Builder", subtitle: "Design the AI conversation logic visually." }, contacts: { title: "Contacts", subtitle: "Upload, segment, and manage your contact lists." }, kb: { title: "Knowledge Base", subtitle: "Documents your AI uses to answer questions (RAG)." }, history: { title: "Call History", subtitle: "Every call, transcript, recording, and outcome." }, analytics: { title: "Analytics", subtitle: "Campaign performance, trends, and lead quality." }, integrations: { title: "Integrations", subtitle: "Connect VoAIs to your CRM, calendar, and automations." }, pricing: { title: "Plans & Billing", subtitle: "Your current plan, usage, and invoices." }, settings: { title: "Settings", subtitle: "Profile, team, security, and notifications." }, inbox: { title: "Unified Inbox", subtitle: "All conversations across calls, WhatsApp, and email — in one place." }, whatsapp: { title: "WhatsApp Business", subtitle: "Conversations, templates, and broadcasts for post-call follow-up." }, admin_overview: { title: "Admin Overview", subtitle: "Platform-wide KPIs across all tenants." }, admin_clients: { title: "Clients", subtitle: "All tenant accounts — usage, billing, status." }, admin_sip: { title: "SIP Trunks", subtitle: "Telephony providers, channel capacity, uptime." }, admin_agents: { title: "Agent Library", subtitle: "Global persona templates available to all clients." }, admin_billing: { title: "Billing & Usage", subtitle: "Revenue, MRR, and per-client usage." }, admin_compliance: { title: "Compliance Center", subtitle: "DND, TRAI rules, calling hours enforcement." }, admin_health: { title: "System Health", subtitle: "AI providers, SIP trunks, queues, error rates." }, admin_audit: { title: "Audit Logs", subtitle: "Every admin action, billing event, API call." }, }; // ============================================================================ // Router — picks the right screen based on route id. // ============================================================================ function Router({ route, go, view, session }) { switch (route) { case "dashboard": return ; case "monitor": return ; case "campaigns": return ; case "agents": return ; case "flow": return ; case "contacts": return ; case "kb": return ; case "history": return ; case "analytics": return ; case "integrations": return ; case "pricing": return ; case "settings": return ; case "inbox": return ; case "whatsapp": return ; case "admin_overview": return ; case "admin_clients": return ; case "admin_sip": return ; case "admin_agents": return ; case "admin_billing": return ; case "admin_compliance": return ; case "admin_health": return ; case "admin_audit": return ; default: return ; } } // ============================================================================ // App — top-level component. Handles auth state + boot sequencing. // ============================================================================ function App() { // null = still checking session, false = unauthenticated, true = authed const [authed, setAuthed] = useState(null); const [session, setSession] = useState(null); // Verify session on boot (or on every page reload). useEffect(() => { const local = VoaisAPI.getSession(); if (!local || !local.token) { setAuthed(false); return; } VoaisAPI.auth.me().then((r) => { if (r.ok && r.data?.authenticated) { const fresh = { token: local.token, tenant: r.data.tenant || local.tenant, user: r.data.user || local.user, admin: r.data.admin || local.admin, actorType: r.data.actorType, subscription: r.data.subscription || null, }; VoaisAPI.setSession(fresh); setSession(fresh); setAuthed(true); // Hydrate UI prefs (theme/density/accent/sidebar) from server. // Local already applied on script load; this just adds anything saved. if (r.data.actorType === "tenant_user") { VoaisAPI.get("/api/me/prefs").then((rr) => { if (rr.ok && rr.data?.prefs) VoaisTweaks.hydrateFromServer(rr.data.prefs); }).catch(() => {}); } } else { VoaisAPI.clearSession(); setAuthed(false); } }).catch(() => setAuthed(false)); }, []); // Auto-logout on any 401 from elsewhere in the app. useEffect(() => { const onLost = () => { setAuthed(false); setSession(null); }; window.addEventListener("voais:auth-lost", onLost); return () => window.removeEventListener("voais:auth-lost", onLost); }, []); const handleAuth = useCallback((sess) => { setSession(sess); setAuthed(true); // After login, fetch prefs. if (sess?.actorType !== "super_admin") { VoaisAPI.get("/api/me/prefs").then((rr) => { if (rr.ok && rr.data?.prefs) VoaisTweaks.hydrateFromServer(rr.data.prefs); }).catch(() => {}); } }, []); const handleLogout = useCallback(async () => { await VoaisAPI.auth.logout(); setAuthed(false); setSession(null); }, []); if (authed === null) { return ( Loading workspace… ); } if (!authed) return ; return ; } // ============================================================================ // AppShell — the authenticated app layout: sidebar + topbar + content. // ============================================================================ function AppShell({ session, onLogout }) { const isSuperAdmin = session?.actorType === "super_admin"; // Initial state from VoaisTweaks (localStorage) + URL hash. const initialTweaks = VoaisTweaks.load(); const initialRoute = (() => { const h = location.hash.replace(/^#/, ""); if (h && ROUTE_META[h]) return h; if (initialTweaks.lastRoute && ROUTE_META[initialTweaks.lastRoute]) return initialTweaks.lastRoute; return isSuperAdmin ? "admin_overview" : "dashboard"; })(); const [route, setRoute] = useState(initialRoute); const [view, setView] = useState(isSuperAdmin ? "admin" : "user"); const [collapsed, setCollapsed] = useState(!!initialTweaks.sidebarCollapsed); const [theme, setTheme] = useState(initialTweaks.theme || "dark"); const [mobileNavOpen, setMobileNavOpen] = useState(false); // Super-admins can flip into a tenant view (Phase 8 will add real impersonation); // for now we only show the toggle if the user is an owner/admin role. const canSwitchToAdmin = isSuperAdmin; // tenant users don't get the toggle in Phase 2 // Keep VoaisTweaks in sync whenever local state changes. useEffect(() => { VoaisTweaks.set("sidebarCollapsed", collapsed); }, [collapsed]); useEffect(() => { VoaisTweaks.set("theme", theme); }, [theme]); // Routing — push to URL hash, scroll to top, close mobile drawer. const go = useCallback((id) => { if (!ROUTE_META[id]) id = isSuperAdmin ? "admin_overview" : "dashboard"; setRoute(id); setMobileNavOpen(false); if (location.hash !== "#" + id) location.hash = id; VoaisTweaks.set("lastRoute", id); window.scrollTo({ top: 0, behavior: "instant" }); }, [isSuperAdmin]); // Listen to back/forward. useEffect(() => { const onHash = () => { const h = location.hash.replace(/^#/, ""); if (h && ROUTE_META[h]) setRoute(h); }; window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); // Switching view changes default route. useEffect(() => { if (view === "admin" && !route.startsWith("admin_") && route !== "settings") go("admin_overview"); if (view === "user" && route.startsWith("admin_")) go("dashboard"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [view]); const nav = view === "admin" ? ADMIN_NAV : USER_NAV; // Subtitle can be a function (so it can read session). const rawMeta = ROUTE_META[route] || ROUTE_META.dashboard; const meta = { title: rawMeta.title, subtitle: typeof rawMeta.subtitle === "function" ? rawMeta.subtitle(session) : rawMeta.subtitle, }; const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark")); return ( {mobileNavOpen && setMobileNavOpen(false)}/>} setMobileNavOpen(true)} session={session} subscription={session?.subscription} /> setMobileNavOpen(true)}/> ); } window.App = App;