Skip to content

Commit 63e788a

Browse files
committed
Merge branch 'task/sa-table-order-kiosk' into v3-test (round 3 gap closure)
2 parents 6fd3867 + 63dad20 commit 63e788a

25 files changed

Lines changed: 988 additions & 75 deletions

self-order/src/hooks/useOrderingSession.ts

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import {
1313
type PaymentRequestResult,
1414
} from '../lib/api'
1515

16+
// Same keys api.ts uses internally for sessionStorage persistence. api.ts
17+
// doesn't expose a clear function (only get/store), so resetSession clears
18+
// them directly here rather than changing api.ts's exported surface.
19+
const SESSION_KEY = 'ury_order_session'
20+
const CONTEXT_KEY = 'ury_order_context'
21+
1622
type Cart = Record<string, { item: MenuItem; qty: number }>
1723

1824
function useQueryToken(): string | null {
@@ -50,43 +56,80 @@ export function useOrderingSession(initialContext?: OrderingContext) {
5056
setOrder(current)
5157
}, [])
5258

53-
useEffect(() => {
54-
async function init() {
55-
try {
56-
let ctx: OrderingContext
57-
if (initialContext) {
58-
ctx = initialContext
59-
} else if (token) {
60-
ctx = await bootstrap(token)
59+
const init = useCallback(async () => {
60+
setLoading(true)
61+
try {
62+
let ctx: OrderingContext
63+
if (initialContext) {
64+
ctx = initialContext
65+
} else if (token) {
66+
ctx = await bootstrap(token)
67+
} else {
68+
const storedContext = getStoredContext()
69+
if (storedContext) {
70+
// No fresh token in the URL (e.g. a bookmarked/refreshed page)
71+
// — reuse the full context saved at bootstrap time, not just
72+
// the session token, so capabilities/table/layout survive a
73+
// refresh too. The backend still rejects the session once it
74+
// actually expires.
75+
ctx = storedContext
6176
} else {
62-
const storedContext = getStoredContext()
63-
if (storedContext) {
64-
// No fresh token in the URL (e.g. a bookmarked/refreshed page)
65-
// — reuse the full context saved at bootstrap time, not just
66-
// the session token, so capabilities/table/layout survive a
67-
// refresh too. The backend still rejects the session once it
68-
// actually expires.
69-
ctx = storedContext
70-
} else {
71-
setError('This link is missing an ordering code. Please rescan the QR code on your table.')
72-
setLoading(false)
73-
return
74-
}
77+
setError('This link is missing an ordering code. Please rescan the QR code on your table.')
78+
setLoading(false)
79+
return
7580
}
76-
setContext(ctx)
77-
const menuResponse = await getMenu(ctx.session)
78-
setMenu(menuResponse.items.filter((item) => !item.disabled))
79-
await loadOrder(ctx.session)
80-
} catch (err) {
81-
setError(err instanceof Error ? err.message : 'Unable to load the menu. Please rescan the QR code.')
82-
} finally {
83-
setLoading(false)
8481
}
82+
setContext(ctx)
83+
const menuResponse = await getMenu(ctx.session)
84+
setMenu(menuResponse.items.filter((item) => !item.disabled))
85+
await loadOrder(ctx.session)
86+
} catch (err) {
87+
setError(err instanceof Error ? err.message : 'Unable to load the menu. Please rescan the QR code.')
88+
} finally {
89+
setLoading(false)
8590
}
91+
// eslint-disable-next-line react-hooks/exhaustive-deps
92+
}, [initialContext, token])
93+
94+
useEffect(() => {
8695
init()
8796
// eslint-disable-next-line react-hooks/exhaustive-deps
8897
}, [])
8998

99+
/**
100+
* Manual "start a fresh order" action — the MVP alternative to an
101+
* auto-idle-reset timer (not wired up yet). Always clears the cart and
102+
* any in-memory order/payment/bill state, and always clears both
103+
* sessionStorage keys so a stale session/context can never leak into the
104+
* next customer.
105+
*
106+
* For device-bootstrapped sessions (kiosk/tablet — `initialContext` was
107+
* passed to the hook) there's a durable device credential behind the
108+
* context, so we can immediately re-bootstrap a fresh session via `init`
109+
* without sending the customer back through a QR scan.
110+
*
111+
* For QR/link-based sessions there is no device credential to re-derive
112+
* a session from — clearing storage here means the next `init` run finds
113+
* neither a token override nor a stored context, so the hook falls back
114+
* to its normal "missing ordering code" error state and the customer (or
115+
* staff) must rescan/re-open the link. That is the correct outcome, not
116+
* a bug: a QR session's only source of truth is the token in the URL.
117+
*/
118+
const resetSession = useCallback(() => {
119+
sessionStorage.removeItem(SESSION_KEY)
120+
sessionStorage.removeItem(CONTEXT_KEY)
121+
setCart({})
122+
setOrder(null)
123+
setBillRequested(false)
124+
setPaymentRequest(null)
125+
setPayingOnline(false)
126+
setSubmitting(false)
127+
setError(null)
128+
setContext(null)
129+
setMenu([])
130+
init()
131+
}, [init])
132+
90133
function addToCart(item: MenuItem) {
91134
setCart((prev) => {
92135
const existing = prev[item.item]
@@ -172,6 +215,7 @@ export function useOrderingSession(initialContext?: OrderingContext) {
172215
submitCart,
173216
handleRequestBill,
174217
payOnline,
218+
resetSession,
175219
cartItems,
176220
cartCount,
177221
cartTotal,

self-order/src/layouts/LandscapeKioskLayout.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,18 @@ function LandscapeKioskLayout({ initialContext }: LayoutProps) {
3232
submitCart,
3333
handleRequestBill,
3434
payOnline,
35+
resetSession,
3536
cartItems,
3637
cartCount,
3738
cartTotal,
3839
} = useOrderingSession(initialContext)
3940

41+
function handleReset() {
42+
if (window.confirm('Start a new order? Current cart will be cleared.')) {
43+
resetSession()
44+
}
45+
}
46+
4047
if (loading) {
4148
return (
4249
<div className="flex min-h-screen items-center justify-center text-xl text-muted-foreground">
@@ -55,10 +62,16 @@ function LandscapeKioskLayout({ initialContext }: LayoutProps) {
5562

5663
return (
5764
<div className="flex h-screen flex-col overflow-hidden text-lg">
58-
<header className="border-b bg-background/95 px-10 py-6">
65+
<header className="flex items-center justify-between border-b bg-background/95 px-10 py-6">
5966
<h1 className="text-3xl font-semibold">
6067
{context?.table ? `Table ${context.table}` : 'Order for Pickup'}
6168
</h1>
69+
<button
70+
onClick={handleReset}
71+
className="rounded-md border px-4 py-2 text-base font-medium text-muted-foreground"
72+
>
73+
New Order
74+
</button>
6275
</header>
6376

6477
{error && (

self-order/src/layouts/MobileQRLayout.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,18 @@ function MobileQRLayout({ initialContext }: LayoutProps) {
2222
submitCart,
2323
handleRequestBill,
2424
payOnline,
25+
resetSession,
2526
cartItems,
2627
cartCount,
2728
cartTotal,
2829
} = useOrderingSession(initialContext)
2930

31+
function handleStartOver() {
32+
if (window.confirm('Start over? Your current cart will be cleared.')) {
33+
resetSession()
34+
}
35+
}
36+
3037
if (loading) {
3138
return (
3239
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
@@ -43,12 +50,23 @@ function MobileQRLayout({ initialContext }: LayoutProps) {
4350
)
4451
}
4552

53+
// `source` (not the absence of a table) is the authoritative signal for
54+
// pickup mode — set server-side by _verify_qr_token/_resolve_device, never
55+
// guessed from context.table being falsy.
56+
const isPickup = context?.source === 'QR Pickup'
57+
4658
return (
4759
<div className="min-h-screen pb-28">
48-
<header className="sticky top-0 z-10 border-b bg-background/95 px-4 py-3 backdrop-blur">
60+
<header className="sticky top-0 z-10 flex items-center justify-between border-b bg-background/95 px-4 py-3 backdrop-blur">
4961
<h1 className="text-lg font-semibold">
50-
{context?.table ? `Table ${context.table}` : 'Order for Pickup'}
62+
{isPickup ? 'Order for Pickup' : context?.table ? `Table ${context.table}` : 'Order'}
5163
</h1>
64+
<button
65+
onClick={handleStartOver}
66+
className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground"
67+
>
68+
Start Over
69+
</button>
5270
</header>
5371

5472
{error && (
@@ -58,6 +76,11 @@ function MobileQRLayout({ initialContext }: LayoutProps) {
5876
{order && order.items.length > 0 && (
5977
<section className="mx-4 mt-4 rounded-lg border p-3">
6078
<h2 className="mb-2 text-sm font-medium text-muted-foreground">Your order so far</h2>
79+
{isPickup && order.pickup_code && (
80+
<p className="mb-2 rounded-md bg-muted p-2 text-center text-sm font-semibold">
81+
Pickup code: {order.pickup_code}
82+
</p>
83+
)}
6184
<ul className="space-y-1 text-sm">
6285
{order.items.map((row, idx) => (
6386
<li key={`${row.item_code}-${idx}`} className="flex justify-between">
@@ -85,7 +108,7 @@ function MobileQRLayout({ initialContext }: LayoutProps) {
85108
member will assist with payment.
86109
</p>
87110
)}
88-
{context?.capabilities.request_bill_enabled && !order.billed && (
111+
{!isPickup && context?.capabilities.request_bill_enabled && !order.billed && (
89112
<button
90113
className="mt-3 w-full rounded-md border py-2 text-sm font-medium disabled:opacity-50"
91114
disabled={billRequested}

self-order/src/layouts/PortableTabletAssignment.tsx

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,44 @@
11
import { useState } from 'react'
2+
import { assignDeviceTable, type OrderingContext } from '../lib/api'
3+
import TabletLayout from './TabletLayout'
24

35
type Step = 'pin' | 'table'
46

57
const MIN_PIN_LENGTH = 4
68
const MAX_PIN_LENGTH = 6
79

10+
// Same storage keys useDeviceBootstrap.ts uses for a provisioned device's
11+
// credentials — a portable/shared tablet is still a provisioned
12+
// URY Ordering Device (device_type "Portable Tablet", table_mode
13+
// "Selectable"), it just can't bootstrap straight into an ordering context
14+
// the way a fixed-table/kiosk device does, because it has no table until
15+
// staff assign one here.
16+
const DEVICE_ID_KEY = 'ury_device_id'
17+
const DEVICE_CREDENTIAL_KEY = 'ury_device_credential'
18+
819
/**
920
* Standalone screen for the "portable/shared tablet" scenario: staff enters
10-
* a PIN and picks a table before handing the tablet to a customer.
21+
* a PIN and picks a table before handing the tablet to a customer. Calls
22+
* the real `assign_device_table` backend endpoint (device credential +
23+
* staff PIN + table -> a bound ordering session), then hands off straight
24+
* into the normal ordering view via `TabletLayout`'s `initialContext` prop
25+
* — the same pattern `App.tsx` already uses for device-bootstrapped
26+
* sessions, so this screen never re-bootstraps once assigned.
1127
*
12-
* There is currently NO backend endpoint for "staff PIN + table selection
13-
* -> customer session" (only QR-token and device-credential bootstrap
14-
* exist — see `get_ordering_context` in
15-
* ury/ury/ury/api/self_ordering.py). This is therefore a UI shell only:
16-
* any PIN of MIN_PIN_LENGTH+ digits is treated as "accepted" (no real
17-
* verification), and there is no customer-safe "list all tables" endpoint
18-
* either, so the table picker is a free-text field rather than a real
19-
* picker. Submitting does not create a session — it surfaces the gap
20-
* instead of silently pretending to succeed.
28+
* There is no customer-safe "list tables for this branch" endpoint yet, so
29+
* the table picker stays a free-text field (table name/number) rather than
30+
* a real picker — building a new tables-listing endpoint was out of scope
31+
* for this MVP pass. The backend still validates the table exists and
32+
* belongs to the device's branch, so a bad value is rejected, not silently
33+
* accepted.
2134
*/
2235
function PortableTabletAssignment() {
2336
const [step, setStep] = useState<Step>('pin')
2437
const [pin, setPin] = useState('')
2538
const [table, setTable] = useState('')
39+
const [submitting, setSubmitting] = useState(false)
40+
const [error, setError] = useState<string | null>(null)
41+
const [context, setContext] = useState<OrderingContext | null>(null)
2642

2743
function handlePinDigit(digit: string) {
2844
if (pin.length >= MAX_PIN_LENGTH) return
@@ -35,23 +51,50 @@ function PortableTabletAssignment() {
3551

3652
function handlePinSubmit() {
3753
if (pin.length < MIN_PIN_LENGTH) return
38-
// UI shell only — any PIN of sufficient length is treated as accepted.
39-
// There is no real staff-PIN verification endpoint yet.
54+
// The PIN itself is only verified server-side, at assignment time —
55+
// this just advances to table entry once enough digits are entered.
56+
setError(null)
4057
setStep('table')
4158
}
4259

43-
function handleAssign() {
44-
if (!table.trim()) return
45-
// TODO(backend): needs a staff-PIN + table-selection endpoint before
46-
// this can create a real session. Until then, this screen cannot hand
47-
// off a working session to a customer.
48-
window.alert('Backend endpoint not yet available — see TODO in this file')
60+
async function handleAssign() {
61+
if (!table.trim() || submitting) return
62+
63+
const deviceId = localStorage.getItem(DEVICE_ID_KEY)
64+
const deviceCredential = localStorage.getItem(DEVICE_CREDENTIAL_KEY)
65+
if (!deviceId || !deviceCredential) {
66+
setError('This tablet is not provisioned. Please contact staff.')
67+
return
68+
}
69+
70+
setSubmitting(true)
71+
setError(null)
72+
try {
73+
const assigned = await assignDeviceTable(deviceId, deviceCredential, pin, table.trim())
74+
setContext(assigned)
75+
} catch (err) {
76+
setError(err instanceof Error ? err.message : 'Could not assign this table. Please check the PIN and try again.')
77+
setPin('')
78+
setStep('pin')
79+
} finally {
80+
setSubmitting(false)
81+
}
82+
}
83+
84+
if (context) {
85+
return <TabletLayout initialContext={context} />
4986
}
5087

5188
return (
5289
<div className="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
5390
<h1 className="text-xl font-semibold">Assign This Tablet</h1>
5491

92+
{error && (
93+
<div className="w-full max-w-xs rounded-md bg-destructive/10 p-3 text-center text-sm text-destructive">
94+
{error}
95+
</div>
96+
)}
97+
5598
{step === 'pin' && (
5699
<div className="flex w-full max-w-xs flex-col items-center gap-4">
57100
<p className="text-sm text-muted-foreground">Enter staff PIN</p>
@@ -103,13 +146,14 @@ function PortableTabletAssignment() {
103146
/>
104147
<button
105148
onClick={handleAssign}
106-
disabled={!table.trim()}
149+
disabled={!table.trim() || submitting}
107150
className="w-full rounded-md bg-primary py-3 font-medium text-primary-foreground disabled:opacity-50"
108151
>
109-
Assign Table
152+
{submitting ? 'Assigning…' : 'Assign Table'}
110153
</button>
111154
<button
112155
onClick={() => setStep('pin')}
156+
disabled={submitting}
113157
className="w-full rounded-md border py-3 text-sm font-medium"
114158
>
115159
Back

0 commit comments

Comments
 (0)