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