feat: add /bestellbestaetigung order confirmation page

Matches the Figma mockup (checkmark hero, order summary card,
delivery-status panel, testimonial band) with the checkout's 4-step
bar inserted (all steps done) and the "Bis dahin: Lass dich
inspirieren" block omitted, per request.

Extracted the step bar into a shared CheckoutSteps component so
/checkout and /bestellbestaetigung don't duplicate it. The actually-
selected shipping cost and payment method are captured into a
sessionStorage snapshot by /checkout's "Jetzt kaufen" click (there's
no real order backend, so this click is what "placing the order"
means here) and read back on the confirmation page — not just
defaulted to the first active method of each, so the receipt matches
what the shopper actually picked. Also tightened the checkout
newsletter-consent copy ("Wenn du zustimmst" instead of "Wenn du
oben zustimmst").
This commit is contained in:
Marco
2026-07-19 23:57:03 +00:00
parent f973eccca6
commit 90be4696af
7 changed files with 389 additions and 52 deletions
@@ -0,0 +1,243 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import type { CartItem } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { formatPrice, formatDate } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
export function BestellbestaetigungContent() {
const products = useProducts();
const [order, setOrder] = useState<OrderSnapshot | null>(null);
const [checked, setChecked] = useState(false);
// The snapshot was already written (and the cart already cleared) by
// /checkout's "Jetzt kaufen" click, before it ever navigated here — see
// CheckoutContent.tsx's handlePurchase(). This is a pure read of that
// browser-only value, deferred to an effect (not a lazy useState
// initializer) purely to avoid an SSR/hydration mismatch: the server
// has no sessionStorage, so it must render the same "nothing yet" state
// the client shows before this effect runs.
useEffect(() => {
try {
const raw = window.sessionStorage.getItem(ORDER_KEY);
// eslint-disable-next-line react-hooks/set-state-in-effect -- reading a browser-only store on mount to avoid an SSR/hydration mismatch, see comment above
if (raw) setOrder(JSON.parse(raw));
} catch {
// ignore — falls through to the "no order" state below
}
setChecked(true);
}, []);
if (!checked) return null;
if (!order) {
return (
<Reveal className="flex flex-col gap-6 items-center text-center pt-20 pb-16 px-[var(--layout-padding-x)] w-full">
<p
className="font-semibold text-h-feature text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Keine Bestellung gefunden
</p>
<p className="text-body text-text-muted max-w-[28rem]">
Hier gibt es gerade nichts zu bestätigen vielleicht ist die Sitzung abgelaufen.
</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>
);
}
const productsLoading = products.length === 0;
const items = order.items
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
.filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
const total = subtotal + order.shippingCost;
return (
<>
{/* items-start + same pt-8/px as /checkout's own header block — not
centered, so the bar sits at the exact same left edge/position a
shopper just saw on the previous step. */}
<Reveal className="flex flex-col items-start pt-8 pb-2 px-[var(--layout-padding-x)] w-full">
<CheckoutSteps current={5} />
</Reveal>
{/* Hero — success badge, headline, short recap */}
<Reveal className="flex flex-col gap-4 items-center text-center pt-12 pb-10 px-[var(--layout-padding-x)] w-full">
<div className="relative flex items-center justify-center size-24 mb-2">
<div className="absolute inset-0 rounded-full bg-brand/10" />
<div className="relative flex items-center justify-center size-14 rounded-full border-2 border-text-primary bg-bg-base">
<svg viewBox="0 0 20 20" className="size-6 text-brand" fill="none" aria-hidden="true">
<path d="m4 10 4 4 8-8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
Vielen Dank!
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Deine Bestellung ist bei uns eingegangen.
</p>
<div className="h-[0.125rem] w-8 bg-brand" />
{!productsLoading && (
<div className="flex flex-col gap-1 max-w-[26rem]">
<p className="text-body text-text-muted">Deine Bestellung macht sich jetzt auf den Weg zu dir.</p>
<p className="text-body text-text-muted">
Du erhältst in Kürze eine Bestellbestätigung per E-Mail mit allen Details.
</p>
</div>
)}
</Reveal>
{!productsLoading && (
<Reveal
delay={0.05}
className="w-full max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
>
<div className="flex-1 p-6 md:p-8 flex flex-col sm:flex-row gap-8">
{/* Order meta */}
<div className="flex flex-row flex-wrap sm:flex-col gap-6 sm:w-36 sm:shrink-0">
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Bestellnummer</p>
<p className="font-bold text-body-sm text-text-primary whitespace-nowrap">{order.orderNumber}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Bestelldatum</p>
<p className="font-bold text-body-sm text-text-primary whitespace-nowrap">
{formatDate(order.orderDateIso)}
</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Zahlungsmethode</p>
<p className="font-bold text-body-sm text-text-primary whitespace-nowrap">{order.paymentMethodTitle}</p>
</div>
</div>
{/* Items + totals */}
<div className="flex-1 flex flex-col gap-5 min-w-0">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Bestellübersicht
</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)} <span>inkl. MwSt.</span>
</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 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">
{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}
</span>
</div>
<div className="h-px bg-border w-full" />
<div className="flex items-center w-full">
<span
className="font-semibold text-h4 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Gesamtbetrag
</span>
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
</div>
</div>
{/* Delivery status panel */}
<div className="w-full lg:w-[18rem] lg:shrink-0 bg-bg-muted p-7 flex flex-col items-center text-center gap-3 justify-center">
<div className="flex items-center justify-center size-14 rounded-full bg-brand/10 text-text-primary shrink-0">
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
<path d="M2 6.5h11v10H2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M13 10.5h4l3.5 3v3H13z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<circle cx="6" cy="17.5" r="1.6" stroke="currentColor" strokeWidth="1.6" />
<circle cx="17" cy="17.5" r="1.6" stroke="currentColor" strokeWidth="1.6" />
</svg>
</div>
<p className="font-semibold text-body text-text-primary">Deine Bestellung wird bearbeitet.</p>
<p className="text-body-sm text-text-muted">
Wir versenden in der Regel innerhalb von 12 Werktagen.
</p>
<p className="text-body-sm text-text-muted">
Du erhältst eine E-Mail, sobald dein Paket unterwegs ist.
</p>
</div>
</Reveal>
)}
{/* Testimonial band — same photo+quote pattern as /not-found (see
that page's own comment); bestellbestaetigung-testimonial-photo.jpg
is cropped from the actual mockup pixels the same way. Explicit
md:h- (not just min-h-), so the image and quote columns are
pinned to an identical height regardless of how many lines the
quote wraps to — a min-height alone lets the taller of the two
content-driven columns stretch the row past what the other side
visually fills up to. */}
<Reveal className="relative w-full flex items-stretch h-[20rem] md:h-[22rem] bg-bg-muted overflow-hidden">
<div className="relative w-full md:w-[45%] shrink-0">
<img
alt=""
src="/bestellbestaetigung-testimonial-photo.jpg"
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
</div>
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16 py-12">
<p
className="font-semibold text-h-section text-text-primary leading-[1.3] max-w-[28rem]"
style={{ fontFamily: "var(--font-lora)" }}
>
Produktivität beginnt nicht mit mehr sondern mit dem, was wirklich zählt.
</p>
<div className="h-[0.125rem] w-8 bg-brand" />
<p className="text-body text-text-muted">Björn</p>
</div>
</Reveal>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { BestellbestaetigungContent } from "./components/BestellbestaetigungContent";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
// robots: noindex — transactional page, same reasoning as /cart and
// /checkout (this one doubles as a receipt, not something to surface in
// search results).
export const metadata: Metadata = {
title: "Bestellbestätigung",
description: "Deine Bestellung bei einfach produktiv wurde erfolgreich aufgegeben.",
robots: {
index: false,
follow: true,
},
};
export default function BestellbestaetigungPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<BestellbestaetigungContent />
<TrustRow />
</main>
<Footer />
</>
);
}
+28 -52
View File
@@ -3,44 +3,15 @@
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useCart } from "../../lib/cart";
import { useCart, clearCart } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, generateOrderNumber, type OrderSnapshot } from "../../lib/order";
import type { ShippingMethod, PaymentMethod, TrustBadge } from "../../lib/payload";
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,
wrapperClassName = "flex-1 min-w-0",
@@ -86,6 +57,28 @@ export function CheckoutContent({
subtotal >= selectedShipping.freeShippingThreshold;
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
const total = subtotal + shipping;
const selectedPayment = paymentMethods.find((m) => m.id === paymentMethodId) ?? null;
// Captures the actually-selected shipping/payment method as the order
// snapshot /bestellbestaetigung reads — see lib/order.ts's own comment,
// there's no real order backend so this click IS what "placing the
// order" means here.
function handlePurchase() {
const snapshot: OrderSnapshot = {
items: cart,
orderNumber: generateOrderNumber(),
orderDateIso: new Date().toISOString(),
shippingCost: shipping,
paymentMethodTitle: selectedPayment?.title ?? "—",
};
try {
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
} catch {
// sessionStorage unavailable (private browsing etc.) — the
// confirmation page falls back to its own empty state.
}
clearCart();
}
if (!productsLoading && items.length === 0) {
return (
@@ -121,25 +114,7 @@ export function CheckoutContent({
<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>
<CheckoutSteps current={2} />
<div className="flex flex-col gap-2">
<p
@@ -322,6 +297,7 @@ export function CheckoutContent({
<Link
href="/bestellbestaetigung"
onClick={handlePurchase}
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)
@@ -433,7 +409,7 @@ export function CheckoutContent({
shouldn't promise emails as if opting in were already
decided either. */}
<p className="text-body-sm leading-[1.45]">
Wenn du oben zustimmst, bekommst du nach deiner Bestellung regelmäßig Impulse &amp; Tipps per E-Mail.
Wenn du zustimmst, bekommst du nach deiner Bestellung 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.
+59
View File
@@ -0,0 +1,59 @@
const STEP_LABELS = ["Warenkorb", "Adresse", "Zahlung", "Abschluss"];
type StepState = "done" | "active" | "upcoming";
function StepCircle({ state, number }: { state: StepState; 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>
);
}
// Shared by /checkout (mid-flow, one step active) and /bestellbestaetigung
// (every step done — `current` past the last step number makes every
// stepNum < current true, so passing STEP_LABELS.length + 1 there marks
// the whole bar complete without a separate "all done" branch).
export function CheckoutSteps({ current }: { current: number }) {
return (
<div className="flex items-center w-full max-w-[50rem]">
{STEP_LABELS.map((label, i) => {
const stepNum = i + 1;
const state: StepState = stepNum < current ? "done" : stepNum === current ? "active" : "upcoming";
return (
<div key={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={state} number={stepNum} />
<span
className={`hidden sm:block text-body-sm whitespace-nowrap ${
state === "upcoming" ? "text-text-muted" : "font-semibold text-text-primary"
}`}
>
{label}
</span>
</div>
</div>
{i < STEP_LABELS.length - 1 && <div className="h-px bg-border flex-1 mx-4 min-w-4" />}
</div>
);
})}
</div>
);
}
+8
View File
@@ -39,6 +39,14 @@ export function removeFromCart(id: string) {
writeCart(readCart().filter((i) => i.id !== id));
}
// Called by /bestellbestaetigung once it has captured a snapshot of the
// cart to display — there's no real order backend here, so "placing an
// order" just means the local cart empties out the same way it would
// after a real purchase completes.
export function clearCart() {
writeCart([]);
}
// qty <= 0 removes the item outright — the cart page's quantity stepper
// never lets the visible count go below 1, but this keeps the function
// itself safe to call with any integer without a separate remove path.
+23
View File
@@ -0,0 +1,23 @@
import type { CartItem } from "./cart";
// sessionStorage, not localStorage — this is a one-time receipt for the
// tab that just placed the order, not something that should persist
// forever. Written by /checkout's "Jetzt kaufen" click (capturing
// whichever shipping/payment method was actually selected there — the
// site has no real order backend, so this snapshot IS the order record),
// read once by /bestellbestaetigung.
export const ORDER_KEY = "ep_last_order";
export type OrderSnapshot = {
items: CartItem[];
orderNumber: string;
orderDateIso: string;
shippingCost: number;
paymentMethodTitle: string;
};
export function generateOrderNumber(): string {
const year = new Date().getFullYear();
const rand = Math.floor(1000 + Math.random() * 9000);
return `#EP-${year}-${rand}`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB