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 />
</>
);
}