43944d8cc8
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns
Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
included, broken down per rate when a cart spans more than one
(new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown
Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).
Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.
Checkout:
- Optional deviating shipping address (separate from the billing address
used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
address differs from billing
Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).
Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.
Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
579 lines
22 KiB
TypeScript
579 lines
22 KiB
TypeScript
import { cookies } from "next/headers";
|
|
import { randomUUID } from "node:crypto";
|
|
import type { CartItem } from "./cart";
|
|
import { sendVerificationEmail } from "./alertAdmin";
|
|
|
|
// 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";
|
|
// Reused from the order-creation service call (see orderServer.ts) for
|
|
// the handful of customer-collection operations that legitimately have no
|
|
// customer session of their own yet — the email-verification link click
|
|
// (cold, from an email client) being the main one. Same trust level
|
|
// ("this app's own backend acting on its own behalf"), so a third secret
|
|
// felt like unnecessary sprawl rather than added security.
|
|
const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || "";
|
|
|
|
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;
|
|
emailVerified: boolean;
|
|
};
|
|
|
|
export type AuthResult =
|
|
| { ok: true; token: string; customer: CustomerSummary }
|
|
| { ok: false; reason: string; emailExists?: boolean };
|
|
|
|
// Called from app/api/account/check-email/route.ts — lets the checkout
|
|
// form detect an existing account *before* a submit attempt fails (see
|
|
// CheckoutContent.tsx's email field onBlur), not just after. Service-secret
|
|
// authenticated (same reasoning as the other service calls in this file) —
|
|
// Customers' read access isn't public, and there's no customer session yet
|
|
// at this point either way.
|
|
export async function checkEmailExists(email: string): Promise<boolean> {
|
|
const params = new URLSearchParams({ "where[email][equals]": email, limit: "1" });
|
|
const res = await fetch(`${PAYLOAD_URL}/api/customers?${params}`, {
|
|
headers: { "x-order-service-secret": SERVICE_SECRET },
|
|
cache: "no-store",
|
|
});
|
|
if (!res.ok) return false;
|
|
const data: { docs?: unknown[] } = await res.json();
|
|
return (data.docs?.length ?? 0) > 0;
|
|
}
|
|
|
|
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;
|
|
// Payload's own message for a duplicate unique field is a generic
|
|
// "The following field is invalid: email" — not distinguishable from
|
|
// any other email-field validation failure by content alone, but at
|
|
// this point the client's own type="email" + required already ruled
|
|
// out a malformed/missing address, so "email" being the flagged field
|
|
// here in practice only ever means one thing: this address is already
|
|
// registered. emailExists lets the checkout UI react to that
|
|
// specifically (switch to the login toggle) instead of just showing
|
|
// an error the customer has no clear next step for.
|
|
const emailExists = Boolean(message?.toLowerCase().includes("email"));
|
|
return {
|
|
ok: false,
|
|
reason: emailExists
|
|
? "Diese E-Mail-Adresse ist bereits registriert. Bitte logge dich stattdessen ein."
|
|
: (message ?? "Registrierung ist gerade nicht möglich."),
|
|
emailExists,
|
|
};
|
|
}
|
|
|
|
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; emailVerified: boolean };
|
|
} = 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,
|
|
emailVerified: data.user.emailVerified,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Always resolves — never throws or returns a distinguishable "email not
|
|
// found" shape. Mirrors Payload's own forgot-password operation, which
|
|
// fails silently on a non-existent email specifically to avoid leaking
|
|
// which addresses are registered (see auth/operations/forgotPassword.js);
|
|
// the caller (app/api/account/forgot-password/route.ts) must preserve that
|
|
// by always responding the same way regardless of this call's outcome.
|
|
export async function requestPasswordReset(email: string): Promise<void> {
|
|
await fetch(`${PAYLOAD_URL}/api/customers/forgot-password`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email }),
|
|
}).catch(() => {
|
|
// Best-effort — same "never reveal anything" reasoning as above.
|
|
});
|
|
}
|
|
|
|
// Payload's reset-password operation logs the customer in on success (see
|
|
// auth/operations/resetPassword.js) and returns the same {token, user}
|
|
// shape as login — reused here so the frontend route can set the session
|
|
// cookie immediately, no separate login step needed after a reset.
|
|
export async function resetPassword(token: string, newPassword: string): Promise<AuthResult> {
|
|
const res = await fetch(`${PAYLOAD_URL}/api/customers/reset-password`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ token, password: newPassword }),
|
|
});
|
|
if (!res.ok) return { ok: false, reason: "Der Link ist ungültig oder abgelaufen." };
|
|
|
|
const data: {
|
|
token: string;
|
|
user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean };
|
|
} = 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,
|
|
emailVerified: data.user.emailVerified,
|
|
},
|
|
};
|
|
}
|
|
|
|
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; emailVerified: boolean } | 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,
|
|
emailVerified: data.user.emailVerified,
|
|
};
|
|
}
|
|
|
|
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;
|
|
emailVerified: boolean;
|
|
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; variantName: string | null }[] | 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,
|
|
emailVerified: u.emailVerified,
|
|
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 };
|
|
}
|
|
|
|
// Called from app/api/account/verify-email/route.ts — no customer session
|
|
// exists at this point (cold click from an email client), so this
|
|
// authenticates as the service instead (see SERVICE_SECRET above).
|
|
export async function verifyEmailByToken(token: string): Promise<boolean> {
|
|
const params = new URLSearchParams({ "where[emailVerificationToken][equals]": token, limit: "1" });
|
|
const res = await fetch(`${PAYLOAD_URL}/api/customers?${params}`, {
|
|
headers: { "x-order-service-secret": SERVICE_SECRET },
|
|
cache: "no-store",
|
|
});
|
|
if (!res.ok) return false;
|
|
const data: { docs?: { id: number; emailVerificationExpires: string | null }[] } = await res.json();
|
|
const doc = data.docs?.[0];
|
|
if (!doc) return false;
|
|
if (doc.emailVerificationExpires && new Date(doc.emailVerificationExpires).getTime() < Date.now()) return false;
|
|
|
|
const patchRes = await fetch(`${PAYLOAD_URL}/api/customers/${doc.id}`, {
|
|
method: "PATCH",
|
|
headers: { "x-order-service-secret": SERVICE_SECRET, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ emailVerified: true }),
|
|
});
|
|
return patchRes.ok;
|
|
}
|
|
|
|
// Called by an already-logged-in customer (app/api/account/resend-
|
|
// verification/route.ts) — updates the token via their own session (self-
|
|
// update access, see Customers.ts), then sends the mail directly (no
|
|
// Payload afterChange hook to piggyback on for a plain update — that hook
|
|
// only fires on create, see Customers.ts's own comment).
|
|
export async function resendVerificationEmail(session: { token: string; customer: CustomerSummary }): Promise<boolean> {
|
|
const newToken = randomUUID();
|
|
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
const res = await fetch(`${PAYLOAD_URL}/api/customers/${session.customer.id}`, {
|
|
method: "PATCH",
|
|
headers: { Authorization: `JWT ${session.token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ emailVerificationToken: newToken, emailVerificationExpires: expires }),
|
|
});
|
|
if (!res.ok) return false;
|
|
await sendVerificationEmail(session.customer.email, session.customer.firstName, newToken);
|
|
return true;
|
|
}
|
|
|
|
// Self-service GDPR deletion (app/api/account/delete/route.ts) — password
|
|
// re-verification happens there via loginCustomer() before this is ever
|
|
// called. orders.customer is ON DELETE SET NULL (see the Payload
|
|
// migration) — past orders keep their own name/address/items snapshot for
|
|
// tax-retention purposes (§147 AO / GDPR Art. 17(3)(b)), only the account
|
|
// itself disappears.
|
|
export async function deleteCustomerAccount(token: string, customerId: number): Promise<boolean> {
|
|
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `JWT ${token}` },
|
|
});
|
|
return res.ok;
|
|
}
|
|
|
|
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) =>
|
|
line.variantName ? { id: line.productSlug, qty: line.quantity, variant: line.variantName } : { id: line.productSlug, qty: line.quantity },
|
|
);
|
|
}
|
|
|
|
export async function saveServerCart(
|
|
token: string,
|
|
customerId: number,
|
|
cart: { productId: number; productSlug: string; quantity: number; variant?: string }[],
|
|
): 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, variantName: line.variant ?? null })),
|
|
}),
|
|
});
|
|
return res.ok;
|
|
}
|
|
|
|
export const ORDER_STATUS_LABEL: Record<string, string> = {
|
|
received: "Eingegangen",
|
|
processing: "In Bearbeitung",
|
|
shipped: "Versandt",
|
|
delivered: "Zugestellt",
|
|
cancelled: "Storniert",
|
|
return_requested: "Rücksendung angefragt",
|
|
returned: "Zurückgesendet",
|
|
};
|
|
|
|
// Which self-service action is available given the order's current
|
|
// status — mirrors CUSTOMER_ALLOWED_TRANSITIONS in Orders.ts exactly
|
|
// (that hook is the real security boundary; this is just so the UI can
|
|
// decide which button, if any, to show).
|
|
export function customerOrderAction(status: string): "cancel" | "request-return" | null {
|
|
if (status === "received") return "cancel";
|
|
if (status === "shipped" || status === "delivered") return "request-return";
|
|
return null;
|
|
}
|
|
|
|
export type CustomerOrder = {
|
|
orderNumber: string;
|
|
createdAt: string;
|
|
total: number;
|
|
status: string;
|
|
itemCount: number;
|
|
/** Raw product relationship ids, in item order — depth=0 keeps them as
|
|
* plain numbers, not populated objects. Callers resolve these to image
|
|
* URLs separately via payload.ts's getProductImagesByIds(), not here —
|
|
* this file already deliberately doesn't fetch from lib/payload.ts. */
|
|
productIds: 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: { product: number }[] }[];
|
|
} = await res.json();
|
|
return (data.docs ?? []).map((doc) => ({
|
|
orderNumber: doc.orderNumber,
|
|
createdAt: doc.createdAt,
|
|
total: doc.total,
|
|
status: doc.status,
|
|
itemCount: doc.items.length,
|
|
productIds: doc.items.map((item) => item.product),
|
|
}));
|
|
}
|
|
|
|
export type CustomerOrderDetail = CustomerOrder & {
|
|
id: number;
|
|
invoiceNumber: string | null;
|
|
invoiceIssuedAt: string | null;
|
|
correctionInvoiceNumber: string | null;
|
|
correctionInvoiceIssuedAt: string | null;
|
|
carrier: string | null;
|
|
trackingNumber: string | null;
|
|
customerFirstName: string;
|
|
customerLastName: string;
|
|
customerEmail: string;
|
|
deliveryMethod: "address" | "packstation";
|
|
street: string | null;
|
|
packstationNumber: string | null;
|
|
postNumber: string | null;
|
|
zip: string;
|
|
city: string;
|
|
country: string;
|
|
hasDifferentShippingAddress: boolean;
|
|
shippingFirstName: string | null;
|
|
shippingLastName: string | null;
|
|
shippingDeliveryMethod: "address" | "packstation" | null;
|
|
shippingStreet: string | null;
|
|
shippingPackstationNumber: string | null;
|
|
shippingPostNumber: string | null;
|
|
shippingZip: string | null;
|
|
shippingCity: string | null;
|
|
shippingCountry: string | null;
|
|
subtotal: number;
|
|
shippingCost: number;
|
|
shippingMethodTitle: string;
|
|
paymentMethodTitle: string;
|
|
discountCode: string | null;
|
|
discountAmount: number;
|
|
returnReason: string | null;
|
|
items: CustomerOrderItem[];
|
|
};
|
|
|
|
export type CustomerOrderItem = {
|
|
product: number;
|
|
productName: string;
|
|
quantity: number;
|
|
unitPrice: number;
|
|
taxRatePercent: number;
|
|
bundleContents: string | null;
|
|
variantName: string | null;
|
|
returnQuantity: 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.
|
|
// depth=0 — every field this type reads is already flat; keeping `product`
|
|
// as a plain id (not populated) is what lets requestOrderStatusChange's
|
|
// caller round-trip a full, valid items array back on a return request
|
|
// (Orders.ts's field-lock hook needs every required item field present,
|
|
// not just returnQuantity — see that hook's own comment).
|
|
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),
|
|
depth: "0",
|
|
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">[] } = await res.json();
|
|
const doc = data.docs?.[0];
|
|
if (!doc) return null;
|
|
return { ...doc, itemCount: doc.items.length };
|
|
}
|
|
|
|
// Called from app/api/account/orders/[orderNumber]/route.ts. Security
|
|
// lives in Orders.ts's beforeChange hook (only `status` can change, plus
|
|
// each item's `returnQuantity` alongside a return_requested transition —
|
|
// see that hook's own comment) — this is just the authenticated call; a
|
|
// request the hook rejects comes back as a non-ok response here.
|
|
//
|
|
// `items`, when provided, must be the order's FULL current items array
|
|
// with only `returnQuantity` adjusted on the returned lines — Payload's
|
|
// array field expects every required sub-field present on each row, not
|
|
// a sparse "just the changed key" patch (the field-lock hook's own diff
|
|
// also expects to see the untouched fields, not their absence). The
|
|
// caller (the API route, which already has the order loaded) builds this
|
|
// from getCustomerOrderDetail()'s own `items`.
|
|
export async function requestOrderStatusChange(
|
|
token: string,
|
|
orderId: number,
|
|
action: "cancel" | "request-return",
|
|
extra?: { returnReason?: string; items?: CustomerOrderItem[] },
|
|
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
const status = action === "cancel" ? "cancelled" : "return_requested";
|
|
const body: { status: string; returnReason?: string; items?: CustomerOrderItem[] } = { status };
|
|
if (action === "request-return") {
|
|
if (extra?.returnReason) body.returnReason = extra.returnReason;
|
|
if (extra?.items) body.items = extra.items;
|
|
}
|
|
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}`, {
|
|
method: "PATCH",
|
|
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
return { ok: false, reason: data?.errors?.[0]?.message ?? "Aktion war nicht möglich." };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
// 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 };
|
|
}
|