Add real order persistence, customer accounts, and cart sync

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 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 06:45:42 +00:00
parent 516945fc8c
commit 7f37f111e8
27 changed files with 1697 additions and 89 deletions
+78 -16
View File
@@ -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 <token>` 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
+34
View File
@@ -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 });
}
+17
View File
@@ -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 });
}
+7
View File
@@ -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 });
}
+8
View File
@@ -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 });
}
+10
View File
@@ -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 });
}
+17
View File
@@ -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 });
}
+50
View File
@@ -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 });
}
+25
View File
@@ -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 });
}
+151
View File
@@ -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<CheckoutBody> | 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,
});
}
@@ -263,6 +263,12 @@ export function BestellbestaetigungContent() {
</Reveal>
)}
<Reveal delay={0.08} className="flex items-center justify-center pb-4 px-[var(--layout-padding-x)] w-full">
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Meine Bestellungen ansehen
</Link>
</Reveal>
{/* 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
+202 -62
View File
@@ -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<number | null>(shippingMethods[0]?.id ?? null);
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(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<string | null>(null);
const [purchasing, setPurchasing] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState("");
const [loginError, setLoginError] = useState<string | null>(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<HTMLAnchorElement>) {
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<HTMLFormElement>) {
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({
</p>
<p className="text-body text-text-muted">Fast geschafft! Nur noch ein paar Angaben.</p>
</div>
{/* 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 ? (
<p className="text-body-sm text-text-primary">
Eingeloggt als <span className="font-bold">{customerEmail}</span>{" "}
<button type="button" onClick={handleLogout} className="underline hover:text-brand transition-colors">
Abmelden
</button>
</p>
) : !showLogin ? (
<p className="text-body-sm text-text-primary">
Schon Kundin?{" "}
<button type="button" onClick={() => setShowLogin(true)} className="underline font-bold hover:text-brand transition-colors">
Hier einloggen
</button>
</p>
) : (
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-end w-full sm:w-auto">
<FormField
label="E-Mail-Adresse"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
autoComplete="email"
wrapperClassName="w-full sm:w-56"
/>
<FormField
label="Passwort"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
autoComplete="current-password"
wrapperClassName="w-full sm:w-56"
/>
<button
type="button"
onClick={handleLogin}
disabled={loggingIn}
className={`px-5 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${loggingIn ? "opacity-70 pointer-events-none" : ""}`}
>
{loggingIn ? "…" : "Einloggen"}
</button>
{loginError && <p className="text-label text-red-600 w-full">{loginError}</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 onSubmit={handleSubmit} 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 */}
@@ -183,19 +292,36 @@ export function CheckoutContent({
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" />
<FormField label="Vorname" name="firstName" type="text" defaultValue={savedProfile?.firstName} placeholder="Max" autoComplete="given-name" required />
<FormField label="Nachname" name="lastName" type="text" defaultValue={savedProfile?.lastName} placeholder="Mustermann" autoComplete="family-name" required />
</div>
{/* 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. */}
<FormField
label="E-Mail-Adresse"
name="email"
type="email"
defaultValue={savedProfile?.email ?? customerEmail ?? undefined}
placeholder="max@beispiel.de"
autoComplete="email"
required
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
{/* Only needed for the inline-registration path — an existing
session already has an account, no password to collect. */}
{!customerEmail && (
<FormField
label="Passwort (für dein neues Konto)"
name="password"
type="password"
placeholder="Mind. 8 Zeichen"
autoComplete="new-password"
required
minLength={8}
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
)}
{/* 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" ? (
<FormField
label="Straße und Hausnummer"
name="street"
type="text"
defaultValue={savedProfile?.street ?? undefined}
placeholder="Musterstraße 1"
autoComplete="street-address"
required
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="Packstationnummer"
name="packstationNumber"
type="text"
defaultValue={savedProfile?.packstationNumber ?? undefined}
inputMode="numeric"
placeholder="123"
autoComplete="off"
required
/>
<FormField
label="Postnummer"
name="postNumber"
type="text"
defaultValue={savedProfile?.postNumber ?? undefined}
inputMode="numeric"
placeholder="1234567"
autoComplete="off"
required
/>
</div>
)}
<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" />
<FormField label="PLZ" name="zip" type="text" defaultValue={savedProfile?.zip ?? undefined} placeholder="10115" autoComplete="postal-code" required />
<FormField label="Ort" name="city" type="text" defaultValue={savedProfile?.city ?? undefined} placeholder="Berlin" autoComplete="address-level2" required />
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Land</span>
<select
defaultValue="Deutschland"
name="country"
defaultValue={savedProfile?.country ?? "Deutschland"}
required
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>
@@ -273,7 +410,11 @@ export function CheckoutContent({
</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" />
<input
type="checkbox"
name="newsletterOptIn"
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>
@@ -341,14 +482,13 @@ export function CheckoutContent({
</label>
))}
<Link
href="/bestellbestaetigung"
onClick={handlePurchase}
aria-disabled={purchasing}
<button
type="submit"
disabled={purchasing}
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 ${purchasing ? "pointer-events-none opacity-70" : ""}`}
>
{purchasing ? "Einen Moment…" : "Jetzt kaufen (zahlungspflichtig)"}
</Link>
</button>
{purchaseError && (
<p className="text-label text-red-600 text-center w-full">{purchaseError}</p>
@@ -508,7 +648,7 @@ export function CheckoutContent({
</div>
</div>
</Reveal>
</div>
</form>
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
</>
+8 -1
View File
@@ -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}
/>
<TrustRow />
</main>
+38
View File
@@ -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;
}
@@ -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 (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
Zurück zur Bestellhistorie
</Link>
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
{order.orderNumber}
</p>
<div className="flex flex-wrap gap-8 w-full">
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Datum</p>
<p className="text-body-sm text-text-primary">{formatDate(order.createdAt)}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Status</p>
<p className="text-body-sm text-text-primary">{ORDER_STATUS_LABEL[order.status] ?? order.status}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Zahlungsart</p>
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
</div>
</div>
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Lieferadresse</p>
<p className="text-body-sm text-text-primary">
{order.customerFirstName} {order.customerLastName}
</p>
<p className="text-body-sm text-text-primary">{address}</p>
<p className="text-body-sm text-text-primary">
{order.zip} {order.city}, {order.country}
</p>
</div>
<div className="w-full bg-bg-base border border-border rounded-md p-6 flex flex-col gap-3">
{order.items.map((item, i) => (
<div key={i} className="flex items-center gap-4 w-full">
<p className="flex-1 text-body-sm text-text-primary">
{item.quantity} × {item.productName}
</p>
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</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(order.subtotal)}</span>
</div>
{order.discountCode && (
<div className="flex items-center w-full">
<span className="text-body-sm text-success">Rabattcode ({order.discountCode})</span>
<span className="flex-1" />
<span className="font-bold text-body-sm text-success">-{formatPrice(order.discountAmount)}</span>
</div>
)}
<div className="flex items-center w-full">
<span className="text-body-sm text-text-primary">Versand ({order.shippingMethodTitle})</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)" }}>
Gesamtsumme
</span>
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
</div>
</div>
</Reveal>
</main>
<Footer />
</>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { Metadata } from "next";
import { redirect } 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, getCustomerOrders, ORDER_STATUS_LABEL } from "../../lib/customerAuth";
// robots: noindex — account area, same reasoning as /checkout.
export const metadata: Metadata = {
title: "Meine Bestellungen",
description: "Deine Bestellhistorie bei einfach produktiv.",
robots: {
index: false,
follow: true,
},
};
export default async function KontoBestellungenPage() {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const orders = await getCustomerOrders(session.token, session.customer.id);
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[56rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Bestellungen
</p>
<p className="text-body text-text-muted">
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
</p>
{orders.length === 0 ? (
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
) : (
<div className="flex flex-col gap-4 w-full">
{orders.map((order) => (
<Link
key={order.orderNumber}
href={`/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`}
className="flex flex-wrap items-center gap-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
>
<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">{order.orderNumber}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Datum</p>
<p className="text-body-sm text-text-primary">{formatDate(order.createdAt)}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Artikel</p>
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Status</p>
<p className="text-body-sm text-text-primary">{ORDER_STATUS_LABEL[order.status] ?? order.status}</p>
</div>
<div className="flex flex-col gap-1 ml-auto">
<p className="text-label text-text-muted">Gesamtbetrag</p>
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
</div>
</Link>
))}
</div>
)}
<div className="flex gap-6">
<Link href="/konto/profil" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Profil &amp; Adresse
</Link>
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Weiter einkaufen
</Link>
</div>
</Reveal>
</main>
<Footer />
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../../components/Reveal";
import { mergeServerCartIntoLocal } from "../../../lib/cart";
export function LoginForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/account/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Login fehlgeschlagen.");
setLoading(false);
return;
}
await mergeServerCartIntoLocal();
router.push("/konto/bestellungen");
router.refresh();
} catch {
setError("Login ist gerade nicht möglich.");
setLoading(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Anmelden
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">E-Mail-Adresse</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
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>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Passwort</span>
<input
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
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>
{error && <p className="text-label text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
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 ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "Einen Moment…" : "Einloggen"}
</button>
</form>
<p className="text-body-sm text-text-muted">
Noch kein Konto? Einfach beim{" "}
<Link href="/checkout" className="underline hover:text-brand transition-colors">
nächsten Einkauf
</Link>{" "}
anlegen.
</p>
</Reveal>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { LoginForm } from "./components/LoginForm";
import { Footer } from "../../components/Footer";
// robots: noindex — account area, same reasoning as /checkout.
export const metadata: Metadata = {
title: "Anmelden",
description: "Melde dich bei deinem einfach produktiv-Konto an.",
robots: {
index: false,
follow: true,
},
};
export default function KontoLoginPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<LoginForm />
</main>
<Footer />
</>
);
}
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { Reveal } from "../../../components/Reveal";
const inputClass =
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
export function PasswordForm({ email }: { email: string }) {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formEl = e.currentTarget;
setSaving(true);
setError(null);
setSuccess(false);
const form = new FormData(formEl);
const currentPassword = String(form.get("currentPassword") ?? "");
const newPassword = String(form.get("newPassword") ?? "");
try {
const res = await fetch("/api/account/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Passwort konnte nicht geändert werden.");
setSaving(false);
return;
}
setSuccess(true);
setSaving(false);
formEl.reset();
} catch {
setError("Passwort konnte gerade nicht geändert werden.");
setSaving(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start w-full pt-4 border-t border-border">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Passwort ändern
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
{/* Hidden, but present so autofill/password managers correctly
associate the new password with this account's email. */}
<input type="hidden" name="email" value={email} autoComplete="username" />
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Aktuelles Passwort</span>
<input type="password" name="currentPassword" autoComplete="current-password" required className={inputClass} />
</label>
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Neues Passwort</span>
<input type="password" name="newPassword" autoComplete="new-password" minLength={8} required className={inputClass} />
</label>
{error && <p className="text-label text-red-600">{error}</p>}
{success && <p className="text-label text-success">Passwort geändert.</p>}
<button
type="submit"
disabled={saving}
className={`px-7 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${saving ? "opacity-70 pointer-events-none" : ""}`}
>
{saving ? "Speichert…" : "Passwort ändern"}
</button>
</form>
</Reveal>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Reveal } from "../../../components/Reveal";
import type { CustomerProfile } from "../../../lib/customerAuth";
const inputClass =
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
function Field({
label,
wrapperClassName = "flex-1 min-w-0",
...props
}: { label: string; wrapperClassName?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<label className={`flex flex-col gap-2 items-start ${wrapperClassName}`}>
<span className="text-label text-text-muted">{label}</span>
<input {...props} className={inputClass} />
</label>
);
}
export function ProfileForm({ profile }: { profile: CustomerProfile }) {
const router = useRouter();
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setError(null);
setSuccess(false);
const form = new FormData(e.currentTarget);
const body = {
firstName: String(form.get("firstName") ?? ""),
lastName: String(form.get("lastName") ?? ""),
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") ?? ""),
};
try {
const res = await fetch("/api/account/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Profil konnte nicht gespeichert werden.");
setSaving(false);
return;
}
setSuccess(true);
setSaving(false);
router.refresh();
} catch {
setError("Profil konnte gerade nicht gespeichert werden.");
setSaving(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<p className="text-body-sm text-text-muted">
{profile.email} · Kundennummer {profile.customerNumber}
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Vorname" name="firstName" type="text" defaultValue={profile.firstName} required />
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
</div>
<div className="w-full flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
<button
type="button"
onClick={() => setDeliveryMethod("address")}
aria-pressed={deliveryMethod === "address"}
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${deliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Lieferadresse
</button>
<button
type="button"
onClick={() => setDeliveryMethod("packstation")}
aria-pressed={deliveryMethod === "packstation"}
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${deliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Packstation
</button>
</div>
</div>
{deliveryMethod === "address" ? (
<Field
label="Straße und Hausnummer"
name="street"
type="text"
defaultValue={profile.street ?? ""}
required
wrapperClassName="w-full"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Packstationnummer" name="packstationNumber" type="text" defaultValue={profile.packstationNumber ?? ""} required />
<Field label="Postnummer" name="postNumber" type="text" defaultValue={profile.postNumber ?? ""} required />
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="PLZ" name="zip" type="text" defaultValue={profile.zip ?? ""} required />
<Field label="Ort" name="city" type="text" defaultValue={profile.city ?? ""} required />
</div>
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Land</span>
<select name="country" defaultValue={profile.country ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
<option>Deutschland</option>
<option>Österreich</option>
<option>Schweiz</option>
</select>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
{success && <p className="text-label text-success">Gespeichert.</p>}
<button
type="submit"
disabled={saving}
className={`px-7 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${saving ? "opacity-70 pointer-events-none" : ""}`}
>
{saving ? "Speichert…" : "Speichern"}
</button>
</form>
</Reveal>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Footer } from "../../components/Footer";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { ProfileForm } from "./components/ProfileForm";
import { PasswordForm } from "./components/PasswordForm";
export const metadata: Metadata = {
title: "Mein Profil",
robots: { index: false, follow: true },
};
export default async function KontoProfilPage() {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const profile = await getCustomerProfile(session.token);
if (!profile) redirect("/konto/login");
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
<ProfileForm profile={profile} />
<PasswordForm email={profile.email} />
</div>
</main>
<Footer />
</>
);
}
+2
View File
@@ -3,6 +3,7 @@ import { Inter, Playfair_Display, Caveat, Lora } from "next/font/google";
import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { CartSync } from "./components/CartSync";
import { getProducts } from "./lib/payload";
const inter = Inter({
@@ -65,6 +66,7 @@ export default async function RootLayout({
>
<body className="min-h-full flex flex-col">
<CartFlyProvider>
<CartSync />
<Navbar singleActiveProduct={singleActiveProduct} />
{children}
</CartFlyProvider>
+18
View File
@@ -106,3 +106,21 @@ export function useCartCount(): number {
export function useCart(): CartItem[] {
return useSyncExternalStore(subscribe, getCart, () => EMPTY_CART);
}
// Called right after a successful login (LoginForm.tsx, CheckoutContent.tsx's
// inline login toggle) — folds whatever was saved server-side into the
// local cart by quantity (addToCart adds to an existing line rather than
// overwriting it), so items added before logging in aren't lost. CartSync
// then picks up the resulting change and pushes the merged cart back to
// the server on its own, closing the loop without a separate save call here.
export async function mergeServerCartIntoLocal(): Promise<void> {
try {
const res = await fetch("/api/account/cart");
if (!res.ok) return;
const data: { cart?: CartItem[] } = await res.json();
for (const item of data.cart ?? []) addToCart(item.id, item.qty);
} catch {
// Best-effort — a failed merge just means the server-side cart stays
// as it was; nothing local is lost either way.
}
}
+332
View File
@@ -0,0 +1,332 @@
import { cookies } from "next/headers";
import type { CartItem } from "./cart";
// Server-only — imported by app/api/account/*/route.ts, app/api/checkout/
// route.ts, and the /checkout and /konto/* Server Components. Never touch
// Payload's own auth cookie directly: Payload (payload.mk360.de) and this
// app (einfach-produktiv.mk360.de) are different origins, so instead this
// app mints its OWN httpOnly cookie holding the JWT Payload issued, and
// simply forwards that token as an Authorization header on every
// subsequent Payload call — no shared-domain cookie config, no CORS setup
// needed on the Payload side.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
const SESSION_COOKIE = "ep_customer_token";
async function resolveTenantId(): Promise<number | null> {
const params = new URLSearchParams({ "where[slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/tenants?${params}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { docs?: { id: number }[] } = await res.json();
return data.docs?.[0]?.id ?? null;
}
export type CustomerSummary = {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
email: string;
};
export type AuthResult = { ok: true; token: string; customer: CustomerSummary } | { ok: false; reason: string };
export async function registerCustomer(input: {
firstName: string;
lastName: string;
email: string;
password: string;
}): Promise<AuthResult> {
const tenantId = await resolveTenantId();
if (tenantId == null) return { ok: false, reason: "Registrierung ist gerade nicht möglich." };
const res = await fetch(`${PAYLOAD_URL}/api/customers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...input, tenant: tenantId }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
const message: string | undefined = data?.errors?.[0]?.message;
return { ok: false, reason: message ?? "Diese E-Mail-Adresse ist bereits registriert." };
}
return loginCustomer(input);
}
export async function loginCustomer(input: { email: string; password: string }): Promise<AuthResult> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) return { ok: false, reason: "E-Mail-Adresse oder Passwort ist falsch." };
const data: { token: string; user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string } } =
await res.json();
return {
ok: true,
token: data.token,
customer: {
id: data.user.id,
customerNumber: data.user.customerNumber,
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
},
};
}
export async function getCustomerFromToken(token: string): Promise<CustomerSummary | null> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const data: { user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string } | null } =
await res.json();
if (!data.user) return null;
return {
id: data.user.id,
customerNumber: data.user.customerNumber,
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
};
}
export type CustomerAddress = {
deliveryMethod: "address" | "packstation" | null;
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string | null;
city: string | null;
country: string | null;
};
export type CustomerProfile = CustomerSummary & CustomerAddress;
type PayloadCustomerMe = {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
email: string;
deliveryMethod: "address" | "packstation" | null;
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string | null;
city: string | null;
country: string | null;
cart: { product: number; productSlug: string; quantity: number }[] | null;
};
export async function getCustomerProfile(token: string): Promise<CustomerProfile | null> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const data: { user: PayloadCustomerMe | null } = await res.json();
if (!data.user) return null;
const u = data.user;
return {
id: u.id,
customerNumber: u.customerNumber,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
deliveryMethod: u.deliveryMethod,
street: u.street,
packstationNumber: u.packstationNumber,
postNumber: u.postNumber,
zip: u.zip,
city: u.city,
country: u.country,
};
}
export async function updateCustomerProfile(
token: string,
customerId: number,
data: {
firstName: string;
lastName: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
postNumber?: string;
zip: string;
city: string;
country: string;
},
): Promise<{ ok: true } | { ok: false; reason: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) return { ok: false, reason: "Profil konnte nicht gespeichert werden." };
return { ok: true };
}
// Verifies the current password by attempting a real login with it (rather
// than trusting the caller) before changing anything — self-update access
// alone (see Customers.ts) would let an already-authenticated request set
// any password without proving it knows the old one.
export async function changeCustomerPassword(
email: string,
currentPassword: string,
newPassword: string,
): Promise<{ ok: true } | { ok: false; reason: string }> {
const verify = await loginCustomer({ email, password: currentPassword });
if (!verify.ok) return { ok: false, reason: "Aktuelles Passwort ist falsch." };
const res = await fetch(`${PAYLOAD_URL}/api/customers/${verify.customer.id}`, {
method: "PATCH",
headers: { Authorization: `JWT ${verify.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ password: newPassword }),
});
if (!res.ok) return { ok: false, reason: "Passwort konnte nicht geändert werden." };
return { ok: true };
}
export async function getServerCart(token: string): Promise<CartItem[]> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return [];
const data: { user: PayloadCustomerMe | null } = await res.json();
return (data.user?.cart ?? []).map((line) => ({ id: line.productSlug, qty: line.quantity }));
}
export async function saveServerCart(
token: string,
customerId: number,
cart: { productId: number; productSlug: string; quantity: number }[],
): Promise<boolean> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
cart: cart.map((line) => ({ product: line.productId, productSlug: line.productSlug, quantity: line.quantity })),
}),
});
return res.ok;
}
export const ORDER_STATUS_LABEL: Record<string, string> = {
received: "Eingegangen",
processing: "In Bearbeitung",
shipped: "Versandt",
delivered: "Zugestellt",
};
export type CustomerOrder = {
orderNumber: string;
createdAt: string;
total: number;
status: string;
itemCount: number;
};
export async function getCustomerOrders(token: string, customerId: number): Promise<CustomerOrder[]> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
sort: "-createdAt",
depth: "0",
limit: "50",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return [];
const data: { docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: unknown[] }[] } =
await res.json();
return (data.docs ?? []).map((doc) => ({
orderNumber: doc.orderNumber,
createdAt: doc.createdAt,
total: doc.total,
status: doc.status,
itemCount: doc.items.length,
}));
}
export type CustomerOrderDetail = CustomerOrder & {
customerFirstName: string;
customerLastName: string;
customerEmail: string;
deliveryMethod: "address" | "packstation";
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string;
city: string;
country: string;
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
paymentMethodTitle: string;
discountCode: string | null;
discountAmount: number;
items: { productName: string; quantity: number; unitPrice: number }[];
};
// Access control (Orders.ts) already scopes a customer's own JWT to only
// their own orders — the where[customer] filter here is redundant with
// that, kept only so a wrong/foreign orderNumber returns "not found"
// instead of leaking whether that order number exists for someone else.
export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise<CustomerOrderDetail | null> {
const params = new URLSearchParams({
"where[orderNumber][equals]": orderNumber,
"where[customer][equals]": String(customerId),
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const data: { docs?: (Omit<CustomerOrderDetail, "itemCount"> & { items: { productName: string; quantity: number; unitPrice: number }[] })[] } =
await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
return { ...doc, itemCount: doc.items.length };
}
// Cookie helpers — Next.js's async cookies() API (Next 15+), usable in
// Route Handlers (read/write) and Server Components (read-only).
export async function setSessionCookie(token: string) {
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 2, // matches Payload's default JWT lifetime — no refresh flow in this stage
});
}
export async function clearSessionCookie() {
const store = await cookies();
store.delete(SESSION_COOKIE);
}
export async function readSessionToken(): Promise<string | null> {
const store = await cookies();
return store.get(SESSION_COOKIE)?.value ?? null;
}
// Convenience for Server Components (checkout page, /konto/*) that just
// need "who's logged in, if anyone" without touching the cookie API twice.
export async function getSessionCustomer(): Promise<{ token: string; customer: CustomerSummary } | null> {
const token = await readSessionToken();
if (!token) return null;
const customer = await getCustomerFromToken(token);
if (!customer) return null;
return { token, customer };
}
+4 -10
View File
@@ -2,10 +2,10 @@ 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.
// forever. Written by /checkout's "Jetzt kaufen" submit once
// POST /api/checkout confirms the order was actually persisted in
// Payload (orderNumber/orderDateIso come back from that response, not
// generated locally), read once by /bestellbestaetigung.
export const ORDER_KEY = "ep_last_order";
export type OrderSnapshot = {
@@ -20,9 +20,3 @@ export type OrderSnapshot = {
discountCode: string | null;
discountAmount: number;
};
export function generateOrderNumber(): string {
const year = new Date().getFullYear();
const rand = Math.floor(1000 + Math.random() * 9000);
return `#EP-${year}-${rand}`;
}
+104
View File
@@ -0,0 +1,104 @@
// Server-only — imported exclusively by app/api/checkout/route.ts. Kept
// out of lib/payload.ts on purpose, same reasoning as discountServer.ts's
// own comment about staying free of next/headers in a module that's also
// reachable from "use client" import graphs.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || "";
// Resolved once per request rather than cached across requests — this
// instance only has 1 tenant today, but a module-level cache would be the
// kind of thing that silently goes stale the moment a second tenant shows
// up. Cheap enough (single indexed lookup) not to worry about at this
// traffic level.
async function resolveTenantId(): Promise<number | null> {
const params = new URLSearchParams({ "where[slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/tenants?${params}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { docs?: { id: number }[] } = await res.json();
return data.docs?.[0]?.id ?? null;
}
export type OrderItemInput = {
productId: number;
productName: string;
quantity: number;
unitPrice: number;
};
export type CreateOrderInput = {
customerId: number;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
postNumber?: string;
zip: string;
city: string;
country: string;
newsletterOptIn: boolean;
items: OrderItemInput[];
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
paymentMethodTitle: string;
discountCode: string | null;
discountAmount: number;
total: number;
};
export type CreatedOrder = { orderNumber: string; createdAt: string };
export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder | null> {
const tenantId = await resolveTenantId();
if (tenantId == null) {
console.error("createOrder: could not resolve tenant id");
return null;
}
const res = await fetch(`${PAYLOAD_URL}/api/orders`, {
method: "POST",
headers: {
"x-order-service-secret": SERVICE_SECRET,
"Content-Type": "application/json",
},
body: JSON.stringify({
tenant: tenantId,
customer: input.customerId,
customerFirstName: input.customerFirstName,
customerLastName: input.customerLastName,
customerEmail: input.customerEmail,
deliveryMethod: input.deliveryMethod,
street: input.street,
packstationNumber: input.packstationNumber,
postNumber: input.postNumber,
zip: input.zip,
city: input.city,
country: input.country,
newsletterOptIn: input.newsletterOptIn,
items: input.items.map((i) => ({
product: i.productId,
productName: i.productName,
quantity: i.quantity,
unitPrice: i.unitPrice,
})),
subtotal: input.subtotal,
shippingCost: input.shippingCost,
shippingMethodTitle: input.shippingMethodTitle,
paymentMethodTitle: input.paymentMethodTitle,
discountCode: input.discountCode,
discountAmount: input.discountAmount,
total: input.total,
}),
});
if (!res.ok) {
console.error(`createOrder: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { doc: { orderNumber: string; createdAt: string } } = await res.json();
return { orderNumber: data.doc.orderNumber, createdAt: data.doc.createdAt };
}
+20
View File
@@ -0,0 +1,20 @@
// Server-only — shared by app/api/checkout/route.ts and
// app/api/account/cart/route.ts. Fetched directly (not via lib/payload.ts's
// getProducts()) because that helper's mapped Product type drops the
// numeric Payload id, which both callers need (Orders.items.product /
// Customers.cart.product relationships) alongside the raw, un-trusted-by-
// the-client price.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export type RawProduct = { id: number; slug: string; name: string; price: number; active: boolean };
export async function fetchProductsBySlug(): Promise<Map<string, RawProduct>> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "100" });
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { cache: "no-store" });
const map = new Map<string, RawProduct>();
if (!res.ok) return map;
const data: { docs?: RawProduct[] } = await res.json();
for (const doc of data.docs ?? []) map.set(doc.slug, doc);
return map;
}