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

+480
View File
@@ -0,0 +1,480 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useI18n } from "@/i18n/i18n-provider";
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
import { useCartStore } from "@/store/cart-store";
import { LinkButton } from "@/components/ui";
import { loginPathWithNext } from "@/lib/safe-next-path";
import { useDraftStore, REVIEW_STEP, STEP_COUNT, type DraftFields, type OfferKind } from "./draft-store";
import { describeFieldError, firstInvalidStep, validateDraft, STEP_FIELDS, type DraftField, type FieldErrors } from "./draft-validation";
import { useCertificatePreview } from "./use-certificate-preview";
import { CertificatePreview } from "./certificate-preview";
import { StepIndicator } from "./step-indicator";
import { DedicationStep, NamesStep, OfferStep, WhomStep } from "./configurator-steps";
import { ReviewStep, type AccountState } from "./review-step";
import { CONFIGURATOR_ANCHOR } from "./anchors";
const AboPayment = dynamic(() => import("./abo-payment").then((module) => module.AboPayment), { ssr: false });
/**
* Konfigurator der Patenschaft in fuenf Schritten (Spec "Verkaufsseite",
* Punkt 3). Entwurf im draft-store, Pruefung mit dem Schema der Urkunde,
* Vorschau ueber /api/patenschaft/vorschau.
*
* Kauf: Einmal-Patenschaften gehen als eigene Position in den Warenkorb
* (Schluessel sp_<uuid>, Menge 1). Abos laufen ueber eine eigene
* Stripe-Sitzung (/api/patenschaft/abo), getrennt vom Warenkorb. Ohne Konto
* fuehren beide Wege zur Anmeldung; der Entwurf bleibt im sessionStorage.
*/
const PURCHASE_RETURN_PATH = "/patenschaft";
interface ConfiguratorProps {
offersOnce: SponsorshipOfferView[];
offersAbo: SponsorshipOfferView[];
dateWindow: { min: string; max: string };
account: AccountState;
stripePublishableKey?: string;
sampleSrc: string;
}
interface AboSession {
clientSecret: string;
}
function isAboSession(value: unknown): value is AboSession {
return Boolean(value) && typeof (value as { clientSecret?: unknown }).clientSecret === "string";
}
export function SponsorshipConfigurator({
offersOnce,
offersAbo,
dateWindow,
account,
stripePublishableKey,
sampleSrc,
}: ConfiguratorProps) {
const { locale } = useI18n();
const strings = getSponsorshipShopStrings(locale);
const c = strings.configurator;
const reduceMotion = useReducedMotion();
const draft = useDraftStore();
const hasHydrated = useDraftStore((s) => s.hasHydrated);
const addSponsorship = useCartStore((s) => s.addSponsorship);
const [touched, setTouched] = useState<Set<DraftField>>(() => new Set());
const [attempted, setAttempted] = useState<Set<number>>(() => new Set());
const [reached, setReached] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [added, setAdded] = useState(false);
const [aboSession, setAboSession] = useState<AboSession | null>(null);
// Neue Schrittueberschrift fokussieren, sobald sie eingeblendet ist (der
// Schrittwechsel wartet auf die Ausblendung des alten Schritts).
const focusStepHeading = useRef(false);
const stepHeadingRef = useCallback((element: HTMLHeadingElement | null) => {
if (element && focusStepHeading.current) {
focusStepHeading.current = false;
element.focus({ preventScroll: true });
}
}, []);
// Ziel beim Schrittwechsel: Vorschau und Schritt, nicht die Abschnittsueberschrift —
// auf dem Telefon soll der neue Schritt ohne Suchen im Blick sein.
const workAreaRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
// Entwurf aus dem sessionStorage erst nach dem ersten Rendern lesen.
useEffect(() => {
void useDraftStore.persist.rehydrate();
}, []);
const hasAbo = offersAbo.length > 0;
const kind: OfferKind = hasAbo ? draft.kind : "EINMALIG";
const isSubscription = kind === "ABO";
const offers = isSubscription ? offersAbo : offersOnce;
const offer = offers.find((candidate) => candidate.slug === draft.offerSlug) ?? null;
// Abo: Beginn ist der Tag der ersten Zahlung, nicht waehlbar. Einmal: ohne
// Eingabe gilt der fruehestmoegliche Tag, das Feld ist damit vorbelegt.
const effectiveDraft: DraftFields = useMemo(
() => ({ ...draft, startDate: isSubscription || !draft.startDate ? dateWindow.min : draft.startDate }),
[draft, isSubscription, dateWindow.min]
);
const validation = validateDraft(effectiveDraft, {
offerAvailable: offer ? offer.isAvailable : null,
window: dateWindow,
isSubscription,
});
const previewRequest = validation.personalization
? { personalization: validation.personalization, locale, term: isSubscription ? ("OPEN" as const) : ("FIXED" as const) }
: null;
const preview = useCertificatePreview(previewRequest);
const previewKey = previewRequest ? JSON.stringify(previewRequest) : null;
const serverError = preview.error && preview.errorKey === previewKey ? preview.error : null;
// Feldfehler der Vorschau (Glyphen, passt nicht aufs Blatt) zu den
// Schema-Fehlern legen — sie blockieren "Weiter" genauso.
const errors: FieldErrors = { ...validation.errors };
const serverField = serverError ? mapServerField(serverError.field) : null;
if (serverError && serverField && !errors[serverField]) {
errors[serverField] = { code: serverError.code, characters: serverError.characters };
}
const step = Math.min(draft.step, REVIEW_STEP);
// Nach der Rueckkehr von der Anmeldung steht der Entwurf auf dem
// Pruefschritt: dorthin scrollen, statt oben auf der Seite zu landen.
useEffect(() => {
if (!hasHydrated || restoredRef.current) return;
restoredRef.current = true;
const restoredStep = useDraftStore.getState().step;
setReached((current) => Math.max(current, restoredStep));
if (restoredStep === REVIEW_STEP) {
workAreaRef.current?.scrollIntoView({ block: "start" });
}
}, [hasHydrated]);
function setField<K extends keyof DraftFields>(key: K, value: DraftFields[K]) {
setActionError(null);
setAdded(false);
draft.setField(key, value);
}
function touch(field: DraftField) {
setTouched((current) => (current.has(field) ? current : new Set(current).add(field)));
}
function errorFor(field: DraftField): string | null {
const error = errors[field];
if (!error) return null;
const fieldStep = STEP_FIELDS.findIndex((fields) => fields.includes(field));
const isServer = serverField === field;
if (!isServer && !touched.has(field) && !attempted.has(fieldStep)) return null;
return describeFieldError(strings, field, error);
}
function goTo(target: number) {
draft.setStep(target);
setReached((current) => Math.max(current, target));
setActionError(null);
// Fokus auf die neue Schrittueberschrift: Screenreader hoeren, wo sie
// sind, und auf dem Telefon rueckt der Schritt in den Blick.
focusStepHeading.current = true;
requestAnimationFrame(() => {
const top = workAreaRef.current?.getBoundingClientRect().top ?? 0;
if (top < 0 || top > window.innerHeight * 0.5) {
workAreaRef.current?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
}
});
}
function next() {
setAttempted((current) => new Set(current).add(step));
const invalid = firstInvalidStep(errors, step);
if (invalid !== null) {
if (invalid !== step) goTo(invalid);
return;
}
goTo(step + 1);
}
function selectOffer(selected: SponsorshipOfferView) {
setActionError(null);
draft.selectOffer(selected.slug, selected.kind);
}
function changeKind(nextKind: OfferKind) {
setActionError(null);
draft.setKind(nextKind);
}
function goToLogin() {
// Der Entwurf liegt bereits im sessionStorage (persist schreibt bei jeder
// Aenderung) und wird nach der Rueckkehr wiederhergestellt. Bewusst eine
// volle Navigation: Anmeldung und Rueckkehr laufen ueber Server-Redirects
// mit neuem Session-Cookie, die Seite soll danach frisch gerendert werden.
window.location.assign(loginPathWithNext(PURCHASE_RETURN_PATH));
}
async function submit() {
setAttempted((current) => new Set(current).add(REVIEW_STEP));
const invalid = firstInvalidStep(errors, REVIEW_STEP);
if (invalid !== null) {
if (invalid !== REVIEW_STEP) goTo(invalid);
return;
}
if (!offer || !validation.personalization) return;
if (account === "GUEST") {
goToLogin();
return;
}
if (isSubscription) {
await startSubscription(offer);
return;
}
if (!offer.productId) {
setActionError(strings.errors.generic);
return;
}
addSponsorship(
{ productId: offer.productId, slug: offer.slug, name: offer.name, weightGrams: 0, priceCents: offer.priceCents, image: null },
{
offerSlug: offer.slug,
offerName: offer.name,
isPhysical: offer.isPhysical,
honeyJars: offer.honeyJars,
personalization: validation.personalization,
}
);
setAdded(true);
setTouched(new Set());
setAttempted(new Set());
setReached(0);
// Paket behalten, alles Persoenliche leeren: die naechste Patenschaft
// ist meist fuer jemand anderen.
draft.reset({ kind: offer.kind, offerSlug: offer.slug, step: 0 });
}
async function startSubscription(selected: SponsorshipOfferView) {
if (!stripePublishableKey) {
setActionError(strings.abo.unavailable);
return;
}
setSubmitting(true);
setActionError(null);
try {
const response = await fetch("/api/patenschaft/abo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ offerSlug: selected.slug, personalization: validation.personalization, locale }),
});
const data: unknown = await response.json().catch(() => null);
if (response.status === 401) {
goToLogin();
return;
}
if (!response.ok || !isAboSession(data)) {
const message = data && typeof (data as { error?: unknown }).error === "string" ? (data as { error: string }).error : null;
setActionError(response.status === 404 || !message ? strings.abo.unavailable : message);
return;
}
setAboSession({ clientSecret: data.clientSecret });
} catch {
setActionError(strings.abo.unavailable);
} finally {
setSubmitting(false);
}
}
const previewMessage =
serverError && !serverField
? serverError.code === "rate_limited"
? strings.errors.rateLimited
: serverError.code === "fit"
? strings.errors.fit
: strings.preview.failed
: null;
// Eigene Vorschau erst, wenn der Entwurf Namen traegt — nach dem Hinzufuegen
// (Entwurf geleert) steht wieder die Beispielurkunde da, nicht die letzte.
const showOwnPreview = Boolean(preview.url) && Boolean(draft.beeName.trim() || draft.recipientName.trim());
const stepTitles = [c.titles.offer, c.titles.whom, c.titles.names, c.titles.dedication, c.titles.review];
const stepProps = { strings, draft: effectiveDraft, setField, errorFor, touch };
return (
<div id={CONFIGURATOR_ANCHOR} className="scroll-mt-24">
<div className="max-w-2xl">
<h2 className="text-4xl leading-[1.05] tracking-[-0.01em] text-heading md:text-5xl">{c.heading}</h2>
<p className="mt-4 leading-relaxed text-ink/70">{c.lead}</p>
</div>
<div ref={workAreaRef} className="mt-12 grid scroll-mt-24 gap-8 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-5">
<div className="lg:sticky lg:top-28">
<CertificatePreview
src={showOwnPreview ? (preview.url as string) : sampleSrc}
alt={showOwnPreview ? strings.preview.alt : strings.hero.previewAlt}
loading={preview.loading}
loadingLabel={strings.preview.loading}
enlargeLabel={strings.preview.enlarge}
closeLabel={strings.preview.close}
note={showOwnPreview ? null : strings.preview.sampleNote}
message={previewMessage}
/>
</div>
</div>
<div className="min-w-0 lg:col-span-7">
<StepIndicator
labels={c.steps}
current={step}
reached={reached}
onSelect={goTo}
ariaLabel={c.stepsLabel}
stepOf={c.stepOf}
/>
<div className="mt-6 border border-ink/10 bg-background px-5 py-7 shadow-[0_30px_60px_-46px_rgba(36,53,43,0.55)] sm:px-9 sm:py-10">
{aboSession && stripePublishableKey ? (
<AboPayment
publishableKey={stripePublishableKey}
clientSecret={aboSession.clientSecret}
heading={strings.abo.heading}
backLabel={strings.abo.back}
loadingLabel={strings.abo.loading}
failedLabel={strings.abo.loadFailed}
onBack={() => setAboSession(null)}
/>
) : added ? (
<AddedPanel strings={strings} onAnother={() => setAdded(false)} />
) : (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, transform: "translateY(10px)" }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, transform: "translateY(0px)" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, transform: "translateY(-6px)" }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
>
<p className="text-xs tracking-wide-caps text-honey-dark" aria-hidden="true">
{c.stepOf(step + 1, STEP_COUNT)}
</p>
<h3
ref={stepHeadingRef}
tabIndex={-1}
// Fokusziel nur fuer Screenreader und Tastatur-Kontext; der
// globale :focus-visible-Ring (globals.css) wuerde die
// Ueberschrift sonst wie ein Eingabefeld umrahmen.
style={{ outline: "none" }}
className="mt-2 font-serif text-3xl leading-tight text-heading sm:text-4xl"
>
{stepTitles[step]}
</h3>
<div className="mt-7">
{step === 0 ? (
<OfferStep
strings={strings}
offers={offers}
kind={kind}
hasAbo={hasAbo}
selectedSlug={draft.offerSlug}
onKind={changeKind}
onSelect={selectOffer}
locale={locale}
error={attempted.has(0) && errors.offer ? describeFieldError(strings, "offer", errors.offer) : null}
/>
) : null}
{step === 1 ? <WhomStep {...stepProps} /> : null}
{step === 2 ? <NamesStep {...stepProps} /> : null}
{step === 3 ? (
<DedicationStep
{...stepProps}
isSubscription={isSubscription}
dateWindow={dateWindow}
startDate={effectiveDraft.startDate}
/>
) : null}
{step === REVIEW_STEP ? (
<ReviewStep
strings={strings}
locale={locale}
offer={offer}
personalization={validation.personalization}
isSubscription={isSubscription}
confirmed={draft.confirmed}
onConfirm={(value) => setField("confirmed", value)}
confirmError={errorFor("confirmed")}
onSubmit={submit}
submitting={submitting}
account={account}
actionError={actionError}
/>
) : null}
</div>
</motion.div>
</AnimatePresence>
)}
</div>
{!aboSession && !added ? (
// Klebt auf dem Telefon am unteren Rand, solange der Konfigurator
// im Blick ist — Daumenreichweite statt Suchen nach dem Knopf.
<div className="sticky bottom-0 z-20 -mx-6 mt-0 flex items-center gap-3 border-t border-ink/10 bg-surface/95 px-6 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur md:static md:mx-0 md:mt-6 md:border-0 md:bg-transparent md:p-0 md:backdrop-blur-none">
{step > 0 ? (
<button
type="button"
onClick={() => goTo(step - 1)}
className="inline-flex min-h-12 select-none items-center justify-center gap-2 border border-ink/20 bg-background px-5 text-sm tracking-wide-caps text-ink transition-[border-color,transform] duration-150 touch-manipulation hover:border-heading active:scale-[0.97]"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<path d="M20 12H5M11 6l-6 6 6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{c.back}
</button>
) : null}
{step < REVIEW_STEP ? (
<button
type="button"
onClick={next}
className="ml-auto inline-flex min-h-12 flex-1 select-none items-center justify-center gap-2 border border-heading bg-forest px-7 text-sm tracking-wide-caps text-cream transition-[background-color,transform,box-shadow] duration-150 touch-manipulation hover:bg-honey-deep hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] active:scale-[0.97] md:flex-none"
>
{c.next}
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
) : null}
</div>
) : null}
</div>
</div>
</div>
);
}
const DRAFT_FIELDS = new Set<string>(["beeName", "recipientName", "giverNames", "dedication", "startDate", "occasion"]);
/** Feld der Vorschau-API auf ein Formularfeld abbilden; "layout" hat keins. */
function mapServerField(field: string | null): DraftField | null {
return field && DRAFT_FIELDS.has(field) ? (field as DraftField) : null;
}
function AddedPanel({
strings,
onAnother,
}: {
strings: ReturnType<typeof getSponsorshipShopStrings>;
onAnother: () => void;
}) {
return (
<div className="flex flex-col items-start gap-6 py-4" role="status">
<svg viewBox="0 0 24 26" className="h-12 w-12 text-honey" aria-hidden="true">
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="currentColor" />
<path d="M8 13.2l2.8 2.8 5.2-5.6" stroke="var(--color-cream)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<p className="font-serif text-3xl leading-tight text-heading">
{strings.configurator.added}
</p>
<div className="flex w-full flex-col gap-3 sm:flex-row">
<LinkButton href="/warenkorb" className="min-h-12 whitespace-nowrap">
{strings.configurator.toCart}
</LinkButton>
<button
type="button"
onClick={onAnother}
className="inline-flex min-h-12 items-center justify-center whitespace-nowrap border border-ink/20 px-6 text-sm tracking-wide-caps text-ink transition-[border-color,transform] duration-150 hover:border-heading active:scale-[0.97]"
>
{strings.configurator.addAnother}
</button>
</div>
</div>
);
}