Skip to content

Admin web (the SPA)

Chapter 18. Chapters 7 and 17 covered the control-plane API. This chapter is the app that drives it — the admin SPA that merchants and staff actually use to onboard, edit themes, and manage a store.

The merchant/admin single-page app — apps/admin-web (@ratio/admin-web). Stack: React 19, Vite 6, react-router 7, Clerk, Monaco, Vitest. Notable absences tell you the philosophy: no react-query / zustand / SWR, no Ant Design, no @ratio/control-plane-client — data flow is a hand-rolled typed fetch client plus React context, and the UI is a bespoke CSS design system. The one workspace dep in the bundle is @ratio/design-tokens, aliased to its TS source so the editor's tokens stay byte-identical with the storefront renderer.

Entry & routing

Provider order (in main.tsx) is load-bearing: ThemeProviderBrowserRouterClerkProvider — theme is outermost so it drives both the app chrome and Clerk's widget appearance; the Clerk publishable key throws at module load if unset. App.tsx splits <SignedOut> (a bespoke sign-in card) from <SignedIn> (<ErrorBoundary>AuthedRoutes). AuthedRoutes fires two loads on mountapi.listStores() and api.me() — and blocks rendering until both resolve, so the role-based landing is correct; everything downstream gets { api, stores, me, reload } via context. The route tree:

PathElementNote
/stores/newOnboardingWizardchrome-less; before /:storeId so "new" isn't an id
/admin/*RequireAdmin → platformusers · stores · base-theme · base-theme/edit
/storesStoresLaunchermulti-store launchpad
/stores/:id/themes/:themeId/editorFullScreenEditorPagefull-screen, outside the shell (lazy)
/stores/:id/themes/:themeId/customizeFullScreenCustomizePagefull-screen, outside the shell (lazy)
/stores/:id/*MerchantLayout (shell)themes · pages · domains · commerce · settings · access · audit · danger

The two editor routes sit outside MerchantLayout (they own the whole screen). Route ordering matters — new and the editor segments are declared before /:storeId so they aren't captured as a store id.

State — three hand-rolled contexts

No global store library. common/store-context.tsx provides the app-wide StoreData { api, stores, me, reload, openCreate } and the per-store outlet context MerchantCtx { api, store } (via useMerchant()); resolveStore matches a URL param by host, hosts[], or id, so old domain URLs still resolve. common/theme.tsx holds light/dark/system (persisted; 'system' removes data-theme so CSS prefers-color-scheme wins). common/ui.tsx carries the toast context. The pattern throughout: a container owns state via useState/useRef + the injected api; there is no cache layer, so each panel load()s on mount and exposes a reload.

The API client — common/api.ts

createApi(baseUrl, getToken, fetchImpl) returns a frozen object of ~50 typed methods (export type Api = ReturnType<…>; getToken/fetchImpl injected so it's testable headless). The request core attaches authorization: Bearer <Clerk JWT>, defaults JSON (a FormData body skips content-type for uploads), and uses an AbortController with a 15s timeout (assistant 90s, uploads 30s). Non-2xx → ApiError(status, …) (unwrapping {error}/{message} envelopes); timeouts/network → a friendly ApiError(0, …) so loaders never hang; pickArray throws if an expected list field is renamed (a malformed response surfaces as an error, not an infinite spinner). Bundle/page saves round-trip a revision token and get a 409 if another editor moved the draft.

The feature map — features/*

FeatureWhat it is
themethe Monaco code editor + the visual customizer (the biggest feature — below)
onboardingthe store-creation wizard (below)
pagesthe page-builder (list + PageEditor from api.pbCatalog())
commerce / settingsconnect the commerce merchant id; per-capability provider pins; rename
domainsconnect/verify custom domains, DNS + SSL status
auditthe Activity view — server-side filter/sort/page via the shared DataTable
assistant"Ask Ratio" (AskRatio) — calls api.assistant, renders reply + actions[]
accessmint a short-lived store-scoped agent token for a bring-your-own AI (ADR-007)
adminthe platform console — all users/stores, the base-theme propagation + base editor
stores / shellthe multi-store launcher; MerchantLayout/PlatformLayout, ⌘K palette, nav

The theme editor & customizer

Both surfaces edit the same bundle draft (ThemeFiles = Record<path, source>) with the same optimistic revisionRef, and both drop the synthesized manifest.json so a full-tree save can't clobber a platform-managed file. Container/presentational is strict — a big container owns all state and composes dumb editor-* / customizer-* children.

  • Code editor — a VS Code-style workbench (activity bar, explorer, Monaco, live preview). Monaco is self-hosted and worker-less (no CDN, no language server; Liquid is highlighted as HTML), and uncontrolled so our edits don't fight the cursor — external writes (the Blocks/Assets panels rewriting the same JSON) reconcile through one dedicated pushEditOperations effect. A 409 on save keeps the dirty buffer ("changed elsewhere"); a 400 parses {issues:[{path,error}]} into per-file messages. A 500ms-debounced live preview re-renders on edit.
  • Visual customizer — a sections rail + a live-preview iframe + in-canvas selection. Its heart is the preview bridge:

The iframe is opaque-origin (allow-scripts without allow-same-origin) so theme scripts can't touch the admin session; messages are authed by event.source + a source:'rt-customizer' tag (origin is null). The bridge relies on server-emitted data-rt-section / data-rt-block markers that exist only in editor preview mode. A stale-index guard silently drops any in-canvas action when the preview no longer matches the current files (an index into a stale render could point at a section that no longer exists).

Version history offers owner-only per-version rollback + reset-to-base; the themes grid (themes-list) is the launch point into the full-screen editor/customizer.

Onboarding

A 4-step chrome-less wizard (Connect → Store → Design → Launch), its {step, data} persisted in sessionStorage (6h TTL, step-clamped) so a refresh doesn't lose progress or re-submit the address.

A live store is created at Step 2, not Step 4

Despite "draft" in the code comments, Step 2's Continue calls createStore, which creates and publishes a live store on the default bundle theme (stamping storeId/themeId). Step 3 (Design) then adopts the chosen base ⊕ brand tokens, and Step 4 (Launch) republishes. This is why the sessionStorage persistence matters — a mid-flow refresh must not re-create the store and hit "domain already connected."

The design system & conventions

No component library (Ant Design is deliberately avoided) — a bespoke CSS system under src/styles/ driven by @ratio/design-tokens, plus hand-rolled primitives in common/: a focus-trapping Dialog, a generic controlled DataTable<T> (serving both client-side Pages and server-side Activity), PageHeader, the ⌘K CommandPalette, an inline-SVG Icon set, and a toast system. Container/presentational is pervasive: a route wrapper pulls context → a *Panel/*List container owns state + api → dumb children in their own files; pure transforms (block-ops, template-blocks, wizard-state, host) are extracted with colocated tests. Built with Vite (tsc -b && vite build) and deployed to Cloudflare Pages with a _redirects SPA fallback so deep links resolve.

Gotchas

  1. Env fails differentlyVITE_CLERK_PUBLISHABLE_KEY throws at load; VITE_ADMIN_API_URL silently defaults to :8787.
  2. Two blocking loads gate the applistStores + me; a persistently failing /me silently hides admin/owner affordances rather than erroring.
  3. revisionRef is a ref, not state — a 409 means the draft moved; the code editor keeps your buffer, the customizer asks you to reload.
  4. Monaco is worker-less — Liquid highlights as HTML; external file writes must go through the reconcile effect or you lose edits.
  5. Preview iframes are opaque-origin — postMessage auth is by event.source, not origin; the data-rt-* markers exist only in editor preview mode.
  6. me.isPlatformAdmin grants a synthetic admin role on every store — the UI must not hide owner actions from platform admins (the backend already bypasses requireRole('owner')).
  7. No shared cache — every panel re-fetches on mount; a mutation must call the right reload or the UI goes stale.