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

+7 -100
View File
@@ -1,118 +1,25 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { releaseUnpaidOrder } from "@/lib/order-release";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Bestellung abgebrochen", robots: { index: false } };
// Diese Seite aendert Daten (siehe releaseCheckout) und darf deshalb nie aus
// einem Cache beantwortet werden.
export const dynamic = "force-dynamic";
const CANCEL_ATTEMPTS_PER_WINDOW = 20;
const CANCEL_WINDOW_MS = 15 * 60 * 1000;
/**
* Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn
* die Kundschaft die Zahlung bei Stripe abbricht.
*
* Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die
* Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe
* api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe
* Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die
* Person gerade ausdruecklich abgebrochen hat.
*
* Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst
* etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits
* bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden —
* etwa wenn jemand nach erfolgreicher Zahlung den Zurueck-Knopf benutzt und
* dabei wieder auf dieser Seite landet.
*/
async function releaseCheckout(cancelToken: string): Promise<void> {
// Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist
// trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest.
const ip = await getClientIp();
if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return;
const order = await prisma.order.findUnique({
where: { cancelToken },
select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true },
});
// Unbekannter Token oder die Bestellung ist laengst nicht mehr offen
// (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne
// unterscheidbare Rueckmeldung nach aussen: die Seite sieht in jedem Fall
// gleich aus und verraet damit nicht, ob es den Token gibt.
if (!order || order.status !== "NEU") return;
if (!order.stripeCheckoutSessionId) {
// Kann vorkommen, wenn das Speichern der Sitzungs-ID fehlgeschlagen ist
// (best-effort, siehe api/checkout/route.ts). Ohne sie laesst sich bei
// Stripe nicht pruefen, ob doch bezahlt wurde — also nichts zurueckbuchen
// und den Ablauf der Sitzung abwarten, der ueber den Webhook ohnehin
// greift.
console.warn(
`[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst bei Ablauf der Sitzung.`
);
return;
}
try {
const stripe = getStripeClient();
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
// "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser
// Seitenaufruf kein Abbruch, sondern ein Zurueck-Klick nach erfolgreicher
// Zahlung. Finger weg.
if (session.status === "complete") return;
// "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit
// niemand die bereits freigegebene Ware im naechsten Moment doch noch
// bezahlt, und erst danach zurueckbuchen.
if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
}
// Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden
// Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein
// checkout.session.expired-Ereignis; der Webhook findet die Bestellung
// dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts).
//
// "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine
// stornierte Bestellung stehen zu lassen wuerde nur verwirren — im
// Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
// gescheiterter Verkauf.
const released = await releaseUnpaidOrder(order.id, "delete");
if (released) {
console.info(
`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.`
);
}
} catch (error: unknown) {
// Nicht weiterwerfen: die Person hat gerade abgebrochen und soll die
// Abbruchseite sehen, nicht einen Serverfehler. Der Bestand kommt dann
// spaetestens mit dem Ablauf der Sitzung zurueck.
console.error(
`[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`,
error
);
}
}
// Reine Anzeige, nachdem eine Zahlung per POST /api/checkout/cancel
// abgebrochen wurde. Frueher gab ein GET mit ?ref=<Token> hier den Bestand
// frei — ein Zustandswechsel beim blossen Aufruf, den auch Link-Vorschauen
// und Mail-Scanner ausloesen. Alte Links fuehren deshalb nur noch hierher;
// ihre Sitzung laeuft ohnehin nach spaetestens 32 Minuten ab und gibt den
// Bestand dann ueber den Webhook frei.
export default async function CheckoutCancelPage(props: PageProps<"/checkout/cancel">) {
const t = getDictionary(await getLocale());
const { ref } = await props.searchParams;
if (typeof ref === "string" && ref) {
await releaseCheckout(ref);
// Token aus Adresszeile, Verlauf und Referer nehmen, sobald er seinen
// Zweck erfuellt hat. Er gibt zwar nur genau diese eine, gerade
// abgebrochene Bestellung frei, aber ein Geheimnis gehoert nicht laenger
// sichtbar herum, als es gebraucht wird.
// Token aus Adresszeile, Verlauf und Referer nehmen.
redirect("/checkout/cancel");
}
+79 -2
View File
@@ -14,7 +14,26 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: routerPush }),
}));
// Stripe.js laesst sich in jsdom nicht laden — der Zahlungsschritt wird
// durch einen Platzhalter ersetzt, der zeigt, was er bekommen hat, und den
// Abbruch-Rueckruf ausloesen kann.
vi.mock("./embedded-payment", () => ({
EmbeddedPayment: (props: {
clientSecret: string;
cancelToken: string;
cancelLabel: string;
onCancelled: () => void;
}) => (
<div data-testid="embedded-payment" data-client-secret={props.clientSecret}>
<button type="button" onClick={props.onCancelled}>
{props.cancelLabel}
</button>
</div>
),
}));
const fetchMock = vi.fn();
const PUBLISHABLE_KEY = "pk_test_abc123";
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
@@ -30,15 +49,29 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
...overrides,
});
function renderCheckout(items: CartItem[]) {
function renderCheckout(items: CartItem[], stripePublishableKey: string | undefined = PUBLISHABLE_KEY) {
useCartStore.setState({ items, isOpen: false });
return render(
<I18nProvider locale="de">
<CheckoutForm tiers={tiers} bulkDiscountTiers={bulkDiscountTiers} />
<CheckoutForm
tiers={tiers}
bulkDiscountTiers={bulkDiscountTiers}
stripePublishableKey={stripePublishableKey}
/>
</I18nProvider>
);
}
async function fillAndSubmit(userEventInstance: ReturnType<typeof userEvent.setup>) {
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
await userEventInstance.type(screen.getByLabelText(/straße|strasse/i), "Wiesenweg 1");
await userEventInstance.type(screen.getByLabelText(/plz|postleitzahl/i), "86450");
await userEventInstance.type(screen.getByLabelText(/ort|stadt/i), "Altenmünster");
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
}
beforeEach(() => {
useCartStore.setState({ items: [], isOpen: false });
routerPush.mockClear();
@@ -102,4 +135,48 @@ describe("CheckoutForm", () => {
// Erfolgsseite erneut einbaut. Genau dafuer steht sie hier.
expect(routerPush).not.toHaveBeenCalled();
});
test("shows the embedded payment step on this page instead of redirecting to Stripe", async () => {
const userEventInstance = userEvent.setup();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
} as Response);
renderCheckout([item()]);
await fillAndSubmit(userEventInstance);
const payment = await screen.findByTestId("embedded-payment");
expect(payment).toHaveAttribute("data-client-secret", "cs_test_1_secret_abc");
// Das Formular tritt zurueck, bleibt aber erhalten.
expect(screen.getByLabelText(/vorname/i)).not.toBeVisible();
});
test("brings back the filled-in form after leaving the payment step", async () => {
const userEventInstance = userEvent.setup();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
} as Response);
renderCheckout([item()]);
await fillAndSubmit(userEventInstance);
await userEventInstance.click(await screen.findByRole("button", { name: /zurück zu deinen angaben/i }));
expect(screen.queryByTestId("embedded-payment")).not.toBeInTheDocument();
expect(screen.getByLabelText(/vorname/i)).toBeVisible();
expect(screen.getByLabelText(/vorname/i)).toHaveValue("Erika");
});
test("creates no order when no publishable key is configured", async () => {
const userEventInstance = userEvent.setup();
renderCheckout([item()], "");
await fillAndSubmit(userEventInstance);
expect(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
});
+76 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
import dynamic from "next/dynamic";
import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
@@ -12,6 +13,31 @@ import { useI18n } from "@/i18n/i18n-provider";
import { TurnstileWidget } from "@/components/turnstile-widget";
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region";
// Erst nachladen, wenn wirklich bezahlt wird: Stripe.js und das React-Paket
// gehoeren nicht in den ersten Aufbau der Kasse, und serverseitig gibt es
// ohnehin nichts zu zeichnen (das Formular ist ein iframe).
const EmbeddedPayment = dynamic(
() => import("./embedded-payment").then((module) => module.EmbeddedPayment),
{ ssr: false }
);
/** Was /api/checkout fuer den Zahlungsschritt zurueckgibt. */
interface PaymentSession {
clientSecret: string;
cancelToken: string;
}
function isPaymentSession(value: unknown): value is PaymentSession {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.clientSecret === "string" &&
candidate.clientSecret.length > 0 &&
typeof candidate.cancelToken === "string" &&
candidate.cancelToken.length > 0
);
}
type Fulfillment = "VERSAND" | "ABHOLUNG";
/**
@@ -62,6 +88,7 @@ export function CheckoutForm({
packstationSearchAvailable = false,
turnstileSiteKey,
shippingRegion = "DE",
stripePublishableKey,
}: {
initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[];
@@ -81,6 +108,12 @@ export function CheckoutForm({
* Laender anbieten, als der Shop beliefert.
*/
shippingRegion?: ShippingRegion;
/**
* Oeffentlicher Stripe-Schluessel (pk_...) fuer das eingebettete
* Zahlungsformular. Fehlt er, wird gar nicht erst eine Bestellung
* angelegt — sie liesse sich auf dieser Seite nicht bezahlen.
*/
stripePublishableKey?: string;
}) {
const { t, locale } = useI18n();
const items = useCartStore((s) => s.items);
@@ -98,6 +131,13 @@ export function CheckoutForm({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isGift, setIsGift] = useState(false);
// Gesetzt, sobald die Bestellung angelegt ist: dann ersetzt der
// Zahlungsschritt das Formular. Das Formular bleibt dabei nur versteckt,
// damit "Zurueck zu deinen Angaben" alle Eingaben unveraendert vorfindet.
const [payment, setPayment] = useState<PaymentSession | null>(null);
// Ein Turnstile-Token gilt genau einmal und ist mit dem ersten Versuch
// verbraucht. Nach "Zurueck" braucht es deshalb ein frisches Widget.
const [turnstileKey, setTurnstileKey] = useState(0);
const [deliveryTarget, setDeliveryTarget] = useState<DeliveryTarget>("ADRESSE");
const [packstationZip, setPackstationZip] = useState("");
@@ -161,6 +201,11 @@ export function CheckoutForm({
return;
}
if (!stripePublishableKey) {
setError(t.checkoutPage.paymentUnavailable);
return;
}
setSubmitting(true);
setError(null);
@@ -217,13 +262,26 @@ export function CheckoutForm({
return;
}
window.location.href = data.url;
if (!isPaymentSession(data)) {
setError(t.checkoutPage.genericError);
setSubmitting(false);
return;
}
setPayment({ clientSecret: data.clientSecret, cancelToken: data.cancelToken });
setSubmitting(false);
} catch {
setError(t.checkoutPage.genericError);
setSubmitting(false);
}
}
function handlePaymentCancelled() {
setPayment(null);
setTurnstileToken(null);
setTurnstileKey((value) => value + 1);
}
// Der Cart-Store haelt seine Positionen nur im Browser (persist/localStorage)
// und liest sie erst nach dem ersten Client-Render nach. Ohne diese Weiche
// wuerde jede:r Kund:in mit gefuelltem Warenkorb kurz die "leer"-Meldung
@@ -246,7 +304,21 @@ export function CheckoutForm({
return (
<div className="py-16">
<Container className="grid lg:grid-cols-3 gap-14">
<form onSubmit={handleSubmit} className="lg:col-span-2 space-y-8">
{payment && stripePublishableKey ? (
<div className="lg:col-span-3">
<EmbeddedPayment
publishableKey={stripePublishableKey}
clientSecret={payment.clientSecret}
cancelToken={payment.cancelToken}
cancelLabel={t.checkoutPage.paymentBack}
onCancelled={handlePaymentCancelled}
/>
</div>
) : null}
{/* Stripe zeigt im Zahlungsschritt selbst Positionen und Summe —
Formular und eigene Zusammenfassung treten dann zurueck. */}
<form onSubmit={handleSubmit} hidden={payment !== null} className="lg:col-span-2 space-y-8">
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
<div className="grid sm:grid-cols-2 gap-4">
@@ -520,7 +592,7 @@ export function CheckoutForm({
{turnstileSiteKey ? (
<div className="mb-4">
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
<TurnstileWidget key={turnstileKey} siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
</div>
) : null}
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
@@ -531,7 +603,7 @@ export function CheckoutForm({
</p>
</form>
<aside className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
<aside hidden={payment !== null} className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
<h2 className="text-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
<ul className="space-y-3 text-sm">
{items.map((item) => (
+170
View File
@@ -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>
);
}
+3
View File
@@ -8,6 +8,7 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form";
import { isPackstationSearchConfigured } from "@/lib/dhl-locations";
import { getStripePublishableKey } from "@/lib/stripe";
// Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und
// Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern —
@@ -41,6 +42,7 @@ export default async function CheckoutPage() {
bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()}
shippingRegion={shippingRegion}
stripePublishableKey={getStripePublishableKey()}
/>;
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
@@ -66,5 +68,6 @@ export default async function CheckoutPage() {
bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()}
shippingRegion={shippingRegion}
stripePublishableKey={getStripePublishableKey()}
/>;
}
@@ -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")}
/>
);
}