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]>
171 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|