Narong's feedback: "I select a 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, hence the gap I mentioned." Ideal UX: orders as tabs at the top so you can switch between order-list and product-grid, create a new order while another is on-going, switch back and forth, and see status (pending/on-going, paid, refunded). Both tiers should share the experience.
<PosWorkspace> component that owns the tab strip + multi-tab state + History tab. No migration; cafe.draft_orders already had everything we needed.
PosWorkspace and using React key={activeTabId} + auto-save instead of a destructive view swap.canParkDrafts: false hid drafts entirely. Now true with the same handlers Pro uses; the underlying cafe.draft_orders table is tier-agnostic.[Order 1] [Order 2] [+ New] · · · [History]. Amber dot = on-going, click switches active cart, X closes, "+" mints fresh, "History" swaps to paid/refunded list.cafe.draft_orders + cafe.orders schema verified intact on local DB.Before:
<LockableShell>
if (view === "orders") return <OrdersView />; // ← unmounts RegisterShell
return <RegisterShell> { cart: useState() } </RegisterShell>;
^^^^^^^^
state destroyed on view switch
Bug flow:
1. User adds items to cart → cart = [A, B, C]
2. User clicks "Orders" → setView("orders") → RegisterShell UNMOUNTS
3. User clicks "+ New" → dispatches nix-cafe:add-new-order event
4. setView("register") → RegisterShell REMOUNTS with empty cart
5. Event fires on the new shell → onAddNew() sees cart=[] → bails
6. User's [A, B, C] is gone. No park, no draft, nothing.
After:
<PosWorkspace> (NEW)
state: { tabs: Map<id, {lines, customer, ...}>, activeTabId, mode }
auto-save: debounced 500ms per active tab to cafe.draft_orders
<PosTabStrip /> (always visible)
if (mode === "history") <OrdersHistoryView />
else <RegisterShell key={activeTabId} initialCart={tabs[activeTabId].lines} />
^^^^^^^^^^^^^^^^^^
remount per tab, but state lives in workspace
Fix flow:
1. Add items → onCartChange propagates to workspace.tabs[active].lines
2. Click "+ New" → workspace mints new tab id, activeTabId = new
3. RegisterShell key changes → remounts with empty cart
4. The old tab's lines are STILL in workspace.tabs[old.id]
5. Click "Order 1" tab → activeTabId = old → remount with [A, B, C]
6. No data loss across tab switches.
Before (starter-register-mount.tsx):
const STARTER_CAPABILITIES = { canParkDrafts: false, ... };
// No saveDraft handler — gated by canParkDrafts.
// Starter top-bar Orders button → router.push("/cafe/orders") (LEAVES POS)
// Starter lockable Orders button → window.open("/cafe/orders") (NEW TAB)
After:
const STARTER_CAPABILITIES = { canParkDrafts: true, ... };
// saveDraft + deleteDraft handlers added (identical to Pro — cafe.draft_orders
// is tier-agnostic, no Odoo dependency).
// Starter no longer needs a route-away Orders button — tabs + History live
// inside the workspace.
Result: one POS UX. Whether the cashier opens the in-app /cafe/pos or the
lockable /cafe/pos/register/N, on Starter or Pro, they see the same tab
strip with the same multi-order semantics. The tier difference is now
strictly about the data backend (cafe.orders vs Odoo pos.order), not the
cart UX.
NEW:
app/(authed)/pos/_components/pos-workspace.tsx (~330 LOC)
Tab orchestrator. Owns tabs[], activeTabId, mode (tabs|history).
Auto-save with debounce + flush-on-switch. Renders tab strip + active
cart or history view.
app/(authed)/pos/_components/pos-tab-strip.tsx (~110 LOC)
Horizontal tab buttons. Each shows label + amber dot + line/total
count. "+ New" mints empty tab. "History" toggles mode.
app/(authed)/pos/_components/orders-history-view.tsx (~480 LOC)
Extracted from the old orders-view.tsx, draft/active filter dropped.
Paid/refunded/cancelled/partial list with per-line refund + receipt
print.
MODIFIED:
app/(authed)/pos/_components/register-shell.tsx
Now controlled: cart + customer come in via props; onCartChange /
onCustomerChange report up. Removed drafts state, drafts-strip,
park/resume/discard handlers, nix-cafe:* event listeners.
app/(authed)/pos/_components/pro-register-mount.tsx
app/(authed)/pos/_components/starter-register-mount.tsx
Wrap RegisterShell with <PosWorkspace>. Both accept initialDrafts +
sessionOrders props from the page-level fetcher.
app/(authed)/pos/_adapters/starter-handlers.ts
+ saveDraft / deleteDraft (identical to Pro). + normalizeStarterDrafts.
app/(authed)/pos/_components/starter-register-mount.tsx
capabilities.canParkDrafts: false → true.
app/(authed)/pos/_components/starter-top-bar.tsx
app/(authed)/pos/_components/starter-lockable-shell.tsx
Dropped the Orders button that routed away from POS. History now lives
inside the tab strip.
app/(pos-fullscreen)/pos/register/[configId]/lockable-shell.tsx
Dropped the view-state toggle + Orders TopBar button. PosWorkspace
handles in-shell history. The unmount-on-switch bug is gone.
app/(authed)/pos/page.tsx
app/(authed)/pos/starter-register-page.tsx
app/(pos-fullscreen)/pos/register/[configId]/page.tsx
Server-side fetch of initialDrafts (listDraftsForSession) +
sessionOrders (listOrdersForSession) for all four POS surfaces.
app/(authed)/pos/_components/cart.tsx
Dropped the draftsStrip slot (replaced by top tab strip).
DELETED:
app/(authed)/pos/_components/drafts-strip.tsx
app/(pos-fullscreen)/pos/register/[configId]/orders-view.tsx
NO MIGRATION. cafe.draft_orders + cafe.orders already carry every column
the workspace needs (R9.3 reserved order_number + sequence_no on drafts;
state enum on orders covers paid/refunded/partial_refund/cancelled).
The Gate 1 local test (test-pos-tabs-local.mjs):
01 /cafe/login returns 200 (auth surface).
02 /cafe/pos returns 307 (unauthed → login, not 5xx).
03 /cafe/pos/register/1 returns 307 (lockable route compiles).
04 /cafe/pos/register/9999 also 307 (auth gate runs before configId
lookup — no 500 on missing config).
05 Login page renders end-to-end with the new component bundle (a
Webpack failure on any of the new files would surface here).
06 Schema smoke — cafe.draft_orders columns present (id, tenant_id,
session_id, label, payload, order_number, sequence_no, updated_at,
created_at) + cafe.orders.state + cafe.orders.order_number.
The full multi-tab UX flow (add tab, switch + verify cart preserved,
auto-save, History tab) needs a logged-in session, which needs the full
Commerce SSO local stack. We took the same shape that the mobile-
responsive session used (workflow precedent in
project_session_2026_05_12_perf_and_mobile.md): typecheck + route smoke
on local, full Playwright at Gate 2 on prod. Cleaner — prod has real
Odoo + real sessions + the Cloudflare Workers runtime where this code
actually deploys.
1. Single cafe commit: feat(pos): multi-order tabs + history view on both tiers.
2. Push origin karouna-dev. Cloudflare auto-rebuilds Cafe Worker (~60-90s).
3. Wait for deploy (ScheduleWakeup ~90s).
4. Prod test (test-pos-tabs-prod.mjs against get-coffee for Pro + flip to
Starter via cafePlanController helper):
01 Schema check — no migration; just assert columns present
02 Pre-flight — config 7 has no open session
03 SSO login as get-coffee owner
04 Create test cashier, capture PIN
05 Open popup terminal at /cafe/pos/register/7
06 PIN unlock → PreShift
07 Open shift with $0 beginning cash
08 Verify tab strip visible: 1 active tab "Order 1", "+", "History"
09 Add an item → tab auto-saves; tab badge shows line count
10 Click "+" → new tab "Order 2" mints; tab strip shows both
11 Switch back to "Order 1" → cart restored; switch to "Order 2" →
cart restored (the Pro bug fix verified)
12 Pay from "Order 1" → SuccessModal renders; close; tab removed
13 Click "History" → paid order appears with green Paid chip
14 Refund one line from History → state flips to partial_refund chip
15 Flip tenant to starter via planCtrl.flipToStarter() and re-run
9-11 to prove Starter has the same tabs (the closed gap)
16 planCtrl.restore() (always-on, in process.on('exit'))
17 Cleanup — force-close session, deactivate cashier, remove drafts
5. Regression sweep (sequential per workflow R8.2):
phase1 + phase2-sso-outdoor + phase2-cafe-multishop + m1 + r7 + r8
+ r9 + r10 = ~95 checks. r9 covers the old orders-view code path
which we replaced — must still pass.
6. Publish prod gallery, stop for approval.