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
+18
View File
@@ -106,3 +106,21 @@ export function useCartCount(): number {
export function useCart(): CartItem[] {
return useSyncExternalStore(subscribe, getCart, () => EMPTY_CART);
}
// Called right after a successful login (LoginForm.tsx, CheckoutContent.tsx's
// inline login toggle) — folds whatever was saved server-side into the
// local cart by quantity (addToCart adds to an existing line rather than
// overwriting it), so items added before logging in aren't lost. CartSync
// then picks up the resulting change and pushes the merged cart back to
// the server on its own, closing the loop without a separate save call here.
export async function mergeServerCartIntoLocal(): Promise<void> {
try {
const res = await fetch("/api/account/cart");
if (!res.ok) return;
const data: { cart?: CartItem[] } = await res.json();
for (const item of data.cart ?? []) addToCart(item.id, item.qty);
} catch {
// Best-effort — a failed merge just means the server-side cart stays
// as it was; nothing local is lost either way.
}
}
+332
View File
@@ -0,0 +1,332 @@
import { cookies } from "next/headers";
import type { CartItem } from "./cart";
// Server-only — imported by app/api/account/*/route.ts, app/api/checkout/
// route.ts, and the /checkout and /konto/* Server Components. Never touch
// Payload's own auth cookie directly: Payload (payload.mk360.de) and this
// app (einfach-produktiv.mk360.de) are different origins, so instead this
// app mints its OWN httpOnly cookie holding the JWT Payload issued, and
// simply forwards that token as an Authorization header on every
// subsequent Payload call — no shared-domain cookie config, no CORS setup
// needed on the Payload side.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
const SESSION_COOKIE = "ep_customer_token";
async function resolveTenantId(): Promise<number | null> {
const params = new URLSearchParams({ "where[slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/tenants?${params}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { docs?: { id: number }[] } = await res.json();
return data.docs?.[0]?.id ?? null;
}
export type CustomerSummary = {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
email: string;
};
export type AuthResult = { ok: true; token: string; customer: CustomerSummary } | { ok: false; reason: string };
export async function registerCustomer(input: {
firstName: string;
lastName: string;
email: string;
password: string;
}): Promise<AuthResult> {
const tenantId = await resolveTenantId();
if (tenantId == null) return { ok: false, reason: "Registrierung ist gerade nicht möglich." };
const res = await fetch(`${PAYLOAD_URL}/api/customers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...input, tenant: tenantId }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
const message: string | undefined = data?.errors?.[0]?.message;
return { ok: false, reason: message ?? "Diese E-Mail-Adresse ist bereits registriert." };
}
return loginCustomer(input);
}
export async function loginCustomer(input: { email: string; password: string }): Promise<AuthResult> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) return { ok: false, reason: "E-Mail-Adresse oder Passwort ist falsch." };
const data: { token: string; user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string } } =
await res.json();
return {
ok: true,
token: data.token,
customer: {
id: data.user.id,
customerNumber: data.user.customerNumber,
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
},
};
}
export async function getCustomerFromToken(token: string): Promise<CustomerSummary | null> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const data: { user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string } | null } =
await res.json();
if (!data.user) return null;
return {
id: data.user.id,
customerNumber: data.user.customerNumber,
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
};
}
export type CustomerAddress = {
deliveryMethod: "address" | "packstation" | null;
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string | null;
city: string | null;
country: string | null;
};
export type CustomerProfile = CustomerSummary & CustomerAddress;
type PayloadCustomerMe = {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
email: string;
deliveryMethod: "address" | "packstation" | null;
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string | null;
city: string | null;
country: string | null;
cart: { product: number; productSlug: string; quantity: number }[] | null;
};
export async function getCustomerProfile(token: string): Promise<CustomerProfile | null> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const data: { user: PayloadCustomerMe | null } = await res.json();
if (!data.user) return null;
const u = data.user;
return {
id: u.id,
customerNumber: u.customerNumber,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
deliveryMethod: u.deliveryMethod,
street: u.street,
packstationNumber: u.packstationNumber,
postNumber: u.postNumber,
zip: u.zip,
city: u.city,
country: u.country,
};
}
export async function updateCustomerProfile(
token: string,
customerId: number,
data: {
firstName: string;
lastName: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
postNumber?: string;
zip: string;
city: string;
country: string;
},
): Promise<{ ok: true } | { ok: false; reason: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) return { ok: false, reason: "Profil konnte nicht gespeichert werden." };
return { ok: true };
}
// Verifies the current password by attempting a real login with it (rather
// than trusting the caller) before changing anything — self-update access
// alone (see Customers.ts) would let an already-authenticated request set
// any password without proving it knows the old one.
export async function changeCustomerPassword(
email: string,
currentPassword: string,
newPassword: string,
): Promise<{ ok: true } | { ok: false; reason: string }> {
const verify = await loginCustomer({ email, password: currentPassword });
if (!verify.ok) return { ok: false, reason: "Aktuelles Passwort ist falsch." };
const res = await fetch(`${PAYLOAD_URL}/api/customers/${verify.customer.id}`, {
method: "PATCH",
headers: { Authorization: `JWT ${verify.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ password: newPassword }),
});
if (!res.ok) return { ok: false, reason: "Passwort konnte nicht geändert werden." };
return { ok: true };
}
export async function getServerCart(token: string): Promise<CartItem[]> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return [];
const data: { user: PayloadCustomerMe | null } = await res.json();
return (data.user?.cart ?? []).map((line) => ({ id: line.productSlug, qty: line.quantity }));
}
export async function saveServerCart(
token: string,
customerId: number,
cart: { productId: number; productSlug: string; quantity: number }[],
): Promise<boolean> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
cart: cart.map((line) => ({ product: line.productId, productSlug: line.productSlug, quantity: line.quantity })),
}),
});
return res.ok;
}
export const ORDER_STATUS_LABEL: Record<string, string> = {
received: "Eingegangen",
processing: "In Bearbeitung",
shipped: "Versandt",
delivered: "Zugestellt",
};
export type CustomerOrder = {
orderNumber: string;
createdAt: string;
total: number;
status: string;
itemCount: number;
};
export async function getCustomerOrders(token: string, customerId: number): Promise<CustomerOrder[]> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
sort: "-createdAt",
depth: "0",
limit: "50",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return [];
const data: { docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: unknown[] }[] } =
await res.json();
return (data.docs ?? []).map((doc) => ({
orderNumber: doc.orderNumber,
createdAt: doc.createdAt,
total: doc.total,
status: doc.status,
itemCount: doc.items.length,
}));
}
export type CustomerOrderDetail = CustomerOrder & {
customerFirstName: string;
customerLastName: string;
customerEmail: string;
deliveryMethod: "address" | "packstation";
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string;
city: string;
country: string;
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
paymentMethodTitle: string;
discountCode: string | null;
discountAmount: number;
items: { productName: string; quantity: number; unitPrice: number }[];
};
// Access control (Orders.ts) already scopes a customer's own JWT to only
// their own orders — the where[customer] filter here is redundant with
// that, kept only so a wrong/foreign orderNumber returns "not found"
// instead of leaking whether that order number exists for someone else.
export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise<CustomerOrderDetail | null> {
const params = new URLSearchParams({
"where[orderNumber][equals]": orderNumber,
"where[customer][equals]": String(customerId),
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const data: { docs?: (Omit<CustomerOrderDetail, "itemCount"> & { items: { productName: string; quantity: number; unitPrice: number }[] })[] } =
await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
return { ...doc, itemCount: doc.items.length };
}
// Cookie helpers — Next.js's async cookies() API (Next 15+), usable in
// Route Handlers (read/write) and Server Components (read-only).
export async function setSessionCookie(token: string) {
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 2, // matches Payload's default JWT lifetime — no refresh flow in this stage
});
}
export async function clearSessionCookie() {
const store = await cookies();
store.delete(SESSION_COOKIE);
}
export async function readSessionToken(): Promise<string | null> {
const store = await cookies();
return store.get(SESSION_COOKIE)?.value ?? null;
}
// Convenience for Server Components (checkout page, /konto/*) that just
// need "who's logged in, if anyone" without touching the cookie API twice.
export async function getSessionCustomer(): Promise<{ token: string; customer: CustomerSummary } | null> {
const token = await readSessionToken();
if (!token) return null;
const customer = await getCustomerFromToken(token);
if (!customer) return null;
return { token, customer };
}
+4 -10
View File
@@ -2,10 +2,10 @@ import type { CartItem } from "./cart";
// sessionStorage, not localStorage — this is a one-time receipt for the
// tab that just placed the order, not something that should persist
// forever. Written by /checkout's "Jetzt kaufen" click (capturing
// whichever shipping/payment method was actually selected there — the
// site has no real order backend, so this snapshot IS the order record),
// read once by /bestellbestaetigung.
// forever. Written by /checkout's "Jetzt kaufen" submit once
// POST /api/checkout confirms the order was actually persisted in
// Payload (orderNumber/orderDateIso come back from that response, not
// generated locally), read once by /bestellbestaetigung.
export const ORDER_KEY = "ep_last_order";
export type OrderSnapshot = {
@@ -20,9 +20,3 @@ export type OrderSnapshot = {
discountCode: string | null;
discountAmount: number;
};
export function generateOrderNumber(): string {
const year = new Date().getFullYear();
const rand = Math.floor(1000 + Math.random() * 9000);
return `#EP-${year}-${rand}`;
}
+104
View File
@@ -0,0 +1,104 @@
// Server-only — imported exclusively by app/api/checkout/route.ts. Kept
// out of lib/payload.ts on purpose, same reasoning as discountServer.ts's
// own comment about staying free of next/headers in a module that's also
// reachable from "use client" import graphs.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || "";
// Resolved once per request rather than cached across requests — this
// instance only has 1 tenant today, but a module-level cache would be the
// kind of thing that silently goes stale the moment a second tenant shows
// up. Cheap enough (single indexed lookup) not to worry about at this
// traffic level.
async function resolveTenantId(): Promise<number | null> {
const params = new URLSearchParams({ "where[slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/tenants?${params}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { docs?: { id: number }[] } = await res.json();
return data.docs?.[0]?.id ?? null;
}
export type OrderItemInput = {
productId: number;
productName: string;
quantity: number;
unitPrice: number;
};
export type CreateOrderInput = {
customerId: number;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
postNumber?: string;
zip: string;
city: string;
country: string;
newsletterOptIn: boolean;
items: OrderItemInput[];
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
paymentMethodTitle: string;
discountCode: string | null;
discountAmount: number;
total: number;
};
export type CreatedOrder = { orderNumber: string; createdAt: string };
export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder | null> {
const tenantId = await resolveTenantId();
if (tenantId == null) {
console.error("createOrder: could not resolve tenant id");
return null;
}
const res = await fetch(`${PAYLOAD_URL}/api/orders`, {
method: "POST",
headers: {
"x-order-service-secret": SERVICE_SECRET,
"Content-Type": "application/json",
},
body: JSON.stringify({
tenant: tenantId,
customer: input.customerId,
customerFirstName: input.customerFirstName,
customerLastName: input.customerLastName,
customerEmail: input.customerEmail,
deliveryMethod: input.deliveryMethod,
street: input.street,
packstationNumber: input.packstationNumber,
postNumber: input.postNumber,
zip: input.zip,
city: input.city,
country: input.country,
newsletterOptIn: input.newsletterOptIn,
items: input.items.map((i) => ({
product: i.productId,
productName: i.productName,
quantity: i.quantity,
unitPrice: i.unitPrice,
})),
subtotal: input.subtotal,
shippingCost: input.shippingCost,
shippingMethodTitle: input.shippingMethodTitle,
paymentMethodTitle: input.paymentMethodTitle,
discountCode: input.discountCode,
discountAmount: input.discountAmount,
total: input.total,
}),
});
if (!res.ok) {
console.error(`createOrder: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { doc: { orderNumber: string; createdAt: string } } = await res.json();
return { orderNumber: data.doc.orderNumber, createdAt: data.doc.createdAt };
}
+20
View File
@@ -0,0 +1,20 @@
// Server-only — shared by app/api/checkout/route.ts and
// app/api/account/cart/route.ts. Fetched directly (not via lib/payload.ts's
// getProducts()) because that helper's mapped Product type drops the
// numeric Payload id, which both callers need (Orders.items.product /
// Customers.cart.product relationships) alongside the raw, un-trusted-by-
// the-client price.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export type RawProduct = { id: number; slug: string; name: string; price: number; active: boolean };
export async function fetchProductsBySlug(): Promise<Map<string, RawProduct>> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "100" });
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { cache: "no-store" });
const map = new Map<string, RawProduct>();
if (!res.ok) return map;
const data: { docs?: RawProduct[] } = await res.json();
for (const doc of data.docs ?? []) map.set(doc.slug, doc);
return map;
}