Shipped 2026-05-13 on prod. Narong's feedback: "I select few products, then I want to start a new order, it resets the order, but I lost the previous on-going order. On Starter we can't even create new order." Direction: orders as tabs at top so cashier can switch between order-list and product-grid, create new orders without losing on-going ones, switch back and forth, see status. Both tiers share one experience. The Pro bug + the Starter gap are now structurally closed via a single new <PosWorkspace> component that wraps RegisterShell on all four POS surfaces.
view==='orders' branch unmounted RegisterShell, destroying cart state). With <PosWorkspace> owning the cart state above the shell, tab switches preserve every cart.cafe.draft_orders on every cart edit. Park button retired — the cashier never thinks about saving.STARTER_CAPABILITIES.canParkDrafts: true, saveDraft/deleteDraft handlers added (identical to Pro — table is tier-agnostic). Route serves on Starter without 5xx. Same UX, two backends.Narong flagged four UX issues on the first ship; all addressed in commits
7b3fc28 + c82138c (the squash-fix follow-up).
(1) Tab strip height was inconsistent
Buttons mixed `py-1` with no fixed height → cart tabs vs. New /
History rendered at slightly different visual heights.
Fix: every tab + New + History is a uniform `h-9` (36px). Strip
has no vertical padding — buttons define the height. `shrink-0`
on the strip so RegisterShell's `h-full` underneath can't collapse
it (this DID happen on first push — caused a 13/19 regression
because the strip squashed to 0 and product-grid intercepted
clicks on the now-invisible "+ New" button).
(2) History button felt detached / not a tab
Was styled like a standalone <Button> with a `flex-1` spacer
pushing it far right. Cashier mental model: "left = orders, right
= a button I'm not sure about".
Fix: History is now a tab styled identically to cart tabs (same
height, same border-on-active, no fill background). Uses a small
Receipt icon + "All orders" label so it reads as a destination,
not an opaque button. `ml-auto` pushes it right but visuals match.
(3) Going back from History wasn't obvious
All cart tabs rendered with their inactive styling in history mode,
looking muted/disabled.
Fix: active state now uses a 3px green bottom underline (no fill).
Inactive tabs keep their amber dot + line count + total — they
look "click to resume" not "greyed out". Plus a `Resume {label}`
tooltip on hover.
(4) Refund button was buried
Was at the bottom-right of the detail panel — required 4 actions
(click row → click line → numpad enables → scroll/look for Refund).
Fix: large primary-red `Refund` button now in the detail header,
right of the order number + state chip. Always visible the moment
a paid order is selected. Disabled until at least one line is
marked. Helper text "Tap a product below to mark it for refund"
replaces the previous instruction. A live "Refund total" appears
next to the button as lines are selected.
The state model + every test ID is unchanged, so the existing
test-pos-tabs-prod.mjs continues to verify 19/19 end-to-end.
233711c feat(pos): multi-order tabs + history view on both tiers
+ 3 new components: PosWorkspace (tab orchestrator + auto-save),
PosTabStrip (horizontal strip with status dots), OrdersHistoryView
(extracted from old orders-view, dropped Active/Paid filter — drafts
are tabs now).
- DELETED: drafts-strip.tsx, orders-view.tsx (superseded).
~ RegisterShell now controlled (cart + customer via props); pro + starter
mounts wrap with PosWorkspace; canParkDrafts: true on Starter; all 4
POS pages now fetch initialDrafts + sessionOrders server-side.
eb87439 fix(pos): break workspace<>shell render loop
Workspace passes inline handleCartChange whose identity changed every
render. RegisterShell's useEffect depended on onCartChange, so every
workspace re-render re-fired the callback, mutated tabs, triggered
another render — infinite loop that also reset the auto-save debounce
timer before it could fire (i.e. drafts never persisted). Fix: ref-
based callback in shell (deps now just [cart]) + useCallback wraps on
all workspace handlers with stable deps + activeTabIdRef so handlers
read latest active id without stale-closure issues.
eca36bf fix(pos): refresh sessionOrders after pay
router.refresh() in handlePaySuccess + handleHistory so the History
tab sees the just-paid order without a manual reload (server-rendered
prop, not auto-fetched on client).
e14e721 fix(pos): decouple tab client id from server draft id
Tab had a single `id` field that swapped from `tmp-...` to the canonical
cafe.draft_orders.id on first auto-save. That swap changed the React
`key={activeTabId}` on RegisterShell, unmounting the shell mid-pay if
the user clicked cart-pay before the auto-save landed (the order's
fetch was in-flight on a destroyed shell — onSuccess fired but its
setLastOrder never reached a mounted modal). Fix: tab.id stable, new
tab.serverId tracks the canonical draft id. RegisterShell never
remounts on auto-save.
BEFORE:
<LockableShell>
if (view === "orders") return <OrdersView />; // ← unmounts shell
return <RegisterShell> { cart: useState() } </RegisterShell>;
^^^^^^^^
state destroyed on view switch
1. User adds items to cart → cart = [A, B, C]
2. Click "Orders" → setView("orders") → RegisterShell UNMOUNTS
3. Click "+ New" → dispatches nix-cafe:add-new-order event
4. setView("register") → RegisterShell REMOUNTS with empty cart
5. Event fires on new shell → onAddNew() sees cart=[] → bails
6. User's [A, B, C] is gone.
AFTER:
<PosWorkspace>
state: { tabs[], activeTabId, mode }
<PosTabStrip /> always visible
mode === "history" ? <OrdersHistoryView />
: <RegisterShell key={activeTab.id} />
- Each tab has a STABLE client id (never changes); state lives in tabs[].
- "+ New" mints a fresh tab synchronously and switches activeTabId.
- Switching tabs: workspace flushes outgoing tab's save in the
background (fire-and-forget), changes activeTabId, key changes,
RegisterShell remounts with new tab's cart from workspace.tabs[].
- Auto-save debounced 500ms; no Park button needed.
Three subtle bugs that only surfaced under prod traffic:
(1) Workspace ↔ shell render loop (commit eb87439)
Workspace's inline `handleCartChange` had new identity each render.
Shell's useEffect depended on `onCartChange`, so every workspace
re-render re-fired the callback. The callback called setTabs, which
re-rendered workspace, which created a new handleCartChange identity,
which re-fired the callback. Symptom: scheduleSave's debounce timer
kept getting reset before firing → drafts never persisted → the test
couldn't find a draft row after auto-save was supposed to land.
Fix: ref-based callback in shell (deps just [cart]); useCallback in
workspace with stable activeTabIdRef.
(2) History didn't refresh after pay (commit eca36bf)
Workspace's sessionOrders is a server-rendered prop. After pay, the
new paid order existed on the server but the prop didn't update on
the client. Click "History" → see the old list. Fix:
router.refresh() in handlePaySuccess + handleHistory.
(3) Auto-save remounted shell mid-pay (commit e14e721)
Tab id swapped from tmp-... to canonical UUID on first save. That
changed React's `key={activeTabId}` on RegisterShell. If pay was
in-flight at that moment, the shell unmounted; submitOrder's
onSuccess fired on a destroyed component; setLastOrder never
reached a mounted modal. Symptom: the test waited 25s for the
success modal that would never appear — also broke r9/r10's pay-
cash regression tests until the fix landed. Fix: decouple tab.id
(stable client id) from tab.serverId (canonical draft id, mutable).
All three were React-state-management bugs, not POS-logic bugs. The
underlying cart + payment flow was unchanged from R5/R9/R10. The
takeaway: lifting state into a wrapper component requires careful
audit of callback identity + React key stability — both can cause
silent regressions that only manifest under prod-cold-Worker latency.
| test-pos-tabs-prod.mjs (new) | 19/19 |
| test-phase1-prod.mjs | 11/11 |
| test-phase2-sso-outdoor-prod.mjs | 6/6 |
| test-phase2-cafe-multishop-prod.mjs | 6/6 |
| test-m1-prod.mjs | 10/10 |
| test-r7-prod.mjs | 14/14 |
| test-r8-prod.mjs | 4/4 |
| test-r9-prod.mjs (stale UI: park-btn, orders-btn, filter) | 7/16 |
| test-r10-prod.mjs (stale UI: orders-btn) | 15/16 |
01 Schema preflight (cafe.draft_orders + cafe.orders columns)
02 Force-close any leftover open session on config 7
03 SSO login as get-coffee owner (narongix@gmail.com)
04 Create test cashier "Tabs Test {ts}" → capture PIN
05 Open /cafe/pos/register/7 in popup terminal
06 PIN unlock → PreShift → open shift with $0
07 Tab strip visible: 1 active tab + "+ New" + "History"
08 Click priced product tile (Bread $5) twice → tab line count updates +
draft row materializes within 8s
09 Click "+ New" → second tab mints synchronously (the Pro bug fix —
cart no longer lost)
10 Switch to first tab → cart shows the items again
11 Switch to second tab → cart empty
12 Pay from first tab → SuccessModal renders → close → tab removed,
Order 2 stays active
13 Click "History" → paid order visible with green Paid chip (polled
up to 10s for router.refresh to land)
14 Click order → click first line → click Refund → state flips to
partial_refund on server
15 Force-close shift to set up Starter pass
16 Flip tenant to cafe_starter via planController helper
17 Starter pass: synthetic configId for shop UUID, route serves with
200/307 (no 5xx) — structural verification that PosWorkspace mounts
on Starter without crashing
18 Starter capability flag: /cafe/pos serves on Starter (no 5xx)
19 Restore tier to cafe_pro + close session + delete drafts + deactivate
test cashier