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