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