/** * 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, Navigate, 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, Button, Card, EmptyState, Section, Skeleton, Stat } from '@/components/ui'; import { PageHeader } from '@/components/ui/page-header'; 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 ( }> {/* `/` is the front door, and the front door is Piggy. -------------------------------------------------- Signing in does not navigate from the form: the auth gate starts rendering these routes at the browser's current address. `/` goes to Piggy directly, while `/login` has its own authenticated-only redirect below so a person who used the explicit sign-in URL does not land on the catch-all Not found page. It is a redirect rather than Piggy mounted at the index, because the workspace needs ONE address. Two paths rendering it would leave the sidebar row unlit on `/`, the breadcrumb blank, and a shared link ambiguous. `replace` keeps `/` out of history, so Back leaves the app instead of bouncing between the two, and the logo — which points at `/` and means "home" — lands on the same screen it always did, only home is Piggy now. Overview moves to `/overview` rather than being displaced: it is the exec's page, it keeps its place at the top of Intelligence, it keeps its tab on the phone, and it is one click from anywhere. What it loses is being the thing you are shown before you have asked for anything, which is the whole point of the change — a report is what you open when you have a question about the business, and Piggy is where you ask it. Nothing else moves. Every other path is registered exactly as before, so `/accounts/:id`, `/margin` and every bookmark and Piggy record link into them still resolve directly, with no pass through here. */} } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* 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} ); } /** * A route that FILLS the content pane instead of flowing down it. * * Shell puts every page inside `mx-auto max-w-7xl px-4 py-5 …`, which is right * for a document and wrong for a workspace: an agent surface with a * conversation list, a transcript and an activity panel wants the whole pane, * a floor it can pin a composer to, and no page scrollbar behind the two * panels that already scroll. * * `absolute inset-0` is how it gets that without a second shell. SidebarInset * is `relative` (see ui/sidebar), so this box is laid out against the content * pane itself — full width whatever the container capped, full height whatever * the container did not stretch to — while the capped container stays exactly * as it is for the twelve pages that want it. Taking it out of flow is also * what makes `overflow-hidden` safe here: the page cannot grow, so the panels * inside must own their own scrolling, which is the contract a workspace wants * anyway. * * The bottom padding is the one thing that has to be restated. An absolutely * positioned child is laid out against its ancestor's PADDING box, so the * inset's own tab-bar clearance does not apply to it, and without this the * composer would sit underneath the phone tab bar — the exact control a phone * user came here to reach. `lg` matches where the tab bar gives way. * * Under 500px tall the reserve is given back. A phone in landscape, or a phone * with the keyboard up, is spending 72px of a 390px viewport on a bar it can * reach again by turning the handset back — while the transcript, which is why * the page exists, is measured at 40px. The tab bar itself stands down at the * same height (Shell.tsx), so nothing lands underneath it. */ function WorkspaceRoute({ children }: { children: React.ReactNode }) { return (
{/* `flex-1` on the fallback, or the spinner for a pane this tall sits up against the header while the rest of it stays empty. */}
}> {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, refetch } = 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 ( /* * The one page that never got a design pass, because it never had an owner: * it lives inline in App.tsx rather than in `pages/`, so the wave that swept * all thirteen routes swept past it. Measured against the rest of the * product it carried a 30px `

` where every other route is 24px, an * accent-coloured "ACCESS MAP" eyebrow of exactly the kind the direction * deleted from Growth (identity colour used as decoration, on a page with * no agent in it), hand-rolled 10px/24px stat tiles instead of `Stat`, and * a 14px section heading floating on the canvas. It is now the same three * primitives every other page is built from and nothing else changed. */
Manage access in Settings } /> {/* `grid-cols-2 gap-3 xl:grid-cols-*`, the same KPI row Overview and Margin use. This carried `grid-cols-3 sm:max-w-xl`, which made Team the one page whose headline figures were a different size and whose row stopped halfway across the page. */}
{error ? ( {/* Three routes rendered an honest error and then offered nothing to do about it. A transient 500 on a page with no Try again is a page a person has to know to reload. */} void refetch()}> Try again } /> ) : null} {isLoading ? (
{[0, 1, 2].map((key) => )}
) : null} {!isLoading && !error && data?.length === 0 ? ( ) : null} {!isLoading && !error && data?.length ? (
{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}
); }