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
| Concern | Choice |
|---|---|
| Build | Vite 6.4, TypeScript 5.7 (strict, noUncheckedIndexedAccess), Node ≥ 20 |
| Routing | react-router-dom 7, BrowserRouter, nested routes in src/App.tsx |
| Server state | TanStack 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 |
| Forms | Plain useState seeded once from the loaded record. No form library. Zod types come from the contracts. |
| UI | shadcn/ui (radix-nova, rtl: true), Radix, Tailwind 4.3, lucide-react, cmdk, vaul, sonner, input-otp, react-day-picker 10 |
| Tables | TanStack Table v9, with features declared once in components/common/DataTableFeatures.ts |
| Drag & drop | @dnd-kit (sections, media) |
| Charts | Recharts 3 |
| Maps | maplibre-gl 4.7, lazy-loaded, OpenFreeMap tiles |
| Spreadsheets | exceljs 4.4, lazy-loaded. CSV is handled in-house. |
| Fonts | Self-hosted Geist, Geist Mono, JetBrains Mono, Space Grotesk, IBM Plex Sans Arabic |
| Tests | Vitest 3.2: 12 unit files for pure logic, no component tests |
| Deploy | Vercel, 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 doesunwrap(await shopApi.x({ body })). - One query-key object (
qk,hooks.ts:133). List keys include page, search, filters and sort. Lists useplaceholderDatato keep the previous page visible. - Mutations invalidate the related keys on success.
- Every request carries
x-language-id, taken fromget-languagesrather 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.
ShopProviderloadsget-user-shops(owned or staffed shops, with per-shop access). It remembers the selection undertotlob.shop.v1and 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
- Who.
canAccess(components/nav.ts:250): owners see everything, and staff need the route's permission.ShopAccessGatewraps every route and redirects to the first reachable page, or shows "no access". The sidebar uses the same function. - Which apps. Nav items with
requiresApp(Reports, Stock count) stay hidden untilget-installed-appsincludes the app.
Internationalization
I18nProviderwith English and Arabic. Arabic is type-checked against English (copy.ts:3168), so a missing translation fails the build.- Sets
<html dir>.DirectionBridgepasses the direction to Radix, the layout uses logical properties, and the sidebar flips to the right. - Numbers and dates are formatted with
ar-LBin Arabic (lib/format.ts:173). PIN digits stay left-to-right.

Development notes
vite.config.tsproxies/merchantand/healthtoVITE_API_PROXY_TARGET, because the API has no CORS for this origin.@totlob/contracts-merchantis 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 atbusiness.totlob.com(a separate Next.js app). - Scrolling over a focused number input is blocked globally (
main.tsx:36-66). - Unused code:
useGuideandcomponents/ScopeRail.tsx.