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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user