"use client"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { Personalization } from "@/lib/certificate/schema"; /** * Patenschaft im Warenkorb. Die Personalisierung reist mit der Position bis * zum Checkout, der sie mit demselben Schema erneut prueft * (lib/sponsorship/checkout-expand.ts). * * isPhysical/honeyJars stehen hier, damit Warenkorb und Kasse ohne * Serveranfrage wissen, ob versendet werden muss und wie viele Glaeser in * die Versandstaffel zaehlen — verbindlich rechnet trotzdem der Server. */ export interface CartSponsorship { offerSlug: string; offerName: string; isPhysical: boolean; honeyJars: number; personalization: Personalization; } export interface CartItem { /** * Eindeutiger Schluessel der Position. Normale Ware: die productId (gleiche * Produkte verschmelzen zu einer Position mit Menge). Patenschaft: * "sp_" — zwei Patenschaften desselben Pakets haben je eigene Namen * und eigene Urkunden und duerfen nie zusammenfallen. */ key: string; productId: string; slug: string; name: string; weightGrams: number; priceCents: number; image: string | null; quantity: number; sponsorship?: CartSponsorship; } /** Was Produktkarten & Co. uebergeben — der Schluessel entsteht im Store. */ export type NewCartItem = Omit; interface CartState { items: CartItem[]; isOpen: boolean; // Client-only: waehrend des Server-Renders und des allerersten Client- // Renders (bevor persist localStorage gelesen hat) ist dies immer false, // damit UI, die auf "items.length === 0" verzweigt (Checkout/Warenkorb), // nicht kurz einen falschen "leerer Warenkorb"-Zustand zeigt, bevor die // echten, aus localStorage wiederhergestellten Positionen ankommen. hasHydrated: boolean; setHasHydrated: (hasHydrated: boolean) => void; openCart: () => void; closeCart: () => void; /** * openDrawer: false laesst die Schublade zu — die Flug-Animation zum * Warenkorb-Symbol (components/cart-flight.tsx) oeffnet sie erst, wenn * der Tropfen gelandet ist. */ addItem: (item: NewCartItem, quantity?: number, options?: { openDrawer?: boolean }) => void; /** Legt immer eine neue Position mit Menge 1 an und gibt ihren Schluessel zurueck. */ addSponsorship: (item: NewCartItem, sponsorship: CartSponsorship, options?: { openDrawer?: boolean }) => string; removeItem: (key: string) => void; setQuantity: (key: string, quantity: number) => void; clear: () => void; } export const SPONSORSHIP_KEY_PREFIX = "sp_"; const CART_STORAGE_VERSION = 2; function newSponsorshipKey(): string { return `${SPONSORSHIP_KEY_PREFIX}${crypto.randomUUID()}`; } export const useCartStore = create()( persist( (set) => ({ items: [], isOpen: false, hasHydrated: false, setHasHydrated: (hasHydrated) => set({ hasHydrated }), openCart: () => set({ isOpen: true }), closeCart: () => set({ isOpen: false }), addItem: (item, quantity = 1, options) => set((state) => { const isOpen = options?.openDrawer === false ? state.isOpen : true; const key = item.productId; const existing = state.items.find((i) => i.key === key); if (existing) { return { items: state.items.map((i) => (i.key === key ? { ...i, quantity: i.quantity + quantity } : i)), isOpen, }; } return { items: [...state.items, { ...item, key, quantity }], isOpen }; }), addSponsorship: (item, sponsorship, options) => { const key = newSponsorshipKey(); set((state) => ({ items: [...state.items, { ...item, key, quantity: 1, sponsorship }], isOpen: options?.openDrawer === false ? state.isOpen : true, })); return key; }, removeItem: (key) => set((state) => ({ items: state.items.filter((i) => i.key !== key), })), setQuantity: (key, quantity) => set((state) => ({ items: state.items // Patenschaften bleiben bei Menge 1: eine Widmung gehoert zu genau // einer Urkunde. Nur 0 (entfernen) ist erlaubt. .map((i) => (i.key === key ? { ...i, quantity: i.sponsorship ? Math.min(quantity, 1) : quantity } : i)) .filter((i) => i.quantity > 0), })), clear: () => set({ items: [] }), }), { name: "wabenhain-cart", version: CART_STORAGE_VERSION, migrate: (persisted) => migrateCartState(persisted), // Nur die Artikel persistieren — "isOpen" ist ein rein transienter // UI-Zustand und darf nach einem Seiten-Reload nicht wiederhergestellt // werden (sonst poppt das Cart-Drawer nach jedem Reload erneut auf). partialize: (state) => ({ items: state.items }), onRehydrateStorage: () => (state) => { state?.setHasHydrated(true); }, } ) ); /** * Hebt einen frueher gespeicherten Warenkorb auf das aktuelle Format. Version * 0/1 kannte keinen Schluessel; dort war die productId der Schluessel, und * genau den bekommen alte Positionen. Unlesbare Eintraege fallen weg, statt * den ganzen Warenkorb unbrauchbar zu machen. */ export function migrateCartState(persisted: unknown): { items: CartItem[] } { const rawItems = persisted && typeof persisted === "object" && Array.isArray((persisted as { items?: unknown }).items) ? ((persisted as { items: unknown[] }).items) : []; const items = rawItems.flatMap((raw): CartItem[] => { if (!raw || typeof raw !== "object") return []; const candidate = raw as Partial; if (typeof candidate.productId !== "string" || typeof candidate.quantity !== "number") return []; const key = typeof candidate.key === "string" && candidate.key.length > 0 ? candidate.key : candidate.productId; return [{ ...(candidate as CartItem), key }]; }); return { items }; } export function cartTotalCents(items: CartItem[]): number { return items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0); } export function cartItemCount(items: CartItem[]): number { return items.reduce((sum, item) => sum + item.quantity, 0); } /** * Menge fuer Versandstaffel und Mengenrabatt: nur, was tatsaechlich ein Glas * ist — der Honig eines Pakets zaehlt mit, eine Urkunde nicht (wie * physicalQuantity im Checkout-Server). */ export function cartPhysicalQuantity(items: CartItem[]): number { return items.reduce((sum, item) => { if (!item.sponsorship) return sum + item.quantity; return sum + item.sponsorship.honeyJars * item.quantity; }, 0); } /** * Rabattbasis wie im Checkout-Server (discountBase): nur Ware ausserhalb von * Patenschaftspaketen — weder die Urkunde noch der Honig eines Pakets zaehlen * fuer Mengenrabatt-Schwelle und Rabattbetrag. */ export function cartDiscountBase(items: CartItem[]): { subtotalCents: number; quantity: number } { return items .filter((item) => !item.sponsorship) .reduce( (base, item) => ({ subtotalCents: base.subtotalCents + item.priceCents * item.quantity, quantity: base.quantity + item.quantity }), { subtotalCents: 0, quantity: 0 } ); } /** * true, wenn nichts versendet werden muss: nur Patenschaften ohne Honig. * Dieselbe Regel wie hasPhysical im Server (physicalQuantity > 0), damit die * Kasse nie "digital" anbietet, wo der Server Versand verlangt. */ export function cartIsDigitalOnly(items: CartItem[]): boolean { return items.length > 0 && items.every((item) => item.sponsorship) && cartPhysicalQuantity(items) === 0; }