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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user