Files
Wabenhain/src/app/checkout/embedded-payment.tsx
T
Nick SzaunigandClaude Opus 5.5 6950657c91 feat(ui): Dunkelmodus fuer Shop und Admin mit Umschalter
Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem
Satz Klassen entstehen:
- heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme
- surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen
- forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe,
  Admin-Leiste), cream der helle Text darauf
- honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird
  im Dunkelmodus heller und bleibt lesbar
Codemod ueber src: text-/border-forest -> *-heading, bg-cream ->
bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die
Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich.

Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in
Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme"
(nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme
schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der
Systemeinstellung. Gewaehlter und automatischer Dunkelblock in
globals.css werden per Test auf Gleichheit geprueft.

Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile
steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt
neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst
ab 1680 px.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:12:44 +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-heading 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>
);
}