Add optional Firma/USt-IdNr. fields to checkout and profile
B2B checkout fields, split out from the e-invoicing migration and picked back up now that it's shipped. Both fields are independently optional, format-validated (shared regex in lib/vatId.ts, mirrored server-side in api/checkout and api/account/profile), persisted in the checkout draft, and saved as a customer profile default that pre-fills future checkouts. Order/customer snapshot fields land in a companion Payload backend commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
<FormField label="Vorname" name="firstName" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required />
|
||||
<FormField label="Nachname" name="lastName" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required />
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="Firma (optional)"
|
||||
name="companyName"
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Muster GmbH"
|
||||
autoComplete="organization"
|
||||
/>
|
||||
<FormField
|
||||
label="USt-IdNr. (optional)"
|
||||
name="vatId"
|
||||
type="text"
|
||||
value={vatId}
|
||||
onChange={(e) => 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."
|
||||
/>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
|
||||
@@ -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 }) {
|
||||
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
|
||||
</div>
|
||||
|
||||
{/* 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). */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Firma (optional)" name="companyName" type="text" defaultValue={profile.companyName ?? ""} />
|
||||
<Field
|
||||
label="USt-IdNr. (optional)"
|
||||
name="vatId"
|
||||
type="text"
|
||||
defaultValue={profile.vatId ?? ""}
|
||||
placeholder="DE123456789"
|
||||
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."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<CustomerProfile
|
||||
zip: u.zip,
|
||||
city: u.city,
|
||||
country: u.country,
|
||||
companyName: u.companyName,
|
||||
vatId: u.vatId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,6 +267,8 @@ export async function updateCustomerProfile(
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
companyName?: string;
|
||||
vatId?: string;
|
||||
},
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
|
||||
|
||||
@@ -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<CreatedOrder
|
||||
customerFirstName: input.customerFirstName,
|
||||
customerLastName: input.customerLastName,
|
||||
customerEmail: input.customerEmail,
|
||||
companyName: input.companyName,
|
||||
vatId: input.vatId,
|
||||
deliveryMethod: input.deliveryMethod,
|
||||
street: input.street,
|
||||
packstationNumber: input.packstationNumber,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Mirrors the backend's own USt-IdNr. validation exactly (Orders.ts/
|
||||
// Customers.ts/CompanySettings.ts in the Payload repo) — kept as a plain
|
||||
// client+server-safe helper here since this repo's frontend needs the same
|
||||
// check twice (checkout's instant client-side pattern + api/checkout's own
|
||||
// server-side re-validation, same "never trust the client" reasoning as
|
||||
// every other checkout field).
|
||||
const VAT_ID_PATTERN = /^[A-Z]{2}[A-Z0-9]{2,12}$/;
|
||||
|
||||
export function normalizeVatId(value: string): string {
|
||||
return value.toUpperCase().trim();
|
||||
}
|
||||
|
||||
export function isValidVatId(value: string): boolean {
|
||||
return VAT_ID_PATTERN.test(value);
|
||||
}
|
||||
Reference in New Issue
Block a user