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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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} />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user