Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff6118a778 | |||
| 3328f30a06 | |||
| df4bd700e6 | |||
| bab2c916be | |||
| 4e22942031 | |||
| 740b791e5e | |||
| bb3f94d39e | |||
| d48a00973d | |||
| 7b4b54a9ac | |||
| 797d9d42fe | |||
| b1b1aa2037 | |||
| 4f95a347dc | |||
| 268f2e841d | |||
| 8d4f167374 | |||
| 6a6d50abdb | |||
| bca29ab7a3 | |||
| 3da8b75395 | |||
| f20a02dfa2 | |||
| 55de9b3e29 | |||
| a3fb864f7d | |||
| 9f92f7324a | |||
| 9bfd0affd0 | |||
| f0aec851d3 | |||
| 36bfa3bd84 | |||
| ba830947d2 | |||
| 89dd11bf77 | |||
| 14b1d5685c | |||
| 19f6559c29 | |||
| 0c884a73ec | |||
| f035ccaacc | |||
| c60e936b7e | |||
| 789a818c6b | |||
| 6a4539bf9b | |||
| 0c849c9525 | |||
| 47d03dd61b | |||
| 50db2fcb4b | |||
| 0c9050cc8a | |||
| 80b82e0117 | |||
| 4d2e78dd2a | |||
| ba4d7b443f | |||
| 6802636d1d |
@@ -50,8 +50,15 @@ header). `SMTP_USER`/`SMTP_PASSWORD`
|
||||
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
|
||||
alerts and resend-verification emails; **does not** need to match anything
|
||||
on the Payload side — this app's SMTP connection is deliberately
|
||||
independent, see the "Monitoring & alerting" section). Set in Coolify's
|
||||
app settings for production, not in a committed `.env`.
|
||||
independent, see the "Monitoring & alerting" section). `STRIPE_SECRET_KEY`,
|
||||
`STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`,
|
||||
`PAYMENT_WEBHOOK_SECRET`, `PAYMENT_TEST_MODE` — see "Payment processing
|
||||
(Stripe)" below. `BREVO_API_KEY`, `BREVO_LIST_ID`,
|
||||
`BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — required for
|
||||
newsletter signups to trigger a confirmation email at all),
|
||||
`BREVO_DOI_REDIRECT_URL` (optional, defaults to `/newsletter-confirmed`) — see
|
||||
"Newsletter signup" below. Set in Coolify's app settings for production,
|
||||
not in a committed `.env`.
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -246,6 +253,19 @@ check against Payload's public API, unlike most content on this site.
|
||||
`localStorage` under `ep_cart`, keyed by each product's `slug`. Still the
|
||||
source of truth while browsing — the server-side mirror (see below) only
|
||||
exists to carry a logged-in customer's cart across devices/browsers.
|
||||
- **Add-to-cart is capped at actual remaining stock (fixed 2026-07-23)** —
|
||||
previously only checked at checkout, so a shopper could add more of a
|
||||
product than was actually in stock and only find out at the last step.
|
||||
`Product`/its variants now carry a real `maxQty` (`app/lib/payload.ts`'s
|
||||
`mapPayloadProduct()` — `null` when unlimited, i.e. backorder allowed or
|
||||
inventory untracked; a deliberate, narrow exception to that function's
|
||||
own "the public API has no reason to leak exact stock counts" comment,
|
||||
since the add-to-cart controls genuinely need it). `AddToCartButton`/
|
||||
`AddToCartInlineButton` disable (and show "Maximale Menge im Warenkorb")
|
||||
once the cart already holds that many; `/cart`'s quantity `<select>`
|
||||
caps its option range the same way instead of always offering a flat 1–9.
|
||||
`api/checkout/route.ts`'s own stock re-check stays as the authoritative
|
||||
server-side guard regardless.
|
||||
- **`app/cart/components/RelatedProducts.tsx`** only ever suggests products
|
||||
not already in the cart — it stopped falling back to re-suggesting an
|
||||
already-in-cart product just to pad the grid out to 3 cards, so with a
|
||||
@@ -268,7 +288,8 @@ check against Payload's public API, unlike most content on this site.
|
||||
its `orderNumber`/`orderDateIso` now come back from that Payload create
|
||||
call, not generated client-side.
|
||||
- **Order confirmation email** is sent from `/api/checkout/route.ts`
|
||||
right after a successful `createOrder()` — fire-and-forget
|
||||
right after a successful `createOrder()` for Überweisung orders only —
|
||||
fire-and-forget
|
||||
(`app/lib/orderEmail.ts`'s `sendOrderConfirmationEmail()`), never blocks
|
||||
or fails the checkout response itself; a send failure alerts admin
|
||||
instead (`sendCriticalAlert`, lower severity than the "order not
|
||||
@@ -277,10 +298,114 @@ check against Payload's public API, unlike most content on this site.
|
||||
`email-templates` collection — see "Email templates & Live Preview"
|
||||
below for how that's edited/previewed. As of the invoice PDF feature
|
||||
(see below), this same send also carries the order's invoice PDF as an
|
||||
attachment.
|
||||
- Still not built: real payment processing — the checkout button is
|
||||
labelled "zahlungspflichtig" but nothing actually captures a payment
|
||||
yet. See `project_backend_checkout_plan` in the assistant's own memory.
|
||||
attachment. Kreditkarte/PayPal orders defer this until payment is
|
||||
confirmed — see "Payment processing (Stripe)" below.
|
||||
|
||||
### Payment processing (Stripe)
|
||||
|
||||
Real payment capture for Kreditkarte/PayPal, via Stripe's Payment Element
|
||||
(one integration covers both — see the approved plan this was built from,
|
||||
`spicy-leaping-pizza.md`, for the full design rationale). Überweisung stays
|
||||
exactly as before: no gateway involved, order goes straight to `received`.
|
||||
|
||||
- **`payment-methods`'s `provider` field** (Payload, admin-only) drives the
|
||||
branch — `'manual'` (Überweisung) or `'stripe'` (Kreditkarte/PayPal).
|
||||
`app/lib/payload.ts`'s `getPaymentMethods()` exposes it; the checkout
|
||||
route re-resolves it server-side, never trusts a client-submitted value.
|
||||
- **Checkout UI collapses Kreditkarte + PayPal into one "Online-Zahlung"
|
||||
option** (`groupPaymentMethodsForCheckout()` in `app/lib/payload.ts`,
|
||||
used by `CheckoutContent.tsx`). Both admin rows still exist and both
|
||||
still need `provider: 'stripe'` — this is a display-layer grouping, not
|
||||
a data change. Reasoning: the PaymentIntent is created with
|
||||
`automatic_payment_methods: { enabled: true }` (Stripe's own recommended
|
||||
Payment Element pattern — Stripe itself decides which eligible method to
|
||||
show), so pre-selecting "Kreditkarte" vs. "PayPal" before that never
|
||||
actually restricted anything; it was redundant friction, not a real
|
||||
choice. The combined option shows a hint text ("die genaue Zahlungsart
|
||||
wählst du im nächsten Schritt") so the consolidation reads as intentional,
|
||||
not a missing option. Überweisung stays a separate, real option.
|
||||
- **`paymentMethodTitle` is snapshotted as a neutral `"Online-Zahlung"`**
|
||||
at order-creation time for the `stripe` branch (the customer hasn't
|
||||
picked an instrument yet at that point) and **refined to the real one**
|
||||
(`"Kreditkarte"`/`"PayPal"`) once Stripe reports it —
|
||||
`resolveStripePaymentMethodLabel()` in `stripeProvider.ts` reads the
|
||||
confirmed PaymentIntent's `payment_method.type` in the webhook route and
|
||||
passes it to `confirm-payment` as an optional field. Best-effort: an
|
||||
unresolved label just leaves the neutral title in place. The
|
||||
`/checkout/verarbeitung` polling page also patches this into the
|
||||
provisional `sessionStorage` snapshot before promoting it, so
|
||||
`/bestellbestaetigung` shows the real instrument too, not the neutral
|
||||
placeholder.
|
||||
- **`app/api/checkout/route.ts`, `provider === 'stripe'` branch**: creates
|
||||
a Stripe PaymentIntent *before* the order (`app/lib/payments/
|
||||
stripeProvider.ts`) — its id is known immediately and gets persisted as
|
||||
the order's own `providerReference` field at creation time, so the
|
||||
backend's abandonment-cleanup job can reconcile with Stripe later even
|
||||
if nothing else about this flow ever completes. The order is created
|
||||
with `status: 'pending_payment'`, `paymentStatus: 'pending'` — no
|
||||
invoice number yet, no confirmation email yet (both deferred to the
|
||||
webhook-driven confirm-payment step, on the backend). Right after, a
|
||||
best-effort (awaited, non-fatal) call attaches `{orderId, orderNumber}`
|
||||
as PaymentIntent metadata (`attachOrderMetadata`) — this is what lets
|
||||
the webhook resolve an incoming Stripe event back to a specific Payload
|
||||
order.
|
||||
- **`app/checkout/components/PaymentStep.tsx`** renders in place of the
|
||||
address form once `/api/checkout` returns `requiresPayment: true` —
|
||||
Stripe's `PaymentElement` (real mode) or a "Testzahlung erfolgreich /
|
||||
fehlgeschlagen" button pair (test mode, see below). A card confirms
|
||||
in-place; PayPal (and 3-D-Secure challenges) redirect out and back via
|
||||
`return_url=/checkout/verarbeitung?orderNumber=...`.
|
||||
- **`app/api/webhooks/stripe/route.ts`** — the real inbound webhook.
|
||||
Verifies `stripe-signature` against `STRIPE_WEBHOOK_SECRET`, reads the
|
||||
**raw** body (never `.json()` — the signature is computed over the exact
|
||||
bytes), handles `payment_intent.succeeded`/`.payment_failed`, and calls
|
||||
the backend's `POST /api/orders/:id/confirm-payment` (guarded by
|
||||
`PAYMENT_WEBHOOK_SECRET`, a secret distinct from `ORDER_SERVICE_SECRET`
|
||||
on purpose — least privilege, it can only hit this one action) with
|
||||
`{paymentStatus, providerReference, paidAt}`. Returns a non-2xx status on
|
||||
any internal failure so Stripe's own retry schedule (~3 days) provides
|
||||
resilience for free, rather than this app building its own retry queue.
|
||||
That backend endpoint flips the order to `received`, assigns the
|
||||
(until-then-deferred) invoice number, and queues the internal admin
|
||||
new-order notification — see the backend repo's own README for that
|
||||
half. It has no SMTP sender of its own, though: it returns a full order
|
||||
snapshot in its response instead, and **this webhook route is what
|
||||
actually sends the confirmation email + invoice PDF**
|
||||
(`app/lib/payments/confirmPaymentEmail.ts`, only when the response isn't
|
||||
`alreadyProcessed: true` — a repeat webhook delivery must never resend
|
||||
it), mirroring exactly what the checkout route already does inline for
|
||||
a manual/Überweisung order.
|
||||
- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
|
||||
`return_url` target. Neither a client-side `confirmPayment()` success nor
|
||||
landing back from a PayPal redirect is trusted as proof of payment on its
|
||||
own (a closed tab mid-redirect looks identical to success from here) —
|
||||
this page polls `/api/checkout/status?orderNumber=...` (session-scoped,
|
||||
so a guessed order number can't be used to probe someone else's payment
|
||||
status) until `paymentStatus` flips to `paid`, then promotes the
|
||||
provisional `sessionStorage` snapshot (`PENDING_ORDER_KEY`, written right
|
||||
before handing off to Stripe) to the real one (`ORDER_KEY`), clears the
|
||||
cart, and redirects to `/bestellbestaetigung` — exactly the same
|
||||
sessionStorage mechanism Überweisung orders already used, just populated
|
||||
a step later. On `failed`/`cancelled` it shows a retry message with the
|
||||
cart left intact (never cleared until payment actually succeeds); on a
|
||||
slow-to-arrive webhook it times out after ~15s with a "we'll email you"
|
||||
message rather than polling forever.
|
||||
|
||||
**Local testing without a real Stripe account** — `PAYMENT_TEST_MODE`
|
||||
(defaults on whenever `STRIPE_SECRET_KEY` is unset, so a fresh `npm run dev`
|
||||
never accidentally calls the real Stripe API): `app/lib/payments/index.ts`
|
||||
swaps in `mockProvider.ts` instead of `stripeProvider.ts` — same interface,
|
||||
so the checkout route and everything downstream of it runs unmodified.
|
||||
`PaymentStep.tsx` shows "Testzahlung erfolgreich"/"Testzahlung
|
||||
fehlgeschlagen" buttons instead of the real Payment Element; clicking one
|
||||
calls `app/api/webhooks/stripe/test-confirm/route.ts`, which skips
|
||||
signature verification (there's no real Stripe event to verify) and calls
|
||||
the exact same backend `confirm-payment` endpoint the real webhook does —
|
||||
so clicking "erfolgreich" exercises the *entire* real pipeline (deferred
|
||||
invoice numbering, gated email, idempotency) end to end, it's only the
|
||||
Stripe API call itself that's faked. That test-confirm route hard-404s
|
||||
whenever `PAYMENT_TEST_MODE` isn't explicitly true, so it can never become
|
||||
a reachable "mark any order paid" endpoint in production.
|
||||
|
||||
### VAT display
|
||||
|
||||
@@ -314,6 +439,11 @@ thumbnails per order row (`getProductImagesByIds()` in `app/lib/payload.ts`,
|
||||
a plain product-id → image-url lookup separate from the slug-keyed
|
||||
catalog, since an order only ever snapshots a numeric product id).
|
||||
|
||||
**None of this renders at all for a Kleinunternehmer tenant** (built
|
||||
2026-07-24) — see "Kleinunternehmerregelung" below for the full list of
|
||||
touched spots and why some read a live setting and others a persisted
|
||||
per-order snapshot.
|
||||
|
||||
### Checkout state persistence
|
||||
|
||||
`app/lib/checkoutDraft.ts` — `localStorage` under `ep_checkout_draft`,
|
||||
@@ -352,6 +482,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 —
|
||||
@@ -525,21 +673,25 @@ inbox, not only in `/konto/bestellungen`.
|
||||
color across the top) with the wordmark + "RECHNUNG" label, seller/buyer
|
||||
addresses, invoice number/date/order-reference/USt-IdNr. shown as small
|
||||
bordered "meta boxes" rather than a plain text row, a rounded/bordered
|
||||
item table with alternating row shading, and a shaded summary card for
|
||||
the totals — deliberately closer to the site's own card-based UI
|
||||
language than a generic invoice template. The footer is pinned to the
|
||||
bottom of the page (`position: absolute` + react-pdf's `fixed` prop),
|
||||
not just wherever the content flow happens to end.
|
||||
- **"Bereits beglichen" badge**: shown next to the meta boxes whenever
|
||||
`order.paymentMethodTitle` is anything other than `"Überweisung"` (bank
|
||||
transfer) — Kreditkarte and PayPal both settle at checkout, so the
|
||||
invoice says so explicitly (`isPaidImmediately()`, in the shared
|
||||
package's `invoicePdf.tsx` — "Überweisung" is the one method named
|
||||
explicitly as the exception, rather than hardcoding a list of
|
||||
item table, and a shaded summary card for the totals — deliberately
|
||||
closer to the site's own card-based UI language than a generic invoice
|
||||
template. The footer is pinned to the bottom of the page
|
||||
(`position: absolute` + react-pdf's `fixed` prop), not just wherever the
|
||||
content flow happens to end. Item rows share one uniform tinted
|
||||
background now (fixed 2026-07-23) — no more alternating white/tinted
|
||||
zebra striping.
|
||||
- **"Bereits beglichen" confirmation**: shown next to the summary card
|
||||
whenever `order.paymentMethodTitle` is anything other than
|
||||
`"Überweisung"` (bank transfer) — Kreditkarte and PayPal both settle at
|
||||
checkout, so the invoice says so explicitly (`isPaidImmediately()`, in
|
||||
the shared package's `invoicePdf.tsx` — "Überweisung" is the one method
|
||||
named explicitly as the exception, rather than hardcoding a list of
|
||||
"immediate" titles that would need updating every time a new payment
|
||||
method is added in Payload). This one genuinely is conditional on the
|
||||
order's own payment method — unlike bank details below, which just used
|
||||
to be worded as if it were.
|
||||
method is added in Payload). Plain green text, not a tinted pill/badge
|
||||
box (fixed 2026-07-23 — a colored background read as too heavy for what's
|
||||
just a status note). This one genuinely is conditional on the order's
|
||||
own payment method — unlike bank details below, which just used to be
|
||||
worded as if it were.
|
||||
- **Bank details**: `company-settings.bankName`/`.iban`/`.bic` — `iban`/
|
||||
`bic` structured and independently format-validated (uppercased/trimmed
|
||||
on save too, so "de123..." doesn't fail validation just for being
|
||||
@@ -556,18 +708,42 @@ inbox, not only in `/konto/bestellungen`.
|
||||
field was filled in) — the misleading wording got fixed instead of
|
||||
adding the behavior it implied, since a card/PayPal customer might
|
||||
still want the seller's bank details for other reasons (e.g. a refund).
|
||||
- **Per-tax-rate summary**: line items are grouped by their own
|
||||
snapshotted `taxRatePercent` (see the Payload README's "Per-product tax
|
||||
rates" section) via the shared package's `computeTaxBreakdown()` (see
|
||||
"VAT display" above) and the summary prints one plain "Netto" / "zzgl. X%
|
||||
MwSt." pair per distinct rate actually present in that order — no `%`
|
||||
after "Netto" itself anymore, since the rate is already stated on the
|
||||
"zzgl." line directly below it. A plain single pair in the common case
|
||||
(one rate for the whole order), a real multi-rate breakdown the moment a
|
||||
product with a different rate is involved. The order-level discount/
|
||||
shipping are distributed proportionally across each rate group before
|
||||
computing net/tax, so the grouped totals still reconcile exactly to
|
||||
`order.total`.
|
||||
- **Summary layout — a genuinely additive chain (fixed 2026-07-23).** The
|
||||
summary card now reads Zwischensumme → Rabatt (if any) → Versand → a
|
||||
divider → Gesamt, using the order's own raw `subtotal`/`discountAmount`/
|
||||
`shippingCost`/`total` fields directly — every row above the divider
|
||||
actually sums to the number below it. A previous version showed
|
||||
"Netto"/"zzgl. X% MwSt." rows computed via `computeTaxBreakdown()`,
|
||||
which distributes discount/shipping proportionally across each tax-rate
|
||||
group *before* computing net/tax (correct for the tax math itself, since
|
||||
ancillary costs are legally apportioned across rates) — but the same
|
||||
layout **also** printed Rabatt/Versand as their own separate rows on top,
|
||||
so the visible rows never actually summed to the printed Gesamt (off by
|
||||
exactly the shipping/discount amount — caught against a real production
|
||||
order, `#EP-0006-ZDX6`'s Stornorechnung, where the gap was concrete and
|
||||
reproducible, not a rounding nit). The per-rate breakdown still exists,
|
||||
just relocated **below** Gesamt as an "enthält X% MwSt.: Y €" annotation
|
||||
(one line per distinct rate, informational — not part of the additive
|
||||
stack above it), the same "contained within, not an extra deduction"
|
||||
framing `VatBreakdown.tsx` already used on `/cart`/`/checkout` (see "VAT
|
||||
display" above). Applies to both the original invoice and its Storno/
|
||||
Gutschrift, which has its own equivalent chain (Zwischensumme → the
|
||||
discount reversal, "Rabatt (entfällt)", a positive add-back since the
|
||||
original discount no longer applies once everything's undone → Versand →
|
||||
Gesamt; a Gutschrift shows Gesamt alone, since it never reverses
|
||||
shipping/discount in the first place — see the Payload README's "How a
|
||||
Stornorechnung/Gutschrift relates to the original invoice"). When the
|
||||
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
|
||||
@@ -583,6 +759,11 @@ inbox, not only in `/konto/bestellungen`.
|
||||
instead of two ~45%-width ones) — otherwise unchanged, two columns as
|
||||
before. `USt-IdNr.` no longer repeats in a header meta box — it already
|
||||
lives in the footer, printing it twice was redundant.
|
||||
- **Buyer B2B fields**: when the order has a `companyName`/`vatId` (see
|
||||
"B2B checkout fields" below), the "An" block shows `companyName` as its
|
||||
own line above the contact person's name, and `vatId` as its own line
|
||||
below the address (labelled "USt-IdNr. …") — the buyer-side counterpart
|
||||
to the seller's own VAT ID already shown in the footer.
|
||||
- **`app/lib/invoiceData.ts`** (still local to this repo — a thin
|
||||
server-only wrapper, not part of the shared package) — `generateInvoicePdf(order, seller)` /
|
||||
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
|
||||
@@ -658,11 +839,173 @@ inbox, not only in `/konto/bestellungen`.
|
||||
reference, per-item quantity/price, net subtotal per rate, tax rate +
|
||||
amount per rate, gross total — all on the PDF, not just the summary the
|
||||
confirmation email's HTML already shows.
|
||||
- **E-invoicing (ZUGFeRD/EN16931) migration** — in progress as of
|
||||
2026-07-23. Everything above is still a plain PDF; the shared package's
|
||||
README and the memory notes behind this project track the phased plan
|
||||
(hybrid PDF/A-3 + embedded XML via `@e-invoice-eu/core`, applied to
|
||||
*every* invoice, not just B2B) — not yet built as of this writing.
|
||||
- **E-invoicing (ZUGFeRD/EN16931) migration — done, live in production
|
||||
since 2026-07-23.** Every invoice generated above is actually a
|
||||
Factur-X-EN16931 hybrid PDF/A-3 (the same visual PDF, plus an embedded
|
||||
machine-readable `factur-x.xml`), for every order, not just B2B — see the
|
||||
shared package's own README for the full phased build (`@e-invoice-eu/core`,
|
||||
atomic invoice numbering, a self-hosted Mustang-CLI CI pipeline that
|
||||
validates every push to that package against real EN16931/PDF-A-3
|
||||
conformance rules).
|
||||
|
||||
### B2B checkout fields
|
||||
|
||||
Split out from the e-invoicing migration, built 2026-07-23 once that
|
||||
shipped. Optional "Firma"/"USt-IdNr." fields sit right under Vorname/
|
||||
Nachname in `/checkout`'s Card 1 — neither is required just because the
|
||||
other is filled in (a sole proprietor might give a VAT ID with no separate
|
||||
company name, and vice versa). Format-validated client- and server-side
|
||||
(`app/lib/vatId.ts`'s `isValidVatId()`/`normalizeVatId()`, same EU-format
|
||||
regex as `company-settings.vatId` on the backend), persisted through
|
||||
`checkoutDraft.ts` like every other Card 1 field, and saved as a
|
||||
`Customers` profile default (`/konto/profil`) that prefills future
|
||||
checkouts — `Orders` keeps its own independent snapshot regardless, same
|
||||
"a later profile edit must never rewrite what an order actually said"
|
||||
reasoning as every other snapshot field. Shown on the invoice PDF's "An"
|
||||
block (see "Invoice PDFs" above) and threaded through both invoice
|
||||
download routes and the correction-invoice email.
|
||||
|
||||
### VAT exemption (innergemeinschaftliche Lieferung)
|
||||
|
||||
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,
|
||||
server-side only.
|
||||
- **`app/lib/vatExemption.ts`** — `destinationCountry()` picks the actual
|
||||
place the goods ship to (the shipping-address override's country when
|
||||
set, billing country otherwise — the exemption depends on where the
|
||||
goods physically move, not necessarily the invoice address);
|
||||
`computeExemptTotals()` de-grosses every item's price and the shipping
|
||||
cost from their normal VAT-inclusive catalog figures to net, since the
|
||||
whole point of the exemption is that the buyer pays less, not that this
|
||||
shop quietly keeps the VAT portion as extra margin.
|
||||
- **VAT-ID *validity* and the exemption *decision* are two separate
|
||||
questions (fixed 2026-07-23)** — a first version only ever called VIES
|
||||
when the destination already qualified for the exemption (Österreich),
|
||||
so a garbage VAT ID on a domestic order (e.g. `"ED123456789"` — not even
|
||||
a real country code) sailed through with just a format check, and a
|
||||
genuinely valid German/Swiss VAT ID got no confirmation either. Now
|
||||
`app/api/checkout/validate-vat/route.ts`/`app/api/checkout/route.ts`
|
||||
check any format-valid VAT ID against VIES regardless of destination
|
||||
(data quality — worth knowing whether it's real at all, same reasoning
|
||||
as `company-settings.vatId`'s own check below) — the exemption itself
|
||||
still only applies when the destination is *also* Österreich. A
|
||||
validated German VAT ID never zero-rates a domestic sale, no matter how
|
||||
real it is.
|
||||
- **Checkout UX**: the USt-IdNr. field's blur gives instant feedback for
|
||||
any country — "✓ USt-IdNr. bestätigt" (plus "— Lieferung wird steuerfrei
|
||||
berechnet." only when the destination actually qualifies) flips the
|
||||
sidebar total to the exempt (de-grossed) figures live, as a preview.
|
||||
`app/api/checkout/route.ts` re-runs the exact same VIES check server-side
|
||||
at submit time regardless, as the actual source of truth — the
|
||||
client-side result is never trusted. **VIES being unreachable fails
|
||||
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). 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). `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
|
||||
(data-quality audit trail), independently of whether `vatExempt` is also
|
||||
true — see the Payload README's "B2B checkout & VAT exemption" section
|
||||
for the full field/audit-trail reasoning and the invoice PDF/EN16931 XML
|
||||
side of this feature.
|
||||
- **Every fixed-length numeric field now hard-caps input length too**
|
||||
(fixed 2026-07-23) — PLZ (`maxLength` = the selected country's own digit
|
||||
count), USt-IdNr. (`14`), Packstationnummer (`3`)/Postnummer (`10`, on
|
||||
the backend) all already had `pattern`/`validate` format checks, but
|
||||
nothing stopped the browser from accepting more characters than could
|
||||
ever pass. Same "as simple/state-of-the-art as possible" input-quality
|
||||
pass as the blur-validation fix above.
|
||||
- **`/bestellbestaetigung`** mirrors the same exempt-totals branch from the
|
||||
persisted `OrderSnapshot.vatExempt` flag (it otherwise re-derives totals
|
||||
live from the current catalog, which would show the wrong, VAT-inclusive
|
||||
figures for an exempt order).
|
||||
|
||||
### Kleinunternehmerregelung (§19 UStG)
|
||||
|
||||
Built 2026-07-24. `company-settings.kleinunternehmer` — a standing
|
||||
per-tenant setting (Payload backend), not a per-order decision like the
|
||||
VAT exemption above — when on, this tenant never charges VAT on anything,
|
||||
domestic or cross-border. See the Payload README's own
|
||||
"Kleinunternehmerregelung" section for the field/collection side; this one
|
||||
covers what changed here.
|
||||
|
||||
- **`api/checkout/route.ts`** forces every item's `taxRatePercent` to `0`
|
||||
when `getCompanySettings().kleinunternehmer` is on — deliberately
|
||||
**without** de-grossing `unitPrice` the way `vatExempt` above does
|
||||
(that exemption zero-rates what would otherwise be a positive-rate
|
||||
charge, so de-grossing means the buyer pays less; a Kleinunternehmer
|
||||
never charged VAT on the sale to begin with, so the catalog gross price
|
||||
already *is* the actual net charge — confirmed with the user as the
|
||||
intended business decision, not an engineering default). The VIES
|
||||
lookup/`isExemptionEligibleCountry()` check is skipped entirely in this
|
||||
branch too — there's no VAT for the intra-community rule to exempt
|
||||
either. Snapshotted onto the new order as `Orders.kleinunternehmer`
|
||||
(mirrors `vatExempt`'s own snapshot reasoning — see the Payload README).
|
||||
- **Storefront "inkl. X% MwSt." hints** — four spots read the *live*
|
||||
setting (`getKleinunternehmer()` in `app/lib/payload.ts`, same ISR-cached
|
||||
60s freshness as `getDefaultTaxRatePercent()`) and drop the MwSt. clause
|
||||
entirely when it's on, since there's no order yet at that point to
|
||||
snapshot from: `ProductGrid.tsx` (shop grid), `RelatedProducts.tsx`
|
||||
(cart's upsell row), `Pricing.tsx`/`TodoKartenHero.tsx` (ToDo-Karten
|
||||
landing page), `ProductSpotlight.tsx` (homepage). `Pricing.tsx`/
|
||||
`ProductSpotlight.tsx` keep "zzgl. Versand" on its own when the MwSt.
|
||||
clause drops; the other two had no such trailing clause to preserve.
|
||||
- **Every already-placed-order display reads the persisted snapshot
|
||||
instead** — `OrderSnapshot.kleinunternehmer` (`app/lib/order.ts`,
|
||||
written into `sessionStorage` at checkout, read by
|
||||
`BestellbestaetigungContent.tsx`) and `CustomerOrderDetail.
|
||||
kleinunternehmer` (`app/lib/customerAuth.ts`, read by
|
||||
`/konto/bestellungen/[orderNumber]`) — never the live company-settings
|
||||
value, for the identical "don't retroactively rewrite an already-issued
|
||||
invoice's tax treatment" reason `vatExempt` already established. Both
|
||||
pages replace the per-item "inkl. X% MwSt." hint and the `VatBreakdown`
|
||||
summary with "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." —
|
||||
taking precedence over the `vatExempt` note wherever both would
|
||||
otherwise apply. `CheckoutContent.tsx`'s live VIES-exemption *preview*
|
||||
is also gated off (`!kleinunternehmer && ...`) so a Kleinunternehmer
|
||||
tenant never shows a misleading "wird steuerfrei berechnet" preview for
|
||||
VAT that was never going to be charged either way.
|
||||
- **On-demand invoice/Stornorechnung/Gutschrift downloads**
|
||||
(`api/account/orders/[orderNumber]/invoice/route.ts` and its
|
||||
`correction-invoice` sibling) thread `order.kleinunternehmer` through to
|
||||
`@einfach-produktiv/invoicing`'s renderers the same way they already
|
||||
thread `vatExempt`.
|
||||
- **`app/company-settings-preview`'s Live Preview** merges the live-edited
|
||||
`kleinunternehmer` checkbox onto the fixed `SAMPLE_INVOICE_ORDER` before
|
||||
rendering (`kleinunternehmer` lives on `InvoiceOrder`, not
|
||||
`InvoiceSeller` — see the invoicing package's own README on why), so an
|
||||
admin sees the §19 UStG notice appear/disappear live as they toggle the
|
||||
field, without this preview needing its own separate mechanism.
|
||||
|
||||
### Company Settings & Live Preview
|
||||
|
||||
@@ -701,6 +1044,73 @@ 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
|
||||
**double opt-in** endpoint, `POST /contacts/doubleOptinConfirmation`
|
||||
(switched 2026-07-25 from the plain `POST /v3/contacts` single-opt-in
|
||||
upsert this originally shipped with) — this only ever *requests* a
|
||||
subscription; Brevo sends a confirmation email (the template at
|
||||
`BREVO_DOUBLE_OPTIN_TEMPLATE_ID`, configured as the list's Double Opt-in
|
||||
template in Brevo's own UI) and only actually adds the contact to
|
||||
`BREVO_LIST_ID` once they click through. `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 request (no error at all, just never stored) rather
|
||||
than rejecting the request. `redirectionUrl` (where Brevo sends the
|
||||
contact after they click confirm) defaults to `/newsletter-confirmed`
|
||||
via `BREVO_DOI_REDIRECT_URL` — a static confirmation page
|
||||
(`app/newsletter-confirmed/page.tsx`), same visual language as
|
||||
`/bestellbestaetigung` (brand-tinted circular checkmark, serif display
|
||||
heading, thin brand divider). No query params to read — Brevo's
|
||||
redirect carries nothing this page needs, unlike `/checkout/verarbeitung`
|
||||
which polls actual payment status.
|
||||
- **`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. As of the double-opt-in switch,
|
||||
also needs `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every
|
||||
signup silently no-ops without it) and optionally
|
||||
`BREVO_DOI_REDIRECT_URL`.
|
||||
|
||||
## Orders & customer accounts
|
||||
|
||||
An account is required to buy — there is no guest checkout. Registration
|
||||
@@ -710,11 +1120,26 @@ field appears there when nobody's logged in). There's no persistent
|
||||
and shown to every logged-out visitor regardless of relevance. Instead,
|
||||
the email field's `onBlur` calls `/api/account/check-email`
|
||||
(`checkEmailExists()` in `customerAuth.ts`, service-secret authenticated —
|
||||
Customers isn't public-read) and only *then* swaps Card 1's password field
|
||||
out for an inline login form, gender-neutral copy, pre-filled with the
|
||||
email just typed. `handleSubmit`'s own `emailExists` handling (see
|
||||
"Checkout registration collisions" below) is the fallback for the case
|
||||
this check was skipped or raced.
|
||||
Customers isn't public-read) and only *then* swaps Card 1's own
|
||||
"Passwort (für dein neues Konto)" field out for an inline login prompt
|
||||
(password field + "Einloggen" button), gender-neutral copy, rendered
|
||||
right under the same email field the shopper just typed into rather than
|
||||
asking for it a second time in a separate field. `handleSubmit`'s own
|
||||
`emailExists` handling (see "Checkout registration collisions" below) is
|
||||
the fallback for the case this check was skipped or raced.
|
||||
|
||||
**Fixed 2026-07-24** — this login prompt used to render in a completely
|
||||
separate block above the whole form (before `<form>` even opens), which on
|
||||
a shopper who'd already scrolled down to reach Card 1's email field meant
|
||||
the prompt popped in off-screen, above their current scroll position, with
|
||||
no auto-scroll wired up for this common blur-triggered path (only the
|
||||
submit-time fallback had one). Moved inline into Card 1 itself instead —
|
||||
no scrolling needed in the common case since it now appears exactly where
|
||||
the shopper is already looking. The submit-time fallback (see "Checkout
|
||||
registration collisions" below) still scrolls it into view, now via a
|
||||
`useEffect` watching `showLogin` rather than a synchronous call at the
|
||||
`setShowLogin(true)` site — the prompt is conditionally rendered, so its
|
||||
ref isn't attached to anything yet at that exact synchronous point.
|
||||
|
||||
- **`app/lib/customerAuth.ts`** (server-only) is the single place that
|
||||
talks to Payload's `customers` collection — a second, fully separate
|
||||
@@ -739,7 +1164,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
|
||||
@@ -793,13 +1221,22 @@ in the header itself, which stays visible above the panel throughout.
|
||||
error, since Payload's own message text doesn't distinguish "duplicate"
|
||||
from other email-field failures) rather than just surfacing a generic
|
||||
error. `CheckoutContent.tsx`'s `handleSubmit` reacts by switching
|
||||
straight to the login toggle with that email pre-filled and
|
||||
scroll-into-view, instead of leaving the customer stuck with an error
|
||||
and no obvious next step.
|
||||
`showLogin` on (same inline prompt described above — reuses Card 1's
|
||||
own email field, nothing to pre-fill) and scrolling it into view via a
|
||||
`useEffect`, instead of leaving the customer stuck with an error and no
|
||||
obvious next step. `handleLogin()` itself posts the live `email` field
|
||||
value, not a separate `loginEmail` state — there's only ever one email
|
||||
input on this form now.
|
||||
- **`/konto/profil`** edits name + the one saved default address (deliberately
|
||||
a single address, not a full address book — see the assistant's memory
|
||||
note on optionally expanding this later), changes the password, shows
|
||||
the email-verification banner, and has the GDPR export/delete section.
|
||||
Its "Land" `<select>` used to hardcode Deutschland/Österreich/Schweiz
|
||||
independently of `/checkout`'s own country list — **fixed 2026-07-24**:
|
||||
`ProfileForm.tsx` now takes a `shippingCountries` prop (`page.tsx` fetches
|
||||
`getShippingCountries()`, same Payload-configurable list `/checkout`
|
||||
already reads), so a country added/removed in the admin reaches both
|
||||
places instead of just one.
|
||||
- **Cart sync**: `app/components/CartSync.tsx` (mounted once in
|
||||
`app/layout.tsx`) watches the local cart via `useCart()` and
|
||||
debounce-POSTs it to `/api/account/cart` on every change; the route
|
||||
@@ -1097,6 +1534,111 @@ session (low-stock digest, stale-unverified-accounts report, weekly revenue
|
||||
report, expired-discount-code cleanup) needed no monitor changes of their
|
||||
own; see the Payload README's "Jobs Queue" section.
|
||||
|
||||
## Mobile responsive pass (2026-07-24)
|
||||
|
||||
A full-site pass fixing concrete Mobile-width bugs across the Home Hero,
|
||||
Navbar, Newsletter, cart, `/challenge`, `/todo-cards`, blog detail, and the
|
||||
legal pages — found by testing on a real phone rather than just resizing a
|
||||
desktop browser to 768px. The detailed technical lessons (aspect-ratio
|
||||
distortion from mixing fluid/fixed sizing, `preserveAspectRatio="none"` SVGs,
|
||||
`whileInView` failing to trigger on short/narrow viewports, etc.) are written
|
||||
up as dated Gotchas in the `figma-to-nextjs` skill
|
||||
(`~/.claude/skills/figma-to-nextjs/SKILL.md`) rather than duplicated here —
|
||||
that's now the reference for "why" on any of this. Two changes are worth
|
||||
calling out specifically since they add new shared components:
|
||||
|
||||
- **`app/components/StepArrow.tsx`** — a small inline-SVG connector arrow,
|
||||
replacing `/icon-arrow-connector.svg` across all three "how it works" step
|
||||
sections (`/challenge`, `/todo-cards`, `/newsletter`). The old asset's
|
||||
color lived in a CSS custom property scoped to the SVG file itself, so it
|
||||
could never actually become the brand orange used everywhere else once
|
||||
loaded via `next/image` — converting it to a real component was the only
|
||||
fix, and having one shared component means a future color/shape/size
|
||||
tweak is one edit instead of three.
|
||||
- **`app/components/SectionTOC.tsx`'s `MobileSectionTOC`** — the
|
||||
Impressum/Datenschutz/AGB/Widerruf/Versand table-of-contents sidebar was
|
||||
`hidden` entirely below `lg:` (a 360px sidebar genuinely doesn't fit next
|
||||
to a readable content column below that), which meant Mobile/Tablet had no
|
||||
on-page navigation aid at all on these often-long pages — exactly where
|
||||
scanning a long legal document by scrolling is hardest. `MobileSectionTOC`
|
||||
is a `<details>/<summary>` accordion (no extra JS state needed) sharing the
|
||||
same scroll-spy "active section" logic (extracted into a `useActiveSection`
|
||||
hook) as the desktop sidebar nav, rendered as its own element right after
|
||||
each page's heading — not nested inside the sidebar's `hidden lg:...`
|
||||
wrapper, which would hide it too regardless of its own `lg:hidden` class.
|
||||
|
||||
## Custom post content blocks, and SEO settings (2026-07-24)
|
||||
|
||||
`app/components/RichText.tsx` switched from a small hand-rolled Lexical
|
||||
JSON→JSX walker to Payload's official `@payloadcms/richtext-lexical/react`
|
||||
renderer + custom `JSXConverters` — needed once the Payload repo's
|
||||
`Posts.content` gained custom Lexical Blocks (`Bild`/`Bildergalerie`/`Video`/
|
||||
`Zitat`, see the Payload README's own section on this), which the old
|
||||
hand-rolled `switch` had no case for at all. Kept the exact same exported
|
||||
call signature (`RichText({ content, quoteLabel })`), so `LiveRichText.tsx`
|
||||
and `app/blog/[slug]/components/LivePostContent.tsx` needed zero changes.
|
||||
`extractHeadings()`/`headingId()` (used by `SectionTOC` on legal pages) stay
|
||||
an independent, minimal walk over the raw JSON, unrelated to the new
|
||||
renderer — legal pages don't use Blocks.
|
||||
|
||||
Block converters follow this project's established "CMS-sourced image"
|
||||
convention (`relative` + `aspect-[…]` + `next/image fill` + `object-cover`,
|
||||
see the blog thumbnail treatment) — a gallery block renders as a
|
||||
`grid-cols-2` grid, a video block resolves a YouTube/Vimeo URL into an
|
||||
`<iframe>` embed. The converters object is built fresh per `RichText()` call
|
||||
(a factory closing over that call's own `quoteLabel`), not a module-level
|
||||
constant — Server Components can render multiple posts concurrently in the
|
||||
same process, so a shared mutable variable would be a real race condition.
|
||||
|
||||
**SEO settings** — new `getSeoSettings()` in `app/lib/payload.ts` (same
|
||||
ISR-cached, public-safe-subset-of-CompanySettings pattern as
|
||||
`getKleinunternehmer()`), with a hardcoded fallback equal to what used to be
|
||||
directly in `app/layout.tsx`. `app/layout.tsx`'s `metadata` export became
|
||||
`generateMetadata()` reading it. `PostDetail`/`mapPayloadPost`/
|
||||
`getPostBySlug()` gained `seoTitle`/`seoDescription`/`seoImage`, each
|
||||
falling back to the post's own `title`/`excerpt`/`thumbnail` when empty;
|
||||
`app/blog/[slug]/page.tsx`'s `generateMetadata()` uses them (now also
|
||||
setting a `twitter` block, previously missing). A few smaller gaps found
|
||||
while auditing every page's metadata were fixed in the same pass: 3
|
||||
`konto/*` pages were missing a `description`, `/konto/bestellungen/
|
||||
[orderNumber]` had a static "Bestelldetails" title despite being a dynamic
|
||||
route (now uses the real order number), and `/shop`/`/blog` were missing an
|
||||
Open Graph image.
|
||||
|
||||
## Tablet responsive fixes (2026-07-24)
|
||||
|
||||
A follow-up to the Mobile pass above — testing at real Tablet widths
|
||||
(768-1023px) surfaced a few spots where the Mobile-focused fixes had left
|
||||
Tablet worse off than before, or where a component's own structural
|
||||
breakpoint no longer needed to be as conservative as originally set:
|
||||
|
||||
- **`Hero.tsx` reverted from `lg:` back to `md:` as its structural
|
||||
breakpoint.** It was moved to `lg:` earlier (see the figma-to-nextjs
|
||||
skill's Gotcha #5) because at `md:col-span-5` the text column was only
|
||||
~283px at 768px and the then-full-size CTA/subtitle text made the whole
|
||||
row wrap to 3 cramped lines. The smaller fixed CTA/subtitle/icon sizes
|
||||
added during the Mobile pass (for true phone widths) are well under half
|
||||
that column's width, so the original wrapping problem doesn't recur —
|
||||
moving back to `md:` means Tablet gets the real 5/7 grid (image beside
|
||||
text) again instead of a phone-style stacked layout. Every `lg:`-gated
|
||||
size override in that file moved to `md:` to match.
|
||||
- **`Tools.tsx`'s card icon** — full 56px size pushed from `md:` to `lg:`;
|
||||
at Tablet the title/description text is still close to its own fluid
|
||||
floor, and the full-size icon read as too big next to it.
|
||||
- **`About.tsx`'s text/photo columns** — the Desktop overlap layout
|
||||
(`flex-[1.4_0_0]` on the photo, `-ml-48`, gradient) kicked in from `md:`
|
||||
already, giving the text column (fixed against the smaller 1:1.4 share)
|
||||
too little room for its fixed-width statement + quote/bio row at Tablet.
|
||||
The ratio is swapped at Tablet (text gets the bigger 1.4 share, photo the
|
||||
smaller one, no overlap) and reverts to the original ratio + overlap only
|
||||
from `lg:` up, where it was designed for.
|
||||
- **`Newsletter.tsx`'s input+submit row** and **`Footer.tsx`'s logo/handle/
|
||||
legal-links row** — both went side-by-side at `md:`, but the columns
|
||||
around them (the newsletter card's fixed-width copy column; the footer's
|
||||
3 groups sharing one `justify-between` row) didn't leave enough width at
|
||||
768px. Both pushed from `md:flex-row` to `lg:flex-row` — stacked through
|
||||
the whole Tablet range, side by side again once there's real room.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
|
||||
|
||||
+8
-1
@@ -7,7 +7,7 @@ import { Footer } from "../components/Footer";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -39,6 +39,13 @@ export default async function AgbPage() {
|
||||
<p className="text-body text-text-muted">Stand: Juli 2026</p>
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
|
||||
comment. Outside the sidebar's `hidden lg:flex` wrapper below
|
||||
(that wrapper's `hidden` would hide this too otherwise). */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileSectionTOC sections={headings} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
|
||||
@@ -34,6 +34,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
|
||||
correctionInvoiceIssuedAt: order.correctionInvoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
companyName: order.companyName,
|
||||
vatId: order.vatId,
|
||||
vatExempt: order.vatExempt,
|
||||
kleinunternehmer: order.kleinunternehmer,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
|
||||
@@ -31,6 +31,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
companyName: order.companyName,
|
||||
vatId: order.vatId,
|
||||
vatExempt: order.vatExempt,
|
||||
kleinunternehmer: order.kleinunternehmer,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
|
||||
+228
-60
@@ -9,6 +9,10 @@ import { describeBundleContents } from "../../lib/bundleContents";
|
||||
import { sendCriticalAlert } from "../../lib/alertAdmin";
|
||||
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../lib/vies";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { upsertNewsletterContact } from "../../lib/brevo";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
|
||||
|
||||
// Plain float arithmetic on money (quantity × unitPrice summed across
|
||||
// lines, a percent discount, subtracting/adding those together) drifts
|
||||
@@ -138,6 +142,15 @@ export async function POST(request: Request) {
|
||||
// Re-price everything server-side — never trust client-submitted prices.
|
||||
const [productsBySlug, companySettings] = await Promise.all([fetchProductsBySlug(), getCompanySettings()]);
|
||||
const defaultTaxRate = companySettings?.taxRatePercent ?? 19;
|
||||
// §19 UStG — a Kleinunternehmer tenant never charges VAT on anything,
|
||||
// full stop, so every item's tax rate is forced to 0% here regardless of
|
||||
// its own catalog/company-settings default rate. Unlike the
|
||||
// intra-community exemption below, prices are NOT de-grossed — see
|
||||
// Orders.ts's own kleinunternehmer field comment and this shop's
|
||||
// Kleinunternehmer decision: catalog gross prices stay exactly what they
|
||||
// are, they simply never had a VAT component charged on top in the
|
||||
// first place.
|
||||
const kleinunternehmer = Boolean(companySettings?.kleinunternehmer);
|
||||
const items: {
|
||||
productId: number;
|
||||
productName: string;
|
||||
@@ -179,7 +192,7 @@ export async function POST(request: Request) {
|
||||
quantity: line.qty,
|
||||
unitPrice: variant?.priceOverride ?? product.price,
|
||||
imageUrl,
|
||||
taxRatePercent: product.taxRatePercent ?? defaultTaxRate,
|
||||
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
|
||||
bundleContents: describeBundleContents(product),
|
||||
variantName: variant?.name ?? null,
|
||||
});
|
||||
@@ -206,7 +219,97 @@ export async function POST(request: Request) {
|
||||
validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal),
|
||||
);
|
||||
}
|
||||
const total = roundMoney(Math.max(0, subtotal - discountAmount) + shippingCost);
|
||||
|
||||
// VAT-ID validity and the exemption decision are two separate questions.
|
||||
// Validity (is this actually a currently-registered VAT ID at all) is
|
||||
// checked via VIES for ANY country whenever one is given — worth
|
||||
// recording regardless of destination, same "data quality" reasoning as
|
||||
// company-settings.vatId's own VIES check on the backend; a merely
|
||||
// format-valid id (e.g. "ED123456789" — "ED" isn't even a real country
|
||||
// code) is never enough on its own. The exemption itself
|
||||
// (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG) additionally
|
||||
// requires the goods' actual destination (the shipping override's
|
||||
// country when set, the billing country otherwise) to be Österreich,
|
||||
// the one EU-cross-border option this checkout offers — a validated
|
||||
// *German* VAT ID never zero-rates a domestic sale, no matter how real
|
||||
// it is. VIES being unreachable fails closed on the exemption: normal
|
||||
// VAT applies, never a guessed exemption (vatIdValidatedAt just stays
|
||||
// unset in that case too).
|
||||
let vatExempt = false;
|
||||
let vatIdValidatedAt: string | null = null;
|
||||
// A Kleinunternehmer never charges VAT on any sale, domestic or
|
||||
// cross-border — the intra-community exemption exists to zero-rate what
|
||||
// would otherwise be a positive-rate charge, which never applies here in
|
||||
// the first place, so the VIES lookup is skipped entirely (also saves an
|
||||
// unneeded network round-trip).
|
||||
const buyerDestinationCountry = destinationCountry(body.country, Boolean(body.hasDifferentShippingAddress), body.shippingCountry);
|
||||
if (!kleinunternehmer && normalizedVatId) {
|
||||
const viesResult = await checkVatIdViaVies(normalizedVatId);
|
||||
if (viesResult.ok && viesResult.valid) {
|
||||
vatIdValidatedAt = new Date().toISOString();
|
||||
if (isExemptionEligibleCountry(buyerDestinationCountry)) {
|
||||
vatExempt = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (vatExempt) {
|
||||
// Re-price every line net of VAT (0% now applies) instead of the
|
||||
// catalog's normal VAT-inclusive price — the whole point of the
|
||||
// exemption is that the buyer pays less, not that this shop quietly
|
||||
// keeps the VAT portion as extra margin. items/subtotal/shippingCost
|
||||
// below are overwritten with the de-grossed figures actually charged
|
||||
// and actually persisted on the order/invoice.
|
||||
for (const item of items) {
|
||||
item.unitPrice = roundMoney(item.unitPrice / (1 + item.taxRatePercent / 100));
|
||||
item.taxRatePercent = 0;
|
||||
}
|
||||
}
|
||||
const exemptTotals = vatExempt
|
||||
? computeExemptTotals(
|
||||
items.map((i) => ({ quantity: i.quantity, grossUnitPrice: i.unitPrice, taxRatePercent: 0 })),
|
||||
shippingCost,
|
||||
defaultTaxRate,
|
||||
discountAmount,
|
||||
)
|
||||
: null;
|
||||
// Note: exemptTotals recomputes `subtotal` from the already-degrossed
|
||||
// `items` above (taxRatePercent 0 there means computeExemptTotals's own
|
||||
// degross() step is a no-op on them) — it exists mainly to degross
|
||||
// `shippingCost` the same way, and to keep both figures derived through
|
||||
// one shared function rather than duplicating the arithmetic here.
|
||||
const finalSubtotal = exemptTotals?.subtotal ?? subtotal;
|
||||
const finalShippingCost = exemptTotals?.shippingCost ?? shippingCost;
|
||||
const total = roundMoney(Math.max(0, finalSubtotal - discountAmount) + finalShippingCost);
|
||||
|
||||
// Gated-payment branch (Kreditkarte/PayPal today) — see
|
||||
// spicy-leaping-pizza.md §3. The PaymentIntent is created BEFORE the
|
||||
// order so its id can be persisted onto the order at creation time
|
||||
// (providerReference), rather than needing a second authenticated
|
||||
// update call that doesn't otherwise exist from this service. Stripe
|
||||
// generates a PaymentIntent id independent of any order existing yet.
|
||||
const requiresPayment = paymentMethod.provider === "stripe";
|
||||
let providerReference: string | undefined;
|
||||
let clientSecret: string | undefined;
|
||||
if (requiresPayment) {
|
||||
try {
|
||||
const intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: body.email,
|
||||
description: `einfach produktiv Bestellung — ${body.firstName} ${body.lastName}`,
|
||||
});
|
||||
providerReference = intent.providerReference;
|
||||
clientSecret = intent.clientSecret;
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Zahlung konnte nicht vorbereitet werden", {
|
||||
customerEmail: body.email,
|
||||
total,
|
||||
error: String(err),
|
||||
});
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlung konnte gerade nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const order = await createOrder({
|
||||
customerId: customer.id,
|
||||
@@ -215,6 +318,9 @@ export async function POST(request: Request) {
|
||||
customerEmail: body.email,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
vatIdValidatedAt,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
@@ -234,13 +340,24 @@ export async function POST(request: Request) {
|
||||
shippingCountry: body.shippingCountry,
|
||||
newsletterOptIn: Boolean(body.newsletterOptIn),
|
||||
items,
|
||||
subtotal,
|
||||
shippingCost,
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
shippingMethodTitle: shippingMethod.title,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
|
||||
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
|
||||
// customer hasn't actually chosen an instrument yet at this point,
|
||||
// Stripe's Payment Element does that next. Snapshotting the specific
|
||||
// resolved row's title here would just record whichever row happened
|
||||
// to be the group's representative id, not what was really picked.
|
||||
// The webhook route refines this to the real instrument
|
||||
// ("Kreditkarte"/"PayPal") once Stripe reports it, via confirm-payment.
|
||||
paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
|
||||
discountCode: body.discountCode || null,
|
||||
discountAmount,
|
||||
total,
|
||||
...(requiresPayment
|
||||
? { status: "pending_payment" as const, paymentProvider: "stripe" as const, paymentStatus: "pending" as const, providerReference }
|
||||
: {}),
|
||||
});
|
||||
if (!order) {
|
||||
// The worst-case failure in this whole flow: the customer went
|
||||
@@ -258,68 +375,119 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
// Fire-and-forget — a failed confirmation email must never undo an
|
||||
// already-successful order or block the response the customer is
|
||||
// waiting on. Lower severity than the "order lost" alert above (the
|
||||
// order itself is safe either way), but still worth knowing about, since
|
||||
// it's the one thing that would otherwise fail completely silently.
|
||||
sendOrderConfirmationEmail(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
createdAt: order.createdAt,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
imageUrl: i.imageUrl,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
})),
|
||||
subtotal,
|
||||
shippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail: body.email,
|
||||
error: String(err),
|
||||
if (requiresPayment && providerReference) {
|
||||
// Best-effort — see stripeProvider.attachOrderMetadata's own comment.
|
||||
// Not fatal: the order's own `providerReference` field (already
|
||||
// persisted above) remains the source of truth for the
|
||||
// expirePendingPayments cleanup job either way; this only speeds up
|
||||
// the webhook's fast path.
|
||||
await paymentProvider.attachOrderMetadata(providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch((err) => {
|
||||
sendCriticalAlert("Zahlungsmetadaten konnten nicht verknüpft werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
providerReference,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Deferred for gated payment methods (Kreditkarte/PayPal) until the
|
||||
// webhook confirms payment — see spicy-leaping-pizza.md §3/§4. Sent
|
||||
// from the backend's confirm-payment endpoint instead, at that point.
|
||||
// Unchanged for Überweisung: fires immediately, exactly as before.
|
||||
if (!requiresPayment) {
|
||||
// Fire-and-forget — a failed confirmation email must never undo an
|
||||
// already-successful order or block the response the customer is
|
||||
// waiting on. Lower severity than the "order lost" alert above (the
|
||||
// order itself is safe either way), but still worth knowing about, since
|
||||
// it's the one thing that would otherwise fail completely silently.
|
||||
sendOrderConfirmationEmail(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
createdAt: order.createdAt,
|
||||
invoiceNumber: order.invoiceNumber as string,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt as string,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
imageUrl: i.imageUrl,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail: body.email,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget, same reasoning as the confirmation email above — a
|
||||
// failed marketing sync is not worth failing checkout over, and doesn't
|
||||
// even need a critical alert (nothing customer-facing depends on it).
|
||||
// Not gated on payment confirmation — a newsletter signup intent isn't
|
||||
// an order-fulfillment concern, unlike the confirmation email/invoice.
|
||||
if (body.newsletterOptIn) {
|
||||
upsertNewsletterContact(body.email, "checkout").catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orderNumber: order.orderNumber,
|
||||
orderId: order.id,
|
||||
orderDateIso: order.createdAt,
|
||||
shippingCost,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
...(requiresPayment
|
||||
? {
|
||||
requiresPayment: true as const,
|
||||
clientSecret,
|
||||
testMode: isPaymentTestMode,
|
||||
// Only surfaced in test mode — PaymentStep's "Testzahlung"
|
||||
// buttons need it to call the test-confirm route directly,
|
||||
// since there's no real Stripe redirect to carry it back
|
||||
// through. A real PaymentIntent id isn't secret (only its
|
||||
// client_secret is), but there's no reason to expose it to the
|
||||
// client outside test mode either.
|
||||
...(isPaymentTestMode ? { providerReference } : {}),
|
||||
}
|
||||
: {}),
|
||||
shippingCost: finalShippingCost,
|
||||
paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
|
||||
discountCode: body.discountCode || null,
|
||||
discountAmount,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../lib/customerAuth";
|
||||
|
||||
// Polled by /checkout/verarbeitung after a Payment Element redirect
|
||||
// returns — see spicy-leaping-pizza.md §3. Requires the customer's own
|
||||
// session (checkout is "Konto Pflicht", so one always exists by the time
|
||||
// this page is reachable) rather than accepting a bare orderNumber, so a
|
||||
// guessed/leaked order number can't be used to probe another customer's
|
||||
// payment status.
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const orderNumber = new URL(request.url).searchParams.get("orderNumber");
|
||||
if (!orderNumber) return NextResponse.json({ ok: false, reason: "orderNumber fehlt." }, { status: 400 });
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, orderNumber);
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: order.status,
|
||||
paymentStatus: order.paymentStatus,
|
||||
// Refined from the checkout-time "Online-Zahlung" placeholder to the
|
||||
// actual instrument (Kreditkarte/PayPal) once confirm-payment sets it
|
||||
// — see resolveStripePaymentMethodLabel's own comment. Returned here
|
||||
// so VerarbeitungContent can patch the pending sessionStorage snapshot
|
||||
// before promoting it, so /bestellbestaetigung shows the real one.
|
||||
paymentMethodTitle: order.paymentMethodTitle,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../../lib/vies";
|
||||
|
||||
// Called from CheckoutContent.tsx on the USt-IdNr. field's blur, whenever
|
||||
// the billing country is Österreich — the only cross-border-EU option this
|
||||
// checkout offers besides Deutschland (domestic, exemption never applies)
|
||||
// and Schweiz (non-EU export, a different exemption entirely, out of
|
||||
// scope here). Gives the shopper immediate feedback on whether their VAT
|
||||
// ID actually qualifies for the innergemeinschaftliche-Lieferung
|
||||
// exemption, before they even submit — api/checkout/route.ts re-runs this
|
||||
// exact same check server-side at submit time regardless (never trusts
|
||||
// this response), since a VIES result could theoretically change between
|
||||
// blur and submit.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const vatId = typeof body?.vatId === "string" ? body.vatId : "";
|
||||
if (!vatId) return NextResponse.json({ ok: false, reason: "USt-IdNr. fehlt." }, { status: 400 });
|
||||
|
||||
const normalized = normalizeVatId(vatId);
|
||||
if (!isValidVatId(normalized)) {
|
||||
return NextResponse.json({ ok: true, valid: false, reason: "Ungültiges USt-IdNr.-Format." });
|
||||
}
|
||||
|
||||
const result = await checkVatIdViaVies(normalized);
|
||||
if (!result.ok) {
|
||||
// `ok: false` here means "VIES couldn't confirm this one way or the
|
||||
// other" (unreachable, or the member state's own gateway is briefly
|
||||
// down — `MS_UNAVAILABLE`, which VIES itself answers 200 for, not an
|
||||
// error status) — NOT "confirmed invalid". Previously this branch
|
||||
// still answered `{ ok: true, valid: false }`, which the client reads
|
||||
// as a rejected VAT ID (`vatIdViesStatus = "invalid"`) instead of
|
||||
// "couldn't check right now" (`"unavailable"`) — a real, currently
|
||||
// registered VAT ID looked wrong to the customer whenever VIES (or
|
||||
// just Germany's own national gateway) had a hiccup.
|
||||
return NextResponse.json({ ok: false, reason: result.reason });
|
||||
}
|
||||
return NextResponse.json({ ok: true, valid: result.valid, name: result.name });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { upsertNewsletterContact, type NewsletterOptInSource } from "../../../lib/brevo";
|
||||
import { isValidEmail } from "../../../lib/email";
|
||||
|
||||
type SubscribeBody = {
|
||||
email?: string;
|
||||
consent?: boolean;
|
||||
source?: NewsletterOptInSource;
|
||||
};
|
||||
|
||||
const VALID_SOURCES: NewsletterOptInSource[] = ["newsletter-page", "newsletter-modal", "newsletter-hero", "challenge"];
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body: SubscribeBody = await req.json();
|
||||
const email = body.email?.trim() ?? "";
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 });
|
||||
}
|
||||
if (!body.consent) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte akzeptiere die Datenschutzerklärung." }, { status: 400 });
|
||||
}
|
||||
|
||||
const source = body.source && VALID_SOURCES.includes(body.source) ? body.source : "newsletter-page";
|
||||
const result = await upsertNewsletterContact(email, source);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { verifyStripeWebhookSignature, resolveStripePaymentMethodLabel } from "../../../lib/payments/stripeProvider";
|
||||
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../lib/payments/confirmPaymentEmail";
|
||||
import { sendCriticalAlert } from "../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Real Stripe webhook — see spicy-leaping-pizza.md §4. Never reachable in
|
||||
// PAYMENT_TEST_MODE in practice (no real Stripe account sends events
|
||||
// here then), but left unconditional rather than gated on the env var —
|
||||
// an invalid/missing signature already fails closed on its own.
|
||||
export async function POST(request: Request) {
|
||||
// Raw body only — request.json() would consume/reparse the stream and
|
||||
// Stripe's signature is computed over the exact original bytes.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) return NextResponse.json({ ok: false }, { status: 400 });
|
||||
|
||||
const event = verifyStripeWebhookSignature(rawBody, signature);
|
||||
if (!event) return NextResponse.json({ ok: false, reason: "invalid signature" }, { status: 400 });
|
||||
|
||||
if (event.type !== "payment_intent.succeeded" && event.type !== "payment_intent.payment_failed") {
|
||||
// Stripe sends many event types we don't act on (e.g.
|
||||
// payment_intent.created, charge.*) — ack them so Stripe stops
|
||||
// retrying something we were never going to process.
|
||||
return NextResponse.json({ ok: true, ignored: event.type });
|
||||
}
|
||||
|
||||
const intent = event.data.object as Stripe.PaymentIntent;
|
||||
const providerReference = intent.id;
|
||||
const orderId = intent.metadata?.orderId;
|
||||
const paymentStatus = event.type === "payment_intent.succeeded" ? "paid" : "failed";
|
||||
|
||||
if (!orderId) {
|
||||
// stripeProvider.attachOrderMetadata (called right after order
|
||||
// creation in /api/checkout) failed to complete for this
|
||||
// PaymentIntent — the order's own `providerReference` field is still
|
||||
// the source of truth and expirePendingPayments will reconcile it
|
||||
// eventually, but that's a multi-hour fallback, not instant. Alert
|
||||
// now rather than silently relying on the cleanup job.
|
||||
sendCriticalAlert("Stripe-Webhook ohne orderId-Metadaten", { providerReference, paymentStatus, eventType: event.type });
|
||||
// Non-2xx so Stripe retries — a later retry might land after the
|
||||
// metadata attach (which races the checkout response) has caught up.
|
||||
return NextResponse.json({ ok: false, reason: "orderId metadata missing" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Best-effort — see resolveStripePaymentMethodLabel's own comment. Only
|
||||
// meaningful on the "paid" path; a failed payment never gets a
|
||||
// paymentMethodTitle refinement (the order becomes 'cancelled' outright).
|
||||
const paymentMethodTitle = paymentStatus === "paid" ? await resolveStripePaymentMethodLabel(intent) : undefined;
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paymentStatus,
|
||||
providerReference,
|
||||
paidAt: new Date().toISOString(),
|
||||
...(paymentMethodTitle ? { paymentMethodTitle } : {}),
|
||||
}),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
// Non-2xx on purpose — lets Stripe's own retry schedule (~3 days)
|
||||
// provide resilience instead of building an internal retry queue.
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
|
||||
const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
|
||||
|
||||
// Fire-and-forget, same reasoning as the checkout route's own send: a
|
||||
// failed confirmation email must never turn an already-successful
|
||||
// payment confirmation into a non-2xx response (that would make Stripe
|
||||
// retry a webhook we've already fully processed). `alreadyProcessed`/
|
||||
// missing `order` means this is a repeat delivery — see confirmPayment.ts's
|
||||
// own comment on why the email must not be sent twice.
|
||||
if (data.order && !data.alreadyProcessed) {
|
||||
void sendConfirmedPaymentEmail(data.order);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isPaymentTestMode } from "../../../../lib/payments";
|
||||
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../../lib/payments/confirmPaymentEmail";
|
||||
import { sendCriticalAlert } from "../../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Test-mode stand-in for the real Stripe webhook — see
|
||||
// spicy-leaping-pizza.md §7. Drives the exact same backend confirm-payment
|
||||
// endpoint the real webhook calls, just without a real Stripe event/
|
||||
// signature (there is none to verify in test mode). Hard-gated: must
|
||||
// 404 whenever PAYMENT_TEST_MODE isn't explicitly on, so this can never
|
||||
// become an unauthenticated "mark any order paid" endpoint in production.
|
||||
export async function POST(request: Request) {
|
||||
if (!isPaymentTestMode) {
|
||||
return NextResponse.json({ ok: false }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const orderId = body?.orderId;
|
||||
const providerReference = body?.providerReference;
|
||||
const paymentStatus = body?.paymentStatus === "failed" ? "failed" : "paid";
|
||||
if (!orderId || !providerReference) {
|
||||
return NextResponse.json({ ok: false, reason: "orderId und providerReference erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ paymentStatus, providerReference, paidAt: new Date().toISOString() }),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("Test-confirm-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
return NextResponse.json({ ok: false, reason: "Backend hat die Testzahlung nicht bestätigt." }, { status: 502 });
|
||||
}
|
||||
|
||||
const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
|
||||
|
||||
// Same email-send as the real webhook route — see its own comment and
|
||||
// confirmPaymentEmail.ts. Reproduces today's "immediate confirmation"
|
||||
// behavior on a test click, exercising the real send path rather than a
|
||||
// separate short-circuit.
|
||||
if (data.order && !data.alreadyProcessed) {
|
||||
void sendConfirmedPaymentEmail(data.order);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { CartItem } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { computeExemptTotals } from "../../lib/vatExemption";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
@@ -31,7 +32,9 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
|
||||
typeof data.shippingCost !== "number" ||
|
||||
typeof data.paymentMethodTitle !== "string" ||
|
||||
(data.discountCode !== null && typeof data.discountCode !== "string") ||
|
||||
typeof data.discountAmount !== "number"
|
||||
typeof data.discountAmount !== "number" ||
|
||||
typeof data.vatExempt !== "boolean" ||
|
||||
typeof data.kleinunternehmer !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,20 +103,42 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
// Displays the *persisted* discount from the snapshot, not a fresh
|
||||
// re-derivation — the purchase already happened, this page is a
|
||||
// Displays the *persisted* discount/shippingCost from the snapshot, not
|
||||
// a fresh re-derivation — the purchase already happened, this page is a
|
||||
// receipt, not a live cart, so it doesn't re-validate the code at all.
|
||||
const { subtotal, totalSavings, total } = computeCartTotals(items, order.shippingCost, {
|
||||
// order.shippingCost is already the actual (possibly de-grossed, if
|
||||
// vatExempt) figure charged at checkout — see api/checkout/route.ts's
|
||||
// own response. `subtotal`/`taxBreakdown` below still need their own
|
||||
// exempt branch, though: computeCartTotals/computeTaxBreakdown build
|
||||
// `subtotal` from each item's *current catalog* gross price via
|
||||
// effectivePrice(), which for an exempt order was never what was
|
||||
// actually charged (the catalog price includes VAT; the exempt order
|
||||
// paid the de-grossed net price instead).
|
||||
const { subtotal: catalogSubtotal, totalSavings, total: catalogTotal } = computeCartTotals(items, order.shippingCost, {
|
||||
type: "fixed",
|
||||
value: order.discountAmount,
|
||||
});
|
||||
const exemptTotals = order.vatExempt
|
||||
? computeExemptTotals(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
grossUnitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
order.shippingCost,
|
||||
defaultTaxRate,
|
||||
order.discountAmount,
|
||||
)
|
||||
: null;
|
||||
const subtotal = exemptTotals?.subtotal ?? catalogSubtotal;
|
||||
const total = exemptTotals?.total ?? catalogTotal;
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
unitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
subtotal,
|
||||
catalogSubtotal,
|
||||
order.discountAmount,
|
||||
order.shippingCost,
|
||||
);
|
||||
@@ -205,7 +230,7 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. {taxRate}% MwSt.</span>
|
||||
{entry.qty} × {formatPrice(unitPrice)} {!order.kleinunternehmer && <span>inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary whitespace-nowrap">
|
||||
@@ -257,7 +282,13 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
{order.kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : order.vatExempt ? (
|
||||
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+33
-13
@@ -19,16 +19,29 @@ export async function generateMetadata({
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post) return { title: "Beitrag nicht gefunden" };
|
||||
|
||||
// Each falls back to the normal field when its SEO override (Posts.ts's
|
||||
// "SEO" collapsible group) is empty — filling those in is optional, a
|
||||
// post already has sensible metadata without them.
|
||||
const title = post.seoTitle || post.title;
|
||||
const description = post.seoDescription || post.excerpt;
|
||||
const image = post.seoImage || post.thumbnail;
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `/blog/${post.slug}` },
|
||||
openGraph: {
|
||||
title: `${post.title} | einfach produktiv.`,
|
||||
description: post.excerpt,
|
||||
title: `${title} | einfach produktiv.`,
|
||||
description,
|
||||
url: `/blog/${post.slug}`,
|
||||
type: "article",
|
||||
images: post.thumbnail ? [{ url: post.thumbnail }] : undefined,
|
||||
images: image ? [{ url: image }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -120,27 +133,34 @@ export default async function BlogDetailPage({
|
||||
{post.relatedProduct?.href && (
|
||||
<Link
|
||||
href={post.relatedProduct.href}
|
||||
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
|
||||
className="group flex items-center gap-4 sm:gap-6 border border-border rounded-md px-5 py-5 sm:px-9 sm:py-7 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image alt="" src={post.relatedProduct.image} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
|
||||
<div className="flex items-end justify-between gap-4 w-full">
|
||||
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
|
||||
so the description wraps at the same point instead of
|
||||
stretching out to fill the space before "Entdecken". */}
|
||||
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
|
||||
{/* Stacked below sm: — the fixed w-[19rem] title column plus
|
||||
"Entdecken" on the same row overflowed a mobile-width
|
||||
card (fixed 2026-07-24). "Entdecken" wraps to its own
|
||||
line with a little space above it; back to the
|
||||
side-by-side row (matching Figma) from sm: up, where
|
||||
there's room for both. */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
|
||||
{/* w-[19rem] (305px) only from sm: — matches Figma's
|
||||
title-col exactly there, so the description wraps at
|
||||
the same point instead of stretching out to fill the
|
||||
space before "Entdecken"; full width below sm:. */}
|
||||
<div className="flex flex-col gap-2 items-start w-full sm:w-[19rem] sm:shrink-0">
|
||||
<p
|
||||
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
|
||||
className="font-semibold text-[1.375rem] text-text-primary sm:whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.relatedProduct.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.description}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap mt-1 sm:mt-0">
|
||||
Entdecken
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
|
||||
@@ -11,6 +11,13 @@ export const metadata: Metadata = {
|
||||
title: "Blog",
|
||||
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
|
||||
alternates: { canonical: "/blog" },
|
||||
openGraph: {
|
||||
title: "Blog | einfach produktiv.",
|
||||
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
|
||||
url: "/blog",
|
||||
type: "website",
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogOverviewPage() {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function CartContent({
|
||||
freeShippingThreshold,
|
||||
shippingSettings,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
showDiscountField,
|
||||
}: {
|
||||
trustBadges: TrustBadge[];
|
||||
@@ -42,6 +43,11 @@ export function CartContent({
|
||||
* override taxRatePercent themselves — see lib/cartTotals.ts's
|
||||
* effectiveTaxRate(). */
|
||||
defaultTaxRate: number;
|
||||
/** §19 UStG — this tenant's company-settings.kleinunternehmer (Payload's
|
||||
* lib/payload.ts's getKleinunternehmer(), same ISR freshness as
|
||||
* defaultTaxRate above). Drops the "inkl. X% MwSt." hints and the VAT
|
||||
* breakdown in favor of the §19 notice below. */
|
||||
kleinunternehmer: boolean;
|
||||
/** Whether Payload currently has at least one active discount code at
|
||||
* all (lib/discountServer.ts's hasActiveDiscountCode()) — no point
|
||||
* showing an open "enter a code" field when nothing could ever validate
|
||||
@@ -208,8 +214,20 @@ export function CartContent({
|
||||
<div key={lineKey} className="w-full">
|
||||
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
|
||||
<div className="relative size-[9.375rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="150px" className="object-cover" />
|
||||
{/* Full-width on mobile (stacked layout) instead of the
|
||||
fixed 150px square — a small square floating above
|
||||
the text looked cramped on a narrow column that has
|
||||
the width to spare; fixed 150px square again from
|
||||
sm: once the row layout kicks in and the image sits
|
||||
beside the text instead. */}
|
||||
<div className="relative w-full aspect-square sm:size-[9.375rem] sm:shrink-0 rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 640px) 150px, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
{discount !== null && (
|
||||
<span className="absolute top-2 left-2 rounded-full bg-brand px-2 py-0.5 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
@@ -236,7 +254,7 @@ export function CartContent({
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
|
||||
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +431,11 @@ export function CartContent({
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
{kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -30,7 +30,7 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
|
||||
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
|
||||
}
|
||||
|
||||
export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number }) {
|
||||
export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultTaxRate: number; kleinunternehmer: boolean }) {
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
// Cart/checkout resolve any product regardless of `active` (see
|
||||
@@ -188,7 +188,7 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
{/* Always rendered, text conditional — min-h reserves this
|
||||
line's height in both states so cards in the same row
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@ import { CartContent } from "./components/CartContent";
|
||||
import { RelatedProducts } from "./components/RelatedProducts";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
|
||||
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { hasActiveDiscountCode } from "../lib/discountServer";
|
||||
|
||||
// robots: noindex — transactional page (mirrors a specific shopper's cart
|
||||
@@ -20,11 +20,12 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function CartPage() {
|
||||
const [trustBadges, shippingMethods, shipping, defaultTaxRate, showDiscountField] = await Promise.all([
|
||||
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField] = await Promise.all([
|
||||
getCartTrustBadges(),
|
||||
getShippingMethods(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
hasActiveDiscountCode(),
|
||||
]);
|
||||
|
||||
@@ -55,10 +56,11 @@ export default async function CartPage() {
|
||||
freeShippingThreshold={freeShippingThreshold}
|
||||
shippingSettings={shipping}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
showDiscountField={showDiscountField}
|
||||
/>
|
||||
</Suspense>
|
||||
<RelatedProducts defaultTaxRate={defaultTaxRate} />
|
||||
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useNewsletterSignup } from "../../lib/useNewsletterSignup";
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
|
||||
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("challenge");
|
||||
|
||||
if (status === "success") {
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 w-full">
|
||||
{/* Stacked full-width below sm: — side by side, the button's own
|
||||
content width plus the input's min-w-0 squeeze left it cramped
|
||||
on a narrow phone. Default align-items: stretch in flex-col
|
||||
mode is what makes both the input and the button (shrink-0,
|
||||
fixed to its label's width) fill the row once stacked, no
|
||||
explicit w-full needed on either. */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`flex-1 min-w-0 bg-white border rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-[#d9d9d9] focus:border-[#f6a701]"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2 disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
{status === "submitting" ? "Wird gesendet…" : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{emailError && <p className="text-[0.8rem] text-red-600">{emailError}</p>}
|
||||
{/* Consent checkbox — this signup's legal basis is consent (email
|
||||
marketing), same wording as the other newsletter forms; colors
|
||||
match this page's own hardcoded palette instead of the shared
|
||||
design tokens, consistent with the rest of the page. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[#444] leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-[#f6a701]"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "error" && <p className="text-[0.8rem] text-red-600">{error}</p>}
|
||||
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+26
-74
@@ -4,9 +4,11 @@ import Image from "next/image";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { StepArrow } from "../components/StepArrow";
|
||||
import { TestimonialsGrid } from "../components/TestimonialsGrid";
|
||||
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { EmailCapture } from "./components/EmailCapture";
|
||||
|
||||
const title = "7-Tage-Challenge – Mehr Klarheit in 7 Tagen";
|
||||
const description =
|
||||
@@ -76,21 +78,12 @@ function IconCheckCircle() {
|
||||
|
||||
function Check() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-0.5">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
|
||||
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: <IconEnvelope />,
|
||||
@@ -122,52 +115,6 @@ const benefits = [
|
||||
{ title: "Gelassener leben", desc: "Weniger Stress, mehr Zeit für die Dinge, die dir wichtig sind." },
|
||||
];
|
||||
|
||||
function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex gap-3 w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="flex-1 min-w-0 bg-white border border-[#d9d9d9] rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none focus:border-[#f6a701] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/* Consent checkbox — this signup's legal basis is consent (email
|
||||
marketing), same wording as the other newsletter forms; colors
|
||||
match this page's own hardcoded palette instead of the shared
|
||||
design tokens, consistent with the rest of the page. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[#444] leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-[#f6a701]"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ChallengePage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const testimonials = await getTestimonials("challenge", { draft: isPreview });
|
||||
@@ -285,32 +232,34 @@ export default async function ChallengePage() {
|
||||
<p className="text-[1rem] text-[#666]">Jeden Tag ein Impuls. In nur wenigen Minuten.</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col lg:flex-row items-start lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{/* items-center below lg: (was items-start) — the step blocks
|
||||
are centered columns now (see RevealItem below), so the
|
||||
connector arrows between them need to be centered too,
|
||||
not flush against the left edge. */}
|
||||
<RevealGroup className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{steps.flatMap((step, i) => [
|
||||
<RevealItem key={step.title} className="group flex lg:flex-col items-start lg:items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
// Icon-above-text, centered, at every breakpoint now
|
||||
// (previously a left-aligned icon+text row below lg: —
|
||||
// fixed 2026-07-24 to match the lg: layout instead of
|
||||
// diverging from it).
|
||||
<RevealItem key={step.title} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{step.icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 lg:text-center">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-[#222221] text-[1rem]">{step.title}</p>
|
||||
<p className="text-[0.875rem] text-[#666] leading-[1.5]">{step.desc}</p>
|
||||
</div>
|
||||
</RevealItem>,
|
||||
i < steps.length - 1 ? (
|
||||
// Same /icon-arrow-connector.svg asset and rotate-on-stack
|
||||
// pattern as todo-cards/newsletter's HowItWorks — this
|
||||
// used to be its own hand-drawn SVG arrow, inconsistent
|
||||
// with those two. Always visible (rotated 90° while
|
||||
// stacked below lg, this page's own structural
|
||||
// breakpoint) rather than hidden below lg like before.
|
||||
// Shared StepArrow component (see its own file) — same
|
||||
// rotate-on-stack pattern as todo-cards/newsletter's
|
||||
// HowItWorks. Always visible (rotated 90° while stacked
|
||||
// below lg, this page's own structural breakpoint) rather
|
||||
// than hidden below lg like before. Bigger below lg:
|
||||
// (w-8 h-8, was w-6 h-6) per explicit feedback.
|
||||
<div key={`arrow-${i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-arrow-connector.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6 rotate-90 lg:w-10 lg:h-3 lg:rotate-0"
|
||||
/>
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
@@ -380,8 +329,11 @@ export default async function ChallengePage() {
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto">
|
||||
<Reveal className="bg-[#f8f3ec] rounded-xl flex flex-col lg:flex-row gap-8 lg:gap-[3.5rem] items-start lg:items-center px-6 lg:px-10 py-8">
|
||||
|
||||
{/* Left: icon + copy */}
|
||||
<div className="flex gap-5 items-start flex-1 min-w-0">
|
||||
{/* Left: icon + copy — icon above text, centered, below lg:
|
||||
(matches the Home Newsletter card's icon-above-text
|
||||
pattern), row layout again from lg: up alongside the
|
||||
outer Reveal's own flex-col -> lg:flex-row switch. */}
|
||||
<div className="flex flex-col items-center text-center gap-5 lg:flex-row lg:items-start lg:text-left flex-1 min-w-0">
|
||||
<div className="shrink-0 -rotate-4">
|
||||
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
|
||||
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -14,51 +14,103 @@ import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
import { PaymentStep } from "./PaymentStep";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import type { ShippingMethod, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { validateEmailFormat } from "../../lib/email";
|
||||
import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
import { groupPaymentMethodsForCheckout } from "../../lib/payload";
|
||||
import type { CustomerProfile } from "../../lib/customerAuth";
|
||||
|
||||
// Native HTML5 pattern validation (instant, no round-trip) mirroring the
|
||||
// same rules Orders.ts/Customers.ts enforce server-side — a plausibility
|
||||
// check, not the source of truth (the backend re-validates regardless of
|
||||
// what a customer's browser did or didn't catch). Only Germany/Austria/
|
||||
// Switzerland are offered in the country <select>, so a fixed 3-way
|
||||
// lookup is enough — unlike the backend's own zip check, which stays
|
||||
// free-text-country-tolerant since Payload's admin has no such select.
|
||||
const PLZ_DIGITS: Record<string, number> = { Deutschland: 5, Österreich: 4, Schweiz: 4 };
|
||||
function plzPattern(country: string): string {
|
||||
const digits = PLZ_DIGITS[country] ?? 4;
|
||||
// what a customer's browser did or didn't catch). `plzDigitsMap` comes
|
||||
// from Payload's shipping-countries collection (see lib/payload.ts's
|
||||
// getShippingCountries()) — which countries are offered, and how many PLZ
|
||||
// digits each expects, is admin-configurable now, not a hardcoded array
|
||||
// here. `?? 4` only matters if a country somehow isn't in the map at all
|
||||
// (shouldn't happen — the <select> options are built from the same list).
|
||||
function plzPattern(country: string, plzDigitsMap: Record<string, number>): string {
|
||||
const digits = plzDigitsMap[country] ?? 4;
|
||||
return `\\d{${digits}}`;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
wrapperClassName = "flex-1 min-w-0",
|
||||
...props
|
||||
}: { label: string; wrapperClassName?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
// Same rules as the pattern/required attributes each field already
|
||||
// carries (and what Orders.ts/Customers.ts re-enforce server-side) — this
|
||||
// is the plausibility check surfaced immediately on blur, not a second
|
||||
// source of truth. Returns "" for valid, an error message otherwise.
|
||||
function validateRequired(label: string, value: string): string {
|
||||
return value.trim() ? "" : `${label} ist erforderlich.`;
|
||||
}
|
||||
|
||||
function validateZip(value: string, country: string, plzDigitsMap: Record<string, number>): string {
|
||||
if (!value.trim()) return "PLZ ist erforderlich.";
|
||||
const digits = plzDigitsMap[country] ?? 4;
|
||||
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
|
||||
}
|
||||
|
||||
function validatePackstationNumber(value: string): string {
|
||||
if (!value.trim()) return "Packstationsnummer ist erforderlich.";
|
||||
return /^\d{1,3}$/.test(value) ? "" : "Packstationsnummer muss aus 1 bis 3 Ziffern bestehen.";
|
||||
}
|
||||
|
||||
function validatePostNumber(value: string): string {
|
||||
if (!value.trim()) return "Postnummer ist erforderlich.";
|
||||
return /^\d{6,10}$/.test(value) ? "" : "Postnummer muss aus 6 bis 10 Ziffern bestehen.";
|
||||
}
|
||||
|
||||
// Optional field — "" (valid) whenever empty, only format-checked once
|
||||
// something's actually been typed, same "never required by the other"
|
||||
// reasoning as the checkout body's own companyName/vatId handling.
|
||||
function validateVatIdFormat(value: string): string {
|
||||
if (!value.trim()) return "";
|
||||
return isValidVatId(normalizeVatId(value)) ? "" : "Ungültiges USt-IdNr.-Format (z. B. DE123456789).";
|
||||
}
|
||||
|
||||
// forwardRef so callers can imperatively .focus() a specific field (see
|
||||
// the "Andere E-Mail-Adresse verwenden" handler below) — plain props
|
||||
// couldn't do that, and every other FormField usage is unaffected since
|
||||
// ref is optional.
|
||||
const FormField = forwardRef<
|
||||
HTMLInputElement,
|
||||
{ label: string; wrapperClassName?: string; error?: string } & React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(function FormField({ label, wrapperClassName = "flex-1 min-w-0", error, ...props }, ref) {
|
||||
return (
|
||||
<label className={`flex flex-col gap-2 items-start ${wrapperClassName}`}>
|
||||
<span className="text-label text-text-muted">{label}</span>
|
||||
<input
|
||||
{...props}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
ref={ref}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
|
||||
error ? "border-red-600" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{error && <span className="text-label text-red-600">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function CheckoutContent({
|
||||
shippingMethods,
|
||||
shippingCountries,
|
||||
paymentMethods,
|
||||
trustBadges,
|
||||
shippingSettings,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
customerEmail,
|
||||
savedProfile,
|
||||
}: {
|
||||
shippingMethods: ShippingMethod[];
|
||||
/** Which countries the "Land" <select>s offer, and each one's PLZ digit
|
||||
* count — admin-configurable (Payload's shipping-countries collection),
|
||||
* not a hardcoded array here anymore. */
|
||||
shippingCountries: ShippingCountry[];
|
||||
paymentMethods: PaymentMethod[];
|
||||
trustBadges: TrustBadge[];
|
||||
/** Delivery-time disclosure (Payload's Shipping Settings) — named
|
||||
@@ -67,6 +119,11 @@ export function CheckoutContent({
|
||||
shippingSettings: ShippingSettings;
|
||||
/** Tenant's default VAT rate, same role as CartContent's own prop. */
|
||||
defaultTaxRate: number;
|
||||
/** §19 UStG — same role as CartContent's own prop. Also forces the VIES
|
||||
* exemption preview off below: a Kleinunternehmer never charges VAT on
|
||||
* anything, domestic or cross-border, so there's nothing left for the
|
||||
* intra-community exemption to zero-rate. */
|
||||
kleinunternehmer: boolean;
|
||||
/** From the checkout page's own session read (app/lib/customerAuth.ts) —
|
||||
* null means no account is logged in yet, which flips "1. Rechnungsadresse"
|
||||
* into inline-registration mode (password field shown, account created on
|
||||
@@ -80,18 +137,88 @@ export function CheckoutContent({
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
const discount = useDiscount();
|
||||
// { "Deutschland": 5, "Österreich": 4, ... } — built once from the
|
||||
// fetched list rather than re-deriving it inline at every plzPattern()/
|
||||
// validateZip() call site.
|
||||
const plzDigitsMap: Record<string, number> = Object.fromEntries(shippingCountries.map((c) => [c.name, c.plzDigits]));
|
||||
const [shippingMethodId, setShippingMethodId] = useState<number | null>(shippingMethods[0]?.id ?? null);
|
||||
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(paymentMethods[0]?.id ?? null);
|
||||
// Kreditkarte/PayPal collapse into one "Online-Zahlung" option here —
|
||||
// see groupPaymentMethodsForCheckout's own comment for why. The
|
||||
// resulting id is still a real payment-methods row id, so everything
|
||||
// downstream (submission, sessionStorage draft restore) is unaffected.
|
||||
const paymentOptions = useMemo(() => groupPaymentMethodsForCheckout(paymentMethods), [paymentMethods]);
|
||||
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(paymentOptions[0]?.id ?? null);
|
||||
const [versandOpen, setVersandOpen] = useState(false);
|
||||
const [purchaseError, setPurchaseError] = useState<string | null>(null);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
// Set once /api/checkout returns `requiresPayment: true` (Kreditkarte/
|
||||
// PayPal) — see spicy-leaping-pizza.md §3. Replaces the form with
|
||||
// PaymentStep instead of navigating away immediately, since the order
|
||||
// isn't actually confirmed yet at this point.
|
||||
const [paymentStep, setPaymentStep] = useState<{
|
||||
clientSecret: string;
|
||||
orderNumber: string;
|
||||
orderId: number;
|
||||
testMode: boolean;
|
||||
providerReference?: string;
|
||||
} | null>(null);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const accountGateRef = useRef<HTMLDivElement>(null);
|
||||
const [loginEmail, setLoginEmail] = useState("");
|
||||
// Scroll target for the submit-time emailExists fallback below — the
|
||||
// common case (blur-triggered, see handleEmailBlur) needs no scroll at
|
||||
// all, since the inline login prompt already renders right where the
|
||||
// shopper is looking (under Card 1's own email field); this ref only
|
||||
// matters if a shopper filled the whole form and hit submit from further
|
||||
// down the page without ever blurring the email field first (e.g.
|
||||
// browser autofill).
|
||||
const loginGateRef = useRef<HTMLDivElement>(null);
|
||||
// Focused after "Andere E-Mail-Adresse verwenden" clears the field below
|
||||
// — the shopper explicitly asked to type a different one, so the cursor
|
||||
// should already be waiting there instead of making them click in.
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const [loginPassword, setLoginPassword] = useState("");
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
|
||||
// Scrolls the inline login prompt into view once it actually exists in
|
||||
// the DOM — can't do this synchronously right where setShowLogin(true)
|
||||
// is called (handleEmailBlur/handleSubmit below): the prompt is
|
||||
// conditionally rendered on `showLogin`, so loginGateRef.current is
|
||||
// still null until after the next render flushes. Effect fires post-
|
||||
// render instead, once the ref is actually attached. A no-op on the
|
||||
// common blur-triggered path in practice — the prompt renders right
|
||||
// under the email field the shopper is already looking at, already in
|
||||
// view — but still correct/harmless there too.
|
||||
useEffect(() => {
|
||||
if (showLogin) loginGateRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [showLogin]);
|
||||
// Per-field inline validation, populated on blur (see each field's own
|
||||
// onBlur below) — surfaces the same plausibility checks the pattern/
|
||||
// required attributes already declare, immediately instead of only at
|
||||
// submit time (native browser validation still applies too, as a
|
||||
// fallback for fields somehow never blurred, e.g. autofill).
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// `refocusEl`, when given, gets focus put right back on it whenever
|
||||
// `message` is non-empty — a field that just failed its own blur
|
||||
// validation keeps the cursor instead of letting focus move on to
|
||||
// wherever the customer tabbed/clicked next, so the correction happens
|
||||
// immediately instead of being left for a submit attempt to catch later.
|
||||
function setFieldError(name: string, message: string, refocusEl?: HTMLInputElement) {
|
||||
setFieldErrors((prev) => {
|
||||
if (!message) {
|
||||
if (!(name in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
}
|
||||
if (prev[name] === message) return prev;
|
||||
return { ...prev, [name]: message };
|
||||
});
|
||||
if (message && refocusEl) {
|
||||
refocusEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Address-card fields — controlled (unlike before) so they can be
|
||||
// persisted via lib/checkoutDraft.ts and restored after navigating away
|
||||
// from /checkout and back. Initial values still come from savedProfile
|
||||
@@ -130,6 +257,14 @@ export function CheckoutContent({
|
||||
const [shippingCity, setShippingCity] = useState("");
|
||||
const [shippingCountry, setShippingCountry] = useState("Deutschland");
|
||||
const [newsletterOptIn, setNewsletterOptIn] = useState(false);
|
||||
// Live VIES status for the USt-IdNr. field — only meaningful once the
|
||||
// goods' destination (shipping override country when set, billing
|
||||
// country otherwise) is Österreich, the one EU-cross-border option this
|
||||
// checkout offers (see lib/vatExemption.ts). "valid" is what actually
|
||||
// drives the exempt-totals preview below; api/checkout/route.ts re-runs
|
||||
// this exact same VIES check server-side at submit time regardless —
|
||||
// this state is a preview, never the source of truth.
|
||||
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
// Flips true only after the hydration effect's setState calls have
|
||||
// actually landed in a render — gates the write-back effect below so it
|
||||
// never fires with the pre-hydration defaults first and briefly
|
||||
@@ -250,6 +385,70 @@ export function CheckoutContent({
|
||||
shipping,
|
||||
);
|
||||
|
||||
// Live preview only — api/checkout/route.ts re-runs the same VIES check
|
||||
// server-side at submit time and is the actual source of truth (see
|
||||
// lib/vatExemption.ts). Destination is the shipping override's country
|
||||
// when set, the billing country otherwise — the exemption depends on
|
||||
// where the goods actually move to, not necessarily the invoice address.
|
||||
const buyerDestinationCountry = destinationCountry(country, hasDifferentShippingAddress, shippingCountry);
|
||||
const vatExemptPreview = !kleinunternehmer && vatIdViesStatus === "valid" && isExemptionEligibleCountry(buyerDestinationCountry);
|
||||
const exemptTotalsPreview = vatExemptPreview
|
||||
? computeExemptTotals(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
grossUnitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
shipping,
|
||||
defaultTaxRate,
|
||||
discountAmount,
|
||||
)
|
||||
: null;
|
||||
const displaySubtotal = exemptTotalsPreview?.subtotal ?? subtotal;
|
||||
const displayShipping = exemptTotalsPreview?.shippingCost ?? shipping;
|
||||
const displayTotal = exemptTotalsPreview?.total ?? total;
|
||||
|
||||
// USt-IdNr. blur — format-checks first (always), then a live VIES lookup
|
||||
// for ANY country, not just Österreich. Two genuinely separate concerns:
|
||||
// whether this is a real, currently-registered VAT ID at all (data
|
||||
// quality — worth knowing regardless of destination, same reasoning as
|
||||
// company-settings.vatId's own VIES check on the backend) vs. whether
|
||||
// *this transaction* qualifies for the cross-border exemption (a
|
||||
// narrower legal question, still gated on isExemptionEligibleCountry()
|
||||
// wherever vatExemptPreview/exemptTotalsPreview are computed below — a
|
||||
// validated German VAT ID never zero-rates a domestic sale).
|
||||
async function handleVatIdBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const input = e.target;
|
||||
const value = input.value;
|
||||
const formatError = validateVatIdFormat(value);
|
||||
setFieldError("vatId", formatError, input);
|
||||
if (!value.trim() || formatError) {
|
||||
setVatIdViesStatus("idle");
|
||||
return;
|
||||
}
|
||||
setVatIdViesStatus("checking");
|
||||
try {
|
||||
const res = await fetch("/api/checkout/validate-vat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ vatId: value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setVatIdViesStatus("unavailable");
|
||||
return;
|
||||
}
|
||||
setVatIdViesStatus(data.valid ? "valid" : "invalid");
|
||||
// Re-focus (no select-all) on an unconfirmed VAT ID so the customer's
|
||||
// attention returns to the field without wiping what they typed.
|
||||
if (!data.valid) {
|
||||
input.focus();
|
||||
}
|
||||
} catch {
|
||||
setVatIdViesStatus("unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
// Logs into an existing account inline, without leaving /checkout —
|
||||
// router.refresh() re-runs the page's Server Component, which re-reads
|
||||
// the now-set session cookie and passes the resolved customerEmail back
|
||||
@@ -261,7 +460,13 @@ export function CheckoutContent({
|
||||
const res = await fetch("/api/account/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: loginEmail, password: loginPassword }),
|
||||
// Card 1's own `email` state, not a separate re-typed value — the
|
||||
// login prompt now renders inline right under that same field (see
|
||||
// "1. Rechnungsadresse" below), so asking for the email a second
|
||||
// time would just be redundant. Whatever's currently in the field
|
||||
// is authoritative; the server rejects it the normal way if it
|
||||
// doesn't match an account.
|
||||
body: JSON.stringify({ email, password: loginPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
@@ -293,6 +498,7 @@ export function CheckoutContent({
|
||||
// in case", only once actually relevant.
|
||||
async function handleEmailBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const email = e.target.value.trim();
|
||||
setFieldError("email", validateEmailFormat(email), e.target);
|
||||
if (!email || customerEmail || showLogin) return;
|
||||
try {
|
||||
const res = await fetch("/api/account/check-email", {
|
||||
@@ -302,7 +508,6 @@ export function CheckoutContent({
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.exists) {
|
||||
setLoginEmail(email);
|
||||
setShowLogin(true);
|
||||
}
|
||||
} catch {
|
||||
@@ -366,13 +571,15 @@ export function CheckoutContent({
|
||||
if (!data.ok) {
|
||||
// The email typed into Card 1 already belongs to an existing
|
||||
// account — registering was never going to work here. Switch
|
||||
// straight to the login toggle with that email pre-filled instead
|
||||
// of just showing an error with no clear next step; the customer
|
||||
// only needs to add their password and resubmit.
|
||||
// straight to the login prompt instead of just showing an error
|
||||
// with no clear next step; the customer only needs to add their
|
||||
// password and resubmit. The scroll-into-view (for a shopper who
|
||||
// filled the whole form and hit submit without ever blurring the
|
||||
// email field, e.g. autofill) happens in the loginGateRef effect
|
||||
// below, not here — this div doesn't exist in the DOM yet at this
|
||||
// exact point, `showLogin` only flips it in after the next render.
|
||||
if (data.emailExists) {
|
||||
setLoginEmail(body.email);
|
||||
setShowLogin(true);
|
||||
accountGateRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
setPurchaseError(data.reason || "Die Bestellung konnte nicht abgeschlossen werden.");
|
||||
setPurchasing(false);
|
||||
@@ -387,7 +594,35 @@ export function CheckoutContent({
|
||||
paymentMethodTitle: data.paymentMethodTitle,
|
||||
discountCode: data.discountCode,
|
||||
discountAmount: data.discountAmount,
|
||||
vatExempt: Boolean(data.vatExempt),
|
||||
kleinunternehmer: Boolean(data.kleinunternehmer),
|
||||
};
|
||||
|
||||
if (data.requiresPayment) {
|
||||
// Order exists in Payload now (status 'pending_payment'), but
|
||||
// nothing is confirmed yet — the sessionStorage snapshot, cart
|
||||
// clear, and navigation to /bestellbestaetigung all wait for
|
||||
// /checkout/verarbeitung to see a confirmed payment (see that
|
||||
// page's own comment). A cancelled/failed payment must leave the
|
||||
// cart intact so the customer can just retry.
|
||||
try {
|
||||
window.sessionStorage.setItem(PENDING_ORDER_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// Same private-browsing fallback as the confirmed-order path
|
||||
// below — /checkout/verarbeitung falls back to its own empty
|
||||
// state if this didn't persist.
|
||||
}
|
||||
setPaymentStep({
|
||||
clientSecret: data.clientSecret,
|
||||
orderNumber: data.orderNumber,
|
||||
orderId: data.orderId,
|
||||
testMode: Boolean(data.testMode),
|
||||
providerReference: data.providerReference,
|
||||
});
|
||||
setPurchasing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
@@ -429,6 +664,33 @@ export function CheckoutContent({
|
||||
);
|
||||
}
|
||||
|
||||
// Order already exists in Payload (status 'pending_payment') — this
|
||||
// replaces the address/cart form with the actual payment UI rather than
|
||||
// navigating away, since nothing is confirmed yet. See PaymentStep's own
|
||||
// comment and spicy-leaping-pizza.md §3.
|
||||
if (paymentStep) {
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-8 items-start pt-8 pb-16 px-[var(--layout-padding-x)] w-full max-w-xl mx-auto">
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Zahlung
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Bestellung {paymentStep.orderNumber} wurde angelegt — schließe jetzt die Zahlung ab.
|
||||
</p>
|
||||
<PaymentStep
|
||||
clientSecret={paymentStep.clientSecret}
|
||||
orderNumber={paymentStep.orderNumber}
|
||||
orderId={paymentStep.orderId}
|
||||
testMode={paymentStep.testMode}
|
||||
providerReference={paymentStep.providerReference}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header — breadcrumb, stepper, title */}
|
||||
@@ -453,74 +715,39 @@ export function CheckoutContent({
|
||||
<p className="text-body text-text-muted">Fast geschafft! Nur noch ein paar Angaben.</p>
|
||||
</div>
|
||||
|
||||
{/* Account gate — an account is required to buy. Not a persistent
|
||||
"already a customer? log in" prompt anymore (removed —
|
||||
Nutzer-Entscheidung: don't show it unless actually relevant):
|
||||
stays empty by default, and only shows the login form once
|
||||
handleEmailBlur (Card 1's email field) or handleSubmit's own
|
||||
emailExists fallback actually detects that the typed email
|
||||
belongs to an existing account. Ref used to scroll this into
|
||||
view when that happens after a submit attempt specifically. */}
|
||||
<div ref={accountGateRef} className="w-full">
|
||||
{customerEmail ? (
|
||||
{/* Only the passive "already logged in" state stays up here — the
|
||||
reactive "this email already has an account, please log in"
|
||||
prompt used to render in this same spot too, which meant it
|
||||
could pop in well above wherever the shopper had scrolled to
|
||||
fill in Card 1's email field, mobile especially (no scroll-to
|
||||
was even wired up for the common case — only the submit-time
|
||||
fallback further down had one). Moved inline right under Card
|
||||
1's own email field instead (see "1. Rechnungsadresse" below) —
|
||||
it now appears exactly where the shopper's attention already
|
||||
is, no scrolling needed either way. */}
|
||||
{customerEmail && (
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Eingeloggt als <span className="font-bold">{customerEmail}</span>{" "}
|
||||
<button type="button" onClick={handleLogout} className="underline hover:text-brand transition-colors">
|
||||
Abmelden
|
||||
</button>
|
||||
</p>
|
||||
) : showLogin ? (
|
||||
<div className="flex flex-col gap-3 items-start w-full">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Für diese E-Mail-Adresse existiert bereits ein Konto — bitte einloggen, um fortzufahren.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-end w-full sm:w-auto">
|
||||
<FormField
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
value={loginEmail}
|
||||
onChange={(e) => setLoginEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
wrapperClassName="w-full sm:w-56"
|
||||
/>
|
||||
<FormField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
wrapperClassName="w-full sm:w-56"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
disabled={loggingIn}
|
||||
className={`px-5 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${loggingIn ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loggingIn ? "…" : "Einloggen"}
|
||||
</button>
|
||||
</div>
|
||||
{loginError && <p className="text-label text-red-600 w-full">{loginError}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(false);
|
||||
setLoginError(null);
|
||||
}}
|
||||
className="text-label text-text-muted underline hover:text-text-primary transition-colors"
|
||||
>
|
||||
Andere E-Mail-Adresse verwenden
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
{/* Form column */}
|
||||
<div className="w-full lg:flex-1 flex flex-col gap-6 items-start min-w-0">
|
||||
{/* 1. Rechnungsadresse */}
|
||||
<Reveal className="w-full bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
{/* border-[#c4b8a0], not the shared border-border (#e5e0d8) — that
|
||||
token is nearly the same luminance as this card's own bg-bg-base
|
||||
background (#f8f5f1), so the card's outline barely read at all
|
||||
(fixed 2026-07-24, feedback: "die Striche bei den Steps sind zu
|
||||
hell"). Darker but still on the warm-cream palette, scoped to
|
||||
just these 4 step cards rather than the shared token, since
|
||||
border-border reads fine everywhere else it's paired with a
|
||||
genuinely different background. */}
|
||||
<Reveal className="w-full bg-bg-base border border-[#c4b8a0] rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -528,8 +755,30 @@ export function CheckoutContent({
|
||||
1. Rechnungsadresse
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<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 />
|
||||
<FormField
|
||||
label="Vorname"
|
||||
name="firstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onBlur={(e) => setFieldError("firstName", validateRequired("Vorname", e.target.value), e.target)}
|
||||
error={fieldErrors.firstName}
|
||||
placeholder="Max"
|
||||
autoComplete="given-name"
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Nachname"
|
||||
name="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onBlur={(e) => setFieldError("lastName", validateRequired("Nachname", e.target.value), e.target)}
|
||||
error={fieldErrors.lastName}
|
||||
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
|
||||
@@ -545,22 +794,52 @@ export function CheckoutContent({
|
||||
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 className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<FormField
|
||||
label="USt-IdNr. (optional)"
|
||||
name="vatId"
|
||||
type="text"
|
||||
value={vatId}
|
||||
onChange={(e) => {
|
||||
setVatId(e.target.value);
|
||||
setVatIdViesStatus("idle");
|
||||
}}
|
||||
onBlur={handleVatIdBlur}
|
||||
error={fieldErrors.vatId}
|
||||
placeholder="DE123456789"
|
||||
autoComplete="off"
|
||||
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
|
||||
maxLength={14}
|
||||
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
{/* Shown for any country — validity is worth confirming
|
||||
regardless of destination (data quality: is this VAT ID
|
||||
even real). Only the "valid" message's wording differs
|
||||
by destination: Österreich additionally gets the
|
||||
exemption note, Deutschland/Schweiz just get a plain
|
||||
confirmation, since the exemption never applies there
|
||||
even for a genuinely valid VAT ID. */}
|
||||
<p className="text-label text-text-muted min-h-[1.05rem]">
|
||||
{vatIdViesStatus === "checking" && "USt-IdNr. wird geprüft…"}
|
||||
{vatIdViesStatus === "valid" && (
|
||||
<span className="text-success">
|
||||
✓ USt-IdNr. bestätigt
|
||||
{isExemptionEligibleCountry(buyerDestinationCountry) ? " — Lieferung wird steuerfrei berechnet." : "."}
|
||||
</span>
|
||||
)}
|
||||
{vatIdViesStatus === "invalid" && (
|
||||
<span className="text-red-600">USt-IdNr. konnte nicht bestätigt werden.</span>
|
||||
)}
|
||||
{vatIdViesStatus === "unavailable" && "USt-IdNr.-Prüfung derzeit nicht möglich."}
|
||||
</p>
|
||||
</div>
|
||||
</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. */}
|
||||
<FormField
|
||||
ref={emailInputRef}
|
||||
label="E-Mail-Adresse"
|
||||
name="email"
|
||||
type="email"
|
||||
@@ -570,11 +849,66 @@ export function CheckoutContent({
|
||||
autoComplete="email"
|
||||
required
|
||||
onBlur={handleEmailBlur}
|
||||
error={fieldErrors.email}
|
||||
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
|
||||
/>
|
||||
{/* Only needed for the inline-registration path — an existing
|
||||
session already has an account, no password to collect. */}
|
||||
{!customerEmail && (
|
||||
{/* Not needed once already logged in (existing session, no
|
||||
password to collect) — and swapped for the inline login
|
||||
prompt below instead of the "create a new account" password
|
||||
field the moment handleEmailBlur/handleSubmit's own
|
||||
emailExists fallback detects the typed email already
|
||||
belongs to an account. Rendered right under the email field
|
||||
itself (previously a separate block all the way at the top
|
||||
of the page, above the form — easy to lose track of once
|
||||
scrolled down to fill in Card 1, especially on mobile,
|
||||
since nothing auto-scrolled to it on the common
|
||||
blur-triggered path either). */}
|
||||
{!customerEmail && (showLogin ? (
|
||||
<div ref={loginGateRef} className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
|
||||
<p className="text-label text-text-muted">
|
||||
Für diese E-Mail-Adresse existiert bereits ein Konto.
|
||||
</p>
|
||||
<FormField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
disabled={loggingIn}
|
||||
className={`px-5 py-2.5 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${loggingIn ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loggingIn ? "…" : "Einloggen"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(false);
|
||||
setLoginError(null);
|
||||
// Clears Card 1's own email field too, not just the
|
||||
// login prompt — leaving the already-registered
|
||||
// address sitting there meant the shopper had to
|
||||
// manually select/delete it before typing a new one.
|
||||
// Password cleared alongside it since it was only
|
||||
// ever meaningful for the email that's now gone.
|
||||
setEmail("");
|
||||
setLoginPassword("");
|
||||
setFieldError("email", "");
|
||||
emailInputRef.current?.focus();
|
||||
}}
|
||||
className="text-label text-text-muted underline hover:text-text-primary transition-colors"
|
||||
>
|
||||
Andere E-Mail-Adresse verwenden
|
||||
</button>
|
||||
</div>
|
||||
{loginError && <p className="text-label text-red-600">{loginError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
|
||||
<FormField
|
||||
label="Passwort (für dein neues Konto)"
|
||||
@@ -584,6 +918,27 @@ export function CheckoutContent({
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
// No refocusEl (3rd arg) here, unlike this form's other
|
||||
// blur-validated fields — this field is at the end of the
|
||||
// "2. Kontodaten" card, right before Card 3+ and the
|
||||
// submit button further down the page; forcing focus back
|
||||
// into it on every blur while <8 chars meant a shopper
|
||||
// could never Tab/click past it (or reach the submit
|
||||
// button at all) until the password was already valid —
|
||||
// effectively trapped. The inline red error text below
|
||||
// still appears immediately either way; only the forced
|
||||
// refocus is dropped.
|
||||
onBlur={(e) =>
|
||||
setFieldError(
|
||||
"password",
|
||||
!e.target.value
|
||||
? "Passwort ist erforderlich."
|
||||
: e.target.value.length < 8
|
||||
? "Passwort muss mindestens 8 Zeichen lang sein."
|
||||
: "",
|
||||
)
|
||||
}
|
||||
error={fieldErrors.password}
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
<p className="text-label text-text-muted">
|
||||
@@ -591,7 +946,7 @@ export function CheckoutContent({
|
||||
einsehen und bei Bedarf stornieren oder zurücksenden kannst.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
{/* Always a plain street address — Packstation isn't a valid
|
||||
Rechnungsadresse (an invoice needs a real postal address).
|
||||
Packstation is only ever offered below, in the optional
|
||||
@@ -602,6 +957,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
error={fieldErrors.street}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="street-address"
|
||||
required
|
||||
@@ -614,14 +971,28 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
onBlur={(e) => setFieldError("zip", validateZip(e.target.value, country, plzDigitsMap), e.target)}
|
||||
error={fieldErrors.zip}
|
||||
placeholder="10115"
|
||||
autoComplete="postal-code"
|
||||
inputMode="numeric"
|
||||
pattern={plzPattern(country)}
|
||||
title={`PLZ muss aus ${PLZ_DIGITS[country] ?? 4} Ziffern bestehen.`}
|
||||
pattern={plzPattern(country, plzDigitsMap)}
|
||||
maxLength={plzDigitsMap[country] ?? 4}
|
||||
title={`PLZ muss aus ${plzDigitsMap[country] ?? 4} Ziffern bestehen.`}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Ort"
|
||||
name="city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
onBlur={(e) => setFieldError("city", validateRequired("Ort", e.target.value), e.target)}
|
||||
error={fieldErrors.city}
|
||||
placeholder="Berlin"
|
||||
autoComplete="address-level2"
|
||||
required
|
||||
/>
|
||||
<FormField label="Ort" name="city" type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required />
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
@@ -632,9 +1003,9 @@ export function CheckoutContent({
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
<option>Deutschland</option>
|
||||
<option>Österreich</option>
|
||||
<option>Schweiz</option>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -659,6 +1030,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingFirstName}
|
||||
onChange={(e) => setShippingFirstName(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingFirstName", validateRequired("Vorname", e.target.value), e.target)}
|
||||
error={fieldErrors.shippingFirstName}
|
||||
placeholder="Max"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -668,6 +1041,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingLastName}
|
||||
onChange={(e) => setShippingLastName(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingLastName", validateRequired("Nachname", e.target.value), e.target)}
|
||||
error={fieldErrors.shippingLastName}
|
||||
placeholder="Mustermann"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -708,6 +1083,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingStreet}
|
||||
onChange={(e) => setShippingStreet(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
error={fieldErrors.shippingStreet}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -720,6 +1097,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingPackstationNumber}
|
||||
onChange={(e) => setShippingPackstationNumber(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingPackstationNumber", validatePackstationNumber(e.target.value), e.target)}
|
||||
error={fieldErrors.shippingPackstationNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="123"
|
||||
autoComplete="off"
|
||||
@@ -733,6 +1112,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value), e.target)}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
@@ -749,11 +1130,14 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingZip}
|
||||
onChange={(e) => setShippingZip(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingZip", validateZip(e.target.value, shippingCountry, plzDigitsMap), e.target)}
|
||||
error={fieldErrors.shippingZip}
|
||||
placeholder="10115"
|
||||
autoComplete="off"
|
||||
inputMode="numeric"
|
||||
pattern={plzPattern(shippingCountry)}
|
||||
title={`PLZ muss aus ${PLZ_DIGITS[shippingCountry] ?? 4} Ziffern bestehen.`}
|
||||
pattern={plzPattern(shippingCountry, plzDigitsMap)}
|
||||
maxLength={plzDigitsMap[shippingCountry] ?? 4}
|
||||
title={`PLZ muss aus ${plzDigitsMap[shippingCountry] ?? 4} Ziffern bestehen.`}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
@@ -761,6 +1145,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingCity}
|
||||
onChange={(e) => setShippingCity(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingCity", validateRequired("Ort", e.target.value), e.target)}
|
||||
error={fieldErrors.shippingCity}
|
||||
placeholder="Berlin"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -774,9 +1160,9 @@ export function CheckoutContent({
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
<option>Deutschland</option>
|
||||
<option>Österreich</option>
|
||||
<option>Schweiz</option>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -800,7 +1186,7 @@ export function CheckoutContent({
|
||||
</Reveal>
|
||||
|
||||
{/* 2. Versandart */}
|
||||
<Reveal delay={0.05} className="w-full bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
<Reveal delay={0.05} className="w-full bg-bg-base border border-[#c4b8a0] rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -831,7 +1217,7 @@ export function CheckoutContent({
|
||||
</Reveal>
|
||||
|
||||
{/* 3. Zahlungsart */}
|
||||
<Reveal delay={0.1} className="w-full bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
<Reveal delay={0.1} className="w-full bg-bg-base border border-[#c4b8a0] rounded-md p-7 flex flex-col gap-5 items-start">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -839,23 +1225,26 @@ export function CheckoutContent({
|
||||
3. Zahlungsart
|
||||
</p>
|
||||
|
||||
{paymentMethods.map((method) => (
|
||||
<label key={method.id} className="flex items-center gap-3 w-full cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethodId === method.id}
|
||||
onChange={() => setPaymentMethodId(method.id)}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
{method.icons.map((icon, i) => (
|
||||
<div key={i} className="relative h-5 w-8 shrink-0">
|
||||
<Image src={icon} alt="" fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
))}
|
||||
{paymentOptions.map((method) => (
|
||||
<label key={method.id} className="flex flex-col gap-1 w-full cursor-pointer">
|
||||
<span className="flex items-center gap-3 w-full">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentMethodId === method.id}
|
||||
onChange={() => setPaymentMethodId(method.id)}
|
||||
className="size-5 shrink-0 accent-brand"
|
||||
/>
|
||||
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
{method.icons.map((icon, i) => (
|
||||
<div key={i} className="relative h-5 w-8 shrink-0">
|
||||
<Image src={icon} alt="" fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
{method.hint && <span className="pl-8 text-label text-text-muted">{method.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -891,7 +1280,7 @@ export function CheckoutContent({
|
||||
|
||||
{/* Sidebar */}
|
||||
<Reveal delay={0.15} className="w-full lg:w-[24.375rem] lg:shrink-0 flex flex-col gap-6 items-start">
|
||||
<div className="bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start w-full">
|
||||
<div className="bg-bg-base border border-[#c4b8a0] rounded-md p-7 flex flex-col gap-5 items-start w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -914,7 +1303,7 @@ export function CheckoutContent({
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. {taxRate}% MwSt.</span>
|
||||
{entry.qty} × {formatPrice(unitPrice)} {!kleinunternehmer && <span>inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary whitespace-nowrap">{formatPrice(entry.qty * unitPrice)}</p>
|
||||
@@ -927,7 +1316,7 @@ export function CheckoutContent({
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Zwischensumme</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(displaySubtotal)}</span>
|
||||
</div>
|
||||
|
||||
{totalSavings > 0 && (
|
||||
@@ -963,7 +1352,7 @@ export function CheckoutContent({
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
@@ -987,9 +1376,15 @@ export function CheckoutContent({
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(displayTotal)}</span>
|
||||
</div>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
{kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : vatExemptPreview ? (
|
||||
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
|
||||
// Loaded once at module scope (not per-render) — same reasoning as any
|
||||
// other client-side SDK singleton. Never called at all in test mode
|
||||
// (mounted conditionally below), so an unset publishable key there is
|
||||
// harmless.
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
function getStripe(): Promise<Stripe | null> {
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
clientSecret: string;
|
||||
orderNumber: string;
|
||||
orderId: number;
|
||||
testMode: boolean;
|
||||
/** Only present in test mode — see api/checkout/route.ts's own comment. */
|
||||
providerReference?: string;
|
||||
};
|
||||
|
||||
// Rendered by CheckoutContent once /api/checkout returns
|
||||
// `requiresPayment: true` (Kreditkarte/PayPal) — see
|
||||
// spicy-leaping-pizza.md §3/§7. The order already exists in Payload at
|
||||
// this point (status 'pending_payment'); this step only collects/confirms
|
||||
// the actual payment, it doesn't create anything.
|
||||
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference }: Props) {
|
||||
if (testMode) {
|
||||
return <TestPaymentButtons orderNumber={orderNumber} orderId={orderId} providerReference={providerReference ?? ""} />;
|
||||
}
|
||||
return (
|
||||
<Elements stripe={getStripe()} options={{ clientSecret }}>
|
||||
<StripePaymentForm orderNumber={orderNumber} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handlePay(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!stripe || !elements) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
// Redirect-based (PayPal always redirects; cards may need a
|
||||
// 3-D-Secure redirect too) — confirmation itself is never trusted
|
||||
// client-side, see /checkout/verarbeitung's own comment. `if_required`
|
||||
// would skip the redirect for methods that don't need one, but the
|
||||
// return_url page's polling handles both cases identically either way,
|
||||
// so there's no benefit to branching here.
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`,
|
||||
},
|
||||
});
|
||||
// Only reached for immediate client-side failures (e.g. invalid card
|
||||
// number) — a redirect on success/pending never returns here at all.
|
||||
if (confirmError) {
|
||||
setError(confirmError.message ?? "Die Zahlung konnte nicht bestätigt werden.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handlePay} className="flex flex-col gap-4">
|
||||
<PaymentElement />
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || submitting}
|
||||
className="rounded-full bg-brand-primary px-6 py-3 text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPaymentButtons({ orderNumber, orderId, providerReference }: { orderNumber: string; orderId: number; providerReference: string }) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function confirm(paymentStatus: "paid" | "failed") {
|
||||
setSubmitting(paymentStatus);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/webhooks/stripe/test-confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderId, providerReference, paymentStatus }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Testzahlung fehlgeschlagen.");
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`);
|
||||
} catch {
|
||||
setError("Testzahlung konnte nicht ausgeführt werden.");
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-amber-500 bg-amber-50 p-4">
|
||||
<p className="text-sm font-semibold text-amber-800">PAYMENT_TEST_MODE aktiv — kein echtes Stripe-Konto verbunden.</p>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirm("paid")}
|
||||
disabled={submitting !== null}
|
||||
className="rounded-full bg-green-600 px-5 py-2 text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting === "paid" ? "Wird bestätigt…" : "Testzahlung erfolgreich"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirm("failed")}
|
||||
disabled={submitting !== null}
|
||||
className="rounded-full bg-red-600 px-5 py-2 text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting === "failed" ? "Wird bestätigt…" : "Testzahlung fehlgeschlagen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import { CheckoutContent } from "./components/CheckoutContent";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getShippingMethods, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
|
||||
import { getShippingMethods, getShippingCountries, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { getSessionCustomer, getCustomerProfile } from "../lib/customerAuth";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /cart.
|
||||
@@ -16,12 +16,14 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function CheckoutPage() {
|
||||
const [shippingMethods, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, session] = await Promise.all([
|
||||
const [shippingMethods, shippingCountries, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, kleinunternehmer, session] = await Promise.all([
|
||||
getShippingMethods(),
|
||||
getShippingCountries(),
|
||||
getPaymentMethods(),
|
||||
getCartTrustBadges(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getSessionCustomer(),
|
||||
]);
|
||||
// Full profile (incl. saved address) only fetched when a session exists
|
||||
@@ -33,10 +35,12 @@ export default async function CheckoutPage() {
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CheckoutContent
|
||||
shippingMethods={shippingMethods}
|
||||
shippingCountries={shippingCountries}
|
||||
paymentMethods={paymentMethods}
|
||||
trustBadges={trustBadges}
|
||||
shippingSettings={shippingSettings}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
customerEmail={session?.customer.email ?? null}
|
||||
savedProfile={profile}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY } from "../../lib/order";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
import { clearDiscount } from "../../lib/discount";
|
||||
import { clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const POLL_TIMEOUT_MS = 15000;
|
||||
|
||||
// The Payment Element's return_url target (see PaymentStep.tsx) — reached
|
||||
// after a card confirms client-side or a PayPal redirect completes.
|
||||
// Neither of those is trustworthy proof of payment on its own (see
|
||||
// spicy-leaping-pizza.md §3's own reasoning: a closed tab mid-PayPal-
|
||||
// redirect looks identical to success from here) — this page polls the
|
||||
// order's actual `paymentStatus`, which only the webhook-driven
|
||||
// confirm-payment endpoint ever sets, and only promotes the pending
|
||||
// sessionStorage snapshot to the confirmed one once that's true.
|
||||
export function VerarbeitungContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const orderNumber = searchParams.get("orderNumber");
|
||||
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
|
||||
const startedAt = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderNumber) return;
|
||||
startedAt.current = Date.now();
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(`/api/checkout/status?orderNumber=${encodeURIComponent(orderNumber!)}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (!data.ok) {
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "paid") {
|
||||
try {
|
||||
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (pending) {
|
||||
// Patch in the real instrument (Kreditkarte/PayPal) now
|
||||
// that it's known — the pending snapshot was written at
|
||||
// checkout submission time with the neutral "Online-
|
||||
// Zahlung" placeholder, before the customer had actually
|
||||
// picked one on the Payment Element.
|
||||
const snapshot = JSON.parse(pending);
|
||||
if (data.paymentMethodTitle) snapshot.paymentMethodTitle = data.paymentMethodTitle;
|
||||
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
|
||||
window.sessionStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Same private-browsing fallback as everywhere else this
|
||||
// sessionStorage snapshot is written — /bestellbestaetigung
|
||||
// has its own empty state.
|
||||
}
|
||||
clearCart();
|
||||
clearDiscount();
|
||||
clearCheckoutDraft();
|
||||
dispatchAuthChanged();
|
||||
router.push("/bestellbestaetigung");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "failed" || data.status === "cancelled") {
|
||||
setState("failed");
|
||||
return;
|
||||
}
|
||||
if (startedAt.current != null && Date.now() - startedAt.current > POLL_TIMEOUT_MS) {
|
||||
setState("timeout");
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
if (!cancelled) setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderNumber]);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col flex-1 items-center justify-center gap-6 py-24 px-[var(--layout-padding-x)] text-center">
|
||||
{state === "polling" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Zahlung wird bestätigt…
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Einen Moment bitte, das dauert normalerweise nur wenige Sekunden.</p>
|
||||
</>
|
||||
)}
|
||||
{state === "timeout" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Das dauert etwas länger
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung wird noch verarbeitet. Sobald sie bestätigt ist, schicken wir dir eine Bestätigungs-E-Mail — du musst hier nicht warten.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === "failed" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Zahlung fehlgeschlagen
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Status konnte nicht geladen werden
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { VerarbeitungContent } from "./VerarbeitungContent";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /checkout itself.
|
||||
export const metadata: Metadata = {
|
||||
title: "Zahlung wird bestätigt",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function VerarbeitungPage() {
|
||||
// useSearchParams (reading ?orderNumber=) requires a Suspense boundary
|
||||
// in the App Router — this page has no meaningful loading state of its
|
||||
// own beyond what VerarbeitungContent already renders.
|
||||
return (
|
||||
<Suspense>
|
||||
<VerarbeitungContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,12 @@ export function LiveCompanySettingsPreviewClient({ initialSettings }: { initialS
|
||||
|
||||
return (
|
||||
<PDFViewer style={{ width: "100%", height: "100vh", border: "none" }}>
|
||||
<InvoiceDocument order={SAMPLE_INVOICE_ORDER} seller={data} />
|
||||
{/* kleinunternehmer isn't part of InvoiceSeller (it's snapshotted
|
||||
per-order, not read live off the seller — see invoicePdf.tsx's
|
||||
own comment) — merged onto the sample order here only, so an
|
||||
admin toggling the checkbox sees the §19 notice reflected live
|
||||
without this preview needing its own separate mechanism. */}
|
||||
<InvoiceDocument order={{ ...SAMPLE_INVOICE_ORDER, kleinunternehmer: data.kleinunternehmer }} seller={data} />
|
||||
</PDFViewer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const FALLBACK: CompanySettings = {
|
||||
sellerEmail: "",
|
||||
vatId: "",
|
||||
taxRatePercent: 19,
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
};
|
||||
|
||||
+30
-16
@@ -6,9 +6,16 @@ export function About() {
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
|
||||
|
||||
{/* Text content — relative + z-10 so it renders above the overlapping
|
||||
photo at md+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake). */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1_0_0] min-w-0 relative z-10">
|
||||
photo at lg+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake).
|
||||
md:flex-[1.4_0_0] lg:flex-[1_0_0] — at Tablet the text column got
|
||||
the narrower 1:1.4 share meant for Desktop's overlap layout,
|
||||
leaving it too cramped for the fixed-width statement + quote/bio
|
||||
row. Widened at Tablet (text gets the bigger share, image the
|
||||
smaller one, no overlap yet) and reverted to the original ratio
|
||||
from lg: up, where the overlap trick actually needs the image to
|
||||
have more room. */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1.4_0_0] lg:flex-[1_0_0] min-w-0 relative z-10">
|
||||
|
||||
{/* Large serif statement — width-constrained as per design */}
|
||||
<p
|
||||
@@ -19,8 +26,13 @@ export function About() {
|
||||
</p>
|
||||
|
||||
{/* Quote row: script quote / divider / author bio — side-by-side
|
||||
from md+, stacked with a horizontal divider below md */}
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6 md:gap-0 w-full">
|
||||
from lg: (was md:) — even with the text column's wider Tablet
|
||||
share above, quote + divider + the whitespace-nowrap bio ("Gründer
|
||||
von einfach-produktiv.") together still needed more room than
|
||||
Tablet's ~384px column has. Stacked with a horizontal divider
|
||||
through the whole Tablet range instead, side-by-side (vertical
|
||||
divider) only once there's real room at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 lg:gap-0 w-full">
|
||||
|
||||
{/* Caveat script text with signature positioned below */}
|
||||
<div className="relative flex-1" style={{ minHeight: "8rem" }}>
|
||||
@@ -51,12 +63,12 @@ export function About() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider — horizontal full-width line below md, vertical
|
||||
gold line beside the author bio from md+ */}
|
||||
<div className="bg-brand w-full h-px md:w-[2px] md:h-24 md:mx-6 shrink-0" />
|
||||
{/* Divider — horizontal full-width line below lg:, vertical
|
||||
gold line beside the author bio from lg: */}
|
||||
<div className="bg-brand w-full h-px lg:w-[2px] lg:h-24 lg:mx-6 shrink-0" />
|
||||
|
||||
<div
|
||||
className="text-bg-white font-normal whitespace-nowrap md:shrink-0"
|
||||
className="text-bg-white font-normal whitespace-nowrap lg:shrink-0"
|
||||
style={{ fontSize: "1rem", lineHeight: "1.5rem" }}
|
||||
>
|
||||
<p>Björn.</p>
|
||||
@@ -68,11 +80,13 @@ export function About() {
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Author photo — overlaps the text column via -ml-48 from md+ only
|
||||
(that overlap trick has nothing to blend into once stacked);
|
||||
plain full-width photo below the text on Mobile. */}
|
||||
{/* Author photo — overlaps the text column via -ml-48 from lg+ only
|
||||
(that overlap trick has nothing to blend into once stacked, and
|
||||
at Tablet it would eat back into the extra width the text column
|
||||
above just gained); plain full-width photo below the text on
|
||||
Mobile, plain side-by-side (no overlap) at Tablet. */}
|
||||
<Reveal
|
||||
className="relative overflow-hidden w-full md:flex-[1.4_0_0] md:-ml-48"
|
||||
className="relative overflow-hidden w-full md:flex-[1_0_0] lg:flex-[1.4_0_0] lg:-ml-48"
|
||||
style={{ minHeight: "14rem" }}
|
||||
delay={0.15}
|
||||
>
|
||||
@@ -80,11 +94,11 @@ export function About() {
|
||||
alt="Björn"
|
||||
src="/about-author.jpg"
|
||||
fill
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
sizes="(min-width: 1024px) 58vw, (min-width: 768px) 42vw, 100vw"
|
||||
className="object-cover object-center pointer-events-none"
|
||||
/>
|
||||
{/* Left gradient: wide enough to cover the text-column overlap — md+ only */}
|
||||
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
{/* Left gradient: wide enough to cover the text-column overlap — lg+ only */}
|
||||
<div className="hidden lg:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
</Reveal>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -128,7 +128,14 @@ export function AddToCartButton({
|
||||
also reserves space for "Ausverkauft"/"Maximale Menge im
|
||||
Warenkorb" — the widest of the four wins regardless of which is
|
||||
showing. */}
|
||||
<span className="relative grid">
|
||||
{/* whitespace-nowrap — inherited by every stacked span below. On a
|
||||
w-full button (e.g. this page's mobile layout), "Maximale Menge
|
||||
im Warenkorb" is long enough to wrap to two lines without this,
|
||||
and since every stacked span shares the same grid cell, that
|
||||
inflated the row height for whichever text is actually showing
|
||||
too — "Ausverkauft" rendered with a tall empty gap underneath it
|
||||
(fixed 2026-07-24). */}
|
||||
<span className="relative grid whitespace-nowrap">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
+10
-4
@@ -63,11 +63,14 @@ export async function Blog() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* flex + arrow as its own span — see Tools.tsx's comment on
|
||||
this same fix (→'s glyph baseline sits low next to text). */}
|
||||
<Link
|
||||
href={featured.href}
|
||||
className="font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
|
||||
className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ Zum Beitrag
|
||||
<span aria-hidden>→</span>
|
||||
<span>Zum Beitrag</span>
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
@@ -109,11 +112,14 @@ export async function Blog() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* flex + arrow as its own span — see the featured post's
|
||||
own Link above / Tools.tsx's comment on this same fix. */}
|
||||
<Link
|
||||
href={post.href}
|
||||
className="font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
className="flex items-center gap-1 font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ Zum Beitrag
|
||||
<span aria-hidden>→</span>
|
||||
<span>Zum Beitrag</span>
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
@@ -20,6 +20,8 @@ function StepCircle({ state, number }: { state: StepState; number: number }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
// Back to the shared border-border (reverted 2026-07-24 per feedback —
|
||||
// only the connector line below should be the darker #c4b8a0).
|
||||
<div className="flex size-9 items-center justify-center rounded-full border border-border font-bold text-body-sm text-text-muted">
|
||||
{number}
|
||||
</div>
|
||||
@@ -50,7 +52,7 @@ export function CheckoutSteps({ current }: { current: number }) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{i < STEP_LABELS.length - 1 && <div className="h-px bg-border flex-1 mx-4 min-w-4" />}
|
||||
{i < STEP_LABELS.length - 1 && <div className="h-px bg-[#c4b8a0] flex-1 mx-4 min-w-4" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Reveal } from "./Reveal";
|
||||
function Word({ children }: { children: string }) {
|
||||
return (
|
||||
<p
|
||||
className="font-bold leading-normal text-text-primary text-h2 whitespace-nowrap"
|
||||
className="font-bold leading-normal text-text-primary text-[length:var(--divider-word-size)] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{children}
|
||||
@@ -28,25 +28,30 @@ export function Divider() {
|
||||
return (
|
||||
<Reveal
|
||||
delay={0.3}
|
||||
className="flex items-center justify-center flex-wrap gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center"
|
||||
className="flex items-center justify-center flex-wrap gap-x-3 sm:gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center"
|
||||
>
|
||||
|
||||
{/* Word + its trailing icon are grouped into one shrink-0 flex unit
|
||||
so flex-wrap only ever breaks BETWEEN pairs, never leaving an
|
||||
arrow stranded alone on its own line — the arrows are always
|
||||
visible now (previously hidden below md: entirely to sidestep
|
||||
that exact problem), this fixes the root cause instead. */}
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
that exact problem), this fixes the root cause instead.
|
||||
gap-3/sm:gap-8 (not a flat gap-8): below 640px the words and
|
||||
icons already shrink via --divider-word-size/--divider-arrow-*
|
||||
(see globals.css), tightening the gaps too is what gets the
|
||||
whole phrase close to fitting on one row instead of each pair
|
||||
wrapping to its own line. */}
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Klarheit</Word>
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Fokus</Word>
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Entlastung</Word>
|
||||
|
||||
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
|
||||
|
||||
@@ -18,8 +18,12 @@ export function Footer() {
|
||||
{/* Footer inner — max-width 1280px, centered */}
|
||||
<div className="flex flex-col items-center w-full max-w-[1280px] py-8 md:py-4">
|
||||
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below md */}
|
||||
<div className="flex flex-col md:flex-row items-center md:justify-between gap-6 md:gap-0 px-8 md:px-16 w-full">
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below
|
||||
lg: (was md:). Logo + handle + 5 legal links all side by side
|
||||
with justify-between read too cramped on Tablet — stacked
|
||||
through that range instead, side by side again once there's
|
||||
real room at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row items-center lg:justify-between gap-6 lg:gap-0 px-8 md:px-16 w-full">
|
||||
|
||||
{/* Logo: "einfach produktiv" white + "." gold */}
|
||||
<div className="flex items-center p-2 shrink-0">
|
||||
|
||||
+97
-57
@@ -2,71 +2,118 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { PopIn, Reveal } from "./Reveal";
|
||||
|
||||
// Shared between the plain (below lg:) and Reveal-wrapped (lg:+) render —
|
||||
// see the two call sites' own comment on why this needs two wrappers.
|
||||
function HeroImage() {
|
||||
return (
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
WebkitMaskComposite: "destination-in",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Structural breakpoint is lg: (1024px) here, not the site-wide md:
|
||||
(768px) — a documented exception (see Gotcha in the figma-to-nextjs
|
||||
skill). At md:col-span-5 the text column was only ~320px at
|
||||
768-1023px viewports, too narrow for the heading/CTA/social-proof
|
||||
row (which wrapped to 3 cramped lines). Staying stacked full-width
|
||||
through the whole Tablet range and only splitting into the 5/7
|
||||
grid once there's real room (≥1024px) fixes that without touching
|
||||
the 5/7 ratio itself, which is fine once it has space. */}
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 lg:items-center gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12 lg:pt-0">
|
||||
{/* Structural breakpoint is md: (768px) for the GRID only — the text
|
||||
column stays ~283-320px wide through the whole 768-1023px Tablet
|
||||
range regardless. Below, every piece of *content* inside the text
|
||||
column (heading/subtitle/CTA/social-proof) keeps its smaller,
|
||||
fixed-below-lg: sizing all the way through Tablet too, not just
|
||||
true Mobile — reusing the full fluid-token sizes at md: (as a
|
||||
first pass 2026-07-24 briefly did) put the original ~19-44px
|
||||
fluid floors right back in that narrow column, recreating the
|
||||
exact 3-line-wrap problem the old `lg:` structural exception
|
||||
existed to avoid. Splitting "grid at md:" from "full-size content
|
||||
at lg:" gets both: Tablet shows the real 5/7 grid, but with
|
||||
content sized for its column's actual width, not the column
|
||||
width `lg:` was designed for. */}
|
||||
<div className="flex flex-col md:grid md:grid-cols-12 md:items-center gap-8 md:gap-[var(--layout-grid-gap)] pt-10 md:pt-0">
|
||||
|
||||
{/* Text content — first in DOM/visual order at every breakpoint so
|
||||
the CTA stays above the fold on Mobile (deliberate exception to
|
||||
the "keep DOM order" default, see Hero decision in the plan).
|
||||
Reveal fires ~immediately since Hero is already in the initial
|
||||
viewport — this doubles as the page's entrance animation. */}
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
<Reveal className="order-1 md:order-none md:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 md:pr-0">
|
||||
|
||||
{/* Heading — forced break after "darf" below lg: (1024px),
|
||||
natural wrap from lg: up. Below lg: the Hero is stacked
|
||||
full-width and narrower per-viewport, where natural wrap
|
||||
produced an awkward break — force it after "darf" there via
|
||||
a responsive <br/> (visible by default, turned off at lg:+).
|
||||
From lg: up the 5/12 grid's text column wraps fine on its
|
||||
own, no forced break needed. */}
|
||||
{/* Heading — smaller fixed-ish size below lg: (text-h1, still a
|
||||
real paired font-size+line-height token, not an arbitrary
|
||||
value) — text-display's own 44px floor wraps very heavily in
|
||||
a ~283-320px Tablet column (even a single word can approach
|
||||
that width). Forced break after "darf" only in the sm-md
|
||||
tablet range (natural wrap there landed awkwardly); removed
|
||||
at true mobile widths (below sm:) 2026-07-24 — narrower
|
||||
still, natural wrap reads fine there, and the forced break
|
||||
made "darf" the whole first line. Full text-display only
|
||||
from lg: up, where the column has real room again. */}
|
||||
<p
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
<span className="text-display">
|
||||
Produktivität darf<br className="lg:hidden" /> sich leicht anfühlen
|
||||
<span className="text-h1 lg:text-display">
|
||||
Produktivität darf<br className="hidden sm:inline md:hidden" /> sich leicht anfühlen
|
||||
</span>
|
||||
{/* Brand's signature orange dot (also in the logo/footer) —
|
||||
bouncy pop-in once the heading scrolls into view, timed to
|
||||
land just after the Reveal's own 0.6s fade-up so it reads
|
||||
as a deliberate flourish, not simultaneous with the text.
|
||||
One-shot, not a looping pulse — continuous motion next to
|
||||
the primary CTA would be distracting rather than "cool". */}
|
||||
<PopIn className="text-display text-brand inline-block" delay={0.5}>
|
||||
the primary CTA would be distracting rather than "cool".
|
||||
Same text-h1 lg:text-display as the heading itself, so the
|
||||
dot scales down to match below lg:. */}
|
||||
<PopIn className="text-h1 lg:text-display text-brand inline-block" delay={0.5}>
|
||||
.
|
||||
</PopIn>
|
||||
</p>
|
||||
|
||||
{/* Subheading */}
|
||||
<p className="font-semibold leading-[2.375rem] min-w-full shrink-0 text-text-primary text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
|
||||
{/* Subheading — smaller fixed size below lg:, text-h-emphasis's
|
||||
own 20px floor read too large next to the now-smaller CTA
|
||||
text. leading shrinks to match, not just font-size. */}
|
||||
<p className="font-semibold leading-[1.75rem] lg:leading-[2.375rem] min-w-full shrink-0 text-text-primary text-[1rem] lg:text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
|
||||
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
>
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-h3 whitespace-nowrap not-italic">
|
||||
{/* Letting this wrap to two lines below lg: (tried 2026-07-24)
|
||||
put the icon beside a two-line text block, which read as
|
||||
broken rather than intentional. Smaller fixed size below
|
||||
lg: instead, so the full phrase fits on one line within
|
||||
the column's width — text-h3's own 19px floor was still
|
||||
too wide for that, both on a 375px phone AND in the
|
||||
~283-320px Tablet grid column. */}
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-[0.8125rem] lg:text-h3 whitespace-nowrap not-italic">
|
||||
Starte mit der 7-Tage-Challenge
|
||||
</span>
|
||||
<div className="relative h-[1.1875rem] w-[1.5625rem] shrink-0">
|
||||
<Image alt="" src="/icon-check.svg" fill sizes="26px" />
|
||||
{/* Scaled down to match the smaller CTA text (same ~0.76
|
||||
aspect ratio as the lg: size), full size again from lg: up
|
||||
alongside text-h3. */}
|
||||
<div className="relative h-[0.8125rem] w-[1.0625rem] lg:h-[1.1875rem] lg:w-[1.5625rem] shrink-0">
|
||||
<Image alt="" src="/icon-check.svg" fill sizes="(min-width: 1024px) 26px, 17px" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Social proof */}
|
||||
<div className="flex gap-3 items-start overflow-clip shrink-0 w-full">
|
||||
{/* Social proof — always avatars-then-text on two lines below
|
||||
lg: (not just when it happens to overflow), single row again
|
||||
from lg: up where the real column width fits it fine. */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 items-center justify-center overflow-clip shrink-0 w-full">
|
||||
{/* Avatars — gap 2px, not overlapping */}
|
||||
<div className="flex gap-[0.125rem] items-center shrink-0">
|
||||
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
|
||||
@@ -79,46 +126,39 @@ export function Hero() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body [word-break:break-word]">
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center [word-break:break-word]">
|
||||
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Image — bleeds to the true edge at every breakpoint (never
|
||||
padded). Below lg: (stacked layout) the full 887:583 aspect
|
||||
padded). Below md: (stacked layout) the full 887:583 aspect
|
||||
ratio at 100vw would make the image ~600-900px tall and
|
||||
dominate the page, so height is capped and object-cover crops
|
||||
it into a supporting banner instead; at lg:+ (grid, image only
|
||||
it into a supporting banner instead; at md:+ (grid, image only
|
||||
58% width) the full aspect ratio looks right again, so the cap
|
||||
is lifted. A small negative top margin below lg: pulls it up to
|
||||
slightly tuck under the text block (deliberately less than the
|
||||
social-proof row's height, so it never covers the avatars/text).
|
||||
Scoped to md:-only (mt-0 at base and again at lg:) — it's a
|
||||
Tablet-specific touch, not a permanent effect. No shadow: a
|
||||
plain box-shadow reads as a hard rectangular edge against the
|
||||
existing corner/right/bottom mask-gradient fade below, which
|
||||
looked worse than no shadow at all — tried and reverted. */}
|
||||
is lifted. No shadow: a plain box-shadow reads as a hard
|
||||
rectangular edge against the existing corner/right/bottom
|
||||
mask-gradient fade below, which looked worse than no shadow at
|
||||
all — tried and reverted. */}
|
||||
{/* No Reveal (fade-in-on-scroll) below md: — whileInView's -80px
|
||||
viewport margin means the image doesn't fade in until scrolled
|
||||
that much further into view; on a short mobile viewport this
|
||||
image sits right at the initial fold, so it stayed at
|
||||
opacity:0 (a white gap, matching the section's own bg-bg-base)
|
||||
above the fold until the user scrolled (reported 2026-07-24).
|
||||
Plain, always-visible image below md: instead; Reveal's fade
|
||||
kept from md: up, where the image is beside the text with
|
||||
plenty of room and this was never an issue. */}
|
||||
<div className="order-2 md:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<HeroImage />
|
||||
</div>
|
||||
<Reveal
|
||||
className="order-2 lg:order-none lg:col-span-7 relative w-full aspect-[887/583] max-h-[16rem] md:max-h-[22rem] lg:max-h-none mt-0 md:-mt-6 lg:mt-0"
|
||||
className="hidden md:block md:col-span-7 relative w-full aspect-[887/583]"
|
||||
delay={0.15}
|
||||
>
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
WebkitMaskComposite: "destination-in",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
<HeroImage />
|
||||
</Reveal>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,12 @@ function AccountLink() {
|
||||
aria-label={loggedIn ? "Mein Konto (eingeloggt)" : "Anmelden"}
|
||||
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||||
{/* -translate-y-0.5 — the glyph's own bounding box centers fine
|
||||
mathematically, but the round head (light, isolated) versus the
|
||||
wide shoulders (heavier, at the bottom) reads as optically
|
||||
bottom-heavy next to the cart icon, sitting visibly lower.
|
||||
Nudged up to match (fixed 2026-07-24). */}
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7 text-text-primary -translate-y-0.5" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M4.5 20c1.2-4 4-6 7.5-6s6.3 2 7.5 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
@@ -475,8 +480,18 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
(below lg). Grouped so spacing stays consistent as individual
|
||||
children hide/show across the three breakpoint tiers. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<AccountLink />
|
||||
<CartLink />
|
||||
{/* No gap between these two — each is already a 44px touch
|
||||
target with the icon centered inside, so even gap-0 here
|
||||
still leaves ~20px of visual space between the actual
|
||||
glyphs. The outer gap-2 is what separates this pair from
|
||||
the CTA-buttons/hamburger group that follows, and stays
|
||||
untouched. Fixed 2026-07-24: gap-2 here on top of that
|
||||
built-in padding read as too much space on mobile, where
|
||||
these two icons are the only always-visible controls. */}
|
||||
<div className="flex items-center">
|
||||
<AccountLink />
|
||||
<CartLink />
|
||||
</div>
|
||||
|
||||
{/* CTA buttons — inline from md (768px) up, i.e. through both
|
||||
"Collapsed-CTA" and full Desktop tiers */}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { useNewsletterSignup } from "../lib/useNewsletterSignup";
|
||||
|
||||
// Same lock icon + copy as /challenge's and /newsletter's EmailCapture —
|
||||
// unified across all newsletter-signup forms instead of each having its
|
||||
@@ -31,6 +34,9 @@ export function Newsletter({
|
||||
title = <>Starte mit einer Woche voller Klarheit<span className="text-brand">.</span></>,
|
||||
description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.",
|
||||
}: NewsletterProps = {}) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-page");
|
||||
|
||||
return (
|
||||
<section className="py-16 w-full">
|
||||
|
||||
@@ -40,10 +46,23 @@ export function Newsletter({
|
||||
{/* Rounded card: cream bg, stacks below md */}
|
||||
<Reveal className="bg-bg-muted flex flex-col md:flex-row gap-8 md:gap-12 items-center px-8 py-8 md:py-0 rounded-md w-full">
|
||||
|
||||
{/* Left: copy — fixed width from md+ so the form always gets the remaining space */}
|
||||
<div className="flex gap-8 items-start w-full md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
|
||||
{/* Left: copy — fixed width from md+ so the form always gets the remaining space.
|
||||
Icon+text stacked (icon on top, centered) below md: — side by
|
||||
side they squeezed the text into a ~164px column on a 375px
|
||||
phone (icon width + gap eating most of the card's inner
|
||||
width), wrapping awkwardly. Row layout with the icon beside
|
||||
the text is fine again from md+, where the fixed copy-column
|
||||
width leaves real room. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full md:flex-row md:items-start md:gap-8 md:text-left md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
|
||||
|
||||
{/* Decorative envelope icon, tilted -4° as per design */}
|
||||
{/* Decorative envelope icon, tilted -4° as per design.
|
||||
w-[4rem], not w-16 — this project's --spacing-16 is a
|
||||
fluid token (floors to 40px below 768px, see globals.css),
|
||||
so pairing w-16 with the fixed h-[3.438rem] squished the
|
||||
icon to a 40:55 box on mobile instead of the SVG's native
|
||||
64:55.0096 (it has preserveAspectRatio="none", so it
|
||||
actually stretches to whatever box it's given — fixed
|
||||
2026-07-24). */}
|
||||
<div className="flex items-center justify-center shrink-0 w-[4.23rem] h-[3.71rem]">
|
||||
<div className="-rotate-4 -scale-y-100">
|
||||
<Image
|
||||
@@ -51,7 +70,7 @@ export function Newsletter({
|
||||
src="/newsletter-icon.svg"
|
||||
width={64}
|
||||
height={55}
|
||||
className="w-16 h-[3.438rem] block"
|
||||
className="w-[4rem] h-[3.438rem] block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,54 +91,84 @@ export function Newsletter({
|
||||
|
||||
{/* Right: form — takes remaining space, centered vertically from md+ */}
|
||||
<div className="flex w-full md:flex-1 items-center md:self-stretch min-w-0">
|
||||
<div className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
{/* Input + submit button — stacked below md, side by side from md+ */}
|
||||
<div className="flex flex-col md:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="flex-1 min-w-0 bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Consent checkbox — required since this signup's legal
|
||||
basis is consent (email marketing), not the "Ich achte
|
||||
auf deine Daten" trust note alone. Same wording/pattern
|
||||
as NewsletterModal's checkbox. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Privacy note — same icon/copy/color as the other
|
||||
newsletter forms (see /challenge's EmailCapture). */}
|
||||
<p className="flex items-center gap-1.5 text-label text-[#888] font-normal leading-normal">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
</div>
|
||||
{/* Input + submit button — stacked below lg: (was md:).
|
||||
The card above already goes side-by-side at md: with a
|
||||
fixed-width copy column (--newsletter-copy-width), which
|
||||
only leaves ~200px for this form column at 768px — not
|
||||
enough room for input+button side by side. Stacked
|
||||
through the whole Tablet range instead, side by side
|
||||
again once the form column has real room at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`flex-1 min-w-0 bg-bg-white border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
|
||||
</button>
|
||||
</div>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
|
||||
)}
|
||||
|
||||
{/* Consent checkbox — required since this signup's legal
|
||||
basis is consent (email marketing), not the "Ich achte
|
||||
auf deine Daten" trust note alone. Same wording/pattern
|
||||
as NewsletterModal's checkbox. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Privacy note — same icon/copy/color as the other
|
||||
newsletter forms (see /challenge's EmailCapture). */}
|
||||
<p className="flex items-center gap-1.5 text-label text-[#888] font-normal leading-normal">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</Reveal>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useNewsletterSignup } from "../lib/useNewsletterSignup";
|
||||
|
||||
const features = [
|
||||
{
|
||||
@@ -34,6 +35,8 @@ const features = [
|
||||
export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-modal");
|
||||
|
||||
// Background scroll lock while open — intercepts and cancels the wheel/
|
||||
// touch input that would cause scrolling, instead of toggling
|
||||
@@ -169,8 +172,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
|
||||
itself is authored upside-down (matches how Newsletter.tsx
|
||||
uses this exact same asset); without it the icon renders
|
||||
flipped. */}
|
||||
<div className="w-16 h-14 -rotate-4 -scale-y-100">
|
||||
flipped. Hidden below md: — removed on mobile 2026-07-24. */}
|
||||
<div className="hidden md:block w-16 h-14 -rotate-4 -scale-y-100">
|
||||
<Image alt="" src="/newsletter-icon.svg" width={64} height={56} className="w-full h-full" />
|
||||
</div>
|
||||
|
||||
@@ -186,39 +189,63 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
|
||||
</p>
|
||||
|
||||
<form className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="w-full bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex gap-2 items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`w-full bg-bg-white border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex gap-2 items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
|
||||
@@ -23,10 +23,11 @@ import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
* see Products.ts), not duplicated here as hardcoded literals.
|
||||
*/
|
||||
export async function ProductSpotlight() {
|
||||
const [product, shipping, defaultTaxRate] = await Promise.all([
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getSpotlightProduct(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
if (!product) return null;
|
||||
|
||||
@@ -85,7 +86,7 @@ export async function ProductSpotlight() {
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt. zzgl. Versand</p>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
const fadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
|
||||
// Plain fade, no y-translate — fixed 2026-07-24. Used to animate opacity
|
||||
// 0→1 *and* y 28→0 together ("fade up"), which read as the whole section
|
||||
// visibly hopping/jumping into place on top of the fade — one motion cue
|
||||
// too many. The fade alone is already a clear enough "this just appeared"
|
||||
// signal without the extra jump.
|
||||
const fadeIn: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
|
||||
};
|
||||
|
||||
type RevealProps = {
|
||||
@@ -16,7 +21,7 @@ type RevealProps = {
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
/** Fades a section up into place once, the first time it scrolls into view. */
|
||||
/** Fades a section into view once, the first time it scrolls into view. */
|
||||
export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -25,7 +30,7 @@ export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
variants={fadeUp}
|
||||
variants={fadeIn}
|
||||
transition={{ delay }}
|
||||
>
|
||||
{children}
|
||||
@@ -53,10 +58,10 @@ export function RevealGroup({ children, className }: { children: ReactNode; clas
|
||||
);
|
||||
}
|
||||
|
||||
/** Child item for use inside a RevealGroup — same fade-up motion, driven by the parent's stagger. */
|
||||
/** Child item for use inside a RevealGroup — same fade motion, driven by the parent's stagger. */
|
||||
export function RevealItem({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div className={className} variants={fadeUp}>
|
||||
<motion.div className={className} variants={fadeIn}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
@@ -115,8 +120,17 @@ export function PopIn({ children, className, delay = 0 }: RevealProps) {
|
||||
<motion.span
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
// animate, not whileInView — its only caller (Hero.tsx's brand dot)
|
||||
// sits above the fold, already visible on load, so there's no real
|
||||
// "scrolls into view" moment to gate on. whileInView's -80px viewport
|
||||
// margin also broke on some phones: the popIn variant's own "hidden"
|
||||
// state translates x:+140, and on a narrow mobile viewport that could
|
||||
// push the dot's pre-animation bounding box past the right edge —
|
||||
// IntersectionObserver then never reports it as visible, so
|
||||
// whileInView never fires and the dot stays stuck off-screen
|
||||
// (reported 2026-07-24: dot invisible on a real phone). Firing on
|
||||
// mount sidesteps that geometry entirely.
|
||||
animate="show"
|
||||
variants={popIn}
|
||||
transition={{ delay }}
|
||||
>
|
||||
|
||||
+179
-110
@@ -1,31 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react";
|
||||
import type { TOCSection } from "./SectionTOC";
|
||||
import { QuoteLabel } from "./QuoteLabel";
|
||||
|
||||
// Minimal Lexical JSON → JSX renderer for Payload's richText fields.
|
||||
// Deliberately small and dependency-free (matches the project's existing
|
||||
// style — see Posts.ts's own hand-rolled extractPlainText on the Payload
|
||||
// side) rather than pulling in @payloadcms/richtext-lexical's full React
|
||||
// renderer just to walk a legal page's headings/paragraphs/lists. Covers
|
||||
// the node types real content actually uses; add more only when a page
|
||||
// genuinely needs them.
|
||||
// Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to
|
||||
// Payload's own official React renderer + custom JSXConverters — needed
|
||||
// once Posts.content gained custom Lexical Blocks (Bild/Bildergalerie/
|
||||
// Video/Zitat, see payload/src/collections/Posts.ts), which the old
|
||||
// hand-rolled switch had no case for at all. extractHeadings()/headingId()
|
||||
// below are kept as an independent, minimal walk over the raw JSON (same
|
||||
// as before) — they only ever need to find h2 headings for SectionTOC and
|
||||
// never touch Blocks, no reason to route that through the new renderer too.
|
||||
|
||||
type LexicalNode = {
|
||||
type: string;
|
||||
children?: LexicalNode[];
|
||||
text?: string;
|
||||
format?: number;
|
||||
tag?: string;
|
||||
listType?: "bullet" | "number";
|
||||
fields?: { url?: string };
|
||||
};
|
||||
|
||||
// Lexical's text format is a bitmask — see TextFormatType in the Lexical
|
||||
// source (IS_BOLD = 1, IS_ITALIC = 2, IS_UNDERLINE = 8).
|
||||
const BOLD = 1;
|
||||
const ITALIC = 2;
|
||||
const UNDERLINE = 8;
|
||||
|
||||
function plainText(node: LexicalNode): string {
|
||||
if (node.type === "text") return node.text ?? "";
|
||||
return (node.children ?? []).map(plainText).join("");
|
||||
@@ -70,114 +63,185 @@ export function extractHeadings(content: unknown): TOCSection[] {
|
||||
return headings;
|
||||
}
|
||||
|
||||
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string, quoteLabel: string): ReactNode {
|
||||
if (!nodes) return null;
|
||||
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`, quoteLabel));
|
||||
// Payload upload relations resolve to the full media doc when fetched at
|
||||
// sufficient depth (every richText-consuming fetch in app/lib/payload.ts
|
||||
// already uses depth >= 2), or fall back to a bare id if not — only
|
||||
// render when actually populated.
|
||||
type MediaRef = { url?: string | null } | number | null | undefined;
|
||||
function mediaUrl(ref: MediaRef): string | null {
|
||||
if (ref && typeof ref === "object" && typeof ref.url === "string") return ref.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNode {
|
||||
switch (node.type) {
|
||||
case "linebreak":
|
||||
return <br key={key} />;
|
||||
case "text": {
|
||||
let el: ReactNode = node.text;
|
||||
const format = node.format ?? 0;
|
||||
if (format & BOLD) el = <strong key={key}>{el}</strong>;
|
||||
if (format & ITALIC) el = <em key={key}>{el}</em>;
|
||||
if (format & UNDERLINE) el = <u key={key}>{el}</u>;
|
||||
return <span key={key}>{el}</span>;
|
||||
}
|
||||
case "link":
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={node.fields?.url ?? "#"}
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</a>
|
||||
);
|
||||
case "heading": {
|
||||
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node);
|
||||
// Naive YouTube/Vimeo URL -> embed URL. Not exhaustive (no playlist/short-
|
||||
// link edge cases) — good enough for a "paste a link" editor field; a
|
||||
// URL that doesn't match either pattern just doesn't render rather than
|
||||
// guessing wrong.
|
||||
function toEmbedUrl(url: string): string | null {
|
||||
const youtube = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{6,})/);
|
||||
if (youtube) return `https://www.youtube.com/embed/${youtube[1]}`;
|
||||
const vimeo = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
type ImageBlockFields = { image: MediaRef; caption?: string | null };
|
||||
type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] };
|
||||
type VideoEmbedBlockFields = { url: string; caption?: string | null };
|
||||
type QuoteBlockFields = { text: string; label?: string | null };
|
||||
|
||||
function BlockCaption({ caption }: { caption?: string | null }) {
|
||||
if (!caption) return null;
|
||||
return <p className="text-body-sm text-text-muted text-center">{caption}</p>;
|
||||
}
|
||||
|
||||
// Same visual treatment as the QuoteBlock converter below (and the native
|
||||
// blockquote case it replaces going forward) — see that converter's own
|
||||
// comment for why both still exist.
|
||||
function Quote({ label, children }: { label?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex items-start gap-6 w-full my-6">
|
||||
{/* Label/icon/underline are optional — if empty, only the divider +
|
||||
quote text render. The quote itself is never optional, just this
|
||||
framing around it. */}
|
||||
{label && <QuoteLabel label={label} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A factory, not a module-level constant — needs to close over each
|
||||
// call's own `quoteLabel` (the native "quote" converter reads it). Server
|
||||
// Components can render multiple posts concurrently in the same process,
|
||||
// so a shared module-level variable set right before rendering would be
|
||||
// a real race condition, not just a style choice.
|
||||
function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
return ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
paragraph: ({ node, nodesToJSX }) => (
|
||||
<p className="text-body text-text-body">{nodesToJSX({ nodes: node.children })}</p>
|
||||
),
|
||||
heading: ({ node, nodesToJSX }) => {
|
||||
const Tag = node.tag as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node as unknown as LexicalNode);
|
||||
return (
|
||||
<Tag
|
||||
key={key}
|
||||
id={Tag === "h2" ? headingId(text) : undefined}
|
||||
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
},
|
||||
list: ({ node, nodesToJSX }) => {
|
||||
const ListTag = node.listType === "number" ? "ol" : "ul";
|
||||
return (
|
||||
<ListTag
|
||||
key={key}
|
||||
className={
|
||||
"flex flex-col gap-2 text-body text-text-body " +
|
||||
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
|
||||
}
|
||||
>
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</ListTag>
|
||||
);
|
||||
}
|
||||
case "listitem":
|
||||
return (
|
||||
<li key={key}>{renderChildren(node.children, key, quoteLabel)}</li>
|
||||
);
|
||||
case "paragraph":
|
||||
return (
|
||||
<p key={key} className="text-body text-text-body">
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</p>
|
||||
);
|
||||
// Lexical's default blockquote feature — used sitewide as a "Merke
|
||||
// dir:" pull-quote callout, per page-blog-detail's actual built Figma
|
||||
// frame (node 4676:341, file jCCZyh1DGwdjpv1wGge9To) — NOT a bordered/
|
||||
// background card (an earlier version of this guessed one; the real
|
||||
// design has no background or padding at all, just a plain 3-column
|
||||
// row: label+underline, a full-height divider rule, then the quote
|
||||
// lines). Icon is the actual exported sparkle asset from that node
|
||||
// (icon-sparkle-merke-dir.png), not a hand-drawn approximation. The
|
||||
// "Merke dir:" label itself is generic/hardcoded here rather than
|
||||
// content-authored, since a blog post's own body text drives which
|
||||
// lines get quoted, not the label framing them — legal pages never
|
||||
// use blockquotes, so this styling is effectively blog-only in
|
||||
// practice despite living in the shared renderer.
|
||||
case "quote":
|
||||
return (
|
||||
<div key={key} className="relative flex items-start gap-6 w-full my-6">
|
||||
{/* Label/icon/underline are optional (Posts.quoteLabel) — if
|
||||
empty, only the divider + quote text render. The blockquote
|
||||
itself is never optional, just this framing around it. */}
|
||||
{quoteLabel && <QuoteLabel label={quoteLabel} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
{/* Lexical's real QuoteNode holds flat text/linebreak children
|
||||
directly, NOT nested paragraphs — pressing Enter inside a
|
||||
blockquote in the editor exits it into a new paragraph
|
||||
rather than adding a line within it (confirmed by reading
|
||||
@lexical/rich-text's QuoteNode.insertNewAfter). An earlier
|
||||
version of this case assumed nested-paragraph children,
|
||||
which only happened to work for this session's own
|
||||
hand-authored seed JSON — any blockquote actually typed in
|
||||
the CMS (Shift+Enter for a soft line break) rendered blank,
|
||||
since child.children was undefined on a plain text node. */}
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return renderChildren(node.children, key, quoteLabel);
|
||||
}
|
||||
},
|
||||
listitem: ({ node, nodesToJSX }) => <li>{nodesToJSX({ nodes: node.children })}</li>,
|
||||
link: ({ node, nodesToJSX }) => (
|
||||
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</a>
|
||||
),
|
||||
// Lexical's native blockquote feature — used by every post written
|
||||
// before Blocks existed. Kept working exactly as before (own comment on
|
||||
// Posts.ts's `content` field editor config on why this stays enabled
|
||||
// alongside the new QuoteBlock) rather than migrating old content.
|
||||
quote: ({ node, nodesToJSX }) => (
|
||||
<Quote label={quoteLabel}>{nodesToJSX({ nodes: node.children })}</Quote>
|
||||
),
|
||||
blocks: {
|
||||
image: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ImageBlockFields;
|
||||
const url = mediaUrl(fields.image);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-[3/2] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={url} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
<BlockCaption caption={fields.caption} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
imageGallery: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ImageGalleryBlockFields;
|
||||
const images = (fields.images ?? []).filter((row) => mediaUrl(row.image));
|
||||
if (images.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
{images.map((row, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<div className="relative aspect-[4/3] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image
|
||||
alt=""
|
||||
src={mediaUrl(row.image)!}
|
||||
fill
|
||||
sizes="(min-width: 768px) 24rem, 50vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<BlockCaption caption={row.caption} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
videoEmbed: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as VideoEmbedBlockFields;
|
||||
const embedUrl = toEmbedUrl(fields.url);
|
||||
if (!embedUrl) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-video rounded-md overflow-hidden bg-bg-muted">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={fields.caption ?? "Video"}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<BlockCaption caption={fields.caption} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Per-quote label, unlike Posts.quoteLabel above (one label shared by
|
||||
// every native blockquote in the post) — new quotes going forward use
|
||||
// this instead of the native blockquote feature.
|
||||
quote: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as QuoteBlockFields;
|
||||
const lines = fields.text.split("\n");
|
||||
return (
|
||||
<Quote label={fields.label ?? undefined}>
|
||||
{lines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</Quote>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -185,18 +249,23 @@ export function RichText({
|
||||
quoteLabel = "Merke dir:",
|
||||
}: {
|
||||
content: unknown;
|
||||
/** Label for any blockquote's callout (see the "quote" case above) —
|
||||
* defaults to "Merke dir:" for callers that don't pass one (legal pages
|
||||
* never use blockquotes, so this only actually matters for blog posts).
|
||||
* Pass "" to hide the label/icon/underline for every blockquote here. */
|
||||
/** Label for any native blockquote's callout — defaults to "Merke dir:"
|
||||
* for callers that don't pass one (legal pages never use blockquotes,
|
||||
* so this only actually matters for blog posts). Pass "" to hide the
|
||||
* label/icon/underline for every native blockquote here. New content
|
||||
* should use the Zitat block instead, which carries its own label. */
|
||||
quoteLabel?: string;
|
||||
}) {
|
||||
const root = (content as { root?: LexicalNode })?.root;
|
||||
const root = (content as { root?: { children?: unknown[] } })?.root;
|
||||
if (!root?.children) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{renderChildren(root.children, "root", quoteLabel)}
|
||||
<LexicalRichText
|
||||
data={content as Parameters<typeof LexicalRichText>[0]["data"]}
|
||||
converters={buildConverters(quoteLabel)}
|
||||
disableContainer
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,24 +10,12 @@ export type TOCSection = { id: string; title: string };
|
||||
// active state away from what was actually clicked.
|
||||
const CLICK_OVERRIDE_MS = 1000;
|
||||
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
// scroll-spy sidebar but driven by CMS-authored headings instead of a
|
||||
// hardcoded array. Any future long legal/content page reuses this too.
|
||||
//
|
||||
// Not sticky itself — Impressum/Datenschutz put an extra card below this
|
||||
// in the same sidebar column, and if only this <nav> were sticky, the
|
||||
// card (a plain-flow sibling) would scroll away independently instead of
|
||||
// travelling with it. The caller wraps whatever the sidebar column
|
||||
// contains (this alone, or this + more) in `lg:sticky lg:top-32
|
||||
// lg:self-start` so the whole column moves as one unit.
|
||||
export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
// Shared between SectionTOC (desktop sidebar nav) and MobileSectionTOC
|
||||
// (below lg: collapsible accordion, added 2026-07-24) — both need the same
|
||||
// scroll-spy "active" state and click-override handling, just render it
|
||||
// completely differently, so the logic lives here once instead of being
|
||||
// duplicated per component.
|
||||
function useActiveSection(sections: TOCSection[]) {
|
||||
const [active, setActive] = useState<string>(sections[0]?.id ?? "");
|
||||
// Not state — read inside the IntersectionObserver callback without
|
||||
// needing to re-subscribe it on every click, and cleared by its own
|
||||
@@ -67,6 +55,28 @@ export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
}, CLICK_OVERRIDE_MS);
|
||||
}
|
||||
|
||||
return { active, handleClick };
|
||||
}
|
||||
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
// scroll-spy sidebar but driven by CMS-authored headings instead of a
|
||||
// hardcoded array. Any future long legal/content page reuses this too.
|
||||
//
|
||||
// Not sticky itself — every caller wraps this in its own `hidden lg:flex
|
||||
// ... lg:sticky lg:top-32 lg:self-start` div (Impressum/Datenschutz also
|
||||
// stack a second "Nachhaltigkeit" card below this in that same wrapper, so
|
||||
// the sticky behavior has to live on the wrapper for the two to travel
|
||||
// together as one unit — putting it on this <nav> instead would leave
|
||||
// that card behind as a plain-flow sibling scrolling past a now-fixed nav).
|
||||
export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
const { active, handleClick } = useActiveSection(sections);
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -92,3 +102,44 @@ export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Below lg: only — a collapsible accordion instead of the sidebar nav
|
||||
// (which is `hidden` entirely below lg:, see SectionTOC's own comment on
|
||||
// why a real 2-column split doesn't fit there). Added 2026-07-24: these
|
||||
// legal pages had no on-page navigation aid at all on Mobile/Tablet, which
|
||||
// is exactly where scanning a long legal document by scrolling is hardest.
|
||||
// Native <details>/<summary> — no extra open/close state needed, and it
|
||||
// stays open after a click so jumping between sections doesn't require
|
||||
// reopening it each time. Render this as its own element in the page
|
||||
// (typically right after the heading, before the two-column content row),
|
||||
// not nested inside a parent that's itself `hidden lg:...` — that would
|
||||
// hide this too regardless of its own lg:hidden class.
|
||||
export function MobileSectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
const { active, handleClick } = useActiveSection(sections);
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<details className="lg:hidden w-full bg-bg-base border border-border rounded-md p-4 open:pb-2">
|
||||
<summary className="text-label font-semibold text-text-muted uppercase tracking-wide cursor-pointer select-none">
|
||||
Inhaltsübersicht
|
||||
</summary>
|
||||
<div className="flex flex-col gap-1 mt-3">
|
||||
{sections.map(({ id, title }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
onClick={() => handleClick(id)}
|
||||
className={
|
||||
"px-3 py-2 rounded-sm text-body-sm transition-colors border-l-2 " +
|
||||
(active === id
|
||||
? "border-toc-active-border bg-bg-muted text-text-primary font-semibold"
|
||||
: "border-transparent text-text-muted hover:text-text-primary")
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Shared "how it works" step connector — used by Challenge's, /todo-cards's,
|
||||
// and /newsletter's ("Impulse & Tipps") step sections. Used to be
|
||||
// /icon-arrow-connector.svg (a thin gray line+chevron) loaded via next/image;
|
||||
// replaced 2026-07-24 for two reasons that both needed an inline SVG to fix:
|
||||
// 1. It read as a faint gray line, not a real arrow, even after the
|
||||
// object-contain aspect-ratio fix — too thin/subtle at these sizes.
|
||||
// 2. Its color lives in a `var(--stroke-0, #C9C9C9)` CSS custom property
|
||||
// that's scoped to the SVG file's own document when loaded via <img
|
||||
// src>/next/image — un-recolorable from the host page's CSS. Inline SVG
|
||||
// sidesteps that entirely. Stroke color: tried brand orange, then
|
||||
// near-black, settled on the same light gray (#C9C9C9) the original
|
||||
// asset's own fallback used, per feedback the same day — just bolder
|
||||
// (strokeWidth 2.5 vs. the original's thin 1.5) and better-shaped.
|
||||
export function StepArrow({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg width="40" height="16" viewBox="0 0 40 16" fill="none" aria-hidden="true" className={className}>
|
||||
<path
|
||||
d="M1 8H33M26 14.5L34.5 8L26 1.5"
|
||||
stroke="#C9C9C9"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -38,9 +38,17 @@ export async function Tools() {
|
||||
key={tool.id}
|
||||
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon — uniform box, pre-flipped/rotated source asset */}
|
||||
<div className="relative flex items-center justify-center shrink-0 size-14">
|
||||
<Image alt="" src={tool.icon} fill sizes="56px" className="object-contain" />
|
||||
{/* Icon — uniform box, pre-flipped/rotated source asset.
|
||||
size-14 (56px) is a fixed value at every width (14 isn't
|
||||
one of this project's fluid spacing-scale steps) — smaller
|
||||
below md: so it doesn't dwarf the title/description text,
|
||||
which does shrink toward its own fluid floor there. Full
|
||||
56px only from lg: up — at md: (Tablet, where this grid
|
||||
already switches to 3-up) the title/description are still
|
||||
fairly close to their own fluid floor, so the full-size
|
||||
icon read as too big next to them too. */}
|
||||
<div className="relative flex items-center justify-center shrink-0 size-11 lg:size-14">
|
||||
<Image alt="" src={tool.icon} fill sizes="(min-width: 1024px) 56px, 44px" className="object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Card content — self-stretch + h-full + justify-between so
|
||||
@@ -67,11 +75,18 @@ export async function Tools() {
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
{/* flex items-center + arrow as its own span, not inline text
|
||||
— the → glyph sits low relative to the surrounding text's
|
||||
cap-height in the font used here, off-center against the
|
||||
label if it's just part of the same text node (fixed
|
||||
2026-07-24, same pattern ProductGrid.tsx's "Mehr
|
||||
erfahren" link already uses). */}
|
||||
<Link
|
||||
href={tool.ctaHref}
|
||||
className="font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
className="flex items-center gap-1 font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ {tool.ctaLabel}
|
||||
<span aria-hidden>→</span>
|
||||
<span>{tool.ctaLabel}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function TrustRow() {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-start md:items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-6 md:gap-12">
|
||||
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -38,6 +38,13 @@ export default async function DatenschutzPage() {
|
||||
<p className="text-body text-text-muted">Stand: Juli 2026</p>
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
|
||||
comment. Outside the sidebar's `hidden lg:flex` wrapper below
|
||||
(that wrapper's `hidden` would hide this too otherwise). */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileSectionTOC sections={headings} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
|
||||
@@ -141,6 +141,35 @@
|
||||
--divider-sparkle-h: clamp(2.0625rem, 1.5554rem + 1.0565vw, 2.50625rem);
|
||||
--divider-sparkle-inner-w: clamp(1.4375rem, 1.09875rem + 0.706vw, 1.734rem);
|
||||
--divider-sparkle-inner-h: clamp(1.9375rem, 1.4375rem + 1.0417vw, 2.375rem);
|
||||
/* Own token, mirrors --text-h2's clamp() exactly rather than the Word
|
||||
component reading var(--text-h2) directly — that's what lets the
|
||||
mobile override below shrink just this component's words without
|
||||
touching every other text-h2 heading site-wide. */
|
||||
--divider-word-size: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
|
||||
}
|
||||
|
||||
/* This project's fluid() scale (see fluid.ts) is calibrated for the
|
||||
768-1440px Tablet-Desktop range and floors out at the 768px value for
|
||||
any narrower viewport (clamp()'s MIN bound) — by design, see the other
|
||||
fluid tokens above. Divider is the one spot that floor doesn't work:
|
||||
the "Klarheit → Fokus → Entlastung" phrase plus its connector icons
|
||||
needs ~550px of width to lay out on one row even at the 768px floor
|
||||
size, far more than a phone's ~310px content width. Below Tailwind's
|
||||
sm: breakpoint, shrink these tokens further so the phrase gets much
|
||||
closer to fitting on one row instead of stacking into three separate
|
||||
centered lines (see Divider.tsx's gap-x-3/gap-3 mobile overrides,
|
||||
same breakpoint). Scoped to these component-only tokens, not
|
||||
--text-h2 itself. */
|
||||
@media (max-width: 639px) {
|
||||
:root {
|
||||
--divider-word-size: 1.125rem;
|
||||
--divider-arrow-w: 1.125rem;
|
||||
--divider-arrow-h: 0.3125rem;
|
||||
--divider-sparkle-w: 0.875rem;
|
||||
--divider-sparkle-h: 1.15rem;
|
||||
--divider-sparkle-inner-w: 0.8rem;
|
||||
--divider-sparkle-inner-h: 1.075rem;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@@ -63,27 +63,38 @@ export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Heading>Angaben zum Anbieter</Heading>
|
||||
<P>{seller.sellerName}</P>
|
||||
<P>{seller.sellerStreet}</P>
|
||||
<P>
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<P>E-Mail: {seller.sellerEmail}</P>
|
||||
{/* gap-1, not the outer container's own gap-4 — these 5 lines are one
|
||||
continuous address block, not 5 separate paragraphs; the large
|
||||
inter-section gap only belongs between a heading's own block and
|
||||
the next, not between lines that visually belong together
|
||||
(fixed 2026-07-24, same fix applied to every block below). */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>{seller.sellerName}</P>
|
||||
<P>{seller.sellerStreet}</P>
|
||||
<P>
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<P>E-Mail: {seller.sellerEmail}</P>
|
||||
</div>
|
||||
|
||||
<Heading>Umsatzsteuer</Heading>
|
||||
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
|
||||
<P>{seller.vatId}</P>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
|
||||
<P>{seller.vatId}</P>
|
||||
</div>
|
||||
|
||||
{seller.registerCourt && seller.registerNumber && (
|
||||
<>
|
||||
<Heading>Handelsregister</Heading>
|
||||
<P>{seller.registerCourt}</P>
|
||||
<P>{seller.registerNumber}</P>
|
||||
{/* Optional/voluntary, not a Pflichtangabe — see
|
||||
CompanySettings.ts's own comment on shareCapital. Only shows
|
||||
if an admin deliberately filled it in. */}
|
||||
{seller.shareCapital ? <P>Stammkapital: {seller.shareCapital.toLocaleString("de-DE")} €</P> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>{seller.registerCourt}</P>
|
||||
<P>{seller.registerNumber}</P>
|
||||
{/* Optional/voluntary, not a Pflichtangabe — see
|
||||
CompanySettings.ts's own comment on shareCapital. Only shows
|
||||
if an admin deliberately filled it in. */}
|
||||
{seller.shareCapital ? <P>Stammkapital: {seller.shareCapital.toLocaleString("de-DE")} €</P> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -100,12 +111,14 @@ export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
|
||||
proprietorship/e.K., already a natural person's own name). No
|
||||
OHG/KG general-partner fallback here — see this file's top
|
||||
comment on why that field doesn't exist yet. */}
|
||||
<P>{seller.managingDirector || seller.sellerName}</P>
|
||||
<P>{seller.sellerStreet}</P>
|
||||
<P>
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>{seller.managingDirector || seller.sellerName}</P>
|
||||
<P>{seller.sellerStreet}</P>
|
||||
<P>
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage, getCompanySettings } from "../lib/payload";
|
||||
import { AnbieterAngaben, anbieterAngabenHeadings } from "./components/AnbieterAngaben";
|
||||
|
||||
@@ -42,6 +42,13 @@ export default async function ImpressumPage() {
|
||||
<p className="text-body text-text-muted">Angaben gemäß § 5 DDG</p>
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
|
||||
comment. Outside the sidebar's `hidden lg:flex` wrapper below
|
||||
(that wrapper's `hidden` would hide this too otherwise). */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileSectionTOC sections={headings} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
|
||||
@@ -13,10 +13,20 @@ import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
|
||||
import { OrderActionButton } from "./components/OrderActionButton";
|
||||
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Bestelldetails",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
// Dynamic (was a static "Bestelldetails" title despite this being a
|
||||
// per-order route) — just formats the already-known order number into
|
||||
// the title, no extra fetch needed for a noindex account page.
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ orderNumber: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { orderNumber } = await params;
|
||||
return {
|
||||
title: `Bestellung ${decodeURIComponent(orderNumber)}`,
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function KontoBestellungDetailPage({ params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const { orderNumber } = await params;
|
||||
@@ -87,6 +97,9 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
common case (no override) keeps the original "Lieferadresse"
|
||||
label, since that's exactly what this address still is. */}
|
||||
<p className="text-label text-text-muted">{order.hasDifferentShippingAddress ? "Rechnungsadresse" : "Lieferadresse"}</p>
|
||||
{order.companyName && (
|
||||
<p className="text-body-sm text-text-primary">{order.companyName}</p>
|
||||
)}
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.customerFirstName} {order.customerLastName}
|
||||
</p>
|
||||
@@ -94,6 +107,14 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.zip} {order.city}, {order.country}
|
||||
</p>
|
||||
{order.vatId && (
|
||||
<p className="text-body-sm text-text-muted">
|
||||
USt-IdNr. {order.vatId}
|
||||
{order.kleinunternehmer
|
||||
? " · Kleinunternehmer gem. § 19 UStG"
|
||||
: order.vatExempt && " · steuerfreie innergemeinschaftliche Lieferung"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{order.hasDifferentShippingAddress && (
|
||||
@@ -122,7 +143,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
{item.quantity} × {item.productName}
|
||||
{item.variantName ? ` (${item.variantName})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>
|
||||
{!order.kleinunternehmer && <p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>}
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
{item.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
@@ -165,7 +186,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
|
||||
</div>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
{order.kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Footer } from "../../components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Passwort vergessen",
|
||||
description: "Setze dein Passwort für dein einfach produktiv-Konto zurück.",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Footer } from "../../components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Passwort zurücksetzen",
|
||||
description: "Vergib ein neues Passwort für dein einfach produktiv-Konto.",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import type { CustomerProfile } from "../../../lib/customerAuth";
|
||||
import type { ShippingCountry } from "../../../lib/payload";
|
||||
|
||||
const inputClass =
|
||||
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
|
||||
@@ -21,7 +22,17 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileForm({ profile }: { profile: CustomerProfile }) {
|
||||
export function ProfileForm({
|
||||
profile,
|
||||
shippingCountries,
|
||||
}: {
|
||||
profile: CustomerProfile;
|
||||
/** Same admin-configurable list /checkout's own "Land" <select> reads
|
||||
* (Payload's shipping-countries collection) — this form used to hardcode
|
||||
* its own Deutschland/Österreich/Schweiz options independently, so a
|
||||
* country added/removed there never reached the profile page. */
|
||||
shippingCountries: ShippingCountry[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -147,9 +158,9 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
|
||||
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select name="country" defaultValue={profile.country ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
|
||||
<option>Deutschland</option>
|
||||
<option>Österreich</option>
|
||||
<option>Schweiz</option>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
|
||||
import { getShippingCountries } from "../../lib/payload";
|
||||
import { ProfileForm } from "./components/ProfileForm";
|
||||
import { PasswordForm } from "./components/PasswordForm";
|
||||
import { VerificationBanner } from "./components/VerificationBanner";
|
||||
@@ -10,6 +11,7 @@ import { AccountDataSection } from "./components/AccountDataSection";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mein Profil",
|
||||
description: "Verwalte deine Kontodaten und dein Passwort bei einfach produktiv.",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
@@ -21,7 +23,7 @@ export default async function KontoProfilPage({
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const profile = await getCustomerProfile(session.token);
|
||||
const [profile, shippingCountries] = await Promise.all([getCustomerProfile(session.token), getShippingCountries()]);
|
||||
if (!profile) redirect("/konto/login");
|
||||
|
||||
const { verified } = await searchParams;
|
||||
@@ -34,7 +36,7 @@ export default async function KontoProfilPage({
|
||||
← Meine Bestellungen
|
||||
</Link>
|
||||
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
|
||||
<ProfileForm profile={profile} />
|
||||
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
|
||||
<PasswordForm email={profile.email} />
|
||||
<AccountDataSection />
|
||||
</div>
|
||||
|
||||
+27
-17
@@ -4,7 +4,7 @@ import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
import { CartSync } from "./components/CartSync";
|
||||
import { getProducts } from "./lib/payload";
|
||||
import { getProducts, getSeoSettings } from "./lib/payload";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -30,22 +30,32 @@ const lora = Lora({
|
||||
weight: ["400", "600"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://einfach-produktiv.mk360.de"),
|
||||
title: {
|
||||
default: "einfach produktiv. – Werkzeuge und Impulse für einen leichteren Alltag",
|
||||
template: "%s | einfach produktiv.",
|
||||
},
|
||||
description: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
|
||||
openGraph: {
|
||||
siteName: "einfach produktiv.",
|
||||
locale: "de_DE",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
},
|
||||
};
|
||||
// Backend-driven since 2026-07-24 (CompanySettings' "SEO" tab) — the
|
||||
// literal strings below are only the fallback getSeoSettings() returns if
|
||||
// that field is empty or unreachable, kept identical to what used to be
|
||||
// hardcoded here so nothing changes until an admin actually fills in the
|
||||
// new fields.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const seo = await getSeoSettings();
|
||||
return {
|
||||
metadataBase: new URL("https://einfach-produktiv.mk360.de"),
|
||||
title: {
|
||||
default: seo.defaultTitle ?? "einfach produktiv.",
|
||||
template: seo.titleTemplate ?? "%s | einfach produktiv.",
|
||||
},
|
||||
description: seo.defaultDescription ?? undefined,
|
||||
openGraph: {
|
||||
siteName: "einfach produktiv.",
|
||||
locale: "de_DE",
|
||||
type: "website",
|
||||
images: seo.defaultOgImage ? [{ url: seo.defaultOgImage }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Server-only — syncs newsletter opt-ins to Brevo's Contacts API via the
|
||||
// double-opt-in endpoint: this only ever *requests* a subscription, it
|
||||
// does not add the contact to the real list itself — Brevo sends the
|
||||
// confirmation email (the template at BREVO_DOUBLE_OPTIN_TEMPLATE_ID,
|
||||
// configured as this list's Double Opt-in template in Brevo's own UI) and
|
||||
// only adds the contact to BREVO_LIST_ID once they click through. This
|
||||
// app never sends marketing mail itself, and — as of this switch — never
|
||||
// even directly grants list membership; it only ever hands Brevo the
|
||||
// contact + consent-to-be-asked. Everything after that (the confirmation
|
||||
// email itself, the post-confirmation Welcome Flow automation) is
|
||||
// configured in Brevo's own UI, not manageable via their public API.
|
||||
//
|
||||
// Previously called the plain `POST /v3/contacts` upsert (single
|
||||
// opt-in — added straight to the list, no confirmation click required).
|
||||
// Switched 2026-07-25 per explicit request once the confirmation-email
|
||||
// template existed to point templateId at.
|
||||
const BREVO_DOUBLE_OPTIN_URL = "https://api.brevo.com/v3/contacts/doubleOptinConfirmation";
|
||||
|
||||
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
export type NewsletterOptInSource = "checkout" | "newsletter-page" | "newsletter-modal" | "newsletter-hero" | "challenge";
|
||||
|
||||
// `source` becomes a Brevo contact attribute so campaigns/segments can
|
||||
// tell a checkout opt-in apart from the standalone signup forms without
|
||||
// needing separate lists.
|
||||
export async function upsertNewsletterContact(
|
||||
email: string,
|
||||
source: NewsletterOptInSource,
|
||||
): Promise<BrevoSyncResult> {
|
||||
const apiKey = process.env.BREVO_API_KEY;
|
||||
const listId = process.env.BREVO_LIST_ID;
|
||||
const templateId = process.env.BREVO_DOUBLE_OPTIN_TEMPLATE_ID;
|
||||
if (!apiKey || !listId || !templateId) {
|
||||
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID/BREVO_DOUBLE_OPTIN_TEMPLATE_ID nicht konfiguriert." };
|
||||
}
|
||||
const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/newsletter-confirmed";
|
||||
|
||||
try {
|
||||
const res = await fetch(BREVO_DOUBLE_OPTIN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"api-key": apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
includeListIds: [Number(listId)],
|
||||
templateId: Number(templateId),
|
||||
redirectionUrl,
|
||||
attributes: { OPT_IN_SOURCE: source },
|
||||
}),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
// 201 Created is this endpoint's success status (unlike the plain
|
||||
// contacts upsert this replaced, which used 204). A contact who's
|
||||
// already confirmed-and-subscribed re-submitting the form is not
|
||||
// treated as an error either — Brevo resends the confirmation email
|
||||
// in that case rather than erroring, which is an acceptable no-op
|
||||
// resend from this app's point of view (matches the previous
|
||||
// endpoint's "always succeeds for an existing contact too" behavior).
|
||||
if (res.ok || res.status === 201) return { ok: true };
|
||||
const body = await res.json().catch(() => null);
|
||||
return { ok: false, reason: body?.message ?? `Brevo antwortete mit ${res.status}` };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "Brevo ist gerade nicht erreichbar." };
|
||||
}
|
||||
}
|
||||
@@ -443,6 +443,10 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
|
||||
|
||||
export type CustomerOrderDetail = CustomerOrder & {
|
||||
id: number;
|
||||
// 'not_applicable' for Überweisung orders (never gated); see
|
||||
// spicy-leaping-pizza.md §1 — read by /api/checkout/status for the
|
||||
// post-Stripe-redirect polling page.
|
||||
paymentStatus: "not_applicable" | "pending" | "paid" | "failed" | "refunded" | "partially_refunded";
|
||||
invoiceNumber: string | null;
|
||||
invoiceIssuedAt: string | null;
|
||||
correctionInvoiceNumber: string | null;
|
||||
@@ -452,6 +456,11 @@ export type CustomerOrderDetail = CustomerOrder & {
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
customerEmail: string;
|
||||
companyName: string | null;
|
||||
vatId: string | null;
|
||||
vatExempt: boolean;
|
||||
kleinunternehmer: boolean;
|
||||
vatIdValidatedAt: string | null;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street: string | null;
|
||||
packstationNumber: string | null;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Single source of truth for "is this a plausible email address" — used
|
||||
// client-side (checkout, newsletter forms) for immediate on-blur feedback
|
||||
// and server-side (newsletter subscribe route) as the same check, not a
|
||||
// second one that could drift out of sync.
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function isValidEmail(value: string): boolean {
|
||||
return EMAIL_PATTERN.test(value);
|
||||
}
|
||||
|
||||
// Returns "" for valid, an error message otherwise.
|
||||
export function validateEmailFormat(value: string): string {
|
||||
if (!value.trim()) return "E-Mail-Adresse ist erforderlich.";
|
||||
return isValidEmail(value) ? "" : "Bitte eine gültige E-Mail-Adresse angeben.";
|
||||
}
|
||||
@@ -130,7 +130,7 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
|
||||
<td style="text-align:center;padding-bottom:20px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td width="56" height="56" style="background:${BRAND}1a;border-radius:50%;text-align:center;vertical-align:middle;font-size:24px;color:${BRAND};">
|
||||
<td width="56" height="56" style="width:56px;height:56px;background:${BRAND}1a;border-radius:50%;text-align:center;vertical-align:middle;font-size:24px;color:${BRAND};">
|
||||
${icon}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,6 +8,15 @@ import type { CartItem } from "./cart";
|
||||
// generated locally), read once by /bestellbestaetigung.
|
||||
export const ORDER_KEY = "ep_last_order";
|
||||
|
||||
// Written for a gated payment method (Kreditkarte/PayPal) right before
|
||||
// PaymentStep hands off to Stripe/the test-confirm flow — see
|
||||
// spicy-leaping-pizza.md §3/§7. Same OrderSnapshot shape as ORDER_KEY,
|
||||
// but this one is provisional: /checkout/verarbeitung only promotes it
|
||||
// to ORDER_KEY once polling confirms the payment actually succeeded, so
|
||||
// an abandoned/failed payment never leaves a confirmation-page-ready
|
||||
// snapshot behind.
|
||||
export const PENDING_ORDER_KEY = "ep_pending_order";
|
||||
|
||||
export type OrderSnapshot = {
|
||||
items: CartItem[];
|
||||
orderNumber: string;
|
||||
@@ -19,4 +28,15 @@ export type OrderSnapshot = {
|
||||
* null/0 when no discount was ever applied. */
|
||||
discountCode: string | null;
|
||||
discountAmount: number;
|
||||
/** Decided server-side at checkout (live VIES check, see api/checkout/
|
||||
* route.ts) — /bestellbestaetigung needs this to know whether to show
|
||||
* the exempt (net, de-grossed) totals instead of the normal VAT-
|
||||
* inclusive catalog prices it would otherwise re-derive live. */
|
||||
vatExempt: boolean;
|
||||
/** §19 UStG — this tenant's company-settings.kleinunternehmer as it stood
|
||||
* at checkout time (see api/checkout/route.ts), never re-derived live —
|
||||
* takes precedence over vatExempt above wherever both would otherwise
|
||||
* apply. /bestellbestaetigung uses this to show the §19 notice instead
|
||||
* of a per-item "inkl. X% MwSt." hint/VAT breakdown. */
|
||||
kleinunternehmer: boolean;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,10 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
|
||||
invoiceIssuedAt: string;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
companyName?: string | null;
|
||||
vatId?: string | null;
|
||||
vatExempt?: boolean;
|
||||
kleinunternehmer?: boolean;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string | null;
|
||||
packstationNumber?: string | null;
|
||||
@@ -68,6 +72,10 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
companyName: order.companyName,
|
||||
vatId: order.vatId,
|
||||
vatExempt: order.vatExempt,
|
||||
kleinunternehmer: order.kleinunternehmer,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
|
||||
+39
-2
@@ -38,6 +38,14 @@ export type CreateOrderInput = {
|
||||
// are independently optional.
|
||||
companyName?: string;
|
||||
vatId?: string;
|
||||
// Decided server-side in api/checkout/route.ts (a live VIES check at the
|
||||
// moment of purchase, never guessed) — see Orders.ts's own comment.
|
||||
vatExempt: boolean;
|
||||
// §19 UStG — this tenant's company-settings.kleinunternehmer as read at
|
||||
// the moment of purchase, snapshotted onto the order (same reasoning as
|
||||
// vatExempt above, plus Orders.ts's own field comment).
|
||||
kleinunternehmer: boolean;
|
||||
vatIdValidatedAt: string | null;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string;
|
||||
packstationNumber?: string;
|
||||
@@ -68,9 +76,28 @@ export type CreateOrderInput = {
|
||||
discountCode: string | null;
|
||||
discountAmount: number;
|
||||
total: number;
|
||||
// Gated-payment fields (see spicy-leaping-pizza.md §1/§3) — all three
|
||||
// omitted for a manual/Überweisung order, which is exactly today's
|
||||
// behavior (Orders.ts's own field defaults apply: status 'received',
|
||||
// paymentProvider 'manual', paymentStatus 'not_applicable').
|
||||
status?: "pending_payment";
|
||||
paymentProvider?: "stripe";
|
||||
paymentStatus?: "pending";
|
||||
// Known before the order is created (Stripe generates a PaymentIntent id
|
||||
// immediately, independent of any order existing yet) — persisted at
|
||||
// creation time specifically so the expirePendingPayments cleanup job
|
||||
// has something to reconcile against even if the webhook metadata
|
||||
// round-trip (stripeProvider.attachOrderMetadata) never completes.
|
||||
providerReference?: string;
|
||||
};
|
||||
|
||||
export type CreatedOrder = { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string };
|
||||
export type CreatedOrder = {
|
||||
id: number;
|
||||
orderNumber: string;
|
||||
createdAt: string;
|
||||
invoiceNumber: string | null;
|
||||
invoiceIssuedAt: string | null;
|
||||
};
|
||||
|
||||
export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder | null> {
|
||||
const tenantId = await resolveTenantId();
|
||||
@@ -93,6 +120,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
|
||||
customerEmail: input.customerEmail,
|
||||
companyName: input.companyName,
|
||||
vatId: input.vatId,
|
||||
vatExempt: input.vatExempt,
|
||||
kleinunternehmer: input.kleinunternehmer,
|
||||
vatIdValidatedAt: input.vatIdValidatedAt,
|
||||
deliveryMethod: input.deliveryMethod,
|
||||
street: input.street,
|
||||
packstationNumber: input.packstationNumber,
|
||||
@@ -127,6 +157,10 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
|
||||
discountCode: input.discountCode,
|
||||
discountAmount: input.discountAmount,
|
||||
total: input.total,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.paymentProvider ? { paymentProvider: input.paymentProvider } : {}),
|
||||
...(input.paymentStatus ? { paymentStatus: input.paymentStatus } : {}),
|
||||
...(input.providerReference ? { providerReference: input.providerReference } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -135,8 +169,11 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: { doc: { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string } } = await res.json();
|
||||
const data: {
|
||||
doc: { id: number; orderNumber: string; createdAt: string; invoiceNumber: string | null; invoiceIssuedAt: string | null };
|
||||
} = await res.json();
|
||||
return {
|
||||
id: data.doc.id,
|
||||
orderNumber: data.doc.orderNumber,
|
||||
createdAt: data.doc.createdAt,
|
||||
invoiceNumber: data.doc.invoiceNumber,
|
||||
|
||||
+171
-1
@@ -93,12 +93,23 @@ export type PostDetail = BlogPost & {
|
||||
* the card entirely, per-post choice (unlike Products.spotlight, which
|
||||
* is a single site-wide flag). */
|
||||
relatedProduct: Product | null;
|
||||
/** SEO overrides (Posts.ts's "SEO" collapsible group) — each null when
|
||||
* empty, callers fall back to title/excerpt/thumbnail themselves rather
|
||||
* than baking the fallback in here, so the distinction between "no
|
||||
* override set" and "override happens to equal the normal value" stays
|
||||
* visible to whoever reads this. */
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
seoImage: string | null;
|
||||
};
|
||||
|
||||
export type PayloadPostDetail = PayloadPost & {
|
||||
content: unknown;
|
||||
quoteLabel: string | null;
|
||||
relatedProduct: PayloadProduct | null;
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
seoImage?: { url: string } | number | null;
|
||||
};
|
||||
|
||||
// Shared by getPostBySlug() and LivePostContent.tsx (which re-maps the raw
|
||||
@@ -120,6 +131,9 @@ export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
|
||||
featured: doc.featured,
|
||||
quoteLabel: doc.quoteLabel ?? "",
|
||||
relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null,
|
||||
seoTitle: doc.seoTitle || null,
|
||||
seoDescription: doc.seoDescription || null,
|
||||
seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -471,6 +485,46 @@ export async function getShippingMethods(): Promise<ShippingMethod[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
// Feeds /checkout's "Land" <select> (both the billing address and the
|
||||
// optional shipping-address override) and its PLZ digit-count validation
|
||||
// — previously a hardcoded array + PLZ_DIGITS map in CheckoutContent.tsx
|
||||
// itself. Which countries are actually deliverable can now change without
|
||||
// a code deploy (e.g. temporarily dropping Schweiz — no customs/export-
|
||||
// invoice handling exists for it yet). Deliberately unrelated to VAT-
|
||||
// exemption eligibility (lib/vatExemption.ts's isExemptionEligibleCountry(),
|
||||
// still hardcoded to "Österreich") — that's a legal/tax-law question, not
|
||||
// a shipping-logistics one, and stays in code on purpose.
|
||||
export type ShippingCountry = {
|
||||
name: string;
|
||||
plzDigits: number;
|
||||
};
|
||||
|
||||
type PayloadShippingCountry = ShippingCountry & { active: boolean };
|
||||
|
||||
export async function getShippingCountries(): Promise<ShippingCountry[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
sort: "sortOrder",
|
||||
limit: "20",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/shipping-countries?${params}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getShippingCountries: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadShippingCountry[] } = await res.json();
|
||||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||||
return docs.map((doc) => ({
|
||||
name: doc.name,
|
||||
plzDigits: doc.plzDigits,
|
||||
}));
|
||||
}
|
||||
|
||||
export type ShippingSettings = {
|
||||
handlingDays: { min: number; max: number };
|
||||
transitDays: { min: number; max: number };
|
||||
@@ -523,13 +577,19 @@ export async function getShippingSettings(): Promise<ShippingSettings> {
|
||||
};
|
||||
}
|
||||
|
||||
export type PaymentMethod = { id: number; title: string; icons: string[] };
|
||||
// `provider` drives the checkout branch in app/api/checkout/route.ts —
|
||||
// 'manual' (Überweisung) keeps today's immediate-order behavior, 'stripe'
|
||||
// (Kreditkarte/PayPal) routes through the payment-intent/webhook-gated
|
||||
// flow. Defaults to 'manual' below for any row created before this field
|
||||
// existed, matching the Payload field's own default.
|
||||
export type PaymentMethod = { id: number; title: string; icons: string[]; provider: "manual" | "stripe" };
|
||||
|
||||
type PayloadPaymentMethod = {
|
||||
id: number;
|
||||
title: string;
|
||||
active: boolean;
|
||||
icons: { icon: { url: string } | number | null }[];
|
||||
provider?: "manual" | "stripe";
|
||||
};
|
||||
|
||||
export async function getPaymentMethods(): Promise<PaymentMethod[]> {
|
||||
@@ -557,9 +617,42 @@ export async function getPaymentMethods(): Promise<PaymentMethod[]> {
|
||||
icons: (doc.icons ?? [])
|
||||
.map((row) => (typeof row.icon === "object" && row.icon ? row.icon.url : null))
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
provider: doc.provider ?? "manual",
|
||||
}));
|
||||
}
|
||||
|
||||
export type CheckoutPaymentOption = PaymentMethod & { hint?: string };
|
||||
|
||||
// Kreditkarte and PayPal both resolve to `provider: 'stripe'` today, and
|
||||
// both end up on the exact same Stripe PaymentIntent
|
||||
// (`automatic_payment_methods: { enabled: true }` — Stripe's own
|
||||
// recommended Payment Element pattern lets Stripe itself decide which
|
||||
// eligible method to show, rather than the older per-method
|
||||
// Checkout-Session split). Pre-selecting one of two identical-behind-the-
|
||||
// scenes rows before the payment step is therefore no longer a real
|
||||
// choice, just redundant friction — so this collapses every active
|
||||
// `stripe` row into one "Online-Zahlung" option (representative id =
|
||||
// the first such row's, since app/api/checkout/route.ts only branches on
|
||||
// `provider`, never on which specific stripe row was picked) with a hint
|
||||
// explaining that the actual instrument is chosen on the next screen.
|
||||
// `manual` rows (Überweisung) pass through unchanged — one real gateway
|
||||
// there, one option, nothing to collapse.
|
||||
export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): CheckoutPaymentOption[] {
|
||||
const manual = methods.filter((m) => m.provider !== "stripe");
|
||||
const stripeMethods = methods.filter((m) => m.provider === "stripe");
|
||||
if (stripeMethods.length === 0) return manual;
|
||||
|
||||
const combinedIcons = Array.from(new Set(stripeMethods.flatMap((m) => m.icons)));
|
||||
const online: CheckoutPaymentOption = {
|
||||
id: stripeMethods[0].id,
|
||||
title: "Online-Zahlung",
|
||||
icons: combinedIcons,
|
||||
provider: "stripe",
|
||||
hint: "Kreditkarte, PayPal & weitere Methoden — die genaue Zahlungsart wählst du im nächsten Schritt.",
|
||||
};
|
||||
return [...manual, online];
|
||||
}
|
||||
|
||||
export type WerkzeugeCard = {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -760,6 +853,14 @@ export type CompanySettings = {
|
||||
sellerEmail: string;
|
||||
vatId: string;
|
||||
taxRatePercent: number;
|
||||
// Kleinunternehmerregelung (§19 UStG) — when true, checkout forces every
|
||||
// order's items to 0% VAT (never de-grossed, unlike the intra-community
|
||||
// exemption) and the tax rate above is ignored. Read live only at
|
||||
// checkout time (see api/checkout/route.ts) to decide what to snapshot
|
||||
// onto the new order — never read live when rendering an existing
|
||||
// order's invoice, see OrderSnapshot/CustomerOrderDetail's own
|
||||
// `kleinunternehmer` field for why.
|
||||
kleinunternehmer: boolean;
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
};
|
||||
@@ -803,3 +904,72 @@ export async function getDefaultTaxRatePercent(): Promise<number> {
|
||||
const data: { docs?: { taxRatePercent: number }[] } = await res.json();
|
||||
return data.docs?.[0]?.taxRatePercent ?? 19;
|
||||
}
|
||||
|
||||
// Same ISR-cached, public-catalog-freshness fetch as getDefaultTaxRatePercent()
|
||||
// above (a separate round trip rather than reusing getCompanySettings()'s
|
||||
// deliberate cache: "no-store") — powers the "inkl. X% MwSt." storefront
|
||||
// hints (dropped entirely when this is true, see ProductGrid.tsx/
|
||||
// ProductSpotlight.tsx/etc.) and the cart/checkout VAT-breakdown display.
|
||||
export async function getKleinunternehmer(): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getKleinunternehmer: Payload returned ${res.status} ${res.statusText}`);
|
||||
return false;
|
||||
}
|
||||
const data: { docs?: { kleinunternehmer: boolean }[] } = await res.json();
|
||||
return data.docs?.[0]?.kleinunternehmer ?? false;
|
||||
}
|
||||
|
||||
export type SeoSettings = {
|
||||
defaultTitle: string | null;
|
||||
titleTemplate: string | null;
|
||||
defaultDescription: string | null;
|
||||
defaultOgImage: string | null;
|
||||
};
|
||||
|
||||
// Fallback matches the values hardcoded in app/layout.tsx before this field
|
||||
// existed — used whenever the backend field is empty or unreachable, so
|
||||
// filling in the CompanySettings SEO tab is optional, not a hard
|
||||
// dependency for the site to render sensible metadata.
|
||||
const SEO_SETTINGS_FALLBACK: SeoSettings = {
|
||||
defaultTitle: "einfach produktiv. – Werkzeuge und Impulse für einen leichteren Alltag",
|
||||
titleTemplate: "%s | einfach produktiv.",
|
||||
defaultDescription: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
|
||||
defaultOgImage: null,
|
||||
};
|
||||
|
||||
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
|
||||
// above — every page's metadata reads this, so it needs to be cheap/cached,
|
||||
// not the always-fresh getCompanySettings() used for invoice generation.
|
||||
export async function getSeoSettings(): Promise<SeoSettings> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1", depth: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getSeoSettings: Payload returned ${res.status} ${res.statusText}`);
|
||||
return SEO_SETTINGS_FALLBACK;
|
||||
}
|
||||
const data: {
|
||||
docs?: {
|
||||
seoDefaultTitle?: string | null;
|
||||
seoTitleTemplate?: string | null;
|
||||
seoDefaultDescription?: string | null;
|
||||
seoDefaultOgImage?: { url?: string } | number | null;
|
||||
}[];
|
||||
} = await res.json();
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return SEO_SETTINGS_FALLBACK;
|
||||
return {
|
||||
defaultTitle: doc.seoDefaultTitle || SEO_SETTINGS_FALLBACK.defaultTitle,
|
||||
titleTemplate: doc.seoTitleTemplate || SEO_SETTINGS_FALLBACK.titleTemplate,
|
||||
defaultDescription: doc.seoDefaultDescription || SEO_SETTINGS_FALLBACK.defaultDescription,
|
||||
defaultOgImage:
|
||||
(typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { sendOrderConfirmationEmail, type OrderConfirmationEmailData } from "../orderEmail";
|
||||
import { sendCriticalAlert } from "../alertAdmin";
|
||||
|
||||
// The `order` snapshot returned by the backend's confirm-payment endpoint
|
||||
// (see docker/payload's src/lib/endpoints/confirmPayment.ts) — matches
|
||||
// OrderConfirmationEmailData minus `customerEmail`, which is passed
|
||||
// separately to sendOrderConfirmationEmail. Backend has no SMTP-based
|
||||
// order-confirmation sender of its own (only the 4 status-change
|
||||
// templates), so it returns everything needed here instead of the
|
||||
// frontend needing an authenticated order-read path it doesn't otherwise
|
||||
// have (ORDER_SERVICE_SECRET only ever authorizes *creating* an order).
|
||||
export type ConfirmPaymentOrderSnapshot = OrderConfirmationEmailData & { customerEmail: string };
|
||||
|
||||
// Called from both the real Stripe webhook route and its PAYMENT_TEST_MODE
|
||||
// test-confirm sibling, right after confirm-payment reports success (and
|
||||
// NOT `alreadyProcessed: true` — a repeat delivery must never resend
|
||||
// this). Mirrors exactly what app/api/checkout/route.ts already does for
|
||||
// a manual/Überweisung order today, just triggered from the payment
|
||||
// webhook instead of the checkout request itself for gated methods.
|
||||
export async function sendConfirmedPaymentEmail(order: ConfirmPaymentOrderSnapshot): Promise<void> {
|
||||
const { customerEmail, ...emailData } = order;
|
||||
try {
|
||||
await sendOrderConfirmationEmail(emailData, customerEmail);
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nach Zahlungsbestätigung nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { stripeProvider } from "./stripeProvider";
|
||||
import { mockProvider } from "./mockProvider";
|
||||
import type { PaymentProvider } from "./types";
|
||||
|
||||
export * from "./types";
|
||||
|
||||
// Defaults to test mode whenever no real Stripe key is configured, so a
|
||||
// fresh local checkout (or CI) never accidentally tries to call the real
|
||||
// Stripe API — matches PAYMENT_TEST_MODE's documented default in the plan.
|
||||
const TEST_MODE = process.env.PAYMENT_TEST_MODE
|
||||
? process.env.PAYMENT_TEST_MODE === "true"
|
||||
: !process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
export const paymentProvider: PaymentProvider = TEST_MODE ? mockProvider : stripeProvider;
|
||||
export const isPaymentTestMode = TEST_MODE;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PaymentProvider, CreatePaymentIntentResult } from "./types";
|
||||
|
||||
// PAYMENT_TEST_MODE stand-in (plan §7) — no network call, no real Stripe
|
||||
// account needed. The synthetic providerReference is still persisted on
|
||||
// the order exactly like a real one, so the whole downstream pipeline
|
||||
// (webhooks/stripe/test-confirm, confirm-payment, expirePendingPayments)
|
||||
// runs unmodified against it.
|
||||
async function createPaymentIntent(): Promise<CreatePaymentIntentResult> {
|
||||
const fakeId = `pi_test_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
||||
return { clientSecret: `${fakeId}_secret_mock`, providerReference: fakeId };
|
||||
}
|
||||
|
||||
async function attachOrderMetadata(): Promise<void> {
|
||||
// No real PaymentIntent to attach metadata to — nothing to do. The
|
||||
// test-confirm route (used instead of a real webhook in test mode)
|
||||
// already receives the order's id directly from the client, so it
|
||||
// never needs to resolve it via metadata the way the real webhook does.
|
||||
}
|
||||
|
||||
export const mockProvider: PaymentProvider = { createPaymentIntent, attachOrderMetadata };
|
||||
@@ -0,0 +1,85 @@
|
||||
import Stripe from "stripe";
|
||||
import type { PaymentProvider, CreatePaymentIntentInput, CreatePaymentIntentResult } from "./types";
|
||||
|
||||
// Server-only — never imported from a "use client" file. Same
|
||||
// process.env-at-point-of-use convention as vies.ts/brevo.ts (no
|
||||
// throwing on a missing key; an unset STRIPE_SECRET_KEY just makes every
|
||||
// call fail at request time, which is the expected state whenever
|
||||
// PAYMENT_TEST_MODE is on and this module is never actually invoked).
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || "";
|
||||
|
||||
let client: Stripe | null = null;
|
||||
function getClient(): Stripe {
|
||||
if (!client) client = new Stripe(STRIPE_SECRET_KEY);
|
||||
return client;
|
||||
}
|
||||
|
||||
async function createPaymentIntent(input: CreatePaymentIntentInput): Promise<CreatePaymentIntentResult> {
|
||||
// automatic_payment_methods lets Stripe itself decide card vs. PayPal
|
||||
// vs. any other method active on this account/region — one PaymentIntent
|
||||
// covers both required methods, per the plan's provider choice (Payment
|
||||
// Element, not per-method Checkout Sessions).
|
||||
const intent = await getClient().paymentIntents.create({
|
||||
amount: input.amountCents,
|
||||
currency: input.currency,
|
||||
receipt_email: input.customerEmail,
|
||||
description: input.description,
|
||||
automatic_payment_methods: { enabled: true },
|
||||
});
|
||||
if (!intent.client_secret) throw new Error("Stripe did not return a client_secret");
|
||||
return { clientSecret: intent.client_secret, providerReference: intent.id };
|
||||
}
|
||||
|
||||
// Called right after the order is persisted in Payload (see
|
||||
// app/api/checkout/route.ts) — the PaymentIntent has to exist before the
|
||||
// order can reference its id (providerReference), so metadata pointing
|
||||
// the other way (PaymentIntent -> order) can only be attached in a
|
||||
// second call, not at creation. This is what lets
|
||||
// app/api/webhooks/stripe/route.ts resolve an incoming
|
||||
// `payment_intent.*` event back to a specific Payload order without a
|
||||
// separate, unauthenticated-from-Stripe's-side lookup endpoint.
|
||||
//
|
||||
// Awaited but non-fatal to checkout on failure (see the call site) — the
|
||||
// order and its own `providerReference` field are already the source of
|
||||
// truth for admin/cleanup-job reconciliation; this metadata only matters
|
||||
// for the webhook's fast path.
|
||||
async function attachOrderMetadata(providerReference: string, metadata: { orderId: string; orderNumber: string }): Promise<void> {
|
||||
await getClient().paymentIntents.update(providerReference, { metadata });
|
||||
}
|
||||
|
||||
export const stripeProvider: PaymentProvider = { createPaymentIntent, attachOrderMetadata };
|
||||
|
||||
const PAYMENT_METHOD_LABELS: Record<string, string> = { card: "Kreditkarte", paypal: "PayPal" };
|
||||
|
||||
// Called only by the real webhook route on `payment_intent.succeeded` —
|
||||
// the checkout route snapshots a neutral "Online-Zahlung" title at order
|
||||
// creation (see its own comment: the customer hasn't chosen an instrument
|
||||
// yet at that point, Stripe's Payment Element does that next), this
|
||||
// resolves the actual one once Stripe reports it so the order/invoice/
|
||||
// confirmation email reflect what was really used, not a placeholder.
|
||||
// Best-effort: an unresolvable label just leaves the neutral title in
|
||||
// place (confirmPayment.ts only overwrites paymentMethodTitle when this
|
||||
// returns something), it doesn't fail the payment confirmation itself.
|
||||
export async function resolveStripePaymentMethodLabel(intent: Stripe.PaymentIntent): Promise<string | undefined> {
|
||||
const pm = intent.payment_method;
|
||||
const pmId = typeof pm === "string" ? pm : pm?.id;
|
||||
if (!pmId) return undefined;
|
||||
try {
|
||||
const resolved = pm && typeof pm === "object" ? pm : await getClient().paymentMethods.retrieve(pmId);
|
||||
return PAYMENT_METHOD_LABELS[resolved.type] ?? resolved.type;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Only used by the real webhook route (never through the PaymentProvider
|
||||
// interface — signature verification is inherently Stripe-shaped, no
|
||||
// other provider exists to share this contract with yet).
|
||||
export function verifyStripeWebhookSignature(rawBody: string, signatureHeader: string): Stripe.Event | null {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || "";
|
||||
try {
|
||||
return getClient().webhooks.constructEvent(rawBody, signatureHeader, webhookSecret);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Provider-agnostic contract — see the approved payment plan
|
||||
// (spicy-leaping-pizza.md §0/§7). Stripe is the only real implementation
|
||||
// today (stripeProvider.ts); mockProvider.ts implements the same shape
|
||||
// for PAYMENT_TEST_MODE so the checkout route never branches on which
|
||||
// provider is active, only on whether one is configured at all.
|
||||
|
||||
export type CreatePaymentIntentInput = {
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
customerEmail: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type CreatePaymentIntentResult = {
|
||||
clientSecret: string;
|
||||
providerReference: string;
|
||||
};
|
||||
|
||||
export type ProviderPaymentUpdate = {
|
||||
providerReference: string;
|
||||
paymentStatus: "paid" | "failed";
|
||||
paidAt: string;
|
||||
};
|
||||
|
||||
export interface PaymentProvider {
|
||||
createPaymentIntent(input: CreatePaymentIntentInput): Promise<CreatePaymentIntentResult>;
|
||||
// Best-effort, awaited but never fatal to checkout — lets the webhook
|
||||
// handler resolve providerReference -> order without the frontend
|
||||
// having to persist a second field via an update path that doesn't
|
||||
// otherwise exist (see stripeProvider.ts's own comment).
|
||||
attachOrderMetadata(providerReference: string, metadata: { orderId: string; orderNumber: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { validateEmailFormat } from "./email";
|
||||
import type { NewsletterOptInSource } from "./brevo";
|
||||
|
||||
// Shared state/submit logic behind every newsletter-signup form
|
||||
// (Newsletter.tsx, NewsletterModal.tsx, WeeklyImpulsesHero.tsx's inline
|
||||
// hero form, /challenge's EmailCapture) — four places with the same
|
||||
// email+consent+submit shape but different markup/visual style, so only
|
||||
// the logic is shared here rather than a one-size-fits-all component.
|
||||
export function useNewsletterSignup(source: NewsletterOptInSource) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [error, setError] = useState("");
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleEmailChange(value: string) {
|
||||
setEmail(value);
|
||||
if (emailError) setEmailError("");
|
||||
}
|
||||
|
||||
function handleEmailBlur(value: string) {
|
||||
setEmailError(validateEmailFormat(value));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const formatError = validateEmailFormat(email);
|
||||
setEmailError(formatError);
|
||||
if (formatError) {
|
||||
emailRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
setStatus("submitting");
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/newsletter/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, consent, source }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setStatus("success");
|
||||
} catch {
|
||||
setError("Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut.");
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
return { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Innergemeinschaftliche Lieferung (§4 Nr. 1b UStG) — a cross-border EU B2B
|
||||
// sale with a VIES-validated buyer VAT ID is zero-rated. Kept separate from
|
||||
// cartTotals.ts/computeTaxBreakdown (which assume each item's own
|
||||
// catalog tax rate) rather than bolted onto them — this is a genuinely
|
||||
// different computation (every rate forced to 0%, every price de-grossed
|
||||
// from its normal VAT-inclusive catalog price to net), used in exactly two
|
||||
// places: CheckoutContent.tsx's live preview and api/checkout/route.ts's
|
||||
// authoritative recompute, which must stay in exact agreement.
|
||||
//
|
||||
// Deliberate simplification: `discountAmount` is carried over unchanged
|
||||
// (not itself re-derived against the de-grossed subtotal) — a discount
|
||||
// code combined with a validated cross-border exemption is a narrow
|
||||
// overlap, and the existing discount math (percent-of-subtotal or a flat
|
||||
// amount, see cartTotals.ts's computeCartTotals) already produces a
|
||||
// reasonable number either way. Revisit only if this combination turns out
|
||||
// to matter in practice.
|
||||
export type ExemptLine = { quantity: number; grossUnitPrice: number; taxRatePercent: number };
|
||||
|
||||
function roundMoney(amount: number): number {
|
||||
return Math.round(amount * 100) / 100;
|
||||
}
|
||||
|
||||
function degross(grossAmount: number, ratePercent: number): number {
|
||||
return grossAmount / (1 + ratePercent / 100);
|
||||
}
|
||||
|
||||
export type ExemptTotals = { subtotal: number; shippingCost: number; total: number };
|
||||
|
||||
// `shippingCostGross`/`defaultTaxRate` — shipping has no per-line tax rate
|
||||
// of its own (see taxBreakdown.ts's proportional-scale comment), so it's
|
||||
// de-grossed at the tenant's default rate as the representative rate,
|
||||
// same fallback cartTotals.ts's effectiveTaxRate() already uses elsewhere.
|
||||
export function computeExemptTotals(items: ExemptLine[], shippingCostGross: number, defaultTaxRate: number, discountAmount: number): ExemptTotals {
|
||||
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * degross(i.grossUnitPrice, i.taxRatePercent), 0));
|
||||
const shippingCost = roundMoney(degross(shippingCostGross, defaultTaxRate));
|
||||
const total = roundMoney(Math.max(0, subtotal - discountAmount) + shippingCost);
|
||||
return { subtotal, shippingCost, total };
|
||||
}
|
||||
|
||||
// The destination the goods actually ship to, not necessarily the billing
|
||||
// address — the exemption depends on where the goods physically move to,
|
||||
// which is the shipping override's country when one is set (see Orders.ts's
|
||||
// own hasDifferentShippingAddress comment), the billing country otherwise.
|
||||
export function destinationCountry(country: string, hasDifferentShippingAddress: boolean, shippingCountry: string | null | undefined): string {
|
||||
return hasDifferentShippingAddress && shippingCountry ? shippingCountry : country;
|
||||
}
|
||||
|
||||
// Only Österreich is a real candidate today — this checkout offers exactly
|
||||
// three countries (Deutschland/Österreich/Schweiz, see CheckoutContent.tsx's
|
||||
// own PLZ_DIGITS), and Deutschland (domestic) / Schweiz (non-EU export, a
|
||||
// different exemption entirely) never qualify for this specific one.
|
||||
export function isExemptionEligibleCountry(country: string): boolean {
|
||||
return country === "Österreich";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Server-only — calls the European Commission's public VIES REST API to
|
||||
// confirm an EU VAT ID is actually registered, not just correctly
|
||||
// formatted (see lib/vatId.ts's own comment: format alone is never
|
||||
// enough to zero-rate an invoice). Confirmed live and working against
|
||||
// the real endpoint 2026-07-23 (POST {countryCode, vatNumber} →
|
||||
// {valid: boolean, ...}) — this is the Commission's own documented REST
|
||||
// API, not a guess.
|
||||
const VIES_URL = "https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number";
|
||||
|
||||
export type ViesCheckResult =
|
||||
| { ok: true; valid: boolean; name: string | null; address: string | null }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
// `vatNumber` must NOT include the country prefix (VIES wants it split
|
||||
// out) — callers pass the full "DE123456789"-shaped id and this function
|
||||
// does the splitting, since every call site already has the normalized
|
||||
// full id (see lib/vatId.ts's normalizeVatId()) rather than the two parts
|
||||
// separately.
|
||||
export async function checkVatIdViaVies(vatId: string): Promise<ViesCheckResult> {
|
||||
const countryCode = vatId.slice(0, 2);
|
||||
const vatNumber = vatId.slice(2);
|
||||
if (!countryCode || !vatNumber) return { ok: false, reason: "Ungültiges USt-IdNr.-Format." };
|
||||
|
||||
try {
|
||||
// 8s timeout — VIES is a shared EU-wide government service with no
|
||||
// uptime SLA to this shop; a slow/unreachable response must not hang
|
||||
// checkout indefinitely. Callers treat `ok: false` as "couldn't
|
||||
// confirm" and fail closed (no exemption), never as "confirmed invalid".
|
||||
const res = await fetch(VIES_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ countryCode, vatNumber }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return { ok: false, reason: `VIES antwortete mit ${res.status}` };
|
||||
const data: { actionSucceed?: boolean; valid?: boolean; name?: string; address?: string; errorWrappers?: { error?: string }[] } = await res.json();
|
||||
// VIES answers 200 even when it couldn't actually perform the check —
|
||||
// `actionSucceed: false` (e.g. `MS_UNAVAILABLE`, the member state's own
|
||||
// national gateway being temporarily down — Germany's in particular is
|
||||
// known to do this) means "couldn't confirm", not "confirmed invalid".
|
||||
// Without this check a `MS_UNAVAILABLE` response fell through to
|
||||
// `Boolean(data.valid)` on a body that has no `valid` field at all,
|
||||
// silently reading as `valid: false` — a real, currently-registered VAT
|
||||
// ID would then look rejected instead of "VIES unavailable, try again".
|
||||
if (data.actionSucceed === false) {
|
||||
const reason = data.errorWrappers?.[0]?.error ?? "VIES konnte die Anfrage nicht bearbeiten.";
|
||||
return { ok: false, reason: `VIES: ${reason}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
valid: Boolean(data.valid),
|
||||
name: data.name && data.name !== "---" ? data.name : null,
|
||||
address: data.address && data.address !== "---" ? data.address : null,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "VIES ist gerade nicht erreichbar." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
|
||||
// robots: noindex — transactional landing page (Brevo's double opt-in
|
||||
// redirectionUrl target, see app/lib/brevo.ts's BREVO_DOI_REDIRECT_URL),
|
||||
// same reasoning as /bestellbestaetigung and /checkout: nothing here is
|
||||
// meant to be found via search, only reached via the confirmation link.
|
||||
export const metadata: Metadata = {
|
||||
title: "Newsletter bestätigt",
|
||||
description: "Deine Newsletter-Anmeldung bei einfach produktiv ist bestätigt.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Static — Brevo's confirmation click lands here with no query params to
|
||||
// read, so unlike /bestellbestaetigung (which hydrates a sessionStorage
|
||||
// order snapshot) or /checkout/verarbeitung (which polls payment status),
|
||||
// this page has nothing to fetch or wait on. Same visual language as
|
||||
// those two: warm bg-bg-base, brand-tinted circular icon, serif display
|
||||
// heading, thin brand divider — see BestellbestaetigungContent.tsx for
|
||||
// the pattern this mirrors.
|
||||
export default function NewsletterConfirmedPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-4 items-center text-center pt-24 pb-16 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="flex items-center justify-center size-14 rounded-full bg-brand/10 text-brand shrink-0">
|
||||
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
|
||||
<path d="M5 13.5 9.5 18 19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
Bestätigt!
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Du bist jetzt Teil unseres Newsletters.
|
||||
</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
<p className="text-body text-text-muted max-w-[28rem] pt-2">
|
||||
Schön, dass du dabei bist! Ab jetzt bekommst du hin und wieder Impulse, neue Produkte
|
||||
und kleine Erinnerungen von uns, damit dein Alltag ein bisschen leichter wird.
|
||||
</p>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="inline-flex items-center justify-center py-4 px-8 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt stöbern
|
||||
</Link>
|
||||
</Reveal>
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { StepArrow } from "../../components/StepArrow";
|
||||
|
||||
// Each icon's own real pixel dimensions (not uniformly square) — needed so
|
||||
// the `h-16 w-auto` sizing below infers the correct aspect ratio instead of
|
||||
@@ -71,18 +72,15 @@ export function HowItWorks() {
|
||||
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
|
||||
</RevealItem>
|
||||
{i < steps.length - 1 && (
|
||||
// md:mt-[1.625rem] (26px) centers the arrow on the h-16
|
||||
// (64px) icon above it — same margin-based centering
|
||||
// technique as Challenge's step connector and todo-cards'
|
||||
// identical HowItWorks, not just the same icon asset.
|
||||
<div className="flex items-center justify-center shrink-0 md:mt-[1.625rem]">
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-arrow-connector.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6 rotate-90 md:w-10 md:h-3 md:rotate-0"
|
||||
/>
|
||||
// md:mt-[1.5rem] centers the arrow on the h-16 icon above it,
|
||||
// same technique as todo-cards'/Challenge's own step
|
||||
// connector. Below md: pulled up with a negative margin so it
|
||||
// sits nearer the icon row above it instead of dead-center in
|
||||
// the whole gap between steps (fixed 2026-07-24, consistency
|
||||
// with Challenge's icon-at-top layout). Bigger below md:
|
||||
// (w-8 h-8, was w-6 h-6) per explicit feedback.
|
||||
<div className="flex items-center justify-center shrink-0 -mt-2 md:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 md:w-10 md:h-4 md:rotate-0" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
|
||||
@@ -8,6 +8,17 @@ const benefits = [
|
||||
{ title: "Motivation & Erinnerung", desc: "Ein freundlicher Schub in die richtige Richtung." },
|
||||
];
|
||||
|
||||
// Inline, brand-orange stroke — same fix/reasoning as
|
||||
// WeeklyImpulsesHero.tsx's own IconCheck (icon-check.svg's fill can't be
|
||||
// recolored from outside the SVG when loaded via <img src>/next/image).
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeeklyBenefits() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
@@ -31,7 +42,7 @@ export function WeeklyBenefits() {
|
||||
<ul className="flex flex-col gap-4 items-start w-full">
|
||||
{benefits.map((b) => (
|
||||
<li key={b.title} className="flex gap-[0.625rem] items-start w-full">
|
||||
<Image alt="" src="/icon-check.svg" width={20} height={20} className="size-5 shrink-0 mt-0.5" />
|
||||
<IconCheck />
|
||||
<div className="flex flex-col gap-0.5 items-start flex-1 min-w-0">
|
||||
<p className="font-semibold text-body text-text-primary">{b.title}</p>
|
||||
<p className="text-body-sm text-text-muted">{b.desc}</p>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { useNewsletterSignup } from "../../lib/useNewsletterSignup";
|
||||
|
||||
// Same lock icon + copy as /challenge's and the shared Newsletter
|
||||
// component's trust note — unified across all newsletter-signup forms.
|
||||
@@ -13,6 +16,17 @@ function LockIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
// Inline, brand-orange stroke — icon-check.svg's fill lives in an internal
|
||||
// CSS var that can't be recolored from outside the SVG when loaded via
|
||||
// <img src>/next/image, same fix/reasoning as todo-cards's own IconCheck.
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const checklist = [
|
||||
"Jeden Mittwoch neue Impulse & Tipps",
|
||||
"Kurz & knackig – in 5 Minuten gelesen",
|
||||
@@ -21,6 +35,9 @@ const checklist = [
|
||||
];
|
||||
|
||||
export function WeeklyImpulsesHero() {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-hero");
|
||||
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Same lg:-only structural exception as Home/todo-cards Hero (see
|
||||
@@ -83,8 +100,8 @@ export function WeeklyImpulsesHero() {
|
||||
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{checklist.map((item) => (
|
||||
<li key={item} className="flex gap-[0.625rem] items-center w-full">
|
||||
<Image alt="" src="/icon-check.svg" width={20} height={20} className="size-5 shrink-0" />
|
||||
<li key={item} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 text-body text-text-primary">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -93,46 +110,80 @@ export function WeeklyImpulsesHero() {
|
||||
{/* Inline email capture — page-specific, simpler than the shared
|
||||
Newsletter component's panel form (no button-adjacent styling
|
||||
needed here, just input + submit inline). */}
|
||||
<div className="flex gap-3 items-start w-full sm:w-auto">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="w-full sm:w-[17.5rem] bg-bg-base border border-border rounded-sm px-4 py-[0.8125rem] text-body-sm text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-brand rounded-sm px-6 py-[0.8125rem] font-bold text-body text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 items-start w-full">
|
||||
{/* flex-col sm:flex-row, no items-start at the base tier —
|
||||
stacks full-width below sm: (default align-items:
|
||||
stretch is what makes the button fill the row once
|
||||
stacked), same pattern as /challenge's EmailCapture.
|
||||
Was a fixed row at every width before, squeezing input
|
||||
+ button together on a narrow phone (fixed 2026-07-24,
|
||||
consistency with the other mail CTAs). */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-3 w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`w-full sm:w-[17.5rem] bg-bg-base border rounded-sm px-4 py-[0.8125rem] text-body-sm text-text-muted font-normal outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="shrink-0 bg-brand rounded-sm px-6 py-[0.8125rem] font-bold text-body text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
|
||||
</button>
|
||||
</div>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal">{emailError}</p>
|
||||
)}
|
||||
|
||||
{/* Consent checkbox — this signup's legal basis is consent
|
||||
(email marketing), same wording/pattern as the shared
|
||||
Newsletter component's and NewsletterModal's checkbox. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{/* Consent checkbox — this signup's legal basis is consent
|
||||
(email marketing), same wording/pattern as the shared
|
||||
Newsletter component's and NewsletterModal's checkbox. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-[0.375rem] items-center">
|
||||
<LockIcon />
|
||||
<span className="text-label text-[#888]">Keine Werbung. Jederzeit abbestellbar.</span>
|
||||
</div>
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-[0.375rem] items-center">
|
||||
<LockIcon />
|
||||
<span className="text-label text-[#888]">Keine Werbung. Jederzeit abbestellbar.</span>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getProducts, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload";
|
||||
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
@@ -13,7 +13,12 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
|
||||
// gives faster first paint and no loading flash.
|
||||
|
||||
export async function ProductGrid() {
|
||||
const [allProducts, shipping, defaultTaxRate] = await Promise.all([getProducts(), getShippingSettings(), getDefaultTaxRatePercent()]);
|
||||
const [allProducts, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getProducts(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const products = allProducts.filter((p) => p.active);
|
||||
|
||||
if (products.length === 0) {
|
||||
@@ -78,7 +83,7 @@ export async function ProductGrid() {
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
|
||||
@@ -15,6 +15,7 @@ export const metadata: Metadata = {
|
||||
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
|
||||
url: "/shop",
|
||||
type: "website",
|
||||
images: ["/hero-todo-karten.png"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,17 @@ const bullets = [
|
||||
"Inklusive Mini-Anleitung mit Tipps für den Start",
|
||||
];
|
||||
|
||||
// Inline, brand-orange stroke — same fix/reasoning as TodoKartenHero.tsx's
|
||||
// own IconCheck (icon-check.svg's fill can't be recolored from outside
|
||||
// the SVG when loaded via <img src>/next/image).
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Focus() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
@@ -33,8 +44,8 @@ export function Focus() {
|
||||
</p>
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{bullets.map((b) => (
|
||||
<li key={b} className="flex gap-[0.625rem] items-center w-full">
|
||||
<Image alt="" src="/icon-check.svg" width={20} height={20} className="size-5 shrink-0" />
|
||||
<li key={b} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 font-semibold text-body text-text-primary">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { StepArrow } from "../../components/StepArrow";
|
||||
|
||||
// Each icon's own real pixel dimensions (not uniformly square, e.g.
|
||||
// icon-step-2 is 180x168) — needed so the `h-16 w-auto` sizing below infers
|
||||
@@ -79,17 +80,12 @@ export function HowItWorks() {
|
||||
</RevealItem>
|
||||
{i < steps.length - 1 && (
|
||||
// md:mt-[1.625rem] (26px) centers the arrow on the h-16
|
||||
// (64px) icon above it — (64 - arrow's own 12px height) / 2
|
||||
// (64px) icon above it — (64 - arrow's own 16px height) / 2
|
||||
// — same margin-based centering technique as Challenge's
|
||||
// step connector, not just the same icon asset.
|
||||
<div className="flex items-center justify-center shrink-0 md:mt-[1.625rem]">
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-arrow-connector.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6 rotate-90 md:w-10 md:h-3 md:rotate-0"
|
||||
/>
|
||||
// step connector, not just the same icon asset. Bigger below
|
||||
// md: (w-8 h-8, was w-6 h-6) per explicit feedback.
|
||||
<div className="flex items-center justify-center shrink-0 md:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 md:w-10 md:h-4 md:rotate-0" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
|
||||
@@ -18,17 +18,27 @@ const bullets = [
|
||||
// reasoning; the bullet list stays hand-written since it's spec detail,
|
||||
// not something the Products collection models.
|
||||
export async function Pricing() {
|
||||
const [product, shipping, defaultTaxRate] = await Promise.all([
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getProductBySlug("todo-karten"),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
if (!product) return null;
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
// Same "any vs. every" split as ProductGrid.tsx/ProductSpotlight.tsx.
|
||||
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
|
||||
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
|
||||
// A deactivated product (product.active === false) isn't caught by
|
||||
// either — the shop grid/spotlight filter those out before they'd ever
|
||||
// reach this page, but this page resolves a product regardless of
|
||||
// active status (existing links to it should still 200, see
|
||||
// mapPayloadProduct's own comment in lib/payload.ts) — so it needs its
|
||||
// own explicit check here to read as "Ausverkauft" rather than fully
|
||||
// buyable (fixed 2026-07-24, feedback: deactivated should look
|
||||
// sold-out on its own detail page).
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
|
||||
@@ -87,7 +97,7 @@ export async function Pricing() {
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt. zzgl. Versand</p>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
@@ -99,12 +109,17 @@ export async function Pricing() {
|
||||
this is an add-to-cart step, not the actual payment step, so
|
||||
a payment-security reassurance is premature here and just
|
||||
duplicates the one shown later at checkout. */}
|
||||
{/* variants={[]} when deactivated — AddToCartButton's own
|
||||
outOfStock prop is ignored whenever variants is non-empty (it
|
||||
defers to each variant's own outOfStock flag instead, see its
|
||||
currentlyOutOfStock calc), so a deactivated product with
|
||||
in-stock variants would otherwise still render as buyable. */}
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
outOfStock={product.outOfStock}
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.variants}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
|
||||
@@ -12,6 +12,19 @@ const checklist = [
|
||||
"Minimalistisch, analog, effektiv",
|
||||
];
|
||||
|
||||
// Inline, brand-orange stroke — icon-check.svg's fill is hardcoded to
|
||||
// #222221 via an internal CSS var that only resolves inside the SVG's own
|
||||
// document, so it can't be recolored from the host page when loaded via
|
||||
// <img src>/next/image. Same simple checkmark path as Challenge's own
|
||||
// Check() component, for the same orange-checkmark look (fixed 2026-07-24).
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Same "todo-karten" product Pricing.tsx reads further down the page —
|
||||
// this is just a compact early teaser so the hero's CTA isn't asking for
|
||||
// a click without saying what it costs. Delivery time is repeated here too
|
||||
@@ -19,15 +32,18 @@ const checklist = [
|
||||
// §1 Abs.1 Nr.8 EGBGB's delivery-date disclosure needs to sit next to every
|
||||
// buy button, not just one of them.
|
||||
export async function TodoKartenHero() {
|
||||
const [product, shipping, defaultTaxRate] = await Promise.all([
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getProductBySlug("todo-karten"),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
|
||||
const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null;
|
||||
// Same "any vs. every" split as ProductGrid.tsx/Pricing.tsx.
|
||||
const anyLowStock = product ? (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock) : false;
|
||||
const anyLowStock = product
|
||||
? product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
@@ -93,8 +109,8 @@ export async function TodoKartenHero() {
|
||||
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{checklist.map((item) => (
|
||||
<li key={item} className="flex gap-[0.625rem] items-center w-full">
|
||||
<Image alt="" src="/icon-check.svg" width={20} height={20} className="size-5 shrink-0" />
|
||||
<li key={item} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 text-body text-text-primary">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -107,7 +123,7 @@ export async function TodoKartenHero() {
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt.</p>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-label text-text-muted">
|
||||
@@ -119,7 +135,15 @@ export async function TodoKartenHero() {
|
||||
</div>
|
||||
|
||||
{product && (
|
||||
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
// variants={[]} when deactivated — see Pricing.tsx's own
|
||||
// comment on why AddToCartButton's outOfStock prop alone
|
||||
// isn't enough once variants is non-empty.
|
||||
<AddToCartButton
|
||||
label="ToDo-Karten bestellen"
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { SectionTOC } from "../../components/SectionTOC";
|
||||
import { SectionTOC, MobileSectionTOC } from "../../components/SectionTOC";
|
||||
import { VERSAND_SECTION_IDS } from "./VersandSections";
|
||||
|
||||
export function VersandTOC() {
|
||||
return <SectionTOC sections={[...VERSAND_SECTION_IDS]} />;
|
||||
}
|
||||
|
||||
// Below lg: accordion counterpart — see SectionTOC.tsx's own comment on
|
||||
// why this needs to be a separate element rather than nested inside
|
||||
// VersandTOC/the page's `hidden lg:block` sidebar wrapper.
|
||||
export function MobileVersandTOC() {
|
||||
return <MobileSectionTOC sections={[...VERSAND_SECTION_IDS]} />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { VersandSections } from "./components/VersandSections";
|
||||
import { VersandTOC } from "./components/VersandTOC";
|
||||
import { VersandTOC, MobileVersandTOC } from "./components/VersandTOC";
|
||||
import { getShippingSettings } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -36,6 +36,12 @@ export default async function VersandPage() {
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC counterpart — below lg: only, see
|
||||
SectionTOC.tsx's own comment. */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileVersandTOC />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:block lg:sticky lg:top-32 lg:self-start">
|
||||
<VersandTOC />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Footer } from "../components/Footer";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -39,6 +39,13 @@ export default async function WiderrufPage() {
|
||||
<p className="text-body text-text-muted">Stand: Juli 2026</p>
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
|
||||
comment. Outside the sidebar's `hidden lg:flex` wrapper below
|
||||
(that wrapper's `hidden` would hide this too otherwise). */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileSectionTOC sections={headings} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
|
||||
Generated
+3943
-51
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -12,12 +12,16 @@
|
||||
"dependencies": {
|
||||
"@einfach-produktiv/invoicing": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#main",
|
||||
"@payloadcms/live-preview-react": "^3.85.2",
|
||||
"@payloadcms/richtext-lexical": "^3.85.2",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@stripe/react-stripe-js": "^6.8.0",
|
||||
"@stripe/stripe-js": "^9.12.0",
|
||||
"motion": "^12.42.2",
|
||||
"next": "16.2.9",
|
||||
"nodemailer": "^9.0.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"stripe": "^22.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
Reference in New Issue
Block a user