Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.
1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
"NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
diese Bestellung je zu bezahlen.
Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
Altbestaende trotzdem ab (Filter entfernt).
2. Kaufabbrueche standen als Bestellungen im Kundenkonto
getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
Bestellnummer und Summe in der Liste und sah aus wie ein zustande
gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
"Zahlung ausstehend" statt "Neu".
3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
Payment.status auf REFUNDED.
4. Admin-Storno schloss die offene Stripe-Sitzung nicht
Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
nicht mehr offene Bestellung laut, statt sie zu verschweigen.
5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
charge.refunded und charge.dispute.created werden jetzt verarbeitet:
eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
Vermerk an der Bestellung. preflight prueft beide Abonnements mit.
Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.
tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
561 lines
22 KiB
TypeScript
561 lines
22 KiB
TypeScript
"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<string | null>(null);
|
||
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [isGift, setIsGift] = useState(false);
|
||
|
||
const [deliveryTarget, setDeliveryTarget] = useState<DeliveryTarget>("ADRESSE");
|
||
const [packstationZip, setPackstationZip] = useState("");
|
||
const [packstationResults, setPackstationResults] = useState<Packstation[] | null>(null);
|
||
const [packstationSearching, setPackstationSearching] = useState(false);
|
||
const [packstationError, setPackstationError] = useState<string | null>(null);
|
||
const [selectedPackstation, setSelectedPackstation] = useState<Packstation | null>(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<HTMLParagraphElement>(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<HTMLFormElement>) {
|
||
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 <CheckoutLoading />;
|
||
}
|
||
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="py-20">
|
||
<Container>
|
||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.emptyCart} />
|
||
</Container>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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">
|
||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
||
|
||
<div className="grid sm:grid-cols-2 gap-4">
|
||
<Field
|
||
name="firstName"
|
||
label={t.checkoutPage.firstName}
|
||
defaultValue={initialValues?.firstName}
|
||
autoComplete="given-name"
|
||
required
|
||
/>
|
||
<Field
|
||
name="lastName"
|
||
label={t.checkoutPage.lastName}
|
||
defaultValue={initialValues?.lastName}
|
||
autoComplete="family-name"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="grid sm:grid-cols-2 gap-4">
|
||
<Field
|
||
name="email"
|
||
label={t.checkoutPage.email}
|
||
type="email"
|
||
defaultValue={initialValues?.email}
|
||
autoComplete="email"
|
||
required
|
||
/>
|
||
<Field
|
||
name="phone"
|
||
label={t.checkoutPage.phoneOptional}
|
||
type="tel"
|
||
inputMode="tel"
|
||
defaultValue={initialValues?.phone}
|
||
autoComplete="tel"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
|
||
<div className="flex gap-4">
|
||
{(["VERSAND", "ABHOLUNG"] as const).map((option) => (
|
||
<button
|
||
type="button"
|
||
key={option}
|
||
onClick={() => setFulfillment(option)}
|
||
className={`flex-1 border px-4 py-3 text-sm text-left transition-colors ${
|
||
fulfillment === option
|
||
? "border-forest bg-forest text-cream"
|
||
: "border-ink/20 text-ink/70"
|
||
}`}
|
||
>
|
||
{option === "VERSAND" ? t.checkoutPage.shippingOption : t.checkoutPage.pickupOption}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{fulfillment === "VERSAND" ? (
|
||
<p className="mt-2 text-xs text-ink/70">
|
||
{t.checkoutPage.deliveryTimeNote}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{fulfillment === "VERSAND" && packstationSearchAvailable ? (
|
||
<div className="flex gap-3">
|
||
{(["ADRESSE", "PACKSTATION"] as const).map((option) => (
|
||
<button
|
||
key={option}
|
||
type="button"
|
||
onClick={() => setDeliveryTarget(option)}
|
||
aria-pressed={deliveryTarget === option}
|
||
className={`flex-1 border px-4 py-3 text-sm transition-colors ${
|
||
deliveryTarget === option
|
||
? "border-forest bg-forest text-cream"
|
||
: "border-ink/20 hover:border-forest"
|
||
}`}
|
||
>
|
||
{option === "ADRESSE"
|
||
? t.checkoutPage.deliveryTargetAddress
|
||
: t.checkoutPage.deliveryTargetPackstation}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{fulfillment === "VERSAND" && !usesPackstation ? (
|
||
<div className="space-y-4">
|
||
<Field
|
||
name="street"
|
||
label={t.checkoutPage.streetLabel}
|
||
defaultValue={initialValues?.street}
|
||
autoComplete="street-address"
|
||
required
|
||
/>
|
||
<div className="grid sm:grid-cols-2 gap-4">
|
||
<Field
|
||
name="zip"
|
||
label={t.checkoutPage.zipLabel}
|
||
defaultValue={initialValues?.zip}
|
||
autoComplete="postal-code"
|
||
inputMode="numeric"
|
||
required
|
||
/>
|
||
<Field
|
||
name="city"
|
||
label={t.checkoutPage.cityLabel}
|
||
defaultValue={initialValues?.city}
|
||
autoComplete="address-level2"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{usesPackstation ? (
|
||
<div className="space-y-4 border border-ink/15 p-4">
|
||
<p className="text-xs text-ink/70">{t.checkoutPage.packstationIntro}</p>
|
||
|
||
{selectedPackstation ? (
|
||
<div className="flex items-start justify-between gap-4 border border-forest/40 bg-forest/5 p-3">
|
||
<div className="text-sm">
|
||
<p className="text-xs tracking-wide-caps text-ink/70">
|
||
{t.checkoutPage.packstationSelectedLabel}
|
||
</p>
|
||
<p className="mt-1 text-forest">{selectedPackstation.name}</p>
|
||
<p className="text-ink/70">
|
||
{selectedPackstation.street ? `${selectedPackstation.street}, ` : ""}
|
||
{selectedPackstation.zip} {selectedPackstation.city}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedPackstation(null)}
|
||
className="shrink-0 text-xs text-ink/70 underline"
|
||
>
|
||
{t.checkoutPage.packstationChange}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-end gap-3">
|
||
<label className="block flex-1 text-sm">
|
||
<span className="text-ink/70">{t.checkoutPage.packstationZipLabel}</span>
|
||
<input
|
||
value={packstationZip}
|
||
onChange={(event) => setPackstationZip(event.target.value)}
|
||
inputMode="numeric"
|
||
maxLength={5}
|
||
autoComplete="postal-code"
|
||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={searchPackstations}
|
||
disabled={packstationSearching || packstationZip.trim().length !== 5}
|
||
className="border border-ink/20 bg-background px-4 py-2 text-sm hover:border-forest disabled:opacity-50"
|
||
>
|
||
{packstationSearching
|
||
? t.checkoutPage.packstationSearching
|
||
: t.checkoutPage.packstationSearchButton}
|
||
</button>
|
||
</div>
|
||
|
||
{packstationError ? (
|
||
<p className="text-xs text-red-700">{packstationError}</p>
|
||
) : null}
|
||
|
||
{packstationResults?.length === 0 ? (
|
||
<p className="text-xs text-ink/70">{t.checkoutPage.packstationNoResults}</p>
|
||
) : null}
|
||
|
||
{packstationResults && packstationResults.length > 0 ? (
|
||
<ul className="space-y-2">
|
||
{packstationResults.map((packstation) => (
|
||
<li key={packstation.number}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedPackstation(packstation)}
|
||
className="w-full border border-ink/20 px-3 py-2 text-left text-sm hover:border-forest"
|
||
>
|
||
<span className="text-forest">{packstation.name}</span>
|
||
<span className="block text-xs text-ink/70">
|
||
{packstation.street ? `${packstation.street}, ` : ""}
|
||
{packstation.zip} {packstation.city}
|
||
{packstation.distanceMeters !== null
|
||
? ` — ${t.checkoutPage.packstationDistance(
|
||
Math.round(packstation.distanceMeters)
|
||
)}`
|
||
: ""}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</>
|
||
)}
|
||
|
||
<Field
|
||
name="postNumber"
|
||
label={t.checkoutPage.postNumberLabel}
|
||
inputMode="numeric"
|
||
autoComplete="off"
|
||
required
|
||
/>
|
||
<p className="text-xs text-ink/70">{t.checkoutPage.postNumberHint}</p>
|
||
</div>
|
||
) : null}
|
||
|
||
<Field
|
||
name="discountCode"
|
||
label={t.checkoutPage.discountCodeLabel}
|
||
placeholder={t.checkoutPage.discountCodePlaceholder}
|
||
autoComplete="off"
|
||
/>
|
||
|
||
<div>
|
||
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={isGift}
|
||
onChange={(event) => setIsGift(event.target.checked)}
|
||
className="h-4 w-4 border-ink/20 accent-forest"
|
||
/>
|
||
{t.checkoutPage.isGiftLabel}
|
||
</label>
|
||
{isGift ? (
|
||
<label className="block text-sm mt-3">
|
||
<span className="text-ink/70">{t.checkoutPage.giftMessageLabel}</span>
|
||
<textarea
|
||
name="giftMessage"
|
||
maxLength={500}
|
||
rows={3}
|
||
placeholder={t.checkoutPage.giftMessagePlaceholder}
|
||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest resize-none"
|
||
/>
|
||
</label>
|
||
) : null}
|
||
</div>
|
||
|
||
{error ? (
|
||
<p ref={errorRef} role="alert" tabIndex={-1} className="text-sm text-red-700 outline-none">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
|
||
{turnstileSiteKey ? (
|
||
<div className="mb-4">
|
||
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
||
</div>
|
||
) : null}
|
||
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
||
</Button>
|
||
<p className="text-xs text-ink/65">
|
||
{t.checkoutPage.paymentMethodsNote}
|
||
</p>
|
||
</form>
|
||
|
||
<aside 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) => (
|
||
<li key={item.productId} className="flex justify-between">
|
||
<span>
|
||
{item.quantity} × {item.name}
|
||
</span>
|
||
<span>{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
|
||
<div className="flex justify-between text-ink/70">
|
||
<span>{t.cart.subtotal}</span>
|
||
<span>{formatPrice(subtotal, "EUR", locale)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-ink/70">
|
||
<span>
|
||
{fulfillment === "VERSAND"
|
||
? t.checkoutPage.shippingWithCount(totalQuantity)
|
||
: t.checkoutPage.shippingPickup}
|
||
</span>
|
||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
||
</div>
|
||
{activeBulkDiscount ? (
|
||
<div className="flex justify-between text-forest">
|
||
<span>
|
||
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||
</span>
|
||
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
|
||
<span>{t.cartPage.total}</span>
|
||
<span>{formatPrice(total, "EUR", locale)}</span>
|
||
</div>
|
||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||
</aside>
|
||
</Container>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
name,
|
||
label,
|
||
type = "text",
|
||
inputMode,
|
||
defaultValue,
|
||
placeholder,
|
||
autoComplete,
|
||
required = false,
|
||
}: {
|
||
name: string;
|
||
label: string;
|
||
type?: string;
|
||
/** Zeigt auf Mobilgeraeten die passende Bildschirmtastatur (z.B. Ziffernblock fuer die PLZ). */
|
||
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
|
||
defaultValue?: string;
|
||
placeholder?: string;
|
||
/** Browser-/Mobil-Autofill fuer Adress- und Kontaktfelder (siehe MDN autofill-Tokens) — ohne das muss jede Bestellung komplett von Hand eingetippt werden. */
|
||
autoComplete?: string;
|
||
required?: boolean;
|
||
}) {
|
||
return (
|
||
<label className="block text-sm">
|
||
<span className="text-ink/70">{label}</span>
|
||
<input
|
||
name={name}
|
||
type={type}
|
||
inputMode={inputMode}
|
||
defaultValue={defaultValue}
|
||
placeholder={placeholder}
|
||
autoComplete={autoComplete}
|
||
required={required}
|
||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|