Blog categories (hasMany), shipping-address contact fields, product SKU on invoices

- Posts.categories is now hasMany — blog list/detail/live-preview render
  a comma-joined list instead of a single category.
- Checkout's "Abweichende Lieferadresse" gains optional Firma + Kontakt-
  E-Mail/Telefon fields (handed to the shipping carrier, not used for
  customer communication).
- Customer profile can now store its own shipping address (mirroring
  Customers.ts's new "Lieferadresse" tab), prefilling the checkout
  override instead of always starting blank.
- Product-level optional SKU (previously only on variants) snapshots onto
  each order item and shows up on invoices (visual + EN16931 XML),
  the confirmation email, and the account order detail page.
This commit is contained in:
Marco
2026-07-30 08:41:39 +00:00
parent 1467750db6
commit e3352d7e32
18 changed files with 403 additions and 34 deletions
+1
View File
@@ -6,6 +6,7 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
id: 1,
slug: "starter-set",
name: "Starter-Set",
sku: null,
price: 29.9,
active: true,
image: null,
+3
View File
@@ -36,6 +36,9 @@ export type CheckoutDraft = {
shippingZip: string;
shippingCity: string;
shippingCountry: string;
shippingCompanyName: string;
shippingContactEmail: string;
shippingContactPhone: string;
newsletterOptIn: boolean;
shippingMethodId: number | null;
paymentMethodId: number | null;
+60
View File
@@ -203,6 +203,23 @@ export type CustomerAddress = {
// /checkout's Firma/USt-IdNr. fields for a returning customer.
companyName: string | null;
vatId: string | null;
// Optional second/shipping address — see Customers.ts's "Lieferadresse"
// tab. Prefills /checkout's "Abweichende Lieferadresse" section once
// hasDifferentShippingAddress is set here; still fully overwritable per
// order (Orders keeps its own shipping* snapshot regardless).
hasDifferentShippingAddress: boolean;
shippingFirstName: string | null;
shippingLastName: string | null;
shippingCompanyName: string | null;
shippingDeliveryMethod: "address" | "packstation" | null;
shippingStreet: string | null;
shippingPackstationNumber: string | null;
shippingPostNumber: string | null;
shippingZip: string | null;
shippingCity: string | null;
shippingCountry: string | null;
shippingContactEmail: string | null;
shippingContactPhone: string | null;
};
export type CustomerProfile = CustomerSummary & CustomerAddress;
@@ -223,6 +240,19 @@ type PayloadCustomerMe = {
country: string | null;
companyName: string | null;
vatId: string | null;
hasDifferentShippingAddress: boolean | null;
shippingFirstName: string | null;
shippingLastName: string | null;
shippingCompanyName: string | null;
shippingDeliveryMethod: "address" | "packstation" | null;
shippingStreet: string | null;
shippingPackstationNumber: string | null;
shippingPostNumber: string | null;
shippingZip: string | null;
shippingCity: string | null;
shippingCountry: string | null;
shippingContactEmail: string | null;
shippingContactPhone: string | null;
cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null;
};
@@ -251,6 +281,19 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
country: u.country,
companyName: u.companyName,
vatId: u.vatId,
hasDifferentShippingAddress: Boolean(u.hasDifferentShippingAddress),
shippingFirstName: u.shippingFirstName,
shippingLastName: u.shippingLastName,
shippingCompanyName: u.shippingCompanyName,
shippingDeliveryMethod: u.shippingDeliveryMethod,
shippingStreet: u.shippingStreet,
shippingPackstationNumber: u.shippingPackstationNumber,
shippingPostNumber: u.shippingPostNumber,
shippingZip: u.shippingZip,
shippingCity: u.shippingCity,
shippingCountry: u.shippingCountry,
shippingContactEmail: u.shippingContactEmail,
shippingContactPhone: u.shippingContactPhone,
};
}
@@ -269,6 +312,19 @@ export async function updateCustomerProfile(
country: string;
companyName?: string;
vatId?: string;
hasDifferentShippingAddress?: boolean;
shippingFirstName?: string;
shippingLastName?: string;
shippingCompanyName?: string;
shippingDeliveryMethod?: "address" | "packstation";
shippingStreet?: string;
shippingPackstationNumber?: string;
shippingPostNumber?: string;
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
shippingContactEmail?: string;
shippingContactPhone?: string;
},
): Promise<{ ok: true } | { ok: false; reason: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
@@ -495,6 +551,7 @@ export type CustomerOrderDetail = CustomerOrder & {
hasDifferentShippingAddress: boolean;
shippingFirstName: string | null;
shippingLastName: string | null;
shippingCompanyName: string | null;
shippingDeliveryMethod: "address" | "packstation" | null;
shippingStreet: string | null;
shippingPackstationNumber: string | null;
@@ -502,6 +559,8 @@ export type CustomerOrderDetail = CustomerOrder & {
shippingZip: string | null;
shippingCity: string | null;
shippingCountry: string | null;
shippingContactEmail: string | null;
shippingContactPhone: string | null;
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
@@ -521,6 +580,7 @@ export type CustomerOrderItem = {
bundleContents: string | null;
variantName: string | null;
returnQuantity: number;
sku: string | null;
};
// Access control (Orders.ts) already scopes a customer's own JWT to only
+2 -1
View File
@@ -196,6 +196,7 @@ export type OrderConfirmationItem = {
bundleContents?: string | null;
variantName?: string | null;
taxRatePercent: number;
sku?: string | null;
};
export type OrderConfirmationData = {
orderNumber: string;
@@ -249,7 +250,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
}
</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}${item.sku ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">Art.-Nr. ${escapeHtml(item.sku)}</span>` : ""}</td>
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
</tr>`,
)
+7
View File
@@ -27,6 +27,7 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
hasDifferentShippingAddress?: boolean;
shippingFirstName?: string | null;
shippingLastName?: string | null;
shippingCompanyName?: string | null;
shippingDeliveryMethod?: "address" | "packstation" | null;
shippingStreet?: string | null;
shippingPackstationNumber?: string | null;
@@ -34,6 +35,8 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
shippingZip?: string | null;
shippingCity?: string | null;
shippingCountry?: string | null;
shippingContactEmail?: string | null;
shippingContactPhone?: string | null;
paymentMethodTitle: string;
};
@@ -93,6 +96,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
hasDifferentShippingAddress: order.hasDifferentShippingAddress ?? false,
shippingFirstName: order.shippingFirstName,
shippingLastName: order.shippingLastName,
shippingCompanyName: order.shippingCompanyName,
shippingDeliveryMethod: order.shippingDeliveryMethod,
shippingStreet: order.shippingStreet,
shippingPackstationNumber: order.shippingPackstationNumber,
@@ -100,6 +104,8 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
shippingZip: order.shippingZip,
shippingCity: order.shippingCity,
shippingCountry: order.shippingCountry,
shippingContactEmail: order.shippingContactEmail,
shippingContactPhone: order.shippingContactPhone,
paymentMethodTitle: order.paymentMethodTitle,
items: order.items.map((i) => ({
productName: i.productName,
@@ -109,6 +115,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
bundleContents: i.bundleContents ?? null,
variantName: i.variantName ?? null,
imageUrl: i.imageUrl ?? null,
sku: i.sku ?? null,
})),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
+22
View File
@@ -27,6 +27,11 @@ export type OrderItemInput = {
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
// Resolved by the caller (api/checkout/route.ts): the variant's own sku
// if one was selected, else the product's sku, else null — same
// "variant overrides product" precedence as unitPrice/taxRatePercent
// elsewhere in this checkout flow.
sku: string | null;
};
export type CreateOrderInput = {
@@ -67,11 +72,23 @@ export type CreateOrderInput = {
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
// Optional, mirrors companyName above — no shipping-side vatId (billing-
// only concept). Contact email/phone have no billing-side equivalent:
// they're handed to the shipping carrier, never used for customer
// communication.
shippingCompanyName?: string;
shippingContactEmail?: string;
shippingContactPhone?: string;
newsletterOptIn: boolean;
items: OrderItemInput[];
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
// Numeric ShippingMethod id, not the same as shippingMethodTitle's frozen
// text snapshot — lets pollCarrierTracking.ts resolve order → shipping
// method → carrier without a fragile title-text match. See Orders.ts's
// own comment on why both fields exist side by side.
shippingMethod: number;
paymentMethodTitle: string;
discountCode: string | null;
discountAmount: number;
@@ -140,6 +157,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
shippingZip: input.shippingZip,
shippingCity: input.shippingCity,
shippingCountry: input.shippingCountry,
shippingCompanyName: input.shippingCompanyName,
shippingContactEmail: input.shippingContactEmail,
shippingContactPhone: input.shippingContactPhone,
newsletterOptIn: input.newsletterOptIn,
items: input.items.map((i) => ({
product: i.productId,
@@ -149,10 +169,12 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
variantName: i.variantName,
sku: i.sku,
})),
subtotal: input.subtotal,
shippingCost: input.shippingCost,
shippingMethodTitle: input.shippingMethodTitle,
shippingMethod: input.shippingMethod,
paymentMethodTitle: input.paymentMethodTitle,
discountCode: input.discountCode,
discountAmount: input.discountAmount,
+8 -8
View File
@@ -21,7 +21,7 @@ export type BlogPost = {
id: number;
title: string;
slug: string;
category: string;
categories: string[];
readTime: number;
excerpt: string;
thumbnail: string | null;
@@ -33,7 +33,7 @@ type PayloadPost = {
id: number;
title: string;
slug: string;
category: { name: string } | number | null;
categories: ({ name: string } | number)[];
readTime: number;
excerpt: string;
thumbnail: { url: string } | number | null;
@@ -73,10 +73,9 @@ export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
id: post.id,
title: post.title,
slug: post.slug,
category:
typeof post.category === "object" && post.category
? post.category.name
: "",
categories: (post.categories ?? [])
.map((c) => (typeof c === "object" && c ? c.name : null))
.filter((name): name is string => Boolean(name)),
readTime: post.readTime,
excerpt: post.excerpt,
thumbnail:
@@ -125,8 +124,9 @@ export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
id: doc.id,
title: doc.title,
slug: doc.slug,
category:
typeof doc.category === "object" && doc.category ? doc.category.name : "",
categories: (doc.categories ?? [])
.map((c) => (typeof c === "object" && c ? c.name : null))
.filter((name): name is string => Boolean(name)),
readTime: doc.readTime,
excerpt: doc.excerpt,
thumbnail:
+1
View File
@@ -20,6 +20,7 @@ export type RawProduct = {
id: number;
slug: string;
name: string;
sku: string | null;
price: number;
active: boolean;
image: { url: string } | number | null;