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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user