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:
+78
-23
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user