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>
146 lines
4.2 KiB
TypeScript
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
|
|
}
|