Document ShippingCountries, Brevo newsletter sync, and this session's fixes

New sections: Newsletter signup & Brevo sync, Destination countries
(Payload-configurable). Updated: VAT exemption (Schweiz/Österreich
hardcoding claim was stale now that destination countries are
Payload-configurable; documented the select-all removal and the
generalized refocus-on-invalid-blur behavior), Invoice PDFs (Netto row),
account order-detail (companyName/vatId now shown).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 21:09:42 +00:00
parent c60e936b7e
commit f035ccaacc
+106 -11
View File
@@ -365,6 +365,24 @@ shows both addresses too, relabeling the first one "Rechnungsadresse"
instead of "Lieferadresse" only once there's an actual second address to
distinguish it from.
### Destination countries (Payload-configurable)
Both country `<select>`s (billing, and the shipping-address override)
are fed by `getShippingCountries()` (`app/lib/payload.ts`) reading
Payload's `shipping-countries` collection (`name`, `plzDigits`,
`active`, `sortOrder`) — not a hardcoded array anymore. `plzDigits` also
drives PLZ's own `maxLength`/pattern validation (`validateZip()` in
`CheckoutContent.tsx` builds a `country → digit count` map from this
list), so an admin adding a country in Payload doesn't need a frontend
deploy to make it selectable, and the PLZ format check automatically
matches whatever digit count that country's row specifies. Seeded with
Deutschland (5 digits) and Österreich (4) — matching what this checkout
already offered before this became configurable. Adding a country here
(e.g. Schweiz) makes it immediately selectable at checkout; it does
**not** by itself add any customs/export-invoice handling or affect VAT
exemption eligibility (see "VAT exemption" below — that's still a
separate, deliberately-not-Payload-configurable legal decision).
### Product variants
A cart line's identity is `(id, variant)` together, not `id` alone —
@@ -601,6 +619,14 @@ inbox, not only in `/konto/bestellungen`.
order is VAT-exempt (see "VAT exemption" below), this annotation becomes
"Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead —
"enthält 0% MwSt.: 0,00 €" would be a meaningless thing to print.
- **Netto row (added 2026-07-23)**: a dedicated "Netto" row sits between
Gesamt and the "enthält X% MwSt." annotation on every invoice this shop
issues (original, Storno, Gutschrift alike) — businesses read this
directly for their own input-tax deduction instead of computing Gesamt
minus MwSt by hand. Shown unconditionally, including on VAT-exempt
orders (net and Gesamt happen to be the same figure there — a first
version skipped the row in that case, corrected same day since the ask
was for every invoice, not conditional on the tax rate).
- **Product thumbnails**: each item row shows a small product image —
resolved from the order-confirmation data's already-available
`imageUrl` for the checkout-time attachment, or via
@@ -724,14 +750,17 @@ download routes and the correction-invoice email.
### VAT exemption (innergemeinschaftliche Lieferung)
Built 2026-07-23. A cross-border EU B2B sale — this checkout's only
cross-border option is Österreich (Deutschland is domestic, Schweiz a
non-EU export with its own different exemption) — to a buyer whose VAT ID
a live lookup against the EU's public VIES service actually confirms is
registered gets zero-rated per §4 Nr. 1b UStG. Deliberately *not* based on
format-validity alone: an unverified VAT ID zero-rating an invoice is a
real compliance risk (if it later turns out unregistered, the seller
retroactively owes the VAT itself).
Built 2026-07-23. A cross-border EU B2B sale — Österreich is the
eligible destination (`isExemptionEligibleCountry()` in
`app/lib/vatExemption.ts`, hardcoded, deliberately **not** read from the
Payload-configurable `shipping-countries` list above — eligibility is a
legal decision, not a shipping-logistics one, so an admin adding a new
destination country can't accidentally also grant it a VAT exemption) —
to a buyer whose VAT ID a live lookup against the EU's public VIES
service actually confirms is registered gets zero-rated per §4 Nr. 1b
UStG. Deliberately *not* based on format-validity alone: an unverified
VAT ID zero-rating an invoice is a real compliance risk (if it later
turns out unregistered, the seller retroactively owes the VAT itself).
- **`app/lib/vies.ts`** — calls the European Commission's public VIES REST
API (`POST .../check-vat-number`, confirmed live 2026-07-23) directly,
@@ -767,14 +796,24 @@ retroactively owes the VAT itself).
closed on the exemption**: normal VAT applies, never a guessed exemption
(contrast the Payload backend's own `company-settings.vatId` VIES check,
which fails open, since that one only needs to catch an admin's
data-entry typo, not decide a tax rate).
data-entry typo, not decide a tax rate). On an unconfirmed VAT ID, the
field re-focuses so the customer's attention returns there — it no
longer also selects the whole existing value (dropped 2026-07-23; a
stray keystroke while just glancing at the error shouldn't wipe out
what was already typed). This same "re-focus the failing field"
behavior now applies generically to *any* checkout field that fails
its own blur validation, not just this one — see the bullet below.
- **Every other checkout field is now blur-validated too** (fixed
2026-07-23, alongside this feature) — inline red error text appears the
moment a field loses focus (required fields, email format, PLZ digit
count per country, Packstation/Postnummer digit count), not only when
the browser's native `pattern`/`required` validation kicks in at submit.
The native attributes stay in place as a fallback for any field somehow
never blurred (e.g. autofill).
never blurred (e.g. autofill). `setFieldError(name, message, refocusEl?)`
(`CheckoutContent.tsx`) takes an optional element to refocus whenever the
message is non-empty — every field's own `onBlur` passes `e.target`, so
a field that fails validation gets focus put right back on it
generically, not just the USt-IdNr. special case above.
- **Persistence**: `Orders.vatExempt`/`vatIdValidatedAt` (Payload backend)
record the outcome, decided once server-side, never editable in the
admin. `vatIdValidatedAt` is set for *any* VIES-confirmed VAT ID
@@ -831,6 +870,59 @@ updating as the admin edits `sellerName`/address/`taxRatePercent`/
current row — the page's initial (pre-postMessage) fetch is the same
live data `getCompanySettings()` always returns.
## Newsletter signup & Brevo sync
Built 2026-07-23. Four separate signup entry points across the site —
the shared `Newsletter` panel (`app/components/Newsletter.tsx`, reused on
Home and `/newsletter`), `NewsletterModal.tsx` (the Navbar's "Newsletter"
CTA), `/newsletter`'s own inline hero form
(`app/newsletter/components/WeeklyImpulsesHero.tsx`), and `/challenge`'s
`EmailCapture` (`app/challenge/components/EmailCapture.tsx`) — plus
checkout's existing `newsletterOptIn` checkbox, all sync to Brevo's
contact list. **Three of the four standalone forms were completely
non-functional before this** (static markup, no `onSubmit` at all —
discovered while wiring this up, not a regression) and the fourth
(`NewsletterModal`) plus checkout's checkbox captured data that just sat
unsynced.
- **`app/lib/brevo.ts`** — the only thing that talks to Brevo.
`upsertNewsletterContact(email, source)` calls Brevo's
`POST /v3/contacts` with `updateEnabled: true` (204 for both a new and
an existing contact — no special-casing needed) and a
`BREVO_LIST_ID`-scoped list membership. `source` (`"checkout"` |
`"newsletter-page"` | `"newsletter-modal"` | `"newsletter-hero"` |
`"challenge"`) is stored as the contact's `OPT_IN_SOURCE` attribute for
segmentation — that attribute has to already exist on the Brevo account
(`POST /v3/contacts/attributes/normal/OPT_IN_SOURCE`) or Brevo silently
drops it on every upsert (no error at all, just never stored) rather
than rejecting the request.
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
state + on-blur validation + refocus-on-invalid-submit behind all four
forms (same "state of the art, simple" input-quality bar as checkout's
own fields). Each form keeps its own markup/visual style (`Newsletter`'s
panel layout, `/challenge`'s hardcoded-hex-color palette, etc.) — only
the logic is shared, not a one-size-fits-all component.
- **`app/lib/email.ts`** — `isValidEmail()`/`validateEmailFormat()`,
the single plain-email-format check shared by every newsletter form
*and* checkout's own email field (previously duplicated between
`CheckoutContent.tsx` and the subscribe route).
- **`app/api/newsletter/subscribe/route.ts`** — validates
email-format + consent server-side too (never trusts the client alone),
then calls `upsertNewsletterContact()`.
- Checkout's sync (`app/api/checkout/route.ts`) is fire-and-forget
alongside the order-confirmation email — a failed marketing sync must
never fail checkout, and isn't worth a critical alert either (nothing
customer-facing depends on it).
- **This app never sends marketing/campaign mail itself** — only
transactional (order confirmation, password reset, status updates).
Whatever automation Brevo has configured on the list (a "Welcome Flow"
etc.) runs entirely on Brevo's own side once a contact lands there;
Brevo's Automation workflows aren't exposed via their public REST API
at all, so that piece can only be built/inspected in Brevo's own UI, not
from this codebase.
- Needs `BREVO_API_KEY`/`BREVO_LIST_ID` set in the deployment environment
— confirmed live end-to-end 2026-07-23.
## Orders & customer accounts
An account is required to buy — there is no guest checkout. Registration
@@ -869,7 +961,10 @@ this check was skipped or raced.
- **`/konto/bestellungen`** lists a customer's own orders (status shown as
a colored `OrderStatusBadge.tsx`, plus an "Abmelden" link —
`LogoutButton.tsx`); **`/konto/bestellungen/[orderNumber]`** shows one
order's full detail (items, address, totals, `status`). `status`
order's full detail (items, address, totals, `status`, plus
companyName/VAT-ID and a VAT-exemption note when the order has them —
added 2026-07-23, `CustomerOrderDetail` already carried these fields but
the page never rendered them). `status`
(`received``processing``shipped``delivered`, plus
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
Payload admin for the shipping states — no shipping-carrier API