Merchant dashboard

Dashboard architecture

merchant-dashboard-v2 is a Vite + React 19 single-page app. Every API call goes through one hooks file built on typed ts-rest clients. Lists keep their state in the URL, and the whole UI is bilingual with RTL.


Stack

ConcernChoice
BuildVite 6.4, TypeScript 5.7 (strict, noUncheckedIndexedAccess), Node ≥ 20
Routingreact-router-dom 7, BrowserRouter, nested routes in src/App.tsx
Server stateTanStack Query 5. Queries stay fresh for 30 s with no focus refetch. 4xx is never retried; 5xx and network errors retry twice. Mutations never retry. (src/main.tsx:21-34)
API@ts-rest/core clients from contracts-merchant 0.7.18 and contracts-accounting 0.0.4
FormsPlain useState seeded once from the loaded record. No form library. Zod types come from the contracts.
UIshadcn/ui (radix-nova, rtl: true), Radix, Tailwind 4.3, lucide-react, cmdk, vaul, sonner, input-otp, react-day-picker 10
TablesTanStack Table v9, with features declared once in components/common/DataTableFeatures.ts
Drag & drop@dnd-kit (sections, media)
ChartsRecharts 3
Mapsmaplibre-gl 4.7, lazy-loaded, OpenFreeMap tiles
Spreadsheetsexceljs 4.4, lazy-loaded. CSV is handled in-house.
FontsSelf-hosted Geist, Geist Mono, JetBrains Mono, Space Grotesk, IBM Plex Sans Arabic
TestsVitest 3.2: 12 unit files for pure logic, no component tests
DeployVercel, tsc -b && vite build into dist/, SPA rewrite

Source layout

src/
  main.tsx        Theme → Query → Auth → I18n → DirectionBridge → Router → App (+ Toaster)
  App.tsx         route table
  api/            client.ts (clients, refresh, unwrap, ApiError) · hooks.ts (~4,800 lines)
                  types.ts · tokens.ts · sso.ts
  auth/           AuthProvider · ShopAccessGate
  shop/           ShopProvider (selected shop + the user's access to it)
  i18n/           I18nProvider · copy.ts (~1,670 strings × 2 languages)
  theme/          ThemeProvider (light / dark / system)
  lib/            pure helpers: format, import/export, discounts, orders, analytics,
                  urlState, phone, timezones, jarde sheet parsing
  components/
    ui/           shadcn primitives
    common/       DataTable, FilterBar/FilterDrawer, Panel, Kpi, Pill, Modal, Combobox,
                  DatePicker, PhoneField, TagField, RowActions, QueryState
    order/ overview/ reports/ assistant/
    ProductPicker, CustomerPicker, CategoryBrowser, *ScopePicker, VariantEditor,
    MediaUploader, FieldDefinitions, AddressSearch, LocationMap, …
  pages/          one file per route
  styles/         index.css (tokens) · print.css (packing slip, invoice, reports)

Data access

  • Pages only call hooks from api/hooks.ts. Each hook does unwrap(await shopApi.x({ body })).
  • One query-key object (qk, hooks.ts:133). List keys include page, search, filters and sort. Lists use placeholderData to keep the previous page visible.
  • Mutations invalidate the related keys on success.
  • Every request carries x-language-id, taken from get-languages rather than hard-coded. Switching language invalidates everything, because names come back localized.
  • URL state (lib/urlState.ts): list page, search, sort and filters live in the query string. The URL is replaced rather than pushed, and defaults are omitted.
  • Order and return creation send a once-per-form idempotency key.

Auth & shop selection

  • Email OTP sign-in, token storage and refresh: see Auth.
  • ShopProvider loads get-user-shops (owned or staffed shops, with per-shop access). It remembers the selection under totlob.shop.v1 and reuses it only if it's still listed.
  • No shops shows CreateShop: one call creates an unnamed shop and default location, and the merchant names it on Store profile.
  • Launch POS / Accounting (api/sso.ts) issues a handoff code and opens the target with #sso=.

Gating

  1. Who. canAccess (components/nav.ts:250): owners see everything, and staff need the route's permission. ShopAccessGate wraps every route and redirects to the first reachable page, or shows "no access". The sidebar uses the same function.
  2. Which apps. Nav items with requiresApp (Reports, Stock count) stay hidden until get-installed-apps includes the app.

Internationalization

  • I18nProvider with English and Arabic. Arabic is type-checked against English (copy.ts:3168), so a missing translation fails the build.
  • Sets <html dir>. DirectionBridge passes the direction to Radix, the layout uses logical properties, and the sidebar flips to the right.
  • Numbers and dates are formatted with ar-LB in Arabic (lib/format.ts:173). PIN digits stay left-to-right.
Overview in Arabic: right-to-left layout, mirrored sidebar, ar-LB number formatting.
Overview in Arabic: right-to-left layout, mirrored sidebar, ar-LB number formatting.

Development notes

  • vite.config.ts proxies /merchant and /health to VITE_API_PROXY_TARGET, because the API has no CORS for this origin. @totlob/contracts-merchant is excluded from pre-bundling.
  • In production the dashboard expects the same origin as the API, or a reverse proxy. Dashboard ADR 0001 places it at app.totlob.com, with marketing at business.totlob.com (a separate Next.js app).
  • Scrolling over a focused number input is blocked globally (main.tsx:36-66).
  • Unused code: useGuide and components/ScopeRail.tsx.
Previous
Integrations & environment