From e3352d7e32cef993af5bad8c885728b2fed86fb4 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 30 Jul 2026 08:41:39 +0000 Subject: [PATCH] Blog categories (hasMany), shipping-address contact fields, product SKU on invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/api/account/profile/route.ts | 67 ++++++++- app/api/checkout/route.ts | 17 ++- .../[slug]/components/LivePostContent.tsx | 4 +- app/blog/[slug]/page.tsx | 4 +- app/blog/page.tsx | 4 +- app/checkout/components/CheckoutContent.tsx | 84 ++++++++++-- app/components/Blog.tsx | 4 +- app/konto/bestellungen/[orderNumber]/page.tsx | 7 + app/konto/profil/components/ProfileForm.tsx | 129 ++++++++++++++++++ app/lib/__tests__/bundleContents.test.ts | 1 + app/lib/checkoutDraft.ts | 3 + app/lib/customerAuth.ts | 60 ++++++++ app/lib/emailTemplates.ts | 3 +- app/lib/orderEmail.ts | 7 + app/lib/orderServer.ts | 22 +++ app/lib/payload.ts | 16 +-- app/lib/productsServer.ts | 1 + package-lock.json | 4 +- 18 files changed, 403 insertions(+), 34 deletions(-) diff --git a/app/api/account/profile/route.ts b/app/api/account/profile/route.ts index d25de66..1b3e6d5 100644 --- a/app/api/account/profile/route.ts +++ b/app/api/account/profile/route.ts @@ -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 }); } diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index a2b4e19..088c5e4 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -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, diff --git a/app/blog/[slug]/components/LivePostContent.tsx b/app/blog/[slug]/components/LivePostContent.tsx index 135f1a2..25cc33f 100644 --- a/app/blog/[slug]/components/LivePostContent.tsx +++ b/app/blog/[slug]/components/LivePostContent.tsx @@ -9,7 +9,7 @@ import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../. const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de"; -// Live-previewable subset of the blog detail page: title/category/readTime/ +// Live-previewable subset of the blog detail page: title/categories/readTime/ // excerpt/byline, the thumbnail, and the RichText body — the fields an // editor actually watches update while typing. The author bio card, // "Weiterlesen" card, and Footer stay static in page.tsx: they either @@ -27,7 +27,7 @@ export function LivePostContent({ initialPost }: { initialPost: PostDetail }) { <>
- {post.category} + {post.categories.join(", ")} {post.readTime} Min
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index d4a0b23..2e6184d 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -75,7 +75,7 @@ export default async function BlogDetailPage({ <>
- {post.category} + {post.categories.join(", ")} {post.readTime} Min
@@ -221,7 +221,7 @@ export default async function BlogDetailPage({
- {nextPost.category} + {nextPost.categories.join(", ")} {nextPost.readTime} Min
diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 3908167..402797c 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -65,7 +65,7 @@ export default async function BlogOverviewPage() {
- {featured.category} + {featured.categories.join(", ")} {featured.readTime} Min @@ -106,7 +106,7 @@ export default async function BlogOverviewPage() {
- {post.category} + {post.categories.join(", ")} {post.readTime} Min diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index bd022b8..af82bb4 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -243,19 +243,32 @@ export function CheckoutContent({ const [zip, setZip] = useState(savedProfile?.zip ?? ""); const [city, setCity] = useState(savedProfile?.city ?? ""); const [country, setCountry] = useState(savedProfile?.country ?? "Deutschland"); - // Optional package destination distinct from the billing address above — - // no savedProfile fallback (a customer's saved profile has only ever had - // one address), just an empty draft-only section. - const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(false); - const [shippingFirstName, setShippingFirstName] = useState(""); - const [shippingLastName, setShippingLastName] = useState(""); - const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">("address"); - const [shippingStreet, setShippingStreet] = useState(""); - const [shippingPackstationNumber, setShippingPackstationNumber] = useState(""); - const [shippingPostNumber, setShippingPostNumber] = useState(""); - const [shippingZip, setShippingZip] = useState(""); - const [shippingCity, setShippingCity] = useState(""); - const [shippingCountry, setShippingCountry] = useState("Deutschland"); + // Optional package destination distinct from the billing address above. + // Seeded from savedProfile's own "Lieferadresse" tab (Customers.ts), + // same precedence as Card 1's billing fields above: draft (if any) + // overwrites this in the hydration effect below, savedProfile is only + // the pre-hydration/SSR-safe fallback. Unlike Card 1, savedProfile *can* + // supply these now (see Customers.ts's "Lieferadresse" tab) — this used + // to always start empty since the saved profile had only one address. + const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(savedProfile?.hasDifferentShippingAddress ?? false); + const [shippingFirstName, setShippingFirstName] = useState(savedProfile?.shippingFirstName ?? ""); + const [shippingLastName, setShippingLastName] = useState(savedProfile?.shippingLastName ?? ""); + const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">( + savedProfile?.shippingDeliveryMethod ?? "address", + ); + const [shippingStreet, setShippingStreet] = useState(savedProfile?.shippingStreet ?? ""); + const [shippingPackstationNumber, setShippingPackstationNumber] = useState(savedProfile?.shippingPackstationNumber ?? ""); + const [shippingPostNumber, setShippingPostNumber] = useState(savedProfile?.shippingPostNumber ?? ""); + const [shippingZip, setShippingZip] = useState(savedProfile?.shippingZip ?? ""); + const [shippingCity, setShippingCity] = useState(savedProfile?.shippingCity ?? ""); + const [shippingCountry, setShippingCountry] = useState(savedProfile?.shippingCountry ?? "Deutschland"); + // Optional, mirrors companyName above — no shipping-side vatId though, + // a VAT ID is a billing/invoice concept, not a shipping one. Contact + // email/phone have no billing-side equivalent at all: they're handed to + // the shipping carrier, not used for any customer communication. + const [shippingCompanyName, setShippingCompanyName] = useState(savedProfile?.shippingCompanyName ?? ""); + const [shippingContactEmail, setShippingContactEmail] = useState(savedProfile?.shippingContactEmail ?? ""); + const [shippingContactPhone, setShippingContactPhone] = useState(savedProfile?.shippingContactPhone ?? ""); const [newsletterOptIn, setNewsletterOptIn] = useState(false); // Live VIES status for the USt-IdNr. field — only meaningful once the // goods' destination (shipping override country when set, billing @@ -301,6 +314,9 @@ export function CheckoutContent({ if (draft.shippingZip) setShippingZip(draft.shippingZip); if (draft.shippingCity) setShippingCity(draft.shippingCity); if (draft.shippingCountry) setShippingCountry(draft.shippingCountry); + if (draft.shippingCompanyName) setShippingCompanyName(draft.shippingCompanyName); + if (draft.shippingContactEmail) setShippingContactEmail(draft.shippingContactEmail); + if (draft.shippingContactPhone) setShippingContactPhone(draft.shippingContactPhone); if (typeof draft.newsletterOptIn === "boolean") setNewsletterOptIn(draft.newsletterOptIn); if (draft.shippingMethodId != null) setShippingMethodId(draft.shippingMethodId); if (draft.paymentMethodId != null) setPaymentMethodId(draft.paymentMethodId); @@ -331,6 +347,9 @@ export function CheckoutContent({ shippingZip, shippingCity, shippingCountry, + shippingCompanyName, + shippingContactEmail, + shippingContactPhone, newsletterOptIn, shippingMethodId, paymentMethodId, @@ -356,6 +375,9 @@ export function CheckoutContent({ shippingZip, shippingCity, shippingCountry, + shippingCompanyName, + shippingContactEmail, + shippingContactPhone, newsletterOptIn, shippingMethodId, paymentMethodId, @@ -558,6 +580,9 @@ export function CheckoutContent({ shippingZip: hasDifferentShippingAddress ? shippingZip : undefined, shippingCity: hasDifferentShippingAddress ? shippingCity : undefined, shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined, + shippingCompanyName: hasDifferentShippingAddress ? shippingCompanyName || undefined : undefined, + shippingContactEmail: hasDifferentShippingAddress ? shippingContactEmail || undefined : undefined, + shippingContactPhone: hasDifferentShippingAddress ? shippingContactPhone || undefined : undefined, newsletterOptIn, }; @@ -1048,6 +1073,15 @@ export function CheckoutContent({ required />
+ setShippingCompanyName(e.target.value)} + placeholder="Muster GmbH" + autoComplete="off" + wrapperClassName="w-full" + />
Lieferart
@@ -1165,6 +1199,30 @@ export function CheckoutContent({ ))} + {/* Both optional and independent — handed to the shipping + carrier, not used for any customer communication (that + stays the account email above). Useful when the + recipient at this address isn't reachable via the + account holder's own email/phone. */} +
+ setShippingContactEmail(e.target.value)} + placeholder="empfang@beispiel.de" + autoComplete="off" + /> + setShippingContactPhone(e.target.value)} + placeholder="+49 30 123456" + autoComplete="off" + /> +
+

Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.

)} diff --git a/app/components/Blog.tsx b/app/components/Blog.tsx index 99bff0d..81e41c2 100644 --- a/app/components/Blog.tsx +++ b/app/components/Blog.tsx @@ -47,7 +47,7 @@ export async function Blog() {
- {featured.category} + {featured.categories.join(", ")} {featured.readTime} Min
@@ -96,7 +96,7 @@ export async function Blog() {
- {post.category} + {post.categories.join(", ")} {post.readTime} Min
diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx index d6cf50b..67238b3 100644 --- a/app/konto/bestellungen/[orderNumber]/page.tsx +++ b/app/konto/bestellungen/[orderNumber]/page.tsx @@ -120,6 +120,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr {order.hasDifferentShippingAddress && (

Lieferadresse

+ {order.shippingCompanyName &&

{order.shippingCompanyName}

}

{order.shippingFirstName} {order.shippingLastName}

@@ -127,6 +128,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr

{order.shippingZip} {order.shippingCity}, {order.shippingCountry}

+ {(order.shippingContactEmail || order.shippingContactPhone) && ( +

+ {[order.shippingContactEmail, order.shippingContactPhone].filter(Boolean).join(" · ")} +

+ )}
)} @@ -145,6 +151,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr

{!order.kleinunternehmer &&

inkl. {item.taxRatePercent}% MwSt.

} {item.bundleContents &&

{item.bundleContents}

} + {item.sku &&

Art.-Nr. {item.sku}

} {item.returnQuantity > 0 && (

davon {item.returnQuantity} zurückgesendet

)} diff --git a/app/konto/profil/components/ProfileForm.tsx b/app/konto/profil/components/ProfileForm.tsx index d15d61f..5e3165a 100644 --- a/app/konto/profil/components/ProfileForm.tsx +++ b/app/konto/profil/components/ProfileForm.tsx @@ -35,6 +35,10 @@ export function ProfileForm({ }) { const router = useRouter(); const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address"); + const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(profile.hasDifferentShippingAddress); + const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">( + profile.shippingDeliveryMethod ?? "address", + ); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [saving, setSaving] = useState(false); @@ -58,6 +62,21 @@ export function ProfileForm({ country: String(form.get("country") ?? ""), companyName: String(form.get("companyName") ?? "") || undefined, vatId: String(form.get("vatId") ?? "") || undefined, + hasDifferentShippingAddress, + shippingFirstName: hasDifferentShippingAddress ? String(form.get("shippingFirstName") ?? "") : undefined, + shippingLastName: hasDifferentShippingAddress ? String(form.get("shippingLastName") ?? "") : undefined, + shippingCompanyName: hasDifferentShippingAddress ? String(form.get("shippingCompanyName") ?? "") || undefined : undefined, + shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined, + shippingStreet: hasDifferentShippingAddress ? String(form.get("shippingStreet") ?? "") || undefined : undefined, + shippingPackstationNumber: hasDifferentShippingAddress + ? String(form.get("shippingPackstationNumber") ?? "") || undefined + : undefined, + shippingPostNumber: hasDifferentShippingAddress ? String(form.get("shippingPostNumber") ?? "") || undefined : undefined, + shippingZip: hasDifferentShippingAddress ? String(form.get("shippingZip") ?? "") : undefined, + shippingCity: hasDifferentShippingAddress ? String(form.get("shippingCity") ?? "") : undefined, + shippingCountry: hasDifferentShippingAddress ? String(form.get("shippingCountry") ?? "") : undefined, + shippingContactEmail: hasDifferentShippingAddress ? String(form.get("shippingContactEmail") ?? "") || undefined : undefined, + shippingContactPhone: hasDifferentShippingAddress ? String(form.get("shippingContactPhone") ?? "") || undefined : undefined, }; try { @@ -91,6 +110,11 @@ export function ProfileForm({

+ {/* Explicit heading, matching /checkout's own "1. Rechnungsadresse" + card title — without it, this section and the "Lieferadresse" + one further down read as one undifferentiated form instead of + two distinct addresses. */} +

Rechnungsadresse

@@ -164,6 +188,111 @@ export function ProfileForm({ +
+ + + + {hasDifferentShippingAddress && ( +
+

Lieferadresse

+
+ + +
+ + +
+ Lieferart +
+ + +
+
+ + {shippingDeliveryMethod === "address" ? ( + + ) : ( +
+ + +
+ )} + +
+ + +
+ + + +
+ + +
+

Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.

+
+ )} + {error &&

{error}

} {success &&

Gespeichert.

} diff --git a/app/lib/__tests__/bundleContents.test.ts b/app/lib/__tests__/bundleContents.test.ts index 11c0073..0b95bde 100644 --- a/app/lib/__tests__/bundleContents.test.ts +++ b/app/lib/__tests__/bundleContents.test.ts @@ -6,6 +6,7 @@ const product = (overrides: Partial = {}): RawProduct => ({ id: 1, slug: "starter-set", name: "Starter-Set", + sku: null, price: 29.9, active: true, image: null, diff --git a/app/lib/checkoutDraft.ts b/app/lib/checkoutDraft.ts index bc13da7..6969a3b 100644 --- a/app/lib/checkoutDraft.ts +++ b/app/lib/checkoutDraft.ts @@ -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; diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index 5290566..8c5be61 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -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 { 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 diff --git a/app/lib/emailTemplates.ts b/app/lib/emailTemplates.ts index 624c2ef..4282469 100644 --- a/app/lib/emailTemplates.ts +++ b/app/lib/emailTemplates.ts @@ -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 : `
` } - ${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} × ${item.quantity}${item.bundleContents ? `
${escapeHtml(item.bundleContents)}` : ""} + ${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} × ${item.quantity}${item.bundleContents ? `
${escapeHtml(item.bundleContents)}` : ""}${item.sku ? `
Art.-Nr. ${escapeHtml(item.sku)}` : ""} ${formatPrice(item.quantity * item.unitPrice)} `, ) diff --git a/app/lib/orderEmail.ts b/app/lib/orderEmail.ts index 8e6044a..2be5f4b 100644 --- a/app/lib/orderEmail.ts +++ b/app/lib/orderEmail.ts @@ -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, diff --git a/app/lib/orderServer.ts b/app/lib/orderServer.ts index d0313f8..7459036 100644 --- a/app/lib/orderServer.ts +++ b/app/lib/orderServer.ts @@ -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 ({ product: i.productId, @@ -149,10 +169,12 @@ export async function createOrder(input: CreateOrderInput): Promise { 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: diff --git a/app/lib/productsServer.ts b/app/lib/productsServer.ts index cc9a8e8..cc38db4 100644 --- a/app/lib/productsServer.ts +++ b/app/lib/productsServer.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index 1870679..17e31cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -455,8 +455,8 @@ } }, "node_modules/@einfach-produktiv/invoicing": { - "version": "0.2.6", - "resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#d6e83db5cf6343e65e2fa25f9ead49fce9a5a308", + "version": "0.2.7", + "resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#e66007cc4aacccf3b1f18892f3ae9a5e6adf685e", "dependencies": { "@e-invoice-eu/core": "^3.1.1" },