Files
Marco 1ec442cc05 Fix guest cart never reaching the server on login
mergeServerCartIntoLocal() relied on CartSync's own change-triggered
push to upload the merged cart — but if the server cart was empty
(first login on this account) and the local guest cart wasn't, the
merge loop never actually ran (nothing in the server response to
add), so no write/change-event ever fired and CartSync's effect never
pushed anything. The guest cart looked fine on the device that just
logged in, but never reached any other device. Now pushes explicitly
at the end regardless of whether anything changed.
2026-08-01 10:46:57 +00:00

203 lines
8.5 KiB
TypeScript

"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<void> {
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<void> {
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.
}
}