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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

+103 -14
View File
@@ -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;
}