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.

Stack
| Concern | Choice |
|---|---|
| Build | Vite 8, TypeScript ~6 (strict, erasableSyntaxOnly, verbatimModuleSyntax) |
| Routing | None. App.tsx picks the screen from state. vercel.json rewrites every path to index.html. |
| Client state | Zustand 5: useAuth (persisted, totlob-pos-session, schema v1 with a migration) and useCart (memory only) |
| Server state | TanStack Query 5. Queries retry once with no focus refetch; mutations never retry. Each hook sets its own staleTime. |
| API | ts-rest client from @totlob/contracts-pos (zod 3.25). All routes are POST /pos/v1/*. |
| UI | shadcn (radix-nova, neutral), Radix, Tailwind 4.3, lucide-react, sonner, input-otp |
| Fonts | Geist / Geist Mono (app), Space Grotesk / JetBrains Mono (sign-in), bundled via @fontsource so the till opens without the network |
| Money | lib/money.ts: BigInt at scale 6, half-up rounding, fixed formatting. Symbols for USD/EUR/GBP only; other currencies show their code. |
| Lint | oxlint (react, typescript, oxc). rules-of-hooks is an error. |
| Tests | None. |
| Deploy | Vercel. 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
| Group | Endpoints |
|---|---|
| Auth | sendOtp, login, refreshToken, redeemHandoffCode (logout is never called) |
| Tills | getTills |
| Shifts | getOpenShift, openShift, closeShift, recordCashMovement, getMyShifts, getShift, getShiftSales, getExpenseCategories |
| Catalogue | getCategories, getCatalog, lookupBarcode, getModifierGroups |
| Sales | createOrder, verifyDiscountPin |
| Customers | searchCustomers (infinite), findCustomersByPhone, createCustomer, updateCustomer |
| Till sections | get, create, update, delete, reorder, move, add products, remove products |
Caching
| Query | staleTime | Why |
|---|---|---|
| Open shift | ∞, invalidated explicitly | Server truth, changes only through the till's own actions |
| Closed shifts | ∞ | Immutable |
| Catalogue | 15 s, keep previous | Stock badges stay fresh. Invalidated after every sale. |
| Categories | 5 min | |
| Modifier groups | 1 h | Rarely change |
| Till sections | 60 s, no placeholder | Avoids taps landing on the previous level's tiles |
| Customer search | 30 s, no retry on 4xx | |
| Expense categories | session | |
| Barcode lookup | mutation | Scanning twice adds twice |
Persistence
| Key | Contents |
|---|---|
totlob-pos-session | Tokens, email, selected till |
totlob-pos-theme | Light / 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 destructiveAlerts 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
/posand/healthtoVITE_API_PROXY_TARGET(defaulthttp://localhost:3000). client.tsfalls back tohttp://localhost:3000whenVITE_API_BASE_URLis unset. The comments inclient.tsandvite.config.tsdescribe a same-origin default instead, so they're out of date..envis committed and points the build at the production API cross-origin, which needsCORS_ORIGINSon the API.scripts/style-gate.mjsfails on retired pre-shadcn class tokens. It isn't wired intopackage.json.