diff --git a/app/api/account/profile/route.ts b/app/api/account/profile/route.ts index 1ac0e4d..d25de66 100644 --- a/app/api/account/profile/route.ts +++ b/app/api/account/profile/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth"; +import { normalizeVatId, isValidVatId } from "../../../lib/vatId"; export async function GET() { const session = await getSessionCustomer(); @@ -12,7 +13,7 @@ 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 } = body ?? {}; + const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {}; if ( typeof firstName !== "string" || !firstName || @@ -34,6 +35,12 @@ export async function PATCH(request: Request) { if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) { return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 }); } + // Both independently optional (see Customers.ts's own comment) — only + // format-checked when actually provided, same as the backend field itself. + const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined; + if (normalizedVatId && !isValidVatId(normalizedVatId)) { + return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 }); + } const result = await updateCustomerProfile(session.token, session.customer.id, { firstName, @@ -45,6 +52,8 @@ export async function PATCH(request: Request) { zip, city, country, + companyName: typeof companyName === "string" && companyName ? companyName : undefined, + vatId: normalizedVatId, }); return NextResponse.json(result, { status: result.ok ? 200 : 400 }); } diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 0d8d478..f03708d 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -8,6 +8,7 @@ import { fetchProductsBySlug } from "../../lib/productsServer"; import { describeBundleContents } from "../../lib/bundleContents"; import { sendCriticalAlert } from "../../lib/alertAdmin"; import { sendOrderConfirmationEmail } from "../../lib/orderEmail"; +import { normalizeVatId, isValidVatId } from "../../lib/vatId"; // Plain float arithmetic on money (quantity × unitPrice summed across // lines, a percent discount, subtracting/adding those together) drifts @@ -30,6 +31,8 @@ type CheckoutBody = { lastName: string; email: string; password?: string; + companyName?: string; + vatId?: string; deliveryMethod: "address" | "packstation"; street?: string; packstationNumber?: string; @@ -85,6 +88,15 @@ export async function POST(request: Request) { if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) { return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 }); } + // Optional — only format-checked when actually provided, same "never + // trust the client" reasoning as every other checkout field re-validated + // here. Normalized the same way Orders.ts's own field does (uppercase + + // trim), so the snapshot on the order matches what would've been + // accepted directly through the Payload admin. + const normalizedVatId = body.vatId ? normalizeVatId(body.vatId) : undefined; + if (normalizedVatId && !isValidVatId(normalizedVatId)) { + return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 }); + } if (body.hasDifferentShippingAddress) { if (!body.shippingFirstName || !body.shippingLastName || !body.shippingZip || !body.shippingCity || !body.shippingCountry) { return NextResponse.json({ ok: false, reason: "Bitte alle Felder der Lieferadresse ausfüllen." }, { status: 400 }); @@ -201,6 +213,8 @@ export async function POST(request: Request) { customerFirstName: body.firstName, customerLastName: body.lastName, customerEmail: body.email, + companyName: body.companyName || undefined, + vatId: normalizedVatId, deliveryMethod: body.deliveryMethod, street: body.street, packstationNumber: body.packstationNumber, diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index f7f7a0f..04b1663 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -102,6 +102,13 @@ export function CheckoutContent({ const [firstName, setFirstName] = useState(savedProfile?.firstName ?? ""); const [lastName, setLastName] = useState(savedProfile?.lastName ?? ""); const [email, setEmail] = useState(savedProfile?.email ?? customerEmail ?? ""); + // Optional B2B fields — sit right next to Rechnungsadresse (not a + // separately gated "order as a business" toggle) since each is + // independently optional (see Customers.ts/Orders.ts's own comment on + // why neither implies the other). Prefilled from the saved profile, same + // as every other Card 1 field. + const [companyName, setCompanyName] = useState(savedProfile?.companyName ?? ""); + const [vatId, setVatId] = useState(savedProfile?.vatId ?? ""); // Always a plain street address — a Packstation isn't a valid Rechnungs- // adresse (an invoice needs a real postal address). Packstation is only // ever offered on the separate, optional shipping-address override below. @@ -143,6 +150,8 @@ export function CheckoutContent({ if (draft.firstName) setFirstName(draft.firstName); if (draft.lastName) setLastName(draft.lastName); if (draft.email) setEmail(draft.email); + if (draft.companyName) setCompanyName(draft.companyName); + if (draft.vatId) setVatId(draft.vatId); if (draft.street) setStreet(draft.street); if (draft.zip) setZip(draft.zip); if (draft.city) setCity(draft.city); @@ -171,6 +180,8 @@ export function CheckoutContent({ firstName, lastName, email, + companyName, + vatId, street, zip, city, @@ -194,6 +205,8 @@ export function CheckoutContent({ firstName, lastName, email, + companyName, + vatId, street, zip, city, @@ -317,6 +330,8 @@ export function CheckoutContent({ firstName, lastName, email, + companyName: companyName || undefined, + vatId: vatId || undefined, // Deliberately still read from FormData, not state — password is the // one address-card field that stays uncontrolled/unpersisted (see // lib/checkoutDraft.ts's own comment on why). @@ -516,6 +531,32 @@ export function CheckoutContent({ setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required /> setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required /> + {/* Optional B2B fields — both independently optional (see + Orders.ts's own comment: a sole proprietor might give a VAT + ID with no separate "company name", and vice versa), so + neither is required just because the other is filled in. */} +
+ setCompanyName(e.target.value)} + placeholder="Muster GmbH" + autoComplete="organization" + /> + setVatId(e.target.value)} + placeholder="DE123456789" + autoComplete="off" + pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}" + title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789." + /> +
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's actual rendered width in the 2-col row above (each half of a gap-4 flex row), instead of stretching full-width. */} diff --git a/app/konto/profil/components/ProfileForm.tsx b/app/konto/profil/components/ProfileForm.tsx index 8b51d18..637e15d 100644 --- a/app/konto/profil/components/ProfileForm.tsx +++ b/app/konto/profil/components/ProfileForm.tsx @@ -45,6 +45,8 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) { zip: String(form.get("zip") ?? ""), city: String(form.get("city") ?? ""), country: String(form.get("country") ?? ""), + companyName: String(form.get("companyName") ?? "") || undefined, + vatId: String(form.get("vatId") ?? "") || undefined, }; try { @@ -83,6 +85,22 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) { + {/* Optional B2B fields — prefills /checkout's own Firma/USt-IdNr. + fields, same "profile default, order keeps its own snapshot" + split as the address fields below (see Customers.ts). */} +
+ + +
+
Lieferart
diff --git a/app/lib/checkoutDraft.ts b/app/lib/checkoutDraft.ts index 0b76bad..bc13da7 100644 --- a/app/lib/checkoutDraft.ts +++ b/app/lib/checkoutDraft.ts @@ -14,6 +14,11 @@ export type CheckoutDraft = { firstName: string; lastName: string; email: string; + // Optional B2B fields — see CheckoutContent.tsx's own comment on why + // they sit here (right next to the Rechnungsadresse fields, not a + // separate persisted concept). + companyName: string; + vatId: string; // Rechnungsadresse is always a plain street address now — no // deliveryMethod/packstationNumber/postNumber here, only on the // shipping* override fields below (see CheckoutContent.tsx). diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index d02ab1b..7708215 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -199,6 +199,10 @@ export type CustomerAddress = { zip: string | null; city: string | null; country: string | null; + // Optional B2B profile default — see Customers.ts's own comment. Prefills + // /checkout's Firma/USt-IdNr. fields for a returning customer. + companyName: string | null; + vatId: string | null; }; export type CustomerProfile = CustomerSummary & CustomerAddress; @@ -217,6 +221,8 @@ type PayloadCustomerMe = { zip: string | null; city: string | null; country: string | null; + companyName: string | null; + vatId: string | null; cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null; }; @@ -243,6 +249,8 @@ export async function getCustomerProfile(token: string): Promise { const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, { diff --git a/app/lib/orderServer.ts b/app/lib/orderServer.ts index 42259f9..1735e90 100644 --- a/app/lib/orderServer.ts +++ b/app/lib/orderServer.ts @@ -34,6 +34,10 @@ export type CreateOrderInput = { customerFirstName: string; customerLastName: string; customerEmail: string; + // Optional B2B snapshot fields — see Orders.ts's own comment on why both + // are independently optional. + companyName?: string; + vatId?: string; deliveryMethod: "address" | "packstation"; street?: string; packstationNumber?: string; @@ -87,6 +91,8 @@ export async function createOrder(input: CreateOrderInput): Promise