SPA overview
The admin console (frontend/) is a client-rendered single-page app: Vite + React + TypeScript +
Tailwind CSS v4, deployed to Cloudflare Workers (Static Assets) (the protovoc Worker). The
respondent-facing survey runtime is part of the same bundle but renders standalone (no shell, no auth).
Entry & routing
frontend/src/main.tsx mounts <App /> into #root under React StrictMode (Inter is loaded via
@fontsource-variable/inter).
frontend/src/App.tsx defines the route tree under BrowserRouter:
- Public routes are matched first and live outside auth/shell —
/s/:id(survey completion) and/s/:id/opt-out. - Everything else goes through
<Gate />, which reads the session fromuseAuth():- while
loading, it shows a spinner; - a signed-in user gets
AuthedApp—ClerkWorkspaceGate→AppShell→ the protectedRoutes; - a signed-out visitor gets
AnonApp— just the full-pageClerkLogin.
- while
Protected routes cover the Analyse surfaces (/, /responses, /verbatim, /kpi, /reports,
/alerts), Engage/Configure pages, and the /admin/* hub. / is the Dashboards home; the dashboard
builder lives at /dashboards/builder.
AppShell
frontend/src/components/layout/AppShell.tsx is the authenticated chrome:
- A collapsible teal sidebar grouped into sections (Analyse / Engage / Configure / Develop); the
collapsed state persists in
localStorage(voc_sidebar_collapsed). - A sticky header with the sidebar toggle, a context-aware Back link (sub-pages return to their
hub via
backTarget), theOrgSwitcher,GlobalSearch, the account link, and Sign out. - The shared Analyse filter bar (
AnalyseFilterBar) renders only on Analyse routes (isAnalyseRoute). - Admin console banners (
api.activeConsoleMessages()) render as dismissible info/warning strips. - The whole shell is wrapped in
<FilterProvider>, so the global filter state is available to every page beneath it (see Filters).
useIdleLogout(signOut) signs the user out after inactivity.
Auth & session
Auth is Clerk, driven directly from the SPA via @clerk/react; the API only verifies the
session JWT. frontend/src/main.tsx wraps the app in <ClerkProvider> (publishable key from
VITE_CLERK_PUBLISHABLE_KEY), and sign-in / org-onboarding render through Clerk components
(frontend/src/components/ClerkAuthUI.tsx). The available sign-in methods (email code, Google, SSO)
are configured in the Clerk dashboard, not in this codebase.
frontend/src/lib/clerk-auth.tsx— backsuseAuth()on top of Clerk's hooks. It exposes{ user, loading, activeOrgId, configured, refresh, signOut }, and holds the current Clerk session JWT in a module slot so the API client can read it synchronously per request. The active Clerk Organization is what drives which tenant the API scopes to.- Org and member surfaces use Clerk components directly:
<OrganizationSwitcher>(theOrgSwitcher), member management viauseOrganization(UsersPage), and<UserProfile>(AccountPage).
The API client
frontend/src/lib/api/http.ts is the shared HTTP core (base URL from VITE_API_URL, or same-origin
when empty). Domain-specific modules live beside it, and frontend/src/lib/api.ts re-exports their
operations as a compatibility façade. The core req<T> helper:
- attaches
Authorization: Bearer <token>from the auth module slot; - on a 401 with a token, refreshes the bearer once (
refreshBearer, de-duplicated so a burst of 401s triggers a single refresh) and retries; if that fails it signs out and redirects to/login; - unwraps the error envelope — the API returns errors as
{ error: { code, message } }, soreqreadsbody.error.messageand throws anErrorwith a.statusproperty. Pages catch this to show the server's message. - returns
undefinedfor204, otherwise the parsed JSON.
reqText handles small CSV/text endpoints; reqBlob handles permission-checked durable export
manifests and CSV parts without exposing R2 references. DTOs and query-string builders are split into
types.ts and query.ts (see Filters).