Compare commits

...

71 Commits

Author SHA1 Message Date
Marco ff6118a778 Fix icon circle collapsing narrow in strict HTML-attribute-stripping renderers
The width="56" height="56" HTML attributes on the icon badge <td> weren't
mirrored in its inline style — renderers that ignore/strip HTML
width/height (Brevo's own editor among them, per a real report) shrink
the cell to fit the glyph instead of staying a 56px circle. Add explicit
width:56px;height:56px to the style too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 14:31:08 +00:00
Marco 3328f30a06 Add /newsletter-confirmed double-opt-in confirmation page
Brevo's redirectionUrl now points here instead of the homepage — a
static page matching /bestellbestaetigung's visual language (brand-
tinted checkmark circle, serif display heading, thin brand divider).
No query params to read; Brevo's confirmation redirect carries nothing
this page needs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 14:08:52 +00:00
Marco df4bd700e6 Switch newsletter signup to Brevo double opt-in
Was a plain POST /v3/contacts upsert (single opt-in — straight onto the
list, no confirmation required). Now calls doubleOptinConfirmation
instead, so a signup only requests subscription; Brevo sends its own
confirmation email and adds the contact to the real list only once they
click through. Needs BREVO_DOUBLE_OPTIN_TEMPLATE_ID set in Coolify
before this works — not yet configured, signups will fail closed with a
logged reason until it is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 13:40:27 +00:00
Marco bab2c916be Consolidate Kreditkarte/PayPal into one "Online-Zahlung" checkout option
Both already route through the same Stripe PaymentIntent
(automatic_payment_methods: enabled — Stripe's own recommended Payment
Element pattern, letting Stripe itself decide which eligible method to
show). Pre-selecting one of two identical-behind-the-scenes rows before
the payment step was redundant friction, not a real choice. Collapses
them into one option with a hint text explaining the actual instrument
is picked on the next screen; Überweisung is unaffected.

Also refines paymentMethodTitle from a neutral "Online-Zahlung"
placeholder (snapshotted at order-creation time, before the customer has
picked an instrument) to the real one Stripe reports, once payment
confirms — carried through to both the stored order and the
sessionStorage snapshot shown on /bestellbestaetigung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 12:17:38 +00:00
Marco 4e22942031 Send the customer confirmation email from the payment webhook
The webhook route previously assumed the backend's confirm-payment
endpoint sent the customer confirmation email; the backend assumed the
opposite. Net effect: a successful Stripe payment never triggered any
confirmation email. Consume the order snapshot confirm-payment now
returns and send it from here, matching what the checkout route already
does for a manual/Überweisung order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 12:07:26 +00:00
Marco 740b791e5e Add Stripe payment processing (cards + PayPal) with a webhook-gated checkout flow
Checkout now branches on payment-methods.provider: Überweisung stays
immediate/unchanged, Kreditkarte/PayPal creates a pending_payment order,
mounts Stripe's Payment Element, and defers invoice/email to a webhook-
verified confirm-payment call once the backend actually confirms payment.
Includes a PAYMENT_TEST_MODE mock provider so the whole gated pipeline is
exercisable locally without a real Stripe account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 12:03:04 +00:00
Marco bb3f94d39e Revert checkout step-number circle borders to the original color
Only the connector line between steps stays the darker #c4b8a0 —
the number circles themselves go back to border-border per feedback.
2026-07-24 21:35:41 +00:00
Marco d48a00973d Fix low-contrast checkout borders, and a Tablet-sizing regression in Hero/About
- Checkout: the 4 step cards' border-border and CheckoutSteps' connector
  lines/upcoming-circle borders were nearly the same luminance as the
  page's own bg-bg-base background, barely visible. Darker (#c4b8a0),
  scoped to just these spots rather than the shared border-border token.
- Hero.tsx: fixes a mistake from the last Tablet pass — moving the CTA/
  subtitle/icon/social-proof size overrides from lg: to md: (to match the
  grid breakpoint move) actually removed their smaller sizing from the
  whole Tablet range, recreating the exact 3-line-wrap problem the lg:
  exception used to prevent. Reverted those specific overrides back to
  lg: (grid structure stays at md:), and added a smaller heading size
  (text-h1) below lg: too — text-display's 44px floor doesn't fit the
  ~283-320px Tablet column any better than the CTA did.
- About.tsx: the quote/divider/bio row and its divider orientation also
  pushed from md: to lg: — still too tight for the Tablet column even
  after the text/photo ratio swap from the previous pass.
2026-07-24 21:32:28 +00:00
Marco 7b4b54a9ac Fix homepage Tablet layout: Hero grid, Tools icon, About columns, Newsletter/Footer stacking
- Hero.tsx: structural breakpoint reverted from lg: back to md: — the
  smaller CTA/subtitle/icon sizes added during the Mobile pass fit
  comfortably in the ~283px Tablet column, so the original 3-line-wrap
  problem that justified lg: doesn't recur. Tablet gets the real 5/7
  grid (image beside text) again instead of a stacked mobile layout.
- Tools.tsx: full-size (56px) card icon pushed from md: to lg: — at
  Tablet it dwarfed the still-close-to-floor title/description text.
- About.tsx: text/photo flex ratio swapped at Tablet (text gets the
  bigger share, no overlap) vs. the original ratio + overlap trick from
  lg: up, where it was designed for — Tablet's text column was too
  narrow for its fixed-width statement + quote/bio row otherwise.
- Newsletter.tsx/Footer.tsx: the input+button row and the logo/handle/
  legal-links row both went side-by-side at md:, but their surrounding
  columns didn't leave enough width at 768px — pushed to lg:flex-row.
2026-07-24 21:20:47 +00:00
Marco 797d9d42fe Custom post content blocks (images/gallery/video/quote) + backend-driven SEO settings
RichText.tsx switched to Payload's official React renderer + custom
JSXConverters (same call signature, LiveRichText/LivePostContent
untouched) — needed to render the new Lexical Blocks the Payload repo's
Posts.content just gained. Converters follow the existing CMS-image
convention (relative + aspect-[...] + fill + object-cover); the video
block resolves YouTube/Vimeo links to an iframe embed.

New getSeoSettings() fetcher (same pattern as getKleinunternehmer()),
app/layout.tsx now generateMetadata() reading it with the same fallback
values it used to hardcode. Per-post SEO overrides (seoTitle/
seoDescription/seoImage) wired into the blog detail page's metadata,
falling back to title/excerpt/thumbnail when empty.

Also fixed while auditing every page's metadata: missing descriptions on
3 konto pages, a static title on the dynamic order-detail route, and
missing OG images on /shop and /blog.
2026-07-24 21:10:33 +00:00
Marco b1b1aa2037 Stack Impulse & Tipps hero's email CTA below sm: for consistency
Input and button were a fixed row at every width, squeezing together on a
narrow phone — now flex-col sm:flex-row, full width when stacked, same
pattern as Challenge's EmailCapture and the other mail CTAs already fixed.
2026-07-24 20:07:50 +00:00
Marco 4f95a347dc Change StepArrow back to light gray
Matches the original icon-arrow-connector.svg's own default color, just
bolder (strokeWidth 2.5 vs. 1.5) and better-shaped than the original.
2026-07-24 20:05:04 +00:00
Marco 268f2e841d Nudge checkmark/login icon vertical alignment
- Checklist checkmarks (Challenge, todo-cards, newsletter): mt-0.5 -> mt-1,
  centering against the first line's glyphs instead of sitting flush at
  the very top edge.
- Navbar login icon: -translate-y-0.5 — its round head vs. wide shoulders
  read as optically bottom-heavy next to the cart icon despite both
  having a mathematically centered bounding box.
2026-07-24 20:02:44 +00:00
Marco 8d4f167374 Deactivated product shows as sold out, StepArrow to black, newsletter checkmarks fixed
- /todo-cards's detail page (the only page resolving a product regardless
  of active status) now renders both CTAs as "Ausverkauft" and disabled
  when the product is deactivated, instead of fully buyable — variants are
  dropped too, since AddToCartButton's outOfStock prop is otherwise ignored
  whenever variants is non-empty.
- StepArrow: brand orange didn't fit here after all, switched to
  near-black (#222221) per feedback.
- Newsletter/"Impulse & Tipps" checklist checkmarks (WeeklyImpulsesHero.tsx,
  WeeklyBenefits.tsx) were still the old un-recolorable icon-check.svg —
  same inline-SVG orange-checkmark fix as todo-cards/Challenge, plus
  items-start instead of items-center on WeeklyImpulsesHero's list for
  top-alignment consistency.
2026-07-24 19:58:24 +00:00
Marco 6a6d50abdb Fix orange dot/hero image visibility, arrow alignment, and add a mobile legal-page TOC
- PopIn (Home Hero's brand dot) switched from whileInView to animate — its
  translate-based entrance could push the element off-screen on a narrow
  phone before the IntersectionObserver ever saw it as visible, leaving it
  stuck invisible permanently.
- Hero image: no longer wrapped in Reveal below lg: — whileInView's margin
  meant it stayed at opacity:0 (a white gap above the fold) on short mobile
  viewports until scrolled. Reveal's fade-in kept from lg: up.
- "→ Label" CTA links (Tools.tsx, Blog.tsx) now use a flex row with the
  arrow as its own span instead of a literal inline "→" character, which
  doesn't reliably align to the surrounding text's cap-height.
- Replaced icon-arrow-connector.svg with a new shared StepArrow component
  (inline SVG) across all three step sections — the old asset's color
  couldn't be overridden from outside the SVG file, so it could never
  actually become brand-orange. Bigger and better-shaped below the
  structural breakpoint per feedback.
- Added MobileSectionTOC (SectionTOC.tsx) — a <details> accordion shown
  below lg: on Impressum/Datenschutz/AGB/Widerruf/Versand, which previously
  had no on-page navigation aid at all below lg: (the sidebar TOC is
  `hidden` entirely there).
- Updated the figma-to-nextjs skill with 8 new dated Gotchas from this
  mobile-responsive pass, and expanded Step 6's verification checklist.
- README: new "Mobile responsive pass" section summarizing the above.
2026-07-24 19:17:56 +00:00
Marco bca29ab7a3 Fix more mobile responsive issues: Hero, arrows, cart button, checkmarks, modal
- Newsletter card icon: fixed an aspect-ratio distortion bug (w-16 maps
  to this project's fluid --spacing-16, which floors to 40px below
  768px, paired with a fixed 55px height — squished the icon on
  mobile). Same root cause existed in NewsletterModal's envelope icon;
  that one's now just hidden below md: instead per feedback.
- Hero: subtitle sized down and CTA checkmark icon scaled to match
  below lg:, social proof text always wraps below the avatars there
  instead of only when it doesn't fit.
- Werkzeuge connector arrows (todo-cards/newsletter/challenge
  HowItWorks): object-contain added — the SVG has
  preserveAspectRatio="none" and was stretching to fill the square
  mobile box instead of keeping its thin-arrow shape.
- Challenge bottom CTA: icon now stacks above the copy, centered, below
  lg: to match the Home Newsletter card's pattern.
- Homepage Werkzeuge icons: smaller below md: to match the (fluid-floor)
  text size next to them.
- Blog detail "Passend dazu" card: the fixed w-[19rem] title column plus
  "Entdecken" sharing its row overflowed on mobile; stacked below sm:
  instead, with a little top margin on "Entdecken".
- Todo-Karten checklist checkmarks: recolored brand-orange (icon-check
  .svg's fill lives in an internal CSS var that can't be overridden from
  outside an <img>-loaded SVG, so switched to an inline SVG) and
  top-aligned instead of vertically centered.
- AddToCartButton: added whitespace-nowrap to the stacked-label grid
  (used to reserve button width across all possible label texts) — one
  of those labels wrapping to two lines on a narrow w-full button was
  inflating the row height for whichever label is actually showing,
  leaving a tall empty gap under "Ausverkauft".
2026-07-24 19:01:18 +00:00
Marco 3da8b75395 Fix several mobile responsive issues across Hero, Newsletter, cart, and Challenge
- Hero CTA: back to a single line below lg: (wrapping put the icon
  beside two lines and looked broken), sized down instead so the full
  phrase fits.
- Hero heading: dropped the forced <br> after "darf" at true mobile
  widths, kept it only for the sm-lg tablet range it was added for.
- Hero social proof: avatars+text now wrap and center instead of
  cramming onto one row (flex-wrap + justify-center, no fixed
  breakpoint needed).
- Newsletter card icon: stacked above the copy and centered below md:
  instead of squeezed into a row beside it.
- TrustRow: left-aligned below md: instead of centered (shop, cart, and
  every other page using this component).
- Cart line item image: full width below sm: instead of a fixed 150px
  square.
- Challenge EmailCapture (top + bottom CTA, same shared component):
  input and button stack full-width below sm: instead of squeezing
  into one row.
- Challenge steps: icon above text and centered at every breakpoint
  (previously only from lg: up), connector arrows centered to match.
2026-07-24 18:48:18 +00:00
Marco f20a02dfa2 Match the profile icon's size to the cart icon in the navbar
Profile was h-6 w-6 (24px) next to cart's h-7 w-7 (28px), a noticeable
size mismatch between the two always-visible nav icons.
2026-07-24 18:38:33 +00:00
Marco 55de9b3e29 Fix three mobile layout issues on the homepage
- Hero CTA: whitespace-nowrap forced the button as wide as the full
  32-char phrase, overflowing the stacked mobile column. Now wraps to
  two lines below lg:, single line from lg: up where there's room.
- Navbar: the account/cart icons are 44px touch targets with the glyph
  centered inside, so the outer gap-2 read as too much space on top of
  that padding. Grouped them with no gap between just the two.
- Divider: "Klarheit -> Fokus -> Entlastung" plus its connector icons
  needs ~550px to fit one row even at the site's 768px fluid floor, far
  more than a phone's ~310px content width. Added a component-scoped
  --divider-word-size (independent from --text-h2) plus a <640px
  override that shrinks the words/icons/gaps together.
2026-07-24 18:32:35 +00:00
Marco a3fb864f7d Focus the email field after clearing it via "andere E-Mail-Adresse"
FormField is now forwardRef so the email input can be targeted
imperatively — clicking the button clears the field (previous commit)
and now also focuses it, so the shopper can start typing immediately
instead of having to click in first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:04:56 +00:00
Marco 9f92f7324a Clear Card 1's email field when switching away from the login prompt
"Andere E-Mail-Adresse verwenden" only hid the inline login prompt
before, leaving the already-registered address still sitting in the
field — the shopper had to manually select/delete it before typing a
new one. Now clears email + loginPassword + the email field's error
alongside dismissing the prompt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:56:42 +00:00
Marco 9bfd0affd0 Tighten Impressum text blocks and drop Reveal's y-translate jump
AnbieterAngaben.tsx's address/VAT/register blocks each got the same
gap-4 as the section headings, so related lines (e.g. name/street/zip+
city) read as visually disconnected paragraphs instead of one block —
wrapped each in its own gap-1 container, keeping gap-4 only between
sections.

Reveal.tsx's shared fadeUp variant animated opacity and a 28px y-
translate together, which read as the whole section hopping into place
on top of the fade. Renamed to fadeIn, opacity only — applies site-wide
via Reveal/RevealGroup/RevealItem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:47:06 +00:00
Marco f0aec851d3 Fix checkout login-prompt placement and unify profile country select
Login prompt (email already has an account) now renders inline under
Card 1's own email field instead of a separate block above the whole
form — no scrolling needed in the common case, and no more re-typing
the email into a second field. The submit-time fallback still scrolls
it into view via a useEffect, now that the target is conditionally
rendered.

/konto/profil's "Land" select was still hardcoded to Deutschland/
Österreich/Schweiz independently of /checkout's own Payload-configurable
shipping-countries list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:44:04 +00:00
Marco 36bfa3bd84 Fix checkout password focus-trap and ToDo-Karten hero CTA copy
The new-account password field's onBlur re-focused itself whenever the
value was still invalid (<8 chars), the same generic pattern every other
blur-validated checkout field uses. For most fields that's a helpful
"fix it now" nudge, but it's a genuine trap on a password field near the
end of a card — a shopper could never Tab or click the submit button
until the password was already valid. Dropped just the refocus here
(inline error text still appears immediately); every other field keeps
the existing behavior.

TodoKartenHero.tsx's price line never showed "zzgl. Versand" at all
(pre-existing gap, unrelated to Kleinunternehmer), unlike Pricing.tsx/
ProductSpotlight.tsx — now consistent across all three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:22:34 +00:00
Marco ba830947d2 Add Kleinunternehmerregelung (§19 UStG) support end-to-end
Checkout forces 0% VAT without de-grossing prices when the tenant is a
Kleinunternehmer (a business decision, not just an engineering default —
unlike the existing intra-community VAT exemption, which does de-gross).
Snapshotted onto the order at checkout time so a later toggle of the
company-settings checkbox never rewrites an already-issued invoice's tax
treatment — same reasoning as the existing vatExempt field.

Threaded through: checkout route, order creation/confirmation email,
on-demand invoice/Storno/Gutschrift downloads, the Bestellbestätigung
page, and the account order-detail page. The four storefront "inkl. X%
MwSt." price hints (shop grid, cart upsell, ToDo-Karten landing page,
homepage spotlight) drop that clause live when the setting is on. The
company-settings Live Preview reflects the checkbox in real time too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:08:13 +00:00
Marco 89dd11bf77 Bump @einfach-produktiv/invoicing for the Netto sizing fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:34:41 +00:00
Marco 14b1d5685c Bump @einfach-produktiv/invoicing for the Netto/MwSt reorder fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:27:37 +00:00
Marco 19f6559c29 Fix VIES check treating "member state unavailable" as "invalid"
VIES answers HTTP 200 even when it couldn't actually perform the check
(actionSucceed: false, e.g. MS_UNAVAILABLE — Germany's own national
gateway does this fairly regularly). checkVatIdViaVies() only ever read
data.valid, which is absent on that response shape, so it silently
read as valid: false — a real, currently-registered German VAT ID
(reported: DE351362947) looked rejected. Worse, /api/checkout/validate-
vat then wrapped even a correctly-returned ok:false as { ok: true,
valid: false }, which the client reads as "invalid" rather than
"unavailable" — the actual bug the user hit, compounding the vies.ts
gap. Both are fixed now: an unconfirmable check surfaces to the client
as ok:false, which CheckoutContent.tsx's handleVatIdBlur already
correctly renders as "USt-IdNr.-Prüfung derzeit nicht möglich"
instead of a rejection. Same fix applied to the payload backend's own
copy of vies.ts (company-settings' VAT check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:21:46 +00:00
Marco 0c884a73ec Make newsletter signup confirmation consistent and casual across all 4 forms
Same short "check your inbox" message everywhere instead of each form
having its own success wording (they'd drifted since each is a
separately-coded component, not a shared one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:18:46 +00:00
Marco f035ccaacc Document ShippingCountries, Brevo newsletter sync, and this session's fixes
New sections: Newsletter signup & Brevo sync, Destination countries
(Payload-configurable). Updated: VAT exemption (Schweiz/Österreich
hardcoding claim was stale now that destination countries are
Payload-configurable; documented the select-all removal and the
generalized refocus-on-invalid-blur behavior), Invoice PDFs (Netto row),
account order-detail (companyName/vatId now shown).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:09:42 +00:00
Marco c60e936b7e Show company name + VAT ID on the account order-detail page; pick up Netto-row invoicing update
Order detail (/konto/bestellungen/[orderNumber]) already carried
companyName/vatId/vatExempt on CustomerOrderDetail but never rendered
them — a B2B customer looking at their own order couldn't see the
company/VAT info that's already on their invoice. Now shown in the
billing-address block, with a note when the order was VAT-exempt.

Also bumps @einfach-produktiv/invoicing to pick up the Netto-row fix
(now shown on every invoice, not just non-exempt ones).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:59:23 +00:00
Marco 789a818c6b Add on-blur email validation to every newsletter signup form
Extends the checkout pattern (inline red error text, refocus on
submit if invalid) to all four newsletter-signup entry points. Two of
them (WeeklyImpulsesHero's inline hero form on /newsletter, and
/challenge's EmailCapture) turned out to be completely non-functional
before this too — same static-markup-with-no-onSubmit issue as
Newsletter.tsx/NewsletterModal.tsx had, just missed in the previous
pass since they're separate components sharing only the visual
pattern, not the code.

Consolidated the shared email+consent+submit state (previously
duplicated per-component) into useNewsletterSignup.ts, and pulled the
plain email-format regex (previously duplicated in CheckoutContent.tsx
and the subscribe route) into lib/email.ts as a single source of
truth. /challenge's EmailCapture is now its own client component
(app/challenge/components/EmailCapture.tsx) since its parent page is
an async Server Component and can't hold form state itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:48:13 +00:00
Marco 6a4539bf9b Sync newsletter opt-ins to Brevo
Both standalone signup forms (Newsletter.tsx on Home/newsletter page,
NewsletterModal.tsx from the Navbar CTA) were previously non-functional
— static markup with no onSubmit/state at all, nothing was ever
captured. They're now real client forms posting to the new
/api/newsletter/subscribe route, which upserts the contact into
Brevo's Contacts API (list id from BREVO_LIST_ID). Checkout's existing
newsletterOptIn checkbox gets the same sync, fire-and-forget alongside
the order-confirmation email — a failed marketing sync must never
fail checkout.

lib/brevo.ts is the only thing that talks to Brevo; this app still
never sends marketing mail itself. Whatever automation Brevo has
configured on the list (Welcome Flow etc.) runs entirely on their
side — Brevo's Automation workflows aren't manageable via their
public API at all, so that part can't be wired up from here.

Needs BREVO_API_KEY and BREVO_LIST_ID set in the frontend's Coolify
environment — not yet added there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:38:41 +00:00
Marco 0c849c9525 Drop select-all on unconfirmed VAT ID, keep the refocus
Re-focus still returns attention to the field, but no longer wipes
the customer's input via select() — a stray keystroke while glancing
at the error message shouldn't erase what they typed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:22:11 +00:00
Marco 47d03dd61b Feed checkout's country selects from Payload instead of a hardcoded list
Both the billing and shipping-override country selects, plus PLZ
maxLength/pattern validation, now read from the new shipping-countries
collection (getShippingCountries()) rather than a hardcoded
Deutschland/Österreich(/Schweiz) array. Lets an admin add or reorder
destination countries without a frontend deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:21:13 +00:00
Marco 50db2fcb4b Refocus a checkout field automatically when its blur validation fails
Generalizes what the USt-IdNr. field already did for an unconfirmed
VIES result — every blur-validated field now gets focus put right
back on it the moment its own validation fails, instead of letting
focus move on to wherever the customer tabbed/clicked next. The
correction happens immediately rather than being left for a submit
attempt to catch later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:54:14 +00:00
Marco 0c9050cc8a Show unconfirmed USt-IdNr. in red, auto-select the field on blur
Makes the "konnte nicht bestätigt werden" message read as an actual
error instead of neutral status text, and re-focuses + selects the
whole VAT ID on an unconfirmed result — the customer almost certainly
needs to retype it, so the next keystroke should just replace it
outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:45:15 +00:00
Marco 80b82e0117 Document VAT-ID-validity-vs-exemption decoupling and maxLength fixes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:41:46 +00:00
Marco 4d2e78dd2a Check VAT ID validity via VIES for any country, not just Österreich
VAT-ID validity and the exemption decision are separate questions.
Previously VIES was only ever called when the destination already
qualified for the cross-border exemption (Österreich), so a garbage
VAT ID on a domestic order (e.g. "ED123456789" — not even a real
country code) sailed through with no feedback at all, and a
Deutschland/Schweiz customer got no confirmation their real VAT ID
was valid either. Now VIES checks any format-valid VAT ID regardless
of destination (data quality, same reasoning as company-settings'
own check) — the exemption itself still only applies when the
destination is also Österreich, a validated German VAT ID never
zero-rates a domestic sale. The status message now always shows
("✓ USt-IdNr. bestätigt", plus the exemption note only when it
actually applies) instead of staying hidden for non-Österreich
orders.

Also added maxLength to PLZ (per-country digit count) and USt-IdNr.
(14) checkout fields — they had pattern validation but nothing
stopping the browser from accepting more characters than could ever
be valid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:37:29 +00:00
Marco ba4d7b443f Document today's features in the README
Stock-capped add-to-cart, B2B checkout fields, VAT exemption
(innergemeinschaftliche Lieferung + VIES), blur-time checkout
validation, and the invoice-layout/e-invoicing status fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:19:36 +00:00
Marco 6802636d1d Add innergemeinschaftliche-Lieferung VAT exemption for cross-border B2B
A validated EU business buyer (Österreich, the one cross-border option
this checkout offers) gets the sale zero-rated per §4 Nr. 1b UStG —
but only after a live VIES lookup confirms the VAT ID is actually
registered right now, never from format-validity alone (real
compliance risk otherwise). VIES unreachable fails closed: normal VAT
applies, no guessed exemption.

- lib/vies.ts: calls the EU's public VIES REST API.
- lib/vatExemption.ts: de-grosses item/shipping prices and computes
  the exempt totals; also picks the actual destination country
  (shipping override when set, billing otherwise).
- api/checkout/validate-vat: on-blur live check for instant feedback;
  api/checkout/route.ts re-runs the same check server-side at submit
  as the actual source of truth, and re-prices every line net-of-VAT
  when exempt.
- CheckoutContent.tsx: VIES status + a live exempt-totals preview;
  BestellbestaetigungContent.tsx mirrors it from the persisted
  snapshot. Both blur-validate every other checkout field now too
  (immediate inline errors, not just on submit).
- vatExempt/vatIdValidatedAt threaded through orderServer.ts,
  customerAuth.ts, orderEmail.ts, and both invoice-download routes so
  the invoice PDF and its e-invoice XML (companion payload-repo
  commit) reflect the exemption correctly wherever it's rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:10:01 +00:00
Marco e48107470a Add optional Firma/USt-IdNr. fields to checkout and profile
B2B checkout fields, split out from the e-invoicing migration and
picked back up now that it's shipped. Both fields are independently
optional, format-validated (shared regex in lib/vatId.ts, mirrored
server-side in api/checkout and api/account/profile), persisted in
the checkout draft, and saved as a customer profile default that
pre-fills future checkouts. Order/customer snapshot fields land in a
companion Payload backend commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:53:21 +00:00
Marco 2d88fb86a1 Cap add-to-cart quantity at actual remaining stock
Stock was only checked at checkout; a shopper could add more of a
product to the cart than was actually in stock and only find out at
the last step. Product/variant now carry a real maxQty, and
AddToCartButton/AddToCartInlineButton/the cart's quantity stepper all
disable or cap once the cart already holds that many.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:53:14 +00:00
Marco 1212b9d115 Pick up invoice paid-badge placement fix (outside the summary card) 2026-07-23 14:54:27 +00:00
Marco 97833ab2bb Remove product thumbnails from the order list, keep them on the detail page
Bestellübersicht rows now show only text (Bestellnummer, Datum, Artikel-Anzahl, Status, Gesamtbetrag) — no product images. The order detail page (app/konto/bestellungen/[orderNumber]/page.tsx) is untouched and still shows a thumbnail per item, which is the only place they should appear.
2026-07-23 14:46:44 +00:00
Marco 03a29cf93c Validate PLZ/Packstationnummer/Postnummer format at checkout, pick up invoice layout fix
Native HTML5 pattern validation, mirroring the same rules the backend now enforces (Orders.ts/Customers.ts, same commit on that repo): PLZ digit count by country (5/DE, 4/AT+CH), Packstationnummer 1-3 digits, Postnummer 6-10 digits — catches the exact mix-up (long number in Packstationnummer, short number in Postnummer) found in a real order's data while investigating this. Client-side only for instant feedback; the backend re-validates regardless.

Also bumps @einfach-produktiv/invoicing to pick up the invoice PDF layout fixes (centered footer, clustered summary rows, relocated paid badge, stacked Packstation/Postnummer address lines).
2026-07-23 14:40:02 +00:00
Marco bd884357b0 Pick up critical e-invoice fix: shipping/discount degross bug + BR-CO-25
Every AllowanceCharge (shipping/discount) generated since the integer-cents pipeline landed was ~19%/7% too large — the /(1+rate/100) degross step was silently dropped in that rewrite. Real impact confirmed on a real order via an external checker: PayableRoundingAmount was showing >1 EUR instead of a normal few-cents residual. Also adds PrepaidAmount/PaymentTerms handling for BR-CO-25. Bumps to git.mk360.de/Marco/einfach-produktiv-invoicing@f6d8e9e.
2026-07-23 14:13:32 +00:00
Marco b5ad13cf43 Revert generalPartners, fix shareCapital: neither is a Pflichtangabe
Corrected after user feedback: Stammkapital/Grundkapital is only required on business correspondence if voluntarily disclosed in the first place (§35a Abs. 1 S. 2 GmbHG) — not an unconditional Pflichtangabe. shareCapital's Impressum rendering stays (shown only if an admin voluntarily filled it in), but it's dropped from the email/invoice footer.

generalPartners is removed entirely — the legal basis was genuinely unclear on research (§125a HGB's Geschäftsbriefe-naming duty only applies to the narrow case where no partner is a natural person; whether §5 DDG's Impressum-specific "vertretungsberechtigte Person" requirement independently mandates it for the general OHG/KG case wasn't resolved with confidence) — reverted rather than shipped on an uncertain legal basis.

Also fixes stale "§5 TMG" citations to "§5 DDG" (TMG was replaced 14 May 2024).
2026-07-23 14:12:35 +00:00
Marco a3912a47c4 Model Stammkapital/Grundkapital and Gesellschafter for the not-yet-needed legal forms
Mirrors the backend's new CompanySettings.shareCapital/generalPartners fields: rendered in the Impressum (AnbieterAngaben.tsx — new "Gesellschafter" section, Stammkapital line under Handelsregister, and the "Verantwortlich für den Inhalt" fallback now considers a general partner before falling back to sellerName) and wired into buildLegalFooterLines() for the invoice/email footer, same as registerCourt/registerNumber/managingDirector already were.

No visible change today (current legalForm is sole-proprietorship, neither field is set) — this is prep so a future legalForm change in company-settings updates the Impressum automatically instead of needing a manual Impressum edit at that point.
2026-07-23 13:26:33 +00:00
Marco 0c3f7ddf2e Pick up e-invoicing shared-package fixes (EN16931 conformance bugs)
Phase 4's Mustang CI check found and fixed several real EN16931/PDF-A-3 compliance bugs in the shared package after Phase 3 had already shipped: a missing CII Delivery element, line amounts reported gross instead of net, missing AllowanceCharge entries for shipping/discount, and a BR-CO-16 rounding mismatch. Bumps the git-dependency lockfile to git.mk360.de/Marco/einfach-produktiv-invoicing@fd5d8e6 to get the fix into production-generated invoices.
2026-07-23 12:44:59 +00:00
Marco b70aefd5cc Phase 3: wire e-invoice generation into checkout email + download routes
invoiceData.ts's generateInvoicePdf()/generateCorrectionInvoicePdf() now
call renderInvoiceEInvoice()/renderCorrectionInvoiceEInvoice() instead of
the plain PDF renderers — both are the single wrapper every caller
already goes through (orderEmail.ts's checkout attachment, and the two
on-demand /invoice and /correction-invoice download routes), so this one
change switches all three. Buffer.from() wraps the library's Uint8Array
return value — every downstream consumer already expects a Buffer,
unchanged.

Imports from "@einfach-produktiv/invoicing/einvoice" (a new subpath, not
the package's main entry) — @e-invoice-eu/core pulls in Node-only
dependencies that broke the client bundle when reachable from the main
entry, which a Client Component also imports transitively (Live
Preview). See that package's own commit for the fix.

Existing failure-handling is unchanged and covers this: a PDF-generation
error still doesn't block the confirmation email, it just sends without
the attachment and alerts admin (see orderEmail.ts) — same safety net
that already existed for the plain-PDF path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:32:46 +00:00
Marco 21c4e9f007 Link Impressum's seller identity to company-settings, not hand-typed prose
"Angaben zum Anbieter"/"Umsatzsteuer"/"Handelsregister"/"Geschäftsführung"/
"Verantwortlich für den Inhalt" used to be hand-typed into the Impressum's
richText content (seed-legal-pages.ts) with no connection to the same
seller data the invoice PDFs and every email footer already pull from
company-settings — an admin updating one had no reason to remember the
other existed, and the old text was already stale in one concrete way:
it never showed Handelsregister/Geschäftsführung at all even though
company-settings has modeled both since the legal-form work shipped.

Now rendered by a new AnbieterAngaben component, straight from
getCompanySettings(), positioned above the CMS richText (which keeps
only genuinely editorial content: Kontakt, Haftung für Inhalte, Haftung
für Links, Urheberrecht). Same "structural/brand elements in code, only
pull the actual numbers/copy that need single-sourcing from data"
pattern this page's own Nachhaltigkeit sidebar card already used.

RichText.tsx's headingId() is now exported so the new block's headings
get the exact same id-assignment logic as CMS-driven ones, keeping the
SectionTOC sidebar's ids in sync with both sources.

/impressum moves from static to dynamic rendering (it now fetches live
company-settings data, cache: "no-store") — an acceptable tradeoff for
a legally-required page to never show stale seller info.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:10:02 +00:00
Marco 039b8ba28d Bring README up to date with Phases 0-2 of the e-invoicing migration
Invoice PDFs section still described the pre-migration architecture
(local invoicePdf.tsx/correctionInvoicePdf.tsx/taxBreakdown.ts, shared
invoice numbering for corrections, free-text bankDetails) even though
the code moved to @einfach-produktiv/invoicing, got atomic/separate
numbering, and switched to structured bankName/iban/bic across the last
several commits. Also fixes the Tests section (those tests moved into
the shared package too) and the misleading "(für Überweisung)" bank
details wording, which was never actually conditional on payment method.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:52:36 +00:00
Marco 05a3b009d3 Phase 2: iban/bic instead of bankDetails, matching the backend collection
CompanySettings type now mirrors the payload repo's split bankDetails ->
iban/bic (see that repo's own commit for the reasoning). No behavior
change here beyond the type/fallback update — the actual footer
rendering lives in @einfach-produktiv/invoicing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:20:17 +00:00
Marco 66ac184a6f Move invoice/tax-breakdown PDF generation into @einfach-produktiv/invoicing
Phase 0 of the e-invoicing migration plan (see the E-Rechnung planning
session) — moves invoicePdf.tsx, correctionInvoicePdf.tsx, and
taxBreakdown.ts into a new shared package, consumed as a git dependency
by both this repo and the payload backend, instead of hand-duplicating
the correction-invoice logic between them (see that package's own README
for the three real drifts the duplication had already caused).

Consumed as raw TS/TSX source via next.config.ts's transpilePackages, not
a pre-built package. Needs `git` in the Docker deps stage and a
project .npmrc (allow-git=root) to let npm ci fetch a git-URL dependency
at all — npm 12+ disables that by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 09:18:20 +00:00
Marco 21e150f177 Add the low-stock hint to TodoKartenHero.tsx too
Missed in the earlier pass — /todo-cards has two independent purchase
CTAs (the hero at the top and the Pricing panel further down), and only
Pricing.tsx got the low-stock text line. The hero had no low-stock
logic at all before this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 06:36:36 +00:00
Marco 2df4dc7ea7 Move low-stock warning from image badge to a text hint, add it to the cart
The image-overlaid pill made the low-stock message read as clutter on
product photos and had no equivalent in the cart at all. It's now a text
line next to the price (ProductGrid/ProductSpotlight/RelatedProducts/
Pricing) and under the product name in cart line items (variant-specific,
not "any variant low"). The line's height is always reserved, not
conditionally rendered, so cards in the same row stay equal-height
regardless of low-stock state — the exact regression an earlier text-based
version of this hint caused before it was replaced by the image badge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 06:25:52 +00:00
Marco d72102bdf0 VAT breakdown: rate/amount right-aligned under the Gesamtsumme € amount
Same row shape as the Gesamtsumme total line itself (label left, flex-1
spacer, value right) instead of the grid — label stays flush with
"Gesamtsumme", rate+amount land flush right under the total's own €
figure. Fixed-width rate column keeps multiple rates aligned to each
other regardless of digit count.
2026-07-23 00:00:02 +00:00
Marco ddf842f910 VAT breakdown: left-aligned under Gesamtsumme label, first rate inline
Back to self-start (flush with "Gesamtsumme", not pinned under the total
€ amount). Multi-rate case switched from a stacked flex column to a
3-column CSS grid so the first rate sits on the same line as "enthält
MwSt.:" instead of dropping to its own row — grid auto-sizes each column
to its widest cell across all rows, so the rate column still stays
aligned between single- and double-digit rates without a hardcoded width.
2026-07-22 23:56:35 +00:00
Marco 02c3fef9b2 Pin VAT breakdown to the right edge, under the Gesamtsumme amount
self-end instead of self-start so the block sits directly beneath the
total's € amount (same right edge) rather than flush left. Dropped the
per-rate rows' pl-2 indent to match — now flush with the "enthält MwSt:"
label above them instead of offset from it.
2026-07-22 23:53:19 +00:00
Marco 5e198a30c6 Un-stretch VAT breakdown, fix rate-column alignment properly
The earlier right-alignment fix spanned the row edge-to-edge across the
full summary panel (same width as the Gesamtsumme total line), which
visually disconnected the "enthält X% MwSt." hint from its own amount on
wide panels. Reverted to content-sized (self-start, no w-full/flex-1
spacer), with a fixed-width right-aligned rate column instead so
single-digit rates (7%) still line up with two-digit ones (19%).
2026-07-22 23:49:58 +00:00
Marco 44029cdaad Stack low-stock badge with discount badge instead of hiding it, update READMEs
Ausverkauft/discount/low-stock badges used a single either/or slot, so a
product with an active discount silently never showed its low-stock pill
(caught live: todo-karten had both at once). Badges now stack in a flex
column across ProductGrid/ProductSpotlight/RelatedProducts/Pricing, with
Ausverkauft still winning outright. READMEs updated for this and the
recent discount-field gating, shipping-address, and money-rounding changes.
2026-07-22 23:42:33 +00:00
Marco b2bffd13a3 Fix MwSt./Versand line wrapping onto two lines in narrow pricing panels
"inkl. X% MwSt. zzgl. Versand" got long enough (once the rate is spelled
out) that sharing a flex row with the price wrapped mid-sentence in
Pricing.tsx's/ProductSpotlight's narrow columns. Moved onto its own line
instead of inline next to the price.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:31:10 +00:00
Marco a50524832e Move low-stock hint to badge, right-align VAT breakdown, gate cart discount field, split billing/shipping delivery method
- Removed the inline "Nur noch wenige verfügbar" text hint from
  AddToCartButton/AddToCartInlineButton (was making card heights vary in
  every grid that renders them — RelatedProducts, ProductSpotlight's CTA
  row) — now only shown via the same image-overlaid pill badge
  Ausverkauft/discount already use (position: absolute, doesn't affect
  layout). Added that badge to RelatedProducts.tsx and todo-cards'
  Pricing.tsx, which didn't have it before.
- RelatedProducts cards now also show "inkl. X% MwSt." (was missing
  entirely)
- VatBreakdown rows are now flex rows with a spacer instead of plain
  text, so every € amount right-aligns to the same edge regardless of
  how many digits the rate itself has (was visibly staggered with mixed
  7%/19% rates)
- Cart's manual discount-code field only renders when Payload actually
  has at least one active code right now (lib/discountServer.ts's new
  hasActiveDiscountCode()) — no point showing an open field that could
  never validate. An already-applied code (e.g. from an older session)
  still always shows its own result row regardless.
- Checkout's "1. Rechnungsadresse" no longer offers a Packstation option
  — a Packstation isn't a valid billing address for an invoice. Only a
  plain street address now; Packstation is only offered on the separate,
  optional "Abweichende Lieferadresse" section, which already had its own
  address/Packstation toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:25:18 +00:00
Marco dc6b61324f Fix money rounding drift, low-stock hint spacing, spotlight CTA height mismatch
- Round subtotal/discountAmount/total to 2 decimals before persisting an
  order — float arithmetic on money was drifting into values like
  84.30000000000001, invisible wherever a display already ran it through
  toFixed(2), but stored as-is and visible raw in the Payload admin's
  plain number field
- Low-stock hint now uses gap-1 consistently (was gap-2) in both
  AddToCartButton/AddToCartInlineButton, for smaller/consistent spacing
  above it regardless of context
- ProductSpotlight's CTA row now uses items-start at sm: — without it,
  default cross-axis stretch made "Mehr erfahren" grow to match
  AddToCartButton's height whenever the low-stock hint made that one
  taller, so the link visibly looked "fatter" than the actual button

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:10:43 +00:00
Marco 43944d8cc8 Fix navbar/discount/invoice bugs from manual QA, add VAT breakdown, shipping-address override, checkout persistence, redesigned mobile menu
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
  event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
  number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns

Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
  a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
  included, broken down per rate when a cart spans more than one
  (new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown

Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).

Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.

Checkout:
- Optional deviating shipping address (separate from the billing address
  used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
  selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
  address differs from billing

Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).

Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.

Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 22:52:15 +00:00
Marco 7a9fed6f95 Remove duplicate Newsletter/7-Tage-Challenge buttons from tablet drawer
They already show inline in the header from md (768px) up — now that the
drawer panel actually renders (previous fix), this pairing became visibly
redundant on the 768-1023px tier specifically. md:hidden on just those two
buttons; the drawer's "Anmelden"/"Mein Konto" link stays, since the header's
account icon links to the same place but doesn't carry that label text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PL4zfTY1sXc8x5QS6FatM
2026-07-22 18:28:34 +00:00
Marco b3c44e3082 Fix invisible mobile nav drawer, redesign its open/close as a modern fade
Root cause: the <header> had a fixed h-[6.25rem], not min-h. Its flex-col
first child (top row) has shrink-0 and always fills all 6.25rem, leaving
zero room for the drawer panel — the panel's own overflow-hidden (needed
for its animation) resets flexbox's automatic min-height to 0, so it got
crushed to a literal 0px box regardless of its own max-height. The
hamburger button itself always worked (toggled to "X" correctly); the
panel it opened was rendering at zero height beneath it, at every
breakpoint where it exists (below lg/1024px).

Also replaced the max-height-accordion technique with a CSS grid-rows
(0fr/1fr) transition — no more guessing/capping a max-height — plus an
opacity+translateY fade on the inner content for a softer, more modern
open/close instead of a flat height-only reveal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PL4zfTY1sXc8x5QS6FatM
2026-07-22 18:24:14 +00:00
Marco 39782eeab9 Out-of-stock UI, variant picker on marketing pages, server-side stock check
- ProductGrid/AddToCartInlineButton/AddToCartButton now show "Ausverkauft"
  and disable add-to-cart per variant (or product-level with no variants),
  derived from trackInventory/stock/allowBackorder via isOutOfStock().
- AddToCartButton (todo-cards Hero+Pricing, homepage spotlight) gains the
  same variant <select> AddToCartInlineButton already had — all three call
  sites already fetch full product data server-side.
- /api/checkout re-validates stock server-side (depth-in-defense, not just
  the disabled button), rejecting when trackInventory is on, allowBackorder
  is off, and requested qty exceeds stock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PL4zfTY1sXc8x5QS6FatM
2026-07-22 17:58:10 +00:00
Marco c5500bcc97 Wire up product variants end-to-end, add tracking-number display
Completes the frontend half of the Payload backend's variant/inventory/
tracking work (see that repo's own commit):

- **Cart**: CartItem gained an optional `variant?: string` field — every
  function that used to match a line by `id` alone (addToCart/
  removeFromCart/setQuantity) now matches by `(id, variant)` together via
  a shared sameLine() helper, so two lines for the same product with
  different variants stay separate entries. `variant` undefined on both
  sides (the no-variants case) still matches by simple equality, so every
  pre-existing call site keeps working unchanged.
- **Selection UI**: AddToCartInlineButton renders a <select> above the
  button when given a non-empty `variants` prop (ProductGrid/
  RelatedProducts pass product.variants straight through); defaults to
  the first variant.
- **Pricing**: cartTotals.ts's new effectivePrice(entry, product) — a
  variant's priceOverride wins over the base product price. Every cart/
  checkout/order-confirmation total and per-line price display now goes
  through this instead of reading product.price directly (fixes both a
  wrong-price bug and a duplicate-React-key bug the old `key={product.id}`
  pattern would have had the moment two variants of one product were both
  in the cart).
- **Checkout**: re-validates the requested variant server-side (same
  "never trust the client" reasoning as price re-derivation) — a variant
  name that doesn't exist on that product fails the whole checkout.
  variantName snapshots onto orders.items, shown as a parenthetical next
  to the product name on the confirmation email, both invoice PDF types,
  and the order-detail page.
- **Cross-device cart**: Customers.cart[].variantName (synced via
  /api/account/cart) carries the selection through a login/logout cycle,
  not just the current session.

Also adds tracking-number display: /konto/bestellungen/[orderNumber]
shows a clickable link when orders.trackingNumber is set, built by a new
app/lib/tracking.ts that mirrors the Payload backend's own copy
byte-for-byte close (same carrier set/URL patterns) so what a customer
sees here matches exactly what the order-shipped email already links to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 17:43:47 +00:00
Marco a935357e70 Fix Live Preview footer placeholder email to not look like a real hardcoded address
DEFAULT_LEGAL_FOOTER_LINES' email was "admin@mk360.de" — the same domain
as the real send address, which read as a hardcoded real value in the
preview rather than an obvious placeholder. Now "kontakt@musterfirma.de",
matching "Musterstraße 12"'s already-fake spirit. Mirrors the same fix
already made on the Payload backend's copy of this constant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 15:44:36 +00:00
109 changed files with 10756 additions and 2149 deletions
+5
View File
@@ -0,0 +1,5 @@
# npm 12+ disables fetching git-protocol dependencies by default
# (allow-git=none). @einfach-produktiv/invoicing is declared directly in
# this file's own package.json (not a transitive dependency), so "root" is
# the narrowest setting that still allows it.
allow-git=root
+4 -2
View File
@@ -1,9 +1,11 @@
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
# git — needed for `npm ci` to fetch @einfach-produktiv/invoicing, a git-URL
# dependency (see package.json); Alpine's base image doesn't ship it.
RUN apk add --no-cache libc6-compat git
WORKDIR /app
COPY package*.json ./
COPY package*.json .npmrc ./
RUN npm ci
FROM base AS builder
+939 -95
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -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} />
+5 -6
View File
@@ -22,12 +22,11 @@ export async function POST(request: Request) {
const cart: CartItem[] = Array.isArray(body?.cart) ? body.cart : [];
const productsBySlug = await fetchProductsBySlug();
const lines = cart
.map((item) => {
const product = productsBySlug.get(item.id);
return product ? { productId: product.id, productSlug: product.slug, quantity: item.qty } : null;
})
.filter((line): line is { productId: number; productSlug: string; quantity: number } => line !== null);
const lines: { productId: number; productSlug: string; quantity: number; variant?: string }[] = [];
for (const item of cart) {
const product = productsBySlug.get(item.id);
if (product) lines.push({ productId: product.id, productSlug: product.slug, quantity: item.qty, variant: item.variant });
}
const ok = await saveServerCart(session.token, session.customer.id, lines);
return NextResponse.json({ ok });
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
import { generateCorrectionInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
import { getProductImagesByIds } from "../../../../../lib/payload";
// On-demand download for "Stornorechnung/Gutschrift herunterladen" on
// /konto/bestellungen/[orderNumber]. The real document was generated once
@@ -22,6 +23,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
const kind = order.status === "returned" ? "gutschrift" : "storno";
const seller = await getSellerForInvoice();
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const pdf = await generateCorrectionInvoicePdf(
kind,
{
@@ -32,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,
@@ -39,7 +45,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
zip: order.zip,
city: order.city,
country: order.country,
items: order.items,
items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
discountAmount: order.discountAmount,
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
import { generateInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
import { getProductImagesByIds } from "../../../../../lib/payload";
// On-demand download for "Rechnung herunterladen" on
// /konto/bestellungen/[orderNumber] — reuses the exact same render call as
@@ -19,6 +20,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
}
const seller = await getSellerForInvoice();
// On-demand re-download has no imageUrl snapshot to fall back to like
// the checkout-time attachment does (order.items only stores a numeric
// product id, see CustomerOrderItem) — resolved fresh here instead.
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const pdf = await generateInvoicePdf(
{
orderNumber: order.orderNumber,
@@ -26,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,
@@ -33,8 +42,18 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
zip: order.zip,
city: order.city,
country: order.country,
hasDifferentShippingAddress: order.hasDifferentShippingAddress,
shippingFirstName: order.shippingFirstName,
shippingLastName: order.shippingLastName,
shippingDeliveryMethod: order.shippingDeliveryMethod,
shippingStreet: order.shippingStreet,
shippingPackstationNumber: order.shippingPackstationNumber,
shippingPostNumber: order.shippingPostNumber,
shippingZip: order.shippingZip,
shippingCity: order.shippingCity,
shippingCountry: order.shippingCountry,
paymentMethodTitle: order.paymentMethodTitle,
items: order.items,
items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
discountAmount: order.discountAmount,
+10 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
export async function GET() {
const session = await getSessionCustomer();
@@ -12,7 +13,7 @@ export async function PATCH(request: Request) {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country } = body ?? {};
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
if (
typeof firstName !== "string" ||
!firstName ||
@@ -34,6 +35,12 @@ export async function PATCH(request: Request) {
if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
}
// Both independently optional (see Customers.ts's own comment) — only
// format-checked when actually provided, same as the backend field itself.
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
}
const result = await updateCustomerProfile(session.token, session.customer.id, {
firstName,
@@ -45,6 +52,8 @@ export async function PATCH(request: Request) {
zip,
city,
country,
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
vatId: normalizedVatId,
});
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+316 -53
View File
@@ -8,6 +8,23 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
import { describeBundleContents } from "../../lib/bundleContents";
import { sendCriticalAlert } from "../../lib/alertAdmin";
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
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
// into results like 84.30000000000001 — cosmetically invisible wherever
// formatPrice()'s toFixed(2) already rounds for display, but stored as-is
// on the order otherwise, which is where it actually showed up (Payload's
// admin list/edit view for a plain number field has no such formatting).
// Rounded once here, right before persisting, rather than chasing it down
// at every downstream display site.
function roundMoney(amount: number): number {
return Math.round(amount * 100) / 100;
}
type CheckoutBody = {
cart: CartItem[];
@@ -18,6 +35,8 @@ type CheckoutBody = {
lastName: string;
email: string;
password?: string;
companyName?: string;
vatId?: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
@@ -25,6 +44,16 @@ type CheckoutBody = {
zip: string;
city: string;
country: string;
hasDifferentShippingAddress?: boolean;
shippingFirstName?: string;
shippingLastName?: string;
shippingDeliveryMethod?: "address" | "packstation";
shippingStreet?: string;
shippingPackstationNumber?: string;
shippingPostNumber?: string;
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
newsletterOptIn: boolean;
};
@@ -63,6 +92,29 @@ export async function POST(request: Request) {
if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
}
// Optional — only format-checked when actually provided, same "never
// trust the client" reasoning as every other checkout field re-validated
// here. Normalized the same way Orders.ts's own field does (uppercase +
// trim), so the snapshot on the order matches what would've been
// accepted directly through the Payload admin.
const normalizedVatId = body.vatId ? normalizeVatId(body.vatId) : undefined;
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
}
if (body.hasDifferentShippingAddress) {
if (!body.shippingFirstName || !body.shippingLastName || !body.shippingZip || !body.shippingCity || !body.shippingCountry) {
return NextResponse.json({ ok: false, reason: "Bitte alle Felder der Lieferadresse ausfüllen." }, { status: 400 });
}
if (body.shippingDeliveryMethod === "address" && !body.shippingStreet) {
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
}
if (body.shippingDeliveryMethod === "packstation" && (!body.shippingPackstationNumber || !body.shippingPostNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
}
if (body.shippingDeliveryMethod !== "address" && body.shippingDeliveryMethod !== "packstation") {
return NextResponse.json({ ok: false, reason: "Lieferart der Lieferadresse ist ungültig." }, { status: 400 });
}
}
// Auth: an existing session wins; otherwise this checkout submit doubles
// as inline registration ("Konto Pflicht, Registrierung direkt im
@@ -90,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;
@@ -98,22 +159,45 @@ export async function POST(request: Request) {
imageUrl: string | null;
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
}[] = [];
for (const line of body.cart) {
const product = productsBySlug.get(line.id);
if (!product) return NextResponse.json({ ok: false, reason: "Ein Artikel im Warenkorb ist nicht mehr verfügbar." }, { status: 400 });
// Same "never trust the client" reasoning as unitPrice below — a
// requested variant that no longer exists on this product (removed,
// or never existed — a tampered request) fails the whole checkout
// rather than silently falling back to the base product/price.
let variant: { name: string; priceOverride: number | null } | null = null;
if (line.variant) {
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
}
// Same depth-in-defense reasoning as the price re-check above — the
// storefront already disables "add to cart" for sold-out items, but a
// tampered/stale request could still submit one, so stock is
// re-validated here as the actual source of truth. Falls through
// (buyable) whenever trackInventory is off or backorders are allowed.
const stockSource = line.variant ? product.variants?.find((v) => v.name === line.variant) : product;
if (stockSource?.trackInventory && !stockSource.allowBackorder && (stockSource.stock ?? 0) < line.qty) {
return NextResponse.json(
{ ok: false, reason: `"${product.name}"${line.variant ? ` (${line.variant})` : ""} ist nicht mehr in ausreichender Menge verfügbar.` },
{ status: 400 },
);
}
const imageUrl = typeof product.image === "object" && product.image ? product.image.url : null;
items.push({
productId: product.id,
productName: product.name,
quantity: line.qty,
unitPrice: product.price,
unitPrice: variant?.priceOverride ?? product.price,
imageUrl,
taxRatePercent: product.taxRatePercent ?? defaultTaxRate,
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
bundleContents: describeBundleContents(product),
variantName: variant?.name ?? null,
});
}
const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
const shippingMethods = await getShippingMethods();
const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId);
@@ -131,16 +215,112 @@ export async function POST(request: Request) {
if (!validation.valid) return NextResponse.json({ ok: false, reason: validation.reason }, { status: 400 });
const redeemed = await redeemDiscountCode(validation.doc);
if (!redeemed) return NextResponse.json({ ok: false, reason: "Rabattcode konnte nicht eingelöst werden." }, { status: 400 });
discountAmount =
validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal);
discountAmount = roundMoney(
validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal),
);
}
// 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 total = Math.max(0, subtotal - discountAmount) + shippingCost;
const order = await createOrder({
customerId: customer.id,
customerFirstName: body.firstName,
customerLastName: body.lastName,
customerEmail: body.email,
companyName: body.companyName || undefined,
vatId: normalizedVatId,
vatExempt,
kleinunternehmer,
vatIdValidatedAt,
deliveryMethod: body.deliveryMethod,
street: body.street,
packstationNumber: body.packstationNumber,
@@ -148,15 +328,36 @@ export async function POST(request: Request) {
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,
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
@@ -174,57 +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,
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,
})),
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,
});
}
+31
View File
@@ -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,
});
}
+39
View File
@@ -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 });
}
+30
View File
@@ -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 });
}
+90
View File
@@ -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 });
}
@@ -5,10 +5,13 @@ import Link from "next/link";
import Image from "next/image";
import type { CartItem } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { computeCartTotals } from "../../lib/cartTotals";
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";
import { VatBreakdown } from "../../components/VatBreakdown";
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
// Rejects (rather than silently patching with fallback values) anything
@@ -29,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;
}
@@ -39,7 +44,7 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
}
}
export function BestellbestaetigungContent() {
export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate: number }) {
const products = useProducts();
const [order, setOrder] = useState<OrderSnapshot | null>(null);
const [checked, setChecked] = useState(false);
@@ -98,13 +103,45 @@ export function BestellbestaetigungContent() {
.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),
})),
catalogSubtotal,
order.discountAmount,
order.shippingCost,
);
return (
<>
@@ -178,22 +215,30 @@ export function BestellbestaetigungContent() {
Bestellübersicht
</p>
{items.map(({ entry, product }) => (
<div key={product.id} className="flex gap-4 items-center w-full">
{items.map(({ entry, product }) => {
const unitPrice = effectivePrice(entry, product);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
return (
<div key={lineKey} className="flex gap-4 items-center w-full">
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden">
<Image src={product.image} alt={product.name} fill sizes="64px" className="object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<p className="text-body-sm text-text-primary">{product.name}</p>
<p className="text-body-sm text-text-primary">
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
<p className="text-label text-text-muted">
{entry.qty} × {formatPrice(product.price)} <span>inkl. 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">
{formatPrice(entry.qty * product.price)}
{formatPrice(entry.qty * unitPrice)}
</p>
</div>
))}
);
})}
<div className="h-px bg-border w-full" />
@@ -237,7 +282,13 @@ export function BestellbestaetigungContent() {
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
<p className="text-label text-text-muted">inkl. MwSt.</p>
{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>
+5 -2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { BestellbestaetigungContent } from "./components/BestellbestaetigungContent";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getDefaultTaxRatePercent } from "../lib/payload";
// robots: noindex — transactional page, same reasoning as /cart and
// /checkout (this one doubles as a receipt, not something to surface in
@@ -15,11 +16,13 @@ export const metadata: Metadata = {
},
};
export default function BestellbestaetigungPage() {
export default async function BestellbestaetigungPage() {
const defaultTaxRate = await getDefaultTaxRatePercent();
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<BestellbestaetigungContent />
<BestellbestaetigungContent defaultTaxRate={defaultTaxRate} />
<TrustRow />
</main>
<Footer />
+33 -13
View File
@@ -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"
+7
View File
@@ -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() {
+146 -28
View File
@@ -7,10 +7,12 @@ import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
import { computeSubtotal, computeCartTotals } from "../../lib/cartTotals";
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { formatPrice, discountPercent } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { VatBreakdown } from "../../components/VatBreakdown";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -19,6 +21,9 @@ export function CartContent({
shippingCost,
freeShippingThreshold,
shippingSettings,
defaultTaxRate,
kleinunternehmer,
showDiscountField,
}: {
trustBadges: TrustBadge[];
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
@@ -34,11 +39,28 @@ export function CartContent({
* "shippingSettings", not "shipping" — that name is already the local
* computed shipping-cost value below. */
shippingSettings: ShippingSettings;
/** Tenant's default VAT rate (Company Settings), for products that don't
* 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
* against it. Only gates the manual-entry form; a code already applied
* (e.g. from an earlier session, or one deactivated after being shared)
* still shows its own result row regardless. */
showDiscountField: boolean;
}) {
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
const products = useProducts();
const discount = useDiscount();
const [discountInput, setDiscountInput] = useState("");
const [discountError, setDiscountError] = useState<string | null>(null);
const [discountLoading, setDiscountLoading] = useState(false);
const searchParams = useSearchParams();
@@ -59,6 +81,16 @@ export function CartContent({
? 0
: shippingCost;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
const taxBreakdown = computeTaxBreakdown(
items.map(({ entry, product }) => ({
quantity: entry.qty,
unitPrice: effectivePrice(entry, product),
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
})),
subtotal,
discountAmount,
shipping,
);
async function handleApplyDiscount(code: string) {
if (!code) return;
@@ -73,6 +105,7 @@ export function CartContent({
const data = await res.json();
if (data.valid) {
applyDiscount({ code: code.toUpperCase(), type: data.type, value: data.value });
setDiscountInput("");
} else {
setDiscountError(data.reason || "Dieser Code ist ungültig.");
}
@@ -149,12 +182,52 @@ export function CartContent({
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
{items.map(({ entry, product }, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const unitPrice = effectivePrice(entry, product);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// (id, variant) together, not id alone — two lines for the
// same product with different variants need distinct React
// keys/element ids and must each only affect their own line
// when the quantity or remove control is used, same "full
// key" reasoning as cart.ts's own sameLine().
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
// The exact variant this line is for, not "any variant low"
// like the product-grid cards use — a cart line already has
// its variant chosen, so it should only warn when that
// specific variant (not some other one) is running low.
const lowStock = entry.variant
? (product.variants.find((v) => v.name === entry.variant)?.lowStock ?? false)
: product.lowStock;
// Same per-line resolution as lowStock above — caps how high
// the quantity stepper below can go, instead of only finding
// out at checkout that this many aren't actually available
// (api/checkout/route.ts's own stock check stays as the
// authoritative server-side guard). null (no cap) falls back
// to the stepper's original fixed 1-9 range; at least 1 is
// always offered even if maxQty is somehow lower than the
// qty already in this line, so the remove (×) button stays
// the only way down, never an empty <select>.
const maxQty = entry.variant
? (product.variants.find((v) => v.name === entry.variant)?.maxQty ?? null)
: product.maxQty;
const qtyOptions = Array.from({ length: Math.max(1, Math.min(9, maxQty ?? 9)) }, (_, n) => n + 1);
return (
<div key={product.id} className="w-full">
<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}%
@@ -167,7 +240,12 @@ export function CartContent({
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
{/* Independent stacked rows, not grid siblings — no
equal-height pressure from neighboring lines, so a
plain conditional line is enough here. */}
{lowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<p className="font-bold text-body-sm text-text-muted">{product.description}</p>
<div className="flex flex-col gap-0.5 items-start">
<p className="text-label text-text-muted">Einzelpreis</p>
@@ -175,32 +253,33 @@ export function CartContent({
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-body-sm text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. MwSt.</span>
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
</div>
<div className="flex gap-4 items-center shrink-0 w-full sm:w-auto justify-between sm:justify-end">
<label className="sr-only" htmlFor={`qty-${product.id}`}>
<label className="sr-only" htmlFor={`qty-${lineKey}`}>
Menge für {product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</label>
<select
id={`qty-${product.id}`}
id={`qty-${lineKey}`}
value={entry.qty}
onChange={(e) => setQuantity(product.id, Number(e.target.value))}
onChange={(e) => setQuantity(product.id, Number(e.target.value), entry.variant)}
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
>
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
{qtyOptions.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<p className="font-bold text-h4 text-text-primary whitespace-nowrap">
{formatPrice(entry.qty * product.price)}
{formatPrice(entry.qty * unitPrice)}
</p>
<button
type="button"
onClick={() => removeFromCart(product.id)}
aria-label={`${product.name} entfernen`}
onClick={() => removeFromCart(product.id, entry.variant)}
aria-label={`${product.name}${entry.variant ? ` (${entry.variant})` : ""} entfernen`}
className="text-text-muted hover:text-text-primary text-xl leading-none active:scale-90 transition-all"
>
×
@@ -246,15 +325,16 @@ export function CartContent({
</div>
)}
{/* Rabattcode — no manual input anymore (Nutzer-Entscheidung:
kein offenes Eingabefeld für jede:n Besucher:in), nur noch
sichtbar wenn tatsächlich ein Code aktiv ist. Codes kommen
jetzt ausschließlich über einen Link mit vorausgefülltem
Code (siehe die useEffect oben), nicht mehr durch manuelle
Eingabe hier. /checkout zeigt weiterhin nur das bereits
angewendete Ergebnis (see lib/discount.ts, shared via
localStorage the same way the cart itself is). */}
{discount && (
{/* Rabattcode — manual input when nothing's applied yet AND
Payload actually has at least one active code right now
(showDiscountField — no point offering an open field
that could never validate against anything); once
active, always shows the result + "Entfernen" regardless
of showDiscountField (also reached via a direct link
with a prefilled code, see the useEffect above).
/checkout mirrors this exact block, sharing state
through lib/discount.ts's localStorage store. */}
{discount ? (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center w-full">
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
@@ -269,12 +349,46 @@ export function CartContent({
Entfernen
</button>
</div>
) : showDiscountField ? (
<form
onSubmit={(e) => {
e.preventDefault();
handleApplyDiscount(discountInput.trim());
}}
className="flex flex-col gap-2 w-full"
>
<div className="flex gap-2 w-full">
<label className="sr-only" htmlFor="cart-discount-code">Rabattcode</label>
<input
id="cart-discount-code"
type="text"
value={discountInput}
onChange={(e) => setDiscountInput(e.target.value)}
placeholder="Rabattcode"
className="flex-1 min-w-0 border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
<button
type="submit"
disabled={!discountInput.trim() || discountLoading}
className="shrink-0 rounded-sm border border-border px-4 py-2 text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand transition-colors disabled:opacity-50"
>
Anwenden
</button>
</div>
{discountError && <p className="text-label text-red-600">{discountError}</p>}
{discountLoading && <p className="text-label text-text-muted">Rabattcode wird geprüft</p>}
</form>
) : (
// No manual field to attach an error to (no active codes
// exist at all right now) — but a ?code= URL param can
// still trigger the auto-apply attempt above regardless
// of showDiscountField, so its failure needs somewhere to
// show.
<>
{discountError && <p className="text-label text-red-600 w-full">{discountError}</p>}
{discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft</p>}
</>
)}
{/* Feedback for a code that arrived via URL (?code=...) but
turned out invalid/expired — surfaced even though there's
no input field to attach it to anymore. */}
{!discount && discountError && <p className="text-label text-red-600 w-full">{discountError}</p>}
{!discount && discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft</p>}
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
@@ -317,7 +431,11 @@ export function CartContent({
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
<p className="text-label text-text-muted">inkl. MwSt.</p>
{kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : (
<VatBreakdown groups={taxBreakdown} />
)}
</div>
<Link
+47 -12
View File
@@ -3,7 +3,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useProducts } from "../../lib/products";
import { formatPrice } from "../../lib/format";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { Reveal } from "../../components/Reveal";
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { useCart } from "../../lib/cart";
@@ -29,7 +30,7 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
}
export function RelatedProducts() {
export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultTaxRate: number; kleinunternehmer: boolean }) {
const cart = useCart();
const products = useProducts();
// Cart/checkout resolve any product regardless of `active` (see
@@ -112,12 +113,7 @@ export function RelatedProducts() {
</p>
</Reveal>
{/* No separate price-disclosure footnote here — the single
"* inkl. MwSt., zzgl. Versandkosten" note lives directly under
the cart's own product table instead (CartContent.tsx), close
enough on the same page view to cover these cards too.
Plain divs, not RevealGroup/RevealItem — this is the one grid on
{/* Plain divs, not RevealGroup/RevealItem — this is the one grid on
the site whose items get swapped after the initial mount (see
the swap-in-place effect above). RevealItem has no viewport
trigger of its own; it only ever renders visible because it
@@ -128,7 +124,13 @@ export function RelatedProducts() {
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => (
{displayProducts.map((product, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// Same "any vs. every" split as ProductGrid.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;
return (
<div
key={product.id}
className={
@@ -155,6 +157,24 @@ export function RelatedProducts() {
sizes="(min-width: 768px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Same top-left pill pattern as ProductGrid.tsx/
ProductSpotlight.tsx — position: absolute, so it never
affects this card's height. Only the discount/Ausverkauft
pill lives here now; the low-stock hint moved to a
reserved-height text line below (see the min-h paragraph
under the price) — plain conditional text here is what
broke equal card heights in this grid before. */}
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
<p
@@ -163,11 +183,26 @@ export function RelatedProducts() {
>
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
<AddToCartInlineButton id={product.id} />
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<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>
{!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
stay equal height regardless of low-stock status; this
component has no h-full/flex-1 spacer trick like
ProductGrid.tsx to absorb a variable-height line instead. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</div>
))}
);
})}
</div>
</section>
);
+10 -3
View File
@@ -4,7 +4,8 @@ import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getCartTrustBadges, getShippingMethods, getShippingSettings } 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
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
@@ -19,10 +20,13 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
const [trustBadges, shippingMethods, shipping] = await Promise.all([
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField] = await Promise.all([
getCartTrustBadges(),
getShippingMethods(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
hasActiveDiscountCode(),
]);
// The cart doesn't ask which shipping method the shopper wants yet
@@ -51,9 +55,12 @@ export default async function CartPage() {
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
showDiscountField={showDiscountField}
/>
</Suspense>
<RelatedProducts />
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
<TrustRow />
</main>
<Footer />
+86
View File
@@ -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
View File
@@ -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" />
File diff suppressed because it is too large Load Diff
+142
View File
@@ -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>
);
}
+8 -2
View File
@@ -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 } 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,11 +16,14 @@ export const metadata: Metadata = {
};
export default async function CheckoutPage() {
const [shippingMethods, paymentMethods, trustBadges, shippingSettings, 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
@@ -32,9 +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>
);
}
+20
View File
@@ -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>
);
}
@@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { InvoiceDocument, SAMPLE_INVOICE_ORDER } from "../../lib/invoicePdf";
import { InvoiceDocument, SAMPLE_INVOICE_ORDER } from "@einfach-produktiv/invoicing";
import type { CompanySettings } from "../../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
@@ -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>
);
}
+4 -1
View File
@@ -15,6 +15,7 @@ const FALLBACK: CompanySettings = {
registerCourt: null,
registerNumber: null,
managingDirector: null,
shareCapital: null,
sellerStreet: "",
sellerZip: "",
sellerCity: "",
@@ -22,7 +23,9 @@ const FALLBACK: CompanySettings = {
sellerEmail: "",
vatId: "",
taxRatePercent: 19,
bankDetails: null,
kleinunternehmer: false,
iban: null,
bic: null,
};
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
+30 -16
View File
@@ -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>
+96 -27
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { addToCart } from "../lib/cart";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
const FEEDBACK_MS = 2000;
@@ -17,6 +17,9 @@ export function AddToCartButton({
label,
className,
productId = "todo-karten",
outOfStock = false,
maxQty = null,
variants = [],
}: {
label: string;
className?: string;
@@ -24,16 +27,35 @@ export function AddToCartButton({
* ProductSpotlight passes the actual CMS-selected spotlight product's id
* explicitly, since that can now be a different product. */
productId?: string;
/** Product-level — only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: boolean;
/** Product-level cap on total cart quantity — only meaningful when
* `variants` is empty, same split as `outOfStock`. null means no cap. */
maxQty?: number | null;
/** Optional — same shape/semantics as AddToCartInlineButton's own
* `variants` prop; all three callers already fetch the full product
* server-side, so this is just threaded straight through. */
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
}) {
const [added, setAdded] = useState(false);
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
const { fly } = useCartFly();
const cart = useCart();
useEffect(() => () => clearTimeout(timeoutRef.current), []);
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
const qtyInCart = cart.find((i) => i.id === productId && i.variant === selectedVariant)?.qty ?? 0;
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
const disabled = currentlyOutOfStock || limitReached;
function handleClick() {
addToCart(productId);
if (disabled) return;
addToCart(productId, 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
clearTimeout(timeoutRef.current);
@@ -55,33 +77,80 @@ export function AddToCartButton({
// a solid bright-green button read as too loud here. `border` (width) is
// added here too since `base` has none by default, unlike
// AddToCartInlineButton's own base which already carries a plain border.
const stateClasses = added
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
: "";
const stateClasses = disabled
? "opacity-60 cursor-not-allowed"
: added
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
: "";
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : label;
return (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
className={`${base} ${stateClasses}`}
>
{/* CSS-grid text-stack, not just swapping the button's text node
directly — this button is inline-flex/content-sized (no w-full),
so "Hinzugefügt ✓" being shorter than most labels made the whole
button visibly shrink while showing the success state. Stacking
both possible texts in the same grid cell (both invisible ones
still contribute to sizing) reserves width for whichever is
wider, so the button's box never changes size either way. */}
<span className="relative grid">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
// Low stock is deliberately NOT surfaced here as its own text line
// (it used to be) — that made this block's height vary card-to-card
// in every grid that renders this component, breaking equal-height
// card alignment (ProductSpotlight's CTA row, RelatedProducts' grid).
// The image-overlaid pill badge (ProductGrid.tsx/ProductSpotlight.tsx/
// RelatedProducts.tsx, position: absolute, doesn't participate in
// layout flow) is the one place this now shows, same as
// Ausverkauft/discount already do. The variant-select suffix below is
// unaffected — a native <select>'s own height doesn't vary with its
// option text.
<div className="flex flex-col gap-2">
{variants.length > 0 && (
<select
value={selectedVariant}
onChange={(e) => setSelectedVariant(e.target.value)}
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
aria-label="Variante auswählen"
>
{variants.map((v) => (
<option key={v.name} value={v.name}>
{v.name}
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
</option>
))}
</select>
)}
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{/* CSS-grid text-stack, not just swapping the button's text node
directly — this button is inline-flex/content-sized (no w-full),
so "Hinzugefügt ✓" being shorter than most labels made the whole
button visibly shrink while showing the success state. Stacking
both possible texts in the same grid cell (both invisible ones
still contribute to sizing) reserves width for whichever is
wider, so the button's box never changes size either way. Now
also reserves space for "Ausverkauft"/"Maximale Menge im
Warenkorb" — the widest of the four wins regardless of which is
showing. */}
{/* 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>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Hinzugefügt
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Ausverkauft
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Maximale Menge im Warenkorb
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Hinzugefügt
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : label}</span>
</span>
</button>
</button>
</div>
);
}
+76 -15
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { addToCart } from "../lib/cart";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
// Exported so consumers like RelatedProducts.tsx can delay their own
@@ -20,20 +20,51 @@ export function AddToCartInlineButton({
id,
label = "In den Warenkorb",
className,
outOfStock = false,
maxQty = null,
variants = [],
}: {
id: string;
label?: string;
className?: string;
/** Product-level — only meaningful when `variants` is empty. A varianted
* product's buyability is entirely per-variant instead (see below). */
outOfStock?: boolean;
/** Product-level cap on total cart quantity — only meaningful when
* `variants` is empty, same split as `outOfStock`. null means no cap
* (backorder allowed / inventory untracked). See lib/payload.ts's
* maxPurchasableQty(). */
maxQty?: number | null;
/** Optional — products.variants (name + optional priceOverride + its own
* outOfStock). When non-empty, a variant must be picked (defaults to the
* first *in-stock* one, or just the first if all are out) before "add to
* cart" is enabled — the selected variant's name is snapshotted onto the
* cart line and, later, the order itself. */
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
}) {
const [added, setAdded] = useState(false);
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
const { fly } = useCartFly();
const cart = useCart();
useEffect(() => () => clearTimeout(timeoutRef.current), []);
// Whichever is actually being offered right now — the selected variant's
// own flag if there are variants, otherwise the plain product-level one.
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
// How much of this exact (id, variant) line is already sitting in the
// cart — capped adds mean "In den Warenkorb" must go disabled once this
// reaches currentMaxQty, not just when the product is fully sold out.
const qtyInCart = cart.find((i) => i.id === id && i.variant === selectedVariant)?.qty ?? 0;
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
const disabled = currentlyOutOfStock || limitReached;
function handleClick() {
addToCart(id);
if (disabled) return;
addToCart(id, 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
clearTimeout(timeoutRef.current);
@@ -48,21 +79,51 @@ export function AddToCartInlineButton({
// anymore (it's a trailing `!` now), so two conflicting utilities like
// border-border/border-success both being present would silently race on
// CSS source order instead of one cleanly winning.
const stateClasses = added
? "border-success bg-success-subtle"
: "border-border hover:border-brand";
const stateClasses = disabled
? "border-border opacity-60 cursor-not-allowed"
: added
? "border-success bg-success-subtle"
: "border-border hover:border-brand";
return (
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
<span
className={
"text-body-sm transition-colors " +
(added ? "font-semibold text-success" : "text-text-primary")
}
// Low stock isn't shown as its own text line here (see
// AddToCartButton.tsx's identical comment on why) — the image-overlaid
// pill badge (ProductGrid.tsx/RelatedProducts.tsx, position: absolute,
// outside layout flow) is where this shows now, same as
// Ausverkauft/discount already do.
<div className="flex flex-col gap-2 w-full">
{variants.length > 0 && (
<select
value={selectedVariant}
onChange={(e) => setSelectedVariant(e.target.value)}
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
aria-label="Variante auswählen"
>
{variants.map((v) => (
<option key={v.name} value={v.name}>
{v.name}
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
</option>
))}
</select>
)}
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
<span
className={
"text-body-sm transition-colors " +
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
>
{currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
</div>
);
}
+10 -4
View File
@@ -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>
+3 -1
View File
@@ -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>
);
})}
+11 -6
View File
@@ -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
+6 -2
View File
@@ -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
View File
@@ -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>
+185 -94
View File
@@ -4,7 +4,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { useCartCount } from "../lib/cart";
import { AUTH_CHANGED_EVENT } from "../lib/auth";
import { NewsletterModal } from "./NewsletterModal";
import { useCartFly } from "./CartFly";
@@ -82,35 +84,41 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
// and reading the session cookie there (next/headers' cookies()) would
// force the entire site into per-request dynamic rendering just for this.
// `loggedIn === null` is the brief "not checked yet" state on first paint.
function AccountLink({ variant }: { variant: "icon" | "mobile" }) {
function AccountLink() {
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
useEffect(() => {
fetch("/api/account/me")
.then((res) => setLoggedIn(res.ok))
.catch(() => setLoggedIn(false));
function checkAuth() {
fetch("/api/account/me")
.then((res) => setLoggedIn(res.ok))
.catch(() => setLoggedIn(false));
}
checkAuth();
// Navbar lives in the root layout and never unmounts across
// navigations, so this effect only ever runs once on its own —
// router.refresh() (called after login/logout) re-fetches Server
// Component data but doesn't re-run an already-mounted Client
// Component's effects. AUTH_CHANGED_EVENT is dispatched explicitly by
// every login/logout call site (see dispatchAuthChanged() in
// ../lib/auth) so this stays in sync without a hard reload.
window.addEventListener(AUTH_CHANGED_EVENT, checkAuth);
return () => window.removeEventListener(AUTH_CHANGED_EVENT, checkAuth);
}, []);
const href = loggedIn ? "/konto/bestellungen" : "/konto/login";
if (variant === "mobile") {
return (
<Link
href={href}
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
{loggedIn ? "Mein Konto" : "Anmelden"}
</Link>
);
}
return (
<Link
href={href}
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>
@@ -327,6 +335,32 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
return () => document.removeEventListener("keydown", onKeyDown);
}, [mobileOpen]);
// Background scroll lock while the fullscreen panel is open — same
// wheel/touchmove interception as NewsletterModal.tsx (see that
// component's own comment on why this approach over overflow:hidden or
// position:fixed on body). Needed now that the panel actually covers the
// viewport instead of pushing page content down in normal flow.
useEffect(() => {
if (!mobileOpen) return;
const isInsidePanel = (target: EventTarget | null) =>
target instanceof Node && !!panelRef.current?.contains(target);
const onWheel = (e: WheelEvent) => {
if (!isInsidePanel(e.target)) e.preventDefault();
};
const onTouchMove = (e: TouchEvent) => {
if (!isInsidePanel(e.target)) e.preventDefault();
};
document.addEventListener("wheel", onWheel, { passive: false });
document.addEventListener("touchmove", onTouchMove, { passive: false });
return () => {
document.removeEventListener("wheel", onWheel);
document.removeEventListener("touchmove", onTouchMove);
};
}, [mobileOpen]);
const closeMobile = () => setMobileOpen(false);
return (
@@ -345,7 +379,11 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
// position itself.
<>
<header
className={`sticky top-0 z-50 w-full h-[6.25rem] flex flex-col transition-[background-color,backdrop-filter] duration-300 ${
// The mobile panel used to be a child of this element and needed the
// header itself to be a flexible column that could grow for it — it's
// now a fixed-position sibling instead (see that panel's own comment
// on why), so this is back to a plain fixed-height bar.
className={`sticky top-0 z-50 w-full h-[6.25rem] transition-[background-color,backdrop-filter] duration-300 ${
scrolled || mobileOpen
? "bg-bg-base/80 backdrop-blur-md"
: "bg-bg-base"
@@ -442,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 variant="icon" />
<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 */}
@@ -502,83 +550,126 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
</div>
</div>
{/* Mobile drawer panel — toggleable below lg (see hamburger above) */}
<div
id="mobile-nav-panel"
ref={panelRef}
className={`lg:hidden w-full overflow-hidden transition-[max-height] duration-300 ease-in-out ${
mobileOpen ? "max-h-[30rem]" : "max-h-0"
}`}
>
<nav className="flex flex-col gap-6 px-8 pt-2 pb-6">
{navLinks.map((link) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
return link.href.startsWith("#") ? (
<Link
key={link.href}
href={resolvedHref}
onClick={
isHomeAnchor
? (e) => {
e.preventDefault();
closeMobile();
const el = document.getElementById(link.href.slice(1));
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
smoothScrollTo(top);
}
history.replaceState(null, "", link.href);
}
: closeMobile
}
className="min-h-11 flex flex-col justify-center gap-1 text-h4 font-semibold text-text-primary w-fit"
>
{link.label}
<span
className={`h-[2px] bg-brand transition-opacity duration-200 ${
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
}`}
/>
</Link>
) : (
<Link
key={link.href}
href={link.href}
onClick={closeMobile}
className="min-h-11 flex items-center text-h4 font-semibold text-text-primary"
>
{link.label}
</Link>
);
})}
</nav>
<div className="flex flex-col gap-3 px-8 pb-8">
<button
type="button"
onClick={() => {
closeMobile();
setNewsletterOpen(true);
}}
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
Newsletter
</button>
<Link
href="/challenge"
onClick={closeMobile}
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
7-Tage-Challenge
</Link>
<div onClick={closeMobile}>
<AccountLink variant="mobile" />
</div>
</div>
</div>
</header>
{/* Fullscreen mobile panel — a sibling of <header>, deliberately NOT
nested inside it (same reason as NewsletterModal, see the
top-of-file comment: `mobileOpen` gives the header its own
backdrop-blur, which would make it a new containing block for any
`position: fixed` descendant and break the panel's fixed-to-
viewport positioning). Circular clip-path reveal expanding from
the hamburger's own corner (top-right) — the growing circle
naturally sweeps toward the opposite corner (bottom-left) last,
reading as the diagonal wipe this is going for without needing a
literal diagonal clip polygon. `vmax` (not %) for the radius so
full coverage holds regardless of viewport aspect ratio. */}
<AnimatePresence>
{mobileOpen && (
<motion.div
id="mobile-nav-panel"
ref={panelRef}
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
exit={{ clipPath: "circle(0vmax at 100% 0%)" }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<div className="flex flex-col min-h-full pt-[6.25rem]">
<nav className="flex flex-col flex-1 items-center justify-center gap-6 px-8 py-10 text-center">
{navLinks.map((link, i) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
// Staggered fade+rise entrance, timed to land after the
// clip-path reveal has visibly opened up — same
// "fancy but restrained" register as the rest of this
// codebase's motion usage (Reveal.tsx et al.), not a
// separate animation language just for this panel.
const linkMotionProps = {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0 },
transition: { delay: 0.15 + i * 0.05, duration: 0.3, ease: "easeOut" as const },
};
return link.href.startsWith("#") ? (
<motion.div key={link.href} {...linkMotionProps}>
<Link
href={resolvedHref}
onClick={
isHomeAnchor
? (e) => {
e.preventDefault();
closeMobile();
const el = document.getElementById(link.href.slice(1));
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
smoothScrollTo(top);
}
history.replaceState(null, "", link.href);
}
: closeMobile
}
className="min-h-11 flex flex-col justify-center gap-1 text-h-feature font-semibold text-text-primary w-fit"
style={{ fontFamily: "var(--font-lora)" }}
>
{link.label}
<span
className={`h-[2px] bg-brand transition-opacity duration-200 ${
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
}`}
/>
</Link>
</motion.div>
) : (
<motion.div key={link.href} {...linkMotionProps}>
<Link
href={link.href}
onClick={closeMobile}
className="min-h-11 flex items-center text-h-feature font-semibold text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{link.label}
</Link>
</motion.div>
);
})}
</nav>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 + navLinks.length * 0.05, duration: 0.3, ease: "easeOut" }}
className="flex flex-col gap-3 px-8 pb-16 pt-10"
>
{/* md:hidden — these two duplicate the inline CTA pair that's
already visible in the header itself from md (768px) up (see
"Trailing controls" above); only genuinely missing below
that, where the inline pair is hidden and the panel is
these buttons' only way to reach them. No login/account CTA
here (removed — Nutzer-Entscheidung: that's already reachable
via the account icon in the header itself, outside this
panel, no need to duplicate it inside). */}
<button
type="button"
onClick={() => {
closeMobile();
setNewsletterOpen(true);
}}
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
Newsletter
</button>
<Link
href="/challenge"
onClick={closeMobile}
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
7-Tage-Challenge
</Link>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
</>
);
+99 -50
View File
@@ -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>
+61 -34
View File
@@ -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>
+37 -8
View File
@@ -2,8 +2,9 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct, getShippingSettings } from "../lib/payload";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
/**
* Product teaser for whichever product is marked `spotlight` in Payload
@@ -22,11 +23,20 @@ import { formatPrice, discountPercent } from "../lib/format";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const [product, shipping] = await Promise.all([getSpotlightProduct(), getShippingSettings()]);
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getSpotlightProduct(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
if (!product) return null;
const image = product.spotlightImage || product.image;
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// Same "any vs. every" split as ProductGrid.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;
return (
// id="spotlight" — the Navbar's "Shop" link becomes an anchor to this
@@ -42,10 +52,16 @@ export async function ProductSpotlight() {
sizes="(min-width: 768px) 380px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
@@ -60,24 +76,37 @@ export async function ProductSpotlight() {
<p className="text-body text-text-body">
{product.spotlightText || product.description}
</p>
{/* MwSt./Versand disclosure on its own line, not crammed into
the price row itself — same reasoning as todo-cards'
Pricing.tsx (identical text, same narrow-column risk). */}
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<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. MwSt. zzgl. Versand</p>
</div>
<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>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
{/* Single product, no grid siblings to stay equal-height with
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
a plain conditional line instead of a reserved-height slot. */}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
{/* items-start at sm: — without it, the default cross-axis
stretch makes "Mehr erfahren" grow to match
AddToCartButton's own height whenever that one gets taller
(e.g. the low-stock hint line pushing its content down), so
a plain text link visibly ends up "fatter" than the actual
button next to it. */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-start gap-3 w-full sm:w-auto">
{/* No className override — the section's bg is bg-bg-base now
(matches Tools/Blog above/below), same as AddToCartButton's
own default styling/ring-offset, so no override is needed
here. */}
<AddToCartButton label="In den Warenkorb" productId={product.id} />
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
{product.href && (
<Link
href={product.href}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import Image from "next/image";
// Native size of the hand-drawn underline texture (icon-merke-dir-underline.png,
// exported from the Figma "label-underline" node) — used as the SSR/pre-hydration
// fallback width before the label's actual rendered width is measured.
const UNDERLINE_NATIVE_WIDTH = 136;
// Blockquote "Merke dir:" label + sparkle icon + underline (see RichText.tsx's
// "quote" case). Split out as its own client component because the underline
// needs to be stretched to match the label's actual rendered width — a fixed
// width only ever matched the one label length it was eyeballed against,
// leaving the underline too short (overflowing labels) or too long (sparse-
// looking short labels) for anything else.
export function QuoteLabel({ label }: { label: string }) {
const labelRef = useRef<HTMLSpanElement>(null);
const [underlineWidth, setUnderlineWidth] = useState(UNDERLINE_NATIVE_WIDTH);
useLayoutEffect(() => {
const el = labelRef.current;
if (!el) return;
const measure = () => setUnderlineWidth(el.offsetWidth);
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [label]);
return (
<div className="relative flex items-center gap-2 shrink-0">
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
</span>
<span
ref={labelRef}
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
{label}
</span>
{/* object-fill (not cover) — the box's height stays fixed, only the
width tracks the label, so the texture stretches horizontally to
match rather than getting cropped. */}
<Image
alt=""
src="/icon-merke-dir-underline.png"
width={UNDERLINE_NATIVE_WIDTH}
height={23}
className="absolute left-8 top-[2.1875rem] h-[1.4375rem] object-fill pointer-events-none"
style={{ width: underlineWidth }}
/>
</div>
);
}
+23 -9
View File
@@ -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 }}
>
+184 -132
View File
@@ -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 JSONJSX 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("");
@@ -35,7 +28,13 @@ function plainText(node: LexicalNode): string {
// "section-1" id from the leading number — immune to copy edits changing
// the heading text later, unlike a text-derived slug. Anything else
// (headings with no leading number) falls back to a plain slugify.
function headingId(text: string): string {
// Exported — the Impressum page renders some of its own headings outside
// this CMS-driven richText (the "Angaben zum Anbieter"/"Umsatzsteuer"/
// "Verantwortlich für den Inhalt" sections come straight from
// company-settings, not the richText field, see app/impressum/page.tsx)
// and needs the exact same id-assignment logic so its SectionTOC entries
// actually match the ids those headings render with.
export function headingId(text: string): string {
const numbered = text.match(/^(\d+)\./);
if (numbered) return `section-${numbered[1]}`;
return text
@@ -64,137 +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 && (
<>
<div className="flex items-center gap-2 shrink-0">
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
</span>
<span
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
{quoteLabel}
</span>
},
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>
{/* Hand-drawn underline image, not a plain bar — exported
straight from the Figma node (label-underline). */}
<Image
alt=""
src="/icon-merke-dir-underline.png"
width={136}
height={23}
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
))}
</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 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);
}
</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({
@@ -202,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>
);
}
+69 -18
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+20 -5
View File
@@ -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>
+1 -1
View File
@@ -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" />}
+36
View File
@@ -0,0 +1,36 @@
import { formatPrice } from "../lib/format";
import type { TaxBreakdownGroup } from "@einfach-produktiv/invoicing";
// The actual amount of VAT included in a total — not just a disclosure
// that VAT is included (see cartTotals.ts's effectiveTaxRate() for the
// "which %" shown next to each line item elsewhere). One line per rate
// when a cart/order spans more than one; a single line otherwise.
//
// Same row shape as the Gesamtsumme total line right above this
// (`flex w-full` + a `flex-1` spacer): a label on the left, flush with
// "Gesamtsumme", and the rate/amount pushed flush right so they land
// directly under the total's own € amount — not tucked in right next to
// the label. The rate itself gets a fixed-width right-aligned column
// (`w-8`, `tabular-nums`) so a single-digit rate ("7%") still lines up
// under a two-digit one ("19%") across rows instead of shifting the
// amount that follows it. Only the first row carries the "enthält
// MwSt.:" label; further rates repeat just the rate/amount pair.
export function VatBreakdown({ groups }: { groups: TaxBreakdownGroup[] }) {
if (groups.length === 0) return null;
return (
<div className="flex flex-col gap-0.5 w-full">
{groups.map((g, i) => (
<div key={g.rate} className="flex items-baseline w-full">
<span className="text-label text-text-muted">
{groups.length === 1 ? `enthält ${g.rate}% MwSt.` : i === 0 ? "enthält MwSt.:" : ""}
</span>
<span className="flex-1" />
{groups.length > 1 && (
<span className="w-8 shrink-0 text-right text-label text-text-muted tabular-nums">{g.rate}%</span>
)}
<span className="ml-1.5 text-label text-text-muted tabular-nums">{formatPrice(g.tax)}</span>
</div>
))}
</div>
);
}
+8 -1
View File
@@ -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} />
@@ -47,7 +47,7 @@ export function LiveEmailPreviewClient({
data,
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
SAMPLE_ORDER.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${SAMPLE_ORDER.orderNumber}`,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
null,
);
+33
View File
@@ -38,6 +38,10 @@
--color-toc-active-border: #f6a701;
--color-success: #2f8f4e;
--color-success-subtle: #e8f4ea;
/* Low-stock warning — distinct from --color-brand's golden yellow (used
for the discount badge) so the two pills never read as the same thing. */
--color-warning: #c2410c;
--color-warning-subtle: #fdf1e9;
/* Radius */
--radius-xs: 0.25rem;
@@ -137,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 {
@@ -0,0 +1,124 @@
import type { ReactNode } from "react";
import { headingId } from "../../components/RichText";
import type { CompanySettings } from "../../lib/payload";
import type { TOCSection } from "../../components/SectionTOC";
// Renders "Angaben zum Anbieter"/"Umsatzsteuer"/(conditionally)
// "Handelsregister"/"Geschäftsführung"/"Verantwortlich für den Inhalt"
// straight from company-settings, matching RichText.tsx's own heading/
// paragraph classes so it reads as one continuous page with the CMS
// content below it, not a bolted-on block. This used to be hand-typed
// prose baked into the Impressum's richText (seed-legal-pages.ts on the
// Payload side) — duplicated, and silently out of date the moment an
// admin changed company-settings without also remembering to re-edit the
// Impressum text by hand. Single-sourced here instead, same "structural/
// brand elements in code, only pull the actual numbers/copy that need
// single-sourcing from data" pattern this page's own Nachhaltigkeit card
// already uses (see page.tsx's comment on that).
//
// Also closes a real compliance gap the old hand-typed text had: it never
// showed Handelsregister/Geschäftsführung at all, even though
// company-settings already models both (§37a HGB/§35a GmbHG) — those
// fields just weren't wired into the Impressum. A sole proprietorship
// (this shop's current legalForm) has neither, so neither section shows
// today, but the moment that changes in company-settings, the Impressum
// picks it up automatically instead of needing a second manual edit.
//
// A "Gesellschafter"/Komplementäre section for OHG/KG was attempted
// 2026-07-23 but reverted the same day — the legal basis turned out
// genuinely unclear on research (§125a HGB's Geschäftsbriefe-naming duty
// only applies to the narrow case where *no* partner is a natural person,
// not the general OHG/KG case; whether §5 DDG's Impressum-specific
// "vertretungsberechtigte Person" requirement independently mandates it
// wasn't resolved with confidence). Deliberately not modeled until that's
// actually clarified — don't rebuild this without re-verifying the legal
// basis first, and don't assume the old attempt's reasoning was correct.
export function anbieterAngabenHeadings(seller: CompanySettings | null): TOCSection[] {
if (!seller) return [];
const sections = ["Angaben zum Anbieter", "Umsatzsteuer"];
if (seller.registerCourt && seller.registerNumber) sections.push("Handelsregister");
if (seller.managingDirector) sections.push("Geschäftsführung");
sections.push("Verantwortlich für den Inhalt");
return sections.map((title) => ({ id: headingId(title), title }));
}
function Heading({ children }: { children: string }) {
return (
<h2
id={headingId(children)}
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
style={{ fontFamily: "var(--font-lora)" }}
>
{children}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</h2>
);
}
function P({ children }: { children: ReactNode }) {
return <p className="text-body text-text-body">{children}</p>;
}
export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
return (
<div className="flex flex-col gap-4 w-full">
<Heading>Angaben zum Anbieter</Heading>
{/* 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>
<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>
<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>
</>
)}
{seller.managingDirector && (
<>
<Heading>Geschäftsführung</Heading>
<P>{seller.managingDirector}</P>
</>
)}
<Heading>Verantwortlich für den Inhalt</Heading>
{/* §18 Abs. 2 MStV wants a natural person — managingDirector first
(Kapitalgesellschaften), falling back to sellerName itself (sole
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. */}
<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>
);
}
+24 -7
View File
@@ -6,19 +6,23 @@ 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 { getLegalPage } from "../lib/payload";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage, getCompanySettings } from "../lib/payload";
import { AnbieterAngaben, anbieterAngabenHeadings } from "./components/AnbieterAngaben";
export const metadata: Metadata = {
title: "Impressum",
description: "Angaben gemäß § 5 TMG für einfach produktiv.",
description: "Angaben gemäß § 5 DDG für einfach produktiv.",
alternates: { canonical: "/impressum" },
};
export default async function ImpressumPage() {
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("impressum", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
const [page, seller] = await Promise.all([getLegalPage("impressum", { draft: isPreview }), getCompanySettings()]);
// Anbieter-Angaben headings first — that block renders above the CMS
// content below, so its TOC entries need to lead too, or the sidebar
// would list sections in a different order than they actually appear.
const headings = [...anbieterAngabenHeadings(seller), ...(page ? extractHeadings(page.content) : [])];
return (
<>
@@ -35,9 +39,16 @@ export default async function ImpressumPage() {
>
Impressum
</p>
<p className="text-body text-text-muted">Angaben gemäß § 5 TMG</p>
<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} />
@@ -66,7 +77,13 @@ export default async function ImpressumPage() {
</div>
</div>
<div className="w-full lg:flex-1 min-w-0">
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
{/* Seller identity (name/address/USt-ID/Handelsregister/
Geschäftsführung) comes straight from company-settings, not
the CMS richText below — single-sourced so it can never
drift out of sync with the same data the invoice PDFs and
every email footer already use. See AnbieterAngaben.tsx. */}
{seller && <AnbieterAngaben seller={seller} />}
{page ? (
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
+93 -13
View File
@@ -1,17 +1,32 @@
import type { Metadata } from "next";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Reveal } from "../../../components/Reveal";
import { Footer } from "../../../components/Footer";
import { VatBreakdown } from "../../../components/VatBreakdown";
import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
import { getProductImagesByIds } from "../../../lib/payload";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
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;
@@ -25,7 +40,13 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
order.deliveryMethod === "address"
? order.street
: `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
const shippingAddress =
order.shippingDeliveryMethod === "packstation"
? `Packstation ${order.shippingPackstationNumber} · Postnummer ${order.shippingPostNumber}`
: order.shippingStreet;
const action = customerOrderAction(order.status);
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
return (
<>
@@ -54,8 +75,31 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</div>
</div>
{order.trackingNumber && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}</p>
{(() => {
const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
return trackingUrl ? (
<a href={trackingUrl} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
{order.trackingNumber}
</a>
) : (
<p className="text-body-sm text-text-primary">{order.trackingNumber}</p>
);
})()}
</div>
)}
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Lieferadresse</p>
{/* Labeled "Rechnungsadresse" only once there's an actual
second (shipping) address to distinguish it from — the
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>
@@ -63,15 +107,43 @@ 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 && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Lieferadresse</p>
<p className="text-body-sm text-text-primary">
{order.shippingFirstName} {order.shippingLastName}
</p>
<p className="text-body-sm text-text-primary">{shippingAddress}</p>
<p className="text-body-sm text-text-primary">
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
</p>
</div>
)}
<div className="w-full bg-bg-base border border-border rounded-md p-6 flex flex-col gap-3">
{order.items.map((item, i) => (
{order.items.map((item, i) => {
const imageUrl = imagesByProductId.get(item.product);
return (
<div key={i} className="flex items-start gap-4 w-full">
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
{imageUrl && <Image src={imageUrl} alt="" fill sizes="64px" className="object-cover" />}
</div>
<div className="flex-1 flex flex-col gap-0.5">
<p className="text-body-sm text-text-primary">
{item.quantity} × {item.productName}
{item.variantName ? ` (${item.variantName})` : ""}
</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>
@@ -79,7 +151,8 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</div>
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</p>
</div>
))}
);
})}
<div className="h-px bg-border w-full" />
@@ -105,12 +178,19 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<div className="h-px bg-border w-full" />
<div className="flex items-center w-full">
<span className="font-semibold text-h4 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Gesamtsumme
</span>
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span className="font-semibold text-h4 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Gesamtsumme
</span>
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
</div>
{order.kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : (
<VatBreakdown groups={taxBreakdown} />
)}
</div>
</div>
+2
View File
@@ -1,12 +1,14 @@
"use client";
import { useRouter } from "next/navigation";
import { dispatchAuthChanged } from "../../lib/auth";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/account/logout", { method: "POST" });
dispatchAuthChanged();
router.push("/");
router.refresh();
}
+2
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../../components/Reveal";
import { mergeServerCartIntoLocal } from "../../../lib/cart";
import { dispatchAuthChanged } from "../../../lib/auth";
export function LoginForm() {
const router = useRouter();
@@ -30,6 +31,7 @@ export function LoginForm() {
return;
}
await mergeServerCartIntoLocal();
dispatchAuthChanged();
router.push("/konto/bestellungen");
router.refresh();
} catch {
+1
View File
@@ -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 },
};
+33 -4
View File
@@ -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);
@@ -45,6 +56,8 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
zip: String(form.get("zip") ?? ""),
city: String(form.get("city") ?? ""),
country: String(form.get("country") ?? ""),
companyName: String(form.get("companyName") ?? "") || undefined,
vatId: String(form.get("vatId") ?? "") || undefined,
};
try {
@@ -83,6 +96,22 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
</div>
{/* Optional B2B fields — prefills /checkout's own Firma/USt-IdNr.
fields, same "profile default, order keeps its own snapshot"
split as the address fields below (see Customers.ts). */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Firma (optional)" name="companyName" type="text" defaultValue={profile.companyName ?? ""} />
<Field
label="USt-IdNr. (optional)"
name="vatId"
type="text"
defaultValue={profile.vatId ?? ""}
placeholder="DE123456789"
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
/>
</div>
<div className="w-full flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
@@ -129,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>
+4 -2
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -11,6 +11,10 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
image: null,
taxRatePercent: null,
bundleItems: null,
variants: null,
trackInventory: false,
stock: null,
allowBackorder: false,
...overrides,
});
+5
View File
@@ -17,6 +17,11 @@ const product = (overrides: Partial<Product> = {}): Product => ({
spotlightHeadline: null,
spotlightText: null,
spotlightImage: null,
variants: [],
outOfStock: false,
lowStock: false,
maxQty: null,
taxRatePercent: null,
...overrides,
});
-97
View File
@@ -1,97 +0,0 @@
import { describe, it, expect } from "vitest";
import { __testables, type InvoiceItem, type InvoiceOrder } from "../invoicePdf";
const { isPaidImmediately, groupByTaxRate } = __testables;
const item = (overrides: Partial<InvoiceItem> = {}): InvoiceItem => ({
productName: "ToDo-Karten",
quantity: 2,
unitPrice: 12.9,
taxRatePercent: 19,
bundleContents: null,
...overrides,
});
const order = (overrides: Partial<InvoiceOrder> = {}): InvoiceOrder => ({
orderNumber: "#EP-0001",
invoiceNumber: "RE-0001",
invoiceIssuedAt: new Date().toISOString(),
customerFirstName: "Max",
customerLastName: "Mustermann",
deliveryMethod: "address",
street: "Musterweg 1",
zip: "10115",
city: "Berlin",
country: "Deutschland",
paymentMethodTitle: "Kreditkarte",
items: [item()],
subtotal: 25.8,
shippingCost: 2.9,
discountAmount: 0,
discountCode: null,
total: 28.7,
...overrides,
});
describe("isPaidImmediately", () => {
it("is true for Kreditkarte", () => {
expect(isPaidImmediately("Kreditkarte")).toBe(true);
});
it("is true for PayPal", () => {
expect(isPaidImmediately("PayPal")).toBe(true);
});
it("is false only for Überweisung", () => {
expect(isPaidImmediately("Überweisung")).toBe(false);
});
it("defaults to true for any future/unknown payment method (only Überweisung is the named exception)", () => {
expect(isPaidImmediately("Sofortüberweisung")).toBe(true);
expect(isPaidImmediately("Klarna")).toBe(true);
});
});
describe("groupByTaxRate (original invoice)", () => {
it("reconciles net+tax to gross, and gross to the order total for a single rate", () => {
const o = order({ items: [item({ quantity: 2, unitPrice: 12.9 })], subtotal: 25.8, shippingCost: 2.9, discountAmount: 0, total: 28.7 });
const groups = groupByTaxRate(o, 19);
expect(groups).toHaveLength(1);
const [g] = groups;
expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
expect(g.gross).toBeCloseTo(28.7, 2);
});
it("distributes a discount proportionally, still reconciling to the discounted total", () => {
const o = order({
items: [item({ quantity: 1, unitPrice: 50 })],
subtotal: 50,
shippingCost: 0,
discountAmount: 10,
total: 40,
});
const groups = groupByTaxRate(o, 19);
const total = groups.reduce((sum, g) => sum + g.gross, 0);
expect(total).toBeCloseTo(40, 2);
});
it("splits multiple tax rates into separate groups that each reconcile", () => {
const o = order({
items: [item({ quantity: 1, unitPrice: 20, taxRatePercent: 19 }), item({ quantity: 1, unitPrice: 10, taxRatePercent: 7 })],
subtotal: 30,
shippingCost: 0,
discountAmount: 0,
total: 30,
});
const groups = groupByTaxRate(o, 19);
expect(groups).toHaveLength(2);
for (const g of groups) expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
expect(groups.reduce((sum, g) => sum + g.gross, 0)).toBeCloseTo(30, 2);
});
it("falls back to the tenant default rate when an item has no taxRatePercent", () => {
const o = order({ items: [item({ taxRatePercent: undefined as unknown as number })] });
const groups = groupByTaxRate(o, 7);
expect(groups[0].rate).toBe(7);
});
});
+10
View File
@@ -0,0 +1,10 @@
// Fired by every client-side call site that logs a customer in or out, so
// already-mounted Client Components (e.g. Navbar's AccountLink, which never
// unmounts across navigations) can re-check /api/account/me without needing
// a hard reload. router.refresh() alone doesn't do this — it only re-runs
// Server Components.
export const AUTH_CHANGED_EVENT = "ep-auth-changed";
export function dispatchAuthChanged() {
window.dispatchEvent(new Event(AUTH_CHANGED_EVENT));
}
+67
View File
@@ -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." };
}
}
+25 -10
View File
@@ -6,7 +6,13 @@ const CART_KEY = "ep_cart";
const CART_EVENT = "ep-cart-updated";
const EMPTY_CART: CartItem[] = [];
export type CartItem = { id: string; qty: number };
// `variant` is the selected variant's name (products.variants[].name),
// undefined for a plain product with no variants. Two lines with the same
// `id` but different `variant` are separate cart entries, never merged —
// same "distinguish by the full key, not just id" reasoning as the
// server-side cart mirror (Customers.ts's cart array, which stores this
// same field as `variantName`).
export type CartItem = { id: string; qty: number; variant?: string };
function readCart(): CartItem[] {
if (typeof window === "undefined") return [];
@@ -27,16 +33,25 @@ export function getCartCount(): number {
return readCart().reduce((sum, item) => sum + item.qty, 0);
}
export function addToCart(id: string, qty = 1) {
// A line is identified by (id, variant) together, not id alone — the same
// product with two different variants selected are separate cart entries.
// `variant` undefined on both sides (the common, no-variants case) still
// matches by simple equality, so every existing call site that never
// passes a variant keeps working unchanged.
function sameLine(item: CartItem, id: string, variant: string | undefined): boolean {
return item.id === id && item.variant === variant;
}
export function addToCart(id: string, qty = 1, variant?: string) {
const items = readCart();
const existing = items.find((i) => i.id === id);
const existing = items.find((i) => sameLine(i, id, variant));
if (existing) existing.qty += qty;
else items.push({ id, qty });
else items.push(variant ? { id, qty, variant } : { id, qty });
writeCart(items);
}
export function removeFromCart(id: string) {
writeCart(readCart().filter((i) => i.id !== id));
export function removeFromCart(id: string, variant?: string) {
writeCart(readCart().filter((i) => !sameLine(i, id, variant)));
}
// Called by /bestellbestaetigung once it has captured a snapshot of the
@@ -50,13 +65,13 @@ export function clearCart() {
// qty <= 0 removes the item outright — the cart page's quantity stepper
// never lets the visible count go below 1, but this keeps the function
// itself safe to call with any integer without a separate remove path.
export function setQuantity(id: string, qty: number) {
export function setQuantity(id: string, qty: number, variant?: string) {
if (qty <= 0) {
removeFromCart(id);
removeFromCart(id, variant);
return;
}
const items = readCart();
const existing = items.find((i) => i.id === id);
const existing = items.find((i) => sameLine(i, id, variant));
if (existing) existing.qty = qty;
writeCart(items);
}
@@ -118,7 +133,7 @@ export async function mergeServerCartIntoLocal(): Promise<void> {
const res = await fetch("/api/account/cart");
if (!res.ok) return;
const data: { cart?: CartItem[] } = await res.json();
for (const item of data.cart ?? []) addToCart(item.id, item.qty);
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
} catch {
// Best-effort — a failed merge just means the server-side cart stays
// as it was; nothing local is lost either way.
+22 -2
View File
@@ -6,15 +6,35 @@ import type { Product } from "./payload";
// discount-code math to all three at once would otherwise mean hand-editing
// 3 near-identical blocks (and risking them drifting apart).
export type CartLine = { entry: { qty: number }; product: Product };
export type CartLine = { entry: { qty: number; variant?: string }; product: Product };
export type DiscountLike = { type: "percent" | "fixed"; value: number };
// A selected variant's priceOverride wins over the base product price —
// null/undefined priceOverride (or no variant selected at all) falls back
// to it. The one place cart/checkout math needs to know about variants at
// all; every other total below builds on this instead of `product.price`
// directly.
export function effectivePrice(entry: { variant?: string }, product: Product): number {
if (!entry.variant) return product.price;
const variant = product.variants.find((v) => v.name === entry.variant);
return variant?.priceOverride ?? product.price;
}
// A product's own taxRatePercent override wins over the tenant's default
// rate — mirrors api/checkout/route.ts's server-side snapshot logic
// (`product.taxRatePercent ?? defaultTaxRate`), kept in sync deliberately
// since this is only ever used for display, never for the actual charged
// amount.
export function effectiveTaxRate(product: Product, defaultRate: number): number {
return product.taxRatePercent ?? defaultRate;
}
// Split out from computeCartTotals() below because callers need a subtotal
// figure *before* they can decide a shipping cost (e.g. checking it against
// a free-shipping threshold) — which computeCartTotals itself takes as an
// input, not something it can decide on its own.
export function computeSubtotal(items: CartLine[]): number {
return items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
return items.reduce((sum, { entry, product }) => sum + entry.qty * effectivePrice(entry, product), 0);
}
export type CartTotals = {
+72
View File
@@ -0,0 +1,72 @@
"use client";
// Survives navigating away from /checkout and back (e.g. to double-check
// something in /cart) — same localStorage approach as lib/cart.ts/
// lib/discount.ts, but plain read/write functions rather than
// useSyncExternalStore: CheckoutContent is this draft's only reader, so
// there's no cross-component subscription to keep in sync the way the cart
// needs (Navbar + CartContent + CheckoutContent all read it at once).
// Deliberately excludes `password` — that field stays a plain uncontrolled
// input, never persisted.
const DRAFT_KEY = "ep_checkout_draft";
export type CheckoutDraft = {
firstName: string;
lastName: string;
email: string;
// Optional B2B fields — see CheckoutContent.tsx's own comment on why
// they sit here (right next to the Rechnungsadresse fields, not a
// separate persisted concept).
companyName: string;
vatId: string;
// Rechnungsadresse is always a plain street address now — no
// deliveryMethod/packstationNumber/postNumber here, only on the
// shipping* override fields below (see CheckoutContent.tsx).
street: string;
zip: string;
city: string;
country: string;
hasDifferentShippingAddress: boolean;
shippingFirstName: string;
shippingLastName: string;
shippingDeliveryMethod: "address" | "packstation";
shippingStreet: string;
shippingPackstationNumber: string;
shippingPostNumber: string;
shippingZip: string;
shippingCity: string;
shippingCountry: string;
newsletterOptIn: boolean;
shippingMethodId: number | null;
paymentMethodId: number | null;
};
export function readCheckoutDraft(): Partial<CheckoutDraft> | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function writeCheckoutDraft(draft: CheckoutDraft) {
try {
window.localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch {
// localStorage unavailable (private browsing etc.) — the draft just
// doesn't persist this time, same best-effort fallback as
// lib/order.ts's sessionStorage write.
}
}
// Called once an order actually completes (handleSubmit) — a leftover
// draft from a finished purchase would otherwise pre-fill the next one.
export function clearCheckoutDraft() {
try {
window.localStorage.removeItem(DRAFT_KEY);
} catch {
// ignore
}
}
-292
View File
@@ -1,292 +0,0 @@
import React from "react";
import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
import { formatDate } from "./format";
// Frontend port of the Payload backend's src/lib/correctionInvoicePdf.tsx
// — the *real* Stornorechnung/Gutschrift is generated and emailed from
// Payload's own Orders.ts afterChange hook (that's where the status
// transition and the correction invoice NUMBER are actually assigned).
// This copy exists only so a customer can re-download the same document
// later from /konto/bestellungen/[orderNumber] without it having been
// stored as a file anywhere — same "deterministic regeneration, not file
// storage" approach already used for the original invoice (see
// invoicePdf.tsx): correctionInvoiceNumber/correctionInvoiceIssuedAt are
// immutable once set, so re-rendering from the order's own stored data
// always reproduces the identical document.
const BRAND = "#f6a701";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const BG_MUTED = "#f8f5f1";
const styles = StyleSheet.create({
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
// A rule, not a filled band — a bold brand-colored line rather than a
// plain 1pt gray divider.
headerBand: {
padding: 32,
paddingBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: BRAND,
},
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
body: { padding: 32, paddingBottom: 90 },
refLine: { fontSize: 10, color: TEXT_MUTED, marginBottom: 24 },
addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
addressBlock: { width: "45%" },
addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" },
addressLine: { fontSize: 10, lineHeight: 1.5 },
metaRow: { flexDirection: "row", gap: 10, marginBottom: 24 },
metaBox: { borderWidth: 1, borderColor: BORDER, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12 },
metaLabel: { fontSize: 7, color: TEXT_MUTED, textTransform: "uppercase", marginBottom: 2 },
metaValue: { fontSize: 10, fontFamily: "Helvetica-Bold" },
table: { borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: BORDER, marginBottom: 16 },
tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 },
tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER },
tableRowAlt: { backgroundColor: BG_MUTED },
colName: { flex: 3 },
colQty: { flex: 1, textAlign: "right" },
colPrice: { flex: 1, textAlign: "right" },
colTotal: { flex: 1, textAlign: "right" },
headerCell: { fontSize: 8, color: TEXT_MUTED, textTransform: "uppercase" },
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
summary: { alignItems: "flex-end", marginBottom: 24 },
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
summaryRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2 },
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
summaryValue: { fontSize: 10 },
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
footer: {
position: "absolute",
bottom: 32,
left: 32,
right: 32,
borderTopWidth: 1,
borderTopColor: BORDER,
paddingTop: 12,
fontSize: 8,
color: TEXT_MUTED,
},
});
function formatPrice(amount: number): string {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
}
export type CorrectionInvoiceKind = "storno" | "gutschrift";
export type CorrectionInvoiceItem = {
productName: string;
quantity: number;
unitPrice: number;
taxRatePercent: number;
bundleContents?: string | null;
returnQuantity?: number;
};
export type CorrectionInvoiceOrder = {
orderNumber: string;
invoiceNumber: string;
invoiceIssuedAt: string;
correctionInvoiceNumber: string;
correctionInvoiceIssuedAt: string;
customerFirstName: string;
customerLastName: string;
deliveryMethod: "address" | "packstation";
street?: string | null;
packstationNumber?: string | null;
postNumber?: string | null;
zip: string;
city: string;
country: string;
items: CorrectionInvoiceItem[];
subtotal: number;
shippingCost: number;
discountAmount: number;
total: number;
};
export type InvoiceSeller = {
sellerName: string;
sellerStreet: string;
sellerZip: string;
sellerCity: string;
sellerCountry: string;
sellerEmail: string;
vatId: string;
taxRatePercent: number;
bankDetails?: string | null;
// Pflichtangaben in Geschäftsbriefen for registered legal forms (§37a
// HGB / §35a GmbHG) — optional because a sole proprietorship (the
// default legalForm in company-settings) has neither. Mirrors
// invoicePdf.tsx's own InvoiceSeller — see that file's comment.
registerCourt?: string | null;
registerNumber?: string | null;
managingDirector?: string | null;
};
// Stornorechnung: every item at its full ordered quantity (nothing
// shipped, undo everything). Gutschrift: only items with a nonzero
// returnQuantity, at that returned quantity — see this file's port source
// (backend src/lib/correctionInvoicePdf.tsx) for the full policy
// reasoning (no shipping refund, no discount reproration on a Gutschrift).
function resolveLineItems(kind: CorrectionInvoiceKind, items: CorrectionInvoiceItem[]): { item: CorrectionInvoiceItem; effectiveQuantity: number }[] {
if (kind === "storno") return items.map((item) => ({ item, effectiveQuantity: item.quantity }));
return items
.filter((item) => (item.returnQuantity ?? 0) > 0)
.map((item) => ({ item, effectiveQuantity: item.returnQuantity as number }));
}
function groupByTaxRate(
kind: CorrectionInvoiceKind,
lines: { item: CorrectionInvoiceItem; effectiveQuantity: number }[],
order: CorrectionInvoiceOrder,
defaultRate: number,
): { rate: number; net: number; tax: number; gross: number }[] {
const groups = new Map<number, number>();
for (const { item, effectiveQuantity } of lines) {
const rate = item.taxRatePercent ?? defaultRate;
const lineGross = effectiveQuantity * item.unitPrice;
groups.set(rate, (groups.get(rate) ?? 0) + lineGross);
}
const scale = kind === "storno" && order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.subtotal : 1;
return Array.from(groups.entries())
.map(([rate, lineGross]) => {
const gross = lineGross * scale;
const net = gross / (1 + rate / 100);
return { rate, net, tax: gross - net, gross };
})
.sort((a, b) => b.rate - a.rate);
}
function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionInvoiceKind; order: CorrectionInvoiceOrder; seller: InvoiceSeller }) {
const kindLabel = kind === "storno" ? "Stornorechnung" : "Gutschrift";
const lines = resolveLineItems(kind, order.items);
const rateGroups = groupByTaxRate(kind, lines, order, seller.taxRatePercent);
const grandTotal = rateGroups.reduce((sum, g) => sum + g.gross, 0);
const refNote =
kind === "storno"
? "vollständige Stornierung des ursprünglichen Rechnungsbetrags (inkl. Versand)."
: "Gutschrift für die zurückgesendeten Artikel — ohne Versandkosten, der ursprüngliche Rabatt bleibt unverändert bei den behaltenen Artikeln.";
const deliveryLine =
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.headerBand}>
<Text style={styles.wordmark}>einfach produktiv.</Text>
<Text style={styles.kindLabel}>{kindLabel.toUpperCase()}</Text>
</View>
<View style={styles.body}>
<Text style={styles.refLine}>
{kindLabel} zu Rechnung Nr. {order.invoiceNumber} vom {formatDate(order.invoiceIssuedAt)} (Bestellung {order.orderNumber}) {refNote}
</Text>
<View style={styles.addressRow}>
<View style={styles.addressBlock}>
<Text style={styles.addressLabel}>Von</Text>
<Text style={styles.addressLine}>{seller.sellerName}</Text>
<Text style={styles.addressLine}>{seller.sellerStreet}</Text>
<Text style={styles.addressLine}>
{seller.sellerZip} {seller.sellerCity}
</Text>
<Text style={styles.addressLine}>{seller.sellerCountry}</Text>
</View>
<View style={styles.addressBlock}>
<Text style={styles.addressLabel}>An</Text>
<Text style={styles.addressLine}>
{order.customerFirstName} {order.customerLastName}
</Text>
<Text style={styles.addressLine}>{deliveryLine}</Text>
<Text style={styles.addressLine}>
{order.zip} {order.city}
</Text>
<Text style={styles.addressLine}>{order.country}</Text>
</View>
</View>
<View style={styles.metaRow}>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>{kindLabel === "Gutschrift" ? "Gutschrift-Nr." : "Storno-Nr."}</Text>
<Text style={styles.metaValue}>{order.correctionInvoiceNumber}</Text>
</View>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>Datum</Text>
<Text style={styles.metaValue}>{formatDate(order.correctionInvoiceIssuedAt)}</Text>
</View>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>USt-IdNr.</Text>
<Text style={styles.metaValue}>{seller.vatId}</Text>
</View>
</View>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.colName, styles.headerCell]}>Artikel</Text>
<Text style={[styles.colQty, styles.headerCell]}>Menge</Text>
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
<Text style={[styles.colTotal, styles.headerCell]}>Betrag</Text>
</View>
{lines.map(({ item, effectiveQuantity }, i) => (
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
<View style={styles.colName}>
<Text>{item.productName}</Text>
{item.bundleContents ? <Text style={styles.bundleLine}>{item.bundleContents}</Text> : null}
</View>
<Text style={styles.colQty}>{effectiveQuantity}</Text>
<Text style={styles.colPrice}>{formatPrice(item.unitPrice)}</Text>
<Text style={styles.colTotal}>-{formatPrice(effectiveQuantity * item.unitPrice)}</Text>
</View>
))}
</View>
<View style={styles.summary}>
<View style={styles.summaryBox}>
{rateGroups.map((g) => (
<React.Fragment key={g.rate}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Netto ({g.rate}%)</Text>
<Text style={styles.summaryValue}>-{formatPrice(g.net)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
<Text style={styles.summaryValue}>-{formatPrice(g.tax)}</Text>
</View>
</React.Fragment>
))}
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>-{formatPrice(grandTotal)}</Text>
</View>
</View>
</View>
</View>
<View style={styles.footer} fixed>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
{seller.registerCourt && seller.registerNumber ? ` · ${seller.registerCourt} · ${seller.registerNumber}` : ""}
{seller.managingDirector ? ` · Geschäftsführung: ${seller.managingDirector}` : ""}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</Page>
</Document>
);
}
export async function renderCorrectionInvoicePdf(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
return renderToBuffer(<CorrectionInvoiceDocument kind={kind} order={order} seller={seller} />);
}
// Exported for unit testing (see app/lib/__tests__/correctionInvoicePdf.test.ts)
// — the actual money math, independent of the PDF rendering.
export const __testables = { resolveLineItems, groupByTaxRate };
+47 -6
View File
@@ -199,6 +199,10 @@ export type CustomerAddress = {
zip: string | null;
city: string | null;
country: string | null;
// Optional B2B profile default — see Customers.ts's own comment. Prefills
// /checkout's Firma/USt-IdNr. fields for a returning customer.
companyName: string | null;
vatId: string | null;
};
export type CustomerProfile = CustomerSummary & CustomerAddress;
@@ -217,7 +221,9 @@ type PayloadCustomerMe = {
zip: string | null;
city: string | null;
country: string | null;
cart: { product: number; productSlug: string; quantity: number }[] | null;
companyName: string | null;
vatId: string | null;
cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null;
};
export async function getCustomerProfile(token: string): Promise<CustomerProfile | null> {
@@ -243,6 +249,8 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
zip: u.zip,
city: u.city,
country: u.country,
companyName: u.companyName,
vatId: u.vatId,
};
}
@@ -259,6 +267,8 @@ export async function updateCustomerProfile(
zip: string;
city: string;
country: string;
companyName?: string;
vatId?: string;
},
): Promise<{ ok: true } | { ok: false; reason: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
@@ -353,19 +363,21 @@ export async function getServerCart(token: string): Promise<CartItem[]> {
});
if (!res.ok) return [];
const data: { user: PayloadCustomerMe | null } = await res.json();
return (data.user?.cart ?? []).map((line) => ({ id: line.productSlug, qty: line.quantity }));
return (data.user?.cart ?? []).map((line) =>
line.variantName ? { id: line.productSlug, qty: line.quantity, variant: line.variantName } : { id: line.productSlug, qty: line.quantity },
);
}
export async function saveServerCart(
token: string,
customerId: number,
cart: { productId: number; productSlug: string; quantity: number }[],
cart: { productId: number; productSlug: string; quantity: number; variant?: string }[],
): Promise<boolean> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
cart: cart.map((line) => ({ product: line.productId, productSlug: line.productSlug, quantity: line.quantity })),
cart: cart.map((line) => ({ product: line.productId, productSlug: line.productSlug, quantity: line.quantity, variantName: line.variant ?? null })),
}),
});
return res.ok;
@@ -397,6 +409,11 @@ export type CustomerOrder = {
total: number;
status: string;
itemCount: number;
/** Raw product relationship ids, in item order — depth=0 keeps them as
* plain numbers, not populated objects. Callers resolve these to image
* URLs separately via payload.ts's getProductImagesByIds(), not here —
* this file already deliberately doesn't fetch from lib/payload.ts. */
productIds: number[];
};
export async function getCustomerOrders(token: string, customerId: number): Promise<CustomerOrder[]> {
@@ -411,26 +428,39 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
cache: "no-store",
});
if (!res.ok) return [];
const data: { docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: unknown[] }[] } =
await res.json();
const data: {
docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: { product: number }[] }[];
} = await res.json();
return (data.docs ?? []).map((doc) => ({
orderNumber: doc.orderNumber,
createdAt: doc.createdAt,
total: doc.total,
status: doc.status,
itemCount: doc.items.length,
productIds: doc.items.map((item) => item.product),
}));
}
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;
correctionInvoiceIssuedAt: string | null;
carrier: string | null;
trackingNumber: string | null;
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;
@@ -438,6 +468,16 @@ export type CustomerOrderDetail = CustomerOrder & {
zip: string;
city: string;
country: string;
hasDifferentShippingAddress: boolean;
shippingFirstName: string | null;
shippingLastName: string | null;
shippingDeliveryMethod: "address" | "packstation" | null;
shippingStreet: string | null;
shippingPackstationNumber: string | null;
shippingPostNumber: string | null;
shippingZip: string | null;
shippingCity: string | null;
shippingCountry: string | null;
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
@@ -455,6 +495,7 @@ export type CustomerOrderItem = {
unitPrice: number;
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
returnQuantity: number;
};
+25
View File
@@ -44,6 +44,31 @@ async function fetchDiscountCode(code: string): Promise<PayloadDiscountCode | nu
return data.docs?.[0] ?? null;
}
// Whether it's worth showing the cart's manual "Rabattcode" input field at
// all — no point offering an open text field for a shopper to type into
// when there's nothing in Payload that could ever validate. Existence-only
// check (active: true), not the fuller validFrom/validUntil/minOrderValue
// window validateDiscountCode() does for an actual submitted code — this
// just gates whether the field renders, the real validation still happens
// at apply time regardless.
export async function hasActiveDiscountCode(): Promise<boolean> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/discount-codes?${params}`, {
headers: { "x-discount-service-secret": SERVICE_SECRET },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`hasActiveDiscountCode: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: unknown[] } = await res.json();
return (data.docs?.length ?? 0) > 0;
}
export type DiscountValidation =
| { valid: true; doc: PayloadDiscountCode }
| { valid: false; reason: string };
+15
View File
@@ -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.";
}
+37 -5
View File
@@ -1,4 +1,5 @@
import { formatPrice, formatDate } from "./format";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import type { CompanySettings } from "./payload";
// Pure string-building functions, no server-only or client-only imports —
@@ -67,15 +68,18 @@ function escapeHtml(s: string): string {
// Live-Preview-only fallback (no real order/company-settings fetch there,
// see /email-preview/[type]) — the actual send always passes the real
// seller (company-settings) through buildLegalFooterLines() below.
// seller (company-settings) through buildLegalFooterLines() below. Email
// deliberately doesn't match the real admin@mk360.de send address, so this
// never reads as a hardcoded real value in the preview — it's a visibly
// fake placeholder, same spirit as "Musterstraße 12".
export const DEFAULT_LEGAL_FOOTER_LINES: string[] = [
"einfach produktiv",
"Musterstraße 12",
"12345 Musterstadt",
"E-Mail: admin@mk360.de",
"E-Mail: kontakt@musterfirma.de",
];
// Every business email needs an Anbieterkennzeichnung (§5 TMG-equivalent
// Every business email needs an Anbieterkennzeichnung (§5 DDG-equivalent
// minimum for business correspondence: full name, postal address, contact,
// plus VAT ID once assigned) — not just a friendly "brand · email" line.
// Built from the same company-settings fields the invoice PDFs already
@@ -87,6 +91,11 @@ export const DEFAULT_LEGAL_FOOTER_LINES: string[] = [
// appended here when actually present, so a sole proprietorship's footer
// stays exactly as short as before this field set existed. Keep this in
// sync with the Payload backend's own copy in src/lib/sellerInfo.ts.
// `shareCapital` deliberately does NOT appear here even though it's a
// company-settings field — see CompanySettings.ts's own comment: it's a
// voluntary disclosure, not something safe to auto-inject into every
// outgoing email regardless of whether the business actually wants that
// disclosure made.
export function buildLegalFooterLines(seller: CompanySettings | null): string[] {
if (!seller) return DEFAULT_LEGAL_FOOTER_LINES;
const lines = [
@@ -121,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>
@@ -160,6 +169,7 @@ export type OrderConfirmationItem = {
unitPrice: number;
imageUrl?: string | null;
bundleContents?: string | null;
variantName?: string | null;
taxRatePercent: number;
};
export type OrderConfirmationData = {
@@ -205,7 +215,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
}
</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
</tr>`,
)
@@ -214,6 +224,27 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
const summaryRow = (label: string, value: string, color = TEXT_PRIMARY) =>
`<tr><td style="padding:4px 0;font-size:14px;color:${color};">${label}</td><td style="padding:4px 0;text-align:right;font-size:14px;color:${color};">${value}</td></tr>`;
const vatRow = (label: string, value: string) =>
`<tr><td style="padding-top:4px;font-size:12px;color:${TEXT_MUTED};">${label}</td><td style="padding-top:4px;text-align:right;font-size:12px;color:${TEXT_MUTED};">${value}</td></tr>`;
// Actual VAT amount included in the total, broken down per rate when the
// order spans more than one — mirrors /bestellbestaetigung's own
// VatBreakdown component (not shared code, this file is plain
// inline-styled HTML for email-client compatibility, see the top-of-file
// comment) and the same lib/taxBreakdown.ts math the invoice PDF uses.
const taxBreakdown = computeTaxBreakdown(
order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })),
order.subtotal,
order.discountAmount,
order.shippingCost,
);
const taxRows =
taxBreakdown.length <= 1
? taxBreakdown[0]
? vatRow(`enthält ${taxBreakdown[0].rate}% MwSt.`, formatPrice(taxBreakdown[0].tax))
: ""
: taxBreakdown.map((g) => vatRow(`davon ${g.rate}% MwSt.`, formatPrice(g.tax))).join("");
const body = `
${paragraphs(template.bodyText, "center")}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;background:${BG_MUTED};border-radius:8px;padding:20px;">
@@ -230,6 +261,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
<td style="font-family:${FONT_SERIF};font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">Gesamtsumme</td>
<td style="text-align:right;font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">${formatPrice(order.total)}</td>
</tr>
${taxRows}
</table>
`;
+24 -5
View File
@@ -1,6 +1,17 @@
import { getCompanySettings, type CompanySettings } from "./payload";
import { renderInvoicePdf, type InvoiceOrder } from "./invoicePdf";
import { renderCorrectionInvoicePdf, type CorrectionInvoiceKind, type CorrectionInvoiceOrder } from "./correctionInvoicePdf";
// "/einvoice" subpath, not the package's main entry — @e-invoice-eu/core
// pulls in Node-only dependencies that break a client bundle if reachable
// from a Client Component; see that package's own src/index.ts comment.
// This file is server-only (Next.js Server Components/Route Handlers),
// but importing from the main entry would still poison the bundle for
// any Client Component that transitively imports this same package.
import {
renderInvoiceEInvoice,
renderCorrectionInvoiceEInvoice,
type InvoiceOrder,
type CorrectionInvoiceKind,
type CorrectionInvoiceOrder,
} from "@einfach-produktiv/invoicing/einvoice";
export type { CompanySettings };
@@ -19,18 +30,26 @@ export async function getSellerForInvoice(): Promise<CompanySettings | null> {
// later always matches what they were emailed. `seller` is passed in
// rather than fetched here, so a caller that already has it (see above)
// doesn't fetch it twice.
//
// As of 2026-07-23 (e-invoicing Phase 3), this produces a Factur-X-EN16931
// hybrid PDF/A-3 (visual PDF + embedded EN16931 XML) via
// @einfach-produktiv/invoicing's renderInvoiceEInvoice(), not a plain PDF —
// same visual document, but now machine-readable too. `Buffer.from()`
// wraps the library's `Uint8Array` return value — every downstream
// consumer (nodemailer's attachment `content`, the two on-demand download
// routes) already expects a `Buffer`, unchanged by this switch.
export async function generateInvoicePdf(order: InvoiceOrder, seller: CompanySettings | null): Promise<Buffer | null> {
if (!seller) {
console.error("generateInvoicePdf: no company-settings row found for tenant");
return null;
}
return renderInvoicePdf(order, seller);
return Buffer.from(await renderInvoiceEInvoice(order, seller));
}
// Frontend-side regeneration for the "Stornorechnung/Gutschrift
// herunterladen" download button — the real document was already
// generated once (Payload's Orders.ts afterChange hook) and emailed; this
// reproduces the identical PDF from the order's own stored
// reproduces the identical PDF/A-3+XML from the order's own stored
// correctionInvoiceNumber/correctionInvoiceIssuedAt, same "deterministic
// regeneration, not file storage" approach as the original invoice.
export async function generateCorrectionInvoicePdf(
@@ -42,5 +61,5 @@ export async function generateCorrectionInvoicePdf(
console.error("generateCorrectionInvoicePdf: no company-settings row found for tenant");
return null;
}
return renderCorrectionInvoicePdf(kind, order, seller);
return Buffer.from(await renderCorrectionInvoiceEInvoice(kind, order, seller));
}
-333
View File
@@ -1,333 +0,0 @@
import React from "react";
import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
import { formatDate } from "./format";
// Generated synchronously in the checkout request (see app/lib/orderEmail.ts)
// and attached to the order-confirmation email, plus available on-demand via
// app/api/account/orders/[orderNumber]/invoice/route.ts — same render
// function both times, so a re-download always matches what was emailed.
//
// Built-in Helvetica, not a registered web font — this renders inside the
// checkout request's own fire-and-forget email step; a font-fetch failure
// there is one more way to lose the invoice attachment for no real design
// benefit. Same choice as the Payload-side correction-invoice PDF
// (src/lib/correctionInvoicePdf.tsx in the backend repo, kept visually in
// sync with this file by eye — not shared code, two separate deployments).
const BRAND = "#f6a701";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const BG_MUTED = "#f8f5f1";
const SUCCESS = "#2f8f4e";
const SUCCESS_TINT = "#e7f5eb";
const styles = StyleSheet.create({
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
// A rule, not a filled band — a bold brand-colored line with a thin
// muted second line underneath, rather than a plain 1pt gray divider.
headerBand: {
padding: 32,
paddingBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: BRAND,
},
headerRuleThin: { height: 1, backgroundColor: BORDER, marginHorizontal: 32 },
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
body: { padding: 32, paddingBottom: 90 },
addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
addressBlock: { width: "45%" },
addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" },
addressLine: { fontSize: 10, lineHeight: 1.5 },
metaRow: { flexDirection: "row", gap: 10, marginBottom: 16, flexWrap: "wrap" },
metaBox: { borderWidth: 1, borderColor: BORDER, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12 },
metaLabel: { fontSize: 7, color: TEXT_MUTED, textTransform: "uppercase", marginBottom: 2 },
metaValue: { fontSize: 10, fontFamily: "Helvetica-Bold" },
paidBadge: { backgroundColor: SUCCESS_TINT, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12, justifyContent: "center" },
paidBadgeText: { fontSize: 10, fontFamily: "Helvetica-Bold", color: SUCCESS },
table: { borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: BORDER, marginTop: 8, marginBottom: 16 },
tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 },
tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER },
tableRowAlt: { backgroundColor: BG_MUTED },
colName: { flex: 3 },
colQty: { flex: 1, textAlign: "right" },
colPrice: { flex: 1, textAlign: "right" },
colTotal: { flex: 1, textAlign: "right" },
headerCell: { fontSize: 8, color: TEXT_MUTED, textTransform: "uppercase" },
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
summary: { alignItems: "flex-end", marginBottom: 24 },
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
summaryRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2 },
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
summaryValue: { fontSize: 10 },
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
// `fixed` (below, on the element) + absolute positioning — always pinned
// to the bottom of the page regardless of how much content is above it,
// rather than just following wherever the content flow happens to end.
footer: {
position: "absolute",
bottom: 32,
left: 32,
right: 32,
borderTopWidth: 1,
borderTopColor: BORDER,
paddingTop: 12,
fontSize: 8,
color: TEXT_MUTED,
},
});
function formatPrice(amount: number): string {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
}
export type InvoiceItem = { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents?: string | null };
export type InvoiceOrder = {
orderNumber: string;
invoiceNumber: string;
invoiceIssuedAt: string;
customerFirstName: string;
customerLastName: string;
deliveryMethod: "address" | "packstation";
street?: string | null;
packstationNumber?: string | null;
postNumber?: string | null;
zip: string;
city: string;
country: string;
paymentMethodTitle: string;
items: InvoiceItem[];
subtotal: number;
shippingCost: number;
discountAmount: number;
discountCode: string | null;
total: number;
};
export type InvoiceSeller = {
sellerName: string;
sellerStreet: string;
sellerZip: string;
sellerCity: string;
sellerCountry: string;
sellerEmail: string;
vatId: string;
taxRatePercent: number;
bankDetails?: string | null;
// Pflichtangaben in Geschäftsbriefen for registered legal forms (§37a
// HGB / §35a GmbHG) — optional because a sole proprietorship (the
// default legalForm in company-settings) has neither. See
// buildLegalFooterLines() in emailTemplates.ts for the same fields'
// equivalent treatment in the email footer.
registerCourt?: string | null;
registerNumber?: string | null;
managingDirector?: string | null;
};
// Used only by /company-settings-preview's Live Preview — a fixed sample
// order so the admin sees a realistic-looking invoice while editing
// company-settings fields, without depending on any real order existing.
export const SAMPLE_INVOICE_ORDER: InvoiceOrder = {
orderNumber: "#EP-0001-A7K2",
invoiceNumber: "RE-0001",
invoiceIssuedAt: new Date().toISOString(),
customerFirstName: "Max",
customerLastName: "Mustermann",
deliveryMethod: "address",
street: "Musterweg 5",
zip: "10115",
city: "Berlin",
country: "Deutschland",
paymentMethodTitle: "Kreditkarte",
items: [
{ productName: "ToDo-Karten Set", quantity: 1, unitPrice: 12.9, taxRatePercent: 19, bundleContents: null },
{ productName: "Wochenplaner Überblick", quantity: 2, unitPrice: 14.9, taxRatePercent: 19, bundleContents: null },
],
subtotal: 42.7,
shippingCost: 0,
discountAmount: 5,
discountCode: "WILLKOMMEN10",
total: 37.7,
};
// "Überweisung" (bank transfer) is the only payment method on this shop
// that ISN'T settled immediately — Kreditkarte/PayPal both capture at
// checkout. Rather than hardcode a list of "immediate" method titles
// (fragile the moment a new one is added in Payload's payment-methods
// collection), the only method that's ever NOT immediate is named
// explicitly — everything else defaults to "paid already".
function isPaidImmediately(paymentMethodTitle: string): boolean {
return paymentMethodTitle !== "Überweisung";
}
// Distributes the order-level discount/shipping proportionally across each
// item's gross line total before computing that line's net/tax — so the
// per-rate summary still reconciles exactly to `order.total` even when a
// discount or shipping cost is present alongside items taxed at different
// rates. Falls back to the seller's default rate for any line that
// predates this field (older orders had no per-item snapshot).
function groupByTaxRate(order: InvoiceOrder, defaultRate: number): { rate: number; net: number; tax: number; gross: number }[] {
const groups = new Map<number, number>();
for (const item of order.items) {
const rate = item.taxRatePercent ?? defaultRate;
const lineGross = item.quantity * item.unitPrice;
groups.set(rate, (groups.get(rate) ?? 0) + lineGross);
}
const scale = order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.subtotal : 1;
return Array.from(groups.entries())
.map(([rate, lineGross]) => {
const gross = lineGross * scale;
const net = gross / (1 + rate / 100);
return { rate, net, tax: gross - net, gross };
})
.sort((a, b) => b.rate - a.rate);
}
// Exported (not just used internally by renderInvoicePdf below) so
// /company-settings-preview's client component can mount it directly with
// @react-pdf/renderer's browser-side <PDFViewer> — a live, in-browser
// rendered PDF that re-renders as the admin edits company-settings fields
// via Payload's postMessage-based useLivePreview(), no server round-trip
// needed for each keystroke the way an HTML preview would.
export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: InvoiceSeller }) {
const rateGroups = groupByTaxRate(order, seller.taxRatePercent);
const paid = isPaidImmediately(order.paymentMethodTitle);
const deliveryLine =
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.headerBand}>
<Text style={styles.wordmark}>einfach produktiv.</Text>
<Text style={styles.kindLabel}>RECHNUNG</Text>
</View>
<View style={styles.body}>
<View style={styles.addressRow}>
<View style={styles.addressBlock}>
<Text style={styles.addressLabel}>Von</Text>
<Text style={styles.addressLine}>{seller.sellerName}</Text>
<Text style={styles.addressLine}>{seller.sellerStreet}</Text>
<Text style={styles.addressLine}>
{seller.sellerZip} {seller.sellerCity}
</Text>
<Text style={styles.addressLine}>{seller.sellerCountry}</Text>
</View>
<View style={styles.addressBlock}>
<Text style={styles.addressLabel}>An</Text>
<Text style={styles.addressLine}>
{order.customerFirstName} {order.customerLastName}
</Text>
<Text style={styles.addressLine}>{deliveryLine}</Text>
<Text style={styles.addressLine}>
{order.zip} {order.city}
</Text>
<Text style={styles.addressLine}>{order.country}</Text>
</View>
</View>
<View style={styles.metaRow}>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>Rechnungs-Nr.</Text>
<Text style={styles.metaValue}>{order.invoiceNumber}</Text>
</View>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>Datum</Text>
<Text style={styles.metaValue}>{formatDate(order.invoiceIssuedAt)}</Text>
</View>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>Bestellnummer</Text>
<Text style={styles.metaValue}>{order.orderNumber}</Text>
</View>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>USt-IdNr.</Text>
<Text style={styles.metaValue}>{seller.vatId}</Text>
</View>
{paid && (
<View style={styles.paidBadge}>
<Text style={styles.paidBadgeText}> Bereits beglichen ({order.paymentMethodTitle})</Text>
</View>
)}
</View>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.colName, styles.headerCell]}>Artikel</Text>
<Text style={[styles.colQty, styles.headerCell]}>Menge</Text>
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
<Text style={[styles.colTotal, styles.headerCell]}>Betrag</Text>
</View>
{order.items.map((item, i) => (
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
<View style={styles.colName}>
<Text>{item.productName}</Text>
{item.bundleContents ? <Text style={styles.bundleLine}>{item.bundleContents}</Text> : null}
</View>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colPrice}>{formatPrice(item.unitPrice)}</Text>
<Text style={styles.colTotal}>{formatPrice(item.quantity * item.unitPrice)}</Text>
</View>
))}
</View>
<View style={styles.summary}>
<View style={styles.summaryBox}>
{order.discountAmount > 0 && (
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Rabatt{order.discountCode ? ` (${order.discountCode})` : ""}</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.discountAmount)}</Text>
</View>
)}
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}</Text>
</View>
{rateGroups.map((g) => (
<React.Fragment key={g.rate}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Netto ({g.rate}%)</Text>
<Text style={styles.summaryValue}>{formatPrice(g.net)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
<Text style={styles.summaryValue}>{formatPrice(g.tax)}</Text>
</View>
</React.Fragment>
))}
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>{formatPrice(order.total)}</Text>
</View>
</View>
</View>
</View>
<View style={styles.footer} fixed>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
{seller.registerCourt && seller.registerNumber ? ` · ${seller.registerCourt} · ${seller.registerNumber}` : ""}
{seller.managingDirector ? ` · Geschäftsführung: ${seller.managingDirector}` : ""}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</Page>
</Document>
);
}
export async function renderInvoicePdf(order: InvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
return renderToBuffer(<InvoiceDocument order={order} seller={seller} />);
}
// Exported for unit testing (see app/lib/__tests__/invoicePdf.test.ts) —
// the actual money math and payment-status logic, independent of PDF
// rendering.
export const __testables = { isPaidImmediately, groupByTaxRate };
+20
View File
@@ -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;
};
+30
View File
@@ -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;
@@ -20,6 +24,16 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
zip: string;
city: string;
country: string;
hasDifferentShippingAddress?: boolean;
shippingFirstName?: string | null;
shippingLastName?: string | null;
shippingDeliveryMethod?: "address" | "packstation" | null;
shippingStreet?: string | null;
shippingPackstationNumber?: string | null;
shippingPostNumber?: string | null;
shippingZip?: string | null;
shippingCity?: string | null;
shippingCountry?: string | null;
paymentMethodTitle: string;
};
@@ -58,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,
@@ -65,6 +83,16 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
zip: order.zip,
city: order.city,
country: order.country,
hasDifferentShippingAddress: order.hasDifferentShippingAddress ?? false,
shippingFirstName: order.shippingFirstName,
shippingLastName: order.shippingLastName,
shippingDeliveryMethod: order.shippingDeliveryMethod,
shippingStreet: order.shippingStreet,
shippingPackstationNumber: order.shippingPackstationNumber,
shippingPostNumber: order.shippingPostNumber,
shippingZip: order.shippingZip,
shippingCity: order.shippingCity,
shippingCountry: order.shippingCountry,
paymentMethodTitle: order.paymentMethodTitle,
items: order.items.map((i) => ({
productName: i.productName,
@@ -72,6 +100,8 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
unitPrice: i.unitPrice,
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents ?? null,
variantName: i.variantName ?? null,
imageUrl: i.imageUrl ?? null,
})),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
+71 -2
View File
@@ -26,6 +26,7 @@ export type OrderItemInput = {
unitPrice: number;
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
};
export type CreateOrderInput = {
@@ -33,6 +34,18 @@ export type CreateOrderInput = {
customerFirstName: string;
customerLastName: string;
customerEmail: string;
// Optional B2B snapshot fields — see Orders.ts's own comment on why both
// are independently optional.
companyName?: string;
vatId?: string;
// 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;
@@ -40,6 +53,20 @@ export type CreateOrderInput = {
zip: string;
city: string;
country: string;
// Optional package destination distinct from the billing address above
// — mirrors Orders.ts's own shipping*/hasDifferentShippingAddress
// fields exactly, just camelCased the same way the rest of this input
// type already is.
hasDifferentShippingAddress?: boolean;
shippingFirstName?: string;
shippingLastName?: string;
shippingDeliveryMethod?: "address" | "packstation";
shippingStreet?: string;
shippingPackstationNumber?: string;
shippingPostNumber?: string;
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
newsletterOptIn: boolean;
items: OrderItemInput[];
subtotal: number;
@@ -49,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();
@@ -72,6 +118,11 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
customerFirstName: input.customerFirstName,
customerLastName: input.customerLastName,
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,
@@ -79,6 +130,16 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
zip: input.zip,
city: input.city,
country: input.country,
hasDifferentShippingAddress: input.hasDifferentShippingAddress ?? false,
shippingFirstName: input.shippingFirstName,
shippingLastName: input.shippingLastName,
shippingDeliveryMethod: input.shippingDeliveryMethod,
shippingStreet: input.shippingStreet,
shippingPackstationNumber: input.shippingPackstationNumber,
shippingPostNumber: input.shippingPostNumber,
shippingZip: input.shippingZip,
shippingCity: input.shippingCity,
shippingCountry: input.shippingCountry,
newsletterOptIn: input.newsletterOptIn,
items: input.items.map((i) => ({
product: i.productId,
@@ -87,6 +148,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
unitPrice: i.unitPrice,
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
variantName: i.variantName,
})),
subtotal: input.subtotal,
shippingCost: input.shippingCost,
@@ -95,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 } : {}),
}),
});
@@ -103,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,
+297 -5
View File
@@ -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,
};
}
@@ -170,6 +184,30 @@ export type Product = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
// Plain booleans, not the raw stock/threshold numbers — the public API
// has no reason to leak exact stock counts, callers only ever need
// "can this be bought right now". `outOfStock` on the product itself
// only matters for a product with no variants; a varianted product's
// buyability is entirely per-variant (see each variant's own flag).
outOfStock: boolean;
// Derived, like outOfStock — no raw stock count/threshold leaked, callers
// only ever need "should a low-stock hint show for this right now".
lowStock: boolean;
// Unlike outOfStock/lowStock, this DOES expose the real number — it's
// the cap the add-to-cart controls (AddToCartButton/AddToCartInlineButton,
// CartContent's quantity stepper) need client-side to stop a shopper from
// putting more in the cart than checkout would actually accept, instead
// of only finding out at the very last step (api/checkout/route.ts's own
// stock check, which stays as the authoritative server-side guard). null
// means "no cap" — backorder allowed or inventory not tracked.
maxQty: number | null;
// Per-product override — null means "use the tenant's default rate"
// (CompanySettings.taxRatePercent, fetched separately since it's behind
// an admin-only secret, see getCompanySettings()). Display-only on the
// storefront; the actual rate used for order totals is resolved and
// snapshotted server-side at checkout (api/checkout/route.ts).
taxRatePercent: number | null;
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
};
type PayloadProduct = {
@@ -188,8 +226,46 @@ type PayloadProduct = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
lowStockThreshold: number | null;
taxRatePercent: number | null;
variants:
| {
name: string;
priceOverride: number | null;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
lowStockThreshold: number | null;
}[]
| null;
};
// A product/variant is only actually unbuyable when it opted into
// inventory tracking AND has zero stock AND backorders aren't allowed —
// the same three-condition check lib/inventory.ts's adjustStock() effectively
// mirrors from the other direction (it only ever touches stock when
// trackInventory is on in the first place).
function isOutOfStock(trackInventory: boolean, stock: number | null, allowBackorder: boolean): boolean {
return trackInventory && !allowBackorder && (stock ?? 0) <= 0;
}
// Below the threshold but not already out of stock — out-of-stock gets its
// own distinct "Ausverkauft" badge, a low-stock one on top of that would be
// redundant/contradictory.
function isLowStock(trackInventory: boolean, stock: number | null, threshold: number | null): boolean {
return trackInventory && threshold != null && stock != null && stock > 0 && stock <= threshold;
}
// null (no cap) whenever backorder is allowed or inventory isn't tracked —
// only a hard-tracked, non-backorderable stock count actually limits what a
// shopper can add to their cart.
function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowBackorder: boolean): number | null {
return trackInventory && !allowBackorder ? (stock ?? 0) : null;
}
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
// one place instead of duplicating the same field mapping, which is
// exactly the kind of drift this session's Shipping Settings work was
@@ -211,6 +287,17 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
spotlightText: product.spotlightText || null,
spotlightImage:
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
taxRatePercent: product.taxRatePercent ?? null,
variants: (product.variants ?? []).map((v) => ({
name: v.name,
priceOverride: v.priceOverride,
outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder),
lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold),
maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder),
})),
};
}
@@ -240,6 +327,28 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
return products.find((p) => p.id === slug) ?? null;
}
// For account order pages — Orders.items only snapshots a numeric
// `product` relationship id (see CustomerOrderItem in lib/customerAuth.ts),
// not an image URL, unlike the checkout/email/invoice paths that resolve
// the image once at order-creation/send time. depth=1 + a single `in`
// query is a plain product-id → image-url lookup, deliberately separate
// from getProducts()'s slug-keyed catalog (an order can reference a
// product that's since been deactivated/deleted, and slugs aren't even
// the key an order item stores).
export async function getProductImagesByIds(ids: number[]): Promise<Map<number, string>> {
const uniqueIds = [...new Set(ids)];
const map = new Map<number, string>();
if (uniqueIds.length === 0) return map;
const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "1", limit: String(uniqueIds.length) });
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
if (!res.ok) return map;
const data: { docs?: { id: number; image: { url: string } | number | null }[] } = await res.json();
for (const doc of data.docs ?? []) {
if (typeof doc.image === "object" && doc.image) map.set(doc.id, doc.image.url);
}
return map;
}
// Derived from getProducts() (same 60s-ISR-cached fetch every other
// discovery surface already uses) instead of its own separate Payload
// query — also what lets the auto-spotlight rule below just be a plain
@@ -376,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 };
@@ -428,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[]> {
@@ -462,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;
@@ -645,13 +833,19 @@ export async function getEmailTemplate(
export type CompanySettings = {
sellerName: string;
// Drives whether registerCourt/registerNumber/managingDirector are
// populated — mirrors Payload's CompanySettings.ts collection exactly
// (same option values), see buildLegalFooterLines() in emailTemplates.ts.
// Drives whether registerCourt/registerNumber/managingDirector/
// shareCapital are populated — mirrors Payload's CompanySettings.ts
// collection exactly (same option values), see buildLegalFooterLines()
// in emailTemplates.ts.
legalForm: "sole-proprietorship" | "e-k" | "gbr" | "ohg" | "kg" | "gmbh" | "ug" | "ag";
registerCourt: string | null;
registerNumber: string | null;
managingDirector: string | null;
// Stammkapital (GmbH/UG) / Grundkapital (AG) — optional, NOT a
// Pflichtangabe (only shown for legal forms that have this concept at
// all; see CompanySettings.ts's SHARE_CAPITAL_APPLICABLE_FORMS and its
// own comment on why this is voluntary, not required, disclosure).
shareCapital: number | null;
sellerStreet: string;
sellerZip: string;
sellerCity: string;
@@ -659,7 +853,16 @@ export type CompanySettings = {
sellerEmail: string;
vatId: string;
taxRatePercent: number;
bankDetails: string | null;
// 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;
};
// Server-only in practice (only ever called from app/lib/invoiceData.ts),
@@ -681,3 +884,92 @@ export async function getCompanySettings(): Promise<CompanySettings | null> {
const data: { docs?: CompanySettings[] } = await res.json();
return data.docs?.[0] ?? null;
}
// A separate, ISR-cached fetch (unlike getCompanySettings()'s deliberate
// cache: "no-store", where invoice generation needs always-fresh bank
// details/legal footer text) — the storefront's "inkl. X% MwSt." display
// rate only needs the same 60s freshness every other public catalog fetch
// here already has, and only ever needs the one number, not the seller's
// bank details/register info.
export async function getDefaultTaxRatePercent(): Promise<number> {
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(`getDefaultTaxRatePercent: Payload returned ${res.status} ${res.statusText}`);
return 19;
}
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,
};
}
+31
View File
@@ -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),
});
}
}
+15
View File
@@ -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;
+20
View File
@@ -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 };
+85
View File
@@ -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;
}
}
+32
View File
@@ -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>;
}
+13
View File
@@ -7,6 +7,15 @@
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export type RawProductVariant = {
name: string;
sku: string | null;
priceOverride: number | null;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
};
export type RawProduct = {
id: number;
slug: string;
@@ -16,6 +25,10 @@ export type RawProduct = {
image: { url: string } | number | null;
taxRatePercent: number | null;
bundleItems: { product: { id: number; name: string } | number; quantity: number }[] | null;
variants: RawProductVariant[] | null;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
};
export async function fetchProductsBySlug(): Promise<Map<string, RawProduct>> {
+26
View File
@@ -0,0 +1,26 @@
// Mirrors the Payload backend's own src/lib/tracking.ts — same carrier
// set/labels/URL patterns, kept in sync by hand (two separate
// deployments, no shared package). Used to render a clickable tracking
// link on /konto/bestellungen/[orderNumber]; the backend's copy builds
// the same link for the order-shipped email.
export const CARRIER_LABELS: Record<string, string> = {
dhl: "DHL",
dpd: "DPD",
hermes: "Hermes",
ups: "UPS",
gls: "GLS",
other: "Sonstiger Versanddienstleister",
};
const CARRIER_TRACKING_URL: Record<string, (trackingNumber: string) => string> = {
dhl: (n) => `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(n)}`,
dpd: (n) => `https://tracking.dpd.de/status/de_DE/parcel/${encodeURIComponent(n)}`,
hermes: (n) => `https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation/#${encodeURIComponent(n)}`,
ups: (n) => `https://www.ups.com/track?loc=de_DE&tracknum=${encodeURIComponent(n)}`,
gls: (n) => `https://www.gls-pakete.de/sendungsverfolgung?trackingNumber=${encodeURIComponent(n)}`,
};
export function buildTrackingUrl(carrier: string | null | undefined, trackingNumber: string | null | undefined): string | null {
if (!carrier || !trackingNumber) return null;
return CARRIER_TRACKING_URL[carrier]?.(trackingNumber) ?? null;
}
+59
View File
@@ -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 };
}
+54
View File
@@ -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";
}
+15
View File
@@ -0,0 +1,15 @@
// Mirrors the backend's own USt-IdNr. validation exactly (Orders.ts/
// Customers.ts/CompanySettings.ts in the Payload repo) — kept as a plain
// client+server-safe helper here since this repo's frontend needs the same
// check twice (checkout's instant client-side pattern + api/checkout's own
// server-side re-validation, same "never trust the client" reasoning as
// every other checkout field).
const VAT_ID_PATTERN = /^[A-Z]{2}[A-Z0-9]{2,12}$/;
export function normalizeVatId(value: string): string {
return value.toUpperCase().trim();
}
export function isValidVatId(value: string): boolean {
return VAT_ID_PATTERN.test(value);
}
+58
View File
@@ -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." };
}
}
+66
View File
@@ -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 />
</>
);
}
+10 -12
View File
@@ -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>
+12 -1
View File
@@ -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>
+41 -8
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { getProducts, getShippingSettings } 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";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
@@ -12,7 +13,12 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
// gives faster first paint and no loading flash.
export async function ProductGrid() {
const [allProducts, shipping] = await Promise.all([getProducts(), getShippingSettings()]);
const [allProducts, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProducts(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const products = allProducts.filter((p) => p.active);
if (products.length === 0) {
@@ -28,6 +34,17 @@ export async function ProductGrid() {
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
{products.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// A varianted product only reads as "ausverkauft" overall once
// every one of its variants is — a single sold-out variant just
// shows as such in the picker itself (AddToCartInlineButton),
// not as a blanket badge that would misleadingly suggest the
// whole product is unavailable while other variants still are.
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
// Mirrors fullyOutOfStock's "any vs. every" split — a varianted
// product reads as low-stock as soon as one variant is, since a
// shopper landing on the grid hasn't picked a variant yet.
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<RevealItem
key={product.id}
@@ -39,12 +56,18 @@ export async function ProductGrid() {
alt={product.name}
fill
sizes="(min-width: 768px) 25vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
@@ -60,12 +83,22 @@ 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. 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
</p>
</div>
{/* Always rendered, text conditional — not a conditional
block — so this line's height (min-h as a cross-browser
safety net for the empty case) is identical whether or
not the product is low-stock. See AddToCartButton.tsx's
own comment: an earlier text-based low-stock hint here
broke equal card heights across the grid, which is why
it moved to the image-overlay pill in the first place. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
{product.href && (
<Link
href={product.href}
@@ -82,7 +115,7 @@ export async function ProductGrid() {
equal-height lesson). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
+1
View File
@@ -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"],
},
};
+13 -2
View File
@@ -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>
))}

Some files were not shown because too many files have changed in this diff Show More