Files
pig/apps/web/src/App.tsx
T
karti e12d27edd1
CI / verify (push) Successful in 3m32s
Polish every product workflow across desktop and mobile
Reframe each screen around the decisions compute brokers make: sellable capacity, full-cost margin, pipeline movement, contract deadlines, evidence review, staged imports, and controlled agent access. Group the shell by operating domain, strengthen mobile navigation and sheets, add responsive record treatments, and make loading, error, empty, readiness, and retry states explicit.

The visual audit exposed sortable table targets and an unnamed file input only after exercising the rendered app, so this commit also pins those accessibility decisions at their actual interaction boundaries. Manrope is self-hosted as a single Latin variable subset to keep the stronger hierarchy without shipping unused font payloads.
2026-08-13 05:34:23 -07:00

354 lines
13 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, 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 { 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 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 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(() => {});
}}
>
<BrowserRouter>
<AuthGate config={config} />
</BrowserRouter>
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
<Toaster />
</ThemeProvider>
</QueryClientProvider>
);
}
/**
* 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 } = useQuery({
queryKey: ['me'],
queryFn: () => get<{ id: string; name: string }>('/api/me'),
});
// Adopt the server's stored appearance preferences once we know who this is.
useEffect(() => {
if (!data) return;
void get<{ themeMode?: string; accentColor?: string }>('/api/me/profile')
.then((profile) => {
const adopt = (window as unknown as { __pigAdoptTheme?: (p: unknown) => void })
.__pigAdoptTheme;
if (profile && adopt) adopt(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) {
return showRegister ? (
<Register
config={config}
onBack={() => setShowRegister(false)}
onRegistered={() => {
setShowRegister(false);
void refetch();
}}
/>
) : (
<SignIn config={config} onCreateAccount={() => 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 <CreateProfile config={config} onCreated={() => void refetch()} />;
}
}
if (error || !data) {
return (
<Centered>
<EmptyState
title="Something went wrong"
description={error instanceof Error ? error.message : 'Unknown error.'}
/>
</Centered>
);
}
return (
<Routes>
<Route element={<Shell />}>
<Route index element={<RoutePage><Overview /></RoutePage>} />
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
<Route path="growth" element={<RoutePage><Growth /></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>} />
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
<Route path="piggy" element={<RoutePage><Piggy /></RoutePage>} />
<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>
);
}
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 } = 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 (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
See who can operate each side of the compute business and where ownership is thin.
</p>
</div>
<Link
to="/settings"
className="tap inline-flex items-center self-start rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline sm:self-auto"
>
Manage access in Settings
</Link>
</header>
<div className="grid grid-cols-3 gap-2 sm:max-w-xl sm:gap-3">
{[
['People', data?.length ?? 0],
['Teams', representedTeams],
['Assignments', assignments],
].map(([label, value]) => (
<Card key={label} className="p-3 sm:p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted sm:text-xs">{label}</p>
<p className="nums mt-1 text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
{error ? (
<Card>
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
</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 aria-labelledby="team-members-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="team-members-heading" className="text-sm font-semibold">People and permissions</h2>
<span className="text-xs text-muted">Roles are enforced server-side</span>
</div>
<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>
);
}