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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 08:01:18 +02:00
1 parent 6ac7cc5d1f
commit 4ee5bafe20
28 files changed
+1272 -179

No files matched your search

@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import { useCartStore } from "@/store/cart-store";
/**
* Leert den Warenkorb, sobald die Rueckkehrseite eine abgeschlossene Zahlung
* bestaetigt hat. Eigene Komponente, weil die Seite selbst serverseitig
* rendert (sie fragt dort bei Stripe nach), der Warenkorb aber nur im
* Browser liegt.
*/
export function ClearCartOnMount() {
const clear = useCartStore((s) => s.clear);
useEffect(() => {
clear();
}, [clear]);
return null;
}
+89 -26
View File
@@ -1,47 +1,110 @@
"use client";
import { useEffect } from "react";
import { use } from "react";
import { cookies } from "next/headers";
import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { useCartStore } from "@/store/cart-store";
import { useI18n } from "@/i18n/i18n-provider";
import { CHECKOUT_COOKIE } from "@/lib/checkout-release";
import { resolveCheckoutReturn, type CheckoutReturn } from "@/lib/checkout-return";
import { getStripePublishableKey } from "@/lib/stripe";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { ClearCartOnMount } from "./clear-cart-on-mount";
import { PaymentRetry } from "./payment-retry";
export default function CheckoutSuccessPage({
searchParams,
}: {
searchParams: Promise<{ order?: string; pending?: string }>;
}) {
const { t } = useI18n();
const { order, pending } = use(searchParams);
const clear = useCartStore((s) => s.clear);
// Liest bei jedem Aufruf den aktuellen Zahlungsstand bei Stripe — nie aus
// einem Cache.
export const dynamic = "force-dynamic";
useEffect(() => {
clear();
// Warenkorb wird nur einmal nach erfolgreichem Checkout geleert.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Rueckkehrseite des eingebetteten Checkouts. Stripe leitet nach jedem
* Zahlungsversuch hierher (return_url in api/checkout/route.ts), bei PayPal
* nach dem Umweg ueber paypal.com.
*/
export default async function CheckoutSuccessPage(props: PageProps<"/checkout/success">) {
const t = getDictionary(await getLocale());
const { session_id: sessionId, order: legacyOrder } = await props.searchParams;
let result: CheckoutReturn;
if (typeof sessionId === "string" && sessionId) {
const browserCancelToken = (await cookies()).get(CHECKOUT_COOKIE)?.value;
result = await resolveCheckoutReturn(sessionId, browserCancelToken);
} else if (typeof legacyOrder === "string" && legacyOrder) {
// Sitzungen, die noch vor der Umstellung auf der gehosteten Stripe-Seite
// geoeffnet wurden, kehren mit ?order= zurueck. Pruefen laesst sich das
// nicht; die neutrale "in Bearbeitung"-Ansicht verspricht nichts, was
// nicht die Bestaetigungsmail ohnehin haelt. Die Nummer aus der URL wird
// bewusst nicht angezeigt.
result = { kind: "processing", orderNumber: "" };
} else {
result = { kind: "notCompleted" };
}
if (result.kind === "open") {
const publishableKey = getStripePublishableKey();
return (
<div className="py-16">
<Container className="max-w-3xl">
<SectionHeading eyebrow={t.checkoutSuccessPage.openEyebrow} title={t.checkoutSuccessPage.openTitle} />
<p role="status" className="mt-4 mb-10 text-ink/70">
{t.checkoutSuccessPage.openText}
</p>
{publishableKey ? (
<PaymentRetry
publishableKey={publishableKey}
clientSecret={result.clientSecret}
cancelToken={result.cancelToken}
/>
) : (
<LinkButton href="/warenkorb">{t.checkoutSuccessPage.backToCart}</LinkButton>
)}
</Container>
</div>
);
}
if (result.kind === "notCompleted") {
return (
<div className="py-24">
<Container className="max-w-xl text-center">
<SectionHeading
eyebrow={t.checkoutSuccessPage.notCompletedEyebrow}
title={t.checkoutSuccessPage.notCompletedTitle}
align="center"
className="mx-auto"
/>
<p className="mt-6 text-ink/70">{t.checkoutSuccessPage.notCompletedText}</p>
<LinkButton href="/warenkorb" className="mt-10">
{t.checkoutSuccessPage.backToCart}
</LinkButton>
</Container>
</div>
);
}
const isPaid = result.kind === "paid";
const { orderNumber } = result;
return (
<div className="py-24">
{/* Erst hier, nach der Pruefung bei Stripe: ein fehlgeschlagener oder
abgebrochener Versuch laesst den Warenkorb unangetastet. */}
<ClearCartOnMount />
<Container className="max-w-xl text-center">
<SectionHeading
eyebrow={pending ? t.checkoutSuccessPage.pendingEyebrow : t.checkoutSuccessPage.successEyebrow}
title={pending ? t.checkoutSuccessPage.pendingTitle : t.checkoutSuccessPage.successTitle}
eyebrow={isPaid ? t.checkoutSuccessPage.successEyebrow : t.checkoutSuccessPage.pendingEyebrow}
title={isPaid ? t.checkoutSuccessPage.successTitle : t.checkoutSuccessPage.pendingTitle}
align="center"
className="mx-auto"
/>
{order ? (
{orderNumber ? (
<p className="mt-4 text-ink/70">
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{order}</span>
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{orderNumber}</span>
</p>
) : null}
<p className="mt-6 text-ink/70">
{pending ? t.checkoutSuccessPage.pendingText : t.checkoutSuccessPage.successText}
{isPaid ? t.checkoutSuccessPage.successText : t.checkoutSuccessPage.pendingText}
</p>
<div className="mt-10 flex items-center justify-center gap-4">
<LinkButton href="/shop">{t.checkoutSuccessPage.continueShopping}</LinkButton>
{order ? (
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(order)}`} variant="secondary">
{orderNumber ? (
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(orderNumber)}`} variant="secondary">
{t.checkoutSuccessPage.trackOrder}
</LinkButton>
) : null}
@@ -0,0 +1,38 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useI18n } from "@/i18n/i18n-provider";
const EmbeddedPayment = dynamic(
() => import("../embedded-payment").then((module) => module.EmbeddedPayment),
{ ssr: false }
);
interface PaymentRetryProps {
publishableKey: string;
clientSecret: string;
cancelToken: string;
}
/**
* Zweiter Versuch nach einer fehlgeschlagenen oder bei PayPal abgebrochenen
* Zahlung: dieselbe, noch offene Sitzung erneut einbetten, statt die
* Kundschaft alles noch einmal eintippen zu lassen. Wer stattdessen
* abbricht, bekommt den Bestand sofort freigegeben und landet auf der
* Abbruchseite.
*/
export function PaymentRetry({ publishableKey, clientSecret, cancelToken }: PaymentRetryProps) {
const { t } = useI18n();
const router = useRouter();
return (
<EmbeddedPayment
publishableKey={publishableKey}
clientSecret={clientSecret}
cancelToken={cancelToken}
cancelLabel={t.checkoutSuccessPage.cancelPayment}
onCancelled={() => router.replace("/checkout/cancel")}
/>
);
}