"use client"; import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react"; import { Container, SectionHeading, Button } from "@/components/ui"; import { TrustBadges } from "@/components/trust-badges"; import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; import CheckoutLoading from "./loading"; import { formatPrice } from "@/lib/format"; import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc"; import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc"; import { useI18n } from "@/i18n/i18n-provider"; import { TurnstileWidget } from "@/components/turnstile-widget"; type Fulfillment = "VERSAND" | "ABHOLUNG"; /** Hausanschrift oder DHL-Packstation — nur bei fulfillment === "VERSAND" relevant. */ type DeliveryTarget = "ADRESSE" | "PACKSTATION"; /** Ein Treffer der DHL-Standortsuche, wie ihn /api/shop/packstationen liefert. */ interface Packstation { number: string; name: string; street: string; zip: string; city: string; distanceMeters: number | null; } export interface CheckoutInitialValues { firstName: string; lastName: string; email: string; phone: string; street: string; zip: string; city: string; } export function CheckoutForm({ initialValues, tiers, bulkDiscountTiers, packstationSearchAvailable = false, turnstileSiteKey, }: { initialValues?: CheckoutInitialValues; tiers: ShippingTierLike[]; bulkDiscountTiers: BulkDiscountTierLike[]; /** * Nur wenn DHL_API_KEY gesetzt ist, kann die Standortsuche ueberhaupt * antworten — sonst wird die Packstation-Option gar nicht erst angeboten, * statt Kundschaft in eine Suche laufen zu lassen, die immer fehlschlaegt. */ packstationSearchAvailable?: boolean; /** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */ turnstileSiteKey?: string; }) { const { t, locale } = useI18n(); const items = useCartStore((s) => s.items); const hasHydrated = useCartStore((s) => s.hasHydrated); const subtotal = cartTotalCents(items); const totalQuantity = cartItemCount(items); // Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit // Token-Rueckgabe statt eines versteckten Feldes. const [turnstileToken, setTurnstileToken] = useState(null); const [fulfillment, setFulfillment] = useState("VERSAND"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [isGift, setIsGift] = useState(false); const [deliveryTarget, setDeliveryTarget] = useState("ADRESSE"); const [packstationZip, setPackstationZip] = useState(""); const [packstationResults, setPackstationResults] = useState(null); const [packstationSearching, setPackstationSearching] = useState(false); const [packstationError, setPackstationError] = useState(null); const [selectedPackstation, setSelectedPackstation] = useState(null); const usesPackstation = fulfillment === "VERSAND" && deliveryTarget === "PACKSTATION"; async function searchPackstations() { setPackstationSearching(true); setPackstationError(null); setPackstationResults(null); try { const response = await fetch( `/api/shop/packstationen?plz=${encodeURIComponent(packstationZip.trim())}` ); const data = await response.json(); if (!response.ok) { setPackstationError(typeof data.error === "string" ? data.error : t.checkoutPage.packstationNoResults); return; } setPackstationResults(Array.isArray(data.packstationen) ? data.packstationen : []); } catch { // fetch() wirft nur bei Netzwerkabbruch — ein Fehlerstatus landet oben. setPackstationError(t.checkoutPage.packstationNoResults); } finally { setPackstationSearching(false); } } const errorRef = useRef(null); // Fokus aktiv auf die Fehlermeldung verschieben statt sie nur farblich // einzublenden — sonst bleibt der Fokus auf dem "Bestellen"-Button stehen // und Screenreader-Nutzer:innen erfahren beim fehlgeschlagenen, geldwerten // Checkout-Absenden nichts vom Fehler (role="alert" allein wird von // manchen Screenreadern bei neu eingefuegten Elementen unzuverlaessig // vorgelesen, Fokusverschiebung ist der robustere Mechanismus). useEffect(() => { if (error) errorRef.current?.focus(); }, [error]); // Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf // die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt. const shipping = fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity); const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity); const bulkDiscountCents = activeBulkDiscount ? Math.round((subtotal * activeBulkDiscount.percent) / 100) : 0; const total = subtotal - bulkDiscountCents + shipping; async function handleSubmit(event: FormEvent) { event.preventDefault(); // Vor dem Absenden abfangen, statt den Server einen 400er zurueckgeben zu // lassen: ohne Auswahl gaebe es keine Lieferadresse. if (usesPackstation && !selectedPackstation) { setError(t.checkoutPage.packstationChooseHint); return; } setSubmitting(true); setError(null); const formData = new FormData(event.currentTarget); const payload = { email: String(formData.get("email") ?? ""), firstName: String(formData.get("firstName") ?? ""), lastName: String(formData.get("lastName") ?? ""), phone: String(formData.get("phone") ?? "") || undefined, fulfillmentMethod: fulfillment, // Bei einer Packstation kommen Anschriftsfelder nicht aus dem Formular, // sondern aus dem gewaehlten Standort. "street" traegt dabei bewusst den // lesbaren Packstation-Namen, damit Rechnung, Bestaetigungsmail und // Admin-Ansicht unveraendert etwas Sinnvolles zeigen; die technisch // noetigen Werte stehen in packstationNumber/postNumber. street: usesPackstation ? selectedPackstation ? `Packstation ${selectedPackstation.number}` : undefined : String(formData.get("street") ?? "") || undefined, zip: usesPackstation ? selectedPackstation?.zip : String(formData.get("zip") ?? "") || undefined, city: usesPackstation ? selectedPackstation?.city : String(formData.get("city") ?? "") || undefined, country: "DE", packstationNumber: usesPackstation ? selectedPackstation?.number : undefined, postNumber: usesPackstation ? String(formData.get("postNumber") ?? "").trim() || undefined : undefined, items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })), discountCode: String(formData.get("discountCode") ?? "").trim() || undefined, giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined, turnstileToken: turnstileToken ?? undefined, }; try { const response = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) { // Auch der 502er (Stripe nicht erreichbar) landet hier als normale // Fehlermeldung. Frueher fuehrte er auf die Erfolgsseite mit dem // Hinweis "wir melden uns" — eine Bestellung ohne Zahlungssitzung ist // aber auf keinem Weg bezahlbar, und die Seite versprach damit etwas, // das niemand einloesen konnte (siehe api/checkout/route.ts). setError(data.error ?? t.checkoutPage.genericError); setSubmitting(false); return; } window.location.href = data.url; } catch { setError(t.checkoutPage.genericError); setSubmitting(false); } } // 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 // sehen, bevor auf das echte Formular umgeschaltet wird — ein grosser, // messbarer Layout-Sprung (siehe CLS) und verwirrend fuer echte Kaeufer:innen. if (!hasHydrated) { return ; } if (items.length === 0) { return (
); } return (

{t.checkoutPage.fulfillmentLabel}

{(["VERSAND", "ABHOLUNG"] as const).map((option) => ( ))}
{fulfillment === "VERSAND" ? (

{t.checkoutPage.deliveryTimeNote}

) : null}
{fulfillment === "VERSAND" && packstationSearchAvailable ? (
{(["ADRESSE", "PACKSTATION"] as const).map((option) => ( ))}
) : null} {fulfillment === "VERSAND" && !usesPackstation ? (
) : null} {usesPackstation ? (

{t.checkoutPage.packstationIntro}

{selectedPackstation ? (

{t.checkoutPage.packstationSelectedLabel}

{selectedPackstation.name}

{selectedPackstation.street ? `${selectedPackstation.street}, ` : ""} {selectedPackstation.zip} {selectedPackstation.city}

) : ( <>
{packstationError ? (

{packstationError}

) : null} {packstationResults?.length === 0 ? (

{t.checkoutPage.packstationNoResults}

) : null} {packstationResults && packstationResults.length > 0 ? (
    {packstationResults.map((packstation) => (
  • ))}
) : null} )}

{t.checkoutPage.postNumberHint}

) : null}
{isGift ? (