Files
einfach-produktiv/app/lib/cart.ts
T
Marco c5500bcc97 Wire up product variants end-to-end, add tracking-number display
Completes the frontend half of the Payload backend's variant/inventory/
tracking work (see that repo's own commit):

- **Cart**: CartItem gained an optional `variant?: string` field — every
  function that used to match a line by `id` alone (addToCart/
  removeFromCart/setQuantity) now matches by `(id, variant)` together via
  a shared sameLine() helper, so two lines for the same product with
  different variants stay separate entries. `variant` undefined on both
  sides (the no-variants case) still matches by simple equality, so every
  pre-existing call site keeps working unchanged.
- **Selection UI**: AddToCartInlineButton renders a <select> above the
  button when given a non-empty `variants` prop (ProductGrid/
  RelatedProducts pass product.variants straight through); defaults to
  the first variant.
- **Pricing**: cartTotals.ts's new effectivePrice(entry, product) — a
  variant's priceOverride wins over the base product price. Every cart/
  checkout/order-confirmation total and per-line price display now goes
  through this instead of reading product.price directly (fixes both a
  wrong-price bug and a duplicate-React-key bug the old `key={product.id}`
  pattern would have had the moment two variants of one product were both
  in the cart).
- **Checkout**: re-validates the requested variant server-side (same
  "never trust the client" reasoning as price re-derivation) — a variant
  name that doesn't exist on that product fails the whole checkout.
  variantName snapshots onto orders.items, shown as a parenthetical next
  to the product name on the confirmation email, both invoice PDF types,
  and the order-detail page.
- **Cross-device cart**: Customers.cart[].variantName (synced via
  /api/account/cart) carries the selection through a login/logout cycle,
  not just the current session.

Also adds tracking-number display: /konto/bestellungen/[orderNumber]
shows a clickable link when orders.trackingNumber is set, built by a new
app/lib/tracking.ts that mirrors the Payload backend's own copy
byte-for-byte close (same carrier set/URL patterns) so what a customer
sees here matches exactly what the order-shipped email already links to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 17:43:47 +00:00

142 lines
5.4 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);
return raw ? JSON.parse(raw) : [];
} 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 {
cachedItems = raw ? JSON.parse(raw) : 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 before logging in aren't lost. 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.
}
}