Files
Wabenhain/src/app/checkout/checkout-form.tsx
T
Nick Szaunig 0b2196460a feat: Kaufjourney-Optimierungen fuer Kund:innen (Shop, Produktseite, Warenkorb, Checkout)
Konkrete Reibungspunkte entlang der Kauf-Journey behoben:
- Shop-Suche als sichtbares, editierbares Feld im Filter-Formular statt nur
  in der kleinen Kopfzeilen-Suche — Suche/Kategorie/Sortierung/Verfuegbarkeit
  lassen sich jetzt in beide Richtungen kombinieren, ohne sich gegenseitig
  zurueckzusetzen; plus "Suche zuruecksetzen"-Link.
- Versandkosten- und Lieferzeit-Hinweis direkt auf der Produktdetailseite
  (bisher erst im Warenkorb/Checkout sichtbar).
- Produktgalerie per Touch-Wisch bedienbar, nicht nur ueber die
  Vorschau-Kacheln (mobile Kaufentscheidung).
- Immer sichtbare mobile Kaufleiste auf der Produktseite, damit der
  Warenkorb-Button auf langen Seiten (Bewertungen, verwandte Produkte) nicht
  wieder erst erscrollt werden muss.
- Live-Lagerbestand (Ausverkauft/Nur noch N) jetzt auch im Cart-Drawer und
  auf der Warenkorb-Seite sichtbar, nicht nur auf der Produktkarte — neuer
  Endpunkt /api/shop/cart-stock + useCartStock-Hook.
- Checkout- und Adressformulare: autoComplete/inputMode/type auf allen
  Feldern (given-name, tel, postal-code, ...) fuer Mobil-Autofill und
  passende Bildschirmtastaturen.
- Bestellpositionen in "Meine Bestellungen" und der Bestellverfolgung
  verlinken jetzt zur Produktseite (samt Bewertungs-Anker) — bisher war die
  Bewertungsfunktion nach dem Kauf nur durch manuelles Zurueeknavigieren
  auffindbar.

Neue Tests fuer die Lagerbestand-Hinweise im Warenkorb; tsc/vitest/build
bleiben gruen (142 Tests).
2026-09-05 18:02:38 +02:00

329 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, type FormEvent, type HTMLAttributes } from "react";
import { useRouter } from "next/navigation";
import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
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";
type Fulfillment = "VERSAND" | "ABHOLUNG";
export interface CheckoutInitialValues {
firstName: string;
lastName: string;
email: string;
phone: string;
street: string;
zip: string;
city: string;
}
export function CheckoutForm({
initialValues,
tiers,
bulkDiscountTiers,
}: {
initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[];
bulkDiscountTiers: BulkDiscountTierLike[];
}) {
const { t } = useI18n();
const items = useCartStore((s) => s.items);
const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items);
const router = useRouter();
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isGift, setIsGift] = useState(false);
// 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();
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,
street: String(formData.get("street") ?? "") || undefined,
zip: String(formData.get("zip") ?? "") || undefined,
city: String(formData.get("city") ?? "") || undefined,
country: "DE",
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,
};
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) {
if (data.orderNumber) {
router.push(`/checkout/success?order=${data.orderNumber}&pending=1`);
return;
}
setError(data.error ?? t.checkoutPage.genericError);
setSubmitting(false);
return;
}
window.location.href = data.url;
} catch {
setError(t.checkoutPage.genericError);
setSubmitting(false);
}
}
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/60">
{t.checkoutPage.deliveryTimeNote}
</p>
) : null}
</div>
{fulfillment === "VERSAND" ? (
<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}
<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 className="text-sm text-red-700">{error}</p> : 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)}</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)}</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)}</span>
</div>
{activeBulkDiscount ? (
<div className="flex justify-between text-forest">
<span>
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
</span>
<span>-{formatPrice(bulkDiscountCents)}</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)}</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>
);
}