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
+66 -1
View File
@@ -13,7 +13,32 @@ export async function PATCH(request: Request) {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
const {
firstName,
lastName,
deliveryMethod,
street,
packstationNumber,
postNumber,
zip,
city,
country,
companyName,
vatId,
hasDifferentShippingAddress,
shippingFirstName,
shippingLastName,
shippingCompanyName,
shippingDeliveryMethod,
shippingStreet,
shippingPackstationNumber,
shippingPostNumber,
shippingZip,
shippingCity,
shippingCountry,
shippingContactEmail,
shippingContactPhone,
} = body ?? {};
if (
typeof firstName !== "string" ||
!firstName ||
@@ -42,6 +67,33 @@ export async function PATCH(request: Request) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
}
// Same shape as the checkout's own shipping-address-override validation
// (api/checkout/route.ts) — required fields only apply when the toggle
// is actually on, since this whole block is optional otherwise.
if (hasDifferentShippingAddress) {
if (
typeof shippingFirstName !== "string" ||
!shippingFirstName ||
typeof shippingLastName !== "string" ||
!shippingLastName ||
(shippingDeliveryMethod !== "address" && shippingDeliveryMethod !== "packstation") ||
typeof shippingZip !== "string" ||
!shippingZip ||
typeof shippingCity !== "string" ||
!shippingCity ||
typeof shippingCountry !== "string" ||
!shippingCountry
) {
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder der Lieferadresse ausfüllen." }, { status: 400 });
}
if (shippingDeliveryMethod === "address" && !shippingStreet) {
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
}
if (shippingDeliveryMethod === "packstation" && (!shippingPackstationNumber || !shippingPostNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
}
}
const result = await updateCustomerProfile(session.token, session.customer.id, {
firstName,
lastName,
@@ -54,6 +106,19 @@ export async function PATCH(request: Request) {
country,
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
vatId: normalizedVatId,
hasDifferentShippingAddress: Boolean(hasDifferentShippingAddress),
shippingFirstName: hasDifferentShippingAddress ? shippingFirstName : undefined,
shippingLastName: hasDifferentShippingAddress ? shippingLastName : undefined,
shippingCompanyName: hasDifferentShippingAddress && shippingCompanyName ? shippingCompanyName : undefined,
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
shippingStreet: hasDifferentShippingAddress ? shippingStreet : undefined,
shippingPackstationNumber: hasDifferentShippingAddress ? shippingPackstationNumber : undefined,
shippingPostNumber: hasDifferentShippingAddress ? shippingPostNumber : undefined,
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
shippingContactEmail: hasDifferentShippingAddress && shippingContactEmail ? shippingContactEmail : undefined,
shippingContactPhone: hasDifferentShippingAddress && shippingContactPhone ? shippingContactPhone : undefined,
});
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+16 -1
View File
@@ -54,6 +54,9 @@ type CheckoutBody = {
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
shippingCompanyName?: string;
shippingContactEmail?: string;
shippingContactPhone?: string;
newsletterOptIn: boolean;
};
@@ -160,6 +163,7 @@ export async function POST(request: Request) {
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
sku: string | null;
}[] = [];
for (const line of body.cart) {
const product = productsBySlug.get(line.id);
@@ -168,7 +172,7 @@ export async function POST(request: Request) {
// requested variant that no longer exists on this product (removed,
// or never existed — a tampered request) fails the whole checkout
// rather than silently falling back to the base product/price.
let variant: { name: string; priceOverride: number | null } | null = null;
let variant: { name: string; priceOverride: number | null; sku: string | null } | null = null;
if (line.variant) {
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
@@ -195,6 +199,9 @@ export async function POST(request: Request) {
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
bundleContents: describeBundleContents(product),
variantName: variant?.name ?? null,
// Variant sku takes precedence over the product's own — same
// "variant overrides product" precedence unitPrice already uses.
sku: variant?.sku ?? product.sku ?? null,
});
}
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
@@ -345,11 +352,15 @@ export async function POST(request: Request) {
shippingZip: body.shippingZip,
shippingCity: body.shippingCity,
shippingCountry: body.shippingCountry,
shippingCompanyName: Boolean(body.hasDifferentShippingAddress) ? body.shippingCompanyName || undefined : undefined,
shippingContactEmail: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactEmail || undefined : undefined,
shippingContactPhone: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactPhone || undefined : undefined,
newsletterOptIn: Boolean(body.newsletterOptIn),
items,
subtotal: finalSubtotal,
shippingCost: finalShippingCost,
shippingMethodTitle: shippingMethod.title,
shippingMethod: shippingMethod.id,
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
// customer hasn't actually chosen an instrument yet at this point,
@@ -429,6 +440,7 @@ export async function POST(request: Request) {
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
shippingFirstName: body.shippingFirstName,
shippingLastName: body.shippingLastName,
shippingCompanyName: body.hasDifferentShippingAddress ? body.shippingCompanyName : undefined,
shippingDeliveryMethod: body.shippingDeliveryMethod,
shippingStreet: body.shippingStreet,
shippingPackstationNumber: body.shippingPackstationNumber,
@@ -436,6 +448,8 @@ export async function POST(request: Request) {
shippingZip: body.shippingZip,
shippingCity: body.shippingCity,
shippingCountry: body.shippingCountry,
shippingContactEmail: body.hasDifferentShippingAddress ? body.shippingContactEmail : undefined,
shippingContactPhone: body.hasDifferentShippingAddress ? body.shippingContactPhone : undefined,
paymentMethodTitle: paymentMethod.title,
items: items.map((i) => ({
productName: i.productName,
@@ -445,6 +459,7 @@ export async function POST(request: Request) {
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
variantName: i.variantName,
sku: i.sku,
})),
subtotal: finalSubtotal,
shippingCost: finalShippingCost,