From 7f37f111e8323d7b3015bf53901b7024ae3d26b8 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 22 Jul 2026 06:45:42 +0000 Subject: [PATCH] Add real order persistence, customer accounts, and cart sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkout now persists orders server-side (Payload orders collection, re-priced from live product data, discount codes redeemed exactly once) instead of writing a client-only sessionStorage snapshot. Buying requires an account (registration inline in checkout, no separate step) — accounts get order history with delivery status, profile/address editing, password change, and a cart that syncs across devices while logged in. Co-Authored-By: Claude Sonnet 5 --- README.md | 94 ++++- app/api/account/cart/route.ts | 34 ++ app/api/account/login/route.ts | 17 + app/api/account/logout/route.ts | 7 + app/api/account/me/route.ts | 8 + app/api/account/orders/route.ts | 10 + app/api/account/password/route.ts | 17 + app/api/account/profile/route.ts | 50 +++ app/api/account/register/route.ts | 25 ++ app/api/checkout/route.ts | 151 ++++++++ .../components/BestellbestaetigungContent.tsx | 6 + app/checkout/components/CheckoutContent.tsx | 264 ++++++++++---- app/checkout/page.tsx | 9 +- app/components/CartSync.tsx | 38 ++ app/konto/bestellungen/[orderNumber]/page.tsx | 112 ++++++ app/konto/bestellungen/page.tsx | 84 +++++ app/konto/login/components/LoginForm.tsx | 87 +++++ app/konto/login/page.tsx | 24 ++ app/konto/profil/components/PasswordForm.tsx | 77 ++++ app/konto/profil/components/ProfileForm.tsx | 151 ++++++++ app/konto/profil/page.tsx | 31 ++ app/layout.tsx | 2 + app/lib/cart.ts | 18 + app/lib/customerAuth.ts | 332 ++++++++++++++++++ app/lib/order.ts | 14 +- app/lib/orderServer.ts | 104 ++++++ app/lib/productsServer.ts | 20 ++ 27 files changed, 1697 insertions(+), 89 deletions(-) create mode 100644 app/api/account/cart/route.ts create mode 100644 app/api/account/login/route.ts create mode 100644 app/api/account/logout/route.ts create mode 100644 app/api/account/me/route.ts create mode 100644 app/api/account/orders/route.ts create mode 100644 app/api/account/password/route.ts create mode 100644 app/api/account/profile/route.ts create mode 100644 app/api/account/register/route.ts create mode 100644 app/api/checkout/route.ts create mode 100644 app/components/CartSync.tsx create mode 100644 app/konto/bestellungen/[orderNumber]/page.tsx create mode 100644 app/konto/bestellungen/page.tsx create mode 100644 app/konto/login/components/LoginForm.tsx create mode 100644 app/konto/login/page.tsx create mode 100644 app/konto/profil/components/PasswordForm.tsx create mode 100644 app/konto/profil/components/ProfileForm.tsx create mode 100644 app/konto/profil/page.tsx create mode 100644 app/lib/customerAuth.ts create mode 100644 app/lib/orderServer.ts create mode 100644 app/lib/productsServer.ts diff --git a/README.md b/README.md index b9b496b..1f9a11b 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,12 @@ specific to this project. image — see `AGENTS.md` before touching anything version-specific, this Next.js release differs from older training-data conventions) - **React 19.2.4**, **Tailwind CSS 4**, **Motion** for animation -- No local database, no auth — all editable content comes from the shared - Payload CMS at `payload.mk360.de` (see below) +- No local database — all editable content (and now orders/customer + accounts) lives in the shared Payload CMS at `payload.mk360.de` (see + below). Customer auth is Payload's own (a second, separate `auth: true` + collection there, `customers` — not this app's own user store), bridged + via an httpOnly session cookie this app mints itself; see "Orders & + customer accounts" below. ## Getting started @@ -34,9 +38,11 @@ the Payload backend). `NEXT_PUBLIC_PAYLOAD_URL` (optional, defaults to the same `https://payload.mk360.de` — only needed if the client-side Live Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET` (no safe default — required for discount codes to validate/redeem at all; -must match the value set on the Payload backend). Set in Coolify's app -settings for production, not in a committed `.env` — this app has no other -secrets. +must match the value set on the Payload backend). `ORDER_SERVICE_SECRET` +(no safe default — required for `/api/checkout` to persist an order in +Payload at all; must match the value set on the Payload backend). Set in +Coolify's app settings for production, not in a committed `.env` — this app +has no other secrets. ## Pages @@ -48,6 +54,9 @@ secrets. | `/cart` | Cart (localStorage-backed, see below) | | `/checkout` | Shipping + payment method selection, order summary | | `/bestellbestaetigung` | Order confirmation — reads the one-time snapshot `/checkout` wrote | +| `/konto/login` | Customer login | +| `/konto/bestellungen`, `/konto/bestellungen/[orderNumber]` | Order history + order detail (own orders only) | +| `/konto/profil` | Profile/address editing + password change | | `/challenge` | "Mini-Challenge" tool | | `/todo-cards` | "Todo-Karten" tool | | `/newsletter` | Newsletter signup | @@ -182,24 +191,77 @@ check against Payload's public API, unlike most content on this site. atomic against a true concurrent race on a capped code's very last redemption — not worth custom atomic SQL at this shop's traffic level. -## Cart & checkout — demo status +## Cart & checkout - **Cart** (`app/lib/cart.ts`) is entirely client-side, stored in - `localStorage` under `ep_cart`, keyed by each product's `slug`. + `localStorage` under `ep_cart`, keyed by each product's `slug`. Still the + source of truth while browsing — the server-side mirror (see below) only + exists to carry a logged-in customer's cart across devices/browsers. - **`app/cart/components/RelatedProducts.tsx`** only ever suggests products not already in the cart — it stopped falling back to re-suggesting an already-in-cart product just to pad the grid out to 3 cards, so with a small catalog it can render fewer cards (down to 1, centered in the 12-column grid) rather than recommending something already added. -- **Checkout has no real backend for the *order* itself.** `/checkout`'s - "Jetzt kaufen" click writes a one-time snapshot (chosen shipping/payment - method, cart contents, applied discount) to `sessionStorage` - (`app/lib/order.ts`), which `/bestellbestaetigung` reads once and - displays — that snapshot *is* the order record. There is no payment - processing, no persisted order in Payload or anywhere else, and no - confirmation email yet — discount-code validation/redemption is the one - part of this flow with real server-side enforcement today (see above). - Treat the rest as a frontend/demo checkout flow, not a functioning store. +- **`/checkout`'s "Jetzt kaufen" always goes through + `POST /api/checkout`.** That route re-prices the entire cart server-side + from Payload's live product data (never trusts client-submitted prices), + re-validates+redeems a discount code exactly once, registers a new + account inline if nobody's logged in yet ("Konto Pflicht" — see below), + and only then creates the order in Payload's `orders` collection via + `app/lib/orderServer.ts`. `app/lib/order.ts`'s `OrderSnapshot` is still + written to `sessionStorage` for `/bestellbestaetigung` to read once, but + its `orderNumber`/`orderDateIso` now come back from that Payload create + call, not generated client-side. +- Still not built: real payment processing (the checkout button is + labelled "zahlungspflichtig" but nothing captures a payment) and a + transactional confirmation email — see `project_backend_checkout_plan` + in the assistant's own memory for what's deliberately deferred to a + later stage. + +## Orders & customer accounts + +An account is required to buy — there is no guest checkout. Registration +happens inline in `/checkout`'s "1. Rechnungsadresse" card (a password +field appears there when nobody's logged in); returning customers can +instead expand a small "Schon Kundin? Einloggen" toggle in the same place +without leaving the page. + +- **`app/lib/customerAuth.ts`** (server-only) is the single place that + talks to Payload's `customers` collection — a second, fully separate + `auth: true` collection from any admin login, existing purely for this + storefront's own accounts. Payload issues a JWT on register/login; this + app never relies on Payload's own auth cookie (different origin — + `einfach-produktiv.mk360.de` vs `payload.mk360.de`) and instead mints its + **own** httpOnly `ep_customer_token` cookie holding that JWT, forwarded + as an `Authorization: JWT ` header on every subsequent Payload + call. No token refresh in this stage — Payload's ~2h default JWT + lifetime means a session just expires and the customer logs in again. +- **`app/api/account/*`** — thin route handlers around `customerAuth.ts`: + `register`, `login`, `logout`, `me`, `orders` (list), `profile` + (GET/PATCH incl. the one saved default address), `password` (verifies + the current password via a real login attempt before changing it, + doesn't just trust the caller), `cart` (GET/POST, see below). +- **`/konto/bestellungen`** lists a customer's own orders; + **`/konto/bestellungen/[orderNumber]`** shows one order's full detail + (items, address, totals, `status`). `status` (`received` → `processing` + → `shipped` → `delivered`) is maintained by hand in the Payload admin — + no shipping-carrier API integration. +- **`/konto/profil`** edits name + the one saved default address (deliberately + a single address, not a full address book — see the assistant's memory + note on optionally expanding this later) and changes the password. +- **Cart sync**: `app/components/CartSync.tsx` (mounted once in + `app/layout.tsx`) watches the local cart via `useCart()` and + debounce-POSTs it to `/api/account/cart` on every change; the route + 401s (silently, by design) when nobody's logged in. On login + (`LoginForm.tsx`, and `CheckoutContent.tsx`'s inline toggle), + `mergeServerCartIntoLocal()` (`app/lib/cart.ts`) folds whatever was + saved server-side into the local cart by quantity — CartSync's own + effect then pushes the merged result back up on its own, so there's no + separate explicit "save after merge" call. +- Only a single default address per account, single-currency, no order + cancellation/return flow, no email verification, no password-reset + (self-service — a customer who forgets their password currently has no + recovery path). All known, deliberately out of scope for now. ## Deployment diff --git a/app/api/account/cart/route.ts b/app/api/account/cart/route.ts new file mode 100644 index 0000000..cde3c2c --- /dev/null +++ b/app/api/account/cart/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import type { CartItem } from "../../../lib/cart"; +import { getServerCart, getSessionCustomer, saveServerCart } from "../../../lib/customerAuth"; +import { fetchProductsBySlug } from "../../../lib/productsServer"; + +export async function GET() { + const session = await getSessionCustomer(); + if (!session) return NextResponse.json({ cart: [] }, { status: 401 }); + const cart = await getServerCart(session.token); + return NextResponse.json({ cart }); +} + +// Called by CartSync.tsx (debounced) on every local cart change while a +// session is active — keeps the server-side mirror current so the cart +// follows the customer across devices. Silently no-ops when logged out; +// the caller doesn't care either way. +export async function POST(request: Request) { + const session = await getSessionCustomer(); + if (!session) return NextResponse.json({ ok: false }, { status: 401 }); + + const body = await request.json().catch(() => null); + const cart: CartItem[] = Array.isArray(body?.cart) ? body.cart : []; + + const productsBySlug = await fetchProductsBySlug(); + const lines = cart + .map((item) => { + const product = productsBySlug.get(item.id); + return product ? { productId: product.id, productSlug: product.slug, quantity: item.qty } : null; + }) + .filter((line): line is { productId: number; productSlug: string; quantity: number } => line !== null); + + const ok = await saveServerCart(session.token, session.customer.id, lines); + return NextResponse.json({ ok }); +} diff --git a/app/api/account/login/route.ts b/app/api/account/login/route.ts new file mode 100644 index 0000000..bbaad1e --- /dev/null +++ b/app/api/account/login/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { loginCustomer, setSessionCookie } from "../../../lib/customerAuth"; + +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const email = typeof body?.email === "string" ? body.email : ""; + const password = typeof body?.password === "string" ? body.password : ""; + if (!email || !password) { + return NextResponse.json({ ok: false, reason: "Bitte E-Mail und Passwort angeben." }, { status: 400 }); + } + + const result = await loginCustomer({ email, password }); + if (!result.ok) return NextResponse.json(result, { status: 401 }); + + await setSessionCookie(result.token); + return NextResponse.json({ ok: true, customer: result.customer }); +} diff --git a/app/api/account/logout/route.ts b/app/api/account/logout/route.ts new file mode 100644 index 0000000..4c6ddf8 --- /dev/null +++ b/app/api/account/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { clearSessionCookie } from "../../../lib/customerAuth"; + +export async function POST() { + await clearSessionCookie(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/account/me/route.ts b/app/api/account/me/route.ts new file mode 100644 index 0000000..54e33b2 --- /dev/null +++ b/app/api/account/me/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { getSessionCustomer } from "../../../lib/customerAuth"; + +export async function GET() { + const session = await getSessionCustomer(); + if (!session) return NextResponse.json({ customer: null }, { status: 401 }); + return NextResponse.json({ customer: session.customer }); +} diff --git a/app/api/account/orders/route.ts b/app/api/account/orders/route.ts new file mode 100644 index 0000000..a1c869b --- /dev/null +++ b/app/api/account/orders/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { getCustomerOrders, getSessionCustomer } from "../../../lib/customerAuth"; + +export async function GET() { + const session = await getSessionCustomer(); + if (!session) return NextResponse.json({ orders: [] }, { status: 401 }); + + const orders = await getCustomerOrders(session.token, session.customer.id); + return NextResponse.json({ orders }); +} diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts new file mode 100644 index 0000000..d9da1a0 --- /dev/null +++ b/app/api/account/password/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { changeCustomerPassword, getSessionCustomer } from "../../../lib/customerAuth"; + +export async function POST(request: Request) { + const session = await getSessionCustomer(); + if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 }); + + const body = await request.json().catch(() => null); + const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : ""; + const newPassword = typeof body?.newPassword === "string" ? body.newPassword : ""; + if (!currentPassword || !newPassword || newPassword.length < 8) { + return NextResponse.json({ ok: false, reason: "Bitte aktuelles und ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 }); + } + + const result = await changeCustomerPassword(session.customer.email, currentPassword, newPassword); + return NextResponse.json(result, { status: result.ok ? 200 : 400 }); +} diff --git a/app/api/account/profile/route.ts b/app/api/account/profile/route.ts new file mode 100644 index 0000000..1ac0e4d --- /dev/null +++ b/app/api/account/profile/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth"; + +export async function GET() { + const session = await getSessionCustomer(); + if (!session) return NextResponse.json({ profile: null }, { status: 401 }); + return NextResponse.json({ profile: session.customer }); +} + +export async function PATCH(request: Request) { + const session = await getSessionCustomer(); + if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 }); + + const body = await request.json().catch(() => null); + const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country } = body ?? {}; + if ( + typeof firstName !== "string" || + !firstName || + typeof lastName !== "string" || + !lastName || + (deliveryMethod !== "address" && deliveryMethod !== "packstation") || + typeof zip !== "string" || + !zip || + typeof city !== "string" || + !city || + typeof country !== "string" || + !country + ) { + return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 }); + } + if (deliveryMethod === "address" && !street) { + return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 }); + } + if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) { + return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 }); + } + + const result = await updateCustomerProfile(session.token, session.customer.id, { + firstName, + lastName, + deliveryMethod, + street, + packstationNumber, + postNumber, + zip, + city, + country, + }); + return NextResponse.json(result, { status: result.ok ? 200 : 400 }); +} diff --git a/app/api/account/register/route.ts b/app/api/account/register/route.ts new file mode 100644 index 0000000..1e212ef --- /dev/null +++ b/app/api/account/register/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { registerCustomer, setSessionCookie } from "../../../lib/customerAuth"; + +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const { firstName, lastName, email, password } = body ?? {}; + if ( + typeof firstName !== "string" || + typeof lastName !== "string" || + typeof email !== "string" || + typeof password !== "string" || + !firstName || + !lastName || + !email || + !password + ) { + return NextResponse.json({ ok: false, reason: "Bitte alle Felder ausfüllen." }, { status: 400 }); + } + + const result = await registerCustomer({ firstName, lastName, email, password }); + if (!result.ok) return NextResponse.json(result, { status: 400 }); + + await setSessionCookie(result.token); + return NextResponse.json({ ok: true, customer: result.customer }); +} diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 0000000..7c96708 --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,151 @@ +import { NextResponse } from "next/server"; +import type { CartItem } from "../../lib/cart"; +import { getShippingMethods, getPaymentMethods } from "../../lib/payload"; +import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer"; +import { createOrder } from "../../lib/orderServer"; +import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth"; +import { fetchProductsBySlug } from "../../lib/productsServer"; + +type CheckoutBody = { + cart: CartItem[]; + shippingMethodId: number; + paymentMethodId: number; + discountCode: string | null; + firstName: string; + lastName: string; + email: string; + password?: string; + deliveryMethod: "address" | "packstation"; + street?: string; + packstationNumber?: string; + postNumber?: string; + zip: string; + city: string; + country: string; + newsletterOptIn: boolean; +}; + +function isValidBody(body: unknown): body is CheckoutBody { + const b = body as Partial | null; + return Boolean( + b && + Array.isArray(b.cart) && + b.cart.length > 0 && + typeof b.shippingMethodId === "number" && + typeof b.paymentMethodId === "number" && + typeof b.firstName === "string" && + b.firstName && + typeof b.lastName === "string" && + b.lastName && + typeof b.email === "string" && + b.email && + (b.deliveryMethod === "address" || b.deliveryMethod === "packstation") && + typeof b.zip === "string" && + b.zip && + typeof b.city === "string" && + b.city && + typeof b.country === "string" && + b.country, + ); +} + +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + if (!isValidBody(body)) { + return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 }); + } + if (body.deliveryMethod === "address" && !body.street) { + return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 }); + } + if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) { + return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 }); + } + + // Auth: an existing session wins; otherwise this checkout submit doubles + // as inline registration ("Konto Pflicht, Registrierung direkt im + // Checkout") — logging in with an *existing* account happens separately + // beforehand via /api/account/login from the checkout page's own toggle. + let customer: CustomerSummary; + const session = await getSessionCustomer(); + if (session) { + customer = session.customer; + } else { + if (!body.password) { + return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 }); + } + const result = await registerCustomer({ + firstName: body.firstName, + lastName: body.lastName, + email: body.email, + password: body.password, + }); + if (!result.ok) return NextResponse.json(result, { status: 400 }); + await setSessionCookie(result.token); + customer = result.customer; + } + + // Re-price everything server-side — never trust client-submitted prices. + const productsBySlug = await fetchProductsBySlug(); + const items: { productId: number; productName: string; quantity: number; unitPrice: number }[] = []; + for (const line of body.cart) { + const product = productsBySlug.get(line.id); + if (!product) return NextResponse.json({ ok: false, reason: "Ein Artikel im Warenkorb ist nicht mehr verfügbar." }, { status: 400 }); + items.push({ productId: product.id, productName: product.name, quantity: line.qty, unitPrice: product.price }); + } + const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0); + + const shippingMethods = await getShippingMethods(); + const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId); + if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 }); + const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold; + const shippingCost = freeShipping ? 0 : shippingMethod.price; + + const paymentMethods = await getPaymentMethods(); + const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId); + if (!paymentMethod) return NextResponse.json({ ok: false, reason: "Zahlungsart ist ungültig." }, { status: 400 }); + + let discountAmount = 0; + if (body.discountCode) { + const validation = await validateDiscountCode(body.discountCode, subtotal); + if (!validation.valid) return NextResponse.json({ ok: false, reason: validation.reason }, { status: 400 }); + const redeemed = await redeemDiscountCode(validation.doc); + if (!redeemed) return NextResponse.json({ ok: false, reason: "Rabattcode konnte nicht eingelöst werden." }, { status: 400 }); + discountAmount = + validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal); + } + const total = Math.max(0, subtotal - discountAmount) + shippingCost; + + const order = await createOrder({ + customerId: customer.id, + customerFirstName: body.firstName, + customerLastName: body.lastName, + customerEmail: body.email, + deliveryMethod: body.deliveryMethod, + street: body.street, + packstationNumber: body.packstationNumber, + postNumber: body.postNumber, + zip: body.zip, + city: body.city, + country: body.country, + newsletterOptIn: Boolean(body.newsletterOptIn), + items, + subtotal, + shippingCost, + shippingMethodTitle: shippingMethod.title, + paymentMethodTitle: paymentMethod.title, + discountCode: body.discountCode || null, + discountAmount, + total, + }); + if (!order) return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 }); + + return NextResponse.json({ + ok: true, + orderNumber: order.orderNumber, + orderDateIso: order.createdAt, + shippingCost, + paymentMethodTitle: paymentMethod.title, + discountCode: body.discountCode || null, + discountAmount, + }); +} diff --git a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx index b659e39..f9abbc7 100644 --- a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx +++ b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx @@ -263,6 +263,12 @@ export function BestellbestaetigungContent() { )} + + + Meine Bestellungen ansehen + + + {/* Testimonial band — same proven structure as /not-found's version (see that page's own comment), not a new layout: w-[45%] image column, narrow 40px edge gradient into bg-muted, quote in the diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index dadeeb6..865e616 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useCart, clearCart } from "../../lib/cart"; +import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart"; import { useProducts } from "../../lib/products"; import { useDiscount, clearDiscount } from "../../lib/discount"; import { computeSubtotal, computeCartTotals } from "../../lib/cartTotals"; @@ -12,8 +12,9 @@ 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 { ORDER_KEY, type OrderSnapshot } from "../../lib/order"; import type { ShippingMethod, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload"; +import type { CustomerProfile } from "../../lib/customerAuth"; function FormField({ label, @@ -36,6 +37,8 @@ export function CheckoutContent({ paymentMethods, trustBadges, shippingSettings, + customerEmail, + savedProfile, }: { shippingMethods: ShippingMethod[]; paymentMethods: PaymentMethod[]; @@ -44,6 +47,14 @@ export function CheckoutContent({ * "shippingSettings", not "shipping", since that name is already the * local computed shipping-cost value below. */ shippingSettings: ShippingSettings; + /** From the checkout page's own session read (app/lib/customerAuth.ts) — + * null means no account is logged in yet, which flips "1. Rechnungsadresse" + * into inline-registration mode (password field shown, account created on + * submit) since an account is required to buy. */ + customerEmail: string | null; + /** Full profile (name + saved address) — null when logged out, pre-fills + * Card 1's fields for a returning customer instead of leaving them blank. */ + savedProfile: CustomerProfile | null; }) { const router = useRouter(); const cart = useCart(); @@ -52,9 +63,14 @@ export function CheckoutContent({ const [shippingMethodId, setShippingMethodId] = useState(shippingMethods[0]?.id ?? null); const [paymentMethodId, setPaymentMethodId] = useState(paymentMethods[0]?.id ?? null); const [versandOpen, setVersandOpen] = useState(false); - const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">("address"); + const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(savedProfile?.deliveryMethod ?? "address"); const [purchaseError, setPurchaseError] = useState(null); const [purchasing, setPurchasing] = useState(false); + const [showLogin, setShowLogin] = useState(false); + const [loginEmail, setLoginEmail] = useState(""); + const [loginPassword, setLoginPassword] = useState(""); + const [loginError, setLoginError] = useState(null); + const [loggingIn, setLoggingIn] = useState(false); const productsLoading = products.length === 0 && cart.length > 0; const items = cart @@ -69,59 +85,104 @@ export function CheckoutContent({ subtotal >= selectedShipping.freeShippingThreshold; const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0; const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount); - 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. If a discount is applied, it must be re-validated - // and its redemption counter incremented server-side first (see - // app/api/discount/redeem/route.ts) — the code could have expired or hit - // its redemption cap since it was applied back in the cart. - async function handlePurchase(e: React.MouseEvent) { - if (discount) { - e.preventDefault(); - setPurchasing(true); - setPurchaseError(null); - try { - const res = await fetch("/api/discount/redeem", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: discount.code, subtotal }), - }); - const data = await res.json(); - if (!data.redeemed) { - setPurchaseError(data.reason || "Der Rabattcode konnte nicht final eingelöst werden."); - setPurchasing(false); - return; - } - } catch { - setPurchaseError("Der Rabattcode konnte gerade nicht geprüft werden."); + // Logs into an existing account inline, without leaving /checkout — + // router.refresh() re-runs the page's Server Component, which re-reads + // the now-set session cookie and passes the resolved customerEmail back + // down, flipping this form out of registration mode. + async function handleLogin() { + setLoggingIn(true); + setLoginError(null); + try { + const res = await fetch("/api/account/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: loginEmail, password: loginPassword }), + }); + const data = await res.json(); + if (!data.ok) { + setLoginError(data.reason || "Login fehlgeschlagen."); + setLoggingIn(false); + return; + } + await mergeServerCartIntoLocal(); + router.refresh(); + } catch { + setLoginError("Login ist gerade nicht möglich."); + setLoggingIn(false); + } + } + + async function handleLogout() { + await fetch("/api/account/logout", { method: "POST" }); + router.refresh(); + } + + // Always goes through /api/checkout now — that route re-prices + // everything server-side, re-validates+redeems a discount code exactly + // once (see its own comment), registers a new account inline when no + // session exists yet ("Konto Pflicht"), and only then persists the order + // in Payload. Replaces the old client-only sessionStorage snapshot. + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setPurchasing(true); + setPurchaseError(null); + + const form = new FormData(e.currentTarget); + const body = { + cart, + shippingMethodId, + paymentMethodId, + discountCode: discount?.code ?? null, + firstName: String(form.get("firstName") ?? ""), + lastName: String(form.get("lastName") ?? ""), + email: String(form.get("email") ?? ""), + password: customerEmail ? undefined : String(form.get("password") ?? ""), + deliveryMethod, + street: String(form.get("street") ?? "") || undefined, + packstationNumber: String(form.get("packstationNumber") ?? "") || undefined, + postNumber: String(form.get("postNumber") ?? "") || undefined, + zip: String(form.get("zip") ?? ""), + city: String(form.get("city") ?? ""), + country: String(form.get("country") ?? ""), + newsletterOptIn: form.get("newsletterOptIn") === "on", + }; + + try { + const res = await fetch("/api/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!data.ok) { + setPurchaseError(data.reason || "Die Bestellung konnte nicht abgeschlossen werden."); setPurchasing(false); return; } - } - const snapshot: OrderSnapshot = { - items: cart, - orderNumber: generateOrderNumber(), - orderDateIso: new Date().toISOString(), - shippingCost: shipping, - paymentMethodTitle: selectedPayment?.title ?? "—", - discountCode: discount?.code ?? null, - discountAmount, - }; - try { - window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot)); + const snapshot: OrderSnapshot = { + items: cart, + orderNumber: data.orderNumber, + orderDateIso: data.orderDateIso, + shippingCost: data.shippingCost, + paymentMethodTitle: data.paymentMethodTitle, + discountCode: data.discountCode, + discountAmount: data.discountAmount, + }; + 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(); + clearDiscount(); + router.push("/bestellbestaetigung"); } catch { - // sessionStorage unavailable (private browsing etc.) — the - // confirmation page falls back to its own empty state. + setPurchaseError("Die Bestellung konnte gerade nicht abgeschlossen werden."); + setPurchasing(false); } - clearCart(); - clearDiscount(); - // Only needed for the discount path — the plain Link already handles - // navigation itself when its default wasn't prevented above. - if (discount) router.push("/bestellbestaetigung"); } if (!productsLoading && items.length === 0) { @@ -169,9 +230,57 @@ export function CheckoutContent({

Fast geschafft! Nur noch ein paar Angaben.

+ + {/* Account gate — an account is required to buy, so this either + confirms the active session or offers "already a customer?" + login inline (registration itself happens as part of the main + form submit below, via the password field in Card 1). */} + {customerEmail ? ( +

+ Eingeloggt als {customerEmail}{" "} + +

+ ) : !showLogin ? ( +

+ Schon Kundin?{" "} + +

+ ) : ( +
+ setLoginEmail(e.target.value)} + autoComplete="email" + wrapperClassName="w-full sm:w-56" + /> + setLoginPassword(e.target.value)} + autoComplete="current-password" + wrapperClassName="w-full sm:w-56" + /> + + {loginError &&

{loginError}

} +
+ )} -
+
{/* Form column */}
{/* 1. Rechnungsadresse */} @@ -183,19 +292,36 @@ export function CheckoutContent({ 1. Rechnungsadresse

- - + +
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's actual rendered width in the 2-col row above (each half of a gap-4 flex row), instead of stretching full-width. */} + {/* Only needed for the inline-registration path — an existing + session already has an account, no password to collect. */} + {!customerEmail && ( + + )} {/* Segmented control, same sm:w-[calc(50%-0.5rem)] half-row width as the field(s) below it — Lieferadresse keeps Straße und Hausnummer, Packstation swaps it out for @@ -234,37 +360,48 @@ export function CheckoutContent({ {deliveryMethod === "address" ? ( ) : (
)}
- - + +
))} - {purchasing ? "Einen Moment…" : "Jetzt kaufen (zahlungspflichtig)"} - + {purchaseError && (

{purchaseError}

@@ -508,7 +648,7 @@ export function CheckoutContent({
- + setVersandOpen(false)} shipping={shippingSettings} /> diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx index 05cb82a..f260d2d 100644 --- a/app/checkout/page.tsx +++ b/app/checkout/page.tsx @@ -3,6 +3,7 @@ import { CheckoutContent } from "./components/CheckoutContent"; import { TrustRow } from "../components/TrustRow"; import { Footer } from "../components/Footer"; import { getShippingMethods, getPaymentMethods, getCartTrustBadges, getShippingSettings } from "../lib/payload"; +import { getSessionCustomer, getCustomerProfile } from "../lib/customerAuth"; // robots: noindex — transactional page, same reasoning as /cart. export const metadata: Metadata = { @@ -15,12 +16,16 @@ export const metadata: Metadata = { }; export default async function CheckoutPage() { - const [shippingMethods, paymentMethods, trustBadges, shippingSettings] = await Promise.all([ + const [shippingMethods, paymentMethods, trustBadges, shippingSettings, session] = await Promise.all([ getShippingMethods(), getPaymentMethods(), getCartTrustBadges(), getShippingSettings(), + getSessionCustomer(), ]); + // Full profile (incl. saved address) only fetched when a session exists + // — pre-fills Card 1 for a returning customer instead of leaving it blank. + const profile = session ? await getCustomerProfile(session.token) : null; return ( <> @@ -30,6 +35,8 @@ export default async function CheckoutPage() { paymentMethods={paymentMethods} trustBadges={trustBadges} shippingSettings={shippingSettings} + customerEmail={session?.customer.email ?? null} + savedProfile={profile} /> diff --git a/app/components/CartSync.tsx b/app/components/CartSync.tsx new file mode 100644 index 0000000..55a16d2 --- /dev/null +++ b/app/components/CartSync.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useCart } from "../lib/cart"; + +// Mirrors the local cart to the server whenever it changes, so a logged-in +// customer's cart follows them across devices (see Customers.ts's `cart` +// field). Renders nothing — mounted once in the root layout. Debounced +// (not fired on every keystroke-equivalent quantity bump) and silently a +// no-op when logged out — POST /api/account/cart 401s in that case, which +// this component doesn't need to distinguish from success; there's simply +// nothing to keep in sync yet. +export function CartSync() { + const cart = useCart(); + const isFirstRender = useRef(true); + + useEffect(() => { + // Skip the mount-time fire — this would otherwise POST on every page + // load even when nothing actually changed. + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + const timeout = setTimeout(() => { + fetch("/api/account/cart", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cart }), + }).catch(() => { + // Best-effort — a failed sync just means the next cart change (or + // the next login-time merge) tries again. + }); + }, 800); + return () => clearTimeout(timeout); + }, [cart]); + + return null; +} diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx new file mode 100644 index 0000000..8b310d2 --- /dev/null +++ b/app/konto/bestellungen/[orderNumber]/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next"; +import { redirect, notFound } from "next/navigation"; +import Link from "next/link"; +import { Reveal } from "../../../components/Reveal"; +import { Footer } from "../../../components/Footer"; +import { formatPrice, formatDate } from "../../../lib/format"; +import { getSessionCustomer, getCustomerOrderDetail, ORDER_STATUS_LABEL } from "../../../lib/customerAuth"; + +export const metadata: Metadata = { + title: "Bestelldetails", + robots: { index: false, follow: true }, +}; + +export default async function KontoBestellungDetailPage({ params }: { params: Promise<{ orderNumber: string }> }) { + const { orderNumber } = await params; + const session = await getSessionCustomer(); + if (!session) redirect("/konto/login"); + + const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber)); + if (!order) notFound(); + + const address = + order.deliveryMethod === "address" + ? order.street + : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`; + + return ( + <> +
+ + + ← Zurück zur Bestellhistorie + + +

+ {order.orderNumber} +

+ +
+
+

Datum

+

{formatDate(order.createdAt)}

+
+
+

Status

+

{ORDER_STATUS_LABEL[order.status] ?? order.status}

+
+
+

Zahlungsart

+

{order.paymentMethodTitle}

+
+
+ +
+

Lieferadresse

+

+ {order.customerFirstName} {order.customerLastName} +

+

{address}

+

+ {order.zip} {order.city}, {order.country} +

+
+ +
+ {order.items.map((item, i) => ( +
+

+ {item.quantity} × {item.productName} +

+

{formatPrice(item.quantity * item.unitPrice)}

+
+ ))} + +
+ +
+ Zwischensumme + + {formatPrice(order.subtotal)} +
+ {order.discountCode && ( +
+ Rabattcode ({order.discountCode}) + + -{formatPrice(order.discountAmount)} +
+ )} +
+ Versand ({order.shippingMethodTitle}) + + + {order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)} + +
+ +
+ +
+ + Gesamtsumme + + + {formatPrice(order.total)} +
+
+ +
+