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:
Marco
2026-07-19 21:49:30 +00:00
parent 59b73f6f1f
commit f2c05deaed
7 changed files with 406 additions and 0 deletions
+380
View File
@@ -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 &amp; 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 (24 Werktage)", price: SHIPPING_COST },
{ id: "express", label: "Expressversand (12 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 &amp; 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>
</>
);
}