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.
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.
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.
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).
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.
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.
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.
Raw: result.json
| 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 |
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.
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