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