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,
@@ -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 }) {
<>
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
+2 -2
View File
@@ -75,7 +75,7 @@ export default async function BlogDetailPage({
<>
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
@@ -221,7 +221,7 @@ export default async function BlogDetailPage({
</div>
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{nextPost.category}</span>
<span>{nextPost.categories.join(", ")}</span>
<span></span>
<span>{nextPost.readTime} Min</span>
</div>
+2 -2
View File
@@ -65,7 +65,7 @@ export default async function BlogOverviewPage() {
</div>
<div className="flex flex-col justify-center gap-3 p-8 sm:p-12 min-w-0">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{featured.category}</span>
<span>{featured.categories.join(", ")}</span>
<span></span>
<span>{featured.readTime} Min</span>
<span></span>
@@ -106,7 +106,7 @@ export default async function BlogOverviewPage() {
</div>
<div className="flex flex-col gap-2 min-w-0 flex-1">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
<span></span>
+71 -13
View File
@@ -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
/>
</div>
<FormField
label="Firma (optional)"
type="text"
value={shippingCompanyName}
onChange={(e) => setShippingCompanyName(e.target.value)}
placeholder="Muster GmbH"
autoComplete="off"
wrapperClassName="w-full"
/>
<div className="w-full sm:w-[calc(50%-0.5rem)] flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full rounded-sm border border-border overflow-hidden">
@@ -1165,6 +1199,30 @@ export function CheckoutContent({
))}
</select>
</label>
{/* 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. */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="Kontakt-E-Mail (optional)"
type="email"
value={shippingContactEmail}
onChange={(e) => setShippingContactEmail(e.target.value)}
placeholder="empfang@beispiel.de"
autoComplete="off"
/>
<FormField
label="Telefonnummer (optional)"
type="tel"
value={shippingContactPhone}
onChange={(e) => setShippingContactPhone(e.target.value)}
placeholder="+49 30 123456"
autoComplete="off"
/>
</div>
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
</div>
)}
+2 -2
View File
@@ -47,7 +47,7 @@ export async function Blog() {
<div className="flex flex-col justify-between gap-4 py-4 px-4 sm:pr-4 sm:pl-4 sm:col-span-5 min-w-0">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{featured.category}</span>
<span>{featured.categories.join(", ")}</span>
<span></span>
<span>{featured.readTime} Min</span>
</div>
@@ -96,7 +96,7 @@ export async function Blog() {
<div className="flex flex-col justify-between gap-4 py-3 px-4 sm:col-span-3 min-w-0 text-text-primary">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{post.category}</span>
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
@@ -120,6 +120,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{order.hasDifferentShippingAddress && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Lieferadresse</p>
{order.shippingCompanyName && <p className="text-body-sm text-text-primary">{order.shippingCompanyName}</p>}
<p className="text-body-sm text-text-primary">
{order.shippingFirstName} {order.shippingLastName}
</p>
@@ -127,6 +128,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<p className="text-body-sm text-text-primary">
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
</p>
{(order.shippingContactEmail || order.shippingContactPhone) && (
<p className="text-body-sm text-text-muted">
{[order.shippingContactEmail, order.shippingContactPhone].filter(Boolean).join(" · ")}
</p>
)}
</div>
)}
@@ -145,6 +151,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</p>
{!order.kleinunternehmer && <p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>}
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
{item.sku && <p className="text-label text-text-muted">Art.-Nr. {item.sku}</p>}
{item.returnQuantity > 0 && (
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
)}
+129
View File
@@ -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<string | null>(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({
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
{/* 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. */}
<p className="font-semibold text-body-sm text-text-primary">Rechnungsadresse</p>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Vorname" name="firstName" type="text" defaultValue={profile.firstName} required />
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
@@ -164,6 +188,111 @@ export function ProfileForm({
</select>
</label>
<div className="h-px bg-border w-full" />
<label className="flex gap-3 items-start w-full cursor-pointer">
<input
type="checkbox"
checked={hasDifferentShippingAddress}
onChange={(e) => setHasDifferentShippingAddress(e.target.checked)}
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-body-sm text-text-primary">
Abweichende Lieferadresse hinterlegen wird beim Checkout vorgeschlagen, sobald dort "Abweichende Lieferadresse" aktiviert wird
</span>
</label>
{hasDifferentShippingAddress && (
<div className="flex flex-col gap-4 items-start w-full">
<p className="font-semibold text-body-sm text-text-primary">Lieferadresse</p>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Vorname" name="shippingFirstName" type="text" defaultValue={profile.shippingFirstName ?? ""} required />
<Field label="Nachname" name="shippingLastName" type="text" defaultValue={profile.shippingLastName ?? ""} required />
</div>
<Field
label="Firma (optional)"
name="shippingCompanyName"
type="text"
defaultValue={profile.shippingCompanyName ?? ""}
wrapperClassName="w-full"
/>
<div className="w-full flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
<button
type="button"
onClick={() => setShippingDeliveryMethod("address")}
aria-pressed={shippingDeliveryMethod === "address"}
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${shippingDeliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Lieferadresse
</button>
<button
type="button"
onClick={() => setShippingDeliveryMethod("packstation")}
aria-pressed={shippingDeliveryMethod === "packstation"}
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${shippingDeliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Packstation
</button>
</div>
</div>
{shippingDeliveryMethod === "address" ? (
<Field
label="Straße und Hausnummer"
name="shippingStreet"
type="text"
defaultValue={profile.shippingStreet ?? ""}
required
wrapperClassName="w-full"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field
label="Packstationnummer"
name="shippingPackstationNumber"
type="text"
defaultValue={profile.shippingPackstationNumber ?? ""}
required
/>
<Field label="Postnummer" name="shippingPostNumber" type="text" defaultValue={profile.shippingPostNumber ?? ""} required />
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="PLZ" name="shippingZip" type="text" defaultValue={profile.shippingZip ?? ""} required />
<Field label="Ort" name="shippingCity" type="text" defaultValue={profile.shippingCity ?? ""} required />
</div>
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Land</span>
<select name="shippingCountry" defaultValue={profile.shippingCountry ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
</label>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field
label="Kontakt-E-Mail (optional)"
name="shippingContactEmail"
type="email"
defaultValue={profile.shippingContactEmail ?? ""}
/>
<Field
label="Telefonnummer (optional)"
name="shippingContactPhone"
type="tel"
defaultValue={profile.shippingContactPhone ?? ""}
/>
</div>
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
</div>
)}
{error && <p className="text-label text-red-600">{error}</p>}
{success && <p className="text-label text-success">Gespeichert.</p>}
+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;
+2 -2
View File
@@ -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"
},