← All tasks

v0.2 Slice G — Customer Display PROD · GATE 2

Shipped 2026-05-16. Customer Display now supports a customer scanning a QR on their phone — cashier writes cart/idle/paid to a new cafe.display_states table; display polls every 1500ms in addition to the existing same-origin BroadcastChannel. Settings/Display gains a slideshow on the idle screen (toggle / 3-5-7s speed / multi-image upload). Three POS toolbars now open a mode-picker modal instead of firing window.open directly.

10/10 prod test · 51/51 regression green — 61/61 total. No regressions across the standard sweep. End-to-end polling round-trip verified on get-coffee Pro (POST + GET /cafe/api/display/<sessionId>/state?secret=… with the same cart payload survived the jsonb round-trip), settings slideshow round-trips both tiers, modal opens with both CTAs.

What landed on prod

migrationcafe.display_states + 3 slideshow cols

One idempotent migration applied to prod Supabase via node migrate.js from local. Verified on prod: cafe.display_states (3 cols, FK confdeltype='c' = ON DELETE CASCADE on cafe.sessions) + cafe.tenant_config 3 slideshow cols (images jsonb NULL, enabled NOT NULL DEFAULT false, speed NOT NULL DEFAULT 5) + the cafe_tenant_config_slideshow_speed_check CHECK constraint.

H3.2Cross-device transport — hybrid (BC + polling)

Cashier publishes to BroadcastChannel AND mirrors to POST /cafe/api/display/<sessionId>/state (debounced 250ms; the paid event uses fetch keepalive so it survives a tab close). Display subscribes to BC AND polls GET /cafe/api/display/<sessionId>/state?secret=… every 1500ms. Last-write-wins via updated_at. Same-device cashiers keep the snappy UX they have today; QR/remote devices get ≤2s lag.

Verified end-to-end: anonymous POST without secret → 404 (route bypassed middleware cleanly). POST with the correct secret → 200; GET reads back the same cart payload (lines array + subtotal preserved through the jsonb round-trip). Wrong secret → 404 (no leak of which session ids exist).

H3.2Toolbar Display button → DisplayModeDialog

Three POS toolbars (Pro lockable, Starter lockable, Starter in-app) replaced their direct window.open with a shared modal. Two CTAs: "This device" (existing window.open behavior) and "Display QR" (renders a QR encoding the same display URL via the qrcode npm pkg, with a Copy URL fallback). Verified: the modal opens, both testids are reachable, and the QR image renders.

H3.3Slideshow on the idle screen

Settings/Display gets a Slideshow card with toggle + 3s/5s/7s speed pills + multi-image upload + per-image remove + live preview. Existing single promo_image_url stays — toggle picks which renders. Display's idle screen renders a CSS-opacity cross-fade rotation when enabled with at least one image.

Verified end-to-end on get-coffee Pro: seeded 2 https URLs + enabled=true + speed=3 via UPDATE cafe.tenant_config, page re-hydrated correctly (toggle checked, both image rows visible), cleanup restored defaults (enabled=false, images=NULL, speed=5). Lumière Starter same shared form renders the slideshow card identically.

cleanupcloseShiftAction drops the display_state row

Soft-close path explicitly DELETEs the cafe.display_states row so the table doesn't accumulate stale payloads. FK CASCADE catches hard-delete of the session row.

Prod test — 10/10

Raw: result.json

Regression sweep — 51/51 green

test-phase1-prod.mjs11/11
test-phase2-sso-outdoor-prod.mjs6/6
test-phase2-cafe-multishop-prod.mjs6/6
test-m1-prod.mjs10/10
test-r7-prod.mjs14/14
test-r8-prod.mjs4/4

What went sideways during Gate 2 (logged for next time)

First push failed the CF Workers Build. Webpack: display-branding-client.tsx (a "use client" component) imported the runtime constants SLIDESHOW_MAX_IMAGES + SLIDESHOW_VALID_SPEEDS from lib/db/display_branding.ts, which transitively pulls the pg driver — Node-only fs/dns/net/tls failed to resolve in the Edge bundle. The original client had only import type (erased by tsc before webpack), which is why Slice F shipped fine. Mixing a runtime import with the type imports drags the whole module.

Fix: extracted the constants + types to a pure lib/display_branding_constants.ts with no DB imports. Both the client and the DAO now read from it. One follow-up commit (195fb2c) reproduced the build cleanly. Worth a memory entry for the next slice.

Local tsc --noEmit didn't catch it — the typecheck operates on TypeScript source, not webpack's module-resolution. Always trust the CF build over local typecheck for "would this work on Workers" questions.

Files shipped

nix-outdoor-sales-backend — commit a3dbdac
migrations/20260515160000_cafe_display_states_and_slideshow.ts · migrate.js

nix-cafe — commits 6697f48 + 195fb2c (build fix)
New:
lib/display_branding_constants.ts (extracted to fix the build)
lib/db/display_states.ts · app/api/display/[sessionId]/state/route.ts · app/(authed)/pos/_components/display-mode-dialog.tsx
Modified:
lib/db/schema.ts · lib/db/display_branding.ts · lib/actions/display_branding.ts · lib/actions/register.ts · middleware.ts
app/(public-display)/display/[sessionId]/page.tsx · display-client.tsx
app/(authed)/settings/display/display-branding-client.tsx
app/(authed)/pos/_components/register-shell.tsx · starter-top-bar.tsx · starter-lockable-shell.tsx
app/(pos-fullscreen)/pos/register/[configId]/lockable-shell.tsx
Dep: qrcode + @types/qrcode