"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_, 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>(() => new Set()); const [attempted, setAttempted] = useState>(() => new Set()); const [reached, setReached] = useState(0); const [submitting, setSubmitting] = useState(false); const [actionError, setActionError] = useState(null); const [added, setAdded] = useState(false); const [aboSession, setAboSession] = useState(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(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(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 (

{c.heading}

{c.lead}

{/* scroll-mb: ein fokussiertes Feld rollt auf dem Telefon oberhalb der klebenden Schrittleiste ins Bild, nicht darunter. */}
{aboSession && stripePublishableKey ? ( setAboSession(null)} /> ) : added ? ( setAdded(false)} /> ) : (

{stepTitles[step]}

{step === 0 ? ( ) : null} {step === 1 ? : null} {step === 2 ? : null} {step === 3 ? ( ) : null} {step === REVIEW_STEP ? ( setField("confirmed", value)} confirmError={errorFor("confirmed")} onSubmit={submit} submitting={submitting} account={account} actionError={actionError} /> ) : null}
)}
{!aboSession && !added ? ( // Klebt auf dem Telefon am unteren Rand, solange der Konfigurator // im Blick ist — Daumenreichweite statt Suchen nach dem Knopf.
{step > 0 ? ( ) : null} {step < REVIEW_STEP ? ( ) : null}
) : null}
); } const DRAFT_FIELDS = new Set(["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; onAnother: () => void; }) { return (

{strings.configurator.added}

{strings.configurator.toCart}
); }