533 lines
22 KiB
TypeScript
533 lines
22 KiB
TypeScript
/**
|
|
* 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<PublicConfig | null>(null);
|
|
const [configError, setConfigError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadPublicConfig()
|
|
.then(setConfig)
|
|
.catch((error: unknown) =>
|
|
setConfigError(error instanceof Error ? error.message : 'Could not reach the server.'),
|
|
);
|
|
}, []);
|
|
|
|
if (configError) {
|
|
return (
|
|
<Centered>
|
|
<EmptyState
|
|
title="Cannot reach PIG"
|
|
description={configError}
|
|
/>
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
if (!config) return <Splash />;
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider
|
|
onPersist={(prefs) => {
|
|
// 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.
|
|
*/}
|
|
<PlatformAudioProvider>
|
|
<BrowserRouter>
|
|
<AuthGate config={config} />
|
|
</BrowserRouter>
|
|
</PlatformAudioProvider>
|
|
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
|
|
<Toaster />
|
|
</ThemeProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 <Splash />;
|
|
|
|
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 (
|
|
<RoutePage>
|
|
<Learn />
|
|
</RoutePage>
|
|
);
|
|
}
|
|
return (
|
|
<SignedOut>
|
|
{showRegister ? (
|
|
<Register
|
|
config={config}
|
|
onBack={() => setShowRegister(false)}
|
|
onRegistered={() => {
|
|
setShowRegister(false);
|
|
void refetch();
|
|
}}
|
|
/>
|
|
) : (
|
|
<SignIn config={config} onCreateAccount={() => setShowRegister(true)} />
|
|
)}
|
|
</SignedOut>
|
|
);
|
|
}
|
|
// 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 (
|
|
<SignedOut>
|
|
<CreateProfile config={config} onCreated={() => void refetch()} />
|
|
</SignedOut>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<Centered>
|
|
<EmptyState
|
|
title="Something went wrong"
|
|
description={error instanceof Error ? error.message : 'Unknown error.'}
|
|
/>
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<IdentityProvider identity={data}>
|
|
<LayoutProvider>
|
|
<PiggyContextProvider>
|
|
<AppRoutes />
|
|
</PiggyContextProvider>
|
|
</LayoutProvider>
|
|
</IdentityProvider>
|
|
);
|
|
}
|
|
|
|
function AppRoutes() {
|
|
return (
|
|
<Routes>
|
|
<Route element={<Shell />}>
|
|
{/*
|
|
`/` 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.
|
|
*/}
|
|
<Route index element={<Navigate to="/piggy" replace />} />
|
|
<Route path="login" element={<Navigate to="/" replace />} />
|
|
<Route path="overview" element={<RoutePage><Overview /></RoutePage>} />
|
|
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
|
|
<Route path="growth" element={<RoutePage><Growth /></RoutePage>} />
|
|
<Route path="calendar" element={<RoutePage><Calendar /></RoutePage>} />
|
|
<Route path="learn" element={<RoutePage><Learn /></RoutePage>} />
|
|
<Route path="capacity" element={<RoutePage><Capacity /></RoutePage>} />
|
|
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
|
|
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
|
|
<Route path="accounts" element={<RoutePage><Accounts /></RoutePage>} />
|
|
{/*
|
|
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.
|
|
*/}
|
|
<Route path="accounts/:id" element={<RoutePage><Account /></RoutePage>} />
|
|
{/*
|
|
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.
|
|
*/}
|
|
<Route path="motion" element={<RoutePage><Motion /></RoutePage>} />
|
|
<Route path="motion/library" element={<RoutePage><MotionLibrary /></RoutePage>} />
|
|
<Route path="motion/library/:id" element={<RoutePage><MotionTemplate /></RoutePage>} />
|
|
<Route path="motion/engagements" element={<RoutePage><MotionEngagements /></RoutePage>} />
|
|
<Route path="motion/engagements/:id" element={<RoutePage><Engagement /></RoutePage>} />
|
|
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
|
|
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
|
|
<Route path="piggy" element={<WorkspaceRoute><Piggy /></WorkspaceRoute>} />
|
|
<Route path="team" element={<Team />} />
|
|
<Route path="facts" element={<RoutePage><FactReview /></RoutePage>} />
|
|
<Route path="settings" element={<RoutePage><Settings /></RoutePage>} />
|
|
<Route path="*" element={<Placeholder title="Not found" />} />
|
|
</Route>
|
|
</Routes>
|
|
);
|
|
}
|
|
|
|
function RoutePage({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<Suspense fallback={<RouteLoading />}>
|
|
{children}
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className="absolute inset-0 flex min-h-0 flex-col overflow-hidden pb-[calc(4.5rem+var(--safe-bottom))] [@media(max-height:500px)]:pb-[var(--safe-bottom)] lg:pb-0">
|
|
{/* `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. */}
|
|
<Suspense fallback={<div className="flex flex-1 items-center justify-center"><RouteLoading /></div>}>
|
|
{children}
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RouteLoading() {
|
|
return (
|
|
<div
|
|
className="flex min-h-[50dvh] items-center justify-center"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex flex-col items-center gap-3 text-sm text-muted">
|
|
<PiggyMark className="h-9 w-9 animate-pulse text-fg" aria-hidden />
|
|
<span>Loading view…</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Splash() {
|
|
return (
|
|
<Centered>
|
|
<PiggyMark className="h-12 w-12 animate-pulse text-fg" title="Loading pig" />
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
function Centered({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex min-h-dvh items-center justify-center bg-bg px-6">
|
|
<div className="w-full max-w-md">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Placeholder({ title }: { title: string }) {
|
|
usePageTitle(title);
|
|
return (
|
|
<EmptyState
|
|
title={title}
|
|
description="That page does not exist or may have moved. Use Search to return to a workspace."
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 `<h1>` 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.
|
|
*/
|
|
<div className="flex flex-col gap-6">
|
|
<PageHeader
|
|
title="Team"
|
|
description="See who can operate each side of the compute business and where ownership is thin."
|
|
actions={
|
|
<Link
|
|
to="/settings"
|
|
className="tap inline-flex items-center rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline"
|
|
>
|
|
Manage access in Settings
|
|
</Link>
|
|
}
|
|
/>
|
|
|
|
{/* `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. */}
|
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
|
|
<Stat label="People" value={data?.length ?? 0} />
|
|
<Stat label="Teams" value={representedTeams} />
|
|
<Stat label="Assignments" value={assignments} />
|
|
</div>
|
|
|
|
{error ? (
|
|
<Card>
|
|
{/* 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. */}
|
|
<EmptyState
|
|
title="Team unavailable"
|
|
description={error instanceof Error ? error.message : 'Could not load team access.'}
|
|
action={
|
|
<Button type="button" variant="outline" onClick={() => void refetch()}>
|
|
Try again
|
|
</Button>
|
|
}
|
|
/>
|
|
</Card>
|
|
) : null}
|
|
|
|
{isLoading ? (
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{[0, 1, 2].map((key) => <Skeleton key={key} className="h-40 rounded-2xl" />)}
|
|
</div>
|
|
) : null}
|
|
|
|
{!isLoading && !error && data?.length === 0 ? (
|
|
<Card>
|
|
<EmptyState title="No team members yet" description="Invite and assign the first operator from Settings." />
|
|
</Card>
|
|
) : null}
|
|
|
|
{!isLoading && !error && data?.length ? (
|
|
<Section
|
|
title="People and permissions"
|
|
description="Roles are enforced server-side."
|
|
>
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{data.map((person) => {
|
|
const initials = person.name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase())
|
|
.join('');
|
|
return (
|
|
<Card key={person.id} className="p-4 sm:p-5">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Avatar className="size-11 border border-border">
|
|
<AvatarFallback className="bg-accent-subtle text-sm font-semibold text-accent-fg">
|
|
{initials || 'P'}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-semibold">{person.name}</p>
|
|
<p className="truncate text-sm text-muted">{person.title || 'Team member'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-1.5">
|
|
{person.teams.map((membership) => (
|
|
<Badge key={`${membership.team}:${membership.role}`} tone="accent">
|
|
{membership.team} · {membership.role}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{person.teams.length === 0 ? (
|
|
<p className="mt-4 text-sm text-warning">No operational team assigned</p>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</Section>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|