Frontend architecture
The frontend is a Next.js 14 App Router app in TypeScript, in frontend/src. It is a thin, fast client over the backend API: no server-side data layer of its own, just pages and components that call /api and render.
Stack
| Concern | Choice |
|---|---|
| Framework | Next.js 14 (App Router), React 18 |
| Language | TypeScript |
| Styling | Tailwind CSS with a project design system in app/globals.css |
| Animation | Framer Motion |
| Data fetching | TanStack Query; large lists virtualized with TanStack Virtual |
| Icons | lucide-react |
| Build/run | next dev (port 3000 locally), next start in production (standalone output) |
Routing map
| Route | File | Purpose |
|---|---|---|
/ | app/page.tsx | Entry; routes the user into the app or to login. |
/dashboard | app/dashboard/page.tsx | The team's claims, per-claim usage, and headline stat cards (claims, items, processing speed); create a claim. |
/claims/[id] | app/claims/[id]/page.tsx | A claim: its rooms, details, submission history (components/SubmissionsCard.tsx: date, items, photos, claim key, submission id, processing time), and submit. Send for Estimation disables once every item is submitted; audio-only items show a neutral "no photo to send" note instead of a warning. |
/rooms/[id] | app/rooms/[id]/page.tsx | A room: upload media, list jobs, review room inventory. |
/jobs/[id] | app/jobs/[id]/page.tsx | A job: processing progress and its inventory. |
/settings | app/settings/page.tsx | Profile (time zone preference), auto-approval rules, AI prompts, integrations. |
/changelog | app/changelog/page.tsx | Customer changelog, rendered from src/content/changelog/customer.md. The sidebar item shows a dot while there are unseen entries (localStorage). |
/updates | app/updates/page.tsx | Internal release log (admins only), rendered from src/content/changelog/internal.md; same unseen-dot behavior. Note the gate is UI-only: the markdown ships in the client bundle. |
/admin | app/admin/page.tsx | Internal admin dashboard (admins only): system-wide health across all teams. Overview tab (live stats, range-scoped credits and AI cost, last 10 jobs, claims, jobs, team leaderboard), Teams tab (per-team component visibility with global defaults; training ships hidden), Activity Log tab (all teams, team filter), and Media tab (browse and play every upload). |
/logs | app/logs/page.tsx | Pipeline Logs (admins only). Jobs tab: every job system-wide with detailed logs, filters, grouping, plus per-job Mark failed (dismiss a stuck job with a refund) and Delete (hard-delete with confirm). API Requests tab (components/ApiRequestsPanel.tsx): OpenRouter calls grouped by job with per-job totals (requests, cost, time), searchable by job/claim/filename and filterable by company/user, expanding to per-call request/response JSON with an open-in-OpenRouter link. |
/training, /training/missions/[key] | app/training/* | The training hub and mission runner. Hidden (nav and page) when the admin switches the training component off for the team. See training. |
/verify/[code] | app/verify/[code]/page.tsx | Public certificate verification (no sign-in). |
/help, /help/[slug] | app/help/* | The in-app Help Center. |
/login, /logout, /auth/callback | app/* | The SSO flow. |
error.tsx, not-found.tsx | app/* | Error and 404 pages. |
A client-side SSE proxy route also exists at app/api/jobs/[id]/stream/route.ts to relay the backend stream during local development.
Dates and times format through lib/datetime.ts, which shows every timestamp in the user's chosen time zone (Settings > Profile, stored on the user row) or the browser's local zone by default. Do not call toLocaleString with a hardcoded zone.
Changelogs are markdown files in src/content/changelog/ imported as raw strings (a webpack rule in next.config.js), parsed by lib/changelog.ts (## YYYY-MM-DD - [New|Improved|Fixed] Title, an optional summary line, then optional bullets) and rendered by components/ChangelogFeed.tsx as cards grouped under a date heading, with kind pills, a month dropdown and a search box across the top. The kind tag is optional in the parser so an untagged entry still renders (as Improved) instead of dropping off the page. Customer entries are capped at two lines and one change each; internal entries deliberately are not. Updating them on every product commit is a hard rule in CLAUDE.md. The footer (components/Footer.tsx) shows the team's credit balance and a Buy credits button (on prepaid-billing instances, moved here from the old top bar), the copyright line, and the build's commit hash (linking to /changelog) and date from NEXT_PUBLIC_COMMIT_SHA / NEXT_PUBLIC_COMMIT_TIME, which docker-compose bakes in at build time. Video playback goes through components/MediaFrame.tsx, which reserves a 16:9 box with a logo placeholder and spinner so pages never shift while media loads.
The app shell
app/layout.tsx wraps everything in three context providers and the shell:
components/PostHogProvider.tsxinitialises product analytics and wires page views and user identity. It is a no-op when no PostHog key is configured (the default), so it is invisible until analytics is turned on.components/AppShell.tsxis the frame: the responsive sidebar and the content column. There is no desktop top bar — the team's credit balance now lives in the footer (Footertakes abillingprop), and the content column's left margin follows the sidebar's collapsed width. On mobile a slim top bar carries only the hamburger + logo.components/Sidebar.tsxis the left nav with the Adjust Square logo, an environment badge (hidden in production, amber for stage, violet for local), and the signed-in user with a sign-out button. On desktop the rail is collapsible to an icon strip via a toggle above the user chip; the choice persists in thecv_nav_collapsedcookie through a shareduseSyncExternalStorestore (lib/navCollapsed.ts) that both the sidebar (its width) andAppShell(the content margin) read, so a toggle re-renders them together. Collapse applies at thexlbreakpoint only; the mobile drawer is always full width. It also renders an Admin tab, but only whenuseAuth().user?.is_adminis true (the allowlisted operators). Hiding it is cosmetic; the/adminpage and its API enforce access on their own (see authentication).- The environment comes from
NEXT_PUBLIC_APP_ENVviagetAppEnv()inlayout.tsx.
The API client
All backend calls go through lib/api.ts. There is no axios or generated client; it is hand-written fetch/XMLHttpRequest wrappers.
- Auth header:
actorHeaders()reads the token fromlocalStorage(cv_auth_token) and addsAuthorization: Bearer <token>to every request. - Base URL: everything is relative to
/api, which nginx (prod) or the Next proxy (dev) routes to the backend. - Uploads: video/photo/audio uploads use
XMLHttpRequestso a real upload-progress percentage can drive the UI. - Errors: failed calls try to surface the backend's
detailmessage so users see something meaningful. - Streaming:
subscribeToJobProgressopens anEventSourceto the job stream and dispatchesprogress,log, andmetricscallbacks.
// lib/api.ts: every call attaches the token
function actorHeaders(): Record<string, string> {
const token = getAuthToken() // localStorage 'cv_auth_token'
return token ? { Authorization: `Bearer ${token}` } : {}
}
Authentication on the client
lib/auth.tsx provides AuthProvider/useAuth. On mount it restores a saved session from localStorage, and if none exists (and the user did not just log out) it probes /api/auth/me in case a same-subdomain AdjustSquare cookie is present. The login redirect, token exchange at /auth/callback, and logout are covered in authentication.
Real-time job progress
When a job is processing, the page subscribes to the SSE stream and updates components/ProcessingStatus.tsx live: an animated progress bar, the current stage text, and a scrolling log. When the stream sends complete, the UI swaps to the inventory.
Component map
| Area | Components |
|---|---|
| Shell & nav | AppShell, Sidebar, ModalShell, ModalPortal |
| Claims & rooms | CreateClaimModal, CreateRoomModal, RoomUpload |
| Upload | RoomUpload (one drop area for any media mix), ConfirmUploadsModal, GroupPhotosModal |
| Training | app/training (hub + mission runner), app/verify/[code] (public certificate page), TrainingTour (spotlight tours), TrainingTracker (floating mission task list), TrainingBadgeIcon |
| Processing | ProcessingStatus, JobCompletionChime |
| Review | InventoryTable, ItemPhotosModal, ItemModal, ReviewMode, TranscriptPanel, VideoPlayer |
| Submit | SubmitClaimModal |
| Settings | PromptsCard, automation UI |
| Helpers | StyledSelect, ConfirmModal, HelpArticleBody |
Helper libraries: lib/media.ts (upload classification: extension lists, classifyFile, folder traversal for drag-and-drop), lib/confidence.ts (confidence buckets and colors), lib/sourceTag.ts (video/photo/audio badges), lib/automationSettings.tsx (auto-approval context), lib/chime.ts (the completion sound), hooks/useEscapeToClose.ts.
The design system
The visual language lives in frontend/src/app/globals.css as a set of component classes (.btn-primary, .card, .as-input, .as-table, .pill-*, .eyebrow, motion primitives). The handbook you are reading reuses the same fonts (Inter Tight, JetBrains Mono, Instrument Serif), the #0B80F0 blue, and the slate palette so the two surfaces feel like one product.
The project has firm UI and motion rules (use the design-system classes, never a native <select>, animate everything in and out with Framer Motion). They are summarized in engineering conventions and live in full in the repo's CLAUDE.md.
The Help Center
In-app help is content-driven. Articles are TypeScript modules in frontend/src/content/help/ (one file per article, registered in articles.ts), rendered by app/help/[slug]/page.tsx through components/HelpArticleBody.tsx. The house style: short verb-first headings, terse steps, screenshots, and a path that always starts from the Dashboard. This is distinct from the handbook (this Docusaurus site), which documents how the system works rather than how to perform a task in the UI.