Add /checkout page
Fixes the dead "Zur Kasse gehen" link on /cart. Built against the actual Figma frame (page-checkout, node 4761:465, jCCZyh1DGwdjpv1wGge9To): breadcrumb, 4-step progress stepper (Warenkorb done / Adresse active / Zahlung+Abschluss upcoming), Rechnungsadresse form, Versandart (real radio toggle — switching to Express actually updates the shown total), Zahlungsart (Kreditkarte/PayPal/Überweisung with the real exported payment-network logos), sidebar order summary reusing the same cart/products data as /cart, trust bullets, and a newsletter hint box (divider under the text, not the icon — deliberately different from /cart's own version per this page's own Figma spec). No real payment processing — "Jetzt kaufen" links to /bestellbestaetigung (not yet built) same as the rest of this prototype site's forms (cart, newsletter signup) don't hit a real backend either. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useCart } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
const EXPRESS_SHIPPING_COST = 4.9;
|
||||
|
||||
const steps = [
|
||||
{ label: "Warenkorb", state: "done" as const },
|
||||
{ label: "Adresse", state: "active" as const },
|
||||
{ label: "Zahlung", state: "upcoming" as const },
|
||||
{ label: "Abschluss", state: "upcoming" as const },
|
||||
];
|
||||
|
||||
function StepCircle({ state, number }: { state: "done" | "active" | "upcoming"; number: number }) {
|
||||
if (state === "done") {
|
||||
return (
|
||||
<div className="flex size-9 items-center justify-center rounded-full bg-brand text-text-primary">
|
||||
<svg viewBox="0 0 20 20" className="size-4" fill="none" aria-hidden="true">
|
||||
<path d="m4 10 4 4 8-8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === "active") {
|
||||
return (
|
||||
<div className="flex size-9 items-center justify-center rounded-full bg-brand font-bold text-body-sm text-bg-white">
|
||||
{number}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex size-9 items-center justify-center rounded-full border border-border font-bold text-body-sm text-text-muted">
|
||||
{number}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
...props
|
||||
}: { label: string } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<label className="flex flex-col gap-2 items-start flex-1 min-w-0">
|
||||
<span className="text-label text-text-muted">{label}</span>
|
||||
<input
|
||||
{...props}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckoutContent() {
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
const [shippingMethod, setShippingMethod] = useState<"standard" | "express">("standard");
|
||||
const [paymentMethod, setPaymentMethod] = useState<"card" | "paypal" | "bank">("card");
|
||||
|
||||
const productsLoading = products.length === 0 && cart.length > 0;
|
||||
const items = cart
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
|
||||
const freeShipping = subtotal >= FREE_SHIPPING_THRESHOLD;
|
||||
const shipping = items.length === 0 || freeShipping
|
||||
? 0
|
||||
: shippingMethod === "express"
|
||||
? EXPRESS_SHIPPING_COST
|
||||
: SHIPPING_COST;
|
||||
const total = subtotal + shipping;
|
||||
|
||||
if (!productsLoading && items.length === 0) {
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full">
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Checkout
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Dein Warenkorb ist leer — es gibt noch nichts zum Abschließen.
|
||||
</p>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zum Shop
|
||||
</Link>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header — breadcrumb, stepper, title */}
|
||||
<Reveal className="flex flex-col gap-8 items-start pt-8 pb-6 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<Link href="/cart" className="hover:text-brand transition-colors">Warenkorb</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Checkout</span>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center w-full max-w-[50rem]">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="flex items-center flex-1 last:flex-initial">
|
||||
<div className="flex flex-col items-center gap-0 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<StepCircle state={step.state} number={i + 1} />
|
||||
<span
|
||||
className={`hidden sm:block text-body-sm whitespace-nowrap ${
|
||||
step.state === "upcoming" ? "text-text-muted" : "font-semibold text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{i < steps.length - 1 && <div className="h-px bg-border flex-1 mx-4 min-w-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Checkout
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Fast geschafft! Nur noch ein paar Angaben.</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
{/* Form column */}
|
||||
<div className="w-full lg:flex-1 flex flex-col gap-6 items-start min-w-0">
|
||||
{/* 1. Rechnungsadresse */}
|
||||
<Reveal className="w-full bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
1. Rechnungsadresse
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField label="Vorname" type="text" placeholder="Max" autoComplete="given-name" />
|
||||
<FormField label="Nachname" type="text" placeholder="Mustermann" autoComplete="family-name" />
|
||||
</div>
|
||||
<FormField label="E-Mail-Adresse" type="email" placeholder="max@beispiel.de" autoComplete="email" />
|
||||
<FormField label="Straße und Hausnummer" type="text" placeholder="Musterstraße 1" autoComplete="street-address" />
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField label="PLZ" type="text" placeholder="10115" autoComplete="postal-code" />
|
||||
<FormField label="Ort" type="text" placeholder="Berlin" autoComplete="address-level2" />
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
defaultValue="Deutschland"
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
<option>Deutschland</option>
|
||||
<option>Österreich</option>
|
||||
<option>Schweiz</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex gap-3 items-start w-full cursor-pointer">
|
||||
<input type="checkbox" className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand" />
|
||||
<span className="flex flex-col gap-1 text-body-sm text-text-primary">
|
||||
Ich möchte regelmäßig Impulse & Tipps per E-Mail erhalten.
|
||||
<span className="text-label text-text-muted">Du kannst dich jederzeit mit einem Klick abmelden.</span>
|
||||
</span>
|
||||
</label>
|
||||
</Reveal>
|
||||
|
||||
{/* 2. Versandart */}
|
||||
<Reveal delay={0.05} className="w-full bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
2. Versandart
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
{ id: "standard", label: "Standardversand (2–4 Werktage)", price: SHIPPING_COST },
|
||||
{ id: "express", label: "Expressversand (1–2 Werktage)", price: EXPRESS_SHIPPING_COST },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<label key={option.id} className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="shipping"
|
||||
checked={shippingMethod === option.id}
|
||||
onChange={() => setShippingMethod(option.id)}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 flex flex-col gap-0.5">
|
||||
<span className="text-body-sm text-text-primary">{option.label}</span>
|
||||
<span className="text-label text-text-muted">innerhalb Deutschlands</span>
|
||||
</span>
|
||||
<span className="text-body-sm text-text-primary whitespace-nowrap">
|
||||
{freeShipping ? "Kostenlos" : formatPrice(option.price)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</Reveal>
|
||||
|
||||
{/* 3. Zahlungsart */}
|
||||
<Reveal delay={0.1} className="w-full bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
3. Zahlungsart
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethod === "card"}
|
||||
onChange={() => setPaymentMethod("card")}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">Kreditkarte</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
<img alt="Visa" src="/icon-payment-visa.png" className="h-[1.1875rem] w-auto" />
|
||||
<img alt="Mastercard" src="/icon-payment-mastercard.png" className="h-[1.1875rem] w-auto" />
|
||||
<img alt="American Express" src="/icon-payment-amex.png" className="h-[1.1875rem] w-auto" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethod === "paypal"}
|
||||
onChange={() => setPaymentMethod("paypal")}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">PayPal</span>
|
||||
<img alt="PayPal" src="/icon-payment-paypal.png" className="h-5 w-auto shrink-0" />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethod === "bank"}
|
||||
onChange={() => setPaymentMethod("bank")}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">Überweisung</span>
|
||||
<img alt="" src="/icon-payment-bank.png" className="h-[1.3125rem] w-auto shrink-0" />
|
||||
</label>
|
||||
|
||||
<Link
|
||||
href="/bestellbestaetigung"
|
||||
className="w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt kaufen (zahlungspflichtig)
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-2 items-center justify-center w-full">
|
||||
<img alt="" src="/icon-lock.svg" className="w-3.5 h-4" />
|
||||
<span className="text-body-sm text-text-muted">Sichere SSL-Verschlüsselung</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Reveal delay={0.15} className="w-full lg:w-[24.375rem] lg:shrink-0 flex flex-col gap-6 items-start">
|
||||
<div className="bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Deine Bestellung
|
||||
</p>
|
||||
|
||||
{items.map(({ entry, product }) => (
|
||||
<div key={product.id} className="flex gap-4 items-center w-full">
|
||||
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm text-text-primary">{product.name}</p>
|
||||
<p className="text-label text-text-muted">{entry.qty} × {formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary whitespace-nowrap">{formatPrice(entry.qty * product.price)}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Zwischensumme</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Versand</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">innerhalb Deutschlands</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span
|
||||
className="font-semibold text-h4 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
{[
|
||||
{ icon: "/icon-lock.svg", title: "Sichere Zahlung", desc: "Deine Daten sind bei uns sicher und geschützt." },
|
||||
{ icon: "/icon-trust-return.png", title: "14 Tage Rückgaberecht", desc: "Nicht zufrieden? Sende deine Bestellung innerhalb von 14 Tagen zurück." },
|
||||
{ icon: "/icon-trust-leaf.png", title: "Nachhaltig verpackt", desc: "Wir achten auf umweltfreundliche Materialien und plastikfreien Versand." },
|
||||
].map((b) => (
|
||||
<div key={b.title} className="flex gap-3 items-start w-full">
|
||||
<img alt="" src={b.icon} className="size-5 shrink-0 object-contain mt-0.5" />
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm font-semibold text-text-primary">{b.title}</p>
|
||||
<p className="text-label text-text-muted">{b.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Newsletter hint — divider under the TEXT, not the icon, per
|
||||
this page's own Figma spec (different from /cart's version,
|
||||
which puts the divider under the icon — the two aren't meant
|
||||
to be identical, see project notes on this pattern). */}
|
||||
<div className="bg-bg-muted flex gap-5 items-start p-6 rounded-md w-full">
|
||||
<img alt="" src="/icon-envelope-hint.png" className="h-8 w-10 shrink-0 object-contain" />
|
||||
<div className="flex flex-col gap-2 items-start flex-1 min-w-0 text-text-primary">
|
||||
<p className="text-body-sm leading-[1.45]">
|
||||
Nach deiner Bestellung bekommst du regelmäßig Impulse & Tipps per E-Mail.
|
||||
</p>
|
||||
<p className="text-body-sm leading-[1.45]">
|
||||
Für mehr Klarheit, Fokus und Struktur – jede Woche.
|
||||
</p>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<div className="h-[0.1875rem] w-5 bg-brand" />
|
||||
<div className="h-[0.1875rem] w-2.5 bg-brand" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user