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
- 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]>
199 lines
7.5 KiB
TypeScript
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;
|
|
}
|