ce3ac3e79a
Cross-device sync only ever worked one way (local→server on every change via CartSync) plus a server→local merge at the exact moment of login/register — a customer already signed in on a second device (no fresh login action happening) never picked up changes made elsewhere. Adds pullServerCart(): idempotent (server qty overwrites a matching local line rather than adding to it, safe to call repeatedly, unlike mergeServerCartIntoLocal()'s additive merge which only stays correct right after a login clears the local cart's ambiguity). Triggered on CartSync's mount and on window focus — covers "open the site while already logged in" and "switch back to this tab after changing the cart on another device," without a polling interval.
184 lines
7.6 KiB
TypeScript
184 lines
7.6 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. CartSync then picks up the resulting change and pushes the
|
|
// merged cart back to the server on its own, closing the loop without a
|
|
// separate save call here.
|
|
export async function mergeServerCartIntoLocal(): Promise<void> {
|
|
try {
|
|
const res = await fetch("/api/account/cart");
|
|
if (!res.ok) return;
|
|
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.
|
|
}
|
|
}
|
|
|
|
// 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 an addition made on device A. This
|
|
// covers that gap: called on mount and on window focus (CartSync.tsx), not
|
|
// just at login.
|
|
//
|
|
// Unlike mergeServerCartIntoLocal()'s additive `addToCart` (only safe
|
|
// right after login, when the local cart can't yet overlap the server
|
|
// one — see that function's own comment), this must be idempotent: it can
|
|
// run repeatedly against a cart that's already in sync. For a line that
|
|
// exists on both sides, the server's qty wins outright (overwrite, not
|
|
// add) — otherwise calling this twice in a row would double the quantity
|
|
// every time. A local-only line (added on this device but not yet pushed
|
|
// by CartSync's 800ms debounce) is left untouched rather than dropped.
|
|
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();
|
|
const serverItems = data.cart ?? [];
|
|
if (serverItems.length === 0) return;
|
|
const items = readCart();
|
|
for (const server of serverItems) {
|
|
const existing = items.find((i) => sameLine(i, server.id, server.variant));
|
|
if (existing) existing.qty = server.qty;
|
|
else items.push(server.variant ? { id: server.id, qty: server.qty, variant: server.variant } : { id: server.id, qty: server.qty });
|
|
}
|
|
writeCart(items);
|
|
} catch {
|
|
// Best-effort — a failed pull just means this device doesn't see
|
|
// another device's changes yet; nothing local is lost either way.
|
|
}
|
|
}
|