From 90be4696af9c0f23318af4ad752328712fd04460 Mon Sep 17 00:00:00 2001
From: Marco
Date: Sun, 19 Jul 2026 23:57:03 +0000
Subject: [PATCH] feat: add /bestellbestaetigung order confirmation page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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").
---
.../components/BestellbestaetigungContent.tsx | 243 ++++++++++++++++++
app/bestellbestaetigung/page.tsx | 28 ++
app/checkout/components/CheckoutContent.tsx | 80 ++----
app/components/CheckoutSteps.tsx | 59 +++++
app/lib/cart.ts | 8 +
app/lib/order.ts | 23 ++
.../bestellbestaetigung-testimonial-photo.jpg | Bin 0 -> 12965 bytes
7 files changed, 389 insertions(+), 52 deletions(-)
create mode 100644 app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
create mode 100644 app/bestellbestaetigung/page.tsx
create mode 100644 app/components/CheckoutSteps.tsx
create mode 100644 app/lib/order.ts
create mode 100644 public/bestellbestaetigung-testimonial-photo.jpg
diff --git a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
new file mode 100644
index 0000000..a3e35d5
--- /dev/null
+++ b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
@@ -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(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 (
+
+
+ Keine Bestellung gefunden
+
+
+ Hier gibt es gerade nichts zu bestätigen — vielleicht ist die Sitzung abgelaufen.
+
+
+ Zum Shop
+
+
+ );
+ }
+
+ 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. */}
+
+
+
+
+ {/* Hero — success badge, headline, short recap */}
+
+
+
+
+
+
+
+
+
+ Vielen Dank!
+
+
+ Deine Bestellung ist bei uns eingegangen.
+
+
+
+ {!productsLoading && (
+
+
Deine Bestellung macht sich jetzt auf den Weg zu dir.
+
+ Du erhältst in Kürze eine Bestellbestätigung per E-Mail mit allen Details.
+
+ Wir versenden in der Regel innerhalb von 1–2 Werktagen.
+
+
+ Du erhältst eine E-Mail, sobald dein Paket unterwegs ist.
+
+
+
+ )}
+
+ {/* 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. */}
+
+
+
+
+
+
+
+ „Produktivität beginnt nicht mit mehr – sondern mit dem, was wirklich zählt.“
+
+
+
Björn
+
+
+ >
+ );
+}
diff --git a/app/bestellbestaetigung/page.tsx b/app/bestellbestaetigung/page.tsx
new file mode 100644
index 0000000..56a4478
--- /dev/null
+++ b/app/bestellbestaetigung/page.tsx
@@ -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 (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx
index 935be16..be665ec 100644
--- a/app/checkout/components/CheckoutContent.tsx
+++ b/app/checkout/components/CheckoutContent.tsx
@@ -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 (
-
-
-
- );
- }
- if (state === "active") {
- return (
-
- {number}
-
- );
- }
- return (
-
- {number}
-
- );
-}
-
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({
Checkout
-
- {steps.map((step, i) => (
-
-
-
-
-
- {step.label}
-
-
-
- {i < steps.length - 1 && }
-
- ))}
-
+
Jetzt kaufen (zahlungspflichtig)
@@ -433,7 +409,7 @@ export function CheckoutContent({
shouldn't promise emails as if opting in were already
decided either. */}
- Wenn du oben zustimmst, bekommst du nach deiner Bestellung regelmäßig Impulse & Tipps per E-Mail.
+ Wenn du zustimmst, bekommst du nach deiner Bestellung regelmäßig Impulse & Tipps per E-Mail.
Für mehr Klarheit, Fokus und Struktur – jede Woche.
diff --git a/app/components/CheckoutSteps.tsx b/app/components/CheckoutSteps.tsx
new file mode 100644
index 0000000..03ede26
--- /dev/null
+++ b/app/components/CheckoutSteps.tsx
@@ -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 (
+
+
+
+ );
+ }
+ if (state === "active") {
+ return (
+
+ {number}
+
+ );
+ }
+ return (
+
+ {number}
+
+ );
+}
+
+// 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 (
+
+ );
+}
diff --git a/app/lib/cart.ts b/app/lib/cart.ts
index ab09f70..aaa0a74 100644
--- a/app/lib/cart.ts
+++ b/app/lib/cart.ts
@@ -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.
diff --git a/app/lib/order.ts b/app/lib/order.ts
new file mode 100644
index 0000000..a66e85b
--- /dev/null
+++ b/app/lib/order.ts
@@ -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}`;
+}
diff --git a/public/bestellbestaetigung-testimonial-photo.jpg b/public/bestellbestaetigung-testimonial-photo.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..9343e4e35b9faa2102f45d7677f55cf09ba7ca13
GIT binary patch
literal 12965
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