feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um: - Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und 3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix - Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT), Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt, Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL) - Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR - Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau, Warenkorb mit Schluessel je Position - Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP, Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP - Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen, Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen - Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten, SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung, Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
+103
-14
@@ -2,8 +2,33 @@
|
||||
|
||||
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;
|
||||
@@ -11,8 +36,12 @@ export interface CartItem {
|
||||
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;
|
||||
@@ -30,12 +59,21 @@ interface CartState {
|
||||
* Warenkorb-Symbol (components/cart-flight.tsx) oeffnet sie erst, wenn
|
||||
* der Tropfen gelandet ist.
|
||||
*/
|
||||
addItem: (item: Omit<CartItem, "quantity">, quantity?: number, options?: { openDrawer?: boolean }) => void;
|
||||
removeItem: (productId: string) => void;
|
||||
setQuantity: (productId: string, quantity: number) => void;
|
||||
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) => ({
|
||||
@@ -48,33 +86,42 @@ export const useCartStore = create<CartState>()(
|
||||
addItem: (item, quantity = 1, options) =>
|
||||
set((state) => {
|
||||
const isOpen = options?.openDrawer === false ? state.isOpen : true;
|
||||
const existing = state.items.find((i) => i.productId === item.productId);
|
||||
const key = item.productId;
|
||||
const existing = state.items.find((i) => i.key === key);
|
||||
if (existing) {
|
||||
return {
|
||||
items: state.items.map((i) =>
|
||||
i.productId === item.productId
|
||||
? { ...i, quantity: i.quantity + quantity }
|
||||
: i
|
||||
),
|
||||
items: state.items.map((i) => (i.key === key ? { ...i, quantity: i.quantity + quantity } : i)),
|
||||
isOpen,
|
||||
};
|
||||
}
|
||||
return { items: [...state.items, { ...item, quantity }], isOpen };
|
||||
return { items: [...state.items, { ...item, key, quantity }], isOpen };
|
||||
}),
|
||||
removeItem: (productId) =>
|
||||
addSponsorship: (item, sponsorship, options) => {
|
||||
const key = newSponsorshipKey();
|
||||
set((state) => ({
|
||||
items: state.items.filter((i) => i.productId !== productId),
|
||||
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: (productId, quantity) =>
|
||||
setQuantity: (key, quantity) =>
|
||||
set((state) => ({
|
||||
items: state.items
|
||||
.map((i) => (i.productId === productId ? { ...i, quantity } : i))
|
||||
// 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).
|
||||
@@ -86,6 +133,27 @@ export const useCartStore = create<CartState>()(
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -93,3 +161,24 @@ export function cartTotalCents(items: CartItem[]): number {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in new issue
Block a user