Files
Wabenhain/src/app/checkout/embedded-payment.tsx
T
Nick SzaunigandClaude Opus 5.5 4ee5bafe20 feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit
ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe
(PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin
dynamisch aus dem Dashboard.

- Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt
  Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt
  alle Eingaben, Turnstile wird neu geladen
- Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit
  der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe
  Sitzung erneut eingebettet
- Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden
  Browser gebunden, die session_id in der URL allein reicht nicht
- Alte /checkout/cancel?ref= Links aendern per GET nichts mehr
- CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert
- STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit
  Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 08:01:18 +02:00

171 lines
5.9 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { loadStripe } from "@stripe/stripe-js/pure";
import type { Stripe } from "@stripe/stripe-js";
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@stripe/react-stripe-js";
import { useI18n } from "@/i18n/i18n-provider";
/**
* Stripe.js genau einmal je Schluessel laden. "pure" statt des Standard-
* Einstiegs: der haengt das Skript bereits beim blossen Import ein, also auf
* jeder Seite, die dieses Modul irgendwann mitbringt. So kommt Stripe erst
* ins Spiel, wenn wirklich bezahlt wird.
*/
const stripeByKey = new Map<string, Promise<Stripe | null>>();
function getStripe(publishableKey: string): Promise<Stripe | null> {
const cached = stripeByKey.get(publishableKey);
if (cached) return cached;
const pending = loadStripe(publishableKey);
stripeByKey.set(publishableKey, pending);
// Nach einem Fehlschlag (Netz, Werbeblocker) darf "Erneut versuchen" nicht
// auf demselben abgelehnten Versprechen haengen bleiben.
pending.catch(() => stripeByKey.delete(publishableKey));
return pending;
}
type LoadState = { status: "loading" } | { status: "ready"; stripe: Stripe } | { status: "failed" };
export interface EmbeddedPaymentProps {
publishableKey: string;
clientSecret: string;
/** Abbruch-Token der Bestellung (siehe api/checkout/cancel). */
cancelToken: string;
/** Beschriftung des Abbruch-Knopfs — je nach Ort "Zurueck" oder "Abbrechen". */
cancelLabel: string;
/** Wird erst aufgerufen, nachdem der Server den Bestand freigegeben hat. */
onCancelled: () => void;
}
/**
* Das Stripe-Zahlungsformular als iframe auf der eigenen Seite.
*
* Kartendaten, PayPal-Anmeldung und 3-D-Secure laufen vollstaendig innerhalb
* von Stripes iframe bzw. bei PayPal — dieser Code sieht davon nichts. Ueber
* "bezahlt" entscheidet er ebenfalls nicht: Stripe leitet nach jeder Zahlung
* auf /checkout/success weiter, und erst dort wird serverseitig nachgefragt.
*/
export function EmbeddedPayment({
publishableKey,
clientSecret,
cancelToken,
cancelLabel,
onCancelled,
}: EmbeddedPaymentProps) {
const { t } = useI18n();
const [load, setLoad] = useState<LoadState>({ status: "loading" });
const [attempt, setAttempt] = useState(0);
const [cancelling, setCancelling] = useState(false);
const [cancelError, setCancelError] = useState(false);
const headingRef = useRef<HTMLHeadingElement>(null);
// Fokus auf die neue Ueberschrift: der Schritt ersetzt das Formular, auf
// dem der Fokus eben noch lag. Ohne das stuende er fuer Screenreader im
// Nichts, und auf dem Handy bliebe die Seite unten am alten Knopf stehen.
useEffect(() => {
headingRef.current?.focus();
headingRef.current?.scrollIntoView?.({ block: "start" });
}, []);
useEffect(() => {
let cancelled = false;
getStripe(publishableKey)
.then((stripe) => {
if (cancelled) return;
setLoad(stripe ? { status: "ready", stripe } : { status: "failed" });
})
.catch((error: unknown) => {
if (cancelled) return;
console.error("[checkout] Stripe.js konnte nicht geladen werden:", error);
setLoad({ status: "failed" });
});
return () => {
cancelled = true;
};
}, [publishableKey, attempt]);
async function handleCancel() {
setCancelling(true);
setCancelError(false);
try {
const response = await fetch("/api/checkout/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cancelToken }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
onCancelled();
} catch (error: unknown) {
// Stehen bleiben statt trotzdem weiterzuschalten: sonst legte der
// naechste Versuch eine zweite Bestellung an, waehrend die erste ihren
// Bestand noch bis zum Sitzungsablauf festhaelt.
console.error("[checkout] Abbruch fehlgeschlagen:", error);
setCancelError(true);
setCancelling(false);
}
}
return (
<section aria-labelledby="payment-heading" className="space-y-6">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h2
id="payment-heading"
ref={headingRef}
tabIndex={-1}
className="scroll-mt-24 text-2xl text-forest outline-none"
>
{t.checkoutPage.paymentHeading}
</h2>
<button
type="button"
onClick={handleCancel}
disabled={cancelling}
className="text-sm text-ink/70 underline disabled:opacity-50"
>
{cancelling ? t.checkoutPage.paymentCancelling : cancelLabel}
</button>
</div>
{cancelError ? (
<p role="alert" className="text-sm text-red-700">
{t.checkoutPage.paymentCancelError}
</p>
) : null}
{/* Mindesthoehe haelt den Platz frei, bis Stripe das iframe gezeichnet
hat — sonst springt alles darunter beim Laden nach oben. */}
<div className="min-h-[36rem]">
{load.status === "loading" ? (
<p className="text-sm text-ink/70" aria-live="polite">
{t.checkoutPage.paymentLoading}
</p>
) : null}
{load.status === "failed" ? (
<p role="alert" className="text-sm text-red-700">
{t.checkoutPage.paymentLoadError}{" "}
<button
type="button"
onClick={() => {
setLoad({ status: "loading" });
setAttempt((value) => value + 1);
}}
className="underline"
>
{t.common.turnstile.retry}
</button>
</p>
) : null}
{load.status === "ready" ? (
<EmbeddedCheckoutProvider stripe={load.stripe} options={{ clientSecret }}>
<EmbeddedCheckout id="stripe-embedded-checkout" />
</EmbeddedCheckoutProvider>
) : null}
</div>
</section>
);
}