"use client"; import { useSyncExternalStore } from "react"; const CART_KEY = "ep_cart"; const CART_EVENT = "ep-cart-updated"; const EMPTY_CART: CartItem[] = []; // `variant` is the selected variant's name (products.variants[].name), // undefined for a plain product with no variants. Two lines with the same // `id` but different `variant` are separate cart entries, never merged — // same "distinguish by the full key, not just id" reasoning as the // server-side cart mirror (Customers.ts's cart array, which stores this // same field as `variantName`). export type CartItem = { id: string; qty: number; variant?: string }; function readCart(): CartItem[] { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(CART_KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function writeCart(items: CartItem[]) { window.localStorage.setItem(CART_KEY, JSON.stringify(items)); window.dispatchEvent(new Event(CART_EVENT)); } export function getCartCount(): number { return readCart().reduce((sum, item) => sum + item.qty, 0); } // A line is identified by (id, variant) together, not id alone — the same // product with two different variants selected are separate cart entries. // `variant` undefined on both sides (the common, no-variants case) still // matches by simple equality, so every existing call site that never // passes a variant keeps working unchanged. function sameLine(item: CartItem, id: string, variant: string | undefined): boolean { return item.id === id && item.variant === variant; } export function addToCart(id: string, qty = 1, variant?: string) { const items = readCart(); const existing = items.find((i) => sameLine(i, id, variant)); if (existing) existing.qty += qty; else items.push(variant ? { id, qty, variant } : { id, qty }); writeCart(items); } export function removeFromCart(id: string, variant?: string) { writeCart(readCart().filter((i) => !sameLine(i, id, variant))); } // Called by /bestellbestaetigung once it has captured a snapshot of the // cart to display — there's no real order backend here, so "placing an // order" just means the local cart empties out the same way it would // after a real purchase completes. export function clearCart() { writeCart([]); } // qty <= 0 removes the item outright — the cart page's quantity stepper // never lets the visible count go below 1, but this keeps the function // itself safe to call with any integer without a separate remove path. export function setQuantity(id: string, qty: number, variant?: string) { if (qty <= 0) { removeFromCart(id, variant); return; } const items = readCart(); const existing = items.find((i) => sameLine(i, id, variant)); if (existing) existing.qty = qty; writeCart(items); } // Cached-by-raw-string snapshot, not a fresh JSON.parse() every call — // useSyncExternalStore (below) requires getSnapshot to return the SAME // reference when the underlying data hasn't actually changed, or React // treats every render as a change. readCart()'s plain JSON.parse would // allocate a new array each call and break that. let cachedRaw: string | null | undefined; let cachedItems: CartItem[] = EMPTY_CART; export function getCart(): CartItem[] { if (typeof window === "undefined") return EMPTY_CART; const raw = window.localStorage.getItem(CART_KEY); if (raw === cachedRaw) return cachedItems; cachedRaw = raw; try { const parsed = raw ? JSON.parse(raw) : EMPTY_CART; cachedItems = Array.isArray(parsed) ? parsed : EMPTY_CART; } catch { cachedItems = EMPTY_CART; } return cachedItems; } function subscribe(onStoreChange: () => void) { window.addEventListener(CART_EVENT, onStoreChange); window.addEventListener("storage", onStoreChange); return () => { window.removeEventListener(CART_EVENT, onStoreChange); window.removeEventListener("storage", onStoreChange); }; } // useSyncExternalStore, not useState+useEffect — localStorage is an // external store outside React, and the previous approach (setState // synchronously inside an effect body) causes an extra render and trips // the react-hooks/set-state-in-effect lint rule. This is React's own // recommended pattern for subscribing to exactly this kind of external // store, and is correctly SSR-safe via the third (server snapshot) // argument — 0 / EMPTY_CART until the client hydrates and reads // localStorage for real. export function useCartCount(): number { return useSyncExternalStore(subscribe, getCartCount, () => 0); } export function useCart(): CartItem[] { return useSyncExternalStore(subscribe, getCart, () => EMPTY_CART); } // Called right after a successful login (LoginForm.tsx, CheckoutContent.tsx's // inline login toggle) — folds whatever was saved server-side into the // local cart by quantity (addToCart adds to an existing line rather than // overwriting it), so items added as a guest before logging in aren't lost. // This only stays correct because LogoutButton.tsx clears the local cart on // logout — the local cart is always either empty (no guest additions since // the last logout) or holds only genuinely new guest-session items, never a // stale copy of what's already in the server cart, so this add never // double-counts. // // Always pushes the resulting cart to the server itself at the end, // explicitly — does NOT rely on CartSync's own change-triggered push. // That was the bug (confirmed live 2026-08-01): logging in with an empty // server cart but a non-empty guest cart means the merge loop below never // actually runs (nothing in `data.cart` to add), so `writeCart` is never // called, no change event fires, and CartSync's effect — which only // re-fires when the cart *reference* actually changes — never pushes the // guest cart to the server at all. The cart looked fine on the device // that was already logged in, but never reached any other device. An // explicit push here doesn't depend on anything having changed. export async function mergeServerCartIntoLocal(): Promise { try { const res = await fetch("/api/account/cart"); if (res.ok) { const data: { cart?: CartItem[] } = await res.json(); for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant); } } catch { // Best-effort — a failed merge just means the server-side cart stays // as it was; nothing local is lost either way. Still fall through to // push below — the local (guest) cart is genuine either way. } try { await fetch("/api/account/cart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cart: readCart() }), }); } catch { // Best-effort — CartSync's own push will retry on the next cart // change, or the next login-time merge tries again. } } // Cross-device sync for an *already* logged-in session — CartSync.tsx // only ever pushes local→server, and mergeServerCartIntoLocal() above only // ever runs at the moment of a fresh login/register, so a customer already // signed in on device B never picked up a change made on device A. This // covers that gap: called on mount and on window focus (CartSync.tsx), not // just at login. // // Replaces the local cart outright with the server's — NOT an additive // merge like mergeServerCartIntoLocal() above (that one is only safe // right after login, when the local cart can't yet overlap the server // one, see its own comment). An earlier version of this function only // added/updated matching lines and never removed a local line the server // no longer had, which meant a *removal* on device A never reached device // B (confirmed live 2026-08-01 — adding synced, removing didn't). Server // wins outright, including "server cart is now empty." // // Trade-off: a local addition made in the last few hundred ms — after // this fetch went out but before CartSync's own 800ms debounce pushed it // — could theoretically get overwritten by a pull that lands in between. // Narrow enough (and self-healing on the next change/focus) to accept, // consistent with every other "best-effort" sync path in this file. export async function pullServerCart(): Promise { try { const res = await fetch("/api/account/cart"); if (!res.ok) return; const data: { cart?: CartItem[] } = await res.json(); writeCart(data.cart ?? []); } catch { // Best-effort — a failed pull just means this device doesn't see // another device's changes yet; nothing local is lost either way. } }