Add menu item customization/detail pages and configurable currency formatting

Lets customers pick options on a menu item detail page before adding to the
basket, and makes displayed prices respect a configurable currency/locale
via VITE_CURRENCY/VITE_LOCALE. Also crossfades the basket's empty/filled
states with AnimatePresence in wait mode instead of animating both at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:00:45 +02:00
co-authored by Claude Sonnet 5
parent e98b0d86d2
commit d66e09f515
38 changed files with 2099 additions and 229 deletions
+78 -23
View File
@@ -9,71 +9,126 @@ import type { ReactNode } from 'react'
const STORAGE_KEY = 'ghost-kitchen-basket'
export type BasketLine = {
menuItemId: number
optionIds: Array<number>
quantity: number
}
type StoredLines = Partial<Record<string, BasketLine>>
// Two lines with the same Menu Item and the same set of selected Options
// are the same line (their quantities combine) — order of selection
// shouldn't matter, so the key sorts the option ids first.
const lineKey = (menuItemId: number, optionIds: Array<number>) =>
`${menuItemId}:${[...optionIds].sort((a, b) => a - b).join(',')}`
const isBasketLine = (value: unknown): value is BasketLine =>
typeof value === 'object' &&
value !== null &&
typeof (value as BasketLine).menuItemId === 'number' &&
Array.isArray((value as BasketLine).optionIds) &&
typeof (value as BasketLine).quantity === 'number'
type BasketContextValue = {
quantities: Record<number, number>
lines: Array<BasketLine>
itemCount: number
add: (menuItemId: number) => void
remove: (menuItemId: number) => void
add: (menuItemId: number, optionIds?: Array<number>) => void
remove: (menuItemId: number, optionIds?: Array<number>) => void
clear: () => void
}
const BasketContext = createContext<BasketContextValue | null>(null)
const readStoredQuantities = (): Record<number, number> => {
const readStoredLines = (): StoredLines => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<number, number>) : {}
if (!raw) return {}
const parsed: unknown = JSON.parse(raw)
if (typeof parsed !== 'object' || parsed === null) return {}
const result: StoredLines = {}
for (const [key, value] of Object.entries(parsed)) {
// Earlier versions stored a flat `Record<menuItemId, quantity>` with
// no customization — read a leftover value like that as a plain,
// option-free line rather than dropping it.
if (typeof value === 'number') {
const menuItemId = Number(key)
if (!Number.isFinite(menuItemId) || value <= 0) continue
result[lineKey(menuItemId, [])] = {
menuItemId,
optionIds: [],
quantity: value,
}
} else if (isBasketLine(value)) {
result[key] = value
}
}
return result
} catch {
return {}
}
}
export const BasketProvider = ({ children }: { children: ReactNode }) => {
const [quantities, setQuantities] = useState<Record<number, number>>({})
const [storedLines, setStoredLines] = useState<StoredLines>({})
// Read from localStorage after mount, not during initial render, so the
// server-rendered and first client-rendered HTML match (no localStorage on
// the server) and React doesn't flag a hydration mismatch.
useEffect(() => {
setQuantities(readStoredQuantities())
setStoredLines(readStoredLines())
}, [])
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(quantities))
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(storedLines))
} catch {
// Unavailable (private mode, quota) — basket still works for this
// page load, it just won't persist across a reload.
}
}, [quantities])
}, [storedLines])
const add = (menuItemId: number) =>
setQuantities((prev) => ({
const add = (menuItemId: number, optionIds: Array<number> = []) => {
const key = lineKey(menuItemId, optionIds)
setStoredLines((prev) => ({
...prev,
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
[key]: {
menuItemId,
optionIds,
quantity: (prev[key]?.quantity ?? 0) + 1,
},
}))
}
const remove = (menuItemId: number) =>
setQuantities((prev) => {
const current = prev[menuItemId] ?? 0
if (current <= 1) {
const remove = (menuItemId: number, optionIds: Array<number> = []) => {
const key = lineKey(menuItemId, optionIds)
setStoredLines((prev) => {
const current = prev[key]
if (!current) return prev
if (current.quantity <= 1) {
const next = { ...prev }
delete next[menuItemId]
delete next[key]
return next
}
return { ...prev, [menuItemId]: current - 1 }
return { ...prev, [key]: { ...current, quantity: current.quantity - 1 } }
})
}
const clear = () => setQuantities({})
const clear = () => setStoredLines({})
const lines = useMemo(
() => Object.values(storedLines).filter((line) => line !== undefined),
[storedLines],
)
const itemCount = useMemo(
() => Object.values(quantities).reduce((sum, quantity) => sum + quantity, 0),
[quantities],
() => lines.reduce((sum, line) => sum + line.quantity, 0),
[lines],
)
const value = useMemo(
() => ({ quantities, itemCount, add, remove, clear }),
[quantities, itemCount],
() => ({ lines, itemCount, add, remove, clear }),
[lines, itemCount],
)
return (