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
+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,
});
}