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:
Marco
2026-07-23 17:53:21 +00:00
parent 2d88fb86a1
commit e48107470a
8 changed files with 119 additions and 1 deletions
@@ -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. */}