/** * Application root: routing, data fetching, and the auth gate. */ import { lazy, Suspense, useEffect, useState } from 'react'; import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { Link } from 'react-router-dom'; import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api'; import { ThemeProvider } from '@/lib/theme'; import { IdentityProvider, useIdentityQuery } from '@/lib/identity'; import { LayoutProvider } from '@/lib/layout'; import { PiggyContextProvider } from '@/lib/piggy-context'; import { PlatformAudioProvider } from '@/lib/audio'; import { Shell } from '@/components/Shell'; import { SignIn } from '@/pages/SignIn'; import { CreateProfile } from '@/pages/CreateProfile'; import { Register } from '@/pages/Register'; import { PiggyMark } from '@/components/PiggyMark'; import { Badge, Card, EmptyState, Skeleton } from '@/components/ui'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Toaster } from '@/components/ui/sonner'; import { usePageTitle } from '@/lib/title'; const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview }))); const Capacity = lazy(() => import('@/pages/Capacity').then(({ Capacity }) => ({ default: Capacity }))); const DemandPipeline = lazy(() => import('@/pages/Pipeline').then(({ DemandPipeline }) => ({ default: DemandPipeline }))); const SupplyPipeline = lazy(() => import('@/pages/Pipeline').then(({ SupplyPipeline }) => ({ default: SupplyPipeline }))); const Settings = lazy(() => import('@/pages/Settings').then(({ Settings }) => ({ default: Settings }))); const Accounts = lazy(() => import('@/pages/Accounts').then(({ Accounts }) => ({ default: Accounts }))); const Account = lazy(() => import('@/pages/Account').then(({ Account }) => ({ default: Account }))); const Margin = lazy(() => import('@/pages/Margin').then(({ Margin }) => ({ default: Margin }))); const FactReview = lazy(() => import('@/pages/FactReview').then(({ FactReview }) => ({ default: FactReview }))); const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts }))); const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports }))); const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy }))); const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth }))); const Calendar = lazy(() => import('@/pages/Calendar').then(({ Calendar }) => ({ default: Calendar }))); const Learn = lazy(() => import('@/pages/Learn').then(({ Learn }) => ({ default: Learn }))); // Lazy is load-bearing for these five and not merely conventional: they are the // only pages that pull react-markdown and remark-gfm, and an eager import would // put a markdown parser into the entry chunk every route pays for. const Motion = lazy(() => import('@/pages/Motion').then(({ Motion }) => ({ default: Motion }))); const MotionLibrary = lazy(() => import('@/pages/MotionLibrary').then(({ MotionLibrary }) => ({ default: MotionLibrary }))); const MotionTemplate = lazy(() => import('@/pages/MotionTemplate').then(({ MotionTemplate }) => ({ default: MotionTemplate }))); const MotionEngagements = lazy(() => import('@/pages/MotionEngagements').then(({ MotionEngagements }) => ({ default: MotionEngagements }))); const Engagement = lazy(() => import('@/pages/Engagement').then(({ Engagement }) => ({ default: Engagement }))); const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, // Refetching every time a phone user switches apps and comes back is // wasteful on cellular; the interval on the dashboard covers freshness. refetchOnWindowFocus: false, retry: (failureCount, error) => { // Retrying an auth failure just produces the same failure slower. if (error instanceof ApiError && (error.needsSignIn || error.needsProfile)) return false; return failureCount < 2; }, }, }, }); export function App() { const [config, setConfig] = useState(null); const [configError, setConfigError] = useState(null); useEffect(() => { loadPublicConfig() .then(setConfig) .catch((error: unknown) => setConfigError(error instanceof Error ? error.message : 'Could not reach the server.'), ); }, []); if (configError) { return ( ); } if (!config) return ; return ( { // Fire and forget: a failed preference save must never block the UI, // and the local copy already applied the change. void patch('/api/me/preferences', prefs).catch(() => {}); }} > {/* Above the router, so the music survives navigation AND covers the anonymous Learn page — a share-code visitor gets the same platform character as a member. It never plays unbidden: the browser refuses audio until the page has had a real gesture, so it begins when someone actually starts using the page, and the header control mutes it for good on that device. */} {/* Inside ThemeProvider: the host reads the resolved light/dark value. */} ); } /** * The signed-out screens render outside Shell. Their shared AuthShell supplies * the public header and its music control; keeping this boundary component * means the auth gate does not need to know anything about that presentation. * * Learn is not wrapped: it brings its own chrome and already hosts one. */ function SignedOut({ children }: { children: React.ReactNode }) { return children; } /** * Decides what to show based on *why* a request failed. * * The distinction between "not signed in" and "signed in but not a member" is * the one that matters: sending an invited-but-unprovisioned user back to a * login screen they have already completed is an infuriating loop, and it is * the default behaviour if both are treated as "auth error". */ function AuthGate({ config }: { config: PublicConfig }) { // Which unauthenticated screen to show. Kept in state rather than a route so // that a half-filled registration form is not lost to an accidental Back. const [showRegister, setShowRegister] = useState(false); const { data, isLoading, error, refetch } = useIdentityQuery(); // Adopt the server's stored appearance once we know who this is. Appearance // only: sidebar-collapsed and dock-open are per-device and live in // localStorage, deliberately (see lib/layout.tsx). useEffect(() => { if (!data) return; void get<{ themeMode?: string; accentColor?: string }>('/api/me/profile') .then((profile) => { if (!profile) return; const host = window as unknown as { __pigAdoptTheme?: (p: unknown) => void }; host.__pigAdoptTheme?.(profile); }) .catch(() => {}); }, [data]); // React to sign-in and sign-out without a page reload. useEffect(() => { const supabase = getSupabase(); if (!supabase) return; const { data: subscription } = supabase.auth.onAuthStateChange(() => { void refetch(); }); return () => subscription.subscription.unsubscribe(); }, [refetch]); if (isLoading) return ; if (error instanceof ApiError) { if (error.needsSignIn) { /* * Learn is the one route reachable without an account. It gates itself on * a share code, and the API only ever serves it platform-track rows — so * sending a code-holder to the sign-in screen would make the code * unusable, which is the whole point of having one. * * Rendered outside Shell deliberately: the page uses no identity, layout * or dock hook, and there is no member to build a workspace chrome for. */ if (window.location.pathname === '/learn') { return ( ); } return ( {showRegister ? ( setShowRegister(false)} onRegistered={() => { setShowRegister(false); void refetch(); }} /> ) : ( setShowRegister(true)} /> )} ); } // Authenticated but not a member. This is a step in the flow, not an // error — sending them back to a login screen they have already completed // would be a loop with no exit. if (error.needsProfile) { return ( void refetch()} /> ); } } if (error || !data) { return ( ); } return ( ); } function AppRoutes() { return ( }> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* The first record route in the product. Registered after the list so the list keeps `/accounts` exactly; react-router matches the more specific path regardless of order, but keeping them adjacent is how the next four record routes will read. */} } /> {/* Flat, in the register of the routes above — nesting these under a layout route would give Motion a chrome no other group has, and the five pages share no shell of their own. */} } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ); } function RoutePage({ children }: { children: React.ReactNode }) { return ( }> {children} ); } function RouteLoading() { return (
Loading view…
); } function Splash() { return ( ); } function Centered({ children }: { children: React.ReactNode }) { return (
{children}
); } function Placeholder({ title }: { title: string }) { usePageTitle(title); return ( ); } function Team() { usePageTitle('Team'); const { data, isLoading, error } = useQuery({ queryKey: ['team'], queryFn: () => get< { id: string; name: string; email: string; title: string | null; teams: { team: string; role: string }[]; }[] >('/api/team'), }); const assignments = (data ?? []).reduce((total, person) => total + person.teams.length, 0); const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size; return (

Access map

Team

See who can operate each side of the compute business and where ownership is thin.

Manage access in Settings
{[ ['People', data?.length ?? 0], ['Teams', representedTeams], ['Assignments', assignments], ].map(([label, value]) => (

{label}

{value}

))}
{error ? ( ) : null} {isLoading ? (
{[0, 1, 2].map((key) => )}
) : null} {!isLoading && !error && data?.length === 0 ? ( ) : null} {!isLoading && !error && data?.length ? (

People and permissions

Roles are enforced server-side
{data.map((person) => { const initials = person.name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase()) .join(''); return (
{initials || 'P'}

{person.name}

{person.title || 'Team member'}

{person.teams.map((membership) => ( {membership.team} · {membership.role} ))}
{person.teams.length === 0 ? (

No operational team assigned

) : null}
); })}
) : null}
); }