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).
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|