Files
Ghost_Kitchen/src/lib/basket.tsx
T
lukasandClaude Sonnet 5 d66e09f515 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>
2026-09-10 21:00:45 +02:00

146 lines
4.2 KiB
TypeScript

import {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
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 = {
lines: Array<BasketLine>
itemCount: number
add: (menuItemId: number, optionIds?: Array<number>) => void
remove: (menuItemId: number, optionIds?: Array<number>) => void
clear: () => void
}
const BasketContext = createContext<BasketContextValue | null>(null)
const readStoredLines = (): StoredLines => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
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 [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(() => {
setStoredLines(readStoredLines())
}, [])
useEffect(() => {
try {
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.
}
}, [storedLines])
const add = (menuItemId: number, optionIds: Array<number> = []) => {
const key = lineKey(menuItemId, optionIds)
setStoredLines((prev) => ({
...prev,
[key]: {
menuItemId,
optionIds,
quantity: (prev[key]?.quantity ?? 0) + 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[key]
return next
}
return { ...prev, [key]: { ...current, quantity: current.quantity - 1 } }
})
}
const clear = () => setStoredLines({})
const lines = useMemo(
() => Object.values(storedLines).filter((line) => line !== undefined),
[storedLines],
)
const itemCount = useMemo(
() => lines.reduce((sum, line) => sum + line.quantity, 0),
[lines],
)
const value = useMemo(
() => ({ lines, itemCount, add, remove, clear }),
[lines, itemCount],
)
return (
<BasketContext.Provider value={value}>{children}</BasketContext.Provider>
)
}
export const useBasket = () => {
const context = useContext(BasketContext)
if (!context) {
throw new Error('useBasket must be used within a BasketProvider')
}
return context
}