Compare commits

..

104 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
Marco 07c70c86f5 Add legalForm-driven Pflichtangaben (register court/number, managing director)
Mirrors the Payload backend's new company-settings.legalForm field: when
present, registerCourt/registerNumber/managingDirector now appear in the
email footer (buildLegalFooterLines) and both invoice PDF footers,
matching §37a HGB / §35a GmbHG requirements for registered legal forms.
A sole proprietorship (the default) renders identically to before —
these fields are only appended when actually set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 15:09:43 +00:00
Marco d7e7928dfc Set Reply-To to sellerEmail, make From display name dynamic, fix stale footer docs
Customer replies to order-confirmation and resend-verification mail now
route to the seller's real address via Reply-To, and the From display
name reflects sellerName — but the From address itself stays
admin@mk360.de since sellerEmail's domain isn't confirmed SPF-authorized
on the Hostinger account yet (see the "SMTP From address pending SPF"
memory note for the follow-up). Also cleans up README references left
over from the previous footer rewrite (stale "company line" wording, a
dangling cross-reference to a renamed section).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:38:01 +00:00
Marco e61a62e579 Give every email a full legal footer (Anbieterkennzeichnung), not just a company line
Order confirmation, resend-verification, and the internal critical-alert
mail now render name, street, ZIP/city, email, and VAT ID from
company-settings instead of a bare "<sellerName> · <sellerEmail>" line,
so every email this app sends meets business-correspondence footer
requirements rather than just the customer-facing ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:15:43 +00:00
Marco a801d41d79 Document partial returns, the reconstructed-items PATCH, and the new test suite 2026-07-22 11:42:44 +00:00
Marco 51fee198f4 Add a Vitest unit test suite (cart totals, invoice tax grouping, bundle contents)
No test infrastructure existed in this repo yet. Covers the pure logic
most likely to silently produce wrong numbers on a live order: discount/
shipping math, per-rate invoice grouping, and bundle-contents string
building. Extracted describeBundleContents() out of the checkout route
into its own module so it's importable from a test (route.ts files only
allow HTTP-method exports).
2026-07-22 11:40:31 +00:00
Marco 91f6fef6ea Support partial returns — per-item quantity, item-only Gutschrift (no shipping refund, no discount reproration)
Customers can now select which items and how many units to return
instead of only the whole order. The Gutschrift reflects only the
returned quantities, excludes shipping (already delivered), and leaves
the original discount untouched — confirmed policy, not an engineering
default. Stornorechnung (pre-shipping cancellation) is unaffected and
stays a full reversal including shipping.
2026-07-22 11:30:46 +00:00
Marco d249614027 Gate the company-settings preview page behind Draft Mode
Unlike the email-templates preview (marketing copy), this page shows
real bank/address details once filled in — it must not render for an
unauthenticated visitor who finds the URL.
2026-07-22 11:14:26 +00:00
Marco e50d43ea44 Rename invoice-settings to company-settings, add its own Live Preview, and refine invoice PDF layout
Company data now has its own Payload admin group and a live in-browser
PDF preview (react-pdf's PDFViewer) instead of just a plain settings
form. Invoice header is a brand-colored rule instead of a filled band,
and the footer is now pinned to the page bottom instead of following
content flow.
2026-07-22 11:11:58 +00:00
Marco f144ad25f2 Document the invoice redesign, correction-invoice downloads, bundles, per-product tax rates, and return reasons
Keeps the README in sync with this round's shipped work.
2026-07-22 10:35:54 +00:00
Marco 179b59d73d Redesign invoice PDFs, add correction-invoice downloads, return reasons, and per-product tax/bundle support
Invoice + Stornorechnung/Gutschrift PDFs get a modern header-band layout,
a "bereits beglichen" badge for immediately-paid orders, labelled bank
details, and a per-tax-rate summary breakdown. Correction invoices can
now be re-downloaded from the account (regenerated deterministically,
not stored as files, same approach as the original invoice). Return
requests capture a reason. Products can define bundles (bundleItems) and
a per-product VAT rate override, both snapshotted onto order items.
2026-07-22 10:31:22 +00:00
Marco 04cc69f98b Extend the collections table to cover the full backend feature set
The overview table only listed content-rendering collections, leaving
orders/customers/number-ranges/email-templates/invoice-settings
documented only in prose further down — added them as rows plus a
quick "what's admin-configurable without a deploy" summary.
2026-07-22 10:01:26 +00:00
Marco 6102fef6d1 Document invoice PDF generation and status-change emails in the README
Keeps the README consistent with the invoice/correction-invoice/
status-email work just shipped — new Invoice PDFs section, extended
Email templates and Order cancellation sections.
2026-07-22 09:59:27 +00:00
Marco 5232b14cdf Generate invoice PDFs attached to order confirmation, and send emails on order status changes
Invoice PDFs (§14 UStG line items, tenant-configurable VAT rate) are now
generated at checkout and attached to the confirmation email, plus
available on demand from the order-detail page. Payload-side, orders now
also email the customer on shipped/cancelled/return_requested/returned,
with Stornorechnung/Gutschrift correction PDFs attached for the latter two
so the original invoice's immutable number stays honest.
2026-07-22 09:49:56 +00:00
Marco fa02d95dff Make checkout's login prompt reactive instead of persistent, and give the confirmation email real style and voice
The always-visible "Schon Kundin?" toggle was gendered and shown to every
logged-out visitor regardless of relevance. Card 1's email field now
checks on blur (/api/account/check-email) whether that address already
has an account, and only then swaps in a gender-neutral login form,
pre-filled — the collision check in handleSubmit stays as a fallback.

The order-confirmation and password-reset emails also got a real visual
pass: same warm background/brand color/circular success-icon treatment as
the on-screen /bestellbestaetigung page, serif heading, thin brand
divider, table-based layout for email-client compatibility. Copy is
on-brand and a little playful now instead of generic transactional
boilerplate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:38:41 +00:00
Marco ec75a480bd Detect checkout email collisions and show login state in the navbar
Registering with an email that already has an account previously just
failed with a generic error and no clear next step. registerCustomer()
now flags emailExists specifically, and checkout switches straight to the
login toggle (email pre-filled, scrolled into view) instead. The account
icon also gets a small underline while logged in, matching the nav links'
active-state styling — it was otherwise the only nav element that gave no
visual signal of session state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:26:00 +00:00
Marco f0df359db4 Add password reset, order confirmation email with editable templates, and fix missing account entry points
Password reset uses Payload's built-in forgot/reset-password flow,
customized to link to this app instead of the Payload admin. Order
confirmation email and the password-reset email's wording both come from
a new Payload email-templates collection, editable without a deploy and
previewable via Live Preview at /email-preview/[type] (same mechanism as
Posts/LegalPages/Testimonials, sample data instead of a real document).

Also: order numbers get a random suffix (prevents guessing, motivated by
a considered-and-deferred guest order-lookup feature); the discount code
field only shows in the cart when a code is actually active (codes now
apply via a ?code= link instead of manual entry); and three navigation
gaps found while testing — no reachable login link with an empty cart, no
logout link anywhere, no way back from profile to order history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:14:08 +00:00
Marco adca6e0f64 Fix verify-email redirect pointing at the internal container address
request.url reflects the container's internal 0.0.0.0:3000 behind
Caddy's reverse proxy, not the public domain — sent real browsers to an
unreachable address. Caught live during post-deploy verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 07:31:02 +00:00
Marco df05ea5358 Add rate limiting, sliding sessions, email verification, GDPR self-service, order cancellation/returns, and critical-error alerting
Complements Payload's per-account login lockout with per-IP rate limiting
on auth routes; proxy.ts silently refreshes an active customer's session
via Payload's built-in refresh-token endpoint instead of a long-lived
token. Registration now sends a non-blocking email-verification link
(doesn't gate login, since checkout registers and immediately logs in
mid-purchase). /konto/profil gets GDPR export/delete; order detail pages
get self-service cancel/return-request, backed by a Payload hook that
closes a real gap (a customer's JWT could previously PATCH any field of
their own order, not just status). Checkout failures now email an alert
independent of Payload's own health, since Kuma's uptime checks can't see
an order silently failing to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 07:28:01 +00:00
Marco 7f37f111e8 Add real order persistence, customer accounts, and cart sync
Checkout now persists orders server-side (Payload orders collection,
re-priced from live product data, discount codes redeemed exactly once)
instead of writing a client-only sessionStorage snapshot. Buying requires
an account (registration inline in checkout, no separate step) — accounts
get order history with delivery status, profile/address editing, password
change, and a cart that syncs across devices while logged in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 06:45:42 +00:00
Marco 516945fc8c Document discount codes, active-column, and RelatedProducts changes
New Discount codes section (validate/redeem routes, discountServer.ts,
cartTotals.ts, localStorage persistence), DISCOUNT_SERVICE_SECRET env var,
discount-codes collection row (first non-public-read collection), and a
note on RelatedProducts no longer padding its grid with already-in-cart
products.
2026-07-21 21:37:37 +00:00
Marco 06abf1a6ae Add discount code feature (server-validated) and RelatedProducts polish
Discount codes:
- New shared lib/cartTotals.ts (computeSubtotal/computeCartTotals) factored
  out of the previously-triplicated subtotal/totalSavings/total math in
  CartContent/CheckoutContent/BestellbestaetigungContent, extended to also
  fold in a discount amount (percent or fixed, clamped so total can't go
  negative).
- lib/discount.ts mirrors lib/cart.ts's exact localStorage pattern so an
  applied code survives the /cart -> /checkout transition without a second
  input field — Checkout only displays it.
- New /api/discount/validate (read-only check) and /api/discount/redeem
  (re-validates + increments the redemption counter, called once from
  checkout's handlePurchase right before the OrderSnapshot is written).
  Both talk to Payload's new discount-codes collection through
  lib/discountServer.ts, a server-only module kept separate from
  lib/payload.ts on purpose (that file is also imported by "use client"
  components; the RSC-boundary break hit earlier this session was exactly
  this mistake with next/headers).
- OrderSnapshot gains discountCode/discountAmount so /bestellbestaetigung
  displays what was actually applied instead of losing it on recompute.

RelatedProducts: no longer falls back to re-suggesting a product already
in the cart just to pad the grid out to 3 cards — shows only the
genuinely available remainder (down to 1 card), centered in the 12-column
grid instead of left-aligned.
2026-07-21 21:16:17 +00:00
Marco 028a1fc4ec Add active-product-count-driven automation
- Products gain `active`/`spotlight*`/`updatedAt` on the base Product type
  (folded in from the now-removed separate SpotlightProduct type) so shop
  grid, spotlight, and related-products can each filter `.active` from the
  same already-fetched list — cart/checkout/order-confirmation/product-
  detail pages keep resolving any product regardless of active status.
- getSpotlightProduct() now derives from getProducts() instead of its own
  Payload query: with exactly 1 active product, that one IS the spotlight
  (overriding any `spotlight` flag elsewhere); otherwise same
  most-recently-updated tie-break as before, just computed client-side.
- ProductGrid drops the already-dead SHOP_GRID_EXCLUDE_IDS list in favor of
  the same `active` filter, with an empty-state message if 0 active.
- RelatedProducts gates on >=2 active products regardless of cart contents
  or how many display slots would otherwise resolve.
- Navbar's "Shop" link becomes an anchor to the homepage spotlight section
  (id="spotlight") instead of a real /shop navigation whenever exactly 1
  product is active — passed down from the now-async root layout, which
  fetches the catalog once for this decision.
2026-07-21 20:39:22 +00:00
Marco 37b710c933 Document testimonials collection and Live Preview in the README
Adds the testimonials row to the collections table, PAYLOAD_PREVIEW_SECRET/
NEXT_PUBLIC_PAYLOAD_URL to the env vars section, and a new Live Preview
section covering /api/preview, the 3 Live-Preview-aware components, and
the next/headers RSC-boundary gotcha hit while building it.
2026-07-21 19:34:01 +00:00
Marco 4f2f137b27 Fix RSC build break: keep next/headers out of lib/payload.ts
payload.ts's mapping functions/types are also imported by "use client"
components (LiveTestimonialsGrid, LivePostContent) — importing
next/headers anywhere in that module made it unbundlable for the client,
breaking the production build. draftMode() is now only ever called in the
Server Component pages themselves; they pass the resulting boolean into
getPostBySlug/getLegalPage/getTestimonials as a plain `draft` option.
2026-07-21 19:21:10 +00:00
Marco 26ae4a15f4 Wire testimonials CMS collection and Payload Live Preview
Testimonials on /todo-cards, /newsletter, /challenge now come from the new
Payload testimonials collection via a shared TestimonialsGrid component,
instead of 3 separately hardcoded arrays.

Adds Next.js Draft Mode (/api/preview) plus Live-Preview-aware client
wrappers (LiveRichText, LiveTestimonialsGrid, LivePostContent) for posts,
legal pages, and testimonials — mounted only while Draft Mode is enabled,
so ordinary visitors keep getting the plain static components.
2026-07-21 19:02:20 +00:00
Marco 2d6cff9f40 feat(blog): optional quote label + swappable related-product card per post
Both were fully hardcoded before: every blockquote showed a static
"Merke dir:" label, and every post's "Passend dazu" card always linked
the same flagship product. Now driven by two new Posts fields —
quoteLabel (empty hides the label/icon/underline, blockquote still
renders) and relatedProduct (empty hides the card entirely) — mirroring
Products.spotlight but per-post instead of a single site-wide flag.

README's collection table updated to match today's Payload changes
(shipping-settings, the new Posts fields, trust-badges' placeholder
tokens, admin sidebar grouping) — also fixed a stale claim that `media`
isn't tenant-scoped; it already was.
2026-07-21 13:10:06 +00:00
Marco 225a8567a9 feat(shipping): move delivery-time settings to Payload, polish product/cart CTAs
The delivery-time range (handling + transit days) was a hardcoded
HANDLING_DAYS/TRANSIT_DAYS_DE pair in lib/shipping.ts — changing it
needed a code deploy. Now sourced from Payload's new Shipping Settings
collection via getShippingSettings(), threaded down as a prop to the
few Client Components (Cart/Checkout/VersandModal) that can't fetch it
themselves, with the old code constants removed.

Also: the delivery-time note is now shown on every purchase CTA (shop
grid, home spotlight, ToDo-Karten hero + pricing panel), not just one
of them — required next to each buy button per Art. 246a §1 Abs.1
Nr.8 EGBGB, not just somewhere reachable via a link. Checkout's
sidebar was missing the "ab 39€ kostenlos" note Cart already had;
that's fixed too, and both now show the delivery-time range on its own
line instead of crammed onto the shipping-cost line.

Related smaller fixes bundled in since they touch the same files:
price/delivery-time spacing tightened into its own group, the
redundant "Sichere Zahlung" note under Cart's checkout button (already
shown via the trustBadges list right below) replaced with "Sichere
SSL-Verschlüsselung" to match Checkout, and ToDo-Karten's pricing panel
no longer shows a premature payment-security note at the add-to-cart
step.
2026-07-21 12:44:35 +00:00
Marco a6edd7ff61 fix(cart): make the "Hinzugefügt" success state subtler
Was a solid bright-green fill with white text; now the same pale
success-subtle fill + success-colored text/border AddToCartInlineButton
already uses elsewhere (RelatedProducts, shop cards) — consistent, less
loud.
2026-07-21 12:44:16 +00:00
Marco 9c9f0b02c0 perf(images): convert remaining <img> tags to next/image project-wide
Clears every remaining @next/next/no-img-element warning — automatic
responsive srcset, lazy-loading, and format optimization instead of
always loading the original file at full size. Fixed-size icons got
explicit width/height; dynamic-aspect photos got fill inside a
relative wrapper.
2026-07-21 12:44:03 +00:00
Marco af00fd091f fix(shop): match "Mehr erfahren" link style to Werkzeug-card CTAs
Was always brand-colored with an underline on hover; now black by
default and brand-colored on hover, consistent with the Werkzeuge
cards' CTA links elsewhere on the site.
2026-07-21 11:15:54 +00:00
Marco a560ec9434 fix(cart): only hide free-shipping banner after it's actually been seen
The hide-timer used to fire on a plain phase==="success" timeout,
regardless of whether the banner was actually on screen — if the
threshold was reached while scrolled away, it could hide itself before
the user ever saw it.

Gates the timer on continuous IntersectionObserver visibility
(useInView, no `once`) instead of a lifetime "ever visible" flag — the
latter flips true as soon as the page loads (the banner sits at the
top), which defeats the purpose entirely. The timer now only runs
while the banner is actually in view, and restarts if the user scrolls
away and back before it completes.
2026-07-21 11:15:51 +00:00
Marco cc9da6ac6d feat(newsletter, checkout): add consent links, unify trust-note styling, optimize images
Every email-signup form (Home/newsletter Newsletter section, /newsletter
hero, /challenge, the Newsletter overlay) now links "Datenschutzerklärung"
to /datenschutz next to its consent checkbox, opened in a new tab so a
partially filled form isn't lost. Checkout gets an equivalent AGB +
Datenschutzerklärung note under the order button, and the Versand
modal's Widerrufsbelehrung link also opens in a new tab.

Unified the "Keine Werbung. Jederzeit abbestellbar." trust note (icon +
#888 copy) across all newsletter forms, matching /challenge's existing
style instead of each form having its own wording/color.

Also converted the remaining raw <img> tags in these files to next/image
per the no-img-element lint rule (bandwidth/LCP).
2026-07-21 11:15:44 +00:00
Marco a72aea1070 fix(navbar): reset scroll to top when navigating home via logo
Since the Navbar lives in the root layout and never unmounts across
navigations, Next.js's default Link scroll behavior left the previous
page's scroll offset in place instead of resetting to top — landing
users wherever that old offset happened to fall in Home's layout
(often around the Werkzeuge section) instead of at the top.
2026-07-21 11:15:37 +00:00
155 changed files with 18140 additions and 1649 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
+1593 -25
View File
File diff suppressed because it is too large Load Diff
+15 -4
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { TrustRow } from "../components/TrustRow";
import { RichText, extractHeadings } from "../components/RichText";
import { SectionTOC } from "../components/SectionTOC";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
export const metadata: Metadata = {
@@ -14,7 +17,8 @@ export const metadata: Metadata = {
};
export default async function AgbPage() {
const page = await getLegalPage("agb");
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("agb", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
return (
@@ -35,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} />
@@ -42,7 +53,7 @@ export default async function AgbPage() {
{/* Static, not part of the CMS content — same reasoning as
the Impressum/Datenschutz pages' own callout cards. */}
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
<Image alt="" src="/icon-trust-leaf.png" width={28} height={28} className="size-7 object-contain" />
<p
className="font-semibold text-body text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
@@ -59,7 +70,7 @@ export default async function AgbPage() {
<div className="w-full lg:flex-1 min-w-0">
{page ? (
<RichText content={page.content} />
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import type { CartItem } from "../../../lib/cart";
import { getServerCart, getSessionCustomer, saveServerCart } from "../../../lib/customerAuth";
import { fetchProductsBySlug } from "../../../lib/productsServer";
export async function GET() {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ cart: [] }, { status: 401 });
const cart = await getServerCart(session.token);
return NextResponse.json({ cart });
}
// Called by CartSync.tsx (debounced) on every local cart change while a
// session is active — keeps the server-side mirror current so the cart
// follows the customer across devices. Silently no-ops when logged out;
// the caller doesn't care either way.
export async function POST(request: Request) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false }, { status: 401 });
const body = await request.json().catch(() => null);
const cart: CartItem[] = Array.isArray(body?.cart) ? body.cart : [];
const productsBySlug = await fetchProductsBySlug();
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 });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { checkEmailExists } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
// Called on blur from the checkout email field (CheckoutContent.tsx) —
// lets the form switch to login mode as soon as an existing account is
// detected, instead of only after a failed registration attempt. Same
// exposure as the registration-collision case already had (both reveal
// "this email has an account"), so rate-limited the same way rather than
// treated as a new problem.
export async function POST(request: Request) {
if (!checkRateLimit(`check-email:${getClientIp(request)}`, { limit: 20, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ exists: false }, { status: 429 });
}
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email : "";
if (!email) return NextResponse.json({ exists: false });
const exists = await checkEmailExists(email);
return NextResponse.json({ exists });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { deleteCustomerAccount, getSessionCustomer, loginCustomer, clearSessionCookie } from "../../../lib/customerAuth";
// GDPR self-service deletion. Password re-verified here (not just trusting
// the active session) before anything is deleted — same reasoning as
// changeCustomerPassword. See customerAuth.ts's deleteCustomerAccount for
// what actually survives (past orders, anonymized-by-omission — their own
// snapshot fields aren't touched, only the account/login disappears).
export async function POST(request: Request) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const password = typeof body?.password === "string" ? body.password : "";
if (!password) return NextResponse.json({ ok: false, reason: "Bitte dein Passwort zur Bestätigung eingeben." }, { status: 400 });
const verify = await loginCustomer({ email: session.customer.email, password });
if (!verify.ok) return NextResponse.json({ ok: false, reason: "Passwort ist falsch." }, { status: 400 });
const deleted = await deleteCustomerAccount(verify.token, session.customer.id);
if (!deleted) return NextResponse.json({ ok: false, reason: "Konto konnte nicht gelöscht werden." }, { status: 500 });
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerProfile, getCustomerOrders, getCustomerOrderDetail } from "../../../lib/customerAuth";
// GDPR data portability (Art. 20) — a full, structured, machine-readable
// export of everything tied to the account: profile + every order's full
// detail (not just the summary list, so this is a genuinely complete
// export, not a teaser).
export async function GET() {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const profile = await getCustomerProfile(session.token);
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
const orders = await Promise.all(
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
);
const payload = {
exportedAt: new Date().toISOString(),
profile,
orders: orders.filter(Boolean),
};
return new NextResponse(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": 'attachment; filename="meine-daten.json"',
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { requestPasswordReset } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
// Always responds the same way regardless of whether the email exists —
// see requestPasswordReset()'s own comment. Rate-limited a bit tighter
// than login/register (5/15min) since there's no secondary defense here
// the way Payload's own per-account lockout backs up the login route.
export async function POST(request: Request) {
if (!checkRateLimit(`forgot-password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: true });
}
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email : "";
if (email) await requestPasswordReset(email);
return NextResponse.json({ ok: true });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { loginCustomer, setSessionCookie } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
// Complements Payload's own per-account lockout (5 attempts / 10min,
// see Customers.ts in the Payload repo) with a per-IP layer — that one
// alone doesn't stop someone spraying single attempts across many
// different email addresses from the same IP.
if (!checkRateLimit(`login:${getClientIp(request)}`, { limit: 10, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email : "";
const password = typeof body?.password === "string" ? body.password : "";
if (!email || !password) {
return NextResponse.json({ ok: false, reason: "Bitte E-Mail und Passwort angeben." }, { status: 400 });
}
const result = await loginCustomer({ email, password });
if (!result.ok) return NextResponse.json(result, { status: 401 });
await setSessionCookie(result.token);
return NextResponse.json({ ok: true, customer: result.customer });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { clearSessionCookie } from "../../../lib/customerAuth";
export async function POST() {
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { getSessionCustomer } from "../../../lib/customerAuth";
export async function GET() {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ customer: null }, { status: 401 });
return NextResponse.json({ customer: session.customer });
}
@@ -0,0 +1,66 @@
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
// by Payload's Orders.ts afterChange hook and emailed at the moment of the
// status change — this regenerates the identical PDF from the order's own
// stored correctionInvoiceNumber/correctionInvoiceIssuedAt (immutable once
// set) rather than storing the file anywhere, same approach as the
// original invoice's own download route.
export async function GET(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const { orderNumber } = await params;
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
if (!order.correctionInvoiceNumber || !order.correctionInvoiceIssuedAt || !order.invoiceNumber || !order.invoiceIssuedAt) {
return NextResponse.json({ ok: false, reason: "Für diese Bestellung liegt keine Korrekturrechnung vor." }, { status: 404 });
}
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,
{
orderNumber: order.orderNumber,
invoiceNumber: order.invoiceNumber,
invoiceIssuedAt: order.invoiceIssuedAt,
correctionInvoiceNumber: order.correctionInvoiceNumber,
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,
postNumber: order.postNumber,
zip: order.zip,
city: order.city,
country: order.country,
items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
discountAmount: order.discountAmount,
total: order.total,
},
seller,
);
if (!pdf) return NextResponse.json({ ok: false, reason: "Korrekturrechnung konnte nicht erzeugt werden." }, { status: 500 });
const filename = kind === "storno" ? `Stornorechnung-${order.correctionInvoiceNumber}.pdf` : `Gutschrift-${order.correctionInvoiceNumber}.pdf`;
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
@@ -0,0 +1,74 @@
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
// the checkout-time attachment (app/lib/orderEmail.ts), so a re-download
// always matches what was emailed; invoiceNumber itself never changes
// (assigned once, server-side, at order creation — see Orders.ts).
export async function GET(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const { orderNumber } = await params;
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
if (!order.invoiceNumber || !order.invoiceIssuedAt) {
return NextResponse.json({ ok: false, reason: "Für diese Bestellung liegt noch keine Rechnung vor." }, { status: 404 });
}
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,
invoiceNumber: order.invoiceNumber,
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,
postNumber: order.postNumber,
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.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
discountAmount: order.discountAmount,
discountCode: order.discountCode,
total: order.total,
},
seller,
);
if (!pdf) return NextResponse.json({ ok: false, reason: "Rechnung konnte nicht erzeugt werden." }, { status: 500 });
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="Rechnung-${order.invoiceNumber}.pdf"`,
},
});
}
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import {
getSessionCustomer,
getCustomerOrderDetail,
requestOrderStatusChange,
customerOrderAction,
type CustomerOrderItem,
} from "../../../../lib/customerAuth";
// The real security boundary is Orders.ts's beforeChange hook in Payload
// (only `status`/`returnReason`/items' `returnQuantity` can change, only
// via an allowed transition) — the checks here are just for a friendlier
// error message than a bare 403 when the request is malformed or stale
// (e.g. two tabs open, order shipped in the meantime, a quantity that no
// longer fits).
export async function PATCH(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const { orderNumber } = await params;
const body = await request.json().catch(() => null);
const action = body?.action;
if (action !== "cancel" && action !== "request-return") {
return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 });
}
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
if (customerOrderAction(order.status) !== action) {
return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 });
}
if (action === "cancel") {
const result = await requestOrderStatusChange(session.token, order.id, "cancel");
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
// request-return: partial returns supported — the client sends
// { product, returnQuantity } per line it wants to return (0 or
// omitted for lines being kept). Reconstruct the order's FULL items
// array here (see requestOrderStatusChange's own comment on why a
// sparse patch doesn't work), validating each requested quantity
// against what was actually ordered.
const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : "";
if (!returnReason) {
return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 });
}
const requestedQuantities = new Map<number, number>();
if (Array.isArray(body?.returnItems)) {
for (const line of body.returnItems) {
const product = Number(line?.product);
const returnQuantity = Number(line?.returnQuantity);
if (Number.isFinite(product) && Number.isFinite(returnQuantity) && returnQuantity > 0) {
requestedQuantities.set(product, returnQuantity);
}
}
}
if (requestedQuantities.size === 0) {
return NextResponse.json({ ok: false, reason: "Bitte mindestens einen Artikel mit Menge auswählen." }, { status: 400 });
}
const items: CustomerOrderItem[] = order.items.map((item) => {
const requested = requestedQuantities.get(item.product) ?? 0;
if (requested > item.quantity) {
throw Object.assign(new Error("returnQuantity exceeds ordered quantity"), { status: 400 });
}
return { ...item, returnQuantity: requested };
});
try {
const result = await requestOrderStatusChange(session.token, order.id, "request-return", { returnReason, items });
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
} catch {
return NextResponse.json({ ok: false, reason: "Eine der Mengen übersteigt die bestellte Menge." }, { status: 400 });
}
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getCustomerOrders, getSessionCustomer } from "../../../lib/customerAuth";
export async function GET() {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ orders: [] }, { status: 401 });
const orders = await getCustomerOrders(session.token, session.customer.id);
return NextResponse.json({ orders });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { changeCustomerPassword, getSessionCustomer } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
if (!checkRateLimit(`password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
const newPassword = typeof body?.newPassword === "string" ? body.newPassword : "";
if (!currentPassword || !newPassword || newPassword.length < 8) {
return NextResponse.json({ ok: false, reason: "Bitte aktuelles und ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 });
}
const result = await changeCustomerPassword(session.customer.email, currentPassword, newPassword);
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+59
View File
@@ -0,0 +1,59 @@
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();
if (!session) return NextResponse.json({ profile: null }, { status: 401 });
return NextResponse.json({ profile: session.customer });
}
export async function PATCH(request: Request) {
const session = await getSessionCustomer();
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, companyName, vatId } = body ?? {};
if (
typeof firstName !== "string" ||
!firstName ||
typeof lastName !== "string" ||
!lastName ||
(deliveryMethod !== "address" && deliveryMethod !== "packstation") ||
typeof zip !== "string" ||
!zip ||
typeof city !== "string" ||
!city ||
typeof country !== "string" ||
!country
) {
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
}
if (deliveryMethod === "address" && !street) {
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
}
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,
lastName,
deliveryMethod,
street,
packstationNumber,
postNumber,
zip,
city,
country,
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
vatId: normalizedVatId,
});
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { registerCustomer, setSessionCookie } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
if (!checkRateLimit(`register:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const body = await request.json().catch(() => null);
const { firstName, lastName, email, password } = body ?? {};
if (
typeof firstName !== "string" ||
typeof lastName !== "string" ||
typeof email !== "string" ||
typeof password !== "string" ||
!firstName ||
!lastName ||
!email ||
!password
) {
return NextResponse.json({ ok: false, reason: "Bitte alle Felder ausfüllen." }, { status: 400 });
}
const result = await registerCustomer({ firstName, lastName, email, password });
if (!result.ok) return NextResponse.json(result, { status: 400 });
await setSessionCookie(result.token);
return NextResponse.json({ ok: true, customer: result.customer });
}
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, resendVerificationEmail } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
if (!checkRateLimit(`resend-verification:${getClientIp(request)}`, { limit: 3, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
if (session.customer.emailVerified) return NextResponse.json({ ok: true });
const ok = await resendVerificationEmail(session);
return NextResponse.json({ ok }, { status: ok ? 200 : 500 });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { resetPassword, setSessionCookie } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
if (!checkRateLimit(`reset-password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const body = await request.json().catch(() => null);
const token = typeof body?.token === "string" ? body.token : "";
const password = typeof body?.password === "string" ? body.password : "";
if (!token || !password || password.length < 8) {
return NextResponse.json({ ok: false, reason: "Bitte ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 });
}
const result = await resetPassword(token, password);
if (!result.ok) return NextResponse.json(result, { status: 400 });
await setSessionCookie(result.token);
return NextResponse.json({ ok: true, customer: result.customer });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyEmailByToken } from "../../../lib/customerAuth";
// Entered from the link in the verification email — no session exists
// yet at this point. See Customers.ts's own comment on why this is a
// non-blocking flag (login already works before this is ever clicked).
//
// Base URL is deliberately NOT built from request.url — behind Caddy's
// reverse proxy that reflects the container's internal address
// (0.0.0.0:3000, confirmed live), not the public domain, and would send a
// real browser to an unreachable address. Same hardcoded-origin approach
// as Customers.ts's own FRONTEND_URL default on the Payload side.
const SITE_URL = "https://einfach-produktiv.mk360.de";
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get("token");
if (!token) return new Response("Ungültiger Link.", { status: 400 });
const ok = await verifyEmailByToken(token);
const url = new URL("/konto/profil", SITE_URL);
url.searchParams.set("verified", ok ? "1" : "0");
return NextResponse.redirect(url);
}
+493
View File
@@ -0,0 +1,493 @@
import { NextResponse } from "next/server";
import type { CartItem } from "../../lib/cart";
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
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[];
shippingMethodId: number;
paymentMethodId: number;
discountCode: string | null;
firstName: string;
lastName: string;
email: string;
password?: string;
companyName?: string;
vatId?: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
postNumber?: 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;
};
function isValidBody(body: unknown): body is CheckoutBody {
const b = body as Partial<CheckoutBody> | null;
return Boolean(
b &&
Array.isArray(b.cart) &&
b.cart.length > 0 &&
typeof b.shippingMethodId === "number" &&
typeof b.paymentMethodId === "number" &&
typeof b.firstName === "string" &&
b.firstName &&
typeof b.lastName === "string" &&
b.lastName &&
typeof b.email === "string" &&
b.email &&
(b.deliveryMethod === "address" || b.deliveryMethod === "packstation") &&
typeof b.zip === "string" &&
b.zip &&
typeof b.city === "string" &&
b.city &&
typeof b.country === "string" &&
b.country,
);
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
if (!isValidBody(body)) {
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
}
if (body.deliveryMethod === "address" && !body.street) {
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
}
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
// Checkout") — logging in with an *existing* account happens separately
// beforehand via /api/account/login from the checkout page's own toggle.
let customer: CustomerSummary;
const session = await getSessionCustomer();
if (session) {
customer = session.customer;
} else {
if (!body.password) {
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 });
}
const result = await registerCustomer({
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
password: body.password,
});
if (!result.ok) return NextResponse.json(result, { status: 400 });
await setSessionCookie(result.token);
customer = result.customer;
}
// 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;
quantity: number;
unitPrice: number;
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: variant?.priceOverride ?? product.price,
imageUrl,
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
bundleContents: describeBundleContents(product),
variantName: variant?.name ?? null,
});
}
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);
if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 });
const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold;
const shippingCost = freeShipping ? 0 : shippingMethod.price;
const paymentMethods = await getPaymentMethods();
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
if (!paymentMethod) return NextResponse.json({ ok: false, reason: "Zahlungsart ist ungültig." }, { status: 400 });
let discountAmount = 0;
if (body.discountCode) {
const validation = await validateDiscountCode(body.discountCode, subtotal);
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 = 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 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,
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,
newsletterOptIn: Boolean(body.newsletterOptIn),
items,
subtotal: finalSubtotal,
shippingCost: finalShippingCost,
shippingMethodTitle: shippingMethod.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
// through checkout believing they bought something, and nothing was
// persisted. Kuma's uptime checks can't see this (the site is up,
// this route just returned a 500) — this is the one alert path that
// can.
sendCriticalAlert("Bestellung konnte nicht gespeichert werden", {
customerId: customer.id,
customerEmail: body.email,
cart: body.cart,
total,
timestamp: new Date().toISOString(),
});
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
}
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,
...(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 });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { validateDiscountCode, redeemDiscountCode } from "../../../lib/discountServer";
// Called once, from CheckoutContent.tsx's handlePurchase(), right before
// the OrderSnapshot is written — re-validates (the window/limit may have
// changed since the cart-side /validate check, however unlikely) and only
// then increments the redemption counter. If this fails, the caller must
// not complete the purchase with a dead code silently still showing as
// applied.
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const code = typeof body?.code === "string" ? body.code : "";
const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0;
if (!code) {
return NextResponse.json({ redeemed: false, reason: "Kein Code angegeben." }, { status: 400 });
}
const result = await validateDiscountCode(code, subtotal);
if (!result.valid) return NextResponse.json({ redeemed: false, reason: result.reason });
const ok = await redeemDiscountCode(result.doc);
if (!ok) return NextResponse.json({ redeemed: false, reason: "Rabattcode konnte nicht eingelöst werden." });
return NextResponse.json({ redeemed: true });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { validateDiscountCode } from "../../../lib/discountServer";
// Called from CartContent.tsx when a shopper clicks "Anwenden" — read-only
// check (active/window/minOrderValue/remaining-redemptions), does NOT
// increment the redemption counter. That only happens in /redeem, at
// actual purchase time (see CheckoutContent.tsx's handlePurchase()).
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const code = typeof body?.code === "string" ? body.code : "";
const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0;
if (!code) {
return NextResponse.json({ valid: false, reason: "Bitte einen Code eingeben." }, { status: 400 });
}
const result = await validateDiscountCode(code, subtotal);
if (!result.valid) return NextResponse.json(result);
return NextResponse.json({ valid: true, type: result.doc.type, value: result.doc.value });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
// Deliberately checks Payload connectivity, not just "did this route
// handler run" — the site can return 200s from every static/ISR page
// while Payload itself is unreachable (stale cached content masks it for
// a while). Meant for a Kuma HTTP monitor, added to the existing "Content
// & API" group alongside the direct Payload monitors (see ~/dev/README.md).
export async function GET() {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${PAYLOAD_URL}/api/posts?limit=1`, { signal: controller.signal, cache: "no-store" });
clearTimeout(timeout);
if (!res.ok) return NextResponse.json({ ok: false, payload: false }, { status: 503 });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: false, payload: false }, { status: 503 });
}
}
+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 });
}
+28
View File
@@ -0,0 +1,28 @@
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { NextRequest } from "next/server";
// Entered only via the `livePreview.url` link Payload puts in its admin
// (posts/legal-pages/testimonials, see payload.config.ts and those
// collections' own `admin.livePreview.url` resolvers) — enables Draft Mode
// so the target page renders its Live-Preview-aware components (see the
// `isPreview` checks in those pages' page.tsx), then redirects into the
// actual page. `path` is never redirected to as-is (open-redirect risk);
// it's validated to be an internal path first.
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const secret = searchParams.get("secret");
const path = searchParams.get("path");
if (!secret || secret !== process.env.PAYLOAD_PREVIEW_SECRET) {
return new Response("Invalid secret", { status: 401 });
}
if (!path || !path.startsWith("/") || path.startsWith("//")) {
return new Response("Invalid path", { status: 400 });
}
const draft = await draftMode();
draft.enable();
redirect(path);
}
+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,9 +5,13 @@ import Link from "next/link";
import Image from "next/image";
import type { CartItem } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { formatPrice, formatDate, discountPercent } from "../../lib/format";
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
@@ -26,7 +30,11 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
typeof data.orderNumber !== "string" ||
typeof data.orderDateIso !== "string" ||
typeof data.shippingCost !== "number" ||
typeof data.paymentMethodTitle !== "string"
typeof data.paymentMethodTitle !== "string" ||
(data.discountCode !== null && typeof data.discountCode !== "string") ||
typeof data.discountAmount !== "number" ||
typeof data.vatExempt !== "boolean" ||
typeof data.kleinunternehmer !== "boolean"
) {
return null;
}
@@ -36,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);
@@ -95,12 +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));
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
const totalSavings = items.reduce((sum, { entry, product }) => {
const discount = discountPercent(product.price, product.compareAtPrice);
return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
}, 0);
const total = subtotal + order.shippingCost;
// 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.
// 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 (
<>
@@ -174,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" />
@@ -205,6 +254,13 @@ export function BestellbestaetigungContent() {
<span className="font-bold text-body-sm text-success">-{formatPrice(totalSavings)}</span>
</div>
)}
{order.discountCode && (
<div className="flex items-center w-full">
<span className="text-body-sm text-success">Rabattcode ({order.discountCode})</span>
<span className="flex-1" />
<span className="font-bold text-body-sm text-success">-{formatPrice(order.discountAmount)}</span>
</div>
)}
<div className="flex items-center w-full">
<span className="text-body-sm text-text-primary">Versand</span>
<span className="flex-1" />
@@ -226,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>
@@ -252,6 +314,12 @@ export function BestellbestaetigungContent() {
</Reveal>
)}
<Reveal delay={0.08} className="flex items-center justify-center pb-4 px-[var(--layout-padding-x)] w-full">
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Meine Bestellungen ansehen
</Link>
</Reveal>
{/* Testimonial band — same proven structure as /not-found's version
(see that page's own comment), not a new layout: w-[45%] image
column, narrow 40px edge gradient into bg-muted, quote in the
@@ -270,9 +338,12 @@ export function BestellbestaetigungContent() {
gap shows the image itself, not whatever's behind it — the
parent's overflow-hidden clips the small overflow back
down to a clean box either way. */}
<img
<Image
alt=""
src="/bestellbestaetigung-testimonial-photo.jpg"
width={366}
height={126}
sizes="(min-width: 768px) 45vw, 100vw"
className="absolute -inset-1 w-[calc(100%+0.5rem)] h-[calc(100%+0.5rem)] object-cover"
/>
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
+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 />
@@ -0,0 +1,64 @@
"use client";
import Image from "next/image";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { Reveal } from "../../../components/Reveal";
import { RichText } from "../../../components/RichText";
import { formatDate } from "../../../lib/format";
import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Live-previewable subset of the blog detail page: title/category/readTime/
// excerpt/byline, the thumbnail, and the RichText body — the fields an
// editor actually watches update while typing. The author bio card,
// "Weiterlesen" card, and Footer stay static in page.tsx: they either
// aren't post-specific content (bio) or are about a *different* post
// (nextPost), not the document currently open in the admin.
export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
const { data } = useLivePreview<PayloadPostDetail>({
initialData: initialPost as unknown as PayloadPostDetail,
serverURL: PAYLOAD_URL,
depth: 2,
});
const post = data?.slug ? mapPayloadPost(data) : initialPost;
return (
<>
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
<p
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-playfair)" }}
>
{post.title}
</p>
<p className="text-body text-text-muted">{post.excerpt}</p>
<div className="flex items-center gap-3 pt-1">
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
</div>
<p className="text-body-sm text-text-primary">
Björn <span className="text-text-muted"> {formatDate(post.publishedAt)}</span>
</p>
</div>
</Reveal>
{post.thumbnail && (
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
</div>
</Reveal>
)}
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<RichText content={post.content} quoteLabel={post.quoteLabel} />
</Reveal>
</>
);
}
+127 -92
View File
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { notFound } from "next/navigation";
import { draftMode } from "next/headers";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { RichText } from "../../components/RichText";
import { LivePostContent } from "./components/LivePostContent";
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
import { formatDate } from "../../lib/format";
@@ -17,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,
},
};
}
@@ -37,7 +52,8 @@ export default async function BlogDetailPage({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
const { isEnabled: isPreview } = await draftMode();
const post = await getPostBySlug(slug, { draft: isPreview });
if (!post) notFound();
// "Weiterlesen" — any other post, most recent first. Not the current
@@ -49,97 +65,116 @@ export default async function BlogDetailPage({
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
{/* Playfair (not Lora), matching the actual Figma title's font —
same "hero headline" family as Hero.tsx/TodoKartenHero.tsx/
WeeklyImpulsesHero.tsx use, not the Lora section-heading style
the legal pages use. Sized down from text-display (Figma's
literal 44px), but text-h-feature (max 40px) read too small
no named token sits between the two, so this is a one-off
fluid(36, 48) clamp using the project's own fluid.ts formula
(768px Tablet floor → 1440px Desktop cap), landing between
them instead of jumping all the way back to text-display. */}
<p
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-playfair)" }}
>
{post.title}
</p>
<p className="text-body text-text-muted">{post.excerpt}</p>
<div className="flex items-center gap-3 pt-1">
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
</div>
<p className="text-body-sm text-text-primary">
Björn <span className="text-text-muted"> {formatDate(post.publishedAt)}</span>
</p>
</div>
</Reveal>
{isPreview ? (
<LivePostContent initialPost={post} />
) : (
<>
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
{/* Playfair (not Lora), matching the actual Figma title's font
same "hero headline" family as Hero.tsx/TodoKartenHero.tsx/
WeeklyImpulsesHero.tsx use, not the Lora section-heading style
the legal pages use. Sized down from text-display (Figma's
literal 44px), but text-h-feature (max 40px) read too small —
no named token sits between the two, so this is a one-off
fluid(36, 48) clamp using the project's own fluid.ts formula
(768px Tablet floor → 1440px Desktop cap), landing between
them instead of jumping all the way back to text-display. */}
<p
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-playfair)" }}
>
{post.title}
</p>
<p className="text-body text-text-muted">{post.excerpt}</p>
<div className="flex items-center gap-3 pt-1">
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
</div>
<p className="text-body-sm text-text-primary">
Björn <span className="text-text-muted"> {formatDate(post.publishedAt)}</span>
</p>
</div>
</Reveal>
{post.thumbnail && (
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
// edge (an earlier version guessed that, which came out far too
// wide) and not capped to the body column either.
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
</div>
</Reveal>
{post.thumbnail && (
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
// edge (an earlier version guessed that, which came out far too
// wide) and not capped to the body column either.
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
</div>
</Reveal>
)}
<Reveal delay={0.15} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<RichText content={post.content} quoteLabel={post.quoteLabel} />
</Reveal>
</>
)}
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<RichText content={post.content} />
{/* "Passend dazu" — static cross-promo, not CMS content (same
reasoning as the legal pages' brand callouts): every post
currently points at the same flagship product rather than
needing a per-post "related product" field that nothing else
uses yet. Matches the actual built Figma frame exactly (node
4674:349, fetched via get_design_context) — 1px border-border,
rounded-md, px-9/py-7 padding; an earlier version guessed a
plain borderless row instead, which get_metadata's structural
dump didn't reveal (frame-level stroke/padding/radius aren't
visible there, only get_design_context shows those). */}
<Link
href="/todo-cards"
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
>
<img alt="" src="/icon-todo-passend-dazu.png" className="w-16 h-[4.6875rem] shrink-0 object-contain" />
<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">
<p
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
style={{ fontFamily: "var(--font-lora)" }}
>
ToDo-Karten
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">Bringe Struktur in deine Aufgaben und gewinne Zeit zurück.</p>
</div>
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
Entdecken
<svg
viewBox="0 0 20 20"
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
aria-hidden="true"
>
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
{/* "Passend dazu" — per-post CMS content now (Posts.relatedProduct),
not a hardcoded flagship-product link. Hidden entirely if the
post has no related product, or that product has no detail
page to send "Entdecken" to. Matches the actual built Figma
frame exactly (node 4674:349, fetched via get_design_context) —
1px border-border, rounded-md, px-9/py-7 padding; an earlier
version guessed a plain borderless row instead, which
get_metadata's structural dump didn't reveal (frame-level
stroke/padding/radius aren't visible there, only
get_design_context shows those). */}
{post.relatedProduct?.href && (
<Link
href={post.relatedProduct.href}
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>
</Link>
<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>
{/* 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 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 mt-1 sm:mt-0">
Entdecken
<svg
viewBox="0 0 20 20"
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
aria-hidden="true"
>
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
</Link>
)}
</Reveal>
{/* Author bio — same hardcoded-brand-chrome reasoning as above;
+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() {
+229 -27
View File
@@ -1,20 +1,29 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
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, 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 } from "../../lib/payload";
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
export function CartContent({
trustBadges,
shippingCost,
freeShippingThreshold,
shippingSettings,
defaultTaxRate,
kleinunternehmer,
showDiscountField,
}: {
trustBadges: TrustBadge[];
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
@@ -24,10 +33,37 @@ export function CartContent({
/** Lowest freeShippingThreshold among active ShippingMethods, or null if
* none has one (in which case FreeShippingBanner just doesn't render). */
freeShippingThreshold: number | null;
/** Delivery-time disclosure (Payload's Shipping Settings), fetched by the
* page and threaded down here — this is a Client Component, so it can't
* fetch it itself. Also passed straight through to VersandModal. Named
* "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();
// While the /api/products fetch is still pending, treat a non-empty
// cart as "loading" rather than "empty" — the old hardcoded PRODUCTS
// lookup was synchronous, so this distinction didn't exist before;
@@ -39,16 +75,60 @@ export function CartContent({
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
const totalSavings = items.reduce((sum, { entry, product }) => {
const discount = discountPercent(product.price, product.compareAtPrice);
return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
}, 0);
const subtotal = computeSubtotal(items);
const shipping =
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
? 0
: shippingCost;
const total = subtotal + shipping;
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;
setDiscountLoading(true);
setDiscountError(null);
try {
const res = await fetch("/api/discount/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, subtotal }),
});
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.");
}
} catch {
setDiscountError("Rabattcode konnte gerade nicht geprüft werden.");
} finally {
setDiscountLoading(false);
}
}
// No manual input field anymore (see the Rabattcode section below) —
// codes are shared as a direct link instead (e.g. "/cart?code=SAVE10"),
// auto-applied once on arrival. Only fires while nothing's applied yet
// and there's actually something in the cart to validate a minimum-order
// value against.
useEffect(() => {
const code = searchParams.get("code");
if (code && !discount && items.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time sync from the URL's ?code= param to app state on arrival, via an async server validation call, not a render-cascade
handleApplyDiscount(code);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only ever re-run when the cart finishes loading or the URL's code param itself changes, not on every discount/handleApplyDiscount identity change
}, [searchParams, items.length]);
return (
<>
@@ -102,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}%
@@ -120,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>
@@ -128,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"
>
×
@@ -199,6 +325,71 @@ export function CartContent({
</div>
)}
{/* 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>
<span className="flex-1" />
<span className="font-bold text-body-sm text-success">-{formatPrice(discountAmount)}</span>
</div>
<button
type="button"
onClick={clearDiscount}
className="self-start text-label text-text-muted hover:text-text-primary underline transition-colors"
>
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>}
</>
)}
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
@@ -222,6 +413,9 @@ export function CartContent({
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
: "innerhalb Deutschlands"}
</p>
<p className="text-label text-text-muted">
Lieferzeit {shippingSettings.totalDays.min}{shippingSettings.totalDays.max} Werktage
</p>
</div>
<div className="h-px bg-border w-full" />
@@ -237,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
@@ -247,9 +445,13 @@ export function CartContent({
Zur Kasse gehen
</Link>
<div className="flex gap-[0.625rem] items-center justify-center w-full">
<img alt="" src="/icon-lock.svg" className="w-4 h-[1.125rem]" />
<span className="text-body-sm text-text-muted">Sichere Zahlung</span>
{/* "Sichere SSL-Verschlüsselung", not "Sichere Zahlung" (what
used to be here) — matches /checkout's identical note
under its own buy button; the old wording duplicated the
"Sichere Zahlung" trustBadges entry right below. */}
<div className="flex gap-2 items-center justify-center w-full">
<Image alt="" src="/icon-lock.svg" width={14} height={16} className="w-3.5 h-4" />
<span className="text-body-sm text-text-muted">Sichere SSL-Verschlüsselung</span>
</div>
</div>
@@ -258,7 +460,7 @@ export function CartContent({
<div className="flex flex-col gap-4 items-start w-full">
{trustBadges.map((b) => (
<div key={b.id} className="flex gap-3 items-center w-full">
<img alt="" src={b.icon} className="size-[1.375rem] shrink-0 object-contain" />
<Image alt="" src={b.icon} width={22} height={22} className="size-[1.375rem] shrink-0 object-contain" />
<span className="flex-1 text-body-sm text-text-primary">{b.title}</span>
</div>
))}
@@ -272,7 +474,7 @@ export function CartContent({
<Reveal className="flex flex-col items-start pb-10 px-[var(--layout-padding-x)] w-full">
<div className="bg-bg-muted flex gap-5 items-start p-6 rounded-md w-full lg:max-w-[51.875rem]">
<div className="flex flex-col gap-3 items-center justify-center shrink-0">
<img alt="" src="/icon-envelope-hint.png" className="h-[2.8125rem] w-16 object-contain" />
<Image alt="" src="/icon-envelope-hint.png" width={64} height={45} className="h-[2.8125rem] w-16 object-contain" />
<div className="h-[0.1875rem] w-6 bg-brand" />
</div>
<div className="flex flex-col gap-2 items-start flex-1 min-w-0 text-text-primary">
@@ -294,7 +496,7 @@ export function CartContent({
</Reveal>
)}
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} />
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
</>
);
}
+22 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
import { formatPrice } from "../../lib/format";
const SUCCESS_VISIBLE_MS = 2500;
@@ -53,14 +53,29 @@ function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; th
}
}
// Gates the hide-timer on the banner being CURRENTLY visible — deliberately
// not `{ once: true }`: since the banner sits at the very top of the cart
// page, it's already visible the instant the page loads (well before the
// user ever scrolls anywhere), so a lifetime "has this ever been seen"
// flag flips true immediately and defeats the whole point — reaching the
// threshold later while scrolled away would still start the timer right
// then, exactly the bug this was meant to fix. Continuous tracking
// instead: the effect below only runs the countdown while `isInView` is
// true, and its own cleanup cancels it the moment the banner scrolls back
// out of view — so it always takes a full uninterrupted 2.5s of the
// banner actually being on screen before it's allowed to hide, restarting
// if the user looks away mid-countdown and comes back.
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref);
// Effect's own cleanup (not a ref) cancels the pending hide-timer
// whenever phase changes away from "success" (e.g. dropping back below
// the threshold before the timer fires) or on unmount.
// whenever phase changes away from "success", the banner scrolls out of
// view, or on unmount.
useEffect(() => {
if (phase !== "success") return;
if (phase !== "success" || !isInView) return;
const t = setTimeout(() => setPhase("hidden"), SUCCESS_VISIBLE_MS);
return () => clearTimeout(t);
}, [phase]);
}, [phase, isInView]);
const remaining = Math.max(0, threshold - subtotal);
const progressPct = Math.min(100, (subtotal / threshold) * 100);
@@ -73,6 +88,7 @@ function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; th
<AnimatePresence>
{phase !== "hidden" && (
<motion.div
ref={ref}
initial={false}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
+84 -36
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";
@@ -16,40 +17,36 @@ function pickRandom(allIds: string[], excludeIds: string[], count: number): stri
return shuffled.slice(0, count);
}
// The catalog only has a handful of products — once the cart holds enough
// distinct ones, "N recommendations that aren't already in the cart" can
// become impossible (e.g. 4 products total, 2 already in the cart, but
// DISPLAY_COUNT is 3 — only 2 non-cart products exist, period). Falling
// back to re-suggesting something already in the cart (a normal "grab
// another one" pattern) beats silently shrinking the grid below
// DISPLAY_COUNT. Shared by both the initial pick and the swap-after-add
// path so neither can under-fill the grid.
function pickWithFallback(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
// Picks up to `count` active products not already in the cart, on top of
// whatever's already in `keep`. Deliberately does NOT fall back to
// re-suggesting a cart item when the non-cart pool runs short (e.g. 2
// active products total, 1 already in the cart) — the grid just renders
// fewer, genuinely-relevant cards instead (see the centering logic in the
// component below), rather than padding itself out with something the
// shopper has already added.
function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
const missing = count - keep.length;
if (missing <= 0) return keep;
let picks = pickRandom(allIds, [...excludeIds, ...keep], missing);
if (picks.length < missing) {
const stillMissing = missing - picks.length;
const fallback = pickRandom(allIds, [...keep, ...picks], stillMissing);
picks = [...picks, ...fallback];
}
return [...keep, ...picks];
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
// Product's own comment in lib/payload.ts) — this is the one discovery
// surface among the useProducts() consumers, so it filters here itself.
const activeProducts = useMemo(() => products.filter((p) => p.active), [products]);
const hasItems = cart.length > 0;
const cartKey = cart
.map((i) => i.id)
.sort()
.join(",");
// useMemo, not a plain .map() — .map() would return a new array
// reference on every render regardless of whether `products` itself
// reference on every render regardless of whether `activeProducts` itself
// changed, which would make the effect below re-run (and re-pick) every
// single render if `productIds` were listed as its dependency.
const productIds = useMemo(() => products.map((p) => p.id), [products]);
const productIds = useMemo(() => activeProducts.map((p) => p.id), [activeProducts]);
// Starts empty — the catalog itself is now fetched (useProducts()), so
// there's nothing to pick a random set from until that resolves. The
@@ -76,7 +73,7 @@ export function RelatedProducts() {
if (!pickedRef.current) {
pickedRef.current = true;
setDisplayIds(pickWithFallback(productIds, cartIds, [], DISPLAY_COUNT));
setDisplayIds(pickAvailable(productIds, cartIds, [], DISPLAY_COUNT));
return;
}
@@ -88,16 +85,19 @@ export function RelatedProducts() {
swapTimeoutRef.current = setTimeout(() => {
setDisplayIds((prev) => {
const stillRelevant = prev.filter((id) => !cartIds.includes(id));
return pickWithFallback(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
return pickAvailable(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
});
}, FEEDBACK_MS);
}, [cartKey, productIds]);
const displayProducts = displayIds
.map((id) => products.find((p) => p.id === id))
.map((id) => activeProducts.find((p) => p.id === id))
.filter((p): p is NonNullable<typeof p> => Boolean(p));
if (displayProducts.length === 0) return null;
// Section-wide gate, independent of cart contents: a "related products"
// section makes no sense with fewer than 2 active products total to
// ever offer, even before considering what's already in the cart.
if (activeProducts.length < 2 || displayProducts.length === 0) return null;
return (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
@@ -113,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
@@ -129,10 +124,30 @@ 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) => (
{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="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
className={
"group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
// Center the row when there are fewer than 3 cards to show
// (e.g. only 1 active product left once the others are
// already in the cart) — only the first card needs an
// explicit start column, later ones auto-flow right after
// it. 3-card case keeps the default left-to-right flow.
(i === 0
? displayProducts.length === 1
? "md:col-start-5"
: displayProducts.length === 2
? "md:col-start-3"
: ""
: "")
}
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image
@@ -142,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
@@ -150,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>
);
+29 -8
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getCartTrustBadges, getShippingMethods } 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
@@ -18,7 +20,14 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
const [trustBadges, shippingMethods] = await Promise.all([getCartTrustBadges(), getShippingMethods()]);
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
// (that's /checkout) — it just estimates using the first active method
@@ -34,12 +43,24 @@ export default async function CartPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<CartContent
trustBadges={trustBadges}
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
/>
<RelatedProducts />
{/* Suspense required — CartContent uses useSearchParams() (?code=
auto-apply, see its own comment) which opts any consumer into
client-side rendering unless wrapped. fallback={null}: the cart
itself is entirely client-rendered from localStorage anyway
(see CartContent's own productsLoading handling), so there's no
meaningful server-rendered content this would flash away from. */}
<Suspense fallback={null}>
<CartContent
trustBadges={trustBadges}
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
showDiscountField={showDiscountField}
/>
</Suspense>
<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>
);
}
+48 -127
View File
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
import Link from "next/link";
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 =
@@ -71,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 />,
@@ -117,52 +115,10 @@ const benefits = [
{ title: "Gelassener leben", desc: "Weniger Stress, mehr Zeit für die Dinge, die dir wichtig sind." },
];
const testimonials = [
{
avatar: "/avatar-1.jpg",
quote: "„Die 7-Tage-Challenge hat mir geholfen, wieder klar zu sehen und mit kleinen Schritten wirklich etwas zu verändern.“",
name: "Sarah M.",
role: "Marketing Managerin",
},
{
avatar: "/avatar-2.jpg",
quote: "„Kurz, konkret und unglaublich wirkungsvoll. Ich habe direkt mehr Fokus und weniger Druck im Kopf.“",
name: "Thomas K.",
role: "Selbständiger Berater",
},
{
avatar: "/avatar-3.jpg",
quote: "„Endlich eine Challenge, die nicht überfordert, sondern genau die richtigen Impulse gibt jeden Tag.“",
name: "Miriam L.",
role: "Projektleiterin",
},
];
export default async function ChallengePage() {
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("challenge", { draft: isPreview });
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>
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
<LockIcon />
Keine Werbung. Jederzeit abbestellbar.
</p>
</div>
);
}
export default function ChallengePage() {
return (
<>
<main className="flex flex-col flex-1 bg-[#f5f0e8]">
@@ -241,10 +197,12 @@ export default function ChallengePage() {
todo-cards hero images; delay={0.15} mirrors their text→image
stagger. */}
<Reveal className="hidden lg:block group flex-1 relative overflow-hidden" delay={0.15}>
<img
<Image
alt="7-Tage-Challenge"
src="/blog-featured.jpg"
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none transition-transform duration-500 group-hover:scale-105"
fill
sizes="48vw"
className="object-cover object-center pointer-events-none transition-transform duration-500 group-hover:scale-105"
/>
{/* Badge */}
<div className="absolute top-8 right-8 w-[7.5rem] h-[7.5rem] rounded-full bg-white shadow-md flex flex-col items-center justify-center text-center p-3 gap-1">
@@ -274,30 +232,34 @@ export default 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">
<img
alt=""
src="/icon-arrow-connector.svg"
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,
])}
@@ -324,14 +286,15 @@ export default function ChallengePage() {
{/* Image */}
<Reveal
className="group w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden"
className="group relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden"
style={{ minHeight: "18rem" }}
>
<img
<Image
alt="Notizbuch"
src="/challenge-content.jpg"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
style={{ minHeight: "18rem" }}
fill
sizes="(min-width: 1024px) 44vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
@@ -355,64 +318,22 @@ export default function ChallengePage() {
</section>
{/* ── Was andere sagen ── */}
{/* max-w-[1600px], not this page's other sections' 1280px — a
deliberate compromise with /todo-cards's and /newsletter's
unbounded fluid width, so all three testimonial sections cap
at the same width instead of Challenge's reading narrower on
wide viewports. Only this one section's cap changed, not the
rest of the page. */}
<section className="bg-white w-full py-14 lg:py-20">
<div className="px-8 lg:px-[5rem] max-w-[1600px] mx-auto flex flex-col gap-10">
<Reveal
className="font-semibold text-[#222221] text-center"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
>
Was andere sagen
</Reveal>
{/* Same style + micro-interactions as /todo-cards's and
/newsletter's identically-styled testimonial cards (kept in
sync deliberately): decorative quote-mark, hover-lift on
the card, avatar scale on the same hover via `group`. */}
<RevealGroup className="flex flex-col lg:flex-row gap-6">
{testimonials.map((t) => (
<RevealItem
key={t.name}
className="group relative flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<span
aria-hidden
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
>
</span>
<p className="text-[#222221] text-[0.95rem] leading-[1.6] flex-1 pr-8">{t.quote}</p>
<div className="flex items-center gap-3">
<img
alt={t.name}
src={t.avatar}
className="w-10 h-10 rounded-full object-cover shrink-0 transition-transform duration-300 group-hover:scale-110"
/>
<div>
<p className="font-semibold text-[#222221] text-[0.9rem]">{t.name}</p>
<p className="text-[#777] text-[0.8rem]">{t.role}</p>
</div>
</div>
</RevealItem>
))}
</RevealGroup>
</div>
</section>
{isPreview ? (
<LiveTestimonialsGrid testimonials={testimonials} />
) : (
<TestimonialsGrid testimonials={testimonials} />
)}
{/* ── Bottom CTA ── */}
<section className="w-full py-14 lg:py-16">
<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>
);
}
+22 -3
View File
@@ -2,7 +2,8 @@ import type { Metadata } from "next";
import { CheckoutContent } from "./components/CheckoutContent";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getShippingMethods, getPaymentMethods, getCartTrustBadges } 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.
export const metadata: Metadata = {
@@ -15,16 +16,34 @@ export const metadata: Metadata = {
};
export default async function CheckoutPage() {
const [shippingMethods, paymentMethods, trustBadges] = 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
// — pre-fills Card 1 for a returning customer instead of leaving it blank.
const profile = session ? await getCustomerProfile(session.token) : null;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<CheckoutContent shippingMethods={shippingMethods} paymentMethods={paymentMethods} trustBadges={trustBadges} />
<CheckoutContent
shippingMethods={shippingMethods}
shippingCountries={shippingCountries}
paymentMethods={paymentMethods}
trustBadges={trustBadges}
shippingSettings={shippingSettings}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
customerEmail={session?.customer.email ?? null}
savedProfile={profile}
/>
<TrustRow />
</main>
<Footer />
@@ -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>
);
}
@@ -0,0 +1,42 @@
"use client";
import dynamic from "next/dynamic";
import { useLivePreview } from "@payloadcms/live-preview-react";
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";
// @react-pdf/renderer's PDFViewer renders into an <iframe> via direct DOM
// access — it has to be excluded from the server render pass entirely
// (ssr: false), unlike the HTML-string email previews elsewhere in this
// app, which can render server-side fine since they're just
// dangerouslySetInnerHTML.
const PDFViewer = dynamic(() => import("@react-pdf/renderer").then((mod) => mod.PDFViewer), { ssr: false });
// Same useLivePreview() mechanism as LiveEmailPreviewClient.tsx — connects
// to the Payload admin's iframe via postMessage and updates `data` as the
// admin edits company-settings fields, no save required. Renders through
// the exact same InvoiceDocument component the real invoice PDF uses
// (app/lib/invoicePdf.tsx), against a fixed sample order
// (SAMPLE_INVOICE_ORDER) — there's no "current" real order to preview
// against generically, same reasoning as the email-templates preview's
// own SAMPLE_ORDER.
export function LiveCompanySettingsPreviewClient({ initialSettings }: { initialSettings: CompanySettings }) {
const { data } = useLivePreview<CompanySettings>({
initialData: initialSettings,
serverURL: PAYLOAD_URL,
depth: 0,
});
return (
<PDFViewer style={{ width: "100%", height: "100vh", border: "none" }}>
{/* 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>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { getCompanySettings, type CompanySettings } from "../lib/payload";
import { LiveCompanySettingsPreviewClient } from "./components/LiveCompanySettingsPreviewClient";
export const metadata: Metadata = {
title: "Firmendaten-Vorschau",
robots: { index: false, follow: false },
};
const FALLBACK: CompanySettings = {
sellerName: "",
legalForm: "sole-proprietorship",
registerCourt: null,
registerNumber: null,
managingDirector: null,
shareCapital: null,
sellerStreet: "",
sellerZip: "",
sellerCity: "",
sellerCountry: "",
sellerEmail: "",
vatId: "",
taxRatePercent: 19,
kleinunternehmer: false,
iban: null,
bic: null,
};
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
// gated on Draft Mode actually being enabled, unlike email-templates'
// preview: this data includes a real bank IBAN/address once filled in,
// not just marketing email copy, so this page must not render for an
// unauthenticated visitor who happens to find the URL. Unlike
// email-templates there's no draft/published distinction in the data
// itself (company-settings has no content-versioning concept, it's just
// the current row) — the initial fetch is the same live data
// getCompanySettings() always returns, useLivePreview() takes over from
// there as the admin edits fields.
export default async function CompanySettingsPreviewPage() {
const draft = await draftMode();
if (!draft.isEnabled) notFound();
const initialSettings = (await getCompanySettings()) ?? FALLBACK;
return <LiveCompanySettingsPreviewClient initialSettings={initialSettings} />;
}
+34 -17
View File
@@ -1,3 +1,4 @@
import Image from "next/image";
import { Reveal } from "./Reveal";
export function About() {
@@ -5,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
@@ -18,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" }}>
@@ -50,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>
@@ -67,21 +80,25 @@ 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}
>
<img
<Image
alt="Björn"
src="/about-author.jpg"
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none"
fill
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>
+102 -25
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);
@@ -49,31 +71,86 @@ export function AddToCartButton({
// into `base`, since appending on top can't rely on CSS source order
// the way branching a whole className (AddToCartInlineButton's
// approach) can when the base itself varies per caller.
const stateClasses = added ? "bg-success! hover:bg-success! text-white!" : "";
// Pale success-subtle fill + success text + a success-colored border, not
// a solid success-green fill with white text — same restrained pairing
// AddToCartInlineButton already uses (border-success + bg-success-subtle),
// 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 = 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>
);
}
+77 -15
View File
@@ -1,7 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { addToCart } from "../lib/cart";
import Image from "next/image";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
// Exported so consumers like RelatedProducts.tsx can delay their own
@@ -19,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);
@@ -47,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>
<img alt="" src="/icon-cart-outline.png" 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>
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef } from "react";
import { useCart } from "../lib/cart";
// Mirrors the local cart to the server whenever it changes, so a logged-in
// customer's cart follows them across devices (see Customers.ts's `cart`
// field). Renders nothing — mounted once in the root layout. Debounced
// (not fired on every keystroke-equivalent quantity bump) and silently a
// no-op when logged out — POST /api/account/cart 401s in that case, which
// this component doesn't need to distinguish from success; there's simply
// nothing to keep in sync yet.
export function CartSync() {
const cart = useCart();
const isFirstRender = useRef(true);
useEffect(() => {
// Skip the mount-time fire — this would otherwise POST on every page
// load even when nothing actually changed.
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const timeout = setTimeout(() => {
fetch("/api/account/cart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cart }),
}).catch(() => {
// Best-effort — a failed sync just means the next cart change (or
// the next login-time merge) tries again.
});
}, 800);
return () => clearTimeout(timeout);
}, [cart]);
return null;
}
+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>
);
})}
+14 -8
View File
@@ -1,9 +1,10 @@
import Image from "next/image";
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}
@@ -16,7 +17,7 @@ function Arrow() {
<div className="flex items-center justify-center shrink-0">
<div className="-scale-y-100 rotate-180">
<div className="relative" style={{ height: "var(--divider-arrow-h)", width: "var(--divider-arrow-w)" }}>
<img alt="" src="/icon-separator.svg" className="absolute inset-0 w-full h-full" />
<Image alt="" src="/icon-separator.svg" fill sizes="48px" />
</div>
</div>
</div>
@@ -27,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
@@ -60,7 +66,7 @@ export function Divider() {
className="relative"
style={{ height: "var(--divider-sparkle-inner-h)", width: "var(--divider-sparkle-inner-w)" }}
>
<img alt="" src="/icon-separator-right.svg" className="absolute inset-0 w-full h-full" />
<Image alt="" src="/icon-separator-right.svg" fill sizes="48px" />
</div>
</div>
</div>
+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 -61
View File
@@ -2,75 +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">
<img
alt=""
src="/icon-check.svg"
className="absolute inset-0 w-full h-full"
/>
{/* 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) => (
@@ -83,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>
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { RichText } from "./RichText";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Wraps just the RichText body of a legal page (/agb, /datenschutz,
// /impressum, /widerruf) for Payload Live Preview — those 4 pages' own
// headings/sidebars/TOC are hardcoded per page, not sourced from
// `page.title` at all, so `content` is the only field that actually
// benefits from real-time editing preview. Falls back to the
// server-fetched `initialContent` until a postMessage arrives, which only
// happens at all while this page is open inside the Payload admin's Live
// Preview iframe — ordinary visitors never mount this differently from a
// plain <RichText>.
export function LiveRichText({ initialContent, quoteLabel }: { initialContent: unknown; quoteLabel?: string }) {
const { data } = useLivePreview<{ content: unknown }>({
initialData: { content: initialContent },
serverURL: PAYLOAD_URL,
depth: 2,
});
return <RichText content={data.content} quoteLabel={quoteLabel} />;
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { TestimonialsGrid } from "./TestimonialsGrid";
import { mapPayloadTestimonial, type PayloadTestimonial, type Testimonial } from "../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Live Preview is document-scoped (Payload's admin has exactly one
// testimonial open at a time), but this page renders a *grid* of several —
// so unlike LiveRichText/LivePostContent (which map 1:1 to a single
// document), this only swaps in the one testimonial currently being edited
// (matched by id) and leaves the rest of the grid as initially fetched.
// initialData starts empty since we don't know which of the `testimonials`
// is open until the first postMessage arrives — acceptable because this
// component only ever mounts inside the Payload admin's own preview
// iframe (see the `isPreview` gate in todo-cards/newsletter/challenge's
// page.tsx), never for ordinary site visitors.
export function LiveTestimonialsGrid({ testimonials }: { testimonials: Testimonial[] }) {
const { data } = useLivePreview<Partial<PayloadTestimonial>>({
initialData: {},
serverURL: PAYLOAD_URL,
depth: 1,
});
const merged =
data.id !== undefined
? testimonials.map((t) => (t.id === data.id ? mapPayloadTestimonial(data as PayloadTestimonial) : t))
: testimonials;
return <TestimonialsGrid testimonials={merged} />;
}
+262 -89
View File
@@ -1,10 +1,12 @@
"use client";
import { useEffect, useRef, useState } from "react";
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";
@@ -31,16 +33,21 @@ function smoothScrollTo(targetY: number) {
requestAnimationFrame(step);
}
const navLinks = [
{ label: "Werkzeuge", href: "#werkzeuge" },
{ label: "Blog", href: "/blog" },
{ label: "Über Björn", href: "#ueber-bjoern" },
{ label: "Shop", href: "/shop" },
];
const anchorIds = navLinks
.filter((l) => l.href.startsWith("#"))
.map((l) => l.href.slice(1));
// "Shop" becomes an in-page anchor to the homepage's ProductSpotlight
// section (id="spotlight") instead of a real /shop navigation whenever
// exactly 1 product is active — same reasoning as the other anchor links,
// #werkzeuge/#ueber-bjoern already have (a full catalog grid is
// degenerate UX with only 1 item to show). Passed down from
// app/layout.tsx, which is the one place already fetching the product
// catalog for this decision.
function getNavLinks(singleActiveProduct: boolean) {
return [
{ label: "Werkzeuge", href: "#werkzeuge" },
{ label: "Blog", href: "/blog" },
{ label: "Über Björn", href: "#ueber-bjoern" },
{ label: "Shop", href: singleActiveProduct ? "#spotlight" : "/shop" },
];
}
// "Werkzeuge" also covers standalone tool/product pages that live under
// the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten),
@@ -66,6 +73,64 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
return pathname === href || pathname.startsWith(`${href}/`);
}
// Account icon — no Figma source exists for this yet (added outside the
// normal Figma-first workflow, see the assistant's own project notes on
// why: without it there was no reachable way to log in at all once the
// cart was empty and no recent order existed — /checkout's own login
// toggle never even renders in that state, see CheckoutContent.tsx's
// early "Warenkorb ist leer" return). Fetches auth state client-side via
// /api/account/me rather than through the server-rendered layout — this
// component's parent (app/layout.tsx) is otherwise static/ISR-cacheable,
// 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() {
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
useEffect(() => {
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";
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"
>
{/* -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>
{/* Only real "am I logged in?" signal on the site outside /konto
itself — same brand-colored underline language as the desktop
nav links' active-state indicator, so it reads as consistent
rather than a new visual idiom. */}
{loggedIn && <span aria-hidden className="absolute bottom-1 h-[2px] w-4 bg-brand rounded-full" />}
</Link>
);
}
// Cart icon + count badge — traced from the Figma Navbar/Default component's
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
@@ -145,7 +210,7 @@ function CartLink() {
);
}
export function Navbar() {
export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
@@ -154,6 +219,12 @@ export function Navbar() {
const panelRef = useRef<HTMLDivElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);
const navLinks = useMemo(() => getNavLinks(singleActiveProduct), [singleActiveProduct]);
const anchorIds = useMemo(
() => navLinks.filter((l) => l.href.startsWith("#")).map((l) => l.href.slice(1)),
[navLinks]
);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll, { passive: true });
@@ -170,10 +241,25 @@ export function Navbar() {
// whenever pathname becomes "/" (covers both the initial load and a
// client-side transition landing here), a short delay lets layout
// settle first.
//
// The no-hash branch below matters just as much: since the Navbar lives
// in the root layout (never unmounts across navigations), Next.js's
// default Link scroll behavior treats "/" as already-visible and leaves
// the current scrollY untouched instead of resetting to top (see
// next/dist/docs .../link.md's "maintain scroll position" default).
// Landing on Home from a page scrolled halfway down (e.g. clicking the
// logo from a scrolled /shop) then visually "lands" wherever that old
// offset happens to fall in Home's layout — often right around the
// Werkzeuge section — instead of at the top. Forcing scrollTo(0, 0) here
// makes a plain logo/Home navigation always start at the top, exactly
// like the same-page click handler below already does.
useEffect(() => {
if (pathname !== "/") return;
const hash = window.location.hash.slice(1);
if (!anchorIds.includes(hash)) return;
if (!anchorIds.includes(hash)) {
window.scrollTo(0, 0);
return;
}
const timer = setTimeout(() => {
const el = document.getElementById(hash);
if (el) {
@@ -182,7 +268,7 @@ export function Navbar() {
}
}, 50);
return () => clearTimeout(timer);
}, [pathname]);
}, [pathname, anchorIds]);
useEffect(() => {
const onScroll = () => {
@@ -201,7 +287,7 @@ export function Navbar() {
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
}, [anchorIds]);
// Close on viewport resize past the structural breakpoint, so the drawer
// never lingers open behind the (now visible) desktop nav. The hamburger
@@ -249,6 +335,32 @@ export function Navbar() {
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 (
@@ -267,7 +379,11 @@ export function Navbar() {
// 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"
@@ -364,7 +480,18 @@ export function Navbar() {
(below lg). Grouped so spacing stays consistent as individual
children hide/show across the three breakpoint tiers. */}
<div className="flex items-center gap-2">
<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 */}
@@ -423,80 +550,126 @@ export function Navbar() {
</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>
</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)} />
</>
);
+116 -26
View File
@@ -1,5 +1,22 @@
"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
// own wording/color for this trust note.
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>
);
}
type NewsletterProps = {
title?: ReactNode;
@@ -17,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">
@@ -26,16 +46,31 @@ 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">
<img
<Image
alt=""
src="/newsletter-icon.svg"
className="w-16 h-[3.438rem] block"
width={64}
height={55}
className="w-[4rem] h-[3.438rem] block"
/>
</div>
</div>
@@ -56,29 +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>
{/* Privacy note */}
<p className="text-label text-text-primary font-normal leading-normal">
Ich achte auf deine Daten. Kein Spam, 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>
+67 -30
View File
@@ -2,7 +2,9 @@
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 = [
{
@@ -33,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
@@ -149,7 +153,7 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
aria-label="Schließen"
className="absolute top-6 right-6 z-10 size-6 flex items-center justify-center active:scale-90 transition-transform"
>
<img alt="" src="/icon-close.png" className="size-full object-contain" />
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
</button>
{/* modal-top: photo + copy/form, stacked below md */}
@@ -168,9 +172,9 @@ 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">
<img alt="" src="/newsletter-icon.svg" className="w-full h-full" />
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>
<p
@@ -185,30 +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 Datenschutzerklärung.
</span>
</label>
</form>
{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"
>
{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>
@@ -224,8 +261,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md:gap-6">
{features.map((f) => (
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
<div className="h-10 w-10 shrink-0 flex items-center justify-center">
<img alt="" src={f.icon} className="max-h-10 max-w-10 object-contain" />
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
<Image alt="" src={f.icon} fill sizes="40px" className="object-contain" />
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0">
<p
+51 -14
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 } 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,14 +23,26 @@ import { formatPrice, discountPercent } from "../lib/format";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const product = await getSpotlightProduct();
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 (
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
// id="spotlight" — the Navbar's "Shop" link becomes an anchor to this
// section instead of navigating to /shop whenever exactly 1 product is
// active (see Navbar.tsx/layout.tsx).
<section id="spotlight" className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
@@ -39,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>
@@ -57,19 +76,37 @@ export async function ProductSpotlight() {
<p className="text-body text-text-body">
{product.spotlightText || product.description}
</p>
<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>
{/* 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>
</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 }}
>
+193 -121
View File
@@ -1,30 +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("");
@@ -34,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
@@ -63,137 +63,209 @@ export function extractHeadings(content: unknown): TOCSection[] {
return headings;
}
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string): ReactNode {
if (!nodes) return null;
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`));
// 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): 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)}
</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)}
{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)}
{nodesToJSX({ nodes: node.children })}
</ListTag>
);
}
case "listitem":
return (
<li key={key}>{renderChildren(node.children, key)}</li>
);
case "paragraph":
return (
<p key={key} className="text-body text-text-body">
{renderChildren(node.children, key)}
</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">
<div className="flex items-center gap-2 shrink-0">
<span className="flex h-7 w-6 items-center justify-center shrink-0">
<img alt="" src="/icon-sparkle-merke-dir.png" className="w-full h-full object-contain" />
</span>
<span
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
Merke dir:
</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>
{/* Hand-drawn underline image, not a plain bar — exported
straight from the Figma node (label-underline). */}
<img
alt=""
src="/icon-merke-dir-underline.png"
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
/>
<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)}
</p>
</div>
);
default:
return renderChildren(node.children, key);
}
);
},
imageGallery: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as ImageGalleryBlockFields;
const images = (fields.images ?? []).filter((row) => mediaUrl(row.image));
if (images.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-4 w-full">
{images.map((row, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="relative aspect-[4/3] rounded-md overflow-hidden bg-bg-muted">
<Image
alt=""
src={mediaUrl(row.image)!}
fill
sizes="(min-width: 768px) 24rem, 50vw"
className="object-cover"
/>
</div>
<BlockCaption caption={row.caption} />
</div>
))}
</div>
);
},
videoEmbed: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as VideoEmbedBlockFields;
const embedUrl = toEmbedUrl(fields.url);
if (!embedUrl) return null;
return (
<div className="flex flex-col gap-2 w-full">
<div className="relative w-full aspect-video rounded-md overflow-hidden bg-bg-muted">
<iframe
src={embedUrl}
title={fields.caption ?? "Video"}
className="absolute inset-0 h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
<BlockCaption caption={fields.caption} />
</div>
);
},
// Per-quote label, unlike Posts.quoteLabel above (one label shared by
// every native blockquote in the post) — new quotes going forward use
// this instead of the native blockquote feature.
quote: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as QuoteBlockFields;
const lines = fields.text.split("\n");
return (
<Quote label={fields.label ?? undefined}>
{lines.map((line, i) => (
<span key={i}>
{line}
{i < lines.length - 1 && <br />}
</span>
))}
</Quote>
);
},
},
});
}
export function RichText({ content }: { content: unknown }) {
const root = (content as { root?: LexicalNode })?.root;
export function RichText({
content,
quoteLabel = "Merke dir:",
}: {
content: unknown;
/** 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?: { children?: unknown[] } })?.root;
if (!root?.children) return null;
return (
<div className="flex flex-col gap-4 w-full">
{renderChildren(root.children, "root")}
<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>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
import type { Testimonial } from "../lib/payload";
// Shared by /todo-cards, /newsletter, and /challenge — all three were
// already pixel-identical (bg-muted filled card, no border, decorative
// quote-mark, quote on top with flex-1 pushing the avatar/name row to the
// bottom, hover-lift + avatar-scale), kept in sync deliberately as one
// visual pattern across pages rather than each page's own (differing)
// Figma spec for this one section. max-w-[1600px] matches Challenge's
// testimonial container cap, the widest of the three original values —
// a deliberate compromise so all three read consistently across viewports
// instead of one looking narrower than the others past ~1440px.
export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[] }) {
if (testimonials.length === 0) return null;
return (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
<Reveal
className="font-semibold text-h-emphasis text-text-primary text-center"
style={{ fontFamily: "var(--font-lora)" }}
>
Was andere sagen
</Reveal>
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
{testimonials.map((t) => (
<RevealItem
key={t.id}
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<span
aria-hidden
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
>
</span>
<p className="flex-1 text-body-sm text-text-primary leading-[1.6] pr-8">{t.quote}</p>
<div className="flex items-center gap-3">
<div className="relative size-10 shrink-0 rounded-full overflow-hidden transition-transform duration-300 group-hover:scale-110">
<Image src={t.avatar} alt={t.name} fill sizes="40px" className="object-cover" />
</div>
<div>
<p className="font-semibold text-body-sm text-text-primary">{t.name}</p>
<p className="text-body-sm text-text-muted">{t.role}</p>
</div>
</div>
</RevealItem>
))}
</RevealGroup>
</div>
</section>
);
}
+21 -5
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
import { getWerkzeugeCards } from "../lib/payload";
@@ -37,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="flex items-center justify-center shrink-0 size-14">
<img alt="" src={tool.icon} className="max-w-full max-h-full 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
@@ -66,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>
+5 -2
View File
@@ -1,3 +1,4 @@
import Image from "next/image";
import { getTrustBadges } from "../lib/payload";
// Content now lives in Payload (TrustBadges collection) instead of being
@@ -10,12 +11,14 @@ 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" />}
<div className="flex gap-4 items-center">
<img alt="" src={item.icon} className="size-8 shrink-0 object-contain" />
<div className="relative size-8 shrink-0">
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
</div>
<div className="flex flex-col gap-0.5 items-start">
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
+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>
);
}
+13 -2
View File
@@ -3,14 +3,25 @@
import { useEffect, useRef } from "react";
import { AnimatePresence, motion } from "motion/react";
import { VersandSections } from "../versand/components/VersandSections";
import type { ShippingSettings } from "../lib/payload";
/**
* Quick-reference version of /versand, opened from the cart's order
* summary "Versand" info link — a full page navigation would pull you out
* of checkout, which is exactly what the link is there to avoid. Reuses
* VersandSections so the two never carry different numbers/copy.
* `shipping` is threaded down from CartContent/CheckoutContent's own page
* (a Server Component), not fetched here — this is a Client Component.
*/
export function VersandModal({ open, onClose }: { open: boolean; onClose: () => void }) {
export function VersandModal({
open,
onClose,
shipping,
}: {
open: boolean;
onClose: () => void;
shipping: ShippingSettings;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
@@ -98,7 +109,7 @@ export function VersandModal({ open, onClose }: { open: boolean; onClose: () =>
</div>
<div className="px-8 py-6 pb-8">
<VersandSections />
<VersandSections shipping={shipping} />
</div>
</motion.div>
</motion.div>
+15 -4
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { SectionTOC } from "../components/SectionTOC";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
export const metadata: Metadata = {
@@ -13,7 +16,8 @@ export const metadata: Metadata = {
};
export default async function DatenschutzPage() {
const page = await getLegalPage("datenschutz");
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("datenschutz", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
return (
@@ -34,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} />
@@ -43,7 +54,7 @@ export default async function DatenschutzPage() {
callout doesn't belong in the generic LegalPages richText
field shared across all 4 legal page types. */}
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
<Image alt="" src="/icon-trust-leaf.png" width={28} height={28} className="size-7 object-contain" />
<p
className="font-semibold text-body text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
@@ -59,7 +70,7 @@ export default async function DatenschutzPage() {
<div className="w-full lg:flex-1 min-w-0">
{page ? (
<RichText content={page.content} />
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
@@ -0,0 +1,59 @@
"use client";
import { useLivePreview } from "@payloadcms/live-preview-react";
import {
renderOrderConfirmationHtml,
renderPasswordResetHtml,
renderOrderStatusHtml,
ORDER_STATUS_EMAIL_ICON,
SAMPLE_ORDER,
type EmailTemplateContent,
} from "../../../lib/emailTemplates";
import type { EmailTemplateType } from "../../../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Same useLivePreview() mechanism as LivePostContent.tsx (blog) — connects
// to the Payload admin's iframe via postMessage and updates `data` as the
// admin edits fields, no save required. Renders through the exact same
// renderOrderConfirmationHtml/renderPasswordResetHtml functions that build
// the real sent email (app/lib/orderEmail.ts, Customers.ts's forgotPassword
// hook on the Payload side uses its own simple template instead — see that
// hook's own comment on why the two aren't pixel-identical for
// password-reset specifically) — order-confirmation previews exactly.
export function LiveEmailPreviewClient({
type,
initialTemplate,
}: {
type: EmailTemplateType;
initialTemplate: EmailTemplateContent;
}) {
const { data } = useLivePreview<EmailTemplateContent>({
initialData: initialTemplate,
serverURL: PAYLOAD_URL,
depth: 0,
});
// No real company-settings fetch in this preview context — passing null
// falls back to DEFAULT_LEGAL_FOOTER_LINES (placeholder Anbieterkennzeichnung)
// inside buildLegalFooterLines(), same shape as the real send just with
// placeholder business data.
const html =
type === "order-confirmation"
? renderOrderConfirmationHtml(data, SAMPLE_ORDER, null)
: type === "password-reset"
? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", null)
: renderOrderStatusHtml(
data,
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
SAMPLE_ORDER.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
null,
);
return (
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { draftMode } from "next/headers";
import { getEmailTemplate, type EmailTemplateType } from "../../lib/payload";
import { LiveEmailPreviewClient } from "./components/LiveEmailPreviewClient";
export const metadata: Metadata = {
title: "E-Mail-Vorschau",
robots: { index: false, follow: false },
};
const VALID_TYPES: EmailTemplateType[] = [
"order-confirmation",
"password-reset",
"order-shipped",
"order-cancelled",
"order-return-requested",
"order-returned",
];
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
"order-shipped": "Deine Bestellung ist unterwegs",
"order-cancelled": "Deine Bestellung wurde storniert",
"order-return-requested": "Deine Rücksendung wurde angefragt",
"order-returned": "Deine Retoure wurde bearbeitet",
};
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
// not a page a real visitor would ever land on. Always reads with
// draft:true so an unsaved edit in the admin shows up here immediately;
// the actual sent email (orderEmail.ts) always reads the published version
// instead.
export default async function EmailPreviewPage({ params }: { params: Promise<{ type: string }> }) {
const { type } = await params;
if (!VALID_TYPES.includes(type as EmailTemplateType)) notFound();
const emailType = type as EmailTemplateType;
await draftMode();
const fallbackHeading =
emailType === "order-confirmation"
? "Vielen Dank für deine Bestellung!"
: emailType === "password-reset"
? "Passwort zurücksetzen"
: STATUS_TYPE_FALLBACK_HEADING[emailType];
const template = (await getEmailTemplate(emailType, { draft: true })) ?? {
type: emailType,
subject: "",
heading: fallbackHeading,
bodyText: "Noch kein Inhalt gespeichert — im Payload-Admin unter E-Mail-Vorlagen anlegen.",
footerText: null,
};
return <LiveEmailPreviewClient type={emailType} initialTemplate={template} />;
}
+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>
);
}
+30 -9
View File
@@ -1,20 +1,28 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { LiveRichText } from "../components/LiveRichText";
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 page = await getLegalPage("impressum");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
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 (
<>
@@ -31,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} />
@@ -47,7 +62,7 @@ export default async function ImpressumPage() {
the actual numbers/copy that need single-sourcing from
data. */}
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
<Image alt="" src="/icon-trust-leaf.png" width={28} height={28} className="size-7 object-contain" />
<p
className="font-semibold text-body text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
@@ -62,9 +77,15 @@ 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 ? (
<RichText content={page.content} />
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const LABEL = { cancel: "Bestellung stornieren", "request-return": "Rücksendung anfragen" } as const;
export type ReturnableItem = { product: number; productName: string; quantity: number };
export function OrderActionButton({
orderNumber,
action,
items,
}: {
orderNumber: string;
action: "cancel" | "request-return";
items: ReturnableItem[];
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [returnReason, setReturnReason] = useState("");
// Keyed by product id, string so the input can hold an empty/partial
// value while typing — parsed to a number only on submit.
const [quantities, setQuantities] = useState<Record<number, string>>({});
async function submit(body: Record<string, unknown>) {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Aktion war nicht möglich.");
setLoading(false);
return;
}
router.refresh();
} catch {
setError("Aktion war gerade nicht möglich.");
setLoading(false);
}
}
async function handleCancel() {
if (!window.confirm("Bestellung wirklich stornieren?")) return;
await submit({ action: "cancel" });
}
async function handleReturnSubmit() {
const trimmedReason = returnReason.trim();
if (!trimmedReason) {
setError("Bitte kurz einen Grund angeben.");
return;
}
const returnItems = Object.entries(quantities)
.map(([product, value]) => ({ product: Number(product), returnQuantity: Number(value) || 0 }))
.filter((line) => line.returnQuantity > 0);
if (returnItems.length === 0) {
setError("Bitte mindestens einen Artikel mit Menge auswählen.");
return;
}
await submit({ action: "request-return", returnReason: trimmedReason, returnItems });
}
if (action === "cancel") {
return (
<div className="flex flex-col gap-2 items-start">
<button
type="button"
onClick={handleCancel}
disabled={loading}
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "…" : LABEL.cancel}
</button>
{error && <p className="text-label text-red-600">{error}</p>}
</div>
);
}
// request-return: a short inline form, not window.prompt() — needs a
// per-item quantity (partial returns are supported, see the Payload
// README's "How a Stornorechnung/Gutschrift relates..." section), which
// a single-line browser prompt can't reasonably capture.
if (!formOpen) {
return (
<button
type="button"
onClick={() => setFormOpen(true)}
className="px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors"
>
{LABEL["request-return"]}
</button>
);
}
return (
<div className="flex flex-col gap-4 w-full border border-border rounded-md p-5">
<p className="font-semibold text-body-sm text-text-primary">Welche Artikel möchtest du zurücksenden?</p>
<div className="flex flex-col gap-3">
{items.map((item) => (
<div key={item.product} className="flex items-center gap-4">
<span className="flex-1 text-body-sm text-text-primary">{item.productName}</span>
<label className="flex items-center gap-2 text-label text-text-muted">
Menge
<input
type="number"
min={0}
max={item.quantity}
value={quantities[item.product] ?? ""}
onChange={(e) => setQuantities((prev) => ({ ...prev, [item.product]: e.target.value }))}
placeholder="0"
className="w-16 px-2 py-1 border border-border rounded-sm text-body-sm text-text-primary"
/>
</label>
<span className="text-label text-text-muted">/ {item.quantity}</span>
</div>
))}
</div>
<label className="flex flex-col gap-1">
<span className="text-label text-text-muted">Grund der Rücksendung</span>
<textarea
value={returnReason}
onChange={(e) => setReturnReason(e.target.value)}
rows={2}
className="px-3 py-2 border border-border rounded-sm text-body-sm text-text-primary"
/>
</label>
<div className="flex gap-3 items-center">
<button
type="button"
onClick={handleReturnSubmit}
disabled={loading}
className={`px-5 py-3 rounded-sm bg-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "…" : "Rücksendung anfragen"}
</button>
<button type="button" onClick={() => setFormOpen(false)} className="text-body-sm text-text-muted hover:text-brand transition-colors">
Abbrechen
</button>
</div>
{error && <p className="text-label text-red-600">{error}</p>}
</div>
);
}
@@ -0,0 +1,235 @@
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";
// 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;
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
if (!order) notFound();
const address =
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 (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
Zurück zur Bestellhistorie
</Link>
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
{order.orderNumber}
</p>
<div className="flex flex-wrap gap-8 w-full">
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Datum</p>
<p className="text-body-sm text-text-primary">{formatDate(order.createdAt)}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Status</p>
<OrderStatusBadge status={order.status} />
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Zahlungsart</p>
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
</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">
{/* 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>
<p className="text-body-sm text-text-primary">{address}</p>
<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) => {
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>
)}
</div>
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</p>
</div>
);
})}
<div className="h-px bg-border w-full" />
<div className="flex items-center w-full">
<span className="text-body-sm text-text-primary">Zwischensumme</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">{formatPrice(order.subtotal)}</span>
</div>
{order.discountCode && (
<div className="flex items-center w-full">
<span className="text-body-sm text-success">Rabattcode ({order.discountCode})</span>
<span className="flex-1" />
<span className="font-bold text-body-sm text-success">-{formatPrice(order.discountAmount)}</span>
</div>
)}
<div className="flex items-center w-full">
<span className="text-body-sm text-text-primary">Versand ({order.shippingMethodTitle})</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}
</span>
</div>
<div className="h-px bg-border w-full" />
<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>
{order.returnReason && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Grund der Rücksendung</p>
<p className="text-body-sm text-text-primary">{order.returnReason}</p>
</div>
)}
<div className="flex flex-col gap-2 items-start">
{order.invoiceNumber && (
<a
href={`/api/account/orders/${encodeURIComponent(order.orderNumber)}/invoice`}
className="text-body-sm text-brand hover:underline"
>
Rechnung herunterladen ({order.invoiceNumber})
</a>
)}
{order.correctionInvoiceNumber && (
<a
href={`/api/account/orders/${encodeURIComponent(order.orderNumber)}/correction-invoice`}
className="text-body-sm text-brand hover:underline"
>
{order.status === "returned" ? "Gutschrift" : "Stornorechnung"} herunterladen ({order.correctionInvoiceNumber})
</a>
)}
</div>
{action && (
<OrderActionButton
orderNumber={order.orderNumber}
action={action}
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
/>
)}
</Reveal>
</main>
<Footer />
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { formatPrice, formatDate } from "../../lib/format";
import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth";
import { OrderStatusBadge } from "../components/OrderStatusBadge";
import { LogoutButton } from "../components/LogoutButton";
// robots: noindex — account area, same reasoning as /checkout.
export const metadata: Metadata = {
title: "Meine Bestellungen",
description: "Deine Bestellhistorie bei einfach produktiv.",
robots: {
index: false,
follow: true,
},
};
export default async function KontoBestellungenPage() {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const orders = await getCustomerOrders(session.token, session.customer.id);
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[56rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Bestellungen
</p>
<p className="text-body text-text-muted">
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
</p>
{orders.length === 0 ? (
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
) : (
<div className="flex flex-col gap-4 w-full">
{orders.map((order) => (
<Link
key={order.orderNumber}
href={`/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`}
className="flex flex-wrap items-center gap-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Bestellnummer</p>
<p className="font-bold text-body-sm text-text-primary">{order.orderNumber}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Datum</p>
<p className="text-body-sm text-text-primary">{formatDate(order.createdAt)}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Artikel</p>
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Status</p>
<OrderStatusBadge status={order.status} />
</div>
<div className="flex flex-col gap-1 ml-auto">
<p className="text-label text-text-muted">Gesamtbetrag</p>
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
</div>
</Link>
))}
</div>
)}
<div className="flex gap-6">
<Link href="/konto/profil" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Profil &amp; Adresse
</Link>
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Weiter einkaufen
</Link>
<LogoutButton />
</div>
</Reveal>
</main>
<Footer />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
"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();
}
return (
<button type="button" onClick={handleLogout} className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Abmelden
</button>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { ORDER_STATUS_LABEL } from "../../lib/customerAuth";
// Colors are a rough "how good is this news" scale — neutral while
// in-progress, success once actually delivered, warm/red for anything
// that means the order didn't complete as planned. Reuses existing tokens
// where they exist (--color-brand, --color-success/-subtle); cancelled/
// return_requested borrow plain Tailwind red/orange since this codebase
// has no custom tokens for those (same reasoning as the existing
// text-red-600 error-text convention elsewhere).
const STYLES: Record<string, string> = {
received: "bg-bg-muted text-text-muted",
processing: "bg-brand/10 text-brand",
shipped: "bg-brand/10 text-brand",
delivered: "bg-success-subtle text-success",
cancelled: "bg-red-50 text-red-600",
return_requested: "bg-orange-50 text-orange-600",
returned: "bg-bg-muted text-text-light",
};
export function OrderStatusBadge({ status }: { status: string }) {
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
>
{ORDER_STATUS_LABEL[status] ?? status}
</span>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
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();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/account/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Login fehlgeschlagen.");
setLoading(false);
return;
}
await mergeServerCartIntoLocal();
dispatchAuthChanged();
router.push("/konto/bestellungen");
router.refresh();
} catch {
setError("Login ist gerade nicht möglich.");
setLoading(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Anmelden
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">E-Mail-Adresse</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Passwort</span>
<input
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "Einen Moment…" : "Einloggen"}
</button>
</form>
<Link href="/konto/passwort-vergessen" className="text-body-sm text-text-muted underline hover:text-brand transition-colors">
Passwort vergessen?
</Link>
<p className="text-body-sm text-text-muted">
Noch kein Konto? Einfach beim{" "}
<Link href="/checkout" className="underline hover:text-brand transition-colors">
nächsten Einkauf
</Link>{" "}
anlegen.
</p>
</Reveal>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { LoginForm } from "./components/LoginForm";
import { Footer } from "../../components/Footer";
// robots: noindex — account area, same reasoning as /checkout.
export const metadata: Metadata = {
title: "Anmelden",
description: "Melde dich bei deinem einfach produktiv-Konto an.",
robots: {
index: false,
follow: true,
},
};
export default function KontoLoginPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<LoginForm />
</main>
<Footer />
</>
);
}
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { Reveal } from "../../../components/Reveal";
export function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
try {
await fetch("/api/account/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
} catch {
// Same "always show success" reasoning as the route itself — a
// network hiccup here shouldn't reveal anything either.
}
setSent(true);
setLoading(false);
}
if (sent) {
return (
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
E-Mail unterwegs
</p>
<p className="text-body text-text-muted">
Falls zu <strong>{email}</strong> ein Konto existiert, haben wir dir eine E-Mail mit einem Link zum
Zurücksetzen deines Passworts geschickt.
</p>
</Reveal>
);
}
return (
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Passwort vergessen
</p>
<p className="text-body text-text-muted">
Gib deine E-Mail-Adresse ein wir schicken dir einen Link, mit dem du ein neues Passwort vergeben kannst.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">E-Mail-Adresse</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
<button
type="submit"
disabled={loading}
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "Einen Moment…" : "Link anfordern"}
</button>
</form>
</Reveal>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { ForgotPasswordForm } from "./components/ForgotPasswordForm";
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 },
};
export default function PasswortVergessenPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<ForgotPasswordForm />
</main>
<Footer />
</>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Reveal } from "../../../components/Reveal";
export function ResetPasswordForm({ token }: { token: string | null }) {
const router = useRouter();
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!token) return;
setLoading(true);
setError(null);
try {
const res = await fetch("/api/account/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Passwort konnte nicht zurückgesetzt werden.");
setLoading(false);
return;
}
router.push("/konto/bestellungen");
router.refresh();
} catch {
setError("Passwort konnte gerade nicht zurückgesetzt werden.");
setLoading(false);
}
}
if (!token) {
return (
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Link ungültig
</p>
<p className="text-body text-text-muted">
Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Fordere gerne einen neuen an.
</p>
</Reveal>
);
}
return (
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Neues Passwort vergeben
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Neues Passwort</span>
<input
type="password"
required
minLength={8}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "Einen Moment…" : "Passwort speichern"}
</button>
</form>
</Reveal>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { ResetPasswordForm } from "./components/ResetPasswordForm";
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 },
};
// token read server-side from searchParams (not the client-side
// useSearchParams() hook) — avoids needing a Suspense boundary here, same
// reasoning as /konto/profil's ?verified= handling.
export default async function PasswortZuruecksetzenPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<ResetPasswordForm token={token ?? null} />
</main>
<Footer />
</>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Reveal } from "../../../components/Reveal";
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";
export function AccountDataSection() {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
async function handleDelete(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setDeleting(true);
setError(null);
try {
const res = await fetch("/api/account/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Konto konnte nicht gelöscht werden.");
setDeleting(false);
return;
}
router.push("/");
router.refresh();
} catch {
setError("Konto konnte gerade nicht gelöscht werden.");
setDeleting(false);
}
}
return (
<Reveal className="flex flex-col gap-4 items-start w-full pt-4 border-t border-border">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Konto &amp; Daten
</p>
<a href="/api/account/export" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Meine Daten exportieren
</a>
{!confirming ? (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-body-sm text-red-600 underline hover:text-red-700 transition-colors"
>
Konto löschen
</button>
) : (
<form onSubmit={handleDelete} className="flex flex-col gap-3 items-start w-full max-w-sm">
<p className="text-body-sm text-text-primary">
Dein Konto und deine gespeicherte Adresse werden gelöscht. Bereits aufgegebene Bestellungen bleiben aus
steuerrechtlichen Gründen mit ihren eigenen Daten erhalten, sind danach aber keinem Konto mehr zugeordnet.
</p>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Passwort zur Bestätigung</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className={inputClass}
/>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={deleting}
className={`px-5 py-3 rounded-sm bg-red-600 hover:bg-red-700 font-bold text-body-sm text-white transition-colors ${deleting ? "opacity-70 pointer-events-none" : ""}`}
>
{deleting ? "…" : "Konto endgültig löschen"}
</button>
<button type="button" onClick={() => setConfirming(false)} className="px-5 py-3 text-body-sm text-text-muted">
Abbrechen
</button>
</div>
</form>
)}
</Reveal>
);
}
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { Reveal } from "../../../components/Reveal";
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";
export function PasswordForm({ email }: { email: string }) {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formEl = e.currentTarget;
setSaving(true);
setError(null);
setSuccess(false);
const form = new FormData(formEl);
const currentPassword = String(form.get("currentPassword") ?? "");
const newPassword = String(form.get("newPassword") ?? "");
try {
const res = await fetch("/api/account/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Passwort konnte nicht geändert werden.");
setSaving(false);
return;
}
setSuccess(true);
setSaving(false);
formEl.reset();
} catch {
setError("Passwort konnte gerade nicht geändert werden.");
setSaving(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start w-full pt-4 border-t border-border">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Passwort ändern
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
{/* Hidden, but present so autofill/password managers correctly
associate the new password with this account's email. */}
<input type="hidden" name="email" value={email} autoComplete="username" />
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Aktuelles Passwort</span>
<input type="password" name="currentPassword" autoComplete="current-password" required className={inputClass} />
</label>
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Neues Passwort</span>
<input type="password" name="newPassword" autoComplete="new-password" minLength={8} required className={inputClass} />
</label>
{error && <p className="text-label text-red-600">{error}</p>}
{success && <p className="text-label text-success">Passwort geändert.</p>}
<button
type="submit"
disabled={saving}
className={`px-7 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${saving ? "opacity-70 pointer-events-none" : ""}`}
>
{saving ? "Speichert…" : "Passwort ändern"}
</button>
</form>
</Reveal>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
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";
function Field({
label,
wrapperClassName = "flex-1 min-w-0",
...props
}: { label: string; wrapperClassName?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<label className={`flex flex-col gap-2 items-start ${wrapperClassName}`}>
<span className="text-label text-text-muted">{label}</span>
<input {...props} className={inputClass} />
</label>
);
}
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);
const [success, setSuccess] = useState(false);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setError(null);
setSuccess(false);
const form = new FormData(e.currentTarget);
const body = {
firstName: String(form.get("firstName") ?? ""),
lastName: String(form.get("lastName") ?? ""),
deliveryMethod,
street: String(form.get("street") ?? "") || undefined,
packstationNumber: String(form.get("packstationNumber") ?? "") || undefined,
postNumber: String(form.get("postNumber") ?? "") || undefined,
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 {
const res = await fetch("/api/account/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Profil konnte nicht gespeichert werden.");
setSaving(false);
return;
}
setSuccess(true);
setSaving(false);
router.refresh();
} catch {
setError("Profil konnte gerade nicht gespeichert werden.");
setSaving(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<p className="text-body-sm text-text-muted">
{profile.email} · Kundennummer {profile.customerNumber}
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Vorname" name="firstName" type="text" defaultValue={profile.firstName} required />
<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">
<button
type="button"
onClick={() => setDeliveryMethod("address")}
aria-pressed={deliveryMethod === "address"}
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${deliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Lieferadresse
</button>
<button
type="button"
onClick={() => setDeliveryMethod("packstation")}
aria-pressed={deliveryMethod === "packstation"}
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${deliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Packstation
</button>
</div>
</div>
{deliveryMethod === "address" ? (
<Field
label="Straße und Hausnummer"
name="street"
type="text"
defaultValue={profile.street ?? ""}
required
wrapperClassName="w-full"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Packstationnummer" name="packstationNumber" type="text" defaultValue={profile.packstationNumber ?? ""} required />
<Field label="Postnummer" name="postNumber" type="text" defaultValue={profile.postNumber ?? ""} required />
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="PLZ" name="zip" type="text" defaultValue={profile.zip ?? ""} required />
<Field label="Ort" name="city" type="text" defaultValue={profile.city ?? ""} required />
</div>
<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`}>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
{success && <p className="text-label text-success">Gespeichert.</p>}
<button
type="submit"
disabled={saving}
className={`px-7 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${saving ? "opacity-70 pointer-events-none" : ""}`}
>
{saving ? "Speichert…" : "Speichern"}
</button>
</form>
</Reveal>
);
}
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
export function VerificationBanner({ emailVerified, justVerified }: { emailVerified: boolean; justVerified: "1" | "0" | undefined }) {
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
if (emailVerified) {
// Only shown right after clicking the link — not a persistent banner
// once verified, that would just be noise on every future visit.
if (justVerified === "1") {
return <p className="text-label text-success w-full">E-Mail-Adresse bestätigt.</p>;
}
return null;
}
async function handleResend() {
setSending(true);
setError(null);
try {
const res = await fetch("/api/account/resend-verification", { method: "POST" });
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Mail konnte nicht gesendet werden.");
setSending(false);
return;
}
setSent(true);
setSending(false);
} catch {
setError("Mail konnte gerade nicht gesendet werden.");
setSending(false);
}
}
return (
<div className="bg-bg-muted rounded-md p-4 flex flex-col gap-1 w-full">
<p className="text-body-sm text-text-primary">
{justVerified === "0"
? "Der Bestätigungslink ist ungültig oder abgelaufen."
: "Bitte bestätige deine E-Mail-Adresse."}{" "}
{!sent && (
<button type="button" onClick={handleResend} disabled={sending} className="underline font-bold hover:text-brand transition-colors">
{sending ? "…" : "Erneut senden"}
</button>
)}
{sent && <span className="text-success">Mail wurde erneut gesendet.</span>}
</p>
{error && <p className="text-label text-red-600">{error}</p>}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
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";
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 },
};
export default async function KontoProfilPage({
searchParams,
}: {
searchParams: Promise<{ verified?: string }>;
}) {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const [profile, shippingCountries] = await Promise.all([getCustomerProfile(session.token), getShippingCountries()]);
if (!profile) redirect("/konto/login");
const { verified } = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
Meine Bestellungen
</Link>
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
<PasswordForm email={profile.email} />
<AccountDataSection />
</div>
</main>
<Footer />
</>
);
}
+38 -18
View File
@@ -3,6 +3,8 @@ import { Inter, Playfair_Display, Caveat, Lora } from "next/font/google";
import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { CartSync } from "./components/CartSync";
import { getProducts, getSeoSettings } from "./lib/payload";
const inter = Inter({
variable: "--font-inter",
@@ -28,28 +30,45 @@ 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 function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Same 60s-ISR-cached call every other page already makes — reused here
// just to know whether Navbar's "Shop" link should behave as an anchor
// to the homepage spotlight instead of a real /shop navigation (see
// Navbar.tsx/ProductSpotlight.tsx).
const products = await getProducts();
const singleActiveProduct = products.filter((p) => p.active).length === 1;
return (
<html
lang="de"
@@ -57,7 +76,8 @@ export default function RootLayout({
>
<body className="min-h-full flex flex-col">
<CartFlyProvider>
<Navbar />
<CartSync />
<Navbar singleActiveProduct={singleActiveProduct} />
{children}
</CartFlyProvider>
</body>
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { describeBundleContents } from "../bundleContents";
import type { RawProduct } from "../productsServer";
const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
id: 1,
slug: "starter-set",
name: "Starter-Set",
price: 29.9,
active: true,
image: null,
taxRatePercent: null,
bundleItems: null,
variants: null,
trackInventory: false,
stock: null,
allowBackorder: false,
...overrides,
});
describe("describeBundleContents", () => {
it("returns null for a regular (non-bundle) product", () => {
expect(describeBundleContents(product())).toBeNull();
});
it("returns null for an empty bundleItems array", () => {
expect(describeBundleContents(product({ bundleItems: [] }))).toBeNull();
});
it("formats a single bundle item as 'qty× name'", () => {
const result = describeBundleContents(
product({ bundleItems: [{ product: { id: 2, name: "ToDo-Karten" }, quantity: 2 }] }),
);
expect(result).toBe("2× ToDo-Karten");
});
it("joins multiple bundle items with a comma", () => {
const result = describeBundleContents(
product({
bundleItems: [
{ product: { id: 2, name: "ToDo-Karten" }, quantity: 2 },
{ product: { id: 3, name: "Wochenplaner" }, quantity: 1 },
],
}),
);
expect(result).toBe("2× ToDo-Karten, 1× Wochenplaner");
});
it("skips a line whose product didn't resolve to an object (depth miss)", () => {
const result = describeBundleContents(
product({
bundleItems: [
{ product: 5, quantity: 1 },
{ product: { id: 3, name: "Wochenplaner" }, quantity: 1 },
],
}),
);
expect(result).toBe("1× Wochenplaner");
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { computeSubtotal, computeCartTotals, type CartLine } from "../cartTotals";
import type { Product } from "../payload";
const product = (overrides: Partial<Product> = {}): Product => ({
id: "todo-karten",
name: "ToDo-Karten",
description: "",
price: 12.9,
compareAtPrice: null,
image: "",
href: null,
active: true,
updatedAt: new Date().toISOString(),
spotlight: false,
spotlightEyebrow: null,
spotlightHeadline: null,
spotlightText: null,
spotlightImage: null,
variants: [],
outOfStock: false,
lowStock: false,
maxQty: null,
taxRatePercent: null,
...overrides,
});
const line = (qty: number, productOverrides: Partial<Product> = {}): CartLine => ({ entry: { qty }, product: product(productOverrides) });
describe("computeSubtotal", () => {
it("sums quantity × price across lines", () => {
expect(computeSubtotal([line(2, { price: 10 }), line(1, { price: 5 })])).toBe(25);
});
it("returns 0 for an empty cart", () => {
expect(computeSubtotal([])).toBe(0);
});
});
describe("computeCartTotals", () => {
it("adds shipping on top of the subtotal with no discount", () => {
const totals = computeCartTotals([line(1, { price: 20 })], 2.9, null);
expect(totals.subtotal).toBe(20);
expect(totals.total).toBeCloseTo(22.9, 6);
expect(totals.discountAmount).toBe(0);
});
it("applies a percent discount before adding shipping", () => {
const totals = computeCartTotals([line(1, { price: 100 })], 5, { type: "percent", value: 10 });
expect(totals.discountAmount).toBe(10);
expect(totals.total).toBe(95); // 100 - 10 + 5
});
it("applies a fixed discount, clamped so the total never goes negative", () => {
const totals = computeCartTotals([line(1, { price: 5 })], 0, { type: "fixed", value: 50 });
expect(totals.discountAmount).toBe(5); // clamped to subtotal
expect(totals.total).toBe(0);
});
it("computes totalSavings from compareAtPrice, separately from the discount code", () => {
const totals = computeCartTotals([line(2, { price: 10, compareAtPrice: 15 })], 0, null);
expect(totals.totalSavings).toBe(10); // 2 × (15 - 10)
expect(totals.subtotal).toBe(20); // uses price, not compareAtPrice
});
it("ignores compareAtPrice when it isn't actually higher than price", () => {
const totals = computeCartTotals([line(1, { price: 10, compareAtPrice: 10 })], 0, null);
expect(totals.totalSavings).toBe(0);
});
});

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