Point of sale

POS architecture

totlob-pos is a Vite + React 19 till. It has no router: a small state machine picks the screen. A Zustand store holds the session and the basket, and every API call is typed from contracts-pos.

The Terminal screen: browse column (CategoryPane), product grid (CatalogPane), cart (CartPane), with the NavRail on the left.
The Terminal screen: browse column (CategoryPane), product grid (CatalogPane), cart (CartPane), with the NavRail on the left.

Stack

ConcernChoice
BuildVite 8, TypeScript ~6 (strict, erasableSyntaxOnly, verbatimModuleSyntax)
RoutingNone. App.tsx picks the screen from state. vercel.json rewrites every path to index.html.
Client stateZustand 5: useAuth (persisted, totlob-pos-session, schema v1 with a migration) and useCart (memory only)
Server stateTanStack Query 5. Queries retry once with no focus refetch; mutations never retry. Each hook sets its own staleTime.
APIts-rest client from @totlob/contracts-pos (zod 3.25). All routes are POST /pos/v1/*.
UIshadcn (radix-nova, neutral), Radix, Tailwind 4.3, lucide-react, sonner, input-otp
FontsGeist / Geist Mono (app), Space Grotesk / JetBrains Mono (sign-in), bundled via @fontsource so the till opens without the network
Moneylib/money.ts: BigInt at scale 6, half-up rounding, fixed formatting. Symbols for USD/EUR/GBP only; other currencies show their code.
Lintoxlint (react, typescript, oxc). rules-of-hooks is an error.
TestsNone.
DeployVercel. npm run build = tsc -b && vite build. Dev server on 5273.

Screen state machine

SignIn ──► TillPicker ──► DrawerGate ──┬─► Terminal (Sell)          ← drawer open
                                       ├─► OpenDrawer               ← no drawer
                                       ├─► CloseDrawer / DrawerHistory
                                       └─► Blocked (retry / sign out / other-branch)

DrawerGate (in App.tsx) treats the open drawer as server truth and never persists it.

  • If the cashier's drawer is open at a different branch than the saved till, the till switches to that branch and shows a toast.
  • If that branch is no longer in the till list, a close-only screen says "Your drawer is at another branch".
  • A till-list failure shows a retry screen, so a network blip isn't mistaken for a revoked branch.

Source layout

src/
  main.tsx        QueryClient defaults; awaits the SSO handoff before first render
  App.tsx         screen state machine, DrawerGate, Blocked screens
  api/client.ts   ts-rest client, fetcher, shared refresh-once, unwrap, ApiError
  api/hooks.ts    every query & mutation
  state/auth.ts   tokens, email, selected till (persisted)
  state/cart.ts   lines, modifiers, notes, promo, manual discount, customer; priceCart()
  state/sso.ts    #sso= handoff exchange
  lib/            money, tender labels, phone, customer display, time, section colours
  components/     ~26 feature components + ui/ primitives
docs/adr/         0001 blind count · 0002 Whish = wallet tender
CONTEXT.md        glossary: Till, Cashier, Shift/Drawer, Float, Variance, Tender, Taken…

Endpoints used

GroupEndpoints
AuthsendOtp, login, refreshToken, redeemHandoffCode (logout is never called)
TillsgetTills
ShiftsgetOpenShift, openShift, closeShift, recordCashMovement, getMyShifts, getShift, getShiftSales, getExpenseCategories
CataloguegetCategories, getCatalog, lookupBarcode, getModifierGroups
SalescreateOrder, verifyDiscountPin
CustomerssearchCustomers (infinite), findCustomersByPhone, createCustomer, updateCustomer
Till sectionsget, create, update, delete, reorder, move, add products, remove products

Caching

QuerystaleTimeWhy
Open shift∞, invalidated explicitlyServer truth, changes only through the till's own actions
Closed shifts∞Immutable
Catalogue15 s, keep previousStock badges stay fresh. Invalidated after every sale.
Categories5 min
Modifier groups1 hRarely change
Till sections60 s, no placeholderAvoids taps landing on the previous level's tiles
Customer search30 s, no retry on 4xx
Expense categoriessession
Barcode lookupmutationScanning twice adds twice

Persistence

KeyContents
totlob-pos-sessionTokens, email, selected till
totlob-pos-themeLight / dark / system
totlob-pos:browse:<shopId>:<locationId>Sections vs All products, per till

The cart is deliberately not persisted: the code notes that held sales need a backend. There's no IndexedDB, service worker or PWA manifest on master.

Error handling

  • All mutations have retry: false. Errors show inline as destructive Alerts in the relevant dialog.
  • Dialogs can't be dismissed while a request is in flight.
  • Specific messages:
    • 409 on open-drawer explains the drawer may be open at another shop.
    • A PIN 429 shows the server's message; a 403 shows "That PIN is not valid".
    • A refused sale shows the server's reason (e.g. stock) in the Charge dialog.
  • An HTML or non-JSON response becomes { code: 'internal', message } rather than crashing.

Configuration notes

  • The dev server proxies /pos and /health to VITE_API_PROXY_TARGET (default http://localhost:3000).
  • client.ts falls back to http://localhost:3000 when VITE_API_BASE_URL is unset. The comments in client.ts and vite.config.ts describe a same-origin default instead, so they're out of date.
  • .env is committed and points the build at the production API cross-origin, which needs CORS_ORIGINS on the API.
  • scripts/style-gate.mjs fails on retired pre-shadcn class tokens. It isn't wired into package.json.
Previous
Routes & screens