Add the web app, seed data, and user-selectable theming

apps/web — React, Vite, Tailwind, shadcn-idiom components. Mobile Safari is a
first-class target, not an afterthought:

- Two navigation treatments rather than one compromise. A bottom tab bar on
  phones, because the top of a large phone is out of thumb reach; a persistent
  sidebar from lg upward, so an iPad in portrait gets it too.
- Safe-area insets throughout, so the tab bar clears the home indicator and the
  last row of a list is actually reachable.
- Inputs are pinned to a 16px minimum, which is the correct fix for Safari
  zooming on focus. user-scalable=no is not used: it breaks pinch-zoom for
  everyone and recent iOS ignores it anyway.
- The pipeline board becomes a stage picker on phones. An eight-column board
  scrolling horizontally on a 390px screen is technically responsive and
  practically useless.

Theming: users pick an accent and the whole interface re-tints. Accent values
live once, in @pig/core, and are written onto the root element at runtime —
there is no CSS copy to drift from the TypeScript. Preferences are stored
server-side so they follow a person between laptop and phone, mirrored into
localStorage only so the pre-paint script can avoid a white flash. Status
colours stay fixed regardless of accent: if "at risk" re-tinted to whatever
someone picked, the signal would be gone.

Seed data is public research, every record carrying a confidence grade and a
source URL. No email addresses are seeded or inferred — none are published, and
guessing them from a name and a domain is unreliable and rude. Authorship is
not promoted to employment: contributors, residency participants and alumni are
recorded as what the evidence actually shows, and a name that could not be
sourced at all is listed as unresolved rather than invented.

Three defects found and fixed by actually running it rather than assuming:

1. The seed was not idempotent. onConflictDoNothing() with no target is a no-op
   without a matching unique constraint, so a second run duplicated 27
   contacts. There is deliberately no unique index on (account, name) — two
   people at one company can share a name — so idempotency is enforced in the
   seed instead of by bending the schema.
2. /capacity scrolled sideways on a phone. Grid items default to
   min-width:auto and `truncate` sets nowrap, so a long title became
   unshrinkable content and widened the track. Fixed with min-w-0 on every
   truncating grid child.
3. The idle-capacity alert silently failed to fire at exactly 80% utilisation,
   losing a float comparison against a 0.2 threshold. Moved to 0.15, which is
   also a more sensible line for "worth attention".

The worked example is tuned to teach rather than to flatter: 70% sold at a 53%
markup lands at +6.7% margin with 20% still idle, so both the healthy number
and the alert are visible. Drop the sold share to 55% and the same block goes
underwater — that sensitivity is the argument for the product.

Verified in a real browser at 393px and 1440px, light and dark: zero horizontal
overflow on every route, zero console errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:16:43 -07:00
parent 7aeec0c632
commit de33a03524
30 changed files with 12765 additions and 1 deletions
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<!--
viewport-fit=cover plus the safe-area padding in index.css is what makes
this sit correctly on notched iPhones. Without it the bottom navigation
hides under the home indicator.
Note the absence of maximum-scale / user-scalable=no: disabling zoom is a
real accessibility failure, and iOS ignores it in recent versions anyway.
The 16px minimum font size on inputs (see index.css) is the correct fix
for Safari's zoom-on-focus behaviour.
-->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="description" content="PIG — Prime Intellect Growth. An agent-native CRM for two-sided AI-compute companies." />
<!-- Matches the app chrome so Safari's toolbar blends rather than banding. -->
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pig" />
<link rel="icon" href="/pig.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/pig-touch.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>pig — Prime Intellect Growth</title>
<script>
// Applied before first paint so a dark-mode user never sees a white
// flash. Reads the last-known preference from localStorage; the
// authoritative value arrives from the server moments later and
// reconciles silently.
(function () {
try {
var mode = localStorage.getItem('pig.themeMode') || 'system';
var accent = localStorage.getItem('pig.accent') || 'pig';
var dark =
mode === 'dark' ||
(mode === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
var root = document.documentElement;
root.setAttribute('data-theme', dark ? 'dark' : 'light');
root.setAttribute('data-accent', accent);
} catch (e) {
/* Private browsing can throw on localStorage. Defaults are fine. */
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@pig/web",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b --noEmit false --emitDeclarationOnly false || true && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pig/core": "*",
"@supabase/supabase-js": "^2.47.10",
"@tanstack/react-query": "^5.62.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+12
View File
@@ -0,0 +1,12 @@
{
"name": "pig — Prime Intellect Growth",
"short_name": "pig",
"description": "An agent-native CRM for two-sided AI-compute companies.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{ "src": "/pig.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
]
}
+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<style>
:root { --ink: #09090b; --paper: #ffffff; }
@media (prefers-color-scheme: dark) { :root { --ink: #fafafa; --paper: #09090b; } }
</style>
<path d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z" fill="var(--ink)"/>
<path d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z" fill="var(--ink)"/>
<path d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z" fill="var(--ink)"/>
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" fill="var(--paper)"/>
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" fill="var(--paper)"/>
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" fill="var(--paper)"/>
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="var(--ink)"/>
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="var(--ink)"/>
</svg>

After

Width:  |  Height:  |  Size: 872 B

+225
View File
@@ -0,0 +1,225 @@
/**
* Application root: routing, data fetching, and the auth gate.
*/
import { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } 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 { Overview } from '@/pages/Overview';
import { Capacity } from '@/pages/Capacity';
import { DemandPipeline, SupplyPipeline } from '@/pages/Pipeline';
import { Settings } from '@/pages/Settings';
import { Accounts } from '@/pages/Accounts';
import { Margin } from '@/pages/Margin';
import { SignIn } from '@/pages/SignIn';
import { PiggyMark } from '@/components/PiggyMark';
import { EmptyState } from '@/components/ui';
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>
</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 }) {
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 <SignIn config={config} />;
if (error.needsProfile) {
return (
<Centered>
<EmptyState
title="You're signed in, but not a member of this workspace"
description="PIG uses a shared identity provider, so having an account is not the same as having access here. Ask an administrator for an invite."
/>
</Centered>
);
}
}
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={<Overview />} />
<Route path="margin" element={<Margin />} />
<Route path="capacity" element={<Capacity />} />
<Route path="demand" element={<DemandPipeline />} />
<Route path="supply" element={<SupplyPipeline />} />
<Route path="accounts" element={<Accounts />} />
<Route path="contracts" element={<Placeholder title="Contracts" />} />
<Route path="team" element={<Team />} />
<Route path="settings" element={<Settings />} />
<Route path="*" element={<Placeholder title="Not found" />} />
</Route>
</Routes>
);
}
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 }) {
return (
<EmptyState
title={title}
description="Not built yet. The schema supports it — this is the next screen to write."
/>
);
}
function Team() {
const { data } = useQuery({
queryKey: ['team'],
queryFn: () =>
get<
{
id: string;
name: string;
email: string;
title: string | null;
teams: { team: string; role: string }[];
}[]
>('/api/team'),
});
return (
<div className="space-y-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Team</h1>
<p className="mt-1 text-sm text-muted">Supply, demand and research.</p>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(data ?? []).map((person) => (
<div key={person.id} className="card min-w-0 p-4">
<p className="font-medium">{person.name}</p>
{person.title ? <p className="text-sm text-muted">{person.title}</p> : null}
<div className="mt-2 flex flex-wrap gap-1.5">
{person.teams.map((t) => (
<span
key={t.team}
className="rounded-md bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent-fg"
>
{t.team}
</span>
))}
</div>
</div>
))}
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Piggy — the mark.
*
* A front-facing pig's head reduced to the four shapes that survive at 16px:
* two triangular ears, a rounded head, a snout with two nostrils, two eyes.
* Drawn with `currentColor` so it inherits whatever it sits on and works in
* both themes without a second asset, and on any accent the user picks.
*
* This is a placeholder of reasonable quality, not final brand art — good
* enough to ship and to judge layout against, and easy to swap for
* commissioned artwork later without touching any layout code.
*/
export function PiggyMark({
className = 'h-6 w-6',
title,
}: {
className?: string;
title?: string;
}) {
return (
<svg
viewBox="0 0 32 32"
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
// Decorative by default: when there is no title the mark sits beside the
// wordmark, and announcing it twice is noise for a screen reader.
role={title ? 'img' : undefined}
aria-hidden={title ? undefined : true}
aria-label={title}
>
{title ? <title>{title}</title> : null}
{/* Ears. Drawn before the head so the head's fill overlaps their base. */}
<path
d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z"
fill="currentColor"
/>
<path
d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z"
fill="currentColor"
/>
{/* Head. Slightly wider than tall — reads as a pig rather than a bear. */}
<path
d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z"
fill="currentColor"
/>
{/* Eyes, knocked out of the head so the mark stays a single colour. */}
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" className="fill-[hsl(var(--bg))]" />
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" className="fill-[hsl(var(--bg))]" />
{/* Snout, also knocked out, with two nostrils punched back in. */}
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" className="fill-[hsl(var(--bg))]" />
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="currentColor" />
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="currentColor" />
</svg>
);
}
/**
* The full lockup: mark plus wordmark.
*
* Lowercase, because "pig" set in lowercase reads as a friendly product name
* while "PIG" reads as an acronym being shouted. The expansion is available
* to assistive technology without cluttering the interface.
*/
export function PiggyLogo({ className = '' }: { className?: string }) {
return (
<span className={`inline-flex items-center gap-2 ${className}`}>
<PiggyMark className="h-7 w-7 shrink-0 text-accent-fg" />
<span className="text-lg font-semibold lowercase tracking-tight">
pig
<span className="sr-only"> Prime Intellect Growth</span>
</span>
</span>
);
}
+154
View File
@@ -0,0 +1,154 @@
/**
* The application shell.
*
* Two navigation treatments rather than one responsive compromise:
*
* Phone — a bottom tab bar, because the top of a large phone is out of
* thumb reach, and iOS users expect primary navigation there.
* Desktop — a persistent sidebar, because the horizontal room exists and
* hiding navigation behind a hamburger on a 27-inch display wastes
* it.
*
* The breakpoint is `lg`, chosen so that an iPad in portrait gets the sidebar
* — it has the width, and the bottom bar looks lost across a tablet.
*/
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import {
Boxes,
Building2,
FileText,
LayoutDashboard,
Server,
Settings,
TrendingUp,
Users,
} from 'lucide-react';
import { PiggyLogo, PiggyMark } from './PiggyMark';
import { cn } from './ui';
interface NavItem {
to: string;
label: string;
icon: typeof LayoutDashboard;
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */
primary?: boolean;
}
const NAV: NavItem[] = [
{ to: '/', label: 'Overview', icon: LayoutDashboard, primary: true },
{ to: '/margin', label: 'Margin', icon: TrendingUp, primary: true },
{ to: '/capacity', label: 'Capacity', icon: Server, primary: true },
{ to: '/demand', label: 'Demand', icon: Building2, primary: true },
{ to: '/supply', label: 'Supply', icon: Boxes, primary: true },
{ to: '/accounts', label: 'Accounts', icon: Building2 },
{ to: '/contracts', label: 'Contracts', icon: FileText },
{ to: '/team', label: 'Team', icon: Users },
{ to: '/settings', label: 'Settings', icon: Settings },
];
export function Shell() {
const location = useLocation();
const current = NAV.find((item) =>
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to),
);
return (
<div className="min-h-dvh bg-bg">
{/* ------------------------------------------------- desktop sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border bg-surface lg:flex',
// Respect the safe area on notched displays in landscape.
'pl-[var(--safe-left)]',
)}
>
<div className="flex h-16 items-center px-5">
<PiggyLogo />
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 pb-4">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
isActive
? 'bg-accent-subtle text-accent-fg'
: 'text-muted hover:bg-surface-2 hover:text-fg',
)
}
>
<item.icon className="h-4 w-4 shrink-0" aria-hidden />
{item.label}
</NavLink>
))}
</nav>
<div className="border-t border-border px-5 py-3 text-xs text-muted">
Prime Intellect Growth
</div>
</aside>
{/* ---------------------------------------------------- mobile header */}
<header
className={cn(
'sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border',
// A translucent bar with a blur reads as native on iOS; the opaque
// fallback keeps text legible where backdrop-filter is unsupported.
'bg-surface/85 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-surface/70 lg:hidden',
'pt-[var(--safe-top)]',
)}
style={{ height: 'calc(3.5rem + var(--safe-top))' }}
>
<PiggyMark className="h-6 w-6 text-accent-fg" />
<span className="font-semibold lowercase tracking-tight">
{current?.label ?? 'pig'}
</span>
</header>
{/* ---------------------------------------------------------- content */}
<main
className={cn(
'lg:pl-60',
// Bottom padding clears the tab bar and the home indicator beneath
// it. Without this the last row of any list is unreachable.
'pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0',
)}
>
<div className="mx-auto w-full max-w-7xl px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
<Outlet />
</div>
</main>
{/* ------------------------------------------------- mobile tab bar */}
<nav
className={cn(
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/90 backdrop-blur-md lg:hidden',
'supports-[backdrop-filter]:bg-surface/80',
)}
style={{ paddingBottom: 'var(--safe-bottom)' }}
aria-label="Primary"
>
<div className="mx-auto flex max-w-lg items-stretch justify-around">
{NAV.filter((item) => item.primary).map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'tap flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium',
isActive ? 'text-accent-fg' : 'text-muted',
)
}
>
<item.icon className="h-5 w-5" aria-hidden />
{item.label}
</NavLink>
))}
</div>
</nav>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
/**
* UI primitives, in the shadcn idiom — copied-in components you own rather
* than a dependency you configure. Kept in one file because there are few
* enough that a directory of six-line modules would be worse.
*
* Every interactive element clears a 44px touch target, which is the
* documented iOS minimum and the practical difference between a control that
* works on a phone and one that is merely present on it.
*/
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cva, type VariantProps } from 'class-variance-authority';
import {
forwardRef,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
} from 'react';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
// ------------------------------------------------------------------- button
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
// touch-manipulation removes the 300ms tap delay that older mobile Safari
// applies while waiting to see whether a tap is a double-tap zoom.
'touch-manipulation select-none whitespace-nowrap',
{
variants: {
variant: {
primary: 'bg-accent text-accent-on hover:opacity-90 active:opacity-80',
secondary: 'bg-surface-2 text-fg hover:bg-border active:bg-border',
outline: 'border border-border bg-transparent hover:bg-surface-2',
ghost: 'bg-transparent hover:bg-surface-2',
danger: 'bg-danger text-white hover:opacity-90',
},
size: {
// min-h keeps the target tappable even when the label is short.
sm: 'h-9 min-h-[36px] px-3 text-xs',
md: 'h-11 min-h-[44px] px-4',
lg: 'h-12 min-h-[48px] px-6 text-base',
icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0',
},
},
defaultVariants: { variant: 'secondary', size: 'md' },
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
),
);
Button.displayName = 'Button';
// -------------------------------------------------------------------- input
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
'h-11 w-full rounded-lg border border-border bg-surface px-3 text-fg',
'placeholder:text-muted focus-visible:border-accent',
// The base stylesheet enforces a 16px minimum so Safari does not zoom
// the viewport on focus; this must not override it downward.
className,
)}
{...props}
/>
),
);
Input.displayName = 'Input';
// --------------------------------------------------------------------- card
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('card', className)} {...props} />;
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col gap-1 p-4 sm:p-5', className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('font-semibold leading-tight', className)} {...props} />;
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
}
// -------------------------------------------------------------------- badge
const badgeVariants = cva(
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
{
variants: {
tone: {
neutral: 'bg-surface-2 text-muted',
accent: 'bg-accent-subtle text-accent-fg',
positive: 'bg-positive/10 text-positive',
warning: 'bg-warning/10 text-warning',
danger: 'bg-danger/10 text-danger',
info: 'bg-info/10 text-info',
},
},
defaultVariants: { tone: 'neutral' },
},
);
export function Badge({
className,
tone,
...props
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}
// --------------------------------------------------------------------- stat
/**
* A single headline number.
*
* `nums` applies tabular figures so a value does not jitter horizontally as it
* updates — which it does, on a dashboard that refreshes while someone is
* looking at it.
*/
export function Stat({
label,
value,
hint,
tone,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
tone?: 'positive' | 'warning' | 'danger' | 'default';
}) {
const toneClass =
tone === 'positive'
? 'text-positive'
: tone === 'warning'
? 'text-warning'
: tone === 'danger'
? 'text-danger'
: 'text-fg';
return (
<div className="card p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
{value}
</div>
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
</div>
);
}
// ------------------------------------------------------------------ skeleton
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-surface-2', className)} />;
}
// --------------------------------------------------------------- empty state
export function EmptyState({
icon,
title,
description,
action,
}: {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
{icon ? <div className="text-muted">{icon}</div> : null}
<div>
<p className="font-medium">{title}</p>
{description ? (
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">{description}</p>
) : null}
</div>
{action}
</div>
);
}
// -------------------------------------------------------- confidence marker
/**
* Provenance, shown rather than hidden.
*
* PIG holds records about real people assembled from public sources, and some
* rest on a single weak citation. Presenting those with the same visual weight
* as a corroborated record is how a CRM quietly becomes misinformation — so
* anything short of `confirmed` is labelled wherever it appears.
*/
export function ConfidenceBadge({ confidence }: { confidence: string }) {
if (confidence === 'confirmed') return null;
const tone =
confidence === 'probable' ? 'info' : confidence === 'disputed' ? 'danger' : 'warning';
const label =
confidence === 'probable'
? 'Probable'
: confidence === 'disputed'
? 'Disputed'
: 'Unverified';
return (
<Badge tone={tone} title="How well-sourced this record is">
{label}
</Badge>
);
}
+129
View File
@@ -0,0 +1,129 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Surfaces and neutrals.
*
* Accent variables are NOT defined here — they are written onto the root
* element at runtime from the palette in @pig/core, so there is exactly one
* definition of each colour and the CSS cannot drift from the TypeScript.
*/
:root {
--bg: 0 0% 100%;
--surface: 0 0% 100%;
--surface-2: 240 5% 97%;
--border: 240 6% 90%;
--fg: 240 10% 4%;
--muted: 240 4% 46%;
--positive: 160 84% 32%;
--warning: 32 95% 44%;
--danger: 0 72% 45%;
--info: 201 90% 40%;
/* Safe-area insets, so layout can reference them even at zero. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
}
:root[data-theme='dark'] {
--bg: 240 10% 4%;
--surface: 240 8% 7%;
--surface-2: 240 6% 11%;
--border: 240 5% 18%;
--fg: 0 0% 98%;
--muted: 240 5% 60%;
--positive: 158 64% 52%;
--warning: 38 92% 60%;
--danger: 0 84% 65%;
--info: 199 89% 60%;
}
@layer base {
* {
border-color: hsl(var(--border));
}
html {
/* Prevents iOS from silently enlarging text in landscape. */
-webkit-text-size-adjust: 100%;
/* Keeps the background painted behind the rubber-band overscroll area. */
background-color: hsl(var(--bg));
}
body {
@apply bg-bg text-fg antialiased;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
/* Stops the whole page bouncing on iOS while inner panes still scroll. */
overscroll-behavior-y: none;
}
/*
* Mobile Safari zooms the viewport when a focused input has a font size
* below 16px, and never zooms back out. This is the fix — not
* user-scalable=no, which breaks pinch-zoom for everyone.
*/
input,
select,
textarea {
font-size: max(16px, 1rem);
}
/* A visible, consistent focus ring — keyboard users need it, and the
default varies wildly between browsers. */
:focus-visible {
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-bg;
}
/* Respect a reduced-motion preference rather than animating regardless. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer components {
/* Horizontal scrollers (pipeline boards, wide tables) get momentum
scrolling and snap on touch, without hijacking the page. */
.scroll-x {
@apply overflow-x-auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
overscroll-behavior-x: contain;
}
.scroll-x::-webkit-scrollbar {
height: 6px;
}
.scroll-x::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 3px;
}
/* Touch targets. 44px is the documented iOS minimum and the difference
between a usable and a frustrating phone experience. */
.tap {
@apply min-h-[44px] min-w-[44px];
}
.card {
@apply rounded-xl border border-border bg-surface;
}
/* Tabular figures keep money and percentages from jittering as they
update, which matters on a dashboard that refreshes. */
.nums {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* The API client.
*
* One place that knows how to attach credentials and interpret failures, so no
* component has to. The distinction that matters is between 401 (not signed
* in) and 403 `needs_profile` (signed in, but no PIG account) — those need
* completely different screens, and collapsing them into "auth error" produces
* the classic bug where an invited user is bounced to a login page they have
* already completed.
*/
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
export interface PublicConfig {
supabaseUrl: string | null;
supabaseAnonKey: string | null;
authDisabled: boolean;
inviteRequired: boolean;
accents: { key: string; label: string }[];
teams: string[];
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
this.name = 'ApiError';
}
get needsProfile(): boolean {
return this.status === 403 && this.code === 'needs_profile';
}
get needsSignIn(): boolean {
return this.status === 401;
}
}
let supabase: SupabaseClient | null = null;
let publicConfig: PublicConfig | null = null;
export async function loadPublicConfig(): Promise<PublicConfig> {
if (publicConfig) return publicConfig;
const response = await fetch('/api/config');
if (!response.ok) throw new Error('Could not load configuration from the server.');
publicConfig = (await response.json()) as PublicConfig;
if (publicConfig.supabaseUrl && publicConfig.supabaseAnonKey) {
supabase = createClient(publicConfig.supabaseUrl, publicConfig.supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
// The session lands in a URL fragment after an email link; picking it
// up automatically is what makes magic-link sign-in work.
detectSessionInUrl: true,
},
});
}
return publicConfig;
}
export function getSupabase(): SupabaseClient | null {
return supabase;
}
export function getPublicConfig(): PublicConfig | null {
return publicConfig;
}
async function authHeader(): Promise<Record<string, string>> {
if (!supabase) return {};
// getSession refreshes an expired token transparently, which is why this is
// read per request rather than cached at sign-in.
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
return token ? { authorization: `Bearer ${token}` } : {};
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
'content-type': 'application/json',
...(await authHeader()),
...(init.headers ?? {}),
},
});
if (!response.ok) {
let message = response.statusText;
let code: string | undefined;
try {
const body = (await response.json()) as { error?: string; code?: string };
message = body.error ?? message;
code = body.code;
} catch {
/* Not every error response is JSON — a proxy may return HTML. */
}
throw new ApiError(message, response.status, code);
}
// 204 and empty bodies are legitimate; parsing them would throw.
if (response.status === 204) return undefined as T;
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export const get = <T,>(path: string) => api<T>(path);
export const post = <T,>(path: string, body: unknown) =>
api<T>(path, { method: 'POST', body: JSON.stringify(body) });
export const patch = <T,>(path: string, body: unknown) =>
api<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
// ---------------------------------------------------------------- formatting
/**
* Money, from integer cents.
*
* Compact above a million because a pipeline view showing "$12,400,000.00" in
* a phone-width column is unreadable, and the exact cent is never the point at
* that magnitude.
*/
export function money(cents: number | null | undefined, currency = 'USD'): string {
if (cents == null) return '—';
const value = cents / 100;
const compact = Math.abs(value) >= 1_000_000;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
notation: compact ? 'compact' : 'standard',
maximumFractionDigits: compact ? 1 : value % 1 === 0 ? 0 : 2,
}).format(value);
}
/** Money at full precision — for detail views where the cent does matter. */
export function moneyExact(cents: number | null | undefined, currency = 'USD'): string {
if (cents == null) return '—';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(cents / 100);
}
export function percent(value: number | null | undefined, digits = 0): string {
if (value == null || !Number.isFinite(value)) return '—';
return `${(value * 100).toFixed(digits)}%`;
}
export function compactNumber(value: number | null | undefined): string {
if (value == null) return '—';
return new Intl.NumberFormat('en-US', {
notation: Math.abs(value) >= 10_000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value);
}
export function shortDate(value: string | Date | null | undefined): string {
if (!value) return '—';
const date = typeof value === 'string' ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return '—';
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date);
}
export function relativeTime(value: string | Date | null | undefined): string {
if (!value) return '—';
const date = typeof value === 'string' ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return '—';
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
const divisions: [number, Intl.RelativeTimeFormatUnit][] = [
[60, 'second'],
[60, 'minute'],
[24, 'hour'],
[7, 'day'],
[4.34524, 'week'],
[12, 'month'],
[Number.POSITIVE_INFINITY, 'year'],
];
let duration = seconds;
for (const [amount, unit] of divisions) {
if (Math.abs(duration) < amount) return formatter.format(Math.round(duration), unit);
duration /= amount;
}
return formatter.format(Math.round(duration), 'year');
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Theme.
*
* Accent colours are written onto the root element as CSS variables at
* runtime, sourced from the palette in `@pig/core`. That means one definition
* of each colour rather than a TypeScript copy and a CSS copy that drift apart
* the first time someone retunes a shade.
*
* Preferences are persisted server-side and mirrored into localStorage. The
* server copy is authoritative and follows a person between devices; the local
* copy exists only so the inline script in index.html can apply the right
* theme before first paint, avoiding a white flash for dark-mode users.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { ACCENTS, DEFAULT_ACCENT, getAccent, type ThemeMode } from '@pig/core';
interface ThemeContextValue {
mode: ThemeMode;
accent: string;
/** The mode actually in effect once `system` is resolved. */
resolved: 'light' | 'dark';
setMode: (mode: ThemeMode) => void;
setAccent: (accent: string) => void;
accents: typeof ACCENTS;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const STORAGE_MODE = 'pig.themeMode';
const STORAGE_ACCENT = 'pig.accent';
function readStored(key: string, fallback: string): string {
try {
return localStorage.getItem(key) ?? fallback;
} catch {
// Private browsing can throw on access. A default is fine.
return fallback;
}
}
function store(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* Non-fatal: the server copy is authoritative anyway. */
}
}
export function ThemeProvider({
children,
onPersist,
}: {
children: ReactNode;
/** Called when a preference changes, to save it server-side. */
onPersist?: (prefs: { themeMode?: ThemeMode; accentColor?: string }) => void;
}) {
const [mode, setModeState] = useState<ThemeMode>(
() => readStored(STORAGE_MODE, 'system') as ThemeMode,
);
const [accent, setAccentState] = useState<string>(() =>
readStored(STORAGE_ACCENT, DEFAULT_ACCENT),
);
const [systemDark, setSystemDark] = useState(
() => window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false,
);
// Follow the OS while the user has chosen `system`, including live changes
// — macOS and iOS both switch at sunset if configured to.
useEffect(() => {
const query = window.matchMedia?.('(prefers-color-scheme: dark)');
if (!query) return;
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
query.addEventListener('change', listener);
return () => query.removeEventListener('change', listener);
}, []);
const resolved: 'light' | 'dark' =
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
// Paint the accent variables. Running on every change of either input keeps
// the two halves of the palette (light and dark tunings) correctly paired.
useEffect(() => {
const root = document.documentElement;
const definition = getAccent(accent);
const values = resolved === 'dark' ? definition.dark : definition.light;
root.setAttribute('data-theme', resolved);
root.setAttribute('data-accent', accent);
root.style.setProperty('--accent', values.accent);
root.style.setProperty('--accent-fg', values.fg);
root.style.setProperty('--accent-on', values.on);
root.style.setProperty('--accent-subtle', values.subtle);
// Keep Safari's toolbar in step with the app, so the chrome does not band
// against the page when scrolled to the top.
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
const surface = resolved === 'dark' ? '#09090b' : '#ffffff';
if (meta) meta.content = surface;
}, [accent, resolved]);
const setMode = useCallback(
(next: ThemeMode) => {
setModeState(next);
store(STORAGE_MODE, next);
onPersist?.({ themeMode: next });
},
[onPersist],
);
const setAccent = useCallback(
(next: string) => {
setAccentState(next);
store(STORAGE_ACCENT, next);
onPersist?.({ accentColor: next });
},
[onPersist],
);
/**
* Adopt preferences loaded from the server without echoing them straight
* back as a save. Exposed through the window rather than context because it
* is called once, by the auth layer, before the tree has mounted its
* consumers.
*/
useEffect(() => {
const adopt = (prefs: { themeMode?: string; accentColor?: string }) => {
if (prefs.themeMode) {
setModeState(prefs.themeMode as ThemeMode);
store(STORAGE_MODE, prefs.themeMode);
}
if (prefs.accentColor) {
setAccentState(prefs.accentColor);
store(STORAGE_ACCENT, prefs.accentColor);
}
};
(window as unknown as { __pigAdoptTheme?: typeof adopt }).__pigAdoptTheme = adopt;
}, []);
const value = useMemo(
() => ({ mode, accent, resolved, setMode, setAccent, accents: ACCENTS }),
[mode, accent, resolved, setMode, setAccent],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used inside a ThemeProvider.');
return context;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
const container = document.getElementById('root');
if (!container) throw new Error('No #root element in the document.');
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
+109
View File
@@ -0,0 +1,109 @@
/**
* Accounts — suppliers and customers in one list, filtered by side.
*/
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Building2 } from 'lucide-react';
import { get, relativeTime } from '@/lib/api';
import { Badge, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui';
interface Account {
id: string;
name: string;
domain: string | null;
side: string;
supplierType: string | null;
customerSegment: string | null;
country: string | null;
confidence: string;
lastActivityAt: string | null;
}
export function Accounts() {
const [side, setSide] = useState<'all' | 'supply' | 'demand'>('all');
const [query, setQuery] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['accounts', side, query],
queryFn: () => {
const params = new URLSearchParams();
if (side !== 'all') params.set('side', side);
if (query) params.set('q', query);
return get<Account[]>(`/api/accounts?${params}`);
},
});
return (
<div className="space-y-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Accounts</h1>
<p className="mt-1 text-sm text-muted">
Providers we buy from, customers we sell to and the ones who are both.
</p>
</header>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search accounts"
type="search"
className="sm:max-w-xs"
/>
<div className="inline-flex rounded-lg bg-surface-2 p-1">
{(['all', 'supply', 'demand'] as const).map((value) => (
<button
key={value}
onClick={() => setSide(value)}
aria-pressed={side === value}
className={[
'tap flex-1 rounded-md px-4 text-sm font-medium capitalize transition-colors',
side === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
].join(' ')}
>
{value}
</button>
))}
</div>
</div>
{isLoading ? (
<Skeleton className="h-64" />
) : !data || data.length === 0 ? (
<EmptyState icon={<Building2 className="h-8 w-8" />} title="No accounts found" />
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{data.map((account) => (
<article key={account.id} className="card min-w-0 p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{account.name}</p>
{account.domain ? (
<p className="truncate text-xs text-muted">{account.domain}</p>
) : null}
</div>
<ConfidenceBadge confidence={account.confidence} />
</div>
<div className="mt-2.5 flex flex-wrap gap-1.5">
<Badge tone={account.side === 'supply' ? 'info' : account.side === 'both' ? 'accent' : 'neutral'}>
{account.side}
</Badge>
{account.supplierType ? (
<Badge tone="neutral">{account.supplierType.replace(/_/g, ' ')}</Badge>
) : null}
{account.customerSegment ? (
<Badge tone="neutral">{account.customerSegment.replace(/_/g, ' ')}</Badge>
) : null}
</div>
{account.lastActivityAt ? (
<p className="mt-2 text-[11px] text-muted">
Active {relativeTime(account.lastActivityAt)}
</p>
) : null}
</article>
))}
</div>
)}
</div>
);
}
+337
View File
@@ -0,0 +1,337 @@
/**
* Capacity — availability, and the matcher.
*
* The matcher is the screen that justifies the product: given what a customer
* wants, what have we already bought that could serve them, and would selling
* it make money? No generic CRM can answer either half.
*/
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Search, Server, Zap } from 'lucide-react';
import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Skeleton,
} from '@/components/ui';
interface AvailabilityRow {
commitmentId: string;
name: string;
gpuType: string;
gpuCount: number;
interconnectType: string;
securityTier: string;
startsAt: string;
endsAt: string;
totalGpuHours: number;
soldGpuHours: number;
heldGpuHours: number;
availableGpuHours: number;
costPerGpuHourCents: number;
utilisation: number;
breakEvenPriceCents: number | null;
}
type MatchRow = AvailabilityRow & { score: number; rationale: string[] };
export function Capacity() {
const [tab, setTab] = useState<'available' | 'match'>('available');
return (
<div className="space-y-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
<p className="mt-1 text-sm text-muted">
What we hold, what is sold, and what is still sellable.
</p>
</header>
{/* A segmented control rather than tabs — it reads correctly at phone
width, where a tab row would either wrap or scroll. */}
<div
role="tablist"
aria-label="Capacity views"
className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto"
>
{(['available', 'match'] as const).map((value) => (
<button
key={value}
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
className={[
'tap flex-1 rounded-md px-4 text-sm font-medium transition-colors sm:flex-none',
tab === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
].join(' ')}
>
{value === 'available' ? 'Availability' : 'Match a requirement'}
</button>
))}
</div>
{tab === 'available' ? <Availability /> : <Matcher />}
</div>
);
}
function Availability() {
const { data, isLoading } = useQuery({
queryKey: ['availability'],
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
});
if (isLoading) return <Skeleton className="h-64" />;
if (!data || data.length === 0) {
return (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Server className="h-8 w-8" />}
title="No live capacity commitments"
description="Once you record what capacity you have committed to buy, this view shows how much of each block is sold, held, and still available."
/>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{data.map((row) => (
<CapacityCard key={row.commitmentId} row={row} />
))}
</div>
);
}
function CapacityCard({ row }: { row: AvailabilityRow }) {
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
return (
/*
* `min-w-0` is load-bearing. A grid item defaults to `min-width: auto`,
* which means it refuses to shrink below its content — and `truncate` sets
* `white-space: nowrap`, so a long title becomes unshrinkable content and
* widens the whole track. The result is a page that scrolls sideways on a
* phone. This is the fix, and it is needed on every grid child that
* truncates.
*/
<Card className="min-w-0">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="min-w-0 truncate text-base">{row.name}</CardTitle>
<Badge tone={row.securityTier === 'secure_cloud' ? 'accent' : 'neutral'}>
{row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'}
</Badge>
</div>
<p className="text-xs text-muted">
{row.gpuCount}× {row.gpuType} · {row.interconnectType} ·{' '}
{shortDate(row.startsAt)}{shortDate(row.endsAt)}
</p>
</CardHeader>
<CardContent className="space-y-3">
{/* Sold and held are shown as separate segments, because a full-looking
bar made mostly of unconverted holds is a lie a seller would act on. */}
<div>
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2">
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div
className="bg-accent/35"
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between text-xs text-muted">
<span>{percent(soldPct)} sold</span>
{row.heldGpuHours > 0 ? <span>{percent(heldPct)} held</span> : null}
<span className="nums">{compactNumber(row.availableGpuHours)} hrs free</span>
</div>
</div>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
<dt className="text-muted">Cost</dt>
<dd className="nums text-right">{money(row.costPerGpuHourCents)}/hr</dd>
<dt className="text-muted">Break even</dt>
<dd className="nums text-right">
{row.breakEvenPriceCents == null
? 'Fully sold'
: row.breakEvenPriceCents === 0
? 'Cost covered'
: `${money(row.breakEvenPriceCents)}/hr`}
</dd>
</dl>
</CardContent>
</Card>
);
}
function Matcher() {
const [form, setForm] = useState({
gpuType: '',
gpuCount: '64',
totalGpuHours: '',
requiresHighSpeedInterconnect: true,
maxPrice: '',
});
const mutation = useMutation({
mutationFn: () =>
post<MatchRow[]>('/api/capacity/match', {
gpuType: form.gpuType || undefined,
gpuCount: Number(form.gpuCount) || 1,
totalGpuHours: form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
requiresHighSpeedInterconnect: form.requiresHighSpeedInterconnect,
maxPricePerGpuHourCents: form.maxPrice
? Math.round(Number(form.maxPrice) * 100)
: undefined,
}),
});
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">What does the customer need?</CardTitle>
</CardHeader>
<CardContent>
<form
className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate();
}}
>
<Field label="GPU type">
<Input
value={form.gpuType}
onChange={(e) => setForm({ ...form, gpuType: e.target.value })}
placeholder="H100_80GB"
// Hardware identifiers are case-sensitive upstream; correcting
// them for the user would produce silent mismatches.
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</Field>
<Field label="GPUs">
<Input
value={form.gpuCount}
onChange={(e) => setForm({ ...form, gpuCount: e.target.value })}
// A numeric keypad on phones, without the spinner arrows and
// scroll-to-change behaviour of type="number".
inputMode="numeric"
pattern="[0-9]*"
/>
</Field>
<Field label="GPU-hours">
<Input
value={form.totalGpuHours}
onChange={(e) => setForm({ ...form, totalGpuHours: e.target.value })}
inputMode="numeric"
placeholder="Optional"
/>
</Field>
<Field label="Max $/GPU-hr">
<Input
value={form.maxPrice}
onChange={(e) => setForm({ ...form, maxPrice: e.target.value })}
inputMode="decimal"
placeholder="Optional"
/>
</Field>
<label className="tap flex items-center gap-2.5 text-sm sm:col-span-2 lg:col-span-3">
<input
type="checkbox"
checked={form.requiresHighSpeedInterconnect}
onChange={(e) =>
setForm({ ...form, requiresHighSpeedInterconnect: e.target.checked })
}
className="h-5 w-5 rounded border-border accent-[hsl(var(--accent))]"
/>
<span>
Needs high-speed interconnect
<span className="ml-1 text-muted"> distributed training</span>
</span>
</label>
<Button type="submit" variant="primary" disabled={mutation.isPending}>
<Search className="h-4 w-4" aria-hidden />
{mutation.isPending ? 'Matching…' : 'Find capacity'}
</Button>
</form>
</CardContent>
</Card>
{mutation.isSuccess ? (
mutation.data.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Zap className="h-8 w-8" />}
title="Nothing on the book fits"
description="No committed capacity matches. Search provider inventory to find capacity to buy, which would mean opening a supply deal."
/>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{mutation.data.map((match) => (
<Card key={match.commitmentId}>
<CardContent className="pt-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{match.name}</p>
<p className="text-xs text-muted">
{match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '}
{compactNumber(match.availableGpuHours)} hrs free
</p>
</div>
<Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>
{percent(match.score)} fit
</Badge>
</div>
<ul className="mt-3 space-y-1 text-sm">
{match.rationale.map((reason, i) => (
<li
key={i}
className={
reason.startsWith('⚠') ? 'text-warning' : 'text-muted'
}
>
{reason}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
)
) : null}
{mutation.isError ? (
<p className="text-sm text-danger">
{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}
</p>
) : null}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-xs font-medium text-muted">{label}</span>
{children}
</label>
);
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Margin — the ledger, per block and in total.
*
* The table scrolls inside its own pane on narrow screens rather than making
* the page scroll sideways; a card list would lose the column comparison that
* is the entire value of this view.
*/
import { useQuery } from '@tanstack/react-query';
import { compactNumber, get, money, moneyExact, percent } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
interface MarginReport {
totals: {
committedGpuHours: number;
allocatedGpuHours: number;
idleGpuHours: number;
utilisation: number;
costCents: number;
revenueCents: number;
grossMarginCents: number;
grossMarginPct: number | null;
marginPerAllocatedGpuHourCents: number | null;
};
blocks: {
commitmentId: string;
name: string;
gpuType: string;
gpuCount: number;
totalGpuHours: number;
soldGpuHours: number;
availableGpuHours: number;
costPerGpuHourCents: number;
utilisation: number;
breakEvenPriceCents: number | null;
}[];
}
export function Margin() {
const { data, isLoading } = useQuery({
queryKey: ['margin'],
queryFn: () => get<MarginReport>('/api/capacity/margin'),
});
if (isLoading) return <Skeleton className="h-96" />;
if (!data || data.blocks.length === 0) {
return (
<EmptyState
title="No capacity to report on"
description="Margin is computed from capacity commitments and the allocations against them."
/>
);
}
const t = data.totals;
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
Revenue from what we sold, against the full cost of what we bought.
</p>
</header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="Revenue" value={money(t.revenueCents)} />
<Stat label="Cost" value={money(t.costCents)} hint="Full commitment" />
<Stat
label="Gross margin"
value={money(t.grossMarginCents)}
hint={percent(t.grossMarginPct, 1)}
tone={t.grossMarginCents >= 0 ? 'positive' : 'danger'}
/>
<Stat
label="Per sold GPU-hour"
value={moneyExact(t.marginPerAllocatedGpuHourCents)}
hint={`${percent(t.utilisation, 1)} utilised`}
/>
</section>
<Card>
<CardHeader>
<CardTitle className="text-base">By commitment</CardTitle>
</CardHeader>
<CardContent className="px-0 sm:px-0">
<div className="scroll-x">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
<th className="px-4 pb-2 font-medium sm:px-5">Commitment</th>
<th className="px-4 pb-2 text-right font-medium">Sold</th>
<th className="px-4 pb-2 text-right font-medium">Free</th>
<th className="px-4 pb-2 text-right font-medium">Utilisation</th>
<th className="px-4 pb-2 text-right font-medium">Cost/hr</th>
<th className="px-4 pb-2 text-right font-medium sm:px-5">Break even</th>
</tr>
</thead>
<tbody>
{data.blocks.map((block) => (
<tr key={block.commitmentId} className="border-b border-border/60 last:border-0">
<td className="px-4 py-3 sm:px-5">
<div className="font-medium">{block.name}</div>
<div className="text-xs text-muted">
{block.gpuCount}× {block.gpuType}
</div>
</td>
<td className="nums px-4 py-3 text-right">
{compactNumber(block.soldGpuHours)}
</td>
<td className="nums px-4 py-3 text-right">
{compactNumber(block.availableGpuHours)}
</td>
<td
className={[
'nums px-4 py-3 text-right font-medium',
block.utilisation < 0.5 ? 'text-warning' : '',
].join(' ')}
>
{percent(block.utilisation)}
</td>
<td className="nums px-4 py-3 text-right">
{moneyExact(block.costPerGpuHourCents)}
</td>
<td className="nums px-4 py-3 text-right sm:px-5">
{block.breakEvenPriceCents == null
? '—'
: moneyExact(block.breakEvenPriceCents)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
/**
* Overview — the landing view.
*
* Leads with margin and idle capacity rather than deal counts, because those
* are the numbers this business actually turns on. A CRM that opens on
* "23 open opportunities" tells you nothing about whether you are making money.
*/
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, ArrowRight, Server, TrendingUp } from 'lucide-react';
import { Link } from 'react-router-dom';
import { compactNumber, get, money, percent, relativeTime } from '@/lib/api';
import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
interface Dashboard {
me: { name: string; teams: { team: string; role: string }[] };
margin: {
revenueCents: number;
costCents: number;
grossMarginCents: number;
grossMarginPct: number | null;
utilisation: number;
idleGpuHours: number;
committedGpuHours: number;
allocatedGpuHours: number;
};
blocks: number;
openDemandDeals: number;
openSupplyDeals: number;
idleAlerts: {
commitmentId: string;
name: string;
gpuType: string;
gpuCount: number;
idleCostCents: number;
utilisation: number;
breakEvenPriceCents: number | null;
}[];
recentActivity: {
id: string;
type: string;
subject: string | null;
occurredAt: string;
}[];
}
export function Overview() {
const { data, isLoading, error } = useQuery({
queryKey: ['dashboard'],
queryFn: () => get<Dashboard>('/api/dashboard'),
// The book does not change second to second, but it does change while
// someone is looking at it during a pipeline review.
refetchInterval: 60_000,
});
if (isLoading) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
);
}
if (error || !data) {
return (
<EmptyState
title="Could not load the overview"
description={error instanceof Error ? error.message : 'Unknown error.'}
/>
);
}
const m = data.margin;
const marginTone = m.grossMarginCents >= 0 ? 'positive' : 'danger';
const firstName = data.me.name.split(' ')[0];
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">
{greeting()}, {firstName}
</h1>
<p className="mt-1 text-sm text-muted">
{data.blocks === 0
? 'No capacity commitments yet — margin appears once you record what you have bought.'
: `${data.blocks} capacity commitment${data.blocks === 1 ? '' : 's'} on the book.`}
</p>
</header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat
label="Gross margin"
value={money(m.grossMarginCents)}
hint={`${percent(m.grossMarginPct, 1)} of revenue`}
tone={marginTone}
/>
<Stat
label="Utilisation"
value={percent(m.utilisation, 1)}
hint={`${compactNumber(m.allocatedGpuHours)} of ${compactNumber(m.committedGpuHours)} GPU-hrs sold`}
tone={m.utilisation < 0.6 ? 'warning' : 'default'}
/>
<Stat
label="Idle capacity"
value={`${compactNumber(m.idleGpuHours)} hrs`}
hint="Bought and unsold"
tone={m.idleGpuHours > 0 ? 'warning' : 'default'}
/>
<Stat
label="Open deals"
value={data.openDemandDeals + data.openSupplyDeals}
hint={`${data.openDemandDeals} demand · ${data.openSupplyDeals} supply`}
/>
</section>
{data.idleAlerts.length > 0 ? (
<Card className="border-warning/30">
<CardHeader className="flex-row items-center gap-2 space-y-0">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" aria-hidden />
<CardTitle className="text-base">Capacity you are paying for and not selling</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.idleAlerts.map((alert) => (
<div
key={alert.commitmentId}
className="flex flex-col gap-2 rounded-lg bg-surface-2 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="truncate font-medium">{alert.name}</p>
<p className="text-xs text-muted">
{alert.gpuCount}× {alert.gpuType} · {percent(alert.utilisation)} utilised
{/*
A zero break-even means the block's cost is already
covered, so any further sale is upside. Printing
"break even above $0.00" is technically true and reads
like a bug, so it is said in words instead.
*/}
{alert.breakEvenPriceCents == null ? null : alert.breakEvenPriceCents > 0 ? (
<>
{' · '}break even above{' '}
<span className="nums">{money(alert.breakEvenPriceCents)}</span>/GPU-hr
</>
) : (
<>{' · '}cost already covered further sales are upside</>
)}
</p>
</div>
<div className="flex items-center gap-3 sm:justify-end">
<span className="nums whitespace-nowrap text-sm font-semibold text-warning">
{money(alert.idleCostCents)}
</span>
<Link
to="/capacity"
className="tap inline-flex items-center gap-1 text-sm font-medium text-accent-fg"
>
Match
<ArrowRight className="h-3.5 w-3.5" aria-hidden />
</Link>
</div>
</div>
))}
</CardContent>
</Card>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">The book</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<Row label="Revenue" value={money(m.revenueCents)} />
<Row label="Cost of committed capacity" value={money(m.costCents)} />
<div className="border-t border-border pt-2">
<Row
label="Gross margin"
value={money(m.grossMarginCents)}
emphasis
tone={marginTone}
/>
</div>
<p className="pt-2 text-xs leading-relaxed text-muted">
Cost is charged against the full commitment, not only the hours that sold
unsold hours are already paid for.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Recent activity</CardTitle>
</CardHeader>
<CardContent>
{data.recentActivity.length === 0 ? (
<p className="py-6 text-center text-sm text-muted">Nothing logged yet.</p>
) : (
<ul className="space-y-2.5">
{data.recentActivity.slice(0, 8).map((activity) => (
<li key={activity.id} className="flex items-start gap-2 text-sm">
<Badge tone="neutral" className="mt-0.5 shrink-0">
{activity.type.replace('_', ' ')}
</Badge>
<span className="min-w-0 flex-1 truncate">{activity.subject ?? '—'}</span>
<span className="shrink-0 text-xs text-muted">
{relativeTime(activity.occurredAt)}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
{data.blocks === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Server className="h-8 w-8" />}
title="No capacity on the book yet"
description="Record a capacity commitment — what you bought, at what cost, over what term — and margin, utilisation and idle alerts all follow from it."
/>
</CardContent>
</Card>
) : null}
</div>
);
}
function Row({
label,
value,
emphasis,
tone,
}: {
label: string;
value: string;
emphasis?: boolean;
tone?: 'positive' | 'danger';
}) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className={emphasis ? 'font-medium' : 'text-muted'}>{label}</span>
<span
className={[
'nums tabular-nums',
emphasis ? 'text-base font-semibold' : '',
tone === 'positive' ? 'text-positive' : tone === 'danger' ? 'text-danger' : '',
].join(' ')}
>
{value}
</span>
</div>
);
}
function greeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
}
+250
View File
@@ -0,0 +1,250 @@
/**
* The pipeline boards, for both sides of the market.
*
* A column-per-stage board on desktop; on a phone, a stage picker and a single
* column. A horizontally scrolling eight-column board on a 390px screen is
* technically responsive and practically unusable — you cannot see where a
* card is going, which is the entire point of a board.
*/
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { get, money, relativeTime } from '@/lib/api';
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
interface DemandDeal {
id: string;
name: string;
stage: string;
productLine: string;
acvCents: number | null;
msaExecuted: boolean;
dpaExecuted: boolean;
updatedAt: string;
}
interface SupplyDeal {
id: string;
name: string;
stage: string;
gpuType: string | null;
gpuCount: number | null;
targetCostPerGpuHourCents: number | null;
updatedAt: string;
}
interface Board<T> {
stages: string[];
deals: { deal: T; accountName: string | null }[];
}
const STAGE_LABELS: Record<string, string> = {
qualification: 'Qualification',
legal: 'Legal',
scoping: 'Scoping',
proposal: 'Proposal',
procurement: 'Procurement',
poc: 'POC',
deployment: 'Deployment',
expansion: 'Expansion',
closed_won: 'Closed won',
closed_lost: 'Closed lost',
sourced: 'Sourced',
qualifying: 'Qualifying',
technical_diligence: 'Technical diligence',
financial_diligence: 'Financial diligence',
pricing: 'Pricing',
contracting: 'Contracting',
onboarding: 'Onboarding',
live: 'Live',
renewal: 'Renewal',
churned: 'Churned',
rejected: 'Rejected',
};
export function DemandPipeline() {
return (
<PipelineBoard<DemandDeal>
title="Demand"
subtitle="Selling compute and post-training. Note that legal sits early — paper gates the deal rather than closing it."
endpoint="/api/deals/demand"
renderCard={(deal, accountName) => (
<>
<p className="truncate font-medium">{deal.name}</p>
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{deal.acvCents ? (
<span className="nums text-sm font-semibold">{money(deal.acvCents)}</span>
) : null}
<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>
{/* Contract state is surfaced on the card because shipping capacity
without executed paper is the mistake this pipeline prevents. */}
{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}
{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}
</div>
</>
)}
/>
);
}
export function SupplyPipeline() {
return (
<PipelineBoard<SupplyDeal>
title="Supply"
subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision."
endpoint="/api/deals/supply"
renderCard={(deal, accountName) => (
<>
<p className="truncate font-medium">{deal.name}</p>
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{deal.gpuCount && deal.gpuType ? (
<Badge tone="accent">
{deal.gpuCount}× {deal.gpuType}
</Badge>
) : null}
{deal.targetCostPerGpuHourCents ? (
<span className="nums text-xs text-muted">
{money(deal.targetCostPerGpuHourCents)}/hr target
</span>
) : null}
</div>
</>
)}
/>
);
}
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({
title,
subtitle,
endpoint,
renderCard,
}: {
title: string;
subtitle: string;
endpoint: string;
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
}) {
const { data, isLoading } = useQuery({
queryKey: [endpoint],
queryFn: () => get<Board<T>>(endpoint),
});
const [activeStage, setActiveStage] = useState<string | null>(null);
const byStage = useMemo(() => {
const map = new Map<string, { deal: T; accountName: string | null }[]>();
for (const stage of data?.stages ?? []) map.set(stage, []);
for (const row of data?.deals ?? []) {
map.get(row.deal.stage)?.push(row);
}
return map;
}, [data]);
if (isLoading) return <Skeleton className="h-96" />;
if (!data || data.deals.length === 0) {
return (
<div className="space-y-5">
<Header title={title} subtitle={subtitle} />
<Card>
<EmptyState
title={`No ${title.toLowerCase()} deals yet`}
description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel."
/>
</Card>
</div>
);
}
const stages = data.stages;
const currentStage = activeStage ?? stages[0]!;
return (
<div className="space-y-5">
<Header title={title} subtitle={subtitle} />
{/* Phone: pick one stage. The chips scroll; the board does not. */}
<div className="lg:hidden">
<div className="scroll-x -mx-4 flex gap-2 px-4 pb-1">
{stages.map((stage) => {
const count = byStage.get(stage)?.length ?? 0;
return (
<button
key={stage}
onClick={() => setActiveStage(stage)}
className={[
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
stage === currentStage
? 'bg-accent text-accent-on'
: 'bg-surface-2 text-muted',
].join(' ')}
>
{STAGE_LABELS[stage] ?? stage}
<span className="ml-1.5 opacity-70">{count}</span>
</button>
);
})}
</div>
<div className="mt-3 space-y-2">
{(byStage.get(currentStage) ?? []).map((row) => (
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
))}
{(byStage.get(currentStage) ?? []).length === 0 ? (
<p className="py-8 text-center text-sm text-muted">
Nothing in {STAGE_LABELS[currentStage] ?? currentStage}.
</p>
) : null}
</div>
</div>
{/* Desktop: the full board, scrolling horizontally within its own pane
so the page itself never scrolls sideways. */}
<div className="scroll-x hidden lg:block">
<div className="flex gap-3 pb-2">
{stages.map((stage) => {
const rows = byStage.get(stage) ?? [];
return (
<section key={stage} className="w-72 shrink-0">
<div className="mb-2 flex items-center justify-between px-1">
<h2 className="text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2>
<span className="nums text-xs text-muted">{rows.length}</span>
</div>
<div className="space-y-2">
{rows.map((row) => (
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
))}
</div>
</section>
);
})}
</div>
</div>
</div>
);
}
function DealCard<T extends { id: string; updatedAt: string }>({
row,
renderCard,
}: {
row: { deal: T; accountName: string | null };
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
}) {
return (
<article className="card p-3">
{renderCard(row.deal, row.accountName)}
<p className="mt-2 text-[11px] text-muted">{relativeTime(row.deal.updatedAt)}</p>
</article>
);
}
function Header({ title, subtitle }: { title: string; subtitle: string }) {
return (
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p>
</header>
);
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Settings — appearance, profile, and connecting an agent.
*
* The appearance section is where the user picks the accent that re-tints the
* whole product. It is saved server-side, so the choice follows them between
* devices rather than being a per-browser quirk.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, Monitor, Moon, Sun, Terminal } from 'lucide-react';
import { get, patch } from '@/lib/api';
import { useTheme } from '@/lib/theme';
import { getAccent, THEME_MODES, type ThemeMode } from '@pig/core';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
import { useState } from 'react';
interface Me {
id: string;
name: string;
email: string;
isPlatformAdmin: boolean;
teams: { team: string; role: string }[];
via: string;
}
export function Settings() {
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
</header>
<Appearance />
<Profile me={me} />
<ConnectAgent />
</div>
);
}
function Appearance() {
const { mode, accent, setMode, setAccent, accents } = useTheme();
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Appearance</CardTitle>
<p className="text-sm text-muted">
Saved to your account, so it follows you between your laptop and your phone.
</p>
</CardHeader>
<CardContent className="space-y-5">
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Theme</p>
<div className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto">
{THEME_MODES.map((value) => {
const Icon = value === 'light' ? Sun : value === 'dark' ? Moon : Monitor;
return (
<button
key={value}
onClick={() => setMode(value as ThemeMode)}
aria-pressed={mode === value}
className={[
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium capitalize transition-colors sm:flex-none',
mode === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
].join(' ')}
>
<Icon className="h-4 w-4" aria-hidden />
{value}
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Accent</p>
<div className="flex flex-wrap gap-2">
{accents.map((option) => {
const selected = option.key === accent;
const definition = getAccent(option.key);
return (
<button
key={option.key}
onClick={() => setAccent(option.key)}
aria-pressed={selected}
aria-label={option.label}
title={option.label}
className={[
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
selected ? 'border-accent bg-accent-subtle' : 'border-border hover:bg-surface-2',
].join(' ')}
>
{/*
The swatch previews the light-mode value while the app is in
light mode and the dark value in dark mode, because the two
are tuned separately and a single preview would misrepresent
one of them.
*/}
<span
className="h-4 w-4 rounded-full border border-black/10"
style={{ backgroundColor: `hsl(${definition.light.accent})` }}
aria-hidden
/>
{option.label}
{selected ? <Check className="h-3.5 w-3.5" aria-hidden /> : null}
</button>
);
})}
</div>
<p className="mt-2 text-xs text-muted">
Status colours positive, warning, danger stay fixed regardless of your accent,
so a warning always looks like a warning.
</p>
</div>
</CardContent>
</Card>
);
}
function Profile({ me }: { me: Me | undefined }) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [title, setTitle] = useState('');
const save = useMutation({
mutationFn: () =>
patch('/api/me/preferences', {
...(name ? { name } : {}),
...(title ? { title } : {}),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['me'] });
setName('');
setTitle('');
},
});
if (!me) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Profile</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted">Name</dt>
<dd>{me.name}</dd>
</div>
<div>
<dt className="text-xs text-muted">Email</dt>
<dd className="break-all">{me.email}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-muted">Teams</dt>
<dd className="mt-1 flex flex-wrap gap-1.5">
{me.teams.length === 0 ? (
<span className="text-muted">No team membership</span>
) : (
me.teams.map((t) => (
<Badge key={t.team} tone="accent">
{t.team} · {t.role}
</Badge>
))
)}
{me.isPlatformAdmin ? <Badge tone="warning">Platform admin</Badge> : null}
</dd>
</div>
</dl>
<form
className="grid gap-3 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
save.mutate();
}}
>
<label className="block">
<span className="mb-1 block text-xs font-medium text-muted">Display name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={me.name} />
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-muted">Title</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Head of Compute"
/>
</label>
<div className="sm:col-span-2">
<Button
type="submit"
variant="primary"
disabled={save.isPending || (!name && !title)}
>
{save.isPending ? 'Saving…' : 'Save profile'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
function ConnectAgent() {
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://your-pig-host';
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-accent-fg" aria-hidden />
<CardTitle className="text-base">Connect your agent</CardTitle>
</div>
<p className="text-sm text-muted">
PIG speaks MCP, so Claude Code, Codex, prime-agent and Buzz agents can all work with
your pipeline directly from the terminal.
</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="scroll-x rounded-lg bg-surface-2 p-3">
<pre className="text-xs leading-relaxed">
<code>{`export PIG_URL=${origin}
export PIG_API_KEY=pig_... # create one below
claude mcp add pig -- npx -y @pig/mcp`}</code>
</pre>
</div>
<p className="text-xs text-muted">
The agent authenticates as its own principal, separately revocable from your own
session, and can never reach further than you can.
</p>
</CardContent>
</Card>
);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Sign-in.
*
* Magic link only. PIG stores no passwords, and adding a password field would
* mean either storing one or pretending to — both worse than an email link for
* an internal tool used by a couple of dozen people.
*/
import { useState } from 'react';
import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
export function SignIn({ config }: { config: PublicConfig }) {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [message, setMessage] = useState('');
async function submit(event: React.FormEvent) {
event.preventDefault();
const supabase = getSupabase();
if (!supabase) {
setStatus('error');
setMessage('Authentication is not configured on this deployment.');
return;
}
setStatus('sending');
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: window.location.origin },
});
if (error) {
setStatus('error');
// Supabase returns a clear message for rate limits and disabled signup,
// both of which the person can act on, so it is shown rather than hidden.
setMessage(error.message);
return;
}
setStatus('sent');
}
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3 text-center">
<PiggyMark className="h-14 w-14 text-fg" title="pig" />
<div>
<h1 className="text-2xl font-semibold lowercase tracking-tight">pig</h1>
<p className="text-sm text-muted">Prime Intellect Growth</p>
</div>
</div>
<Card>
<CardContent className="pt-5">
{status === 'sent' ? (
<div className="space-y-2 text-center">
<p className="font-medium">Check your email</p>
<p className="text-sm text-muted">
A sign-in link is on its way to {email}. It expires shortly, so use it soon.
</p>
</div>
) : (
<form onSubmit={submit} className="space-y-3">
<label className="block">
<span className="mb-1 block text-sm font-medium">Email</span>
<Input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
autoComplete="email"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
<Button
type="submit"
variant="primary"
className="w-full"
disabled={status === 'sending'}
>
{status === 'sending' ? 'Sending…' : 'Email me a sign-in link'}
</Button>
{status === 'error' ? (
<p className="text-sm text-danger">{message}</p>
) : null}
{config.inviteRequired ? (
<p className="text-center text-xs text-muted">
PIG is invite-only. An account alone does not grant access.
</p>
) : null}
</form>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class', '[data-theme="dark"]'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Every colour resolves through a CSS variable so the user's chosen
// accent re-tints the whole interface without a rebuild.
bg: 'hsl(var(--bg))',
surface: 'hsl(var(--surface))',
'surface-2': 'hsl(var(--surface-2))',
border: 'hsl(var(--border))',
fg: 'hsl(var(--fg))',
muted: 'hsl(var(--muted))',
accent: 'hsl(var(--accent))',
'accent-fg': 'hsl(var(--accent-fg))',
'accent-on': 'hsl(var(--accent-on))',
'accent-subtle': 'hsl(var(--accent-subtle))',
positive: 'hsl(var(--positive))',
warning: 'hsl(var(--warning))',
danger: 'hsl(var(--danger))',
info: 'hsl(var(--info))',
},
fontFamily: {
sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Inter', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
},
borderRadius: { lg: '0.75rem', xl: '1rem' },
},
},
plugins: [],
};
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
port: 5173,
// Proxy in development so the browser sees one origin, matching how
// production serves the API and the app together. Auth sessions are
// per-origin, so a split origin in dev but not prod hides real bugs.
proxy: { '/api': { target: 'http://localhost:8920', changeOrigin: true } },
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
/*
* Split the vendor code that changes rarely from the app code that
* changes constantly, so a deploy invalidates only the small chunk.
* This matters on mobile: the auth client alone is a large download,
* and re-fetching it on every deploy over a cellular connection is
* exactly the cost worth avoiding.
*/
manualChunks: {
react: ['react', 'react-dom', 'react-router-dom'],
supabase: ['@supabase/supabase-js'],
query: ['@tanstack/react-query'],
},
},
},
},
});