Files
Wabenhain/src/store/cart-store.ts
T
Nick SzaunigandClaude Opus 5.5 515fca1c16
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
  Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
  zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
  Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:09:32 +02:00

199 lines
7.5 KiB
TypeScript

"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_<uuid>" — 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<CartItem, "quantity" | "key" | "sponsorship">;
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<CartState>()(
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<CartItem>;
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;
}