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]>
This commit is contained in:
1 parent
6ac7cc5d1f
commit
4ee5bafe20
28 files changed
+1272
-179
No files matched your search
@@ -0,0 +1,170 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user