298 Commits

Author SHA1 Message Date
Marco 33f3adb92c Document /r and /sticker short-link redirects in README
Neither route was mentioned anywhere despite predating this session (/r)
or being added this session (/sticker) — every other route/collection
this size gets its own section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2026-08-28 13:21:48 +00:00
Marco 31e4f907f2 Make Redirects.urlPrefix actually gate which route a code resolves under
resolveAndTrackRedirect() now filters on urlPrefix in addition to code,
with each route.ts passing its own literal prefix — previously the field
was admin-display-only, so a code marked "/sticker" in Payload silently
kept resolving under /r/<code> too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2026-08-28 13:15:41 +00:00
Marco d02707a212 Remove gap between "Bis Sonntag" and "Björn" on newsletter-confirmed
Line break only, not a full gap-4 paragraph gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2026-08-28 12:58:13 +00:00
Marco e9b7a2f582 Add /sticker/[code] legacy QR redirect route + placeholder /sticker page
Mirrors app/r/[code]/route.ts's Payload Redirects lookup under the fixed
/sticker prefix already printed on existing sticker QR codes, so those
stay admin-editable/toggleable in the same collection instead of needing
a code-level static redirect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2026-08-28 12:58:10 +00:00
Marco bd1b73e304 Align newsletter-confirmed page visually with transactional page family
Adds the two-tier headline and yellow checkmark list already used on
bestellbestaetigung/PageBlocks, and matches the CTA button sizing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2026-08-28 12:53:51 +00:00
Marco 98b3b3b6da Make product card row alignment fully dynamic via CSS Grid subgrid
Replaces the fixed line-clamp-2 subline cap with real subgrid alignment:
each grid (ProductGrid/RelatedProducts/MerklisteGrid) declares 6 explicit
row-tracks (image/header/price/belowPrice/lowstock/button, last one
minmax(0,1fr)), and every card spans those same 6 tracks via
grid-template-rows: subgrid — so row heights are genuinely shared across
a row of cards. A subline can now wrap to any number of lines (3, 4, ...)
without being truncated, and the price row still starts at the same Y on
every card in that row.

ProductCard.tsx is flattened from nested flex divs into 6 direct grid-row
children so each one can be its own subgrid track; the old flex-1 spacer
is replaced by self-end on the button's row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 23:49:50 +00:00
Marco 8f006b6603 Keep product card prices aligned regardless of subline/name length
The previous equal-height fix (items-stretch) made cards match overall
height, but a subline wrapping to 2 lines still pushed that card's own
price row down relative to its row siblings — the flex-1 spacer only
re-aligns the button at the bottom, not the price above it. Name now
clamps to 1 line, subline always reserves a clamped 2-line slot
(rendered even when empty) — every card's price row starts at the same Y
regardless of title length or whether a subline is set at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 23:37:58 +00:00
Marco 0791e7c8bc Fix shop/related/wishlist grids not equalizing card heights
ProductCard already has a h-full + flex-1 spacer specifically to keep
every card in a row the same height regardless of content (title wrap,
subline wrap, low-stock line) — but all three grids using it set
items-start on the grid container, which overrides the default stretch
and silently defeats that. A product whose subline wraps to 2 lines made
its card taller than its row siblings instead of them matching. Dropping
items-start (default items-stretch) fixes all three call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 23:34:25 +00:00
Marco e882b8b6ac Match block-driven PDP hero/pricing panel to hand-coded siblings visually
Reported: einfach-anfangen's block-built PDP looked visually far off from
todo-cards. Root cause: ProductBlocks.tsx wrapped every block (including
the hero) in one generic padded div, so the hero image lost its
edge-to-edge desktop bleed/hover-scale and neither section had its own
Reveal scroll-in animation, unlike every hand-coded PDP. ProductHero and
ProductPricingPanel now render their own full <section> matching
todo-cards' Hero.tsx/Pricing.tsx exactly (own Reveal, own grid, own
padding) instead of relying on the shared per-block wrapper, which now
only applies to genuinely generic content blocks.

Also wires up the testimonialsRef block for Products.layout (added to the
backend in commit 82583a1) — same async-fetch handling as PageBlocks.tsx,
plus the 'einfach-anfangen' TestimonialsPage value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 23:24:14 +00:00
Marco 1ab4088bf0 Wire up Products.layout (PDP page builder) frontend
New ProductBlocks.tsx renders a product's layout — delegates to
PageBlocks.tsx's renderPageBlockSync for the 9 block types shared with
Pages.layout, handles productHero/productPricingPanel itself (need live
product/shipping/tax context a generic content page doesn't have).

Converts /einfach-anfangen to render through this instead of its own
Hero/HowItWorks/Focus/Pricing components (now deleted) — the first PDP
built as CMS blocks end to end. der-eine/todo-cards/tasse-die-pause are
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 23:12:26 +00:00
Marco a2d4cb29fc Fix Page Builder's QuoteBlock label being silently ignored
app/components/Quote.tsx (the shared component PageBlocks.tsx/Pages.layout
uses) never had a `label` prop at all — dropped when it was extracted from
RichText.tsx's own local Quote. QuoteBlock.label was set correctly in the
admin and mapped through payload.ts, but PageBlocks.tsx's "quote" case
never passed it through, and even if it had, this component had nowhere
to put it. Both fixed; matches RichText.tsx's existing label treatment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:50:11 +00:00
Marco 1d7f49217c Add PDP for Einfach anfangen. (todo-starter)
New product page at /einfach-anfangen — Hero (breadcrumb/price/CTA),
"Eine Karte. Ein Tag." 3-step section (same icon set as todo-cards'
HowItWorks), a spec bullet list, and a closing pricing panel. Follows the
same hand-coded structure as der-eine/todo-cards (bespoke Hero prose, not
read from Products.description). detailHref updated in Payload to point
here instead of the unbuilt /todo-starter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:48:09 +00:00
Marco 8b9e2bdb42 PDP breadcrumbs: read the real product name instead of hardcoded copy
der-eine's breadcrumb hardcoded "Der Eine" — missing the trailing period
its own headline/pricing card already style in brand color. Switched all
three PDP breadcrumbs to the actual product.name via ProductName so they
can't drift from the real title again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:42:43 +00:00
Marco a1b0dffff7 Render the 6 new blog-post blocks (StepRow/Icon/PillList/ChecklistImage/Table/CtaCard)
Follows Posts.content's BlocksFeature update (docker/payload commit
5426a02) — mirrors PageBlocks.tsx's own JSX for each block field-for-field
since it's the same Block config reused a second time. Works in both the
server-rendered page and the client-side Live Preview renderer (shared
converters), unlike testimonialsRef which needs an async fetch and stays
page-builder only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:40:12 +00:00
Marco 1330e3e621 Show product subline on cards/cart/PDP hero; brand-color trailing period
Follows Products.subline (docker/payload commit ad7156e). Cards and cart
line items showed nothing but the bare name — added the subline under it.
der-eine and todo-cards' hero taglines were hardcoded copy nearly
identical to this new field — switched both to read subline instead, so
future edits go through the CMS.

Also adds a shared ProductName component: every hand-written PDP headline
already styled a trailing "." in brand color, but the few places that
render product.name as plain text (cards, cart, the Payload-driven
tasse-die-pause hero) had silently lost that styling. Centralizes it so
it can't drift per call site again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:26:14 +00:00
Marco 21b5f41127 Enlarge Hero block portrait (80px -> 128px)
/ueber-mich's about-author.jpg read as barely-there next to the full
headline at size-20 — bumped so it actually registers as a real photo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:04:32 +00:00
Marco 7489f83564 Follow Products.description consolidation to richText-only
Backend dropped the plain-text description field in favor of a single
richText one (see docker/payload commit 1ba8d07). Renders formatted
(bold/italic/multiple paragraphs) via the shared RichText component on
the PDP (der-eine, tasse-die-pause); everywhere else (Passend-dazu cards,
Product JSON-LD, homepage spotlight fallback) derives plain text at read
time via a new extractPlainText() helper instead of a second field.
Removes the description line from cart line items entirely — it only
bloated the cart with no real benefit there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:04:28 +00:00
Marco 82696cb259 Homepage blog teaser: make the whole card clickable, not just "Zum Beitrag"
The cards already had a hover-lift effect implying the whole thing
was clickable, but only the small arrow line was an actual link —
image and title had pointer-events-none/no href at all. The full
/blog listing page already wraps its own cards in one Link; this
brings the homepage teaser (Blog.tsx) in line with it. The former
inner CTA Link is now a plain span using group-hover for its color
change, since one <a> can't nest another.
2026-08-26 21:26:46 +00:00
Marco 0fea84f362 Fix homepage spotlight teaser dropping italic formatting
SpotlightText's minimal Lexical converter only checked format bit 1
(bold) — bit 2 (italic) was silently ignored, so Tasse "Die Pause"'s
italicized quote ("Nur noch schnell … Nein.") rendered as plain text
on the homepage despite showing correctly in the admin's richText
editor.
2026-08-26 21:24:26 +00:00
Marco 56fae6ff13 Product JSON-LD: add sku + gallery images, single-source the meta description
sku was tracked in Payload but never exposed to the frontend at all —
added to the Product type and wired into buildProductSchema (a real
Schema.org property Google's rich-result validator checks for).
image is now an array (main + gallery) when a product has gallery
photos, instead of always just the one main image.

/der-eine's page metadata description was a separately hardcoded
string that had drifted from Products.description (the one JSON-LD/
cart/checkout actually use) — now reads from the live product via
generateMetadata, same pattern /tasse-die-pause already uses. Updated
Payload's own description field to the more product-descriptive text
that used to live only in that hardcoded string.
2026-08-26 21:20:44 +00:00
Marco 39ebf2604d Hero block: make subline more prominent (was too muted) 2026-08-26 20:58:47 +00:00
Marco 2280a4a4ba Cut /ueber-mich over to the page builder
Content recreated as an 11-block Pages document — first real use of
HeroBlock's optional portrait image (about-author.jpg, uploaded to
Media fresh) alongside the headline. Checklist has no image (uses
ChecklistImageBlock's now-optional image field). Verified via a
temporary alternate slug before switching back and removing the
static file.
2026-08-26 20:51:50 +00:00
Marco b0d76c891f Frontend support for Hero block's optional portrait image 2026-08-26 20:50:25 +00:00
Marco 7fc7ee2c3a Cut /3x3-system over to the page builder
Content recreated as a 17-block Pages document (hero, prose sections,
the 2 key-line quotes, the pill list, the category step row, the
matrix example image — uploaded to Media fresh since the closest
existing media doc turned out to be a different file — and the
closing CTA card). Verified via a temporary alternate slug against
the live catch-all route (including the inline bold "Klarheit."
formatting) before switching back and removing the static file.
2026-08-26 20:45:08 +00:00
Marco 098ba79c8f Frontend support for the Hero block; H1 moves out of the hardcoded wrapper
app/[slug]/page.tsx and LivePageContent.tsx no longer render a
hardcoded H1 from page.title — that's now the Hero block's job (see
HeroBlock.ts's own comment on why). Breadcrumb stays, still built
from page.title.

Also backfilled /lebensuhr's already-migrated Pages document with a
Hero block (order 0) carrying its real headline — without it the
page would have lost its H1 entirely once the hardcoded wrapper was
removed.
2026-08-26 20:41:21 +00:00
Marco f4af0bc15c Cut /lebensuhr over to the page builder
Content recreated as a Pages document (11 blocks: intro prose, the
symbolic clock icon, the "24h" prose, the Alter/Uhrzeit table, more
prose, the 4-phase step row, the "Zinseszins" quote, more prose, the
3-question checklist, closing prose, the CTA card) — verified via a
temporary alternate slug against the live catch-all route before
switching it back to `lebensuhr` and removing the static file.

The static app/lebensuhr/page.tsx is now gone; /lebensuhr resolves
through app/[slug]/page.tsx.
2026-08-26 20:29:47 +00:00
Marco 2d9508324a Frontend support for stepRow subtitle + new icon block
Mirrors the backend schema additions (StepRowBlock.items.subtitle,
new IconBlock) needed for the /lebensuhr Pages migration: PageBlock
type + mapPayloadPageBlock in payload.ts, a "icon" case in
PageBlocks.tsx, and SYMBOLIC_ICONS (the clock face) added alongside
STEP_ICONS in StepIcons.tsx — a distinct registry since these icons
are standalone, not part of an icon-above-text row.
2026-08-26 20:26:46 +00:00
Marco 885389b300 Add the page-builder frontend: /[slug] catch-all + block renderer
Pages/getPageBySlug/mapPayloadPage in payload.ts follow the exact
getPostBySlug/mapPayloadPost pattern. PageBlocks.tsx renders a Pages
doc's `layout` field, reusing existing components (RichText,
StepArrow, STEP_ICONS, TestimonialsGrid) rather than reinventing per-
block styling — matches what /lebensuhr, /3x3-system, and
/7-tage-klarheits-check already hand-built. LivePageContent.tsx
mirrors LivePostContent.tsx's live-preview pattern, using a synchronous
subset of the block renderer (testimonialsRef needs an async fetch a
client component can't perform inline, so it's skipped in preview
only — same "editable subset" scope-cut LivePostContent.tsx already
makes). buildWebPageSchema in structuredData.ts is the generic
JSON-LD fallback for content types with no bespoke schema (Article/
Product don't fit a page-builder page).

Verified end-to-end: inserted a real Pages test document (SQL, since
no admin auth available here) covering richTextSection/quote/ctaCard,
confirmed /[slug] renders it correctly, confirmed notFound() after
deleting it, then cleaned up the test row.
2026-08-26 20:08:24 +00:00
Marco 940545888c Consolidate per-page icon duplicates into a shared registry
/lebensuhr, /3x3-system, and /7-tage-klarheits-check each defined
their own local copies of the same hand-drawn brand-line icons.
Moved them into app/components/icons/StepIcons.tsx as a keyed
registry (STEP_ICONS) — also what the new Payload Pages collection's
StepRowBlock select field maps onto by key, so a page-builder step
row can reference these same icons without duplicating SVG paths a
third time.
2026-08-26 20:03:57 +00:00
Marco e2d81fabc7 Add the trailing period to "einfach produktiv." wherever it was missing
The period is part of the brand name (see Footer.tsx's own comment on
the two-tone logo treatment) — fixed every place it was cleanly
appendable (string/heading endings, or before a dash, matching the
existing precedent in VertragspartnerBlock.tsx). Left the handful of
genuinely awkward mid-sentence spots alone (separable-verb endings,
"einfach produktiv-Konto" hyphenated compounds) rather than force a
period that breaks the sentence — flagging those separately.

Also: Über-mich's checklist now uses the same brand checkmark as
/lebensuhr and /7-tage-klarheits-check instead of plain bullets.
2026-08-26 19:25:27 +00:00
Marco a1c9a2127b Navbar: mark Werkzeuge active on /lebensuhr and /3x3-system 2026-08-26 19:06:06 +00:00
Marco c2d0b6e364 Rename /ueber-bjoern to /ueber-mich, link it in the nav
Nav's "Über Björn" entry pointed to the homepage's #ueber-bjoern
teaser section, not the new bio page — updated the label to "Über
mich" and the href to the real /ueber-mich page. The homepage's own
#ueber-bjoern anchor id (About.tsx) is untouched, it's a separate
section.
2026-08-26 18:55:49 +00:00
Marco 349bea5261 Add /ueber-bjoern bio page
Condensed from einfach-produktiv.com/ueber-bjoern/ — trimmed,
emoji-free, rewritten in the site's own voice rather than the
original's. Same hand-styled article treatment (H2/Quote/CTA-card)
as /lebensuhr and /3x3-system since it's not CMS-backed either.
Reuses the existing about-author.jpg portrait already used on blog
posts.
2026-08-26 18:53:56 +00:00
Marco 85478ff746 Lebensuhr: move the symbolic clock icon under its own heading, not the H1 2026-08-26 16:25:10 +00:00
Marco cf5d7f8739 3x3-system: pull the category row back into the article column
The full-width bg-bg-muted band made the three categories read as
a disconnected banner, separate from the paragraph right above it
introducing them. Now inline in the same 48rem column as the rest
of the article — still icon-above-text with StepArrow connectors,
just not broken out into its own section anymore.
2026-08-26 16:24:13 +00:00
Marco b26a44b979 Lebensuhr: add a purely symbolic clock icon above the headline
No numbers, no age/time data — just a clock silhouette (circle,
ticks, hands at 10:10). Every previous clock attempt tried to carry
actual data and kept going wrong; this one carries nothing, so
there's nothing to get wrong.
2026-08-26 16:23:26 +00:00
Marco 0fedb386b4 Remove the lebensuhr clock-face SVG
Went through several rounds (dot timeline, 24h dial, 12h dial,
decluttering) without landing right, and there's no way for me to
visually verify an SVG in this environment — kept guessing wrong.
Cutting it rather than continuing to iterate blind. The table stays
as the clear, working data source.
2026-08-26 16:17:58 +00:00
Marco 7b9e833560 Cut 3x3-system's prose volume, add a pill-list and a third Quote
Too much straight paragraph text in a row. Trimmed nearly every
section by merging/shortening paragraphs, turned the "everything
competes for priority" list into a tag/pill row instead of prose,
and promoted the closing "Zufall/du entscheidest" line into a Quote
callout (matching the two others already on the page) instead of a
plain paragraph.
2026-08-26 16:07:10 +00:00
Marco 3be8682e1a Simplify: strip clock clutter, drop 3x3-system's decorative dot-grid icon
Lebensuhr clock: each of the 8 points had 3 stacked text lines (age,
time, vorm./nachm.) plus a leader line — too much text crammed
around a small dial. Down to just the age number per point; the
matching time is one glance away in the table.

3x3-system: removed the IconGrid3x3 decorative mark per explicit
feedback that it added nothing.
2026-08-26 16:05:49 +00:00
Marco 5e48b67588 3x3-system: match icon-row/StepArrow pattern used site-wide
Category cards were a standalone 3-card grid with red/amber/green
accents (matching the original blog post) — out of step with how
every other icon-row on the site looks. Now the same icon-above-
text + StepArrow layout as /7-tage-klarheits-check's "So
funktioniert" and /lebensuhr's phase row, and the icons are a single
brand-orange line-icon style instead of per-category colors.
2026-08-26 15:55:17 +00:00
Marco 4326072fbe Lebensuhr clock: real 12-hour dial instead of a nonexistent 24-hour one
A clock face has 12 numbers, not 24 — the previous version invented
a 0-24 dial that doesn't exist on any real clock. Now a proper 1-12
face, with each age plotted at its 12h-mod position, labeled with
time + vorm./nachm. (every dial position is hit twice a day), and a
filled dot for vormittags vs. a hollow ring for nachmittags so the
two laps stay visually distinct.
2026-08-26 15:54:09 +00:00
Marco f0f5252d10 Lebensuhr clock: bigger, unclipped, every hour tick labeled
Sized the SVG up (viewBox 250->420) after labels clipped at the
edge, and gave all 12 ticks a real 24h-scale number (0,2,4…22, full
"HH:00" at the 4 cardinal points) instead of just 00/06/12/18 — with
only 4 numbers shown at those same 4 positions, the dial read like
an ordinary 12-hour clock face (implying two laps/day) rather than
the single 24h lap it actually represents.
2026-08-26 15:48:58 +00:00
Marco 0de2e1c1be 3x3-system: match /lebensuhr's structural style more closely
Category cards now break out into a full-width bg-bg-muted band
(same structural role as /lebensuhr's phase-row section), and the
page gets its own small decorative identity graphic — a literal 3x3
dot grid, paralleling that page's clock face.
2026-08-26 15:45:53 +00:00
Marco 76d59a5682 Lebensuhr clock: show the matching time under each age, plus 00/06/12/18 ring markers
The clock face only had age numbers — no way to tell which time on
the dial each one corresponded to. Added the matching time as a
second line under each age, and hour markers at 00/06/12/18 on the
ring itself so the dial reads as an actual 24h clock, not just an
abstract circle.
2026-08-26 15:44:24 +00:00
Marco af5956848b Lebensuhr: add age range to each life phase card
The four phases (Morgen/Vormittag/Nachmittag/Abend) had no explicit
tie back to age — readers had to infer it themselves from the clock
metaphor. Added an approximate age range per phase, using the same
age/90 = time/24 scale as the table (Vormittag ends exactly at 12:00
= age 45, which conveniently lines up with "Mittag").
2026-08-26 15:37:58 +00:00
Marco cdc779aebe 3x3-system: card layout for categories, matrix image, CTA card; lebensuhr fixes
/3x3-system:
- Categories now a 3-card grid (icon + colored top border) instead of
  a plain bulleted list.
- Embeds the original blog post's Arbeit/Familie/Persönlich matrix
  graphic, framed like the challenge page's image, with a caption
  clarifying it's one example application (the surrounding text only
  ever describes 3 categories, not a 3x3 grid).
- Closing CTA is now the same bordered-card link /lebensuhr uses.
- Tightened remaining prose.

/lebensuhr:
- Fixed missing side padding below 640px (the table card's
  full-bleed negative-margin trick ate the page's own padding).
- Added a small decorative clock-face SVG above the table (8 ages
  plotted at their proportional position) — company piece for the
  table, which stays the actual data source.
2026-08-26 15:36:47 +00:00
Marco 9af970587d Lebensuhr: revert dot-timeline back to a real, styled table
The dot-on-a-line version dropped the explicit "Alter"/"Uhrzeit"
column labels, making it ambiguous which value was which, and
forced a horizontal scroll on mobile for no real gain. Back to an
actual <table> with headers, just dressed up: brand-underlined
header row, zebra striping, bg-bg-muted card frame.
2026-08-26 15:29:50 +00:00
Marco fff2467d19 Lebensuhr: replace plain data table with a clock-scale timeline
A horizontal dotted line with each age/time pair on it reads as an
actual clock scale instead of a bare two-column table needing a
caption to explain itself. Same bg-bg-muted rounded-card treatment
already used by the AGB sidebar and the blog author box.
2026-08-26 15:26:36 +00:00
Marco e9a793db08 Lebensuhr: reuse morning icon for evening (rays stay above the sun) 2026-08-26 15:25:08 +00:00
Marco cfa658a210 Restyle /lebensuhr with shared step/checklist/CTA-card patterns
Reuses /7-tage-klarheits-check's StepArrow icon-row for the four life
phases (was plain H3 blocks), the checkmark-list pattern for the
closing three questions, and the blog's bordered "Passend dazu"
card treatment for the closing CTA. Also tightened the prose —
cut repeated points and filler transitions.
2026-08-26 15:22:14 +00:00
Marco 2dee2e7871 Add /3x3-system article page
Adapted from the einfach-produktiv.com blog post — condensed and
tightened, same hand-styled article treatment as /lebensuhr since
this isn't CMS-backed content either. CTA links to
/7-tage-klarheits-check, matching that page's own closing link.
2026-08-26 15:17:08 +00:00
Marco 790762979e Add /lebensuhr article page
Content page about the "life clock" concept, ending with an internal
link to /7-tage-klarheits-check. Styled by hand (headings/quote match
RichText.tsx's converter output) since it's not CMS-backed content.
2026-08-26 15:11:45 +00:00
Marco c64902c381 Rename /lebensuhr to /7-tage-klarheits-check, update CTA/content copy
Renames the route, its canonical/OG metadata, Navbar's active-route
list, and the testimonials page filter to match. Also updates CTA
copy ("Klarheits-Check starten"), the "So funktioniert" heading, the
"3. Umsetzen" step text, and the breadcrumb label.
2026-08-26 15:04:03 +00:00
Marco bf48a1561c Home Blog featured post: stack image-then-text through 1024px, not 640px
Side-by-side kicked in at sm (640px), squeezing the text column hard on
tablet widths. Moved the breakpoint to lg (1024px), matching the
secondary posts' grid below, which already used lg for the same reason.
2026-08-25 20:35:53 +00:00
Marco 168f17bcef Home Blog featured post: taller card on lg+ via min-h, image follows
The card's height was purely driven by the (short, fixed) text column's
content regardless of viewport width. lg:min-h-[26rem] gives it real
height on large screens; the image's sm:h-full and the text column's
justify-between both stretch to match.
2026-08-25 20:29:24 +00:00
Marco 7e00d51b89 Revert "Home Blog section: cap width at max-w-[75rem], same as the rest of the site"
This reverts commit 233a41f7c9.
2026-08-25 20:23:15 +00:00
Marco 233a41f7c9 Home Blog section: cap width at max-w-[75rem], same as the rest of the site
Was full-bleed w-full with no cap, so on very wide screens the featured
card (and its now-h-full image column) kept stretching wider while the
image's height stayed pinned to the text column's content height —
increasingly squashed the wider the viewport got.
2026-08-25 20:20:18 +00:00
Marco 24ae330037 Home Blog featured post: image stretches to full row height, not a fixed 220px
The 58%-wide image column at a hardcoded 220px tall produced a badly
elongated crop on wide viewports. sm:h-full lets the grid's natural
row-stretch (driven by the text column) size it instead, matching how
/blog's own featured card already does it.
2026-08-25 20:14:32 +00:00
Marco 821fbb0207 Blog: drop hero photo, plain text header like /shop's ShopHeader
Same breadcrumb/title/description block as every other page, no more
bespoke photo-bleed layout. Featured post card goes back to sitting
flush below the header (no more negative-margin overlap trick, which
only made sense with the photo's bottom edge to overlap).
2026-08-25 19:36:49 +00:00
Marco 194117c07d Der Eine.: add Testimonials + closing Pricing CTA, reorder PasstDazu last
Testimonials now pulled from Payload (page: "der-eine", same collection
as todo-cards/newsletter/challenge) instead of a hardcoded array. Pricing
adds a second, final buy CTA (same image+price+button card as the other
product pages' Pricing.tsx) for visitors who scroll past Hero's button.
PasstDazu moves to right before the Footer.
2026-08-25 19:28:47 +00:00
Marco d2332c37e9 Der Eine. Focus.tsx: left-align body text below md instead of justify 2026-08-25 18:58:09 +00:00
Marco d2255c47f2 Tasse "Die Pause": pass productId into AddToCartButton
Both buy buttons were missing productId, only passing numericId.
2026-08-25 18:42:27 +00:00
Marco 2b39ec757e Der Eine.: unboxed bold-outline icons, mobile gallery padding + justified text
Focus.tsx icons now match the site's established icon-trust-*/icon-step-*
look (bold single-stroke outline, no circle badge) instead of the earlier
thin-stroke circled version. Hero.tsx's gallery now gets the same
horizontal page padding as the text column below lg:, and Focus.tsx's
body copy switches from centered to justified below md: to avoid ragged
centered multi-line text on small screens.
2026-08-25 18:42:24 +00:00
Marco 1f537ccb06 PasstDazu driven by Products.relatedProduct; circle back on Focus icons
- PasstDazu.tsx now takes a productSlug prop and reads its cross-sell
  target from the product's own relatedProduct field (Payload) instead
  of hardcoding "todo-karten" — reusable on any product page now.
- payload.ts: Product/PayloadProduct gained relatedProduct, resolved via
  a bounded-recursion mapPayloadProduct call (safe — the fetch's depth:2
  means the nested relation's own relatedProduct is never populated).
- Focus.tsx icons: added the black stroked circle back around each glyph
  (matches icon-step-3.png exactly) — dropped in the previous pass by
  mistake while fixing the color/fill.
2026-08-25 13:45:52 +00:00
Marco b59c9a0e4b Focus.tsx icons: match /todo-cards' actual icon style
Bold uniform-weight black outline, no fill, no circle badge — same
language as tool-icon-todo-karten.png / icon-step-2.png / icon-step-3.png,
not the brand-orange circle-badge treatment tried previously.
2026-08-25 13:33:21 +00:00
Marco 6debcd5a46 Der Eine.: drop "Warum" section, widen + restyle Focus icons
- Removed the brand-story "Warum Der Eine.?" section entirely — for a
  6,90€ accessory item its one unique fact (the two-sided engraving) was
  already covered by Hero/Focus, the rest was pure narrative that didn't
  aid the buying decision.
- Focus.tsx ("Was kann er?") widened to max-w-[75rem] (the site's
  standard content width, same as Tools.tsx/HowItWorks.tsx) instead of
  the narrower inset borrowed from HowItWorks' 3-item version.
- Icons switched from plain black line icons to a circle-badge + brand
  orange (#f6a701) stroke treatment — matching the icon language already
  used elsewhere on this page (IconCheck) and site (HowItWorks' own
  stroked-circle step icon), the flat black icons read as a mismatched
  style.
2026-08-25 13:29:07 +00:00
Marco b3d7855b0e Focus.tsx ("Was kann er?"): icons in HowItWorks row style
Same card shape as todo-cards/components/HowItWorks.tsx (icon on top,
centered, RevealGroup/RevealItem, identical typography) instead of the
inline-checkmark bullets tried first. Icons drawn from the der-eine.png
mockup's own icon row (pencil/sparkle/shield/pen line icons — no matching
/public asset existed for these concepts).
2026-08-25 13:21:55 +00:00
Marco ba6e486e14 Revert Der Eine. mockup redesign, keep only 2 targeted changes
The full mockup-driven redesign (Banner/Lifestyle sections, restyled
Focus/Why/Pricing) was more than asked for — reverted back to the
pre-redesign version (Hero/Focus/Why exactly match commit 13fb37e), with
only the two changes actually requested applied on top:

- Focus.tsx ("Was kann er?"): dropped the product photo, plain text
  section now — a second product shot here was redundant with Hero's own.
- Pricing.tsx (bottom price/buy bar) replaced by PasstDazu.tsx, a
  "Passt dazu" cross-sell card linking to ToDo-Karten — same markup as
  blog/[slug]/page.tsx's own related-product card, hardcoded to
  "todo-karten" the same way Hero.tsx hardcodes "stift-kugelschreiber".

Kept the Payload product.image/gallery repointed at the mockup-derived
photos (ids 88/89) rather than reverting those too — the previous gallery
was mismatched stock photos of a different-colored pen, an independent
correction from the layout redesign.
2026-08-25 13:16:40 +00:00
Marco 71cd75425d Redesign Der Eine. PDP from mockup, reusing existing site patterns
Sections rebuilt to match der-eine.png (hero, tagline banner, brand-story
+ feature icons, lifestyle photo, final CTA bar with trust badges), but
each piece is built from patterns that already exist elsewhere on the
site rather than one-off layouts:
- Focus.tsx (feature icons) mirrors todo-cards/HowItWorks.tsx's exact
  icon-top/title/desc card shape and typography
- Why.tsx (brand story) mirrors todo-cards/Focus.tsx's photo+text layout
- Pricing.tsx's trust-badge row reuses getCartTrustBadges() (real,
  already-vetted copy) with /cart's own markup, not invented text
- Banner.tsx reuses --font-caveat and /icon-separator-right.svg (both
  already used in Divider.tsx) instead of introducing new assets

Photos: product.image/gallery repointed (via direct DB write, see
reference_payload_direct_db_access) to two new Payload media docs
cropped directly from the mockup — the previous gallery was mismatched
supplier stock photos of a different-colored pen. Lifestyle.tsx's
desk photo is a static /public asset (page decoration showing the pen
next to another product, not a photo of the pen alone, so it doesn't
belong in Products.gallery).
2026-08-25 13:10:00 +00:00
Marco 4fa80d6bfb Allow bold formatting in the spotlight product's marketing text
Products.spotlightText switches from a plain textarea to a richText field
(Lexical, same default editor as every other richText field here) so
admins get a formatting toolbar. Rendered via a small dedicated inline
converter (bold + paragraphs only) rather than the full block-oriented
RichText.tsx, which is styled for article-length content (blog posts,
legal pages) and would add heading/paragraph spacing this teaser doesn't
want.
2026-08-25 12:50:00 +00:00
Marco eb160cac61 Preserve paragraph breaks in the spotlight product's text
spotlightText is a plain textarea, and default HTML whitespace handling
collapsed admin-entered paragraph breaks into one run-on line.
2026-08-25 12:36:07 +00:00
Marco 13fb37eb5e Rename Der Alltagsstift back to Der Eine., move route to /der-eine
New copy throughout (hero, "Was kann er?", the "Warum Der Eine.?" brand-story
section replacing the old phone-vs-pen scenario grid, final CTA) plus the
Payload product's name/detailHref updated to match (done directly via psql,
no Payload admin API key available — see reference_payload_direct_db_access).
2026-08-25 12:36:03 +00:00
Marco 988f259371 Add internal redirect short links (/r/<code>)
A static short link — e.g. printed on a QR code — that resolves to a
Payload-editable internal target path, so the link itself never needs
reprinting when the underlying content moves. Tracks a click count.
2026-08-25 12:17:08 +00:00
Marco a085a75dea Allow blog meta row (category/readtime/date) to wrap on narrow cards
Long category names no longer force horizontal overflow — the row
wraps to a second line instead, with each segment kept from breaking
mid-word via whitespace-nowrap.
2026-08-24 22:53:12 +00:00
Marco b6e76ccd22 Add bespoke product detail pages for Der Alltagsstift and the mug
/der-alltagsstift mirrors the todo-cards pattern (Hero/HowItWorks/
Focus/Pricing) — renamed from "Der Eine" for brand-tonality reasons,
copy grounded in visually-verified product facts only.

/tasse-die-pause is a deliberately minimal Hero+Pricing scaffold —
naming/design concept for that product is still undecided, so the
headline reads product.name live from Payload instead of a hardcoded
tagline.
2026-08-24 22:52:41 +00:00
Marco c065a3223f Link product images to detail pages, not just titles
Image sits as a sibling to the badge/WishlistButton overlays, not
wrapping them — nested interactive elements would be invalid HTML and
would fire navigation on a wishlist click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 08:19:42 +00:00
Marco 7f95711048 Link cart line-item titles/images to product detail pages
Cart line items had their own separate markup from ProductCard.tsx and
never got the title-link treatment — same product.href check, only
rendered as a link when the product actually has a detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 08:13:01 +00:00
Marco e7ad9cd88d Document ProductCard consolidation, swipe gallery, dynamic legal-page sourcing, shipping-methods, NotifyMeForm redesign
README had fallen behind several recent changes: the shared ProductCard
component (was still describing three independently-duplicated grids),
ProductGallery's touch-swipe support, legal-pages' contentPart2 field
and company-settings-sourced name/address/email, shipping-methods as
the real cost/threshold source (lib/shipping.ts's old hardcoded
constants were deleted), and NotifyMeForm's single-row redesign.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 08:07:51 +00:00
Marco 340dcd2c94 Fix notify-form input height to actually match the cart button
py-3 alone didn't match AddToCartInlineButton's real height — that
button's tallest child is its 1.875rem cart-icon image, not its text,
so an input with the same padding but only text content still rendered
shorter. Explicit h-14 matches the button's actual 56px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 07:23:34 +00:00
Marco 70bc839f48 Restore reserved low-stock line, clearer notify-me placeholder text
NotifyMeForm's single-row redesign matched AddToCartInlineButton's
button height exactly, which made the sold-out card's earlier
low-stock-line omission overcorrect — it ended up shorter than its
siblings instead of taller. Keeping that line unconditional (like every
other card) is what actually lines them up. Also swapped the generic
"E-Mail-Adresse" placeholder for one that states the purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 07:17:14 +00:00
Marco 82c1b1fd43 Match sold-out card height to in-stock siblings
NotifyMeForm was two stacked rows (email input + full-width button),
making an out-of-stock ProductCard taller than its in-stock siblings.
Redesigned as a single input with the submit control embedded inside it
(same py-3 as AddToCartInlineButton's own button, so the row height
matches exactly), and ProductCard now skips its reserved-height
low-stock line entirely for a fully-out-of-stock product, since that
line can never apply there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 07:14:49 +00:00
Marco c8f231baa6 Dynamic AGB address, real shipping costs on /versand instead of hardcoded constants
- AGB: name/address/email now come from company-settings via new
  VertragspartnerBlock (mid-document, see LegalPages.ts's contentPart2).
- VersandSections/VersandModal/CartContent/CheckoutContent/versand page:
  shipping cost and free-shipping threshold now come from the real
  ShippingMethods data (already fetched elsewhere for checkout), not the
  hardcoded lib/shipping.ts constants — those had already drifted from
  the real Payload values once. lib/shipping.ts deleted, nothing left to
  export.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 06:53:12 +00:00
Marco 9617478b0d Add mobile swipe gallery, shared ProductCard, dynamic Datenschutz address, GSC verification
- ProductGallery: touch swipe (left/right) now navigates slides on mobile,
  previously click-only.
- New shared ProductCard component used by ProductGrid/RelatedProducts/
  MerklisteGrid — product title is now the card's link (replaces the
  separate "Mehr erfahren" line), consistent aspect ratio across all
  three grids, more compact cards.
- Datenschutz: name/address/email now come from company-settings via a
  new VerantwortlicherBlock, same single-source pattern as Impressum's
  AnbieterAngaben — no more hand-typed address to keep in sync.
- layout.tsx: render googleSearchConsoleVerification via Next's native
  verification.google metadata field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 05:38:55 +00:00
Marco 23f8efcc2b Fix Klaro backdrop only covering a column, not full viewport
Root cause: .cookie-modal is the full-viewport wrapper, NOT the
dialog box — the previous centering fix wrongly applied
position/transform to it. Adding a transform to that wrapper created
a new containing block for its position:fixed children, so .cm-bg
(the backdrop) started positioning itself relative to the now-
shrunken/centered wrapper instead of the real viewport — the dark
overlay only covered a 640px-wide column (screenshot-confirmed
2026-08-02) instead of the whole screen.

Moved all sizing/position/shadow overrides to .cm-modal.cm-klaro (the
actual dialog box, a sibling of .cm-bg — both children of the
untouched full-screen .cookie-modal). Also fixed several selectors
that were quietly matching nothing: .cn-body doesn't exist in the
settings modal (that's .cm-header/.cm-body), and the footer button
row is .cm-footer-buttons, not .cm-buttons (that class belongs to the
small notice/context-notice components only). Restyled the modal's
close button and structural padding to match, now that the real
selectors are confirmed against klaro's own consent-modal.jsx source.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:20:16 +00:00
Marco 6e6f273524 Match Klaro modal backdrop color to NewsletterModal's own overlay
Klaro's default .cm-bg is plain black at 50% opacity — functionally
correct (standard modal dimming, confirmed intentional, not a bug)
but a different shade from this site's own established modal
backdrop. Now matches NewsletterModal.tsx's
rgba(134,134,134,0.9) exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:15:15 +00:00
Marco 3336ce77c1 Fix Klaro settings modal losing horizontal centering
The unconditional max-width:640px override collided with Klaro's own
breakpoint-dependent centering (margin:auto only applies above an
internal JS-set breakpoint; below it the modal is fixed/width:100%
with no left/margin, so the wider modal rendered flush-left instead
of centered). Explicit left:50%+translate(-50%,-50%) centers on both
axes regardless of which of Klaro's own branches is active.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:12:12 +00:00
Marco 90c52687bf Fix invisible service descriptions, broken empty-translation glitch; redesign modal list
Real bugs, both confirmed via screenshot 2026-08-02:
- dark3 was set to a near-white cream tone assuming background-only
  use, but Klaro's .cm-list-description rule uses it as a TEXT color
  — every service description was almost invisible. Changed to
  --color-text-muted.
- The service.disableAll.description="" override to hide Klaro's
  boilerplate toggle-all text broke its own t() lookup, rendering a
  literal "[missing translation: de/service/disableAll/description]"
  string instead — worse than the original. Reverted, hidden via a
  plain CSS display:none on .cm-toggle-all .cm-list-description
  instead.

Also a real redesign pass on the settings modal's service/purpose
list (wider modal, row separators, bigger/clearer toggle switches,
distinct footer) — was previously only reachable via the notice's own
generic styling, still visibly "default library layout" per Marco's
own read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:09:28 +00:00
Marco 7ef6d2652b Klaro polish: remove redundant toggle-all text, responsive button rows, real per-service descriptions
- Empties Klaro's own "Mit diesem Schalter..." boilerplate under the
  modal's per-service toggle-all switch — redundant next to a plainly
  labeled switch.
- .cn-ok/.cn-buttons/.cm-buttons now explicit flex-wrap rows (side by
  side until they genuinely don't fit, then wrap) instead of relying
  on Klaro's own fragile inline-block flow.
- Every tracking-codes provider gets a real description in the modal's
  service list (was title-only) — what it does and, where publicly
  documented and stable, the actual cookie names/lifetimes (_ga/
  _ga_<container-id> for GA4, _fbp/fr for Facebook Pixel). GTM/Maps
  descriptions are deliberately non-specific about exact cookies —
  neither has a fixed, publicly enumerable list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:04:13 +00:00
Marco ac6c3d6b71 Add GoogleMapsEmbed.tsx — consent-gated Maps embed, prepared but unused
Uses Klaro's own built-in contextual-consent mechanism (not
loadTrackingCode.ts's script-injection path) — the iframe renders
with data-name="google-maps" and its real src; Klaro's DOM scan finds
it after mount and blanks/restores src based on that service's
consent state, showing its own placeholder (styled via
KlaroConsentManager.tsx's KlaroTheme) in the meantime.

No map embedded anywhere in the app yet — this is prep work only, per
explicit request ahead of an actual page needing one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:58:19 +00:00
Marco 417bbd430e Add product image gallery, harden Klaro border reset, remove attribution link
ProductGallery.tsx (main image + thumbnail strip) wired into
TodoKartenHero.tsx, only replacing the static hero photo once a
product actually has extra gallery photos — no visual change
otherwise. Backend field: Products.gallery (see payload repo).

Klaro's hard black border was still showing after the previous
color-only pass — the targeted border:none rule wasn't enough, so
this blanket-resets border/outline on every .klaro descendant and
re-adds only the shadow this theme actually wants. Also disables the
"Realisiert mit Klaro!" attribution link (BSD-3-Clause has no
on-page-attribution requirement, Klaro's own disablePoweredBy flag
covers this cleanly).

Updates the frontend README with the gallery + Klaro fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:41:23 +00:00
Marco f6b001dd11 Restyle Klaro to actually match the brand, not just its accent colors
CSS-var overrides alone (colors, corner position) still left Klaro's
own border/shadow/spacing/typography, which read as an obviously
bolted-on library widget next to this site's hand-designed
components. New KlaroTheme component overrides Klaro's real DOM
classnames directly (confirmed against kiprotect/klaro's own scss
source) — kills the default border, adds this site's own soft-shadow
card look, restyles every button, uses the site's actual serif/sans
font pairing. Also warms up the notice/modal copy.

Updates both READMEs with the tracking-codes/Klaro/back-in-stock/
settings-search/DHL-shipment-label work from this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:20:28 +00:00
Marco a8ab3e4f44 Fix Klaro's remaining default-blue accent (Einstellungen button, modal links)
green1/red1 covered the notice's accept/decline buttons, but blue1/
blue2 (the modal's "Einstellungen" button + in-modal links) were
still Klaro's stock blue — only visible once the modal itself opens,
easy to miss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:12:14 +00:00
Marco 72b7bc629c Restyle Klaro banner to brand + add persistent reopen button
Was full-width ('wide' theme) with Klaro's default green/dark
palette, neither matching the brand. Now: compact bottom-left corner
notice (~380px), brand colors via Klaro's CSS-var overrides
(dark1/light1 etc. — confusingly named, they're background/text, not
a dark-mode switch), neutral gray decline button instead of red.

Also adds a persistent bottom-left cookie icon (KlaroConsentManager.tsx)
so a visitor can reopen the consent manager any time via Klaro.show(),
not just on first visit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:07:26 +00:00
Marco 43ab5f2407 Swap Zahlungsstatus/Zahlungsart order in order detail view
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:41:49 +00:00
Marco 537543bf91 Replace custom cookie banner with Klaro (open-source, self-hosted CMP)
Swaps the hand-rolled CookieBanner.tsx/useConsent.ts/TrackingScripts.tsx
for kiprotect/klaro — brings a real per-service consent list and
bundled German UI translations that a custom implementation would
have had to build from scratch (per user feedback that a proper CMP
is worth it over a purely custom binary accept/reject banner).

klaroConfig.ts builds Klaro's config dynamically from the existing
tracking-codes backend collection (one Klaro "service" per row,
grouped by consentCategory as its purpose). loadTrackingCode.ts is
the actual script-injection side effect, wired in via each service's
`callback(consent)` — same GA4/Facebook-Pixel/GTM/custom loader logic
TrackingScripts.tsx had, just triggered imperatively instead of
declaratively rendered. Brand color applied via Klaro's CSS custom
property overrides (styling.green1 etc.), not custom SCSS.

No @types/klaro package exists — types/klaro.d.ts declares only the
small surface actually used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:33:10 +00:00
Marco 259002f5c0 Add product thumbnail to back-in-stock email, left-align notify button text
renderBackInStockHtml() (and sendBackInStockEmails.ts on the backend)
now show a small product image above the product name, when the
product has one. Also left-aligns NotifyMeForm's button text to match
the email input's own left-aligned placeholder — was centered, read
as inconsistent next to it.

Also adds the klaro dependency, ahead of swapping the custom cookie
banner for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:19:59 +00:00
Marco 87388479de Fix out-of-stock CTA regressions: grid alignment, preview accuracy, label length
- Replace the invisible reserved-space trick with items-start on every
  product grid (ProductGrid/MerklisteGrid/RelatedProducts) — the
  reservation looked worse in practice (visible dead space under
  in-stock cards' buttons) than letting an out-of-stock card simply be
  taller than its siblings.
- New renderBackInStockHtml() in lib/emailTemplates.ts, used by the
  Live Preview instead of the generic order-status renderer — that one
  showed a fake order number and "Bestellung ansehen", neither of
  which apply to a back-in-stock mail (no order exists). CTA is now
  "Zum Produkt", matching the real backend send.
- Shortened NotifyMeForm's button label ("Benachrichtigen") — the
  longer version wrapped to two lines on narrow single-column cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:12:23 +00:00
Marco 70ee518e1d Ship tracking-codes Phase 2: cookie consent banner + gated script injection
CookieBanner.tsx (equally-weighted Akzeptieren/Ablehnen, TTDSG) +
useConsent() cookie hook + TrackingScripts.tsx render active
tracking-codes rows only once their consentCategory is actually
accepted ('necessary' always renders). Wired into layout.tsx via the
new getTrackingCodes() fetcher. This is what makes the Phase 1
backend collection (tracking-codes) actually usable end to end.

Also reworks NotifyMeForm back to always-visible input+button (better
UX than a collapse-to-reveal step) — the resulting taller CTA is now
reserved on every card via NotifyMeFormReservedSpace, an invisible
twin rendered behind the real button, so an in-stock card's row
height matches an out-of-stock sibling's without the grid's
row-stretch pushing buttons out of alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:03:11 +00:00
Marco c6b12e9d60 Fix missing back-in-stock email Live Preview (404)
'back-in-stock' was added to the Payload-side email-templates type
but never wired into this repo's own EmailTemplateType union or the
Live Preview page's VALID_TYPES/fallback-heading maps — every other
type is registered in three places, this one was only in one, so
opening its Live Preview 404'd.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:57:47 +00:00
Marco d046e5c3bb Fix out-of-stock CTA: replace button instead of stacking, collapse notify form
Two problems in the previous back-in-stock commit: the disabled
"Ausverkauft" button and NotifyMeForm stacked, making out-of-stock
cards visibly taller than in-stock siblings — and since ProductGrid's
cards rely on plain CSS Grid row-stretch for equal card height, that
extra height stretched sibling cards and pushed their own buttons
down (screenshot: "In den Warenkorb" CTAs misaligned across a row).

NotifyMeForm now replaces the button slot entirely when out of stock
(matching the height of a normal button when collapsed), and only
expands to the email input after a click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:55:38 +00:00
Marco 2638515683 Fix NotifyMeForm layout: stack input/button instead of side-by-side
Narrow product cards (shop grid, related products) truncated the
email placeholder when input and button shared a row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:52:37 +00:00
Marco 0dcaad8b8c Add back-in-stock notification signup form
NotifyMeForm.tsx replaces the disabled Add-to-cart button's spot once
a product/variant is out of stock — POSTs to the new
/api/stock-notifications route, which forwards to the backend's new
stock-notifications collection. Threaded product.numericId (not the
commerce slug id) into AddToCartButton/AddToCartInlineButton for this,
same split WishlistButton already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:48:00 +00:00
Marco b0df2f13fa Fix shop filter spacing: consistent checkbox gaps, more room below mobile filter bar
CategoryFilter used gap-4 between the category group and the
availability checkbox but gap-2 within the category list itself,
reading as uneven spacing. ProductGrid's <lg filter bar only had
pb-6 before the product grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:01:16 +00:00
Marco 51632b1668 Fix wishlist items not saving: pass customerId into toggleWishlistItem
The create POST omitted the customer relationship field entirely, so
added items never matched getWishlist's customer-scoped query. Also
scope the toggle-off fallback lookup to the current customer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 15:04:35 +00:00
Marco d010a3aff1 Add wishlist button to cart page's related-products cards
RelatedProducts.tsx was the only product-card grid missing the
WishlistButton (ProductGrid.tsx/ProductSpotlight.tsx already had it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 11:50:31 +00:00
Marco 1ec442cc05 Fix guest cart never reaching the server on login
mergeServerCartIntoLocal() relied on CartSync's own change-triggered
push to upload the merged cart — but if the server cart was empty
(first login on this account) and the local guest cart wasn't, the
merge loop never actually ran (nothing in the server response to
add), so no write/change-event ever fired and CartSync's effect never
pushed anything. The guest cart looked fine on the device that just
logged in, but never reached any other device. Now pushes explicitly
at the end regardless of whether anything changed.
2026-08-01 10:46:57 +00:00
Marco 552718c09a Fix cart pull not syncing removals across devices
pullServerCart() only added/updated matching lines and never dropped
a local line the server no longer had — a removal on one device never
reached another (adding synced, removing didn't). Now replaces the
local cart outright with the server's instead of merging into it,
including down to empty.
2026-08-01 10:40:31 +00:00
Marco ce3ac3e79a Sync the cart to an already-logged-in device, not just at login
Cross-device sync only ever worked one way (local→server on every
change via CartSync) plus a server→local merge at the exact moment of
login/register — a customer already signed in on a second device (no
fresh login action happening) never picked up changes made elsewhere.

Adds pullServerCart(): idempotent (server qty overwrites a matching
local line rather than adding to it, safe to call repeatedly, unlike
mergeServerCartIntoLocal()'s additive merge which only stays correct
right after a login clears the local cart's ambiguity). Triggered on
CartSync's mount and on window focus — covers "open the site while
already logged in" and "switch back to this tab after changing the
cart on another device," without a polling interval.
2026-08-01 10:34:02 +00:00
Marco 56c279b51b Add spacing below the shop's <lg filter bar
pb-2 wasn't enough — the first product card sat almost flush against
the filter controls on mobile/tablet.
2026-08-01 10:30:57 +00:00
Marco 77a9ef27a9 Make Spotlight's wishlist heart hover-only, matching the shop grid
Was always visible in ProductSpotlight.tsx, unlike ProductGrid.tsx's
cards — inconsistent for what's still a single card in a hoverable
context. The `group` ancestor needed for revealOnHover already exists
there (used for the image's hover-zoom effect), so this is just the
prop.
2026-08-01 10:28:51 +00:00
Marco 45bd321bb0 Fix the second "Weiter einkaufen" arrow (cart with items) missed earlier
The previous replace_all only matched the empty-cart instance's exact
indentation — this one, inside the has-items branch, was a level
deeper and kept the raw Unicode "←" (aria-hidden), still hitting the
mobile font-fallback vertical-metrics bug ArrowLeftIcon.tsx exists to
avoid.
2026-08-01 08:39:10 +00:00
Marco 9a0729adef Document product categories and the new /shop filter sidebar
Products table gains the categories field; Search & filters section
covers the price slider, category checkboxes, availability toggle,
the blog's client-side category filter, and the RevealGroup key fix
both grids needed to stop going blank after a filter change.
2026-08-01 08:37:52 +00:00
Marco cc17311d03 Show product category as an eyebrow label on product cards
Same treatment as the blog cards' category line (Blog.tsx) — small
uppercase text-muted line above the product name, applied
consistently across all three card grids (shop, Merkliste, cart's
related products). Omitted entirely for an uncategorized product
rather than showing an empty line.
2026-08-01 08:36:53 +00:00
Marco 83de7df089 Fix shop grid going blank after applying a filter
Same RevealGroup stuck-at-opacity-0 bug as the blog's category
filter (see f95feaf) — whileInView only fires once per component
instance, and a price/category/inStock filter change re-renders the
same /shop page in place, so RevealGroup never naturally remounts
between filter states. Keying it on the actual filtered product set
forces a remount, restarting the viewport tracking each time.
2026-08-01 08:33:51 +00:00
Marco aabfad3541 Replace Unicode "←" with an SVG icon in cart's "Weiter einkaufen" links
Same font-fallback bug ArrowRightIcon.tsx already documents and fixed
for "→" elsewhere (Tools.tsx, Blog.tsx, ProductGrid.tsx) — the site's
custom web fonts don't cover U+2190, so it fell back to a system font
whose vertical metrics sit visibly low on mobile. Missed in that
earlier pass since CartContent.tsx uses the left-pointing twin.
2026-08-01 08:32:19 +00:00
Marco 557f6a1abc Add product categories + shop sidebar filters, fix CTA button alignment
Products now carry an optional categories relationship (backend:
new product-categories collection mirroring the blog's categories
pattern, migration applied and deployed). The shop page gains a left
sidebar (styled like AccountNav) with a dual-handle price slider,
category checkboxes, and an availability toggle — all instant-apply
via searchParams, same union-filter semantics as the blog's category
chips. Uncategorized products always match every category filter
rather than disappearing.

Also fixes CTA buttons sitting at different heights across sibling
cards when one product's title wraps to two lines — MerklisteGrid.tsx
and RelatedProducts.tsx get the same h-full/flex-1 spacer pattern
ProductGrid.tsx already used.
2026-08-01 08:15:58 +00:00
Marco f95feafb1a Fix blog post list staying invisible after re-filtering categories
RevealGroup's whileInView fires once per component instance. Since a
category-filter navigation re-renders the same /blog page in place
(only searchParams changes, no full remount), and rest.length stays
> 0 across most filter transitions, RevealGroup itself never
naturally unmounts — so once it already fired "show" for one
filter's list, newly swapped-in RevealItems (different post ids)
mounted into an already-settled parent with no reason to re-fire the
reveal trigger, staying stuck at opacity 0 forever. Keying
RevealGroup (and the featured Reveal) on the actual rendered post
set forces a remount whenever the filtered posts change, restarting
the viewport tracking each time.
2026-08-01 06:29:53 +00:00
Marco e49dacf057 Fix search overlay CSS trap and blog filter navigation race
SearchOverlay was rendered as a <header> descendant, so the header's
conditional backdrop-blur-md (once scrolled) made it the containing
block for the overlay's fixed positioning, clipping the opaque
background to the header's height and letting page content show
through underneath. Now rendered as a header sibling, same pattern
already used for NewsletterModal.

Blog category chips are now a client component gating navigation
behind useTransition, disabling the chips while a navigation is
pending so rapid clicks can't fire overlapping RSC navigations that
commit out of order and briefly show an empty result.
2026-08-01 06:17:09 +00:00
Marco 5c4a0e011d Die Sieben: fix 3-finger hand icon, expand Worum-es-geht copy
- IconHand had 2 finger segments + a thumb curve (3 digits total,
  visibly wrong for an open-hand icon) — rebuilt as 4 finger capsules +
  thumb using simple rounded-rect shapes instead of hand-tuned path
  arcs, verified visually via a local render before applying.
- Worum-es-geht was too thin for a page opener — expanded with a
  concrete, specific opening (not a generic "in a busy world" framing)
  and more of what the monthly invitations actually look like in
  practice, still in first person.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 23:16:35 +00:00
Marco aa491aa5fd Add /die-sieben — rebuilt from the old WordPress page, brand-voice copy
Free monthly ritual page, modeled on /todo-cards' conventions
(Reveal/RevealGroup, breadcrumb + text-display heading) but without a
product-style hero — this isn't a paid product, so no photo/price/buy
CTA. The WordPress original's monthly-changing "aktuelle Ausgabe",
printable template, and archive have no content model in this codebase
yet (nothing like a `Posts` collection for "editions") — this ships the
evergreen concept page only, with the existing Newsletter signup
standing in as the "stay in the loop" mechanism. Deferred to a memory
note rather than blocking this page on designing a new collection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 23:09:24 +00:00
Marco 8b636bef25 Add the same badge pulse animation to the wishlist icon as the cart icon
Same mount-guard pattern as CartLink's own pulse fix — useWishlist()'s
count only fills in after its client-side fetch resolves, so without the
guard this would have pulsed on every page load too, not just a real
add/remove during the session. Fires on either direction (add or
remove) since the wishlist has no separate "flying" animation like the
cart's ball-to-icon effect to lean on instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:54:42 +00:00
Marco 9f61322604 Match Blog and Shop heading sizes back to text-display
Blog's own hero already used text-display; ShopHeader.tsx used the
smaller text-h-feature, reading as inconsistent next to it. Both now
use text-display.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:52:22 +00:00
Marco b88a8edd35 Fix legal-page autolink color, add Blog breadcrumb, revert breadcrumb color
- RichText.tsx: Lexical's auto-detected "autolink" nodes (a typed-out
  email/URL, as opposed to an editor-inserted link) fell through to
  Payload's unstyled default converter — only "link" was overridden.
  AGB's Vertragspartner email rendered as plain black text because of
  this. Both node types now get the same text-brand/hover:underline
  treatment.
- Breadcrumb color revert: the breadcrumb Startseite links across the
  4 legal pages/shop should stay their original hover:text-brand
  treatment — only the actual content/contact links needed the
  always-brand-colored style, not page-level breadcrumbs.
- /blog was the one page missing both a breadcrumb and the
  text-h-feature heading size every other section page (/shop, legal
  pages) already uses — added to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:50:48 +00:00
Marco 7c26bf7b7c Fix cart-badge false pulse, blog hero-card misattribution, legal breadcrumb color
- Cart badge pulsed on every page load/reload, not just real cart
  changes — useCartCount's SSR snapshot is always 0, so hydration alone
  satisfied the "count increased" check. Skip the very first effect run.
- WishlistButton: touch-device smaller/subtler sizing now applies
  regardless of revealOnHover (e.g. the homepage spotlight heart), not
  just the shop grid's hover-reveal mode.
- /blog: the big featured-style hero card was applied to whatever post
  sorted first post-filter, even when that post isn't actually the
  `featured` one (a category filter can exclude the real featured post
  entirely). Now gated on the post's own `featured` flag.
- Legal pages' "Startseite" breadcrumb link now matches the same
  text-brand/hover:underline treatment already used elsewhere on these
  pages (e.g. Impressum's Anbieter email link), instead of staying muted
  until hover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:46:24 +00:00
Marco bf6c5d079a Fix order-filter dropdowns, restructure Konto identity line, drop blog reset link
- CustomSelect.tsx: mousedown on an option blurred the trigger button
  before the click landed, unmounting the list before anything could be
  selected — add preventDefault on the list's mousedown to stop that.
- KontoShell no longer renders "Eingeloggt als ..." above each page's
  content; each page renders its own title then AccountIdentity right
  below it, and ProfileForm's now-duplicate email/Kundennummer line is
  removed.
- Blog category filter: drop the separate "Zurücksetzen" link — clicking
  an active category badge again already deactivates it.
- Werkzeuge cards: more vertical gap between stacked cards below sm:.
- Legal pages' "Stand: ..." line is now derived from the LegalPages doc's
  own updatedAt instead of a hand-typed string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:29:36 +00:00
Marco d018d87e25 Fix account-nav feedback, blog filter position, and small icon polish
- Remove Merkliste from AccountNav — the Navbar's own wishlist icon already covers it, a second nav entry was redundant.
- Fix duplicate "Mein Profil" heading (ProfileForm already renders its own; KontoShell no longer adds a second one).
- "Eingeloggt als ..." now renders once in KontoShell, consistently on every /konto/* page, instead of only on the orders page.
- Blog category filter chips moved into the hero's own text column (was a separate bar below the entire hero including the photo) — visible immediately on every width instead of requiring a scroll past the hero image first.
- ArrowRightIcon's arrowhead wings shortened (looked like a generic oversized chevron at full length).
- Search overlay's "Esc" text button replaced with an X icon; the actual Escape keyboard shortcut is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 22:13:08 +00:00
Marco f54ff267a7 Document the shared-modules section pointing to @einfach-produktiv/invoicing
Notes what moved into the shared package (VIES, VAT-ID, PLZ, carrier-tracking) and why — this repo's README previously didn't call out the modularization/dedup pass at all, only individual file references scattered through other sections.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 22:01:29 +00:00
Marco 6f033d52b5 Add persistent account navigation (sidebar/tabs); fix CTA arrow on Android
Konto-Aktionen links (profile, wishlist, logout) used to live at the bottom of the orders page's own content — unreachable once the order list got long enough. New KontoShell + AccountNav give every /konto/* page a persistent sidebar (sm+) / tab bar (mobile) instead, always reachable regardless of list length. The order filters also collapse behind a "Filter" toggle on mobile now, since the tab bar above them left little room for 3 full-width dropdowns.

Also: replaced the Unicode "→" arrow in CTA links (Tools.tsx, Blog.tsx, ProductGrid.tsx) with an SVG icon — the glyph isn't covered by the site's custom fonts, so browsers fall back to a system font per-platform; confirmed sitting visibly low relative to the label text on Android/Chrome (Galaxy S22), not reproducible in desktop Chromium. An SVG has no font-fallback path, so it renders identically everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 21:58:04 +00:00
Marco 92727ff22a Keep icon touch targets full-size; shrink logo instead; fluid nav gap
Revert the 40px icon-shrink below 375px (hard to tap accurately) and reclaim that space from the logo instead (76-130px depending on width, vs the always-181px controls). Re-verified overflow-free at 320px via Playwright with all 4 icons at full 44px.

Also: wishlist heart's touch-friendly smaller/subtler size now applies to already-wishlisted (filled) hearts too, not just not-yet-wishlisted ones. The wishlist page's login redirect now returns to the wishlist instead of the generic account/orders overview. Desktop nav-link gap now scales fluidly between 1024-1300px instead of a fixed 48px that felt too wide right where nav labels have the least room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 21:20:01 +00:00
Marco 1a3a8ba20e Show search/wishlist icons on all screen widths, not just sm+
Both were deliberately hidden below 640px to avoid icon-row overflow. Verified via an actual Playwright viewport sweep (320-640px) that simply un-hiding them did overflow — the header's fixed 181px logo + 32px padding left no room. Fixed properly: logo shrinks to 130px and padding drops to px-4 below sm, plus a 40px icon-size step below 375px. Re-verified overflow-free down to 320px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 21:08:11 +00:00
Marco b144758b82 Restore a light background on mobile wishlist hearts
Fully transparent made the heart invisible against some product photos — keeps it smaller than the default but with a semi-transparent (60%) background circle instead of none, and stops dimming the icon itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 20:53:15 +00:00
Marco 92c6953724 Make mobile wishlist hearts smaller/subtler instead of full-size everywhere
Full-size, always-visible hearts on touch devices recreated the exact "a heart on every card" visual noise revealOnHover exists to avoid. Touch devices now get a smaller, backgroundless, slightly transparent heart instead — present and tappable everywhere, but visually quiet. Desktop hover-reveal behavior is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 20:49:25 +00:00
Marco 9bf6e3f4f2 Fix wishlist heart invisible on mobile product grids
revealOnHover relied purely on group-hover/group-focus-within, both mouse/keyboard-only pseudo-classes with no reliable touch equivalent — a tap on a card never reliably triggers group-hover the way a mouse hover does, leaving the heart permanently hidden on touch devices. Adds pointer-coarse:opacity-100 so it's always visible there; desktop hover-reveal behavior is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 20:43:40 +00:00
Marco a077adea0f Rewrite README as current-state reference, drop changelog narrative
Removes all dated section headings (2026-07-24 through 2026-07-31) and inline dated asides, folding still-true facts into the evergreen structure (Pages, Cart & checkout, Orders & customer accounts, etc.). Also fixes references to app/lib/vies.ts/vatId.ts/tracking.ts, which moved into @einfach-produktiv/invoicing. Git history is the actual changelog. 2375 -> 1798 lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 18:06:48 +00:00
Marco b6a826dc46 Replace hand-duplicated VIES/VAT-ID/PLZ/tracking logic with shared package
Deletes app/lib/vies.ts, vatId.ts, tracking.ts in favor of the newly unified @einfach-produktiv/invoicing modules — fixes the actual PLZ inconsistency (this repo already validated per-country digit counts; the backend hardcoded German-only) rather than just deduplicating code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 15:41:01 +00:00
Marco 230b8ebaad Mark already-purchased items on the wishlist instead of auto-removing
A customer often wishlists something specifically to buy it again (gifts, repurchases) — silently removing it after purchase would defeat that. /konto/merkliste now shows a dimmed image + "Gekauft am [date]" badge instead, derived read-only from the customer's own orders (cancelled/returned orders excluded). Removal stays manual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:51:10 +00:00
Marco 8a4170a1e6 Add DHL checkout integrations (autocomplete, postnummer, return label)
Wires the new backend DHL endpoints into checkout: an address-autocomplete
dropdown on the street fields, live Postnummer validation for Packstation
delivery, and a return-label download link on the order-detail page.
Proxied through Next.js API routes since DHL credentials are tenant-
specific and CheckoutContent is a Client Component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:01:05 +00:00
Marco 51e4860fe3 Add spotlight wishlist toggle and shop filter/grid polish
Per-product spotlightShowWishlist opt-in (independent of the global
wishlistEnabled toggle), a reveal-on-hover WishlistButton variant for
multi-card grids (avoids a heart on every card reading as visual noise),
and related PriceRangeFilter/ProductGrid/CustomSelect adjustments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:00:55 +00:00
Marco 8c2220ae45 Document tonight's wishlist/search/filters/CustomSelect work in README
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:24:54 +00:00
Marco ee39a5d7dc Use CustomSelect for checkout's country pickers too
Promoted CustomSelect from konto/bestellungen/components/ to a shared
app/components/ location. New includeAllOption (checkout doesn't want
a "clear" pseudo-option — a country is always genuinely selected) and
fullWidth (matches the other w-full form fields, no sm: shrink) props.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:15:08 +00:00
Marco 2b2ee4d23c Gate order-list filters behind CompanySettings.orderFilterEnabled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:12:54 +00:00
Marco f5b898a69a Gate shop/blog filters behind CompanySettings toggles; fix blog filter z-index bug
Fixed: blog category filter bar was rendering behind the featured-post
card — that card pulls itself up (-mt-8, z-10) to overlap the hero's
bottom edge (its original design), but inserting the filter bar
between the hero and that card meant the same pull now overlapped the
filter bar instead, with the card's stacking rendering on top. Given
relative z-20 to stay above that overlap regardless. Also bumped the
inactive chip's background to bg-bg-muted — border-border on bg-base
is only a ~2% lightness difference, nearly invisible as a pill outline.

Both filters (shop price, blog category) now gated behind new
CompanySettings toggles (shopFilterEnabled/blogFilterEnabled), same
off-by-default pattern as wishlistEnabled/searchEnabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:07:49 +00:00
Marco fc8a1d4201 Add standalone registration page; fix 2 merkliste bugs; honor login redirect
New /konto/registrieren (+ RegisterForm.tsx) — until now an account
could only be created inline during checkout, which stopped making
sense the moment the Wishlist gave accounts a non-purchase use case.
LoginForm now also honors ?redirect= (previously ignored, always
landing on /konto/bestellungen regardless of where the login was
triggered from, e.g. WishlistButton's login-gate) and links to the
new registration page instead of implying "only via checkout".

Fixed: removing an item from the wishlist didn't disappear from
/konto/merkliste — that page was a Server Component with a fixed
render, not reactive to the client-side toggle. New MerklisteGrid.tsx
(Client Component) uses useWishlist()'s live state as the actual
source of truth for which products are still shown.

Fixed: AddToCartInlineButton looked broken on that same page —
`className="w-full"` was passed to it, but its `className` prop
*replaces* the whole default styling (`?? default`, not a merge),
throwing away all the button's actual styling. Its default is
already `w-full`; no override needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:59:23 +00:00
Marco bce5a9f82c Replace shop price toggle-badges with a real min/max range filter
Preset price buckets as toggle chips were reverted per feedback
("keine toggle badges") — PriceRangeFilter.tsx is a plain min/max
number-input pair instead, still a URL search param underneath
(?minPrice=&maxPrice=), same shareable/bookmarkable approach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:53:51 +00:00
Marco bc40e22910 Gate search behind CompanySettings.searchEnabled; fix two reported bugs
Search icon now gated behind the new backend toggle, same pattern as
wishlistEnabled — off by default.

Fixed: blog category filter bar was invisible — it was wrapped in
<Reveal>, which sits right at/just past the hero's bottom edge, the
exact "already near the initial viewport" position Reveal.tsx's own
comment documents as a whileInView(margin:"-80px") trap (an element
already visible without scrolling can permanently never register as
"entered view", since `once: true` never gets a second chance). Now a
plain div, no scroll-reveal animation needed for a filter bar anyway.

Fixed: wishlist count showing one behind on the Navbar badge —
useWishlist.ts's toggle() broadcast the "refetch me" event immediately
after the optimistic local update, before the POST request was even
sent. Another instance's resulting refetch could race the actual
server-side write, get the pre-toggle list, and then never get told to
refetch again. Broadcast now only fires after the request settles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:51:37 +00:00
Marco 2eda211a29 Add instant-search overlay (products + blog posts)
Separate commit on purpose so this can be reverted independently if
needed. Search icon in the Navbar (hidden below sm:, same reasoning
as WishlistLink — Account+Cart are the only always-visible icons on
true mobile) opens a debounced (250ms) overlay searching both
collections at once via a new /api/search route.

Plain Payload `where[...][contains]` queries (Postgres ILIKE), not a
real search index (Meilisearch/Algolia) — matches the catalog's
current small size, see [[project-ecommerce-sota-gaps]]'s own "search
becomes necessary past ~20 products" note. Worth upgrading later
without touching the overlay component itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:39:38 +00:00
Marco 8c5e284b6c Add price-range toggle filters to the shop overview
Scoped down from the original "category checkboxes + price slider"
sidebar proposal — Products have no category taxonomy today (only
Posts do), so a category filter isn't buildable against real data
yet. Price buckets are computed from the active catalog's actual
min/max price (not fixed round-number thresholds), so they stay
sensible regardless of what's actually being sold. Same URL-search-
param, plain-Link toggle-chip pattern as the blog/order-list filters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:37:25 +00:00
Marco 72c7b0454a Add category toggle-button filters to the blog overview
URL-search-param-driven (?categories=A,B), same shareable/bookmarkable
approach as the order-list filters — plain server-rendered toggle
Links, no client component needed since /blog already fetches every
published post in one request (limit 100, no pagination).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:33:20 +00:00
Marco 11aa9689c1 Add wishlist feature (frontend), gated by CompanySettings.wishlistEnabled
New: useWishlist.ts (fetch+optimistic-toggle hook, server-backed since
a wishlist needs a logged-in customer, unlike the guest-friendly cart),
WishlistButton.tsx (heart toggle, login-redirects on 401), Navbar's
wishlist icon+badge (hidden below sm: — Account+Cart are the only
always-visible icons on true mobile, a 3rd icon there risks the same
computed nav-overflow class of bug documented in the figma-to-nextjs
skill), and /konto/merkliste (list page, 404s if the feature gets
disabled after a customer already has rows).

Product gained numericId (the raw Payload id) alongside its existing
slug id — WishlistItems.product is a real numeric relationship field,
unlike cart/checkout's slug-keyed "commerce id".

Backend counterpart (WishlistItems collection, CompanySettings toggle,
migration) already deployed separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:28:30 +00:00
Marco 1d91a3f21c Replace native <select> filters with a fully custom-styled dropdown
A native <select>'s open options popup is rendered by the browser/OS
and can't be styled at all — wrong font size, wrong colors, no brand
styling whatsoever, reported as looking completely off-brand. New
CustomSelect.tsx renders both the trigger and the options list as
plain styled HTML (button + role="listbox" panel, keyboard nav,
click-outside-to-close) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:13:51 +00:00
Marco 475ee3fa16 Replace order-filter chip wall with 3 compact selects; subgrid-align trust badges
OrderFilters.tsx: filter chips (7 status + 5 payment-status + N year
options as pills) read as cluttered and ate a lot of vertical space.
Replaced with 3 native <select>s in a row (a small Client Component
just for the onChange→router.push navigation) — same URL-search-param
filtering underneath, just a much more compact control.

TrustRow.tsx: replaced the hardcoded min-h-[3.2rem] title reservation
(a guess, and it visibly over-reserved space for single-line titles)
with a real CSS Grid + `grid-template-rows: subgrid` — every badge's
icon+title box shares the same row-track height (auto-sized to
whichever title actually needs 2 lines), so descriptions align
without any hardcoded value. The two structurally different layouts
(icon-above-title below lg vs. icon-beside-a-title/description-stack
at lg) now render as two separate blocks instead of fighting one
shared shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:11:12 +00:00
Marco c8cf6ae10d Fix trust-badge title/description misalignment, cap order-confirmation card width at tablet
TrustRow.tsx: reserve 2 lines of height for the title below lg: so the
description starts at the same y-position regardless of whether that
badge's own title happened to wrap to 1 or 2 lines in its narrow
stacked column.

BestellbestaetigungContent.tsx: same fix category as NewsletterModal's
dialog width cap — max-w-[56rem] doesn't constrain anything below
896px, so the order-details card stretched full width through the
640-1023px tablet range. Added max-w-[36rem] there, widening to the
real cap only once lg:'s sidebar split needs the room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:05:40 +00:00
Marco 2f8c184dc6 Add status/paymentStatus/year filters to the order overview
Server-side, URL-search-param-driven filtering (?status=&paymentStatus=&year=)
so the page stays a Server Component — each filter chip is a plain Link
to the same route with one param changed, shareable/bookmarkable/back-
button-safe for free. getCustomerOrders() gained an optional filters
param translated into additional Payload where[] clauses; a new
getCustomerOrderYears() derives the year filter's option list from the
customer's actual orders instead of a hardcoded range. paymentStatus's
"Offen" filter option mirrors PaymentStatusBadge's own grouping
(pending + not_applicable as one option, not two).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:03:30 +00:00
Marco e7d5dd8b1e Revert Bestellnummer/Datum centering — back to left-aligned columns 1-2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:58:51 +00:00
Marco 79d3f836a2 Fix order-list card: explicitly pin every cell's row+column
Root cause of the "still left-packed" report: mixing `order` with only
some items explicitly positioned (col-start on Bestellnummer/Artikel)
is a footgun — Grid's auto-placement cursor for the un-pinned items
(Datum, Status, Zahlungsstatus, Gesamtbetrag) starts scanning from
column 1 again regardless of what's already explicitly placed
elsewhere, so Datum landed back in column 1 instead of column 3.
Every cell now gets an explicit row-start+col-start, removing the
ambiguity entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:55:24 +00:00
Marco 113d25eef9 Order list: center Bestellnummer/Datum row + center footer links on mobile
Bestellnummer now forces sm:col-start-2 so the top row's 2 items
(only spanning 2 of 4 tracks) center within the row instead of
packing flush left with all the leftover space on the right.

The Profil/Weiter einkaufen/Abmelden links row now centers itself
(self-center) below sm, matching the rest of the page's alignment
from sm up — shrink-to-fit width preserved, doesn't stretch full width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:51:22 +00:00
Marco 785c1e6eba Center the cart summary trust badges row between sm and lg
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:49:21 +00:00
Marco 9024112d88 Order-list card: move breakpoint from 500px to sm (640px)
Consistency with the site's structural sm: breakpoint elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:48:07 +00:00
Marco 7ce8d02afe Cart summary trust badges: don't stretch to full card width at tablet
Was sm:flex-1, growing each badge to evenly fill the card's full
width. Now sm:w-auto with no flex-grow, so each badge only takes its
own content width; sm:flex-wrap added as a safety net in case all 3
don't fit on one line at exactly 640px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:46:57 +00:00
Marco 11c8d0b7b6 TrustRow: side-by-side row below lg with icon above text, no wrap
Cart summary trust badges: side by side between sm and lg

TrustRow.tsx: rebuilt as a single continuous below-lg tier instead of
mobile-nebeneinander/tablet-stacked — icon above text (shrinking each
badge to its text column's width) plus dropping whitespace-nowrap on
title/description below lg (wrapping within their own narrow column
instead of forcing overflow) means a full row of 3 badges now fits
without needing to wrap or stack, avoiding the previously-reverted
"odd item alone on its own line" issue a flex-wrap attempt hit.

CartContent.tsx: the sidebar's trust-badge list (title only) now goes
side by side between sm (640px) and lg (1024px) instead of always
stacked — that sidebar is full page width through that whole range,
plenty of room for 3 short titles in a row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:44:30 +00:00
Marco 6b8ba44d46 Shop overview: 2 products per row through tablet, 4 only from lg
Was 4-up from sm (640px) — too narrow a card through the 640-1023px
tablet range. True mobile (below sm) unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:36:57 +00:00
Marco ea7f4a4b3b Blog secondary post cards: image above text through tablet width
Was sm:grid-cols-5 — with the 2-up sm:grid-cols-2 outer grid, each
card's own width through 640-1023px was too narrow for image+text
side by side. Now stacks through lg (1024px), row layout only once
each card has real width. Featured post untouched (already fine).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:32:54 +00:00
Marco 6262a8ab01 Tools cards: icon above text below lg, left-aligned above mobile
Newsletter: left-align copy above mobile, fix missing vertical padding

Tools.tsx: icon+text now stacks below lg (1024px) instead of always
being a row — a 3-up grid from sm (640px) left each card too narrow
for side-by-side icon+text through 640-1023px. Centered on true
mobile, left-aligned from sm up.

Newsletter.tsx: same left-alignment treatment (centered only on true
mobile, left-aligned from sm up). Also removed the outer card's
sm:py-0 — it relied entirely on items-center centering against the
copy column's height for top/bottom breathing room, which broke down
whenever the form column (input/checkbox/privacy note) was as tall or
taller, leaving the input and "Keine Werbung" note flush against the
card edges. py-8 now applies at every breakpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:31:15 +00:00
Marco 7baf8f9e24 Keep the newsletter icon above the text through the whole tablet range
Icon+text switched to row layout at sm (640px), but the outer card
already puts this copy column next to the form from that same
breakpoint — squeezed the text through 640-1023px. Row layout for
icon+text now only kicks in at lg, where there's actually room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:27:26 +00:00
Marco a036c89291 Fix Product Spotlight looking too wide on tablet
sm:flex-row (640px) put the 380px image side-by-side with text well
before there was comfortable room for it — tablet widths (768-1023px)
read as cramped. Now stays stacked like mobile through lg (1024px),
with a max-w-[32rem] cap between sm and lg so the stacked card reads
as centered/narrower instead of stretching to fill the tablet
viewport (true mobile stays unconstrained, the viewport is already
narrow there).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:25:46 +00:00
Marco 32f4068272 Slow down the mobile nav drawer's close animation
The shared ease-out curve (fast start, slow finish) worked well for
the open reveal but made the close shrink almost instantly then
linger on a barely-visible sliver — read as abrupt. Gave exit its own
longer, easeInOut transition instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:59:36 +00:00
Marco 275d5abd95 Close the mobile nav drawer when clicking the navbar itself
Added an onClick on <header> that closes the drawer whenever it's
open — the panel is a sibling, not a descendant, so this never fires
for clicks inside the open drawer, only the bar itself. The
hamburger's own click now stops propagation so toggling the drawer
open doesn't get immediately undone by this same handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:57:14 +00:00
Marco 0f50ab334c Move Artikel to the front of the 4-column row
From 500px up, the second row is now Artikel/Status/Zahlungsstatus/
Gesamtbetrag (was Status/Zahlungsstatus/Artikel/Gesamtbetrag). Done via
min-[500px]:order-* so the below-500px pairing (badges together,
quantities together) stays on plain DOM order, untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:55:13 +00:00
Marco 54725f7c5f Give Bestellnummer/Datum their own row, group the other 4 fields below
From 500px up: 4 columns, with Status forced to col-start-1 so
Status/Zahlungsstatus/Artikel/Gesamtbetrag land together in one row
below Bestellnummer/Datum, instead of Grid filling the empty cells
next to Bestellnummer/Datum first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:52:48 +00:00
Marco 7c9bfc3897 Keep the order-list card at 2 columns always, drop the 4-column breakpoint
With the badges-together/quantities-together field order, 4 columns at
wider viewports put Status/Zahlungsstatus in the same row as
Bestellnummer/Datum instead of their own row. Plain 2 columns at every
width keeps the intended pairing regardless of available space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:51:22 +00:00
Marco 014f1edcdc Reorder order-list card fields: badges together, quantities together
Status/Zahlungsstatus now land in one row (calmer than being split
diagonally across two rows), and Artikel/Gesamtbetrag pair up as the
two "quantity" fields. Also drops the now-unnecessary col-start-1 on
Artikel — Bestellnummer/Datum/Status/Zahlungsstatus already fill all
4 tracks of row 1 exactly with this order, so Grid wraps row 2 on
its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:48:29 +00:00
Marco cca555c0f4 Bump @einfach-produktiv/invoicing to 0.2.9, fixing /cart crash
0.2.8's fileURLToPath fix (for a Payload-backend-only Turbopack bug)
crashed every client bundle that transitively imports fonts.ts —
including this frontend's own /cart page, via CartContent.tsx's
computeTaxBreakdown import pulling in the invoicing barrel file.
0.2.9 branches on typeof window to keep both environments working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:23:52 +00:00
Marco c6fe65ad99 Fix cart quantities doubling on every logout/login and a crash on /cart
Root cause 1 (count doubling): LogoutButton never cleared the local
cart, and mergeServerCartIntoLocal() (called on login) adds server
quantities into the existing local ones rather than replacing — since
CartSync mirrors local to the server continuously, local and server
already held the same quantities at logout time, so every login added
them together, doubling the count each cycle. Fix: clear the local
cart on logout, so the next login's merge starts from empty (or only
genuine guest-session additions) instead of re-adding already-synced
quantities.

Root cause 2 (page couldn't load): readCart()/getCart() only guarded
against JSON.parse syntax errors, not against the parsed value being a
non-array — once localStorage held a corrupted (inflated/malformed)
value, CartContent.tsx's cart.map() threw uncaught during render.
Fix: validate Array.isArray() after parsing, falling back to [].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:13:54 +00:00
Marco 73b4b06eae Keep Bestellnummer/Datum side by side below 500px too
Reverts the col-span-2 full-width-row experiment for very narrow
phones — side by side reads better even with the occasional wrap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:59:20 +00:00
Marco 3704acab41 Document the order-list mobile grid iteration in the README
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:58:21 +00:00
Marco 53c452583d Move the order-list 2-to-4-column breakpoint from 400px to 500px
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:56:33 +00:00
Marco 80843cc08b Give Bestellnummer/Datum full-width rows on very narrow phones
Below 400px each now spans both columns of the 2-column layout (own
row), so the long order number doesn't wrap; from 400px up they drop
back to col-span-1 side by side in the 4-column layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:54:58 +00:00
Marco c6051442df Fix order-list mobile layout: 2 columns under 400px, badges no longer stretch
The forced 4-column layout overflowed on narrow phones — badge labels
like "In Bearbeitung" and the order number wrapped/overlapped into
neighboring columns. Drop to 2 columns below 400px (the field order
already tiles cleanly into 3 rows of 2). Also stop OrderStatusBadge/
PaymentStatusBadge from stretching to their flex-col parent's full
width — self-start keeps the badge background only as wide as its label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:53:41 +00:00
Marco 1fe1a260e4 Keep the order-list card at 4 columns on all screen sizes
Drop the responsive grid-cols-2/4 switch entirely — stay in the
4-column layout even on the narrowest mobile widths, with a tighter
column gap below sm to help it fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:49:16 +00:00
Marco 5d588c55b4 Push the order-list card's 4-column breakpoint out to lg
md (768px) still counts as mobile/tablet range — keep the 2-column
layout through that whole range and only switch to 4 columns at real
desktop widths (lg, 1024px).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:47:50 +00:00
Marco 44f98a3d64 Delay the order-list card's grid breakpoint from sm to md
The card sits inside a max-w-[56rem] container with padding, so at
the sm breakpoint (640px viewport) the card itself is still too
narrow for 4 columns — the wrap to 4 columns was happening too early.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:45:24 +00:00
Marco 8ddde89143 Force Artikel to start a new grid row on the order list
sm:grid-cols-4 left 2 empty cells in row 1 after Bestellnummer/Datum
became col-span-1, so Grid auto-placement flowed Artikel into the
same row instead of wrapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:40:52 +00:00
Marco 84e21589c3 Fix order-list grid: Datum should start in column 2, not 3 2026-07-30 13:34:34 +00:00
Marco 9502cf81b9 Unify order-list card into a single grid for consistent column alignment
One grid for the whole card (Bestellnummer/Datum spanning 2 of 4 tracks
from sm: up, everything else spanning 1) instead of two separate flex/grid
groups. Plain flex-wrap sized every field to its own content width, so a
field could start at a different x position from one order to the next
depending on how wide that particular badge label happened to be — a
grid's column tracks are fixed by the container width, identical on every
card regardless of content, which is what actually guarantees consistent
alignment across different orders.
2026-07-30 13:29:19 +00:00
Marco 18f5156fd8 Attach the invoice PDF to the payment-method-switched email, brand-tone copy
buildInvoiceAttachment() extracted out of sendOrderConfirmationEmail() so
sendPaymentSwitchedEmail() can reuse the same invoice-PDF generation
instead of duplicating it — the invoiceNumber stays the same, but
paymentMethodTitle now reflects the confirmed instrument, which flips
the PDF's own paid/unpaid display, so re-attaching a fresh copy matters
here even though nothing else about the order changed.

Default fallback copy replaced with brand-toned wording matching the
existing order-confirmation fallback's voice, instead of a flat
system-notice tone.
2026-07-30 11:14:18 +00:00
Marco f0b2d21989 Send a dedicated email for a switched Überweisung payment, not the full order-confirmation
New sendPaymentSwitchedEmail() — sent instead of sendOrderConfirmationEmail()
when confirmPaymentEmail.ts sees order.paymentSwitchedAt set. Resending the
full order-confirmation (with its invoice re-attached) after a payment-method
switch read like a brand-new purchase; this is a short, dedicated "Zahlung
erhalten" confirmation instead, matching the order-status-email shape.

Also bumps @einfach-produktiv/invoicing to 0.2.8, fixing a font-path bug
that broke every invoice PDF render inside the Payload backend specifically.
2026-07-30 11:04:27 +00:00
Marco 2e537bc78d Restrict Rücksendung anfragen to delivered orders, fix Impressum email link
- customerOrderAction() only offers "Rücksendung anfragen" once an order
  is delivered, not already at shipped — a return before the package
  arrived doesn't make sense yet. UI-only change (a stricter subset of
  what the backend's CUSTOMER_ALLOWED_TRANSITIONS already permits).
- AnbieterAngaben.tsx's seller email is now a real mailto: link — it was
  plain text, the only non-clickable email on the site.
2026-07-30 10:39:08 +00:00
Marco 4adb844f8d Move Zahlungsart-ändern button next to Bestellung-stornieren 2026-07-30 10:12:11 +00:00
Marco beea592706 Reflect Orders.paymentStatus's new meaning for Überweisung orders
- switch-to-stripe eligibility gained paymentStatus !== "paid" — an
  order an admin already marked paid by hand must never also be
  switchable to Stripe.
- PaymentStatusBadge: "Offen" now renders in the same red/subtle
  style as a failed status (was neutral grey) — worth visually flagging,
  now that it's a real tracked state rather than a permanent placeholder.
2026-07-30 10:08:37 +00:00
Marco 6448c8736a Fix broken email-preview rendering, add payment status to order list, second sample state
- LiveEmailPreviewClient.tsx rendered the email HTML (a full <body>...
  fragment) via dangerouslySetInnerHTML into a plain div, nesting it
  inside the page's own already-existing <body> — invalid HTML the
  browser silently mangled. Switched to an <iframe srcDoc>, giving the
  email its own real document context, exactly like an actual email
  client would render it.
- Added SAMPLE_ORDER_MANUAL + a toggle in the preview so both
  order-confirmation states (paid vs. Vorkasse/Überweisung, incl. the new
  "switch to Kreditkarte/PayPal" mention) are actually visible in the
  admin's Live Preview — SAMPLE_ORDER alone always had isManualPayment:
  false, so the Vorkasse branch was never previewable at all before this.
- /konto/bestellungen (the order list, not just the single-order detail
  page) now also shows the payment status badge next to the existing
  fulfillment status one — CustomerOrder was missing paymentStatus
  entirely.
2026-07-30 09:56:06 +00:00
Marco 0e995884a7 Allow switching an unpaid Überweisung order to Stripe payment
New "Zahlungsart ändern" button on the account order detail page,
shown for a still-'received', still-manual (Überweisung) order when an
active Stripe payment method exists. Reuses PaymentStep (the same Stripe
Payment Element checkout uses) and /checkout/verarbeitung's polling logic
(both now take a returnContext prop/param to land back on the order page
instead of clearing the cart and redirecting to /bestellbestaetigung).

Also:
- Payment status badge (Offen/Bezahlt/...) next to the existing Zahlungsart
  display on the order detail page.
- A one-line mention of the switch option in the Vorkasse unpaid notice
  in the order-confirmation email, shown only when a Stripe option is
  actually active (hasOnlinePaymentOption).
- CustomerOrderDetail gained paymentProvider (was missing from the type
  entirely, even though the field already existed on the order).

Backend counterpart: docker/payload's switchPaymentToStripeEndpoint.
2026-07-30 09:48:44 +00:00
Marco 8c843c0ac1 Profile: Rechnungsadresse is always a street address, no Packstation toggle
Matches /checkout's own billing card exactly (an invoice needs a real
postal address) — the profile form previously offered a Lieferart/
Packstation choice for what's actually always used as the billing
address, inconsistent now that a separate "Lieferadresse" section exists.
2026-07-30 08:48:04 +00:00
Marco e3352d7e32 Blog categories (hasMany), shipping-address contact fields, product SKU on invoices
- Posts.categories is now hasMany — blog list/detail/live-preview render
  a comma-joined list instead of a single category.
- Checkout's "Abweichende Lieferadresse" gains optional Firma + Kontakt-
  E-Mail/Telefon fields (handed to the shipping carrier, not used for
  customer communication).
- Customer profile can now store its own shipping address (mirroring
  Customers.ts's new "Lieferadresse" tab), prefilling the checkout
  override instead of always starting blank.
- Product-level optional SKU (previously only on variants) snapshots onto
  each order item and shows up on invoices (visual + EN16931 XML),
  the confirmation email, and the account order detail page.
2026-07-30 08:41:39 +00:00
Marco 1467750db6 Hide the free-shipping progress banner when the cart has no shippable items
Reuses FreeShippingBanner's existing threshold===null early return —
a "€X bis kostenlosem Versand" nudge makes no sense for a cart that was
never going to be charged shipping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:15:32 +00:00
Marco c83bbfbc35 Filter draft/scheduled posts out of public blog fetches
getBlogPosts()/getPostBySlug() now only return status='published' posts
for normal requests — draftMode's live preview passes {draft:true} to
bypass it, same as before. Backend-side scheduling in the payload repo
(status/scheduledPublishAt + a per-minute autopublish job).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:10:07 +00:00
Marco c852752006 Cap Newsletter card at 1280px, show an explicit "Keine Versandkosten" hint, generalize the /versand exemption note
- Newsletter.tsx: max-w-[1280px] mx-auto, matching /lebensuhr's own
  newsletter-style section and Footer.tsx's existing 1280px convention —
  previously unbounded and stretched edge-to-edge on wide viewports.
- Product pages: noShippingCost now shows "Keine Versandkosten" instead
  of just omitting "zzgl. Versand" silently.
- /versand's exemption note no longer implies only digital products can
  be exempt — noShippingCost is a general per-product flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:01:17 +00:00
Marco a3f843ad15 Honor Products.noShippingCost across cart, checkout, and product pages
- api/checkout/route.ts: authoritative shipping charge is 0 whenever
  every cart line opts out via noShippingCost, regardless of the
  free-shipping threshold.
- Cart/checkout order summaries: the whole "Versand" line (cost, free-
  shipping note, delivery time) is hidden entirely rather than showing
  "Kostenlos" — that's a different state from hitting the threshold.
- ProductSpotlight/Pricing/TodoKartenHero: "zzgl. Versand" and delivery-
  time hints drop for an exempted product's own page.
- /versand + its shared modal: one clarifying sentence that digital
  products are exempt.
- Widerrufsformular link: opens inline in a new tab (no forced download),
  arrow icon changed from a download glyph to a plain right arrow to
  match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:48:33 +00:00
Marco bffc7d39a2 Widerrufsformular link opens inline in a new tab instead of forcing a download
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:35:37 +00:00
Marco 70abe20250 Generate Muster-Widerrufsformular PDF live from company-settings instead of a static upload
The "An:" address used to be baked into a hand-crafted PDF and silently
went stale whenever an admin updated the Impressum's Anbieterdaten
without also re-exporting/re-uploading the file by hand — exactly what
happened 2026-07-29. New /api/muster-widerrufsformular route renders it
on-the-fly via @react-pdf/renderer using the same getCompanySettings()
data as AnbieterAngaben.tsx's Impressum block, so the two can never drift
apart again. Disables react-pdf's default hyphenation, which otherwise
split the seller's email address mid-word to fit the line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:30:51 +00:00
Marco 22fef4fe49 Switch order/verification email From to sellerEmail's domain, honor emailFromName/emailFromAddress override
Mirrors the backend's sellerInfo.ts buildFromHeader() change — SMTP
account moved to admin@einfach-produktiv.com, so From can finally point
at sellerEmail itself instead of the fixed admin@mk360.de placeholder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:22:23 +00:00
Marco fb1bedcacd Fix invisible Stripe pay button, add brand favicon/OG image, navbar 3x3-System CTA, blog hero crop
- PaymentStep: bg-brand-primary was never a real Tailwind class, leaving
  the "Jetzt bezahlen" button unstyled/invisible; switched to bg-brand
  with dark text, matching every other CTA on the site.
- New app/icon.svg + app/apple-icon.png + app/favicon.ico: brand-yellow
  rounded square with a bold italic "e", replacing the Next.js default.
- New public/og-image.png (logo on the site's cream background) wired in
  as the SEO fallback default OG image/description/title in payload.ts.
- Navbar CTA: "Klarheitskompass" -> "Mein 3x3-System", now links to
  /3x3-system (page not built yet).
- Blog overview hero image anchored to the top instead of center-cropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:48:22 +00:00
Marco 27d383b559 Blog overview hero: switch to hero.png (hero.jpg was removed)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:16:54 +00:00
Marco 4524005f0e Newsletter-confirmed letter: add compass emoji to Klarheitskompass heading
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:13:10 +00:00
Marco 14b47fd049 Newsletter-confirmed letter: "wöchentlich" wording, split invitation into two paragraphs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:12:06 +00:00
Marco 47789e94db Tweak newsletter-confirmed letter: note the separate Klarheitskompass email, trim copy
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:09:25 +00:00
Marco a6e1734799 Replace newsletter-confirmed generic copy with Björn's personal welcome letter
Includes the Klarheitskompass gift callout and Sonntags-Impulse framing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:02:06 +00:00
Marco 0a467aed59 Tighten newsletter modal's input-to-submit-button gap
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:52:42 +00:00
Marco 14094ab1d4 Update newsletter/brand copy, hide VAT section without a vatId, relabel Lebensuhr breadcrumb
- Newsletter panel + modal: new title/description copy, weekly cadence wording
- Brand mentions ("einfach-produktiv") switched to "einfach produktiv."
- Impressum: Umsatzsteuer section (heading + TOC entry) only renders when vatId is set
- /lebensuhr breadcrumb eyebrow now reads "Lebensuhr" instead of "7-Tage-Challenge"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:49:46 +00:00
Marco 96fddf4cd9 ueberarbeitung mit bjoern 2026-07-29 20:11:55 +00:00
Marco 3fc98b8480 Correct README/skill: TrustRow's flex-wrap was reverted, not kept
Documents the third round: flex-wrap looked disorganized with exactly
3 badges (the lone wrapped item didn't align under either item above
it), reverted back to strict single-column stacking the same day.
2026-07-29 12:39:39 +00:00
Marco 5f219c23f9 Revert TrustRow from flex-wrap back to single-column stacking
The flex-wrap experiment (2 badges per row + 1 wrapped centered below)
looked disorganized with exactly 3 badges — the wrapped single item
doesn't align under either item in the row above it, reported as
"durcheinander". Reverts to strict single-column stacking below lg:
(the inner-shrink-to-fit-group + outer center/left-align pattern),
which stays visually clean regardless of how many badges exist.
2026-07-29 12:38:44 +00:00
Marco 908f46c962 Document TrustRow's flex-wrap rewrite and the modal sticky-button fix
Covers the two follow-up rounds after the Footer/TrustRow lg: revert:
the items-center misalignment bug and its flex-wrap resolution, plus
NewsletterModal's absolute-to-sticky close button fix. Cross-references
the figma-to-nextjs skill's new Gotchas 22-23.
2026-07-29 12:36:22 +00:00
Marco d389790c57 TrustRow: flex-wrap instead of a hard lg: breakpoint; sticky modal close button
TrustRow now uses flex-wrap + justify-center so badges flow as many
per row as actually fit at the current width, wrapping (and
auto-centering, a native flexbox behavior for the last wrapped line)
instead of needing a breakpoint or risking overflow. Replaces the
earlier lg:-only structural exception entirely.

NewsletterModal's close button was absolutely positioned inside the
dialog's own scrolling container, so it scrolled away with the
content. Switched to sticky (top-6, ml-auto for horizzontal position,
-mb-6 to cancel its own height so it doesn't push content down) so it
stays pinned to the top-right corner while scrolling.
2026-07-29 12:35:12 +00:00
Marco 3aa8720daf Fix TrustRow badges misaligning with each other when centered
items-center directly on the badge container centered each badge
independently within the container's width — badges with different
title/description lengths ended up with different left edges instead
of lining up (confirmed via our own screenshot: icons at different x
positions below 640px). Wraps all badges in one inner shrink-to-fit
group (always items-start internally, so every badge shares the same
left edge) and centers/left-aligns that single group as a unit from
the outer wrapper instead.
2026-07-29 12:25:49 +00:00
Marco 077986affb Revert "Center cart's CartTrustBadges list below 640px"
This reverts commit 5d61dce12b.
2026-07-29 12:22:38 +00:00
Marco 5d61dce12b Center cart's CartTrustBadges list below 640px
The previous TrustRow.tsx fix didn't touch this — the cart summary
sidebar renders its own inline trust-badge list, not the shared
TrustRow component. Each row was w-full unconditionally, so
items-center on the container was a no-op (a full-width flex child
centers to no visible effect). Now w-auto + justify-center below sm:
(icon+title as a compact centered unit), w-full + justify-start (+
flex-1 on the title) from sm: up, matching the tiered-alignment pattern
used elsewhere (Hero's social proof, TrustRow.tsx).
2026-07-29 12:17:54 +00:00
Marco 17679cc81e Only center TrustRow badges below 640px, left-align through Tablet
Centering the whole non-lg: range (previous fix) overcorrected — only
true Mobile should center (matches other centered content there); the
640-1023px Tablet band left-aligns instead, each badge's left edge
lining up via items-start (no per-badge centering, so nothing reads
crooked). Same tiered-alignment pattern as Hero's social-proof row.
2026-07-29 12:13:20 +00:00
Marco 19440b9bcf Document the Footer/TrustRow lg: regression and the Playwright verification method
Both components were wrongly reclassified as plain sm: renames during
the 640px migration; they'd been on lg: for a genuine fixed-content-
width reason, not the fluid-floor bug the migration targets. Also
documents that a real headless-browser check (Playwright, installed
into a scratch dir) is available in this environment after all —
caught both regressions via scrollWidth vs. innerWidth measurements
against the live site.
2026-07-29 12:11:23 +00:00
Marco 56fc8d970d Revert TrustRow's breakpoint from sm: to lg:, center stacked badges
Same fixed-content-doesn't-fit bug as Footer's legal-links row,
confirmed via live-site horizontal-overflow measurement: ~100px
overflow at 666px viewport, ~54px at 768px. Multiple whitespace-nowrap
title+description badges don't fit in one row below ~1024px regardless
of fluid scaling. Also centers stacked badges (was items-start below
sm:, now items-center through the whole non-lg: range) per feedback.
2026-07-29 12:09:44 +00:00
Marco 61afa0dcae Revert Footer's row breakpoint from sm: back to lg:
Confirmed via Playwright against the live site (666px viewport): this
row was causing a real horizontal page overflow (scrollWidth 746px vs
666px viewport) in the 640-1023px band, not just visual cramping —
logo + handle + 5 nowrap legal links (all shrink-0) don't fit in one
row below ~1024px regardless of fluid scaling. Today's earlier sm:
rename wrongly treated this as a simple structural-breakpoint case;
it's actually the same fixed-content-doesn't-fit category as
Cart/Checkout/SectionTOC, which correctly stayed at lg:.
2026-07-29 12:04:07 +00:00
Marco 724530b073 Cap NewsletterModal's width through Tablet instead of just stacking it
Stacking single-column at lg: (previous attempt) let the dialog stretch
to near-full-viewport-width at real Tablet widths, since max-w-[75rem]
barely constrains it there — the photo blew up huge and the text below
read as lost/disconnected. Caps the dialog at max-w-[36rem] through the
640-1023px band (only widening to 75rem once the lg: 2-column split
kicks in), so it reads as a compact modal at every width, not a
near-full-bleed stack.
2026-07-29 11:35:31 +00:00
Marco 5c43a49ba9 Revert "Break NewsletterModal at lg: instead of sm:"
This reverts commit e0299713f5.
2026-07-29 11:33:40 +00:00
Marco e0299713f5 Break NewsletterModal at lg: instead of sm:
The dialog is capped at max-w-[75rem] (1200px), so at real Tablet
viewports (640-1023px) it's essentially full-viewport-width — too
little room per column at sm: for the heading/form or the 3 feature
cards to read well. Stacked through the whole Tablet range now,
side by side again only from lg: up.
2026-07-29 11:32:09 +00:00
Marco a9c8344472 Add a soft edge fade to About's photo at Tablet too
The left gradient was gated to lg: (matching the -ml-48 overlap), but
without any overlap at Tablet the photo sits flush against the dark
text column with a hard vertical seam. Narrower fade (w-16) from sm:
up softens that seam; widens to w-72 at lg: to also cover the deeper
overlap once that kicks in.
2026-07-29 11:24:38 +00:00
Marco cbb423f8ca Center Hero heading/subheading below 640px too
CTA and social-proof were already centered on true Mobile, but the
heading/subheading above them stayed left-aligned — read inconsistent.
Centers the whole text block below sm:, left-aligned again from sm: up.
2026-07-29 11:13:48 +00:00
Marco dfb6eb5e23 Re-center Hero social proof below 640px, keep it left-aligned at Tablet
Centering it site-wide below lg: overcorrected — true Mobile (<640px)
should stay centered (matches the CTA there), only the 640-1023px
Tablet band needed the left-align fix from the previous commit.
2026-07-29 11:08:40 +00:00
Marco 5e1d88d13e Left-align Hero's social proof row below lg:
Centered avatars+text below lg: read as floating/disconnected from the
left-aligned heading/subheading/CTA above it. Left-aligned now to match
the rest of the column; still switches to a single centered row at lg:.
2026-07-29 11:05:59 +00:00
Marco c3a6c84d8a Give About's photo more room at Tablet, not the text column
The wider text share at sm: didn't actually help the inner quote/bio
row go inline (it stays stacked until lg: regardless) — it just took
space away from the photo, which read as too narrow/cramped on Tablet.
1:1.4 text:image ratio now applies from sm: up; only the -ml-48 overlap
itself stays gated to lg:, since that trick needs more absolute room.
2026-07-29 11:02:29 +00:00
Marco 932cf60c5c Fix About Tablet layout, center Hero CTA on Mobile
The sm:/lg: ratio collapse in About.tsx from the breakpoint migration
was wrong: the Tablet-specific wider text share wasn't just compensating
for the grid arriving too early, it's what the inner quote/bio row and
the photo's -ml-48 overlap actually need room for. Restores the
two-tier ratio (wider text, no overlap at sm:; real ratio + overlap
only at lg:), just anchored to sm: instead of the old md:.

Also centers the Hero CTA button below sm: (Mobile) per feedback.
2026-07-29 10:58:36 +00:00
Marco cc935420ec Move structural breakpoint to 640px, shorten Hero heading
Tablet layout (768-1023px) hit the md: grid switch exactly where the
fluid clamp() tokens were already at their floor, leaving no room to
shrink. Moves the fluid floor and structural breakpoint down together
to sm: (640px) so real tablets always get the fluid, desktop-like
structure; consolidates the ad-hoc md:+lg: patchwork in Hero/About/
Newsletter/Footer/Tools back onto one line, leaving documented lg:
exceptions where content genuinely doesn't fit yet. Also shortens the
Hero heading to a single sentence with no trailing period (the brand's
orange dot already renders one, animated).
2026-07-29 10:49:56 +00:00
Marco 94cba756c8 Remove trailing period from Werkzeuge section heading
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:58:32 +00:00
Marco b835e8622f Align newsletter modal copy with the homepage newsletter block
NewsletterModal.tsx advertised a different offer (the 7-Tage-Challenge)
than Newsletter.tsx's own description (7 Impulse) for the same signup
flow. Also rewrote both descriptions to drop the vague "und gewinne
Klarheit" buzzword formula and the word "Klarheit" repeated from the
heading directly above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:58:27 +00:00
Marco 7286c76787 fixed hero title 2026-07-29 08:54:35 +00:00
Marco e175d2b4c3 Bump @einfach-produktiv/invoicing to 0.2.6 (embedded-font fix)
Picks up the Liberation Sans font-embedding fix for PDF/A-3 compliance
— fixes invoice PDF downloads/emails generated by this app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 23:53:51 +00:00
Marco 1306dce6e4 Text updates for customer 2026-07-28 21:39:30 +00:00
Marco 8fa4a20e63 Document the active/inactive toggle and fix stale "6 rows" references
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:04:44 +00:00
Marco 1d875a65c0 Honor email-templates.active for order-confirmation
Skips the send entirely when the admin has deliberately deactivated
the order-confirmation template, matching the backend's new toggle for
the 7 order-status emails. Distinct from a missing row, which still
sends with the hardcoded default wording.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:58:41 +00:00
Marco 0436e87147 Document the 3 new email types and the cross-origin Live Preview limitation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:43:51 +00:00
Marco 7feda76b3c Use 🎉 for order-delivered instead of 🥳
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:14:22 +00:00
Marco c4fb6586e7 Match order-delivered's Live Preview icon to the backend's 🥳
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:13:36 +00:00
Marco 3dccc229af Add order-delivered to Live Preview support
Matches the backend's new opt-in review/follow-up email type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:58:09 +00:00
Marco 6c43171b11 Fix Live Preview 404 for the two new tracking email templates
order-tracking-added/order-tracking-corrected were missing from
VALID_TYPES (app/email-preview/[type]/page.tsx), EmailTemplateType
(app/lib/payload.ts), and ORDER_STATUS_EMAIL_ICON (emailTemplates.ts)
— Live Preview 404'd for both since they were added to the Payload
collection without a matching frontend update.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:35:07 +00:00
Marco 8d4ee75cfb Document newsletter-modal fixes in the README
Error-clears-on-interaction behavior and the modal photo no longer
resizing when the already-subscribed/format-error message appears.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:19:44 +00:00
Marco 8a910f7ff8 Bump @einfach-produktiv/invoicing to 0.2.5
Moves the §19/§4-Nr.-1b-UStG notice out of the invoice summary card
into its own full-width row, matching the Vorkasse-notice fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:49:02 +00:00
Marco a7fa0ec027 Reserve space for newsletter-modal error text so the photo doesn't resize
The modal's left photo stretches (items-stretch, md:aspect-auto) to
match the right column's height. The "already subscribed"/invalid-email
messages were conditionally mounted, so their appearance grew the right
column and dragged the photo's height along with it. Both are now
always rendered with a reserved min-height instead, so toggling them no
longer changes the modal's size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:52:13 +00:00
Marco 0ac2e45077 Clear "already subscribed" newsletter error on next interaction
Matches standard form-validation behavior: any further edit to the
email or consent checkbox after the already-subscribed message
appears now dismisses it, instead of leaving it stuck until submit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:34:20 +00:00
Marco fd98f68ba6 Route already-subscribed through the error state, not success
Per explicit feedback: swapping the whole form out for a bare success
message felt wrong for "you're already signed up, nothing to do" — the
form stays visible with a small red note below it instead, same as
every other inline validation error. No per-form UI changes needed,
they already render the error state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:27:50 +00:00
Marco bccfc28012 Log the real newsletter-subscribe failure reason server-side
The customer-facing message stays generic on purpose (never leak
Brevo's internal error text), but the real reason was discarded
entirely before this — every failure looked identical from the
outside. Cost real debugging time today tracking down a misconfigured
BREVO_LIST_ID in Coolify (was "2", should have been "5") that made
every single signup attempt fail with the same unhelpful message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:25:01 +00:00
Marco 55df2e104c Give the Vorkasse bank details their own line in the confirmation email
Was one run-on sentence; split into three paragraphs: the instruction,
the bank details on their own bold line, then the processing-time note
with a line break after the bank line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:20:15 +00:00
Marco 1706da8598 Add schema.org structured data, Vorkasse email notice, newsletter duplicate detection
- Organization (site-wide), Product (/todo-cards), BlogPosting (every
  /blog/[slug]) JSON-LD via new app/lib/structuredData.ts — no new
  Payload fields needed, derived from existing data. Verified locally
  by curling each page and checking the rendered script tag.
- Order confirmation email gains the same "please transfer to this
  account, processed after payment received" notice the invoice PDF
  already had for Vorkasse orders — OrderConfirmationData's new
  isManualPayment flag is set explicitly by each caller (never derived
  from paymentMethodTitle, which already broke once this session after
  a payment-methods rename). CompanySettings gains bankName (existed on
  the backend, was missing from the frontend's type/usage).
- Newsletter signup now detects an already-subscribed email
  (verified empirically: Brevo's doubleOptinConfirmation endpoint gives
  identical 201 responses for new vs. already-confirmed contacts) via a
  GET /v3/contacts/{email} pre-check, and shows a distinct message
  instead of silently resending the confirmation mail. Success message
  text centralized in useNewsletterSignup.ts instead of duplicated
  across 4 forms.
- Bumped @einfach-produktiv/invoicing to the version with the
  unpaid-notice layout fix (full width, more top spacing — was
  squeezed into the narrow paid-badge column).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:11:48 +00:00
Marco 1dbc0c31ff Drop "neu" from the checkout account-password copy
"Bitte ein Passwort für dein neues Konto vergeben" / "Passwort (für
dein neues Konto)" read oddly — just "dein Konto".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:41:02 +00:00
Marco 06a00d67e4 Fix groupPaymentMethodsForCheckout ignoring sortOrder
Hardcoded manual rows first, then the combined stripe option — so
"Online-Zahlung" (sortOrder 0) showed after "Überweisung (Vorkasse)"
(sortOrder 1) in checkout, contradicting the admin's own ordering.
Now preserves the fetch's sortOrder-sorted order, splicing the combined
entry in at the position of the first stripe row encountered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:39:56 +00:00
Marco 952b902702 Document a concrete Stripe activation checklist
Everything code-side is already live — this is purely the remaining
provisioning steps (test keys -> Coolify -> backend .env -> redeploy ->
end-to-end test -> live keys), so the next person doesn't have to
reconstruct the sequence from scattered comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:17:32 +00:00
Marco f959ebb998 Bump @einfach-produktiv/invoicing for the Vorkasse invoice fix
Fixes isPaidImmediately() incorrectly showing "Bereits beglichen" on
unpaid Überweisung (Vorkasse) invoices after the payment-methods rename
earlier today, plus adds the missing "please transfer to this account,
processed after payment received" instruction text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:12:27 +00:00
Marco bae23775f2 Hide failed-payment order attempts from the customer's own order history
A cancelled order with no invoiceNumber is a Stripe payment that never
succeeded (failed or timed out before ever reaching received/invoiced),
not a real cancellation of something that actually happened — from the
customer's point of view it was never really an order. Filtered out of
getCustomerOrders/getCustomerOrderDetail by default; the row stays in
Payload for admin/audit purposes (shown there as "Zahlung
fehlgeschlagen", see backend).

getCustomerOrderDetail's filter is opt-in via a new optional parameter,
not the default — /api/checkout/status/route.ts's post-payment polling
needs to keep seeing exactly this order to show the "Zahlung
fehlgeschlagen, bitte erneut versuchen" retry state. The GDPR export
route also opts out for the same reason a legal completeness export
can't silently drop rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:05:26 +00:00
Marco 0e12ab1f1e Merge Stripe payment integration + Brevo double opt-in newsletter
- Real payment capture for Kreditkarte/PayPal via Stripe's Payment
  Element, webhook-gated order confirmation, PAYMENT_TEST_MODE for
  local testing without a real Stripe account.
- Newsletter signup switched from single to double opt-in
  (POST /contacts/doubleOptinConfirmation), plus /newsletter-confirmed
  as the post-confirmation landing page.

Backend counterpart already deployed and verified (confirm-payment
endpoint live, payment-methods provider field set on Kreditkarte/PayPal).
STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET/NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
still unset in Coolify — PAYMENT_TEST_MODE auto-engages until then, so
checkout is safe to test without a real Stripe account.
BREVO_DOUBLE_OPTIN_TEMPLATE_ID is set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 15:25:52 +00:00
Marco cb4c9d77f2 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:48:45 +00:00
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
183 changed files with 16991 additions and 2039 deletions
+995 -137
View File
File diff suppressed because it is too large Load Diff
@@ -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 = "Klarheits-Check starten" }: { buttonLabel?: string }) {
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
useNewsletterSignup("challenge");
if (status === "success") {
return <p className="text-[1rem] text-[#222221] font-medium">{successMessage}</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) => handleConsentChange(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>
);
}
@@ -4,9 +4,12 @@ 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 { STEP_ICONS } from "../components/icons/StepIcons";
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 =
@@ -16,12 +19,12 @@ export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/challenge",
canonical: "/7-tage-klarheits-check",
},
openGraph: {
title,
description,
url: "/challenge",
url: "/7-tage-klarheits-check",
images: ["/blog-featured.jpg"],
},
twitter: {
@@ -31,84 +34,32 @@ export const metadata: Metadata = {
},
};
function IconEnvelope() {
return (
<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" />
<path d="M2 2l24 22L50 2" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconMailLines() {
return (
<svg width="56" height="44" viewBox="0 0 56 44" fill="none">
<line x1="2" y1="12" x2="12" y2="12" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="20" x2="9" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="28" x2="7" y2="28" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<rect x="13" y="2" width="41" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
<path d="M13 2l20.5 19L54 2" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconNotepad() {
return (
<svg width="46" height="52" viewBox="0 0 46 52" fill="none">
<rect x="2" y="4" width="32" height="42" rx="2" stroke="#f6a701" strokeWidth="2" />
<line x1="9" y1="16" x2="27" y2="16" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="23" x2="24" y2="23" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="30" x2="20" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<circle cx="37" cy="40" r="7" stroke="#f6a701" strokeWidth="2" />
<line x1="37" y1="27" x2="37" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconCheckCircle() {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
<path d="M14 24l7.5 7.5L34 16" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
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 />,
icon: STEP_ICONS.envelope(),
title: "1. Anmelden",
desc: "Trage dich mit deiner E-Mail-Adresse ein und starte sofort.",
},
{
icon: <IconMailLines />,
icon: STEP_ICONS.mailLines(),
title: "2. E-Mail erhalten",
desc: "Du bekommst 7 Tage lang jeweils einen Impuls direkt in dein Postfach.",
},
{
icon: <IconNotepad />,
icon: STEP_ICONS.notepad(),
title: "3. Umsetzen",
desc: "Setze die einfache Aufgabe in wenigen Minuten um für mehr Klarheit und Fokus.",
desc: "Gehe die einfachen Gedankenanstöße in wenigen Minuten durch für mehr Klarheit und Fokus",
},
{
icon: <IconCheckCircle />,
icon: STEP_ICONS.checkCircle(),
title: "4. Dranbleiben",
desc: "Kleine Schritte führen zu großen Veränderungen Tag für Tag.",
},
@@ -122,55 +73,9 @@ const benefits = [
{ title: "Gelassener leben", desc: "Weniger Stress, mehr Zeit für die Dinge, die dir wichtig sind." },
];
function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-3 w-full">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="flex-1 min-w-0 bg-white border border-[#d9d9d9] rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none focus:border-[#f6a701] transition-colors"
/>
<button
type="submit"
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2"
>
{buttonLabel}
</button>
</div>
{/* Consent checkbox this signup's legal basis is consent (email
marketing), same wording as the other newsletter forms; colors
match this page's own hardcoded palette instead of the shared
design tokens, consistent with the rest of the page. */}
<label className="flex gap-2 items-start cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
/>
<span className="text-[0.8rem] text-[#444] leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-[#f6a701]"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
<LockIcon />
Keine Werbung. Jederzeit abbestellbar.
</p>
</div>
);
}
export default async function ChallengePage() {
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("challenge", { draft: isPreview });
const testimonials = await getTestimonials("klarheits-check", { draft: isPreview });
return (
<>
@@ -205,7 +110,7 @@ export default async function ChallengePage() {
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">Werkzeuge</Link>
<span></span>
<span className="text-text-primary">7-Tage-Challenge</span>
<span className="text-text-primary">7-Tage-Klarheits-Check</span>
</p>
{/* Everything else centered in the remaining vertical space,
@@ -280,37 +185,39 @@ export default async function ChallengePage() {
className="font-semibold text-[#222221]"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
>
So funktioniert die Challenge
So funktioniert der Check
</h2>
<p className="text-[1rem] text-[#666]">Jeden Tag ein Impuls. In nur wenigen Minuten.</p>
</Reveal>
<RevealGroup className="flex flex-col lg:flex-row items-start lg:items-start gap-8 lg:gap-2 w-full">
{/* items-center below lg: (was items-start) the step blocks
are centered columns now (see RevealItem below), so the
connector arrows between them need to be centered too,
not flush against the left edge. */}
<RevealGroup className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
{steps.flatMap((step, i) => [
<RevealItem key={step.title} className="group flex lg:flex-col items-start lg:items-center gap-4 lg:gap-5 flex-1 min-w-0">
// Icon-above-text, centered, at every breakpoint now
// (previously a left-aligned icon+text row below lg: —
// fixed 2026-07-24 to match the lg: layout instead of
// diverging from it).
<RevealItem key={step.title} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
{step.icon}
</div>
<div className="flex flex-col gap-1 lg:text-center">
<div className="flex flex-col gap-1 text-center">
<p className="font-semibold text-[#222221] text-[1rem]">{step.title}</p>
<p className="text-[0.875rem] text-[#666] leading-[1.5]">{step.desc}</p>
</div>
</RevealItem>,
i < steps.length - 1 ? (
// Same /icon-arrow-connector.svg asset and rotate-on-stack
// pattern as todo-cards/newsletter's HowItWorks — this
// used to be its own hand-drawn SVG arrow, inconsistent
// with those two. Always visible (rotated 90° while
// stacked below lg, this page's own structural
// breakpoint) rather than hidden below lg like before.
// Shared StepArrow component (see its own file) — same
// rotate-on-stack pattern as todo-cards/newsletter's
// HowItWorks. Always visible (rotated 90° while stacked
// below lg, this page's own structural breakpoint) rather
// than hidden below lg like before. Bigger below lg:
// (w-8 h-8, was w-6 h-6) per explicit feedback.
<div key={`arrow-${i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
<Image
alt=""
src="/icon-arrow-connector.svg"
width={24}
height={24}
className="w-6 h-6 rotate-90 lg:w-10 lg:h-3 lg:rotate-0"
/>
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
</div>
) : null,
])}
@@ -380,8 +287,11 @@ export default async function ChallengePage() {
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto">
<Reveal className="bg-[#f8f3ec] rounded-xl flex flex-col lg:flex-row gap-8 lg:gap-[3.5rem] items-start lg:items-center px-6 lg:px-10 py-8">
{/* Left: icon + copy */}
<div className="flex gap-5 items-start flex-1 min-w-0">
{/* Left: icon + copy icon above text, centered, below lg:
(matches the Home Newsletter card's icon-above-text
pattern), row layout again from lg: up alongside the
outer Reveal's own flex-col -> lg:flex-row switch. */}
<div className="flex flex-col items-center text-center gap-5 lg:flex-row lg:items-start lg:text-left flex-1 min-w-0">
<div className="shrink-0 -rotate-4">
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
@@ -393,7 +303,7 @@ export default async function ChallengePage() {
className="font-semibold text-[#222221] leading-normal"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.125rem, 2vw, 1.375rem)" }}
>
Starte jetzt deine 7-Tage-Challenge
Starte jetzt deine 7 Tage Klarheit
</p>
<p className="text-[0.9rem] text-[#555] leading-[1.5]">
Trage dich ein und erhalte ab sofort täglich einen Impuls für mehr Klarheit und Fokus.
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { PageBlocks } from "../components/PageBlocks";
import { LivePageContent } from "../components/LivePageContent";
import { getPageBySlug, getCompanySettings } from "../lib/payload";
import { buildWebPageSchema } from "../lib/structuredData";
// First generic catch-all content route in this repo — every other page
// (blog posts excepted, which have their own [slug]) is its own static
// directory under app/. Powers Payload's new Pages collection (the page
// builder): a doc's `layout` blocks field renders via PageBlocks.tsx,
// structurally parallel to how RichText.tsx renders Posts.content's own
// (differently-shaped) Blocks. Next.js resolves any matching static route
// (e.g. app/lebensuhr/page.tsx) before ever reaching this catch-all, so a
// Pages document only actually serves a URL once the static file for that
// slug, if any, is removed.
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const page = await getPageBySlug(slug);
if (!page) return { title: "Seite nicht gefunden" };
const title = page.seoTitle || page.title;
const description = page.seoDescription ?? undefined;
return {
title,
description,
alternates: { canonical: `/${page.slug}` },
openGraph: {
title,
description,
url: `/${page.slug}`,
type: "website",
images: page.seoImage ? [{ url: page.seoImage }] : undefined,
},
twitter: {
title,
description,
images: page.seoImage ? [page.seoImage] : undefined,
},
};
}
export default async function DynamicPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { isEnabled: isPreview } = await draftMode();
const page = await getPageBySlug(slug, { draft: isPreview });
if (!page) notFound();
const seller = await getCompanySettings();
const pageSchema = buildWebPageSchema(page, seller);
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(pageSchema) }} />
<main className="flex flex-col flex-1 bg-bg-base">
{isPreview ? (
<LivePageContent initialPage={page} />
) : (
<>
<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">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
<span></span>
<span className="text-text-primary">{page.title}</span>
</p>
</Reveal>
<Reveal delay={0.1} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14 flex flex-col gap-5">
<PageBlocks blocks={page.layout} />
</Reveal>
</>
)}
</main>
<Footer />
</>
);
}
@@ -0,0 +1,59 @@
import { headingId } from "../../components/RichText";
import type { CompanySettings } from "../../lib/payload";
import type { TOCSection } from "../../components/SectionTOC";
// Renders "2. Vertragspartner" straight from company-settings, same
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx and
// Datenschutz's VerantwortlicherBlock.tsx — this used to be hand-typed
// name/address/email baked into the AGB richText (seed-agb.ts on the
// Payload side), and had already drifted from the real company-settings
// values once (the richText's placeholder "Björn Wendt"/"Musterstraße
// 12" never got updated when the real address was set). Sits mid-document
// (section 1 "Geltungsbereich" comes before it), which is why AGB's
// content is split into `content` (section 1) + `contentPart2` (sections
// 3 onward) rather than just prepending this block like Datenschutz did —
// see LegalPages.ts's `contentPart2` field comment.
export function vertragspartnerHeadings(): TOCSection[] {
return [{ id: headingId("2. Vertragspartner"), title: "2. Vertragspartner" }];
}
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: React.ReactNode }) {
return <p className="text-body text-text-body">{children}</p>;
}
export function VertragspartnerBlock({ seller }: { seller: CompanySettings }) {
return (
<div className="flex flex-col gap-4 w-full">
<Heading>2. Vertragspartner</Heading>
<P>Der Kaufvertrag kommt zustande mit:</P>
<div className="flex flex-col gap-1">
<P>
<strong>einfach produktiv. {seller.sellerName}</strong>
</P>
<P>
<strong>
{seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity}
</strong>
</P>
<P>
<strong>
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="hover:underline">{seller.sellerEmail}</a>
</strong>
</P>
</div>
</div>
);
}
+33 -7
View File
@@ -7,8 +7,10 @@ import { Footer } from "../components/Footer";
import { TrustRow } from "../components/TrustRow";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage, getCompanySettings } from "../lib/payload";
import { formatMonthYear } from "../lib/format";
import { VertragspartnerBlock, vertragspartnerHeadings } from "./components/VertragspartnerBlock";
export const metadata: Metadata = {
title: "AGB",
@@ -18,8 +20,16 @@ export const metadata: Metadata = {
export default async function AgbPage() {
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("agb", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
const [page, seller] = await Promise.all([getLegalPage("agb", { draft: isPreview }), getCompanySettings()]);
// Section 1 ("Geltungsbereich") comes first from `content`, THEN
// "2. Vertragspartner" (dynamic, sits between two CMS-driven halves —
// see LegalPages.ts's `contentPart2` comment), then the rest from
// `contentPart2`.
const headings = [
...(page ? extractHeadings(page.content) : []),
...vertragspartnerHeadings(),
...(page?.contentPart2 ? extractHeadings(page.contentPart2) : []),
];
return (
<>
@@ -36,9 +46,16 @@ export default async function AgbPage() {
>
Allgemeine Geschäftsbedingungen
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
{page && <p className="text-body text-text-muted">Stand: {formatMonthYear(page.updatedAt)}</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} />
@@ -61,9 +78,18 @@ export default async function AgbPage() {
</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">
{page ? (
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
<>
{isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />}
{/* Name/Adresse/E-Mail kommen direkt aus company-settings,
nicht aus der CMS-Richtext — single-sourced, gleiche
Begründung wie Impressum/Datenschutz. */}
{seller && <VertragspartnerBlock seller={seller} />}
{page.contentPart2 ? (
isPreview ? <LiveRichText initialContent={page.contentPart2} /> : <RichText content={page.contentPart2} />
) : null}
</>
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
+2 -2
View File
@@ -10,9 +10,9 @@ export async function GET() {
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 orderSummaries = await getCustomerOrders(session.token, session.customer.id, false);
const orders = await Promise.all(
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber, false)),
);
const payload = {
@@ -34,6 +34,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
correctionInvoiceIssuedAt: order.correctionInvoiceIssuedAt,
customerFirstName: order.customerFirstName,
customerLastName: order.customerLastName,
companyName: order.companyName,
vatId: order.vatId,
vatExempt: order.vatExempt,
kleinunternehmer: order.kleinunternehmer,
deliveryMethod: order.deliveryMethod,
street: order.street,
packstationNumber: order.packstationNumber,
@@ -31,6 +31,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
invoiceIssuedAt: order.invoiceIssuedAt,
customerFirstName: order.customerFirstName,
customerLastName: order.customerLastName,
companyName: order.companyName,
vatId: order.vatId,
vatExempt: order.vatExempt,
kleinunternehmer: order.kleinunternehmer,
deliveryMethod: order.deliveryMethod,
street: order.street,
packstationNumber: order.packstationNumber,
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
import { getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../../../lib/payload";
import { paymentProvider, isPaymentTestMode } from "../../../../../lib/payments";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
// Lets a logged-in customer move an existing, still-unpaid Überweisung
// order onto a Stripe PaymentIntent instead of waiting on their own bank
// transfer — see the backend's switchPaymentToStripe.ts for the matching
// endpoint and why this needs a dedicated backend route rather than the
// generic customer-JWT order-PATCH path (paymentProvider/paymentStatus
// are system fields a customer JWT can never touch).
export async function POST(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 });
// Same eligibility the backend endpoint re-checks authoritatively —
// checked here too for a friendly error instead of a bare 409. An
// explicit allowlist ('pending'/'not_applicable'), not a
// paymentStatus !== "paid" blocklist — see switchPaymentToStripe.ts's
// own comment on why 'failed'/'refunded'/'partially_refunded' aren't
// meaningful states to switch from either.
const eligiblePaymentStatus = order.paymentStatus === "pending" || order.paymentStatus === "not_applicable";
if (order.paymentProvider !== "manual" || order.status !== "received" || !eligiblePaymentStatus) {
return NextResponse.json({ ok: false, reason: "Die Zahlungsart kann für diese Bestellung gerade nicht geändert werden." }, { status: 400 });
}
// Only offer this when a real Stripe payment method is actually active
// — same "Online-Zahlung" grouping the checkout itself uses, so this
// never presents an option that checkout wouldn't currently accept either.
const methods = groupPaymentMethodsForCheckout(await getPaymentMethods());
const hasStripeOption = methods.some((m) => m.provider === "stripe");
if (!hasStripeOption) {
return NextResponse.json({ ok: false, reason: "Aktuell steht keine Online-Zahlung zur Verfügung." }, { status: 400 });
}
let intent: { clientSecret: string; providerReference: string };
try {
intent = await paymentProvider.createPaymentIntent({
amountCents: Math.round(order.total * 100),
currency: "eur",
customerEmail: order.customerEmail,
description: `Bestellung ${order.orderNumber}`,
});
} catch (err) {
return NextResponse.json({ ok: false, reason: "Zahlung konnte nicht vorbereitet werden." }, { status: 500 });
}
const res = await fetch(`${PAYLOAD_URL}/api/orders/${order.id}/switch-payment-to-stripe`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET },
body: JSON.stringify({ providerReference: intent.providerReference }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
return NextResponse.json({ ok: false, reason: data?.reason ?? "Umstellung fehlgeschlagen." }, { status: 400 });
}
// Best-effort, same as checkout's own call — a failure here doesn't
// block the payment itself, only the confirm-payment webhook's metadata
// lookup, which the frontend's own webhook route already alerts on.
await paymentProvider.attachOrderMetadata(intent.providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch(() => {});
return NextResponse.json({
ok: true,
clientSecret: intent.clientSecret,
orderId: order.id,
orderNumber: order.orderNumber,
testMode: isPaymentTestMode,
...(isPaymentTestMode ? { providerReference: intent.providerReference } : {}),
});
}
+67 -12
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
export async function GET() {
const session = await getSessionCustomer();
@@ -13,13 +13,36 @@ export async function PATCH(request: Request) {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
const {
firstName,
lastName,
street,
zip,
city,
country,
companyName,
vatId,
hasDifferentShippingAddress,
shippingFirstName,
shippingLastName,
shippingCompanyName,
shippingDeliveryMethod,
shippingStreet,
shippingPackstationNumber,
shippingPostNumber,
shippingZip,
shippingCity,
shippingCountry,
shippingContactEmail,
shippingContactPhone,
} = body ?? {};
if (
typeof firstName !== "string" ||
!firstName ||
typeof lastName !== "string" ||
!lastName ||
(deliveryMethod !== "address" && deliveryMethod !== "packstation") ||
typeof street !== "string" ||
!street ||
typeof zip !== "string" ||
!zip ||
typeof city !== "string" ||
@@ -29,12 +52,6 @@ export async function PATCH(request: Request) {
) {
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;
@@ -42,18 +59,56 @@ export async function PATCH(request: Request) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
}
// Same shape as the checkout's own shipping-address-override validation
// (api/checkout/route.ts) — required fields only apply when the toggle
// is actually on, since this whole block is optional otherwise.
if (hasDifferentShippingAddress) {
if (
typeof shippingFirstName !== "string" ||
!shippingFirstName ||
typeof shippingLastName !== "string" ||
!shippingLastName ||
(shippingDeliveryMethod !== "address" && shippingDeliveryMethod !== "packstation") ||
typeof shippingZip !== "string" ||
!shippingZip ||
typeof shippingCity !== "string" ||
!shippingCity ||
typeof shippingCountry !== "string" ||
!shippingCountry
) {
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder der Lieferadresse ausfüllen." }, { status: 400 });
}
if (shippingDeliveryMethod === "address" && !shippingStreet) {
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
}
if (shippingDeliveryMethod === "packstation" && (!shippingPackstationNumber || !shippingPostNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
}
}
const result = await updateCustomerProfile(session.token, session.customer.id, {
firstName,
lastName,
deliveryMethod,
deliveryMethod: "address",
street,
packstationNumber,
postNumber,
zip,
city,
country,
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
vatId: normalizedVatId,
hasDifferentShippingAddress: Boolean(hasDifferentShippingAddress),
shippingFirstName: hasDifferentShippingAddress ? shippingFirstName : undefined,
shippingLastName: hasDifferentShippingAddress ? shippingLastName : undefined,
shippingCompanyName: hasDifferentShippingAddress && shippingCompanyName ? shippingCompanyName : undefined,
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
shippingStreet: hasDifferentShippingAddress ? shippingStreet : undefined,
shippingPackstationNumber: hasDifferentShippingAddress ? shippingPackstationNumber : undefined,
shippingPostNumber: hasDifferentShippingAddress ? shippingPostNumber : undefined,
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
shippingContactEmail: hasDifferentShippingAddress && shippingContactEmail ? shippingContactEmail : undefined,
shippingContactPhone: hasDifferentShippingAddress && shippingContactPhone ? shippingContactPhone : undefined,
});
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getSessionCustomer } from "../../../lib/customerAuth";
import { getWishlist, toggleWishlistItem } from "../../../lib/customerAuth";
export async function GET() {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ items: [] }, { status: 401 });
const items = await getWishlist(session.token, session.customer.id);
return NextResponse.json({ items });
}
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 productId = Number(body?.productId);
const variant = typeof body?.variant === "string" ? body.variant : "";
if (!Number.isInteger(productId) || productId <= 0) {
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
}
const result = await toggleWishlistItem(session.token, session.customer.id, productId, variant);
if (!result.ok) return NextResponse.json({ ok: false, reason: "Merkliste konnte nicht aktualisiert werden." }, { status: 500 });
return NextResponse.json({ ok: true, wishlisted: result.wishlisted });
}
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { autocompleteDhlAddress } from "../../../lib/shippingDhl";
// Proxies the checkout's address-autocomplete input through to Payload's
// DHL DataFactory endpoint — same reasoning as validate-dhl-postnumber's
// own route: tenant DHL credentials must never reach the browser.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("query") ?? "";
const suggestions = await autocompleteDhlAddress(query);
return NextResponse.json({ ok: true, suggestions });
}
+259 -65
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import type { CartItem } from "../../lib/cart";
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
import { getShippingMethods, getPaymentMethods, getCompanySettings, groupPaymentMethodsForCheckout } from "../../lib/payload";
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
@@ -8,7 +8,11 @@ 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 { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/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
@@ -50,6 +54,9 @@ type CheckoutBody = {
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
shippingCompanyName?: string;
shippingContactEmail?: string;
shippingContactPhone?: string;
newsletterOptIn: boolean;
};
@@ -122,7 +129,7 @@ export async function POST(request: Request) {
customer = session.customer;
} else {
if (!body.password) {
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 });
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein Konto vergeben." }, { status: 400 });
}
const result = await registerCustomer({
firstName: body.firstName,
@@ -138,6 +145,15 @@ export async function POST(request: Request) {
// Re-price everything server-side — never trust client-submitted prices.
const [productsBySlug, companySettings] = await Promise.all([fetchProductsBySlug(), getCompanySettings()]);
const defaultTaxRate = companySettings?.taxRatePercent ?? 19;
// §19 UStG — a Kleinunternehmer tenant never charges VAT on anything,
// full stop, so every item's tax rate is forced to 0% here regardless of
// its own catalog/company-settings default rate. Unlike the
// intra-community exemption below, prices are NOT de-grossed — see
// Orders.ts's own kleinunternehmer field comment and this shop's
// Kleinunternehmer decision: catalog gross prices stay exactly what they
// are, they simply never had a VAT component charged on top in the
// first place.
const kleinunternehmer = Boolean(companySettings?.kleinunternehmer);
const items: {
productId: number;
productName: string;
@@ -147,6 +163,7 @@ export async function POST(request: Request) {
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
sku: string | null;
}[] = [];
for (const line of body.cart) {
const product = productsBySlug.get(line.id);
@@ -155,7 +172,7 @@ export async function POST(request: Request) {
// 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;
let variant: { name: string; priceOverride: number | null; sku: string | 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 });
@@ -179,18 +196,28 @@ export async function POST(request: Request) {
quantity: line.qty,
unitPrice: variant?.priceOverride ?? product.price,
imageUrl,
taxRatePercent: product.taxRatePercent ?? defaultTaxRate,
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
bundleContents: describeBundleContents(product),
variantName: variant?.name ?? null,
// Variant sku takes precedence over the product's own — same
// "variant overrides product" precedence unitPrice already uses.
sku: variant?.sku ?? product.sku ?? null,
});
}
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
// Products.noShippingCost — a cart made up entirely of items that opt
// out of shipping costs (e.g. purely digital downloads) never gets
// charged shipping at all, regardless of the free-shipping threshold.
// A single item WITHOUT the flag still triggers normal shipping for the
// whole order — this only exempts a product, never the whole cart just
// because it contains an exempt item.
const hasShippableItem = body.cart.some((line) => !productsBySlug.get(line.id)?.noShippingCost);
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 shippingCost = !hasShippableItem || freeShipping ? 0 : shippingMethod.price;
const paymentMethods = await getPaymentMethods();
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
@@ -206,7 +233,97 @@ export async function POST(request: Request) {
validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal),
);
}
const total = roundMoney(Math.max(0, subtotal - discountAmount) + shippingCost);
// VAT-ID validity and the exemption decision are two separate questions.
// Validity (is this actually a currently-registered VAT ID at all) is
// checked via VIES for ANY country whenever one is given — worth
// recording regardless of destination, same "data quality" reasoning as
// company-settings.vatId's own VIES check on the backend; a merely
// format-valid id (e.g. "ED123456789" — "ED" isn't even a real country
// code) is never enough on its own. The exemption itself
// (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG) additionally
// requires the goods' actual destination (the shipping override's
// country when set, the billing country otherwise) to be Österreich,
// the one EU-cross-border option this checkout offers — a validated
// *German* VAT ID never zero-rates a domestic sale, no matter how real
// it is. VIES being unreachable fails closed on the exemption: normal
// VAT applies, never a guessed exemption (vatIdValidatedAt just stays
// unset in that case too).
let vatExempt = false;
let vatIdValidatedAt: string | null = null;
// A Kleinunternehmer never charges VAT on any sale, domestic or
// cross-border — the intra-community exemption exists to zero-rate what
// would otherwise be a positive-rate charge, which never applies here in
// the first place, so the VIES lookup is skipped entirely (also saves an
// unneeded network round-trip).
const buyerDestinationCountry = destinationCountry(body.country, Boolean(body.hasDifferentShippingAddress), body.shippingCountry);
if (!kleinunternehmer && normalizedVatId) {
const viesResult = await checkVatIdViaVies(normalizedVatId);
if (viesResult.ok && viesResult.valid) {
vatIdValidatedAt = new Date().toISOString();
if (isExemptionEligibleCountry(buyerDestinationCountry)) {
vatExempt = true;
}
}
}
if (vatExempt) {
// Re-price every line net of VAT (0% now applies) instead of the
// catalog's normal VAT-inclusive price — the whole point of the
// exemption is that the buyer pays less, not that this shop quietly
// keeps the VAT portion as extra margin. items/subtotal/shippingCost
// below are overwritten with the de-grossed figures actually charged
// and actually persisted on the order/invoice.
for (const item of items) {
item.unitPrice = roundMoney(item.unitPrice / (1 + item.taxRatePercent / 100));
item.taxRatePercent = 0;
}
}
const exemptTotals = vatExempt
? computeExemptTotals(
items.map((i) => ({ quantity: i.quantity, grossUnitPrice: i.unitPrice, taxRatePercent: 0 })),
shippingCost,
defaultTaxRate,
discountAmount,
)
: null;
// Note: exemptTotals recomputes `subtotal` from the already-degrossed
// `items` above (taxRatePercent 0 there means computeExemptTotals's own
// degross() step is a no-op on them) — it exists mainly to degross
// `shippingCost` the same way, and to keep both figures derived through
// one shared function rather than duplicating the arithmetic here.
const finalSubtotal = exemptTotals?.subtotal ?? subtotal;
const finalShippingCost = exemptTotals?.shippingCost ?? shippingCost;
const total = roundMoney(Math.max(0, finalSubtotal - discountAmount) + finalShippingCost);
// Gated-payment branch (Kreditkarte/PayPal today) — see
// spicy-leaping-pizza.md §3. The PaymentIntent is created BEFORE the
// order so its id can be persisted onto the order at creation time
// (providerReference), rather than needing a second authenticated
// update call that doesn't otherwise exist from this service. Stripe
// generates a PaymentIntent id independent of any order existing yet.
const requiresPayment = paymentMethod.provider === "stripe";
let providerReference: string | undefined;
let clientSecret: string | undefined;
if (requiresPayment) {
try {
const intent = await paymentProvider.createPaymentIntent({
amountCents: Math.round(total * 100),
currency: "eur",
customerEmail: body.email,
description: `einfach produktiv Bestellung — ${body.firstName} ${body.lastName}`,
});
providerReference = intent.providerReference;
clientSecret = intent.clientSecret;
} catch (err) {
sendCriticalAlert("Zahlung konnte nicht vorbereitet werden", {
customerEmail: body.email,
total,
error: String(err),
});
return NextResponse.json({ ok: false, reason: "Die Zahlung konnte gerade nicht vorbereitet werden." }, { status: 500 });
}
}
const order = await createOrder({
customerId: customer.id,
@@ -215,6 +332,9 @@ export async function POST(request: Request) {
customerEmail: body.email,
companyName: body.companyName || undefined,
vatId: normalizedVatId,
vatExempt,
kleinunternehmer,
vatIdValidatedAt,
deliveryMethod: body.deliveryMethod,
street: body.street,
packstationNumber: body.packstationNumber,
@@ -232,15 +352,30 @@ export async function POST(request: Request) {
shippingZip: body.shippingZip,
shippingCity: body.shippingCity,
shippingCountry: body.shippingCountry,
shippingCompanyName: Boolean(body.hasDifferentShippingAddress) ? body.shippingCompanyName || undefined : undefined,
shippingContactEmail: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactEmail || undefined : undefined,
shippingContactPhone: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactPhone || undefined : undefined,
newsletterOptIn: Boolean(body.newsletterOptIn),
items,
subtotal,
shippingCost,
subtotal: finalSubtotal,
shippingCost: finalShippingCost,
shippingMethodTitle: shippingMethod.title,
paymentMethodTitle: paymentMethod.title,
shippingMethod: shippingMethod.id,
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
// customer hasn't actually chosen an instrument yet at this point,
// Stripe's Payment Element does that next. Snapshotting the specific
// resolved row's title here would just record whichever row happened
// to be the group's representative id, not what was really picked.
// The webhook route refines this to the real instrument
// ("Kreditkarte"/"PayPal") once Stripe reports it, via confirm-payment.
paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
discountCode: body.discountCode || null,
discountAmount,
total,
...(requiresPayment
? { status: "pending_payment" as const, paymentProvider: "stripe" as const, paymentStatus: "pending" as const, providerReference }
: {}),
});
if (!order) {
// The worst-case failure in this whole flow: the customer went
@@ -258,68 +393,127 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
}
// Fire-and-forget — a failed confirmation email must never undo an
// already-successful order or block the response the customer is
// waiting on. Lower severity than the "order lost" alert above (the
// order itself is safe either way), but still worth knowing about, since
// it's the one thing that would otherwise fail completely silently.
sendOrderConfirmationEmail(
{
orderNumber: order.orderNumber,
createdAt: order.createdAt,
invoiceNumber: order.invoiceNumber,
invoiceIssuedAt: order.invoiceIssuedAt,
customerFirstName: body.firstName,
customerLastName: body.lastName,
deliveryMethod: body.deliveryMethod,
street: body.street,
packstationNumber: body.packstationNumber,
postNumber: body.postNumber,
zip: body.zip,
city: body.city,
country: body.country,
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
shippingFirstName: body.shippingFirstName,
shippingLastName: body.shippingLastName,
shippingDeliveryMethod: body.shippingDeliveryMethod,
shippingStreet: body.shippingStreet,
shippingPackstationNumber: body.shippingPackstationNumber,
shippingPostNumber: body.shippingPostNumber,
shippingZip: body.shippingZip,
shippingCity: body.shippingCity,
shippingCountry: body.shippingCountry,
paymentMethodTitle: paymentMethod.title,
items: items.map((i) => ({
productName: i.productName,
quantity: i.quantity,
unitPrice: i.unitPrice,
imageUrl: i.imageUrl,
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
variantName: i.variantName,
})),
subtotal,
shippingCost,
discountAmount,
discountCode: body.discountCode || null,
total,
},
body.email,
).catch((err) => {
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
orderNumber: order.orderNumber,
customerEmail: body.email,
error: String(err),
if (requiresPayment && providerReference) {
// Best-effort — see stripeProvider.attachOrderMetadata's own comment.
// Not fatal: the order's own `providerReference` field (already
// persisted above) remains the source of truth for the
// expirePendingPayments cleanup job either way; this only speeds up
// the webhook's fast path.
await paymentProvider.attachOrderMetadata(providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch((err) => {
sendCriticalAlert("Zahlungsmetadaten konnten nicht verknüpft werden", {
orderNumber: order.orderNumber,
providerReference,
error: String(err),
});
});
});
}
// Deferred for gated payment methods (Kreditkarte/PayPal) until the
// webhook confirms payment — see spicy-leaping-pizza.md §3/§4. Sent
// from the backend's confirm-payment endpoint instead, at that point.
// Unchanged for Überweisung: fires immediately, exactly as before.
if (!requiresPayment) {
// Fire-and-forget — a failed confirmation email must never undo an
// already-successful order or block the response the customer is
// waiting on. Lower severity than the "order lost" alert above (the
// order itself is safe either way), but still worth knowing about, since
// it's the one thing that would otherwise fail completely silently.
sendOrderConfirmationEmail(
{
orderNumber: order.orderNumber,
createdAt: order.createdAt,
invoiceNumber: order.invoiceNumber as string,
invoiceIssuedAt: order.invoiceIssuedAt as string,
customerFirstName: body.firstName,
customerLastName: body.lastName,
companyName: body.companyName || undefined,
vatId: normalizedVatId,
vatExempt,
kleinunternehmer,
deliveryMethod: body.deliveryMethod,
street: body.street,
packstationNumber: body.packstationNumber,
postNumber: body.postNumber,
zip: body.zip,
city: body.city,
country: body.country,
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
shippingFirstName: body.shippingFirstName,
shippingLastName: body.shippingLastName,
shippingCompanyName: body.hasDifferentShippingAddress ? body.shippingCompanyName : undefined,
shippingDeliveryMethod: body.shippingDeliveryMethod,
shippingStreet: body.shippingStreet,
shippingPackstationNumber: body.shippingPackstationNumber,
shippingPostNumber: body.shippingPostNumber,
shippingZip: body.shippingZip,
shippingCity: body.shippingCity,
shippingCountry: body.shippingCountry,
shippingContactEmail: body.hasDifferentShippingAddress ? body.shippingContactEmail : undefined,
shippingContactPhone: body.hasDifferentShippingAddress ? body.shippingContactPhone : undefined,
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,
sku: i.sku,
})),
subtotal: finalSubtotal,
shippingCost: finalShippingCost,
discountAmount,
discountCode: body.discountCode || null,
total,
isManualPayment: true,
// Only meaningful for the Vorkasse notice above — whether a
// switch to Kreditkarte/PayPal is even worth mentioning right now.
hasOnlinePaymentOption: groupPaymentMethodsForCheckout(paymentMethods).some((m) => m.provider === "stripe"),
},
body.email,
).catch((err) => {
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
orderNumber: order.orderNumber,
customerEmail: body.email,
error: String(err),
});
});
}
// Fire-and-forget, same reasoning as the confirmation email above — a
// failed marketing sync is not worth failing checkout over, and doesn't
// even need a critical alert (nothing customer-facing depends on it).
// Not gated on payment confirmation — a newsletter signup intent isn't
// an order-fulfillment concern, unlike the confirmation email/invoice.
if (body.newsletterOptIn) {
upsertNewsletterContact(body.email, "checkout").catch(() => {});
}
return NextResponse.json({
ok: true,
orderNumber: order.orderNumber,
orderId: order.id,
orderDateIso: order.createdAt,
shippingCost,
paymentMethodTitle: paymentMethod.title,
...(requiresPayment
? {
requiresPayment: true as const,
clientSecret,
testMode: isPaymentTestMode,
// Only surfaced in test mode — PaymentStep's "Testzahlung"
// buttons need it to call the test-confirm route directly,
// since there's no real Stripe redirect to carry it back
// through. A real PaymentIntent id isn't secret (only its
// client_secret is), but there's no reason to expose it to the
// client outside test mode either.
...(isPaymentTestMode ? { providerReference } : {}),
}
: {}),
shippingCost: finalShippingCost,
paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
discountCode: body.discountCode || null,
discountAmount,
vatExempt,
kleinunternehmer,
});
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../lib/customerAuth";
// Polled by /checkout/verarbeitung after a Payment Element redirect
// returns — see spicy-leaping-pizza.md §3. Requires the customer's own
// session (checkout is "Konto Pflicht", so one always exists by the time
// this page is reachable) rather than accepting a bare orderNumber, so a
// guessed/leaked order number can't be used to probe another customer's
// payment status.
export async function GET(request: Request) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const orderNumber = new URL(request.url).searchParams.get("orderNumber");
if (!orderNumber) return NextResponse.json({ ok: false, reason: "orderNumber fehlt." }, { status: 400 });
const order = await getCustomerOrderDetail(session.token, session.customer.id, orderNumber);
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
return NextResponse.json({
ok: true,
status: order.status,
paymentStatus: order.paymentStatus,
// Refined from the checkout-time "Online-Zahlung" placeholder to the
// actual instrument (Kreditkarte/PayPal) once confirm-payment sets it
// — see resolveStripePaymentMethodLabel's own comment. Returned here
// so VerarbeitungContent can patch the pending sessionStorage snapshot
// before promoting it, so /bestellbestaetigung shows the real one.
paymentMethodTitle: order.paymentMethodTitle,
});
}
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { validateDhlPostNumber } from "../../../lib/shippingDhl";
// Called from CheckoutContent.tsx's Postnummer field blur (Packstation
// delivery). Proxied through this Next.js route rather than fetched
// directly from the client the way VIES is (see validate-vat/route.ts) —
// DHL credentials are tenant-specific and live in Payload, unlike VIES's
// public EU endpoint, so the browser must never call Payload's DHL
// endpoint (or hold its own copy of tenant credentials) directly.
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const postNumber = typeof body?.postNumber === "string" ? body.postNumber : "";
const firstName = typeof body?.firstName === "string" ? body.firstName : "";
const lastName = typeof body?.lastName === "string" ? body.lastName : "";
if (!postNumber || !firstName || !lastName) {
return NextResponse.json({ ok: false, reason: "Postnummer, Vorname und Nachname sind erforderlich." }, { status: 400 });
}
const result = await validateDhlPostNumber({ postNumber, firstName, lastName });
return NextResponse.json(result);
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/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 });
}
+155
View File
@@ -0,0 +1,155 @@
import React from "react";
import { Document, Page, Text, View, StyleSheet, Font, renderToBuffer } from "@react-pdf/renderer";
import { getCompanySettings } from "../../lib/payload";
// react-pdf's default hyphenation would otherwise auto-split long words at
// syllable boundaries to fit the line — including inside the seller's own
// email address (confirmed: "hallo@einfach-produktiv.com" rendered as
// "einfach-produk-tiv.com"), which must never be broken mid-word.
Font.registerHyphenationCallback((word) => [word]);
// Generated on-the-fly from live company-settings instead of a static
// uploaded PDF (see /widerruf's own download link) — the "An:"
// address used to be baked into a hand-crafted PDF and silently went
// stale the moment an admin updated the Impressum's Anbieterdaten without
// remembering to also re-export/re-upload this file by hand (exactly what
// happened 2026-07-29: the address changed in company-settings, the PDF
// still had the old one). Same source data as AnbieterAngaben.tsx's
// Impressum block, so the two can never drift apart again.
const BRAND = "#f6a701";
const DARK = "#1b1b1a";
const GREY = "#737371";
const RULE = "#d1cec4";
const styles = StyleSheet.create({
page: {
fontSize: 10.5,
color: DARK,
paddingTop: 78,
paddingBottom: 64,
paddingHorizontal: 64,
},
topBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 8,
backgroundColor: BRAND,
},
title: {
fontSize: 15,
fontFamily: "Times-Bold",
marginBottom: 8,
},
subtitle: {
fontSize: 20,
fontFamily: "Times-Bold",
marginBottom: 10,
},
accent: {
width: 32,
height: 2.2,
backgroundColor: BRAND,
marginBottom: 14,
},
note: {
fontSize: 9.5,
color: GREY,
lineHeight: 1.4,
marginBottom: 16,
},
label: {
fontFamily: "Helvetica-Bold",
marginBottom: 4,
},
paragraph: {
lineHeight: 1.45,
marginBottom: 4,
},
paragraphSpaced: {
lineHeight: 1.45,
marginTop: 16,
marginBottom: 4,
},
section: {
marginTop: 20,
},
sectionLabel: {
fontSize: 8.5,
fontFamily: "Helvetica-Bold",
color: GREY,
marginBottom: 8,
},
sectionRule: {
borderBottomWidth: 0.75,
borderBottomColor: RULE,
},
footerNote: {
fontSize: 8.5,
color: GREY,
marginTop: 18,
},
});
const SECTION_HEADERS = [
"BESTELLTE WARE(N) / DIENSTLEISTUNG",
"BESTELLT AM (*)",
"ERHALTEN AM (*)",
"NAME DES/DER VERBRAUCHER(S)",
"ANSCHRIFT DES/DER VERBRAUCHER(S)",
"UNTERSCHRIFT DES/DER VERBRAUCHER(S) (NUR BEI MITTEILUNG AUF PAPIER)",
"DATUM",
];
export async function GET() {
const seller = await getCompanySettings();
const addressLine = seller
? `${seller.sellerName} - ${seller.sellerStreet}, ${seller.sellerZip} ${seller.sellerCity}, E-Mail: ${seller.sellerEmail}`
: "einfach produktiv.";
const doc = React.createElement(
Document,
{ title: "Muster-Widerrufsformular - einfach produktiv." },
React.createElement(
Page,
{ size: "A4", style: styles.page },
React.createElement(View, { style: styles.topBar }),
React.createElement(Text, { style: styles.title }, "einfach produktiv."),
React.createElement(Text, { style: styles.subtitle }, "Muster-Widerrufsformular"),
React.createElement(View, { style: styles.accent }),
React.createElement(
Text,
{ style: styles.note },
"(Wenn du diesen Vertrag widerrufen möchtest, fülle bitte dieses Formular aus und sende es per Post oder als E-Mail-Anhang an uns zurück.)",
),
React.createElement(Text, { style: styles.label }, "An:"),
React.createElement(Text, { style: styles.paragraph }, addressLine),
React.createElement(
Text,
{ style: styles.paragraphSpaced },
"Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*):",
),
...SECTION_HEADERS.map((header) =>
React.createElement(
View,
{ key: header, style: styles.section },
React.createElement(Text, { style: styles.sectionLabel }, header),
React.createElement(View, { style: styles.sectionRule }),
),
),
React.createElement(Text, { style: styles.footerNote }, "(*) Unzutreffendes streichen."),
),
);
const buffer = await renderToBuffer(doc);
return new Response(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'inline; filename="Muster-Widerrufsformular.pdf"',
"Cache-Control": "no-store",
},
});
}
+38
View File
@@ -0,0 +1,38 @@
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) {
// The customer-facing message stays generic on purpose (never leak
// Brevo's internal error text to a customer) — but the real reason
// was previously discarded entirely, which cost real debugging time
// tracking down a misconfigured BREVO_LIST_ID in Coolify (2026-07-25):
// every failure looked identical from the outside, whether it was a
// bad env var, a Brevo outage, or something else. Logged here so it's
// at least diagnosable from the container's own logs going forward.
console.error(`newsletter subscribe failed for source=${source}: ${result.reason}`);
return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 });
}
return NextResponse.json({ ok: true, alreadySubscribed: result.alreadySubscribed ?? false });
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export type SearchResult = {
type: "product" | "post";
id: string;
title: string;
href: string;
thumbnail: string | null;
};
// Lightweight instant search — plain `where[...][contains]` queries
// against Payload (Postgres ILIKE under the hood) rather than a real
// search index (Meilisearch/Algolia). Fine at this catalog size (a
// handful of products + blog posts, see [[project-ecommerce-sota-gaps]]'s
// own "search becomes necessary past ~20 products" note) — worth
// upgrading only once the catalog actually grows into that range, this
// route can be swapped out later without touching the frontend overlay.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim() ?? "";
if (q.length < 2) return NextResponse.json({ results: [] });
const productParams = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
"where[name][contains]": q,
depth: "1",
limit: "6",
});
const postParams = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[status][equals]": "published",
"where[title][contains]": q,
depth: "1",
limit: "6",
});
const [productsRes, postsRes] = await Promise.all([
fetch(`${PAYLOAD_URL}/api/products?${productParams}`, { next: { revalidate: 30 } }),
fetch(`${PAYLOAD_URL}/api/posts?${postParams}`, { next: { revalidate: 30 } }),
]);
const results: SearchResult[] = [];
if (productsRes.ok) {
const data: { docs?: { id: number; slug: string; name: string; detailHref: string | null; image: { url: string } | number | null }[] } =
await productsRes.json();
for (const doc of data.docs ?? []) {
results.push({
type: "product",
id: `product-${doc.id}`,
title: doc.name,
href: doc.detailHref || "/shop",
thumbnail: typeof doc.image === "object" && doc.image ? doc.image.url : null,
});
}
}
if (postsRes.ok) {
const data: { docs?: { id: number; slug: string; title: string; thumbnail: { url: string } | number | null }[] } = await postsRes.json();
for (const doc of data.docs ?? []) {
results.push({
type: "post",
id: `post-${doc.id}`,
title: doc.title,
href: `/blog/${doc.slug}`,
thumbnail: typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
});
}
}
return NextResponse.json({ results });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { isValidEmail } from "../../lib/email";
import { createStockNotification } from "../../lib/stockNotifications";
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email.trim() : "";
const productId = Number(body?.productId);
const variantName = typeof body?.variantName === "string" ? body.variantName : "";
if (!isValidEmail(email)) {
return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 });
}
if (!Number.isInteger(productId) || productId <= 0) {
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
}
const result = await createStockNotification(email, productId, variantName);
if (!result.ok) return NextResponse.json(result, { status: 500 });
return NextResponse.json({ ok: true });
}
+90
View File
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { verifyStripeWebhookSignature, resolveStripePaymentMethodLabel } from "../../../lib/payments/stripeProvider";
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../lib/payments/confirmPaymentEmail";
import { sendCriticalAlert } from "../../../lib/alertAdmin";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
// Real Stripe webhook — see spicy-leaping-pizza.md §4. Never reachable in
// PAYMENT_TEST_MODE in practice (no real Stripe account sends events
// here then), but left unconditional rather than gated on the env var —
// an invalid/missing signature already fails closed on its own.
export async function POST(request: Request) {
// Raw body only — request.json() would consume/reparse the stream and
// Stripe's signature is computed over the exact original bytes.
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ ok: false }, { status: 400 });
const event = verifyStripeWebhookSignature(rawBody, signature);
if (!event) return NextResponse.json({ ok: false, reason: "invalid signature" }, { status: 400 });
if (event.type !== "payment_intent.succeeded" && event.type !== "payment_intent.payment_failed") {
// Stripe sends many event types we don't act on (e.g.
// payment_intent.created, charge.*) — ack them so Stripe stops
// retrying something we were never going to process.
return NextResponse.json({ ok: true, ignored: event.type });
}
const intent = event.data.object as Stripe.PaymentIntent;
const providerReference = intent.id;
const orderId = intent.metadata?.orderId;
const paymentStatus = event.type === "payment_intent.succeeded" ? "paid" : "failed";
if (!orderId) {
// stripeProvider.attachOrderMetadata (called right after order
// creation in /api/checkout) failed to complete for this
// PaymentIntent — the order's own `providerReference` field is still
// the source of truth and expirePendingPayments will reconcile it
// eventually, but that's a multi-hour fallback, not instant. Alert
// now rather than silently relying on the cleanup job.
sendCriticalAlert("Stripe-Webhook ohne orderId-Metadaten", { providerReference, paymentStatus, eventType: event.type });
// Non-2xx so Stripe retries — a later retry might land after the
// metadata attach (which races the checkout response) has caught up.
return NextResponse.json({ ok: false, reason: "orderId metadata missing" }, { status: 409 });
}
// Best-effort — see resolveStripePaymentMethodLabel's own comment. Only
// meaningful on the "paid" path; a failed payment never gets a
// paymentMethodTitle refinement (the order becomes 'cancelled' outright).
const paymentMethodTitle = paymentStatus === "paid" ? await resolveStripePaymentMethodLabel(intent) : undefined;
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
method: "POST",
headers: {
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentStatus,
providerReference,
paidAt: new Date().toISOString(),
...(paymentMethodTitle ? { paymentMethodTitle } : {}),
}),
}).catch((err) => {
sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
return null;
});
if (!res || !res.ok) {
// Non-2xx on purpose — lets Stripe's own retry schedule (~3 days)
// provide resilience instead of building an internal retry queue.
return NextResponse.json({ ok: false }, { status: 502 });
}
const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
// Fire-and-forget, same reasoning as the checkout route's own send: a
// failed confirmation email must never turn an already-successful
// payment confirmation into a non-2xx response (that would make Stripe
// retry a webhook we've already fully processed). `alreadyProcessed`/
// missing `order` means this is a repeat delivery — see confirmPayment.ts's
// own comment on why the email must not be sent twice.
if (data.order && !data.alreadyProcessed) {
void sendConfirmedPaymentEmail(data.order);
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { isPaymentTestMode } from "../../../../lib/payments";
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../../lib/payments/confirmPaymentEmail";
import { sendCriticalAlert } from "../../../../lib/alertAdmin";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
// Test-mode stand-in for the real Stripe webhook — see
// spicy-leaping-pizza.md §7. Drives the exact same backend confirm-payment
// endpoint the real webhook calls, just without a real Stripe event/
// signature (there is none to verify in test mode). Hard-gated: must
// 404 whenever PAYMENT_TEST_MODE isn't explicitly on, so this can never
// become an unauthenticated "mark any order paid" endpoint in production.
export async function POST(request: Request) {
if (!isPaymentTestMode) {
return NextResponse.json({ ok: false }, { status: 404 });
}
const body = await request.json().catch(() => null);
const orderId = body?.orderId;
const providerReference = body?.providerReference;
const paymentStatus = body?.paymentStatus === "failed" ? "failed" : "paid";
if (!orderId || !providerReference) {
return NextResponse.json({ ok: false, reason: "orderId und providerReference erforderlich." }, { status: 400 });
}
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
method: "POST",
headers: {
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
"Content-Type": "application/json",
},
body: JSON.stringify({ paymentStatus, providerReference, paidAt: new Date().toISOString() }),
}).catch((err) => {
sendCriticalAlert("Test-confirm-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
return null;
});
if (!res || !res.ok) {
return NextResponse.json({ ok: false, reason: "Backend hat die Testzahlung nicht bestätigt." }, { status: 502 });
}
const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
// Same email-send as the real webhook route — see its own comment and
// confirmPaymentEmail.ts. Reproduces today's "immediate confirmation"
// behavior on a test click, exercising the real send path rather than a
// separate short-circuit.
if (data.order && !data.alreadyProcessed) {
void sendConfirmedPaymentEmail(data.order);
}
return NextResponse.json({ ok: true });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -7,6 +7,7 @@ import type { CartItem } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { computeExemptTotals } from "../../lib/vatExemption";
import { formatPrice, formatDate } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CheckoutSteps } from "../../components/CheckoutSteps";
@@ -31,7 +32,9 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
typeof data.shippingCost !== "number" ||
typeof data.paymentMethodTitle !== "string" ||
(data.discountCode !== null && typeof data.discountCode !== "string") ||
typeof data.discountAmount !== "number"
typeof data.discountAmount !== "number" ||
typeof data.vatExempt !== "boolean" ||
typeof data.kleinunternehmer !== "boolean"
) {
return null;
}
@@ -100,20 +103,42 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
.filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
// Displays the *persisted* discount from the snapshot, not a fresh
// re-derivation — the purchase already happened, this page is a
// Displays the *persisted* discount/shippingCost from the snapshot, not
// a fresh re-derivation — the purchase already happened, this page is a
// receipt, not a live cart, so it doesn't re-validate the code at all.
const { subtotal, totalSavings, total } = computeCartTotals(items, order.shippingCost, {
// order.shippingCost is already the actual (possibly de-grossed, if
// vatExempt) figure charged at checkout — see api/checkout/route.ts's
// own response. `subtotal`/`taxBreakdown` below still need their own
// exempt branch, though: computeCartTotals/computeTaxBreakdown build
// `subtotal` from each item's *current catalog* gross price via
// effectivePrice(), which for an exempt order was never what was
// actually charged (the catalog price includes VAT; the exempt order
// paid the de-grossed net price instead).
const { subtotal: catalogSubtotal, totalSavings, total: catalogTotal } = computeCartTotals(items, order.shippingCost, {
type: "fixed",
value: order.discountAmount,
});
const exemptTotals = order.vatExempt
? computeExemptTotals(
items.map(({ entry, product }) => ({
quantity: entry.qty,
grossUnitPrice: effectivePrice(entry, product),
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
})),
order.shippingCost,
defaultTaxRate,
order.discountAmount,
)
: null;
const subtotal = exemptTotals?.subtotal ?? catalogSubtotal;
const total = exemptTotals?.total ?? catalogTotal;
const taxBreakdown = computeTaxBreakdown(
items.map(({ entry, product }) => ({
quantity: entry.qty,
unitPrice: effectivePrice(entry, product),
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
})),
subtotal,
catalogSubtotal,
order.discountAmount,
order.shippingCost,
);
@@ -158,9 +183,23 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
</Reveal>
{!productsLoading && (
// Outer card+sidebar split stays lg: (fixed 18rem delivery-status
// sidebar, L297 below — same fixed-width-block category as Tier C
// exceptions elsewhere), while the inner order-meta/items row
// already switches at sm: — intentional, not an inconsistent
// leftover: the inner row has no fixed-width sidebar to fight for
// room against, just two flexible columns.
//
// max-w-[36rem] between sm and lg (same fix/value as
// NewsletterModal.tsx's own dialog cap) — max-w-[56rem] (896px)
// doesn't actually constrain anything below that viewport width,
// so at real Tablet widths (640-1023px) this card stretched to
// fill the full page width instead of reading as a compact,
// centered card. Widens to the real 56rem cap only once lg:'s
// sidebar split kicks in and needs the room.
<Reveal
delay={0.05}
className="w-full max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
className="w-full max-w-[36rem] lg:max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
>
<div className="flex-1 p-6 md:p-8 flex flex-col sm:flex-row gap-8">
{/* Order meta */}
@@ -205,7 +244,7 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
{entry.variant ? ` (${entry.variant})` : ""}
</p>
<p className="text-label text-text-muted">
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. {taxRate}% MwSt.</span>
{entry.qty} × {formatPrice(unitPrice)} {!order.kleinunternehmer && <span>inkl. {taxRate}% MwSt.</span>}
</p>
</div>
<p className="text-body-sm text-text-primary whitespace-nowrap">
@@ -257,7 +296,13 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
<VatBreakdown groups={taxBreakdown} />
{order.kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : order.vatExempt ? (
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
) : (
<VatBreakdown groups={taxBreakdown} />
)}
</div>
</div>
</div>
@@ -297,8 +342,8 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
identical height regardless of how many lines the quote wraps
to (a min-height alone lets whichever column has more content
stretch the row past what the other side visually fills). */}
<Reveal className="relative w-full flex items-stretch h-[14rem] md:h-[16rem] bg-bg-muted overflow-hidden">
<div className="relative w-full md:w-[45%] shrink-0">
<Reveal className="relative w-full flex items-stretch h-[14rem] sm:h-[16rem] bg-bg-muted overflow-hidden">
<div className="relative w-full sm:w-[45%] shrink-0">
{/* -inset-1, not inset-0 — this section fades in via Reveal's
y:28→0 transform; a plain inset-0 image can leave a
hairline gap at the top edge while that's still settling
@@ -312,10 +357,10 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
src="/bestellbestaetigung-testimonial-photo.jpg"
width={366}
height={126}
sizes="(min-width: 768px) 45vw, 100vw"
sizes="(min-width: 640px) 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" />
<div className="hidden sm:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
</div>
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16">
<p
@@ -9,7 +9,7 @@ import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../.
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Live-previewable subset of the blog detail page: title/category/readTime/
// Live-previewable subset of the blog detail page: title/categories/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
@@ -27,7 +27,7 @@ export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
<>
<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>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
+41 -17
View File
@@ -7,8 +7,9 @@ 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 { getBlogPosts, getPostBySlug, getCompanySettings } from "../../lib/payload";
import { formatDate } from "../../lib/format";
import { buildArticleSchema } from "../../lib/structuredData";
export async function generateMetadata({
params,
@@ -19,16 +20,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,
},
};
}
@@ -48,9 +62,12 @@ export default async function BlogDetailPage({
// showing a fake/duplicate card when this is the only post.
const otherPosts = await getBlogPosts(4);
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
const seller = await getCompanySettings();
const articleSchema = buildArticleSchema(post, seller);
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
<main className="flex flex-col flex-1 bg-bg-base">
{isPreview ? (
<LivePostContent initialPost={post} />
@@ -58,7 +75,7 @@ export default async function BlogDetailPage({
<>
<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>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
@@ -120,27 +137,34 @@ export default async function BlogDetailPage({
{post.relatedProduct?.href && (
<Link
href={post.relatedProduct.href}
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
className="group flex items-center gap-4 sm:gap-6 border border-border rounded-md px-5 py-5 sm:px-9 sm:py-7 hover:border-brand transition-colors"
>
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
<Image alt="" src={post.relatedProduct.image} fill sizes="64px" className="object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
<div className="flex items-end justify-between gap-4 w-full">
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
so the description wraps at the same point instead of
stretching out to fill the space before "Entdecken". */}
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
{/* Stacked below sm: — the fixed w-[19rem] title column plus
"Entdecken" on the same row overflowed a mobile-width
card (fixed 2026-07-24). "Entdecken" wraps to its own
line with a little space above it; back to the
side-by-side row (matching Figma) from sm: up, where
there's room for both. */}
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
{/* w-[19rem] (305px) only from sm: — matches Figma's
title-col exactly there, so the description wraps at
the same point instead of stretching out to fill the
space before "Entdecken"; full width below sm:. */}
<div className="flex flex-col gap-2 items-start w-full sm:w-[19rem] sm:shrink-0">
<p
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
className="font-semibold text-[1.375rem] text-text-primary sm:whitespace-nowrap"
style={{ fontFamily: "var(--font-lora)" }}
>
{post.relatedProduct.name}
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.description}</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.descriptionText}</p>
</div>
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap mt-1 sm:mt-0">
Entdecken
<svg
viewBox="0 0 20 20"
@@ -197,7 +221,7 @@ export default async function BlogDetailPage({
</div>
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{nextPost.category}</span>
<span>{nextPost.categories.join(", ")}</span>
<span></span>
<span>{nextPost.readTime} Min</span>
</div>
+102 -41
View File
@@ -4,65 +4,110 @@ import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
import { Newsletter } from "../components/Newsletter";
import { Footer } from "../components/Footer";
import { getBlogPosts } from "../lib/payload";
import { BlogCategoryFilter } from "../components/BlogCategoryFilter";
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
import { formatDate } from "../lib/format";
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() {
const posts = await getBlogPosts(100);
const [featured, ...rest] = posts;
// Every distinct category name in DOM order — Payload's `categories` field
// stores related docs, no separate slug on this side, but since this page
// already fetches every published post (limit 100, no pagination), a
// plain client-agnostic array filter is enough; no separate Payload query
// per category needed.
function distinctCategories(posts: { categories: string[] }[]): string[] {
const seen = new Set<string>();
for (const post of posts) for (const c of post.categories) seen.add(c);
return Array.from(seen);
}
export default async function BlogOverviewPage({
searchParams,
}: {
searchParams: Promise<{ categories?: string }>;
}) {
const [allPosts, blogFilterEnabled] = await Promise.all([getBlogPosts(100), getBlogFilterEnabled()]);
const { categories: categoriesParam } = await searchParams;
const activeCategories = blogFilterEnabled && categoriesParam ? categoriesParam.split(",").filter(Boolean) : [];
const allCategories = blogFilterEnabled ? distinctCategories(allPosts) : [];
const posts =
activeCategories.length === 0 ? allPosts : allPosts.filter((post) => post.categories.some((c) => activeCategories.includes(c)));
// getBlogPosts sorts "-featured,-publishedAt", so index 0 IS the actual
// featured post when the unfiltered list is shown — but a category
// filter can (and often will) exclude it entirely, in which case index 0
// is just the newest matching post, not an editorially featured one. It
// still doesn't deserve the big hero-card treatment (implies "the
// featured post", not "whatever sorted first"), so that layout is gated
// on the post's own `featured` flag, not on array position.
const [first, ...restAll] = posts;
const featured = first?.featured ? first : undefined;
const rest = featured ? restAll : posts;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
{/* Header — copy left, photo bleeds to the viewport edge on the
right with a left-edge fade into bg-base, matching Figma's
hero-fade-left/hero-fade-bottom overlays (node 4577:330). */}
<Reveal className="relative flex flex-col md:flex-row items-center w-full min-h-[20rem] md:min-h-[27.5rem] border-b border-border overflow-hidden">
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 md:py-0 w-full md:w-auto md:max-w-[26rem]">
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Blog
</p>
<p className="text-body text-text-muted">
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
</p>
</div>
<div className="relative w-full md:absolute md:inset-y-0 md:right-0 md:w-[68%] h-56 md:h-full">
<Image alt="" src="/hero.jpg" fill sizes="(min-width: 768px) 68vw, 100vw" className="object-cover" />
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-bg-base to-transparent" />
</div>
{/* Header — plain text, same treatment as /shop's ShopHeader.tsx
and every legal page (the bespoke photo-bleed hero this used to
be predated that convention being established elsewhere). */}
<Reveal className="flex flex-col gap-4 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full border-b border-border">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
<span></span>
<span className="text-text-primary">Blog</span>
</p>
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Blog
</p>
<p className="text-body text-text-muted">
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
</p>
{allCategories.length > 1 && (
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
)}
</Reveal>
{posts.length === 0 && (
<p className="text-body text-text-muted w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-10">
Keine Beiträge in dieser Kategorie gefunden.
</p>
)}
{featured && (
// -mt-8, not pt-10 — pulls the card up to slightly overlap the
// hero section's bottom edge instead of sitting flush below it.
// relative z-10 keeps it stacked above the hero's own photo.
<Reveal delay={0.1} className="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
// key'd for the same reason as RevealGroup below — guards against
// the same stuck-at-opacity-0 failure mode if a future filter
// combination ever swaps in a *different* featured post without
// an intervening moment where `featured` was falsy.
<Reveal key={featured.id} delay={0.1} className="w-full px-[var(--layout-padding-x)] pt-8 pb-4">
<Link
href={`/blog/${featured.slug}`}
className="group grid grid-cols-1 md:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
className="group grid grid-cols-1 sm:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-video md:aspect-auto md:h-full bg-bg-muted">
<div className="relative w-full aspect-video sm:aspect-auto sm:h-full bg-bg-muted">
{featured.thumbnail && (
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 768px) 40rem, 100vw" className="object-cover" />
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 640px) 40rem, 100vw" className="object-cover" />
)}
</div>
<div className="flex flex-col justify-center gap-3 p-8 md:p-12 min-w-0">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{featured.category}</span>
<div className="flex flex-col justify-center gap-3 p-8 sm:p-12 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span className="whitespace-nowrap">{featured.categories.join(", ")}</span>
<span></span>
<span>{featured.readTime} Min</span>
<span className="whitespace-nowrap">{featured.readTime} Min</span>
<span></span>
<span className="uppercase">{formatDate(featured.publishedAt)}</span>
<span className="uppercase whitespace-nowrap">{formatDate(featured.publishedAt)}</span>
</div>
<p
className="font-semibold text-h-section text-text-primary"
@@ -88,7 +133,23 @@ export default async function BlogOverviewPage() {
)}
{rest.length > 0 && (
<RevealGroup className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border">
// key'd on the rendered post set — RevealGroup's `whileInView`
// only fires once per component instance (viewport.once=true),
// and a category-filter change re-renders this same page
// component in place (only `searchParams` differs, no full
// remount). Since `rest.length > 0` stays true across most
// filter transitions, RevealGroup itself never naturally
// unmounts, so once it has already fired "show" for one
// filter's list, freshly swapped-in RevealItems (new post ids)
// mount into an already-settled parent that has no reason to
// re-fire the reveal trigger — they were stuck at opacity 0
// forever, reported as the list appearing blank after refiltering.
// Forcing a remount on every distinct post set restarts
// RevealGroup's viewport tracking from scratch each time.
<RevealGroup
key={rest.map((post) => post.id).join(",")}
className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border"
>
{rest.map((post) => (
<RevealItem key={post.id} className="py-8 first:pt-0 last:pb-0">
<Link href={`/blog/${post.slug}`} className="group flex flex-col sm:flex-row gap-6 items-start">
@@ -98,12 +159,12 @@ export default async function BlogOverviewPage() {
)}
</div>
<div className="flex flex-col gap-2 min-w-0 flex-1">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span className="whitespace-nowrap">{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
<span className="whitespace-nowrap">{post.readTime} Min</span>
<span></span>
<span className="uppercase">{formatDate(post.publishedAt)}</span>
<span className="uppercase whitespace-nowrap">{formatDate(post.publishedAt)}</span>
</div>
<p
className="font-semibold text-h-small text-text-primary"
+129 -49
View File
@@ -7,12 +7,14 @@ 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 { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } 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 { ArrowLeftIcon } from "../../components/ArrowLeftIcon";
import { ProductName } from "../../components/ProductName";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -22,6 +24,7 @@ export function CartContent({
freeShippingThreshold,
shippingSettings,
defaultTaxRate,
kleinunternehmer,
showDiscountField,
}: {
trustBadges: TrustBadge[];
@@ -42,6 +45,11 @@ export function CartContent({
* override taxRatePercent themselves — see lib/cartTotals.ts's
* effectiveTaxRate(). */
defaultTaxRate: number;
/** §19 UStG — this tenant's company-settings.kleinunternehmer (Payload's
* lib/payload.ts's getKleinunternehmer(), same ISR freshness as
* defaultTaxRate above). Drops the "inkl. X% MwSt." hints and the VAT
* breakdown in favor of the §19 notice below. */
kleinunternehmer: boolean;
/** Whether Payload currently has at least one active discount code at
* all (lib/discountServer.ts's hasActiveDiscountCode()) — no point
* showing an open "enter a code" field when nothing could ever validate
@@ -71,7 +79,7 @@ export function CartContent({
const subtotal = computeSubtotal(items);
const shipping =
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
items.length === 0 || !cartHasShippableItem(items) || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
? 0
: shippingCost;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
@@ -157,22 +165,28 @@ export function CartContent({
href="/shop"
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
>
<span aria-hidden></span>
<ArrowLeftIcon />
<span className="font-bold text-body-sm">Weiter einkaufen</span>
</Link>
</Reveal>
) : (
<>
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
<FreeShippingBanner subtotal={subtotal} threshold={freeShippingThreshold} />
{/* threshold forced to null (banner just doesn't render, see its
own early return) whenever nothing in the cart actually
triggers shipping — a "€X bis kostenlosem Versand" nudge
makes no sense for a cart that was never going to be charged
shipping in the first place. */}
<FreeShippingBanner subtotal={subtotal} threshold={cartHasShippableItem(items) ? freeShippingThreshold : null} />
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-4 px-[var(--layout-padding-x)] w-full">
{/* Cart card — lg:-only split from the sidebar (same "wide content
next to sidebar" shape as the Hero's image/text split, see
figma-to-nextjs skill Gotcha #5: Figma's 830px card alone
already exceeds the 768px Tablet floor, so md: would never
have had room for a real 2-column layout at Tablet widths
anyway). */}
already exceeds even the site's 640px structural floor, so
sm: would never have had room for a real 2-column layout at
Tablet widths anyway — a deliberate exception to the site-wide
sm: consolidation, not a leftover of it). */}
<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);
@@ -208,8 +222,32 @@ export function CartContent({
<div key={lineKey} className="w-full">
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
<div className="relative size-[9.375rem] shrink-0 rounded-sm overflow-hidden">
<Image src={product.image} alt={product.name} fill sizes="150px" className="object-cover" />
{/* Full-width on mobile (stacked layout) instead of the
fixed 150px square — a small square floating above
the text looked cramped on a narrow column that has
the width to spare; fixed 150px square again from
sm: once the row layout kicks in and the image sits
beside the text instead. */}
<div className="relative w-full aspect-square sm:size-[9.375rem] sm:shrink-0 rounded-sm overflow-hidden">
{product.href ? (
<Link href={product.href} aria-label={product.name} className="block w-full h-full">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 640px) 150px, 100vw"
className="object-cover"
/>
</Link>
) : (
<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}%
@@ -217,18 +255,29 @@ export function CartContent({
)}
</div>
<div className="flex flex-col gap-[0.625rem] items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h-small text-text-primary hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
<ProductName name={product.name} />
{entry.variant ? ` (${entry.variant})` : ""}
</Link>
) : (
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
<ProductName name={product.name} />
{entry.variant ? ` (${entry.variant})` : ""}
</p>
)}
{product.subline && <p className="text-body-sm text-text-muted">{product.subline}</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>
<p className="flex items-baseline gap-1.5">
@@ -236,7 +285,7 @@ export function CartContent({
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
</div>
@@ -278,7 +327,7 @@ export function CartContent({
href="/shop"
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
>
<span aria-hidden></span>
<ArrowLeftIcon />
<span className="font-bold text-body-sm">Weiter einkaufen</span>
</Link>
</Reveal>
@@ -373,31 +422,40 @@ export function CartContent({
)}
<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">
Versand
<button
type="button"
onClick={() => setVersandOpen(true)}
className="text-text-muted hover:text-brand transition-colors"
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
>
<span aria-hidden></span>
</button>
</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
</span>
</div>
<p className="text-label text-text-muted">
{shipping === 0 && freeShippingThreshold !== null
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
: "innerhalb Deutschlands"}
</p>
<p className="text-label text-text-muted">
Lieferzeit {shippingSettings.totalDays.min}{shippingSettings.totalDays.max} Werktage
</p>
{/* Hidden entirely (not just "Kostenlos") when nothing in
the cart actually triggers shipping at all — that's a
different state from hitting the free-shipping
threshold, which is still a real promotional message
worth showing. */}
{cartHasShippableItem(items) && (
<>
<div className="flex items-center w-full">
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
Versand
<button
type="button"
onClick={() => setVersandOpen(true)}
className="text-text-muted hover:text-brand transition-colors"
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
>
<span aria-hidden></span>
</button>
</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
</span>
</div>
<p className="text-label text-text-muted">
{shipping === 0 && freeShippingThreshold !== null
? `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" />
@@ -413,7 +471,11 @@ export function CartContent({
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
<VatBreakdown groups={taxBreakdown} />
{kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : (
<VatBreakdown groups={taxBreakdown} />
)}
</div>
<Link
@@ -434,10 +496,22 @@ export function CartContent({
</div>
{/* Title only — /checkout renders the same CartTrustBadges
docs with description too, see CheckoutContent.tsx. */}
<div className="flex flex-col gap-4 items-start w-full">
docs with description too, see CheckoutContent.tsx.
Side by side between sm (640px) and lg (1024px) — this
sidebar is full page width through that whole range (it
only becomes a narrow fixed-width column at lg:, see this
file's own lg:w-[20.625rem] above), plenty of room for 3
short titles in a row; stacked at true mobile and again
once the sidebar narrows at lg:. sm:w-auto with no
flex-grow (was sm:flex-1) so each badge only takes its own
content width instead of stretching to fill the full card
width; sm:justify-center centers that shrink-to-fit row so
the leftover space splits evenly left/right instead of
collecting on the right (the default with items packed
from the start edge). */}
<div className="flex flex-col sm:flex-row sm:flex-wrap lg:flex-col gap-4 sm:gap-x-8 sm:gap-y-4 lg:gap-4 items-start sm:justify-center lg:justify-start w-full">
{trustBadges.map((b) => (
<div key={b.id} className="flex gap-3 items-center w-full">
<div key={b.id} className="flex gap-3 items-center w-full sm:w-auto lg:w-full">
<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>
@@ -474,7 +548,13 @@ export function CartContent({
</Reveal>
)}
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
<VersandModal
open={versandOpen}
onClose={() => setVersandOpen(false)}
shipping={shippingSettings}
shippingCost={shippingCost}
freeShippingThreshold={freeShippingThreshold}
/>
</>
);
}
+30 -72
View File
@@ -1,12 +1,10 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useProducts } from "../../lib/products";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { Reveal } from "../../components/Reveal";
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { ProductCard } from "../../components/ProductCard";
import { FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { useCart } from "../../lib/cart";
const DISPLAY_COUNT = 3;
@@ -30,7 +28,15 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
}
export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number }) {
export function RelatedProducts({
defaultTaxRate,
kleinunternehmer,
wishlistEnabled,
}: {
defaultTaxRate: number;
kleinunternehmer: boolean;
wishlistEnabled: boolean;
}) {
const cart = useCart();
const products = useProducts();
// Cart/checkout resolve any product regardless of `active` (see
@@ -123,18 +129,23 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
(opacity: 0) — invisible. Not worth chasing a fix for a
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => {
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
{/* No items-start, and grid-auto-rows + subgrid on each card — see
ProductGrid.tsx's own comment on why both are needed for
ProductCard's internal rows (price, etc.) to actually line up
across cards, not just overall card height. No RevealItem
wrapper here (see comment above), so the subgrid classes go
straight onto ProductCard's own className prop instead. */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => (
<ProductCard
key={product.id}
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled={wishlistEnabled}
wishlistRevealOnHover
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 " +
"sm:col-span-4 [grid-row:span_6] " +
// 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
@@ -142,67 +153,14 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
// it. 3-card case keeps the default left-to-right flow.
(i === 0
? displayProducts.length === 1
? "md:col-start-5"
? "sm:col-start-5"
: displayProducts.length === 2
? "md:col-start-3"
? "sm:col-start-3"
: ""
: "")
}
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
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
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
<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>
<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>
);
+6 -3
View File
@@ -4,7 +4,7 @@ import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
import { hasActiveDiscountCode } from "../lib/discountServer";
// robots: noindex — transactional page (mirrors a specific shopper's cart
@@ -20,12 +20,14 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
const [trustBadges, shippingMethods, shipping, defaultTaxRate, showDiscountField] = await Promise.all([
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField, wishlistEnabled] = await Promise.all([
getCartTrustBadges(),
getShippingMethods(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
hasActiveDiscountCode(),
getWishlistEnabled(),
]);
// The cart doesn't ask which shipping method the shopper wants yet
@@ -55,10 +57,11 @@ export default async function CartPage() {
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
showDiscountField={showDiscountField}
/>
</Suspense>
<RelatedProducts defaultTaxRate={defaultTaxRate} />
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} wishlistEnabled={wishlistEnabled} />
<TrustRow />
</main>
<Footer />
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { autocompleteDhlAddress, type DhlAddressSuggestion } from "../../lib/shippingDhl";
// Wraps a plain street-address input with a DHL DataFactory suggestion
// dropdown. Completely invisible/inert when the tenant doesn't have
// autocomplete activated (dhl-settings.autocompleteEnabled off) — the proxy
// route just returns an empty suggestions array in that case (see
// app/api/checkout/autocomplete-address/route.ts), so this degrades to a
// plain text input with no dropdown ever appearing, same "no half-built UI
// when a feature is off" convention as WishlistButton/searchEnabled.
//
// Styling duplicates FormField's classes (defined locally in
// CheckoutContent.tsx, not exported) rather than importing it, to keep this
// component usable on its own.
export function AddressAutocomplete({
label,
name,
value,
onChange,
onBlur,
onSelectSuggestion,
error,
placeholder,
autoComplete,
required,
wrapperClassName = "flex-1 min-w-0",
}: {
label: string;
name?: string;
value: string;
onChange: (value: string) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
onSelectSuggestion: (suggestion: DhlAddressSuggestion) => void;
error?: string;
placeholder?: string;
autoComplete?: string;
required?: boolean;
wrapperClassName?: string;
}) {
const [suggestions, setSuggestions] = useState<DhlAddressSuggestion[]>([]);
const [open, setOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (value.trim().length < 3) {
setSuggestions([]);
return;
}
debounceRef.current = setTimeout(async () => {
const res = await fetch(`/api/checkout/autocomplete-address?query=${encodeURIComponent(value)}`);
const data = await res.json().catch(() => ({ ok: false, suggestions: [] }));
setSuggestions(data.ok ? data.suggestions : []);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<label className={`relative flex flex-col gap-2 items-start ${wrapperClassName}`}>
<span className="text-label text-text-muted">{label}</span>
<input
name={name}
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
// Delayed so a click on a suggestion below (which itself fires a
// blur first) still registers before the dropdown unmounts.
onBlur={(e) => {
setTimeout(() => setOpen(false), 150);
onBlur?.(e);
}}
placeholder={placeholder}
autoComplete={autoComplete}
required={required}
aria-invalid={error ? true : undefined}
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
error ? "border-red-600" : "border-border"
}`}
/>
{error && <span className="text-label text-red-600">{error}</span>}
{open && suggestions.length > 0 && (
<ul className="absolute top-full left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-sm border border-border bg-background shadow-lg">
{suggestions.map((s, i) => (
<li key={i}>
<button
type="button"
onClick={() => {
onChange(`${s.street}${s.houseNumber ? ` ${s.houseNumber}` : ""}`);
onSelectSuggestion(s);
setOpen(false);
}}
className="w-full text-left px-4 py-2 text-body-sm text-text-primary hover:bg-brand/10 transition-colors"
>
{s.street}
{s.houseNumber ? ` ${s.houseNumber}` : ""}, {s.zip} {s.city}
</button>
</li>
))}
</ul>
)}
</label>
);
}
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
"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;
/** Where /checkout/verarbeitung sends the customer once payment is
* confirmed — "checkout" (default) clears the cart/draft and lands on
* /bestellbestaetigung, exactly like today. "account" is used by the
* account order detail page's "Zahlungsart ändern" flow (an existing,
* already-confirmed order — nothing to clear, and /bestellbestaetigung
* would be the wrong destination): lands back on that same order's
* page instead. See VerarbeitungContent.tsx's own branching on this. */
returnContext?: "checkout" | "account";
};
// 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. Also reused as-is by
// the account order detail page's payment-method-switch flow (see
// returnContext above) — the Stripe collection UI itself is identical
// either way, only the post-payment destination differs.
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference, returnContext = "checkout" }: Props) {
if (testMode) {
return (
<TestPaymentButtons
orderNumber={orderNumber}
orderId={orderId}
providerReference={providerReference ?? ""}
returnContext={returnContext}
/>
);
}
return (
<Elements stripe={getStripe()} options={{ clientSecret }}>
<StripePaymentForm orderNumber={orderNumber} returnContext={returnContext} />
</Elements>
);
}
function StripePaymentForm({ orderNumber, returnContext }: { orderNumber: string; returnContext: "checkout" | "account" }) {
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)}&context=${returnContext}`,
},
});
// 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 px-6 py-3 text-text-primary font-semibold hover:bg-brand-hover active:scale-[0.97] transition-all disabled:opacity-50"
>
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
</button>
</form>
);
}
function TestPaymentButtons({
orderNumber,
orderId,
providerReference,
returnContext,
}: {
orderNumber: string;
orderId: number;
providerReference: string;
returnContext: "checkout" | "account";
}) {
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)}&context=${returnContext}`);
} 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>
);
}
+6 -2
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { CheckoutContent } from "./components/CheckoutContent";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getShippingMethods, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { getShippingMethods, getShippingCountries, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { getSessionCustomer, getCustomerProfile } from "../lib/customerAuth";
// robots: noindex — transactional page, same reasoning as /cart.
@@ -16,12 +16,14 @@ export const metadata: Metadata = {
};
export default async function CheckoutPage() {
const [shippingMethods, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, session] = await Promise.all([
const [shippingMethods, shippingCountries, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, kleinunternehmer, session] = await Promise.all([
getShippingMethods(),
getShippingCountries(),
getPaymentMethods(),
getCartTrustBadges(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getSessionCustomer(),
]);
// Full profile (incl. saved address) only fetched when a session exists
@@ -33,10 +35,12 @@ export default async function CheckoutPage() {
<main className="flex flex-col flex-1 bg-bg-base">
<CheckoutContent
shippingMethods={shippingMethods}
shippingCountries={shippingCountries}
paymentMethods={paymentMethods}
trustBadges={trustBadges}
shippingSettings={shippingSettings}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
customerEmail={session?.customer.email ?? null}
savedProfile={profile}
/>
@@ -0,0 +1,161 @@
"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");
// "account" — the payment-method-switch flow on an existing order's
// account page (see PaymentStep.tsx's own returnContext comment).
// Defaults to "checkout" for a bare/missing param, same as before this
// branch existed.
const isAccountContext = searchParams.get("context") === "account";
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") {
if (isAccountContext) {
// Nothing to clear — this order was already placed (as
// Überweisung) and confirmed long before this switch, there's
// no cart/discount/draft snapshot involved. Land back on the
// same order instead of /bestellbestaetigung, which would
// read as a brand-new purchase.
router.push(`/konto/bestellungen/${encodeURIComponent(orderNumber!)}`);
return;
}
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">
{isAccountContext
? "Die Zahlung konnte nicht abgeschlossen werden. Deine Bestellung bleibt unverändert — du kannst es jederzeit erneut versuchen."
: "Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen."}
</p>
<Link
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/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"
>
{isAccountContext ? "Zurück zur Bestellung" : "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={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/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"
>
{isAccountContext ? "Zurück zur Bestellung" : "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>
);
}
@@ -31,7 +31,12 @@ export function LiveCompanySettingsPreviewClient({ initialSettings }: { initialS
return (
<PDFViewer style={{ width: "100%", height: "100vh", border: "none" }}>
<InvoiceDocument order={SAMPLE_INVOICE_ORDER} seller={data} />
{/* kleinunternehmer isn't part of InvoiceSeller (it's snapshotted
per-order, not read live off the seller — see invoicePdf.tsx's
own comment) — merged onto the sample order here only, so an
admin toggling the checkbox sees the §19 notice reflected live
without this preview needing its own separate mechanism. */}
<InvoiceDocument order={{ ...SAMPLE_INVOICE_ORDER, kleinunternehmer: data.kleinunternehmer }} seller={data} />
</PDFViewer>
);
}
+4
View File
@@ -21,10 +21,14 @@ const FALLBACK: CompanySettings = {
sellerCity: "",
sellerCountry: "",
sellerEmail: "",
emailFromName: null,
emailFromAddress: null,
vatId: "",
taxRatePercent: 19,
kleinunternehmer: false,
iban: null,
bic: null,
bankName: null,
};
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
+41 -18
View File
@@ -3,12 +3,20 @@ import { Reveal } from "./Reveal";
export function About() {
return (
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col sm:flex-row sm: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).
The 1:1.4 text:image ratio (text gets the narrower share) applies
from sm: up unchanged — the photo needs the room, the text
column doesn't need to be wide (the inner quote/bio row stays
stacked at Tablet regardless, see below, so a wider text column
doesn't even help it go inline; it just steals space the photo
should have — reported 2026-07-29). Only the -ml-48 overlap
itself (see the photo below) is gated to lg:, since that trick
needs more absolute room than Tablet has to not look cramped. */}
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 sm:flex-[1_0_0] min-w-0 relative z-10">
{/* Large serif statement — width-constrained as per design */}
<p
@@ -19,8 +27,15 @@ 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">
only from lg: (1024px), a deliberate exception to the site's
sm: (640px) structural consolidation: quote + divider + the
whitespace-nowrap bio ("Gründer von einfach produktiv.")
together need more room than the sm:-anchored text column has
through the whole 640-1023px range, independent of the outer
text/image ratio above. Stacked with a horizontal divider
through that whole range instead, side-by-side (vertical
divider) only once there's real room at lg:. */}
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 lg:gap-0 w-full">
{/* Caveat script text with signature positioned below */}
<div className="relative flex-1" style={{ minHeight: "8rem" }}>
@@ -51,28 +66,31 @@ 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>
<p>Führungskraft.</p>
<p>Familienmensch.</p>
<p>Gründer von einfach-produktiv.</p>
<p>Gründer von einfach produktiv.</p>
</div>
</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 — gets the wider 1.4 share from sm: up already (see
the text column's own comment above); plain, no overlap at
Tablet. Overlaps the text column via -ml-48 only from lg+, where
there's real absolute room for the photo to bleed under it
without looking cramped. Plain full-width photo below the text
on true Mobile. */}
<Reveal
className="relative overflow-hidden w-full md:flex-[1.4_0_0] md:-ml-48"
className="relative overflow-hidden w-full sm:flex-[1.4_0_0] lg:-ml-48"
style={{ minHeight: "14rem" }}
delay={0.15}
>
@@ -80,11 +98,16 @@ export function About() {
alt="Björn"
src="/about-author.jpg"
fill
sizes="(min-width: 768px) 58vw, 100vw"
sizes="(min-width: 640px) 58vw, 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 — from sm: up, not just lg:. Even without the
-ml-48 overlap at Tablet, the photo sits directly against the
dark text column with no transition, reading as a hard vertical
seam (reported 2026-07-29). A narrower fade (w-16) softens just
that seam at Tablet; widens to w-72 at lg: to also cover the
deeper -ml-48 overlap once that kicks in. */}
<div className="hidden sm:block absolute inset-y-0 left-0 w-16 lg:w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
</Reveal>
</section>
+51 -33
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
import { NotifyMeForm } from "./NotifyMeForm";
const FEEDBACK_MS = 2000;
@@ -17,6 +18,7 @@ export function AddToCartButton({
label,
className,
productId = "todo-karten",
numericId,
outOfStock = false,
maxQty = null,
variants = [],
@@ -27,6 +29,10 @@ export function AddToCartButton({
* ProductSpotlight passes the actual CMS-selected spotlight product's id
* explicitly, since that can now be a different product. */
productId?: string;
/** Payload's real numeric product id (`product.numericId`) — only used to
* scope a NotifyMeForm signup once out of stock, never for the cart/
* checkout path itself (that stays on the slug `productId` above). */
numericId: number;
/** Product-level — only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: boolean;
@@ -82,7 +88,9 @@ export function AddToCartButton({
: added
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
: "";
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : label;
// currentlyOutOfStock has no branch here — that state renders
// NotifyMeForm instead of this button entirely (see below).
const displayLabel = limitReached ? "Maximale Menge im Warenkorb" : label;
return (
// Low stock is deliberately NOT surfaced here as its own text line
@@ -111,39 +119,49 @@ export function AddToCartButton({
))}
</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. */}
<span className="relative grid">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
{currentlyOutOfStock ? (
// Replaces the button slot entirely rather than stacking below a
// disabled "Ausverkauft" button — same reasoning as
// AddToCartInlineButton's identical swap (see that file's own
// comment on the `items-start` grid fix this relies on).
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
) : (
<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 "Maximale Menge im Warenkorb" — the
widest of the three 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 (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">
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="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>
</button>
</button>
)}
</div>
);
}
+35 -16
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
import { NotifyMeForm } from "./NotifyMeForm";
// Exported so consumers like RelatedProducts.tsx can delay their own
// follow-up UI changes (e.g. swapping out this exact card) until after
@@ -18,6 +19,7 @@ export const FEEDBACK_MS = 2000;
*/
export function AddToCartInlineButton({
id,
numericId,
label = "In den Warenkorb",
className,
outOfStock = false,
@@ -25,6 +27,10 @@ export function AddToCartInlineButton({
variants = [],
}: {
id: string;
/** Payload's real numeric product id (`product.numericId`) — only used to
* scope a NotifyMeForm signup once out of stock, never for the cart/
* checkout path itself (that stays on the slug `id` above). */
numericId: number;
label?: string;
className?: string;
/** Product-level — only meaningful when `variants` is empty. A varianted
@@ -107,23 +113,36 @@ export function AddToCartInlineButton({
))}
</select>
)}
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
<span
className={
"text-body-sm transition-colors " +
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
{currentlyOutOfStock ? (
// Replaces the button slot entirely rather than stacking below a
// disabled "Ausverkauft" button. An out-of-stock card is taller
// than its in-stock siblings now — ProductGrid.tsx/MerklisteGrid.tsx/
// RelatedProducts.tsx all use `items-start` on their grid (not the
// CSS Grid default `stretch`) specifically so that doesn't cascade
// into pushing every other card's button down to match; an earlier
// attempt reserved the extra height invisibly on every card
// instead, which looked worse in practice (visible dead space
// under in-stock cards' buttons).
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
) : (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{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>
<span
className={
"text-body-sm transition-colors " +
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
>
{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>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Mirror of ArrowRightIcon.tsx — same reasoning applies here: the Unicode
// "←" (U+2190) previously used inline in CartContent.tsx's "Weiter
// einkaufen" links isn't covered by the site's custom web fonts, so it
// fell back to a system font whose vertical metrics sit visibly low on
// mobile relative to the label text next to it. An SVG has no
// font-fallback path — renders identically everywhere.
export function ArrowLeftIcon() {
return (
<svg aria-hidden width="16" height="12" viewBox="0 0 16 12" fill="none" className="shrink-0">
<path d="M15 6H1M1 6L4 3M1 6L4 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+21
View File
@@ -0,0 +1,21 @@
// Replaces the Unicode "→" (U+2192) previously used inline in CTA links
// (Tools.tsx, Blog.tsx, ProductGrid.tsx) — that glyph isn't covered by the
// site's custom web fonts, so browsers fall back to a system font just for
// that one character. The fallback's vertical metrics differ enough by
// platform (confirmed: sat visibly low relative to the label text on a
// Samsung Galaxy S22/Android Chrome, not reproducible in desktop Chromium)
// that centering it via flex `items-center` alone isn't reliable across
// devices. An SVG has no font-fallback path — it renders identically
// everywhere. `currentColor` stroke follows the parent Link's own
// text/hover color, same as every other icon in this codebase.
// Arrowhead wings are deliberately short relative to the shaft (4.2 units
// vs. a 14-unit shaft) — the first version used full-length 45° wings
// (7 units), which read as a generic, oversized chevron next to the small
// bold label text it sits beside.
export function ArrowRightIcon() {
return (
<svg aria-hidden width="16" height="12" viewBox="0 0 16 12" fill="none" className="shrink-0">
<path d="M1 6H15M15 6L12 3M15 6L12 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+109 -80
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import Image from "next/image";
import { getBlogPosts } from "../lib/payload";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
import { ArrowRightIcon } from "./ArrowRightIcon";
export async function Blog() {
const posts = await getBlogPosts(3);
@@ -31,91 +32,119 @@ export async function Blog() {
{/* Posts grid */}
<RevealGroup className="flex flex-col gap-10 items-start px-[var(--layout-padding-x)] w-full">
{/* Featured post — image on top on Mobile, side-by-side from md+ */}
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
<div className="relative w-full aspect-video md:aspect-auto md:col-span-7 md:h-[220px] bg-bg-muted">
{featured.thumbnail && (
<Image
alt=""
src={featured.thumbnail}
fill
sizes="(min-width: 768px) 58vw, 100vw"
className="object-cover pointer-events-none"
/>
)}
</div>
<div className="flex flex-col justify-between gap-4 py-4 px-4 md:pr-4 md:pl-4 md:col-span-5 min-w-0">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{featured.category}</span>
<span></span>
<span>{featured.readTime} Min</span>
</div>
<div className="flex flex-col gap-4 text-text-primary">
<p
className="font-semibold text-h-section leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
{featured.title}
</p>
<p className="font-normal text-body leading-6 line-clamp-2 md:line-clamp-none">
{featured.excerpt}
</p>
</div>
{/* Featured post — image on top through lg (1024px), side-by-side
only from lg+ (was sm+, but that squeezed the text column too
hard on tablet widths — same reasoning as the secondary posts'
grid below, which already used this lg breakpoint).
lg:min-h stretches the row taller on large screens (the image's
lg:h-full and the text column's justify-between both follow) —
without it the card's height was purely driven by the text
column's content, which stays short regardless of viewport
width. */}
<RevealItem>
{/* Whole card is the click target now (image + title + excerpt),
not just the "Zum Beitrag" line — the hover-lift below already
implied the whole card was clickable, so only the small arrow
actually working was a mismatch. `group` drives the arrow
row's hover color from anywhere on the card, not just when
hovering that row itself. */}
<Link
href={featured.href}
className="group w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-12 lg:min-h-[26rem] transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-7 lg:h-full bg-bg-muted">
{featured.thumbnail && (
<Image
alt=""
src={featured.thumbnail}
fill
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover"
/>
)}
</div>
<Link
href={featured.href}
className="font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
>
Zum Beitrag
</Link>
</div>
<div className="flex flex-col justify-between gap-4 py-4 px-4 lg:pr-4 lg:pl-4 lg:col-span-5 min-w-0">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{featured.categories.join(", ")}</span>
<span></span>
<span>{featured.readTime} Min</span>
</div>
<div className="flex flex-col gap-4 text-text-primary">
<p
className="font-semibold text-h-section leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
{featured.title}
</p>
<p className="font-normal text-body leading-6 line-clamp-2 lg:line-clamp-none">
{featured.excerpt}
</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).
Plain span now, not its own nested Link (the whole card
is one Link already) — group-hover keeps the color
change working from anywhere on the card. */}
<span className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap group-hover:text-brand transition-colors">
<ArrowRightIcon />
<span>Zum Beitrag</span>
</span>
</div>
</Link>
</RevealItem>
{/* Secondary posts — image on top on Mobile, side-by-side from md+ */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
{/* Secondary posts — image on top through lg (1024px), side-by-side
only from lg+. These sit 2-up (sm:grid-cols-2 below) well
before lg, so each card's own available width through the
640-1023px tablet range is too narrow for image+text side by
side — was sm:grid-cols-5, squeezing the text column. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 w-full">
{secondary.map((post) => (
<RevealItem
key={post.id}
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-video md:aspect-auto md:col-span-2 md:h-[230px] bg-bg-muted">
{post.thumbnail && (
<Image
alt=""
src={post.thumbnail}
fill
sizes="(min-width: 768px) 29vw, 100vw"
className="object-cover pointer-events-none"
/>
)}
</div>
<div className="flex flex-col justify-between gap-4 py-3 px-4 md:col-span-3 min-w-0 text-text-primary">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
<div className="flex flex-col gap-4">
<p
className="font-semibold text-h-small leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
{post.title}
</p>
<p className="font-normal text-body leading-6 line-clamp-1 md:line-clamp-none">
{post.excerpt}
</p>
</div>
<RevealItem key={post.id}>
<Link
href={post.href}
className="group block border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-2 lg:h-[230px] bg-bg-muted">
{post.thumbnail && (
<Image
alt=""
src={post.thumbnail}
fill
sizes="(min-width: 1024px) 29vw, 100vw"
className="object-cover"
/>
)}
</div>
<Link
href={post.href}
className="font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
>
Zum Beitrag
</Link>
</div>
<div className="flex flex-col justify-between gap-4 py-3 px-4 lg:col-span-3 min-w-0 text-text-primary">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
<div className="flex flex-col gap-4">
<p
className="font-semibold text-h-small leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
{post.title}
</p>
<p className="font-normal text-body leading-6 line-clamp-1 lg:line-clamp-none">
{post.excerpt}
</p>
</div>
</div>
{/* Plain span, not its own nested Link — see the featured
post's own comment above. */}
<span className="flex items-center gap-1 font-bold text-body whitespace-nowrap group-hover:text-brand transition-colors">
<ArrowRightIcon />
<span>Zum Beitrag</span>
</span>
</div>
</Link>
</RevealItem>
))}
</div>
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
function buildCategoryHref(active: string[], category: string): string {
const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category];
return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog";
}
// Client-side navigation (not a plain <Link>) specifically so `isPending`
// can gate the chips: rapid clicks used to fire multiple concurrent RSC
// navigations with no guarantee they'd commit in the order they were
// requested, so a slower, stale navigation could land after a newer one and
// briefly show the wrong (sometimes empty) result. Disabling the chips
// while a navigation is in flight makes that ordering race impossible —
// only one navigation can ever be outstanding at a time.
export function BlogCategoryFilter({ allCategories, activeCategories }: { allCategories: string[]; activeCategories: string[] }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<div className="flex flex-wrap gap-2 w-full">
{allCategories.map((category) => {
const active = activeCategories.includes(category);
return (
<button
key={category}
type="button"
disabled={isPending}
onClick={() => startTransition(() => router.push(buildCategoryHref(activeCategories, category)))}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors disabled:opacity-60 disabled:pointer-events-none ${
active
? "bg-brand border-brand text-text-primary"
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
}`}
>
{category}
</button>
);
})}
</div>
);
}
+15 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useCart } from "../lib/cart";
import { useCart, pullServerCart } 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`
@@ -10,6 +10,14 @@ import { useCart } from "../lib/cart";
// 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.
//
// Push (local→server) and pull (server→local) are both handled here, but
// deliberately asymmetric: push reacts to every local cart change (that's
// this device's news to share), pull only runs on mount and on window
// focus (see pullServerCart()'s own comment on why it's safe to call
// repeatedly) — no polling interval, since "another device changed my
// cart while this tab has been open and unfocused the whole time" is a
// rare enough case not to justify a persistent timer.
export function CartSync() {
const cart = useCart();
const isFirstRender = useRef(true);
@@ -34,5 +42,11 @@ export function CartSync() {
return () => clearTimeout(timeout);
}, [cart]);
useEffect(() => {
pullServerCart();
window.addEventListener("focus", pullServerCart);
return () => window.removeEventListener("focus", pullServerCart);
}, []);
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>
);
})}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Option = { value: string; label: string };
// A fully custom-styled dropdown — a native <select>'s trigger box can be
// styled, but its open options popup is rendered by the browser/OS itself
// and can't be reached with CSS at all (wrong font size, wrong colors, no
// brand styling whatsoever). This renders both the trigger and the
// options panel as plain HTML we control end to end. Originally built for
// /konto/bestellungen's filters, promoted to a shared component so
// checkout's country selects can use the same look (moved here 2026-07-30).
export function CustomSelect({
label,
options,
value,
onChange,
includeAllOption = true,
fullWidth = false,
}: {
/** Screen-reader label — also the trigger's placeholder text when
* `includeAllOption` is true and nothing is selected. */
label: string;
options: Option[];
value: string;
onChange: (value: string) => void;
/** true (default): prepends a `{value: "", label}` "clear/show all"
* pseudo-option — the filter-dropdown use case (Order/blog/etc.
* filters), where "nothing selected" is a real, meaningful state.
* false: no pseudo-option, every real option is selectable and one is
* always genuinely selected — the plain-select-replacement use case
* (e.g. checkout's country picker), where there's no "clear" concept. */
includeAllOption?: boolean;
/** false (default): trigger shrinks to its content width from sm: up —
* right for a row of compact filter dropdowns. true: trigger always
* stays full width of its container — right for a form-field
* replacement (e.g. checkout's country picker, alongside other w-full
* inputs). */
fullWidth?: boolean;
}) {
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const allOptions: Option[] = includeAllOption ? [{ value: "", label }, ...options] : options;
const selectedIndex = Math.max(
0,
allOptions.findIndex((o) => o.value === value),
);
const selectedLabel = allOptions[selectedIndex]?.label ?? label;
useEffect(() => {
if (!open) return;
setHighlighted(selectedIndex);
function onClickOutside(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
listRef.current?.querySelector<HTMLElement>(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: "nearest" });
}, [open, highlighted]);
// Keyboard/focus-driven close: Tabbing (or programmatically moving
// focus) away from the trigger+list entirely used to leave the panel
// open forever — the mousedown-outside listener above only ever reacts
// to a mouse click, not focus leaving via Tab. `relatedTarget` is where
// focus is headed; null on some browsers when it lands outside the
// document/on a non-focusable element, which should also close.
function onBlur(e: React.FocusEvent) {
if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
}
function select(index: number) {
onChange(allOptions[index].value);
setOpen(false);
}
function onKeyDown(e: React.KeyboardEvent) {
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlighted((i) => Math.min(i + 1, allOptions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(highlighted);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
}
return (
<div ref={rootRef} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={label}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKeyDown}
className={`flex items-center justify-between gap-2 w-full ${fullWidth ? "" : "sm:w-auto min-w-[10rem]"} border rounded-sm ${
fullWidth ? "px-4 py-3" : "px-3 py-2"
} text-body-sm transition-colors outline-none ${
value ? "border-brand text-text-primary" : "border-border text-text-muted"
} hover:border-brand focus-visible:border-brand`}
>
<span className="truncate">{selectedLabel}</span>
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}>
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<ul
ref={listRef}
role="listbox"
aria-label={label}
// Without this, a mousedown on an <li> blurs the trigger button
// first (li isn't focusable) — the resulting onBlur closes and
// unmounts this list before the click event ever fires, so
// nothing is ever selectable by mouse/touch.
onMouseDown={(e) => e.preventDefault()}
className="absolute z-20 mt-1 w-full sm:min-w-[12rem] max-h-64 overflow-y-auto bg-bg-base border border-border rounded-sm shadow-lg py-1"
>
{allOptions.map((o, i) => (
<li
key={o.value || "__all__"}
data-index={i}
role="option"
aria-selected={i === selectedIndex}
onMouseEnter={() => setHighlighted(i)}
onClick={() => select(i)}
className={`px-3 py-2 text-body-sm cursor-pointer transition-colors ${
i === selectedIndex ? "font-semibold text-brand" : "text-text-primary"
} ${i === highlighted ? "bg-bg-muted" : ""}`}
>
{o.label}
</li>
))}
</ul>
)}
</div>
);
}
+12 -7
View File
@@ -4,7 +4,7 @@ import { Reveal } from "./Reveal";
function Word({ children }: { children: string }) {
return (
<p
className="font-bold leading-normal text-text-primary text-h2 whitespace-nowrap"
className="font-bold leading-normal text-text-primary text-[length:var(--divider-word-size)] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
{children}
@@ -28,26 +28,31 @@ 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">
<Word>Entlastung</Word>
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
<Word>Leichtigkeit</Word>
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
the text-h2 words next to it, previously hard rem values that
+11 -2
View File
@@ -18,8 +18,17 @@ 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:. Reverted here from an earlier sm: rename (2026-07-29's
breakpoint migration): this row isn't the "grid arrives before
the fluid floor" bug the sm: consolidation targets — logo +
handle + 5 nowrap legal links (all shrink-0) simply don't fit
in one row below ~1024px regardless of fluid scaling (measured:
~746px combined natural width at a 666px viewport, causing a
real horizontal page overflow, not just visual cramping).
Same fixed-content-doesn't-fit category as Cart/Checkout/
SectionTOC, kept at lg: for the same reason. */}
<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">
+44
View File
@@ -0,0 +1,44 @@
// Server Component — no "use client" needed. The consent gating itself
// happens entirely client-side inside Klaro (KlaroConsentManager.tsx),
// not via any React state here: Klaro's own contextual-consent DOM scan
// (renderContextualConsentNotices, runs as part of Klaro.render()) finds
// this iframe by its `data-name="google-maps"` attribute after mount,
// blanks its `src` and inserts its own "Karte laden?" placeholder in
// front of it until that service's consent is granted, then restores the
// real `src`. See klaroConfig.ts's own comment on the "google-maps"
// service this depends on — a `tracking-codes` row with that provider
// must exist and be `active`, or Klaro never gates (and never un-blanks)
// this iframe at all.
//
// `width`/`height` are real HTML attributes, not just the Tailwind aspect-
// ratio wrapper below — Klaro reads `element.width`/`element.height` (the
// DOM attributes) to size its own placeholder box before the real iframe
// has rendered, so both need to be present even though the wrapper's
// `aspect-[...]` class is what actually controls layout.
export function GoogleMapsEmbed({
src,
title,
className,
}: {
/** A full Google Maps embed URL (Google Maps → Teilen → Karte einbetten
* → "HTML kopieren" → the `src` attribute of that iframe), e.g.
* "https://www.google.com/maps/embed?pb=...". */
src: string;
title: string;
className?: string;
}) {
return (
<div className={`relative w-full aspect-[16/9] overflow-hidden rounded-md border border-border ${className ?? ""}`}>
<iframe
data-name="google-maps"
src={src}
title={title}
width={800}
height={450}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="absolute inset-0 h-full w-full border-0"
/>
</div>
);
}
+124 -64
View File
@@ -2,71 +2,138 @@ 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: 640px) 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 sm: (640px, moved down from the old md:
768px so the grid arrives exactly where the fluid token floor
also sits — see fluid.ts/globals.css) for the GRID only — the
text column stays narrow through the whole 640-1023px Tablet
range regardless. Below, every piece of *content* inside the text
column (heading/subtitle/CTA/social-proof) deliberately keeps its
smaller, fixed-below-lg: sizing all the way through Tablet too,
not just true Mobile — reusing the full fluid-token sizes that
early 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. This is a narrower,
intentional exception to the site-wide sm: consolidation — the
column's real width at sm: hasn't been visually verified yet
(no browser in this environment), so full-size content stays
gated behind lg: as a fast-follow rather than a guess. Splitting
"grid at sm:" 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 sm:grid sm:grid-cols-12 sm:items-center gap-8 sm:gap-[var(--layout-grid-gap)] pt-10 sm: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">
viewport — this doubles as the page's entrance animation.
Centered below sm: (true Mobile) — the CTA/social-proof below
were already centered there, left-aligned heading/subheading
above them read inconsistent (reported 2026-07-29). Left-
aligned again from sm: up, matching the rest of the column. */}
<Reveal className="order-1 sm:order-none sm:col-span-5 flex flex-col gap-7 items-center sm:items-start pl-[var(--layout-padding-x)] pr-10 sm: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). The old forced break after "darf" only existed
for a "wide single-column, not yet grid" band (640-767px
under the old md: 768px grid switch) that no longer exists
now the grid itself starts at sm: (640px) — below sm: the
stack is narrower than that old band ever was, where natural
wrap already reads fine (confirmed 2026-07-24), so the break
is removed rather than re-anchored to a new range. 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"
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary text-center sm:text-left"
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">
Verlier&apos; dich nicht im mehr, verankere was zählt
</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">
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
{/* 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] text-center sm:text-left [word-break:break-word] not-italic">
Ich helfe dir, zwischen Job, Familie und eigenen Projekten nicht unterzugehen.
</p>
{/* CTA */}
{/* CTA — centered below sm: (reads better against the centered
image/banner above it on true Mobile), left-aligned with the
rest of the text column again from sm: up. */}
<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"
href="https://einfach-produktiv.mk360.de/7-tage-klarheits-check"
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 self-center sm:self-auto"
>
<span className="font-semibold leading-[2.375rem] text-text-primary text-h3 whitespace-nowrap not-italic">
Starte mit der 7-Tage-Challenge
{/* 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">
Jetzt starten
</span>
<div className="relative h-[1.1875rem] w-[1.5625rem] shrink-0">
<Image alt="" src="/icon-check.svg" fill sizes="26px" />
{/* Scaled down to match the smaller CTA text (same ~0.76
aspect ratio as the lg: size), full size again from lg: up
alongside text-h3. */}
<div className="relative h-[0.8125rem] w-[1.0625rem] lg:h-[1.1875rem] lg:w-[1.5625rem] shrink-0">
<Image alt="" src="/icon-check.svg" fill sizes="(min-width: 1024px) 26px, 17px" />
</div>
</Link>
{/* Social proof */}
<div className="flex gap-3 items-start overflow-clip shrink-0 w-full">
{/* Social proof — always avatars-then-text on two lines below
lg: (not just when it happens to overflow), single row again
from lg: up where the real column width fits it fine.
Centered on true Mobile (<640px, matches the CTA above it,
see self-center there); left-aligned in the 640-1023px
Tablet band instead, matching the left-aligned heading/
subheading — plain centered there read as floating/
disconnected from that column (reported 2026-07-29); back to
a centered row at lg:. */}
<div className="flex flex-col lg:flex-row gap-3 items-center sm:items-start lg:items-center justify-center sm:justify-start lg:justify-center overflow-clip shrink-0 w-full">
{/* Avatars — gap 2px, not overlapping */}
<div className="flex gap-[0.125rem] items-center shrink-0">
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
@@ -79,46 +146,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]">
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center sm:text-left [word-break:break-word]">
1.120+ 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
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
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
padded). Below sm: (stacked layout, moved down from the old
md: 768px) 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 sm:+ (grid, image only 58% width) the full aspect
ratio looks right again, so the cap 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 sm: — 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 sm: instead; Reveal's fade
kept from sm: up, where the image is beside the text with
plenty of room and this was never an issue. */}
<div className="order-2 sm: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 sm:block sm: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>
+296
View File
@@ -0,0 +1,296 @@
"use client";
import { useEffect, useState } from "react";
import "klaro/dist/klaro.css";
import type { TrackingCode } from "../lib/payload";
import { buildKlaroConfig } from "../lib/klaroConfig";
import { loadTrackingCode } from "../lib/loadTrackingCode";
// Replaced a hand-rolled CookieBanner.tsx + useConsent.ts + TrackingScripts.tsx
// with kiprotect/klaro (open source, self-hosted, npm install klaro) —
// the custom banner only ever covered the accept/reject UI itself; Klaro
// additionally brings a real per-service consent list, bundled German UI
// translations, and (via `cookies`, not used here yet) cookie-deletion on
// withdrawal — all things a hand-rolled version would have had to build
// from scratch. See project memory for the fuller reasoning.
//
// Dynamically imported inside an effect (client-only, after mount) rather
// than a static top-level import — Klaro touches `window`/`document` at
// module-eval time in places, which isn't SSR-safe. The CSS import above
// stays static (Next.js requires CSS imports to be static, not inside a
// dynamic import()), paired with the "-no-css" JS build so the stylesheet
// isn't loaded twice.
//
// `buildKlaroConfig`'s `styling` CSS-var overrides (brand colors, corner
// position) only get Klaro so far — its bundled klaro.css still draws its
// own border/shadow/spacing/typography, which read as an obviously
// bolted-on library widget next to this site's own hand-designed
// components (NewsletterModal.tsx, NotifyMeForm.tsx, etc.). The <style>
// block below is a real CSS override pass against Klaro's actual DOM
// classnames (confirmed against kiprotect/klaro's own src/scss/*.scss,
// not guessed) — same "plain unlayered <style> tag wins the cascade"
// approach the Payload admin's own AdminUIStyles.tsx uses, `!important`
// added only where needed to beat klaro.css's own rules of otherwise-equal
// specificity. Kills Klaro's default border entirely (replaced with a
// soft shadow, matching this site's own card language) and restyles
// every button to the site's actual rounded/weighted look instead of
// Klaro's generic flat rectangles.
function KlaroTheme() {
return (
<style>{`
/* Blanket reset first — the previous pass only targeted
.cookie-notice/.cookie-modal directly and a hard black border
still showed up in production (screenshot-confirmed), so every
descendant gets border/outline stripped here regardless of which
specific Klaro rule was actually drawing it; the two rules below
re-add exactly the borders this theme actually wants. */
.klaro, .klaro * { box-sizing: border-box; border: 0 !important; outline: 0 !important; }
/* Matches NewsletterModal.tsx's own backdrop color
(bg-[rgba(134,134,134,0.9)]) instead of Klaro's default plain
black at 50% — same dimming purpose, but consistent with how
every other modal on this site already looks. .cm-bg is a
*sibling* of the actual dialog box (.cm-modal.cm-klaro), both
direct children of the full-screen .cookie-modal wrapper — see
that wrapper's own comment below on why it must never be resized/
transformed itself. */
.klaro .cm-bg { background: rgba(134, 134, 134, 0.9) !important; }
.klaro .cookie-notice, .klaro .cm-modal.cm-klaro {
box-shadow: 0 20px 44px -14px rgba(26,26,24,0.22), 0 4px 14px rgba(26,26,24,0.07) !important;
border-radius: 18px !important;
font-family: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
}
.klaro .cookie-notice .cn-body { padding: 22px 24px !important; }
.klaro .cookie-notice p, .klaro .cm-modal.cm-klaro p {
font-size: 14px !important;
line-height: 1.6 !important;
margin-top: 0 !important;
margin-bottom: 10px !important;
}
.klaro h1, .klaro h2 {
font-family: var(--font-lora), Georgia, serif !important;
font-weight: 700 !important;
letter-spacing: -0.01em !important;
}
.klaro .cm-btn {
border: none !important;
border-radius: 8px !important;
font-weight: 700 !important;
font-size: 13px !important;
padding: 10px 18px !important;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.klaro .cm-btn:hover { opacity: 0.88; }
.klaro .cm-btn:active { transform: scale(0.97); }
.klaro .cm-btn:focus-visible { outline: 2px solid #f6a701 !important; outline-offset: 2px; }
/* Side-by-side by default (accept/decline in the notice, the
modal's own bottom action row), only wrapping to a stacked
layout once they genuinely don't fit — flex-wrap does this
naturally at any width, no fixed breakpoint needed. Klaro's own
.cn-ok/.cn-buttons rely on inline-block flow for the same
result, which is fragile; this is the explicit version. */
.klaro .cn-ok, .klaro .cn-buttons, .klaro .cm-buttons {
display: flex !important;
flex-wrap: wrap !important;
gap: 10px !important;
width: auto !important;
}
.klaro .cn-buttons .cm-btn, .klaro .cm-buttons .cm-btn { width: auto !important; margin: 0 !important; }
/* The "Karte laden?" placeholder ContextualConsentNotice renders in
place of a gated embed (GoogleMapsEmbed.tsx) — fills that embed's
own aspect-ratio box, so this needs to look like a real card
slot, not a bare unstyled div floating inside it. */
.klaro.cm-as-context-notice {
height: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.klaro .context-notice {
width: 100% !important;
height: 100% !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 4px !important;
padding: 24px !important;
background: #fffdf8 !important;
text-align: center !important;
}
.klaro .context-notice p { text-align: center !important; max-width: 32ch; }
.klaro .context-notice .cm-buttons { display: flex !important; gap: 10px !important; margin-top: 6px !important; }
.klaro a.cm-link, .klaro .cookie-notice a, .klaro .cookie-modal a {
color: #a06b00 !important;
text-decoration: underline !important;
font-weight: 600 !important;
}
.klaro select, .klaro .cm-list-input + label {
border-radius: 6px !important;
}
/* ---- Settings modal service/purpose list — a real pass modeled on
well-known CMPs like Cookiebot (roomier modal, clear row
separators, bigger switches, muted-but-legible descriptions),
not just inherited from the notice's own styling.
DOM structure (confirmed against kiprotect/klaro's own
src/components/consent-modal.jsx — the FIRST version of this
pass guessed wrong and broke the backdrop, see the incident note
below):
.cookie-modal full-viewport wrapper (position:
fixed, 100%×100%) — MUST stay
untouched; giving it its own
transform/position (tried first)
creates a new containing block
for its position:fixed children,
so .cm-bg below started
positioning itself relative to
THIS shrunken/centered box
instead of the real viewport —
the backdrop only covered a
640px-wide column, screenshot-
confirmed 2026-08-02.
.cm-bg the dark backdrop (styled above)
.cm-modal.cm-klaro the actual dialog box — every
size/position override belongs
HERE, not on .cookie-modal.
.cm-header close (×) + h1.title + intro <p>
.cm-body the service/purpose list
.cm-footer > .cm-footer-buttons decline/accept/accept-all
row (NOT .cm-buttons — that
class belongs to the small
notice/context-notice components
only, an earlier pass wrongly
reused it here too and the rule
silently matched nothing). */
.klaro .cm-modal.cm-klaro {
position: fixed !important;
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%) !important;
width: calc(100% - 40px) !important;
max-width: 640px !important;
max-height: 88vh !important;
overflow: auto !important;
margin: 0 !important;
}
.klaro .cm-header { padding: 32px 36px 0 !important; }
.klaro .cm-body { padding: 8px 36px !important; }
.klaro .cm-footer { padding: 0 36px 32px !important; }
.klaro .cm-header h1.title { font-size: 26px !important; margin-bottom: 4px !important; }
.klaro .cm-header > p { color: #6b6b69 !important; margin-bottom: 0 !important; }
.klaro .cm-header .hide {
position: absolute !important;
top: 28px !important;
right: 28px !important;
color: #6b6b69 !important;
font-size: 20px !important;
}
.klaro .cm-header .hide:hover { color: #1a1a18 !important; }
/* Re-adds a deliberate row separator the blanket border-reset above
removes — each purpose/service row genuinely benefits from one,
unlike the notice's own outer border which just looked heavy. */
.klaro .cm-switch-container {
border-bottom: 1px solid #e5e0d8 !important;
padding: 16px 4px !important;
padding-left: 70px !important;
}
.klaro .cm-switch-container:last-child { border-bottom: 0 !important; }
.klaro .cm-list-title { font-size: 15px !important; font-weight: 700 !important; }
.klaro .cm-list-description { font-size: 13.5px !important; line-height: 1.5 !important; padding-top: 5px !important; max-width: 46ch; }
.klaro p.purposes { font-size: 12px !important; font-weight: 600 !important; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 6px !important; }
/* Bigger, clearer toggle (44×24 vs. Klaro's cramped 50×30-but-
visually-thin default) — brand amber when on, plain border-tone
off, matching this site's own bg-brand/bg-border pairing. */
.klaro .cm-switch, .klaro .cm-list-input { width: 44px !important; height: 24px !important; }
.klaro .slider { border: 1px solid #e5e0d8 !important; }
.klaro .slider::before { width: 18px !important; height: 18px !important; left: 3px !important; bottom: 2px !important; box-shadow: 0 1px 2px rgba(0,0,0,0.15) !important; }
.klaro .cm-list-input:checked + .cm-list-label .slider::before { transform: translateX(19px) !important; }
.klaro .cm-caret { color: #6b6b69 !important; }
/* Bottom action row (decline/accept/accept-all) — real classname
is .cm-footer-buttons here, NOT .cm-buttons (see the DOM-
structure note above); reads as a distinct footer, not just the
last list item — separated + given the same side-by-side/wrap
treatment as the notice's own buttons. */
.klaro .cm-footer-buttons {
display: flex !important;
flex-wrap: wrap !important;
gap: 10px !important;
border-top: 1px solid #e5e0d8 !important;
margin-top: 16px !important;
padding-top: 20px !important;
}
.klaro .cm-footer-buttons .cm-btn { width: auto !important; margin: 0 !important; }
.klaro .cm-powered-by { display: none !important; }
/* Hides the "alle umschalten" toggle's own boilerplate description
("Mit diesem Schalter können Sie alle Dienste aktivieren oder
deaktivieren.") — redundant next to a plainly-labeled switch.
CSS hide, not an empty translation override — Klaro's own t()
treats an empty string as "translation missing" and renders a
literal "[missing translation: ...]" debug string instead
(confirmed via screenshot 2026-08-02), which is worse than the
original text. */
.klaro .cm-toggle-all .cm-list-description { display: none !important; }
`}</style>
);
}
export function KlaroConsentManager({ codes }: { codes: TrackingCode[] }) {
// Kept in state (not just called once) so the persistent reopen button
// below can call Klaro.show() itself, any time after the initial
// accept/reject decision — a visitor must always be able to revisit
// their choice, not just on first load.
const [klaroModule, setKlaroModule] = useState<typeof import("klaro/dist/klaro-no-css") | null>(null);
useEffect(() => {
// Nothing to ask consent for — don't even load/render Klaro. A cookie
// banner with zero services to list would just be visual noise.
if (codes.length === 0) return;
let cancelled = false;
import("klaro/dist/klaro-no-css").then((Klaro) => {
if (cancelled) return;
const config = buildKlaroConfig(codes, loadTrackingCode);
Klaro.setup(config);
setKlaroModule(Klaro);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- `codes` comes
// from a server-fetched, 60s-ISR-cached layout prop; it's stable for
// the lifetime of this component in practice, and Klaro.setup() isn't
// meant to be called more than once per page load anyway.
}, []);
if (codes.length === 0) return null;
return (
<>
<KlaroTheme />
{klaroModule && (
<button
type="button"
onClick={() => klaroModule.show()}
aria-label="Cookie-Einstellungen öffnen"
title="Cookie-Einstellungen"
className="fixed bottom-5 left-5 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-bg-base shadow-md hover:border-brand transition-colors"
>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<circle cx="9" cy="9.5" r="1.1" fill="currentColor" />
<circle cx="14" cy="8.5" r="1" fill="currentColor" />
<circle cx="15" cy="13.5" r="1.1" fill="currentColor" />
<circle cx="10.5" cy="14.5" r="1" fill="currentColor" />
<circle cx="12" cy="11" r="0.9" fill="currentColor" />
</svg>
</button>
)}
</>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { Reveal } from "./Reveal";
import { renderPageBlockSync } from "./PageBlocks";
import { mapPayloadPage, type PayloadPage, type Page } from "../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Live-previewable subset of a Pages document: the breadcrumb and the
// layout blocks (the H1 now lives in the first block, usually a "hero" —
// see HeroBlock.ts) — same "editable subset only" scope as LivePostContent.tsx
// (see that file's own comment). `testimonialsRef` blocks are skipped here
// (see renderPageBlockSync's own comment) since they need an async fetch a
// client component can't perform inline; editing that block still shows up
// once the page is saved and reloaded outside the preview iframe.
export function LivePageContent({ initialPage }: { initialPage: Page }) {
const { data } = useLivePreview<PayloadPage>({
initialData: initialPage as unknown as PayloadPage,
serverURL: PAYLOAD_URL,
depth: 2,
});
const page = data?.slug ? mapPayloadPage(data) : initialPage;
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">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
<span></span>
<span className="text-text-primary">{page.title}</span>
</p>
</Reveal>
<Reveal delay={0.1} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14 flex flex-col gap-5">
{page.layout.map(renderPageBlockSync)}
</Reveal>
</>
);
}
+180 -18
View File
@@ -6,6 +6,8 @@ import Image from "next/image";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { useCartCount } from "../lib/cart";
import { useWishlist } from "../lib/useWishlist";
import { SearchButton, SearchOverlay } from "./SearchOverlay";
import { AUTH_CHANGED_EVENT } from "../lib/auth";
import { NewsletterModal } from "./NewsletterModal";
import { useCartFly } from "./CartFly";
@@ -44,7 +46,7 @@ function getNavLinks(singleActiveProduct: boolean) {
return [
{ label: "Werkzeuge", href: "#werkzeuge" },
{ label: "Blog", href: "/blog" },
{ label: "Über Björn", href: "#ueber-bjoern" },
{ label: "Über mich", href: "/ueber-mich" },
{ label: "Shop", href: singleActiveProduct ? "#spotlight" : "/shop" },
];
}
@@ -58,7 +60,10 @@ function getNavLinks(singleActiveProduct: boolean) {
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
// page-weekly-impulses's own breadcrumb ("Startseite Werkzeuge
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"];
// /der-eine and /tasse-die-pause (both bespoke product detail pages, same
// "own route, not nested" shape as /todo-cards) added here for the same
// reason (2026-08-25).
const WERKZEUGE_ROUTES = ["/todo-cards", "/7-tage-klarheits-check", "/newsletter", "/der-eine", "/tasse-die-pause", "/lebensuhr", "/3x3-system"];
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
if (href === "#werkzeuge") {
@@ -113,7 +118,12 @@ function AccountLink() {
aria-label={loggedIn ? "Mein Konto (eingeloggt)" : "Anmelden"}
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
{/* -translate-y-0.5 — the glyph's own bounding box centers fine
mathematically, but the round head (light, isolated) versus the
wide shoulders (heavier, at the bottom) reads as optically
bottom-heavy next to the cart icon, sitting visibly lower.
Nudged up to match (fixed 2026-07-24). */}
<svg viewBox="0 0 24 24" className="h-7 w-7 text-text-primary -translate-y-0.5" fill="none" aria-hidden="true">
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.8" />
<path d="M4.5 20c1.2-4 4-6 7.5-6s6.3 2 7.5 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
@@ -126,6 +136,71 @@ function AccountLink() {
);
}
// Wishlist icon + count badge — only rendered by the caller when
// `wishlistEnabled` (CompanySettings). Visible at every width, alongside
// Search/Account/Cart — all 4 stay full 44px touch targets even on the
// smallest phones (shrinking them was tried and reverted: it made them
// hard to hit accurately). The logo shrinks further below 375px instead
// to make room — confirmed via an actual Playwright viewport sweep down
// to 320px that this fits without wrapping/overflow (see git history).
function WishlistLink() {
const { count } = useWishlist();
const [pulse, setPulse] = useState(false);
const prevCountRef = useRef(0);
// Same guard as CartLink's own — useWishlist() starts from an empty
// cached/SSR-safe list and only fills in the real count once its own
// fetch resolves client-side, so without this the badge pulsed on every
// page load/reload the instant that first real count arrived, not just
// on an actual add/remove during the session.
const hasMountedRef = useRef(false);
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
prevCountRef.current = count;
return;
}
if (count !== prevCountRef.current) {
setPulse(true);
const t = setTimeout(() => setPulse(false), 350);
prevCountRef.current = count;
return () => clearTimeout(t);
}
}, [count]);
return (
<Link
href="/konto/merkliste"
aria-label={count > 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg
viewBox="0 0 20 18"
className={"h-6 w-6 text-text-primary transition-transform duration-300 " + (pulse ? "scale-110" : "scale-100")}
fill="none"
aria-hidden="true"
>
<path
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
{count > 0 && (
<span
className={
"absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white transition-transform duration-300 " +
(pulse ? "scale-125" : "scale-100")
}
>
{count > 99 ? "99+" : count}
</span>
)}
</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
@@ -137,6 +212,12 @@ function CartLink() {
const anchorRef = useRef<HTMLAnchorElement>(null);
const [pulse, setPulse] = useState(false);
const prevVisibleRef = useRef(0);
// useCartCount's getServerSnapshot is always 0 (see cart.ts) so hydration
// always transitions 0 -> the real count on first render — without this
// guard that transition alone satisfied "visibleCount > prevVisibleRef"
// and pulsed the badge on every single page load/reload, not just an
// actual in-session cart change.
const hasMountedRef = useRef(false);
const pathname = usePathname();
// Held back by pendingCount while a ball is mid-flight, so the badge only
@@ -151,6 +232,11 @@ function CartLink() {
}, [registerCartIcon]);
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
prevVisibleRef.current = visibleCount;
return;
}
if (visibleCount > prevVisibleRef.current) {
setPulse(true);
const t = setTimeout(() => setPulse(false), 350);
@@ -205,12 +291,21 @@ function CartLink() {
);
}
export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
export function Navbar({
singleActiveProduct,
wishlistEnabled,
searchEnabled,
}: {
singleActiveProduct: boolean;
wishlistEnabled: boolean;
searchEnabled: boolean;
}) {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
const [mobileOpen, setMobileOpen] = useState(false);
const [newsletterOpen, setNewsletterOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);
@@ -378,13 +473,23 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
// 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.
//
// onClick here closes the drawer on any click that lands on the bar
// itself (logo/cart/account/CTAs already call closeMobile() from
// their own onClick, so this mainly covers clicking empty space in
// the bar) — safe as a catch-all specifically because the panel is
// a sibling, not a descendant, so a click inside the open drawer
// never bubbles up to this handler. The hamburger's own onClick
// stops propagation so toggling it open doesn't immediately get
// undone by this same handler.
onClick={() => mobileOpen && setMobileOpen(false)}
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"
}`}
>
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-8">
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-2 min-[375px]:px-4 sm:px-8">
<div className="w-full flex items-center justify-between">
{/* Logo — real navigation to "/" from anywhere else; only when
@@ -396,7 +501,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
while leaving that page's content on screen. */}
<Link
href="/"
className="shrink-0"
className="shrink-0 w-[76px] min-[375px]:w-[130px] sm:w-[181px]"
onClick={
pathname === "/"
? (e) => {
@@ -408,11 +513,21 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
: closeMobile
}
>
{/* Fixed width/height are the real file dimensions (Next Image
needs them for optimization/layout); the wrapping Link's own
w-[112px] min-[375px]:w-[130px] sm:w-[181px] + h-auto here is
what actually shrinks the rendered logo below 640px — there
wasn't enough header width for 4 full-44px icons + hamburger
otherwise (confirmed via an actual Playwright viewport sweep
down to 320px, not assumed) — the icons themselves are never
shrunk (see WishlistLink's own comment on why), so the logo
is what gives on the very smallest phones instead. */}
<Image
src="/logo.png"
alt="einfach produktiv"
alt="einfach produktiv."
width={181}
height={61}
className="w-full h-auto"
priority
/>
</Link>
@@ -423,8 +538,27 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
Figma's Navbar-Mobile component set exactly, see the plan.
Matches the Figma NavLink component's hover: text never
changes color, an underline (brand, 2px x 40px) fades in on
hover and stays on for the active section. */}
<nav className="hidden lg:flex items-center gap-12">
hover and stays on for the active section.
Deliberate exception to the site-wide sm: (640px) structural
consolidation (see the 640px-breakpoint plan): this md:/lg:
3-tier scheme (hamburger-only <768, hamburger+inline-CTAs
768-1023, full-inline ≥1024) is untouched by that migration.
Horizontal nav-link overflow is a different failure mode than
the vertical grid/flex reflows the rest of the site has —
there's no fluid token that shrinks link text to make a full
inline nav fit at 640px, and nothing here was ever tied to
the fluid token scale the way Hero/About/etc. were.
The gap itself, though, does scale fluidly — just on its own
1024-1300px range (this nav's own floor/ceiling, only ever
relevant while it's actually visible), not the site-wide
640-1440px scale: clamp(1.5rem, 8.696vw - 4.065rem, 3rem) is
gap-6 (1.5rem) at exactly 1024px, gap-12 (3rem) from 1300px
up, and eases linearly between — the fixed 48px gap read as
too wide right where the nav labels themselves have the
least room (just above 1024px). */}
<nav className="hidden lg:flex items-center gap-[clamp(1.5rem,8.696vw_-_4.065rem,3rem)]">
{navLinks.map((link) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
const underline = (
@@ -474,9 +608,26 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
{/* Trailing controls — CTA buttons (md+), cart (always), hamburger
(below lg). Grouped so spacing stays consistent as individual
children hide/show across the three breakpoint tiers. */}
<div className="flex items-center gap-2">
<AccountLink />
<CartLink />
<div className="flex items-center gap-1 min-[375px]:gap-2">
{/* No gap between these — each is a full 44px touch target
(never shrunk, even on the smallest phones — a smaller
target was tried and reverted for being hard to tap
accurately) with the icon centered inside, so even gap-0
here still leaves visual space between the actual glyphs.
All 4
icons (Search/Account/Wishlist/Cart) are visible at every
width, including true mobile — the 375px-and-below size
step exists specifically so all 4 plus the hamburger fit
without wrapping/overflow on the narrowest real phone
viewports (checked at 320px). The outer gap-2 is what
separates this group from the CTA-buttons/hamburger group
that follows. */}
<div className="flex items-center">
{searchEnabled && <SearchButton onOpen={() => setSearchOpen(true)} />}
<AccountLink />
{wishlistEnabled && <WishlistLink />}
<CartLink />
</div>
{/* CTA buttons — inline from md (768px) up, i.e. through both
"Collapsed-CTA" and full Desktop tiers */}
@@ -494,10 +645,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
Newsletter
</button>
<Link
href="/challenge"
href="/3x3-system"
className="px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all"
>
7-Tage-Challenge
Mein 3x3-System
</Link>
</div>
@@ -510,7 +661,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
aria-expanded={mobileOpen}
aria-controls="mobile-nav-panel"
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
onClick={() => setMobileOpen((v) => !v)}
onClick={(e) => {
e.stopPropagation();
setMobileOpen((v) => !v);
}}
className="lg:hidden flex h-11 w-11 items-center justify-center shrink-0"
>
<span className="relative block h-4 w-6">
@@ -556,7 +710,14 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
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%)" }}
// Own (slower, gentler) transition for the exit — the shared
// ease-out curve above is front-loaded (fast start, slow
// finish), which reads great for the reveal but meant the
// close shrank most of the way almost immediately, then
// lingered on a barely-visible sliver — felt abrupt rather
// than smooth. easeInOut plus a longer duration spreads the
// shrink evenly instead.
exit={{ clipPath: "circle(0vmax at 100% 0%)", transition: { duration: 0.7, ease: "easeInOut" } }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<div className="flex flex-col min-h-full pt-[6.25rem]">
@@ -643,11 +804,11 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
Newsletter
</button>
<Link
href="/challenge"
href="/3x3-system"
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
Mein 3x3-System
</Link>
</motion.div>
</div>
@@ -656,6 +817,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
</AnimatePresence>
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
{searchEnabled && <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />}
</>
);
}
+123 -54
View File
@@ -1,7 +1,10 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import Image from "next/image";
import { Reveal } from "./Reveal";
import { useNewsletterSignup } from "../lib/useNewsletterSignup";
// Same lock icon + copy as /challenge's and /newsletter's EmailCapture —
// unified across all newsletter-signup forms instead of each having its
@@ -28,22 +31,56 @@ type NewsletterProps = {
* instead of a second near-duplicate component.
*/
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.",
title = "Kleine Impulse große Wirkung",
description = "Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.",
}: NewsletterProps = {}) {
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
useNewsletterSignup("newsletter-page");
return (
<section className="py-16 w-full">
{/* Outer section padding — same fluid horizontal padding as all other sections */}
<div className="px-[var(--layout-padding-x)] w-full">
{/* 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">
{/* max-w-[1280px] mx-auto — the consistent default width for this
card, matching /lebensuhr's own newsletter-style "Bottom CTA"
section (same 1280px cap Footer.tsx already uses) and every
other narrow-viewport-friendly section on the site. Previously
unbounded here, so on wide desktop viewports this card stretched
edge-to-edge while /lebensuhr's version stayed centered and
noticeably narrower — reported 2026-07-29. */}
{/* Rounded card: cream bg, stacks below sm (moved down from the old md:) */}
{/* py-8 at every breakpoint, not just below sm (was sm:py-0,
relying purely on items-center + the taller column's own
height to create top/bottom breathing room) — the form column
(input, checkbox+label, privacy note) can be as tall as or
taller than the copy column depending on viewport width, so
centering alone left no slack to distribute and the input/
"Keine Werbung" note sat flush against the card's top/bottom
edge. */}
<Reveal className="bg-bg-muted flex flex-col sm:flex-row gap-8 sm:gap-12 items-center px-8 py-8 rounded-md w-full max-w-[1280px] mx-auto">
{/* 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 sm+ so the form always gets the remaining space.
Icon+text stacked (icon on top, centered) below sm: — side by
side they squeezed the text into a narrow column on a phone
(icon width + gap eating most of the card's inner width),
wrapping awkwardly, and the same squeeze reappeared through
the whole tablet range (640-1023px) once the outer card's own
sm:flex-row already put this copy column next to the form —
row layout for icon+text is only comfortable once there's
real room, i.e. lg+. Left-aligned (not centered) from sm up —
only true mobile keeps the centered treatment. */}
<div className="flex flex-col items-center gap-4 text-center w-full sm:items-start sm:text-left lg:flex-row lg:gap-8 sm:w-[var(--newsletter-copy-width)] lg:py-4 lg:shrink-0">
{/* Decorative envelope icon, tilted -4° as per design */}
{/* Decorative envelope icon, tilted -4° as per design.
w-[4rem], not w-16 — this project's --spacing-16 is a
fluid token (floors to 40px below 768px, see globals.css),
so pairing w-16 with the fixed h-[3.438rem] squished the
icon to a 40:55 box on mobile instead of the SVG's native
64:55.0096 (it has preserveAspectRatio="none", so it
actually stretches to whatever box it's given — fixed
2026-07-24). */}
<div className="flex items-center justify-center shrink-0 w-[4.23rem] h-[3.71rem]">
<div className="-rotate-4 -scale-y-100">
<Image
@@ -51,7 +88,7 @@ export function Newsletter({
src="/newsletter-icon.svg"
width={64}
height={55}
className="w-16 h-[3.438rem] block"
className="w-[4rem] h-[3.438rem] block"
/>
</div>
</div>
@@ -70,56 +107,88 @@ export function Newsletter({
</div>
</div>
{/* 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">
{/* Right: form — takes remaining space, centered vertically from sm+ */}
<div className="flex w-full sm:flex-1 items-center sm:self-stretch min-w-0">
{status === "success" ? (
<p className="text-body text-text-primary font-medium">{successMessage}</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 min-w-0 w-full">
{/* Input + submit button — stacked below md, side by side from md+ */}
<div className="flex flex-col md:flex-row gap-4 items-stretch w-full">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="flex-1 min-w-0 bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
/>
<button
type="submit"
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
>
Jetzt anmelden
</button>
</div>
{/* Consent checkbox — required since this signup's legal
basis is consent (email marketing), not the "Ich achte
auf deine Daten" trust note alone. Same wording/pattern
as NewsletterModal's checkbox. */}
<label className="flex gap-2 items-start cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary font-normal leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
{/* Input + submit button — stacked below lg:, a deliberate
exception to the site's sm: (640px) structural
consolidation, not a leftover of it. The card above
already goes side-by-side at sm: with a fixed-width
copy column (--newsletter-copy-width), which only
leaves ~128px for this form column at 640px and
~197px at 768px — not enough room for input+button
side by side even at the new, lower floor. Stacked
through the whole 640-1023px range instead, side by
side again once the form column has real room (≈333px)
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"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
</button>
</div>
{emailError && (
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</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>
{/* 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) => handleConsentChange(e.target.checked)}
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary font-normal leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
</div>
{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>
+102 -44
View File
@@ -4,12 +4,13 @@ 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 = [
{
icon: "/icon-sparkle-wrapper.svg",
title: "7 Tage. Ein Fokus.",
desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.",
desc: "Wöchentliche Impulse für mehr Klarheit und weniger Reibung.",
},
{
icon: "/icon-checklist.png",
@@ -34,6 +35,8 @@ const features = [
export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const { email, emailError, consent, handleConsentChange, status, error, successMessage, 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
@@ -141,26 +144,50 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
// Capped narrower than the Desktop 75rem through the whole
// 640-1023px Tablet band — this dialog is a modal, not a page
// section, so at Tablet it should read as a compact centered
// card, not stretch to near-full-viewport-width once stacked
// single-column (see the flex-col/flex-row split below):
// full-bleed-width + a single stacked photo/text column made
// the photo huge and the text below it look lost/disconnected
// (reported 2026-07-29, after first trying a lg:-gated
// structural stack with no width cap). max-w-[75rem] only
// takes over once the 2-column split itself starts at lg:.
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[36rem] lg:max-w-[75rem] max-h-[90vh] overflow-y-auto"
>
{/* sticky, not absolute — the dialog itself is the scrolling
container (overflow-y-auto above), so an absolute-positioned
child scrolls away with the rest of the content instead of
staying pinned to the visible top-right corner (reported
2026-07-29). sticky top-6 keeps it fixed to the scrolled
viewport's top edge; ml-auto pushes it to the right within
the dialog's normal block flow (sticky positioning doesn't
use right-* the way absolute does); -mb-6 cancels its own
height (size-6 = 1.5rem) so it doesn't push the modal-top
content below it down — same visual overlap as the old
absolute positioning, just still visible after scrolling. */}
<button
ref={closeButtonRef}
type="button"
onClick={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"
className="sticky top-6 ml-auto mr-6 -mb-6 z-20 size-6 flex items-center justify-center active:scale-90 transition-transform"
>
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
</button>
{/* modal-top: photo + copy/form, stacked below md */}
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
{/* modal-top: photo + copy/form, stacked below lg: — paired with
the dialog's own narrower max-w-[36rem] cap through Tablet
(see above), so the stacked photo stays a reasonably-sized
4:3 banner instead of blowing up to near-full-viewport-width. */}
<div className="flex flex-col lg:flex-row items-stretch border-b border-border">
<div className="relative w-full lg:flex-1 aspect-[4/3] lg:aspect-auto">
<Image
src="/newsletter-modal-photo.jpg"
alt="Notizbuch mit Kaffee und Stift"
fill
sizes="(min-width: 768px) 50vw, 100vw"
sizes="(min-width: 1024px) 50vw, 36rem"
className="object-cover"
/>
</div>
@@ -169,8 +196,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
itself is authored upside-down (matches how Newsletter.tsx
uses this exact same asset); without it the icon renders
flipped. */}
<div className="w-16 h-14 -rotate-4 -scale-y-100">
flipped. Hidden below lg: — removed on mobile 2026-07-24. */}
<div className="hidden lg: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>
@@ -179,46 +206,75 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-lora)" }}
>
Starte mit einer Woche voller Klarheit<span className="text-brand">.</span>
Kleine Impulse große Wirkung
</p>
<p className="text-body text-text-primary">
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.
</p>
<form className="flex flex-col gap-5 items-start w-full">
<div className="flex flex-col gap-4 items-start w-full">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="w-full bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
/>
<button
type="submit"
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
>
Jetzt anmelden
</button>
</div>
<label className="flex gap-2 items-center w-full cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
{status === "success" ? (
<p className="text-body text-text-primary font-medium">{successMessage}</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-5 items-start w-full">
<div className="flex flex-col gap-2 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"
}`}
/>
{/* Always rendered (min-h reserves one line's worth of
space) rather than conditionally mounted — this sits
inside the same row the photo on the left stretches
to match (items-stretch, md:aspect-auto), so an error
popping in and out used to grow/shrink the whole
modal, visibly resizing the photo along with it. */}
<p className="text-label text-red-600 font-normal -mt-2 min-h-[1.05rem]">{emailError}</p>
<button
type="submit"
disabled={status === "submitting"}
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base disabled:opacity-60 disabled:pointer-events-none"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
</form>
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
</button>
</div>
<label className="flex gap-2 items-center w-full cursor-pointer">
<input
type="checkbox"
required
checked={consent}
onChange={(e) => handleConsentChange(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>
{/* Same reserved-space fix as emailError above — this is
the "already subscribed" message, the one that actually
prompted it. */}
<p className="text-label text-red-600 font-normal min-h-[1.05rem]">
{status === "error" ? error : ""}
</p>
</form>
)}
</div>
</div>
@@ -231,7 +287,9 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
items-start + a fixed icon bounding box (icons have different
native proportions, e.g. the sparkle glyph isn't square) fixes
it without needing the flip trick. */}
<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">
{/* Same lg: exception as modal-top above, for the same narrower-
dialog-width-through-Tablet reason. */}
<div className="flex flex-col lg:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 lg:gap-6">
{features.map((f) => (
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { isValidEmail } from "../lib/email";
import { ArrowRightIcon } from "./ArrowRightIcon";
/**
* Replaces the (disabled) Add-to-cart button's spot once a product/variant
* is out of stock — lets a visitor leave their email to be notified once
* lib/jobs/sendBackInStockEmails.ts (Payload backend) sends the "it's
* back" mail. Always shows the email input + submit control directly (no
* extra click to reveal them).
*
* One input with the submit control embedded inside it (absolutely
* positioned, not a layout sibling) — not a separate stacked button below
* the input. A first version stacked input + full-width "Benachrichtigen"
* button, which made an out-of-stock ProductCard.tsx visibly taller than
* its in-stock siblings (two form rows vs. one button); shrinking that
* stacked version's own padding was tried and reverted — the actual ask
* was to keep every control's height untouched and make the CARD match
* instead. Embedding the submit icon inside the input keeps this whole
* control to exactly one row, given an explicit h-14 (3.5rem/56px) to
* match AddToCartInlineButton's own "In den Warenkorb" button's actual
* rendered height — that button isn't 56px from its py-3 padding alone,
* its 1.875rem cart-icon image is the tallest thing in it, so matching
* padding here wouldn't have matched height; a sold-out card now ends up
* exactly as tall as an in-stock one.
*/
export function NotifyMeForm({ productId, variantName = "" }: { productId: number; variantName?: string }) {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isValidEmail(email)) {
setError("Bitte gib eine gültige E-Mail-Adresse ein.");
return;
}
setError("");
setStatus("submitting");
try {
const res = await fetch("/api/stock-notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, productId, variantName }),
});
const data: { ok: boolean; reason?: string } = await res.json();
if (!data.ok) {
setStatus("error");
setError(data.reason || "Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
return;
}
setStatus("success");
} catch {
setStatus("error");
setError("Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
}
}
if (status === "success") {
return <p className="text-body-sm text-success">Danke! Wir melden uns, sobald es wieder verfügbar ist.</p>;
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5 w-full">
<div className="relative w-full">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Bei Verfügbarkeit benachrichtigen"
aria-label="E-Mail-Adresse für Benachrichtigung, sobald das Produkt wieder verfügbar ist"
// h-14 (3.5rem/56px), not py-3 alone — AddToCartInlineButton's
// own button isn't 56px tall because of its py-3 padding alone,
// it's that plus its 1.875rem/30px cart-icon image, which is
// taller than this input's own text line-height would be at
// that same padding. Setting the height explicitly (rather than
// trying to reverse-engineer a padding value that happens to
// produce 56px for text-body-sm) is what actually matches it.
className="w-full h-14 rounded-sm border border-border pl-3 pr-12 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
/>
<button
type="submit"
disabled={status === "submitting"}
aria-label="Benachrichtigen"
title="Benachrichtigen"
className="absolute right-1.5 top-1/2 -translate-y-1/2 flex size-8 items-center justify-center rounded-sm text-text-primary hover:text-brand transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{status === "submitting" ? "…" : <ArrowRightIcon />}
</button>
</div>
{error && <p className="text-label text-red-600">{error}</p>}
</form>
);
}
+227
View File
@@ -0,0 +1,227 @@
import Image from "next/image";
import Link from "next/link";
import type { PageBlock } from "../lib/payload";
import { getTestimonials } from "../lib/payload";
import { RichText } from "./RichText";
import { Quote } from "./Quote";
import { StepArrow } from "./StepArrow";
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
import { TestimonialsGrid } from "./TestimonialsGrid";
// Renders a Payload Pages document's `layout` blocks field — structurally
// parallel to RichText.tsx's own `blocks: {...}` converter map, but one
// level up (full page sections, not inline Lexical nodes). Each block
// component reuses the exact same visual treatment already hand-built on
// /lebensuhr, /3x3-system, and /7-tage-klarheits-check, rather than
// inventing new styling — this is what those pages' repeated patterns get
// consolidated into for CMS-driven pages going forward.
export async function PageBlocks({ blocks }: { blocks: PageBlock[] }) {
const rendered = await Promise.all(
blocks.map(async (block) => {
if (block.blockType === "testimonialsRef") {
const testimonials = await getTestimonials(block.page);
if (testimonials.length === 0) return null;
return <TestimonialsGrid key={block.id} testimonials={testimonials} />;
}
return renderPageBlockSync(block);
})
);
return <>{rendered}</>;
}
// Every block EXCEPT testimonialsRef (needs its own async data fetch,
// which a client component can't await — see LivePageContent.tsx, which
// uses this directly and simply skips that one block type in preview,
// same "live-previewable subset" scope-cut LivePostContent.tsx already
// makes for its own author-bio/"Weiterlesen" chrome).
export function renderPageBlockSync(block: PageBlock): React.ReactNode {
switch (block.blockType) {
// Same "font-playfair clamp headline" treatment every hand-built page
// (/lebensuhr, /3x3-system, /ueber-mich) used for its own H1 before
// this block existed — see HeroBlock.ts's own comment on why the
// headline moved out of Pages.title and into here.
case "hero": {
const headline = (
<p
className={
block.image
? "font-semibold text-[clamp(2rem,1.393rem+1.5vw,2.75rem)] text-text-primary leading-[1.15]"
: "font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
}
style={{ fontFamily: "var(--font-playfair)" }}
>
{block.headline}
</p>
);
return (
<div key={block.id} className="flex flex-col gap-4 items-start">
{/* Optional round portrait beside the headline — e.g. /ueber-mich's
"Hallo, ich bin Björn." size-20 (80px) read as barely-there/
easy to miss next to a full headline — bumped to size-32
(128px) so it actually registers as a real photo, not a
small decorative blob. */}
{block.image ? (
<div className="flex items-center gap-6">
<div className="relative size-32 shrink-0 rounded-full overflow-hidden ring-1 ring-border">
<Image alt="" src={block.image} fill sizes="128px" className="object-cover" />
</div>
{headline}
</div>
) : (
headline
)}
{/* text-primary + text-h-emphasis, not text-muted body — same
weight class as the homepage Hero's own subheading
("Ich helfe dir..."), which a plain muted text-body read too
quiet next to. */}
{block.subline && (
<p className="font-semibold text-h-emphasis text-text-primary leading-[1.4]">{block.subline}</p>
)}
{block.ctaLabel && block.ctaHref && (
<Link
href={block.ctaHref}
className="inline-flex items-center gap-2 bg-brand hover:brightness-95 active:scale-[0.97] transition-all rounded-sm px-6 py-3 font-semibold text-body text-text-primary"
>
{block.ctaLabel}
</Link>
)}
</div>
);
}
case "richTextSection":
return <RichText key={block.id} content={block.content} />;
case "quote":
return <Quote key={block.id} label={block.label}>{block.text}</Quote>;
case "icon": {
const SymbolIcon = SYMBOLIC_ICONS[block.icon];
return SymbolIcon ? <div key={block.id}>{SymbolIcon()}</div> : null;
}
case "image":
if (!block.image) return null;
return (
<div key={block.id} className="flex flex-col gap-2 w-full">
<div className="relative w-full aspect-[3/2] rounded-xl overflow-hidden bg-bg-muted">
<Image alt={block.caption ?? ""} src={block.image} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-contain" />
</div>
{block.caption && <p className="text-body-sm text-text-muted text-center">{block.caption}</p>}
</div>
);
case "pillList":
return (
<div key={block.id} className="flex flex-wrap gap-2">
{block.items.map((item) => (
<span key={item.id} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">{item.label}</span>
))}
</div>
);
case "checklistImage":
return (
<div key={block.id} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
{block.image && (
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
<Image alt="" src={block.image} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
</div>
)}
<ul className="flex-1 w-full flex flex-col gap-4">
{block.items.map((item) => (
<li key={item.id} className="flex items-start gap-3">
<CheckIcon />
<p className="text-body text-text-body">{item.text}</p>
</li>
))}
</ul>
</div>
);
case "table":
return (
<div key={block.id} className="bg-bg-muted rounded-xl overflow-hidden w-full">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b-2 border-brand">
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.labelHeader}</th>
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.valueHeader}</th>
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={row.id} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
case "stepRow": {
const icons = block.items.map((item) => STEP_ICONS[item.icon]);
return (
<div key={block.id} className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
{block.items.flatMap((item, i) => [
<div key={item.id} 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">
{icons[i]?.()}
</div>
<div className="flex flex-col gap-1 text-center">
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
</div>
</div>,
i < block.items.length - 1 ? (
<div key={`arrow-${item.id}`} className="flex items-center justify-center shrink-0 lg:mt-5">
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
</div>
) : null,
])}
</div>
);
}
case "testimonialsRef":
// Needs an async fetch — handled by PageBlocks (server) above.
// Skipped in the client-side live-preview renderer.
return null;
case "ctaCard":
return (
<Link
key={block.id}
href={block.href}
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
>
<div className="flex flex-col gap-1">
<p className="font-bold text-[0.8125rem] text-brand">{block.eyebrow}</p>
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{block.title}</p>
{block.description && <p className="text-body-sm text-text-muted">{block.description}</p>}
</div>
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary 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>
</Link>
);
default:
// Unknown/malformed block — same "skip rather than crash" convention
// RichText.tsx's own blocks use.
return null;
}
}
// Same checkmark used by /lebensuhr's and /7-tage-klarheits-check's own
// checklists.
function CheckIcon() {
return (
<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>
);
}
+251
View File
@@ -0,0 +1,251 @@
import Link from "next/link";
import Image from "next/image";
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
import { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getTestimonials } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
import { AddToCartButton } from "./AddToCartButton";
import { ProductName } from "./ProductName";
import { RichText } from "./RichText";
import { Reveal } from "./Reveal";
import { renderPageBlockSync } from "./PageBlocks";
import { TestimonialsGrid } from "./TestimonialsGrid";
// Renders a Payload Products document's `layout` blocks field — same
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
// this delegates to directly for every block type the two fields share
// (richTextSection/stepRow/quote/image/icon/pillList/checklistImage/table/
// ctaCard are the exact same Block configs, just registered a second time
// on Products.layout — see that field's own comment in Products.ts). Only
// `productHero` and `productPricingPanel` are product-specific, since they
// need the live product/shipping/tax context that a generic content page
// doesn't have.
export async function ProductBlocks({ product }: { product: Product }) {
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const ctx: ProductBlockContext = { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
const rendered = await Promise.all(
product.layout.map(async (block) => {
// Needs its own async data fetch — same reason PageBlocks.tsx
// handles this one block type in its own outer async map instead of
// the sync switch below (renderProductBlock/renderPageBlockSync).
if (block.blockType === "testimonialsRef") {
const testimonials = await getTestimonials(block.page);
if (testimonials.length === 0) return null;
return (
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-6">
<TestimonialsGrid testimonials={testimonials} />
</div>
);
}
// productHero/productPricingPanel render their own full section
// (edge-to-edge image, own Reveal, own padding rhythm) — matching
// the hand-coded PDPs (todo-cards etc.) precisely needed each of
// those, unlike a generic content block, so they opt out of this
// shared wrapper entirely rather than fighting it.
if (block.blockType === "productHero" || block.blockType === "productPricingPanel") {
return <div key={block.id}>{renderProductBlock(block, ctx)}</div>;
}
return (
<Reveal key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12">
{renderProductBlock(block, ctx)}
</Reveal>
);
})
);
return <>{rendered}</>;
}
type ProductBlockContext = {
product: Product;
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
taxRate: number;
kleinunternehmer: boolean;
};
function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
switch (block.blockType) {
case "productHero":
return <ProductHero {...ctx} body={block.body} />;
case "productPricingPanel":
return <ProductPricingPanel {...ctx} />;
// Every other block type is identical to Pages.layout's own — same
// Block config, reused a second time (see this file's own comment).
default:
return renderPageBlockSync(block as PageBlock);
}
}
// Matches der-eine/todo-cards' own hand-coded Hero.tsx exactly — same
// section/grid/Reveal/padding structure, same edge-to-edge image with
// hover-scale — so a block-driven PDP hero doesn't visually stand apart
// from its hand-coded siblings. Only the body prose differs: it's this
// block's own `body` richText instead of hardcoded JSX.
function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: ProductBlockContext & { body: unknown }) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<section className="bg-bg-base w-full overflow-hidden">
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">
Startseite
</Link>
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">
<ProductName name={product.name} />
</span>
</p>
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
<div className="flex flex-col gap-2 items-start w-full">
<p className="font-semibold text-h-page text-text-primary" style={{ fontFamily: "var(--font-playfair)" }}>
<ProductName name={product.name} />
</p>
{product.subline && (
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
{product.subline}
</p>
)}
</div>
{body ? <RichText content={body} /> : null}
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
</div>
<AddToCartButton
label="In den Warenkorb"
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
<Reveal
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
delay={0.15}
>
<Image
src={product.image}
alt={product.name}
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
</div>
</section>
);
}
// Matches todo-cards' own hand-coded Pricing.tsx exactly — see that
// file's own comment on why this closing panel looks the way it does.
function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: ProductBlockContext) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 410px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{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-3 items-start flex-1 min-w-0 w-full">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
<ProductName name={product.name} />
</p>
{product.subline && <p className="text-body-sm text-text-primary">{product.subline}</p>}
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<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
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<AddToCartButton
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
</section>
);
}
+163
View File
@@ -0,0 +1,163 @@
import Link from "next/link";
import Image from "next/image";
import type { ReactNode } from "react";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
import { AddToCartInlineButton } from "./AddToCartInlineButton";
import { WishlistButton } from "./WishlistButton";
import { ProductName } from "./ProductName";
import type { Product } from "../lib/payload";
// Single shared card markup for every product grid (ProductGrid,
// RelatedProducts, MerklisteGrid) — these three used to each duplicate
// this JSX independently and had drifted (different image aspect ratios,
// a "Mehr erfahren" link present on some but not others, purchased/
// out-of-stock badges only on some). The title itself is now the card's
// only link (product.href, when set) — no separate "Mehr erfahren" CTA
// line, which is also what makes the card more compact than before.
// No "use client" — plain enough (no hooks/browser APIs of its own) to
// render from both ProductGrid's Server Component and RelatedProducts'/
// MerklisteGrid's Client Components.
export function ProductCard({
product,
defaultTaxRate,
kleinunternehmer,
wishlistEnabled,
wishlistRevealOnHover = false,
topLeftBadge,
belowPrice,
className = "",
}: {
product: Product;
defaultTaxRate: number;
kleinunternehmer: boolean;
wishlistEnabled: boolean;
/** See WishlistButton's own doc: true for any grid where an unprompted
* heart on every card would read as noise (ProductGrid, RelatedProducts);
* false (default) for /konto/merkliste, where every card is already
* wishlisted. */
wishlistRevealOnHover?: boolean;
/** Overrides the default Ausverkauft/discount pill — used by
* MerklisteGrid for its "Gekauft am ..." badge. Pass `null` to render
* no badge at all. */
topLeftBadge?: ReactNode;
/** Rendered directly under the price — e.g. ProductGrid's delivery-time
* line. Omitted entirely by grids that don't have anything to say there
* (RelatedProducts, MerklisteGrid), rather than every card carrying a
* fixed slot for content only one of the three actually has. */
belowPrice?: ReactNode;
className?: string;
}) {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
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 (
// 6 direct grid rows (image / header / price / belowPrice / lowstock /
// button) instead of the old flex column — a plain flex-1 spacer only
// pins the button to the bottom of THIS card, it can't make the price
// row start at the same Y as a row sibling whose header block is a
// different height (e.g. a 2-line vs 1-line subline). grid-template-
// rows: subgrid pulls in the row-tracks each caller's grid container
// already declares (see ProductGrid.tsx's own comment) so every row is
// genuinely shared/max'd across the row of cards — dynamic, no
// line-clamp/truncation needed on the subline. gap-3 is used for every
// row gap including after the image (was pt-4/1rem there before —
// 0.25rem less, accepted for one consistent grid gap instead of mixed
// margin/gap spacing).
<div
className={`group bg-bg-base border border-border rounded-md overflow-hidden grid [grid-template-rows:subgrid] [grid-row:span_6] gap-3 transition-transform duration-300 hover:-translate-y-1 ${className}`}
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
{/* Link wraps only the image, not the whole header — WishlistButton
below is its own interactive element and sits as a sibling, not
nested inside this Link (nested interactive elements are both
invalid HTML and would fire navigation on a wishlist click). */}
{product.href ? (
<Link href={product.href} aria-label={product.name} className="absolute inset-0 z-0">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
</Link>
) : (
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
)}
{topLeftBadge !== undefined ? (
topLeftBadge
) : 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>
)
)}
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
)}
</div>
<div className="flex flex-col gap-1 items-start w-full px-5">
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h4 text-text-primary w-full hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
<ProductName name={product.name} />
</Link>
) : (
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
<ProductName name={product.name} />
</p>
)}
{product.subline && <p className="text-body-sm text-text-muted w-full">{product.subline}</p>}
</div>
<p className="flex items-baseline gap-1.5 px-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>
<div className="px-5">{belowPrice}</div>
{/* Always rendered, text conditional — an empty row still occupies
its shared track (0-height unless a row sibling needs it), same
reasoning as the price/header rows above. */}
<p className="text-label font-bold text-warning px-5">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
{/* self-end pins the button to the bottom of this row even though
the row itself is minmax(0,1fr) (see grid-auto-rows) and can be
taller than the button — replaces the old flex-1 spacer. */}
<div className="self-end px-5 pb-5 w-full">
<AddToCartInlineButton
id={product.id}
numericId={product.numericId}
outOfStock={product.outOfStock}
maxQty={product.maxQty}
variants={product.variants}
/>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useRef, useState } from "react";
import Image from "next/image";
const SWIPE_THRESHOLD_PX = 40;
/**
* Main image + thumbnail strip, swappable on click. `image` is always
* slide zero, `gallery` (Products.gallery, optional/empty for most
* products) fills in the rest. Renders as a single plain image with no
* thumbnail row at all when `gallery` is empty — the common case, and
* exactly today's pre-gallery appearance, no behavior change for any
* product that hasn't opted in. Plain divs/buttons, no carousel package —
* same "no charting/UI-library dependency for a simple case" reasoning as
* OrderQueueWidget.tsx's own OrderSparkline.
*/
export function ProductGallery({ image, gallery, alt }: { image: string; gallery: string[]; alt: string }) {
const slides = [image, ...gallery];
const [current, setCurrent] = useState(0);
const touchStartX = useRef<number | null>(null);
if (slides.length <= 1) {
return (
<div className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base">
<Image src={image} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
</div>
);
}
function next() {
setCurrent((c) => (c + 1) % slides.length);
}
function prev() {
setCurrent((c) => (c - 1 + slides.length) % slides.length);
}
function handleTouchStart(e: React.TouchEvent) {
touchStartX.current = e.touches[0].clientX;
}
function handleTouchEnd(e: React.TouchEvent) {
if (touchStartX.current === null) return;
const delta = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (delta <= -SWIPE_THRESHOLD_PX) next();
else if (delta >= SWIPE_THRESHOLD_PX) prev();
}
return (
<div className="flex flex-col gap-3.5 w-full">
<div
className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base touch-pan-y"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<Image src={slides[current]} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
<button
type="button"
onClick={prev}
aria-label="Vorheriges Bild"
className="absolute left-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M15 19l-7-7 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="Nächstes Bild"
className="absolute right-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<span className="absolute bottom-3.5 right-3.5 rounded-full bg-text-primary/60 px-2.5 py-1 text-label font-semibold text-white tabular-nums">
{current + 1} / {slides.length}
</span>
</div>
<div className="flex gap-2.5">
{slides.map((src, i) => (
<button
key={src + i}
type="button"
onClick={() => setCurrent(i)}
aria-label={`Bild ${i + 1} anzeigen`}
aria-current={i === current}
className={`relative h-[4.75rem] w-[4.75rem] shrink-0 overflow-hidden rounded-sm border-2 transition-opacity ${
i === current ? "border-brand opacity-100" : "border-transparent opacity-70 hover:opacity-100"
}`}
>
<Image src={src} alt="" fill sizes="76px" className="object-cover" />
</button>
))}
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
// Every hand-written PDP headline styles a trailing "." in brand color
// (e.g. der-eine/Pricing.tsx's "Der Eine<span className="text-brand">.</span>"),
// matching how every product's own Payload `name` is actually written
// ("Der Eine.", "Die Pause.", "Einfach anfangen."). Anywhere `product.name`
// is rendered as plain text instead (cards, cart line items, the one
// Payload-driven PDP hero) silently lost that styling — this centralizes
// the same trailing-period split so it can't drift per call site again.
export function ProductName({ name }: { name: string }) {
if (!name.endsWith(".")) return <>{name}</>;
return (
<>
{name.slice(0, -1)}
<span className="text-brand">.</span>
</>
);
}
+80 -12
View File
@@ -1,11 +1,60 @@
import { Fragment, type ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { WishlistButton } from "./WishlistButton";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
type LexicalTextNode = { type: "text"; text: string; format?: number };
type LexicalNode = { type: string; text?: string; format?: number; children?: LexicalNode[] };
// Minimal Lexical JSON → JSX converter for spotlightText — deliberately NOT
// the full block-oriented RichText.tsx (Posts/LegalPages' renderer, with
// its own heading/paragraph spacing and image/quote blocks). This teaser
// only ever needs paragraphs of plain text with optional bold spans
// (Lexical's text `format` bitmask: 1 = bold), rendered inline to match
// this section's own <p className="text-body ..."> treatment rather than
// picking up article-level block styling.
function renderInline(node: LexicalNode, key: number): ReactNode {
if (node.type === "text") {
const textNode = node as LexicalTextNode;
const format = typeof textNode.format === "number" ? textNode.format : 0;
const bold = (format & 1) === 1;
// 2 = italic in Lexical's format bitmask — was silently dropped here,
// so italic spans (e.g. Tasse "Die Pause"'s spotlightText) rendered as
// plain text on the homepage despite showing correctly in the admin's
// richText editor.
const italic = (format & 2) === 2;
let el: ReactNode = textNode.text;
if (italic) el = <em>{el}</em>;
if (bold) el = <strong>{el}</strong>;
return <Fragment key={key}>{el}</Fragment>;
}
if (node.type === "linebreak") return <br key={key} />;
// Any other inline node (link, etc.) — render its text children without
// the wrapper itself; this teaser has no use for an actual <a> here.
return (node.children ?? []).map((child, i) => renderInline(child, i));
}
function SpotlightText({ content, fallback }: { content: unknown; fallback: string }) {
const root = (content as { root?: { children?: LexicalNode[] } } | null)?.root;
if (!root?.children?.length) {
return <p className="text-body text-text-body whitespace-pre-line">{fallback}</p>;
}
return (
<>
{root.children.map((paragraph, i) => (
<p key={i} className="text-body text-text-body">
{(paragraph.children ?? []).map((child, j) => renderInline(child, j))}
</p>
))}
</>
);
}
/**
* Product teaser for whichever product is marked `spotlight` in Payload
* (defaults to none — the section just doesn't render until one is set),
@@ -23,10 +72,12 @@ import { effectiveTaxRate } from "../lib/cartTotals";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const [product, shipping, defaultTaxRate] = await Promise.all([
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getSpotlightProduct(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getWishlistEnabled(),
]);
if (!product) return null;
@@ -41,14 +92,22 @@ export async function ProductSpotlight() {
// 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">
<section id="spotlight" className="w-full bg-bg-base py-12 sm:py-16 px-[var(--layout-padding-x)]">
{/* Row layout only from lg (1024px) up — sm:flex-row used to kick in
at 640px, but a 380px-wide image + text squeezed into the rest of
a tablet-width viewport (768-1023px) read as cramped/too wide.
Tablet now stays stacked like mobile, but max-w-[32rem] between sm
and lg keeps that stacked card centered and narrower than the
tablet viewport instead of stretching to fill it — true mobile
(below sm) stays unconstrained since the viewport itself is
already narrow there. */}
<Reveal className="max-w-[75rem] sm:max-w-[32rem] lg:max-w-[75rem] mx-auto rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 sm:p-10">
<div className="group relative w-full lg:w-[23.75rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src={image}
alt={product.name}
fill
sizes="(min-width: 768px) 380px, 100vw"
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{fullyOutOfStock ? (
@@ -62,6 +121,9 @@ export async function ProductSpotlight() {
</span>
)
)}
{wishlistEnabled && product.spotlightShowWishlist && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
)}
</div>
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
@@ -72,9 +134,7 @@ export async function ProductSpotlight() {
>
{product.spotlightHeadline || product.name}
</p>
<p className="text-body text-text-body">
{product.spotlightText || product.description}
</p>
<SpotlightText content={product.spotlightText} fallback={product.descriptionText} />
{/* 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). */}
@@ -85,10 +145,18 @@ export async function ProductSpotlight() {
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt. zzgl. Versand</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{/* Single product, no grid siblings to stay equal-height with
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
@@ -105,7 +173,7 @@ export async function ProductSpotlight() {
(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} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
<AddToCartButton label="In den Warenkorb" productId={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
{product.href && (
<Link
href={product.href}
+21
View File
@@ -0,0 +1,21 @@
import { QuoteLabel } from "./QuoteLabel";
// Same visual language as RichText.tsx's Quote converter (Caveat script +
// brand divider) — extracted here so PageBlocks.tsx (and any future hand-
// written page) can reuse it instead of each page defining its own copy,
// which is what /lebensuhr, /3x3-system, and /ueber-mich each did before
// this existed. `label` was dropped in that extraction (this component had
// no prop for it at all) — QuoteBlock.label was set in the admin but never
// actually reached the page, unlike RichText.tsx's own local Quote, which
// this now matches.
export function Quote({ label, children }: { label?: string | null; children: React.ReactNode }) {
return (
<div className="relative flex items-start gap-6 w-full my-2">
{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>
);
}
+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 }}
>
+323 -109
View File
@@ -1,31 +1,27 @@
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react";
import type { TOCSection } from "./SectionTOC";
import { QuoteLabel } from "./QuoteLabel";
import { StepArrow } from "./StepArrow";
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
// 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("");
@@ -70,114 +66,327 @@ export function extractHeadings(content: unknown): TOCSection[] {
return headings;
}
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string, quoteLabel: string): ReactNode {
if (!nodes) return null;
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`, quoteLabel));
// Payload upload relations resolve to the full media doc when fetched at
// sufficient depth (every richText-consuming fetch in app/lib/payload.ts
// already uses depth >= 2), or fall back to a bare id if not — only
// render when actually populated.
type MediaRef = { url?: string | null } | number | null | undefined;
function mediaUrl(ref: MediaRef): string | null {
if (ref && typeof ref === "object" && typeof ref.url === "string") return ref.url;
return null;
}
function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNode {
switch (node.type) {
case "linebreak":
return <br key={key} />;
case "text": {
let el: ReactNode = node.text;
const format = node.format ?? 0;
if (format & BOLD) el = <strong key={key}>{el}</strong>;
if (format & ITALIC) el = <em key={key}>{el}</em>;
if (format & UNDERLINE) el = <u key={key}>{el}</u>;
return <span key={key}>{el}</span>;
}
case "link":
return (
<a
key={key}
href={node.fields?.url ?? "#"}
className="text-brand hover:underline"
>
{renderChildren(node.children, key, quoteLabel)}
</a>
);
case "heading": {
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const text = plainText(node);
// Naive YouTube/Vimeo URL -> embed URL. Not exhaustive (no playlist/short-
// link edge cases) — good enough for a "paste a link" editor field; a
// URL that doesn't match either pattern just doesn't render rather than
// guessing wrong.
function toEmbedUrl(url: string): string | null {
const youtube = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{6,})/);
if (youtube) return `https://www.youtube.com/embed/${youtube[1]}`;
const vimeo = url.match(/vimeo\.com\/(\d+)/);
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
return null;
}
type ImageBlockFields = { image: MediaRef; caption?: string | null };
type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] };
type VideoEmbedBlockFields = { url: string; caption?: string | null };
type QuoteBlockFields = { text: string; label?: string | null };
type IconBlockFields = { icon: string };
type PillListBlockFields = { items: { id?: string | null; label: string }[] };
type ChecklistImageBlockFields = { image: MediaRef; items: { id?: string | null; text: string }[] };
type TableBlockFields = { labelHeader: string; valueHeader: string; rows: { id?: string | null; label: string; value: string }[] };
type StepRowBlockFields = {
items: { id?: string | null; icon: string; title: string; subtitle?: string | null; description: string }[];
};
type CtaCardBlockFields = { eyebrow: string; title: string; description?: string | null; href: string };
function BlockCaption({ caption }: { caption?: string | null }) {
if (!caption) return null;
return <p className="text-body-sm text-text-muted text-center">{caption}</p>;
}
// Same checkmark as PageBlocks.tsx's own — see that file's comment.
function CheckIcon() {
return (
<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>
);
}
// Same visual treatment as the QuoteBlock converter below (and the native
// blockquote case it replaces going forward) — see that converter's own
// comment for why both still exist.
function Quote({ label, children }: { label?: string; children: React.ReactNode }) {
return (
<div className="relative flex items-start gap-6 w-full my-6">
{/* Label/icon/underline are optional — if empty, only the divider +
quote text render. The quote itself is never optional, just this
framing around it. */}
{label && <QuoteLabel label={label} />}
<div className="w-px self-stretch bg-brand shrink-0" />
<p
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
style={{ fontFamily: "var(--font-caveat)" }}
>
{children}
</p>
</div>
);
}
// A factory, not a module-level constant — needs to close over each
// call's own `quoteLabel` (the native "quote" converter reads it). Server
// Components can render multiple posts concurrently in the same process,
// so a shared module-level variable set right before rendering would be
// a real race condition, not just a style choice.
function buildConverters(quoteLabel: string): JSXConvertersFunction {
return ({ defaultConverters }) => ({
...defaultConverters,
paragraph: ({ node, nodesToJSX }) => (
<p className="text-body text-text-body">{nodesToJSX({ nodes: node.children })}</p>
),
heading: ({ node, nodesToJSX }) => {
const Tag = node.tag as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const text = plainText(node as unknown as LexicalNode);
return (
<Tag
key={key}
id={Tag === "h2" ? headingId(text) : undefined}
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
style={{ fontFamily: "var(--font-lora)" }}
>
{renderChildren(node.children, key, quoteLabel)}
{nodesToJSX({ nodes: node.children })}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</Tag>
);
}
case "list": {
},
list: ({ node, nodesToJSX }) => {
const ListTag = node.listType === "number" ? "ol" : "ul";
return (
<ListTag
key={key}
className={
"flex flex-col gap-2 text-body text-text-body " +
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
}
>
{renderChildren(node.children, key, quoteLabel)}
{nodesToJSX({ nodes: node.children })}
</ListTag>
);
}
case "listitem":
return (
<li key={key}>{renderChildren(node.children, key, quoteLabel)}</li>
);
case "paragraph":
return (
<p key={key} className="text-body text-text-body">
{renderChildren(node.children, key, quoteLabel)}
</p>
);
// Lexical's default blockquote feature — used sitewide as a "Merke
// dir:" pull-quote callout, per page-blog-detail's actual built Figma
// frame (node 4676:341, file jCCZyh1DGwdjpv1wGge9To) — NOT a bordered/
// background card (an earlier version of this guessed one; the real
// design has no background or padding at all, just a plain 3-column
// row: label+underline, a full-height divider rule, then the quote
// lines). Icon is the actual exported sparkle asset from that node
// (icon-sparkle-merke-dir.png), not a hand-drawn approximation. The
// "Merke dir:" label itself is generic/hardcoded here rather than
// content-authored, since a blog post's own body text drives which
// lines get quoted, not the label framing them — legal pages never
// use blockquotes, so this styling is effectively blog-only in
// practice despite living in the shared renderer.
case "quote":
return (
<div key={key} className="relative flex items-start gap-6 w-full my-6">
{/* Label/icon/underline are optional (Posts.quoteLabel) — if
empty, only the divider + quote text render. The blockquote
itself is never optional, just this framing around it. */}
{quoteLabel && <QuoteLabel label={quoteLabel} />}
<div className="w-px self-stretch bg-brand shrink-0" />
{/* Lexical's real QuoteNode holds flat text/linebreak children
directly, NOT nested paragraphs — pressing Enter inside a
blockquote in the editor exits it into a new paragraph
rather than adding a line within it (confirmed by reading
@lexical/rich-text's QuoteNode.insertNewAfter). An earlier
version of this case assumed nested-paragraph children,
which only happened to work for this session's own
hand-authored seed JSON — any blockquote actually typed in
the CMS (Shift+Enter for a soft line break) rendered blank,
since child.children was undefined on a plain text node. */}
<p
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
style={{ fontFamily: "var(--font-caveat)" }}
},
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 auto-detects a typed-out URL/email as its own "autolink" node,
// distinct from an editor-inserted "link" node — falls through to
// Payload's unstyled default converter without this, which is why the
// Impressum/AGB's typed-in-place mailto addresses rendered as plain
// black text instead of matching every editor-inserted link.
autolink: ({ node, nodesToJSX }) => (
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
{nodesToJSX({ nodes: node.children })}
</a>
),
// Lexical's native blockquote feature — used by every post written
// before Blocks existed. Kept working exactly as before (own comment on
// Posts.ts's `content` field editor config on why this stays enabled
// alongside the new QuoteBlock) rather than migrating old content.
quote: ({ node, nodesToJSX }) => (
<Quote label={quoteLabel}>{nodesToJSX({ nodes: node.children })}</Quote>
),
blocks: {
image: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as ImageBlockFields;
const url = mediaUrl(fields.image);
if (!url) return null;
return (
<div className="flex flex-col gap-2 w-full">
<div className="relative w-full aspect-[3/2] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={url} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-cover" />
</div>
<BlockCaption caption={fields.caption} />
</div>
);
},
imageGallery: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as ImageGalleryBlockFields;
const images = (fields.images ?? []).filter((row) => mediaUrl(row.image));
if (images.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-4 w-full">
{images.map((row, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="relative aspect-[4/3] rounded-md overflow-hidden bg-bg-muted">
<Image
alt=""
src={mediaUrl(row.image)!}
fill
sizes="(min-width: 768px) 24rem, 50vw"
className="object-cover"
/>
</div>
<BlockCaption caption={row.caption} />
</div>
))}
</div>
);
},
videoEmbed: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as VideoEmbedBlockFields;
const embedUrl = toEmbedUrl(fields.url);
if (!embedUrl) return null;
return (
<div className="flex flex-col gap-2 w-full">
<div className="relative w-full aspect-video rounded-md overflow-hidden bg-bg-muted">
<iframe
src={embedUrl}
title={fields.caption ?? "Video"}
className="absolute inset-0 h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
<BlockCaption caption={fields.caption} />
</div>
);
},
// Per-quote label, unlike Posts.quoteLabel above (one label shared by
// every native blockquote in the post) — new quotes going forward use
// this instead of the native blockquote feature.
quote: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as QuoteBlockFields;
const lines = fields.text.split("\n");
return (
<Quote label={fields.label ?? undefined}>
{lines.map((line, i) => (
<span key={i}>
{line}
{i < lines.length - 1 && <br />}
</span>
))}
</Quote>
);
},
// Same page-builder blocks as PageBlocks.tsx (Pages.layout) — see
// that file's own comment on each block's visual treatment, mirrored
// here field-for-field since it's the exact same Block config
// registered a second time (Posts.content's BlocksFeature) so blog
// posts can use them mid-article too. `testimonialsRef` is the one
// page-builder block deliberately NOT included: it needs an async
// data fetch, and this converter set is shared with LiveRichText's
// client-side live-preview rendering (see blog's LivePostContent.tsx),
// where an async component would break — PageBlocks.tsx only gets
// away with it by awaiting outside the sync per-block switch, in its
// own async server component.
icon: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as IconBlockFields;
const SymbolIcon = SYMBOLIC_ICONS[fields.icon];
return SymbolIcon ? <div>{SymbolIcon()}</div> : null;
},
pillList: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as PillListBlockFields;
return (
<div className="flex flex-wrap gap-2">
{fields.items.map((item, i) => (
<span key={item.id ?? i} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">
{item.label}
</span>
))}
</div>
);
},
checklistImage: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as ChecklistImageBlockFields;
const url = mediaUrl(fields.image);
return (
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
{url && (
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
<Image alt="" src={url} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
</div>
)}
<ul className="flex-1 w-full flex flex-col gap-4">
{fields.items.map((item, i) => (
<li key={item.id ?? i} className="flex items-start gap-3">
<CheckIcon />
<p className="text-body text-text-body">{item.text}</p>
</li>
))}
</ul>
</div>
);
},
table: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as TableBlockFields;
return (
<div className="bg-bg-muted rounded-xl overflow-hidden w-full">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b-2 border-brand">
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.labelHeader}</th>
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.valueHeader}</th>
</tr>
</thead>
<tbody>
{fields.rows.map((row, i) => (
<tr key={row.id ?? i} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
},
stepRow: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as StepRowBlockFields;
const icons = fields.items.map((item) => STEP_ICONS[item.icon]);
return (
<div className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
{fields.items.flatMap((item, i) => [
<div key={item.id ?? i} 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">
{icons[i]?.()}
</div>
<div className="flex flex-col gap-1 text-center">
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
</div>
</div>,
i < fields.items.length - 1 ? (
<div key={`arrow-${item.id ?? i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
</div>
) : null,
])}
</div>
);
},
ctaCard: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as CtaCardBlockFields;
return (
<Link
href={fields.href}
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
>
{renderChildren(node.children, key, quoteLabel)}
</p>
</div>
);
default:
return renderChildren(node.children, key, quoteLabel);
}
<div className="flex flex-col gap-1">
<p className="font-bold text-[0.8125rem] text-brand">{fields.eyebrow}</p>
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{fields.title}</p>
{fields.description && <p className="text-body-sm text-text-muted">{fields.description}</p>}
</div>
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary 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>
</Link>
);
},
},
});
}
export function RichText({
@@ -185,18 +394,23 @@ export function RichText({
quoteLabel = "Merke dir:",
}: {
content: unknown;
/** Label for any blockquote's callout (see the "quote" case above) —
* defaults to "Merke dir:" for callers that don't pass one (legal pages
* never use blockquotes, so this only actually matters for blog posts).
* Pass "" to hide the label/icon/underline for every blockquote here. */
/** Label for any native blockquote's callout — defaults to "Merke dir:"
* for callers that don't pass one (legal pages never use blockquotes,
* so this only actually matters for blog posts). Pass "" to hide the
* label/icon/underline for every native blockquote here. New content
* should use the Zitat block instead, which carries its own label. */
quoteLabel?: string;
}) {
const root = (content as { root?: LexicalNode })?.root;
const root = (content as { root?: { children?: unknown[] } })?.root;
if (!root?.children) return null;
return (
<div className="flex flex-col gap-4 w-full">
{renderChildren(root.children, "root", quoteLabel)}
<LexicalRichText
data={content as Parameters<typeof LexicalRichText>[0]["data"]}
converters={buildConverters(quoteLabel)}
disableContainer
/>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import type { SearchResult } from "../api/search/route";
const DEBOUNCE_MS = 250;
// Plain trigger button — no state of its own. `open`/`onOpen` are lifted to
// Navbar (mirrors NewsletterModal's pattern) so SearchOverlay itself can be
// rendered as a <header> *sibling* instead of a descendant. Rendering it
// inside <header> put it under the header's conditional `backdrop-blur-md`
// (applied once `scrolled` or `mobileOpen` is true), and per spec a
// `backdrop-filter` makes its element a new containing block for
// `position: fixed` descendants — the overlay's `fixed inset-0` then
// resolved against the ~100px header instead of the viewport, clipping its
// opaque background to that band while the input/results overflowed past
// it, letting the page content underneath show through.
export function SearchButton({ onOpen }: { onOpen: () => void }) {
return (
<button
type="button"
onClick={onOpen}
aria-label="Suche öffnen"
className="flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</button>
);
}
export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open, onClose]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (query.trim().length < 2) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query.trim())}`, { cache: "no-store" });
const data: { results?: SearchResult[] } = await res.json().catch(() => ({ results: [] }));
setResults(data.results ?? []);
setLoading(false);
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
const products = results.filter((r) => r.type === "product");
const posts = results.filter((r) => r.type === "post");
if (!open) return null;
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center bg-bg-base/95 backdrop-blur-sm pt-[15vh] px-[var(--layout-padding-x)]" onClick={onClose}>
<div className="w-full max-w-[36rem]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 border-b-2 border-border focus-within:border-brand transition-colors pb-3">
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-muted shrink-0" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Produkte, Blogbeiträge…"
className="flex-1 min-w-0 bg-transparent outline-none text-h4 text-text-primary placeholder:text-text-muted"
/>
{/* X icon, not the old "Esc" text — the keyboard shortcut still
works (see the Escape keydown handler above), this button is
just the mouse/touch affordance, and a close icon reads
faster than a text label at a glance. */}
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 h-8 w-8 flex items-center justify-center text-text-muted hover:text-brand transition-colors">
<svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" aria-hidden="true">
<path d="M2 2L14 14M14 2L2 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="mt-6 flex flex-col gap-6 max-h-[55vh] overflow-y-auto">
{loading && <p className="text-body-sm text-text-muted">Suche</p>}
{!loading && query.trim().length >= 2 && results.length === 0 && (
<p className="text-body-sm text-text-muted">Keine Treffer für {query}.</p>
)}
{products.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Produkte</p>
{products.map((r) => (
<SearchResultRow key={r.id} result={r} onClose={onClose} />
))}
</div>
)}
{posts.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Blog</p>
{posts.map((r) => (
<SearchResultRow key={r.id} result={r} onClose={onClose} />
))}
</div>
)}
</div>
</div>
</div>
);
}
function SearchResultRow({ result, onClose }: { result: SearchResult; onClose: () => void }) {
return (
<Link
href={result.href}
onClick={onClose}
className="flex items-center gap-3 p-2 rounded-sm hover:bg-bg-muted transition-colors"
>
<div className="relative h-12 w-12 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
{result.thumbnail && <Image alt="" src={result.thumbnail} fill sizes="48px" className="object-cover" />}
</div>
<span className="text-body text-text-primary">{result.title}</span>
</Link>
);
}
+70 -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,29 @@ 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
// even the site's 640px structural floor, so sm: wouldn't leave room for
// a real 2-column split at Tablet widths — a deliberate exception to the
// site-wide sm: consolidation, not a leftover of it.
//
// 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 +103,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>
);
}
+3 -3
View File
@@ -15,7 +15,7 @@ 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)]">
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 sm: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"
@@ -24,11 +24,11 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
Was andere sagen
</Reveal>
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm: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"
className="group relative sm: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
+38 -13
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
import { ArrowRightIcon } from "./ArrowRightIcon";
import { getWerkzeugeCards } from "../lib/payload";
// Content now lives in Payload (WerkzeugeCards collection). Icons use a
@@ -20,27 +21,47 @@ export async function Tools() {
{/* Section header */}
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
<p className="font-bold text-brand text-h-small">
Meine Werkzeuge
Werkzeuge
</p>
<p
className="font-semibold text-text-primary text-h-section"
style={{ fontFamily: "var(--font-lora)" }}
>
Werkzeuge für einen leichteren Alltag.
Für mehr Orientierung im Alltag
</p>
</Reveal>
{/* Tools grid — 3 cols md+, stacked below md; internal icon+text layout
stays horizontal at every size, only the outer span changes */}
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
{/* Tools grid — 3 cols sm+ (moved down from the old md: so the grid
arrives where the fluid floor also sits), stacked below sm;
internal icon+text layout stays horizontal at every size, only
the outer span changes */}
<RevealGroup className="grid grid-cols-1 gap-y-14 sm:grid-cols-12 sm:gap-y-10 gap-x-10 sm:gap-x-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
{tools.map((tool) => (
<RevealItem
key={tool.id}
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
// Icon above text below lg (1024px) — a 3-up grid from sm
// (640px) leaves each card too narrow for icon+text side by
// side through the whole 640-1023px range. Centered on true
// mobile (matches the rest of this stacked-card pattern
// site-wide), left-aligned from sm up once there's a real
// single-column card width to left-align within.
className="sm:col-span-4 flex flex-col items-center text-center gap-4 sm:items-start sm:text-left lg:flex-row lg:gap-8 rounded-md transition-transform duration-300 hover:-translate-y-1"
>
{/* Icon — uniform box, pre-flipped/rotated source asset */}
<div className="relative flex items-center justify-center shrink-0 size-14">
<Image alt="" src={tool.icon} fill sizes="56px" className="object-contain" />
{/* Icon — uniform box, pre-flipped/rotated source asset.
size-14 (56px) is a fixed value at every width (14 isn't
one of this project's fluid spacing-scale steps) — smaller
below lg: so it doesn't dwarf the title/description text,
which does shrink toward its own fluid floor there. Full
56px only from lg: up — a deliberate exception to the
site's sm: (640px) structural consolidation: the grid now
switches to 3-up at sm:, but title/description are still
fairly close to their own fluid floor through the whole
640-1023px range, so the full-size icon still reads too big
next to them there. Not re-verified visually — narrower
exception kept as-is, same reasoning as Hero's content
sizing. */}
<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
@@ -55,8 +76,8 @@ export async function Tools() {
minimum text→CTA gap even for the tallest card (the one
that defines the row height, and so has ~zero leftover
space for justify-between to distribute on its own). */}
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-start gap-4 min-w-0 text-text-primary [word-break:break-word]">
<div className="flex flex-col gap-4 items-start w-full">
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-center text-center sm:items-start sm:text-left gap-4 min-w-0 text-text-primary [word-break:break-word]">
<div className="flex flex-col gap-4 items-center sm:items-start w-full">
<p
className="font-semibold leading-normal text-h-section w-full"
style={{ fontFamily: "var(--font-lora)" }}
@@ -67,11 +88,15 @@ export async function Tools() {
{tool.description}
</p>
</div>
{/* SVG arrow, not a Unicode "→" character — see
ArrowRightIcon.tsx's own comment on why (font-fallback
vertical-metrics mismatch, platform-dependent). */}
<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}
<ArrowRightIcon />
<span>{tool.ctaLabel}</span>
</Link>
</div>
</RevealItem>
+49 -12
View File
@@ -11,21 +11,58 @@ 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)]">
{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">
<div className="relative size-8 shrink-0">
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
<div className="w-full bg-bg-base py-8 px-[var(--layout-padding-x)]">
{/* Below lg (1024px): a CSS Grid with `grid-template-rows: subgrid` —
not a fixed min-height guess — so every badge's "icon+title" box
shares the SAME row track height (auto-sized to whichever
badge's title actually needs 2 lines), and every description
starts exactly at that row's bottom edge regardless of how many
lines its own title happens to wrap to. Icon beside text (the
lg: layout below) is what made a full row of 3 badges too wide
below ~1024px in the first place (see git history: originally
lg:-gated for exactly this, then briefly tried flex-wrap, which
put an odd 3rd item alone on its own wrapped line and read as
disorganized) — icon above text instead shrinks each badge down
to just its text column's width, letting a real row of 3 fit
without wrapping at all. Two structurally different layouts
(icon-above-title here vs. icon-beside-a-title/description-
stack at lg:) don't share one flexible markup shape cleanly, so
this renders as two separate blocks (lg:hidden / hidden lg:flex)
rather than fighting one shape across both breakpoints. */}
<div
className="lg:hidden grid justify-center gap-x-6 gap-y-1"
style={{ gridTemplateColumns: `repeat(${items.length}, auto)`, gridTemplateRows: "repeat(2, auto)" }}
>
{items.map((item) => (
<div key={item.id} className="grid row-span-2 justify-items-center" style={{ gridTemplateRows: "subgrid" }}>
<div className="flex flex-col items-center gap-2">
<div className="relative size-8 shrink-0">
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
</div>
<p className="font-semibold text-body text-text-primary text-center">{item.title}</p>
</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>
<p className="text-body-sm text-text-muted text-center">{item.description}</p>
</div>
))}
</div>
{/* lg+: original icon-beside-text row with dividers, unchanged. */}
<div className="hidden lg:flex justify-center gap-12 items-center">
{items.map((item, i) => (
<div key={item.id} className="flex items-center gap-12">
{i > 0 && <div className="h-10 w-px bg-border" />}
<div className="flex items-center gap-4">
<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>
</div>
</div>
</div>
</div>
))}
))}
</div>
</div>
);
}
+5 -1
View File
@@ -17,10 +17,14 @@ export function VersandModal({
open,
onClose,
shipping,
shippingCost,
freeShippingThreshold,
}: {
open: boolean;
onClose: () => void;
shipping: ShippingSettings;
shippingCost: number;
freeShippingThreshold: number | null;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
@@ -109,7 +113,7 @@ export function VersandModal({
</div>
<div className="px-8 py-6 pb-8">
<VersandSections shipping={shipping} />
<VersandSections shipping={shipping} shippingCost={shippingCost} freeShippingThreshold={freeShippingThreshold} />
</div>
</motion.div>
</motion.div>
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useWishlist } from "../lib/useWishlist";
// Heart-toggle for a product card/detail page. Login-gated (unlike the
// cart, which works for guests) — a logged-out click redirects to
// /konto/login?redirect=<back-here> instead of silently failing, since
// there's no local-storage fallback that would make sense for a wishlist
// (see useWishlist.ts's own comment on why this can't reuse cart.ts's
// guest-friendly pattern).
export function WishlistButton({
productId,
variant = "",
className = "",
revealOnHover = false,
}: {
productId: number;
variant?: string;
className?: string;
/** false (default): always visible — right for /konto/merkliste (every
* card there is already-wishlisted, so hover-reveal would be pointless)
* and the product detail page. true: invisible until the card is
* hovered/focused, unless the product is already wishlisted (a filled
* heart stays as a permanent status indicator) — right for any grid or
* card-like feature (ProductGrid.tsx, ProductSpotlight.tsx) where a
* heart sitting there unprompted reads as visual noise. Relies on the
* parent card already carrying `group`/`focus-within` (see
* ProductGrid.tsx/ProductSpotlight.tsx).
*
* On coarse-pointer (touch) devices, hover-reveal can't work at all —
* touch has no persistent `:hover`, so a tap on the card never reliably
* reveals a `group-hover`-gated element the way a mouse hover does.
* Rather than falling back to "always visible at full size" (which
* recreates the exact "a heart on every card" visual noise this mode
* exists to avoid), touch devices get a smaller variant with a lighter
* (not removed — fully transparent made it invisible against some
* product photos) background instead — present and tappable everywhere,
* but visually quieter than the full-size default. Applies to
* already-wishlisted hearts too (not just the hover-revealed ones) —
* consistent sizing across every heart on a touch device, rather than
* only the not-yet-wishlisted ones shrinking. This smaller touch sizing
* is applied regardless of `revealOnHover` (see the className below) —
* a heart reading visually "quieter" on a touch screen is a general
* touch-device trait, not specific to the multi-card-grid use case it
* was first built for. */
revealOnHover?: boolean;
}) {
const { isWishlisted, toggle } = useWishlist();
const [pending, setPending] = useState(false);
const router = useRouter();
const wishlisted = isWishlisted(productId, variant);
async function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (pending) return;
const res = await fetch("/api/account/wishlist", { method: "GET", cache: "no-store" });
if (res.status === 401) {
router.push(`/konto/login?redirect=${encodeURIComponent(window.location.pathname)}`);
return;
}
setPending(true);
await toggle(productId, variant);
setPending(false);
}
return (
<button
type="button"
onClick={handleClick}
aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"}
aria-pressed={wishlisted}
disabled={pending}
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-all active:scale-90 disabled:opacity-60 pointer-coarse:h-7 pointer-coarse:w-7 pointer-coarse:bg-bg-base/60 ${
revealOnHover && !wishlisted
? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
: ""
} ${className}`}
>
<svg
width="20"
height="18"
viewBox="0 0 20 18"
fill={wishlisted ? "currentColor" : "none"}
className={`${wishlisted ? "text-brand" : "text-text-primary"} pointer-coarse:scale-75`}
>
<path
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
</button>
);
}
+159
View File
@@ -0,0 +1,159 @@
// Shared registry of the site's hand-drawn brand-line icons, used by
// StepArrow-connected icon rows (/lebensuhr's phase row, /3x3-system's
// category row, /7-tage-klarheits-check's "So funktioniert"). Previously
// each page defined its own copy of these locally — consolidated here so
// the Payload page-builder's `stepRow` block can select one by a fixed
// key (see PageBlocks.tsx), and so the icons aren't duplicated per page
// anymore. The Payload StepRowBlock's `icon` select options MUST stay in
// sync with STEP_ICON_KEYS below by hand — no automatic sync between the
// two repos.
function IconSunrise() {
return (
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
<line x1="4" y1="30" x2="40" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<path d="M13 30a9 9 0 0 1 18 0" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="22" y1="4" x2="22" y2="10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="10" x2="13" y2="14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="35" y1="10" x2="31" y2="14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconGrowthBars() {
return (
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
<line x1="4" y1="36" x2="40" y2="36" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<rect x="7" y="25" width="7" height="11" rx="1" stroke="#f6a701" strokeWidth="2" />
<rect x="18.5" y="16" width="7" height="20" rx="1" stroke="#f6a701" strokeWidth="2" />
<rect x="30" y="6" width="7" height="30" rx="1" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconSunHigh() {
return (
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="8" stroke="#f6a701" strokeWidth="2" />
<line x1="20" y1="2" x2="20" y2="7" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="20" y1="33" x2="20" y2="38" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="20" x2="7" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="33" y1="20" x2="38" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="7" y1="7" x2="10.5" y2="10.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="29.5" y1="29.5" x2="33" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="33" y1="7" x2="29.5" y2="10.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="10.5" y1="29.5" x2="7" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconEnvelope() {
return (
<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" />
<path d="M2 2l24 22L50 2" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconMailLines() {
return (
<svg width="56" height="44" viewBox="0 0 56 44" fill="none">
<line x1="2" y1="12" x2="12" y2="12" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="20" x2="9" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="28" x2="7" y2="28" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<rect x="13" y="2" width="41" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
<path d="M13 2l20.5 19L54 2" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconNotepad() {
return (
<svg width="46" height="52" viewBox="0 0 46 52" fill="none">
<rect x="2" y="4" width="32" height="42" rx="2" stroke="#f6a701" strokeWidth="2" />
<line x1="9" y1="16" x2="27" y2="16" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="23" x2="24" y2="23" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="30" x2="20" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<circle cx="37" cy="40" r="7" stroke="#f6a701" strokeWidth="2" />
<line x1="37" y1="27" x2="37" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconCheckCircle() {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
<path d="M14 24l7.5 7.5L34 16" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconTarget() {
return (
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="16" stroke="#f6a701" strokeWidth="2" />
<circle cx="20" cy="20" r="9" stroke="#f6a701" strokeWidth="2" />
<circle cx="20" cy="20" r="2" fill="#f6a701" />
</svg>
);
}
function IconTrendingUp() {
return (
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
<path d="M4 30l10-10 6 6 14-14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M24 10h10v10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconShield() {
return (
<svg width="36" height="40" viewBox="0 0 36 40" fill="none">
<path d="M18 3l14 5v9c0 9-6 16-14 20-8-4-14-11-14-20v-9l14-5z" stroke="#f6a701" strokeWidth="2" strokeLinejoin="round" />
</svg>
);
}
// Keys MUST match the Payload StepRowBlock's `icon` select field options
// (docker/payload/src/blocks/StepRowBlock.ts) exactly.
export const STEP_ICONS: Record<string, () => React.ReactNode> = {
sunrise: IconSunrise,
growthBars: IconGrowthBars,
sunHigh: IconSunHigh,
envelope: IconEnvelope,
mailLines: IconMailLines,
notepad: IconNotepad,
checkCircle: IconCheckCircle,
target: IconTarget,
trendingUp: IconTrendingUp,
shield: IconShield,
};
// Same symbolic clock icon /lebensuhr uses above its "24 Stunden" heading
// — purely decorative (no numbers/data), unlike the STEP_ICONS above which
// always sit inside an icon-above-text row. Keys MUST match the Payload
// IconBlock's `icon` select field options (docker/payload/src/blocks/
// IconBlock.ts) exactly.
function IconClockSymbol() {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg) => {
const rad = (deg * Math.PI) / 180;
const x1 = 24 + 16 * Math.sin(rad), y1 = 24 - 16 * Math.cos(rad);
const x2 = 24 + 18.5 * Math.sin(rad), y2 = 24 - 18.5 * Math.cos(rad);
return <line key={deg} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#f6a701" strokeWidth="1.5" strokeLinecap="round" />;
})}
<line x1="24" y1="24" x2="17" y2="15" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="24" y1="24" x2="32" y2="15" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<circle cx="24" cy="24" r="1.8" fill="#f6a701" />
</svg>
);
}
export const SYMBOLIC_ICONS: Record<string, () => React.ReactNode> = {
clockSymbol: IconClockSymbol,
};
@@ -0,0 +1,54 @@
import { headingId } from "../../components/RichText";
import type { CompanySettings } from "../../lib/payload";
import type { TOCSection } from "../../components/SectionTOC";
// Renders "1. Verantwortlicher" straight from company-settings, same
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx — this
// used to be hand-typed name/address/email baked into the Datenschutz
// richText (seed-datenschutz.ts on the Payload side), silently out of
// date the moment company-settings changed without a matching manual
// edit here too. The heading itself stays numbered "1." (headingId's own
// `^(\d+)\.` match turns that into "section-1", exactly the id this
// section's TOC entry already had before the RichText stopped rendering
// it — no anchor breakage).
export function verantwortlicherHeadings(): TOCSection[] {
return [{ id: headingId("1. Verantwortlicher"), title: "1. Verantwortlicher" }];
}
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: React.ReactNode }) {
return <p className="text-body text-text-body">{children}</p>;
}
export function VerantwortlicherBlock({ seller }: { seller: CompanySettings }) {
return (
<div className="flex flex-col gap-4 w-full">
<Heading>1. Verantwortlicher</Heading>
<P>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</P>
<div className="flex flex-col gap-1">
<P>{seller.managingDirector || seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
<P>
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
</P>
</div>
<P>Ein gesetzlich vorgeschriebener Datenschutzbeauftragter ist für unser Unternehmen aufgrund seiner Größe derzeit nicht erforderlich.</P>
</div>
);
}
+22 -6
View File
@@ -6,8 +6,10 @@ import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage, getCompanySettings } from "../lib/payload";
import { formatMonthYear } from "../lib/format";
import { VerantwortlicherBlock, verantwortlicherHeadings } from "./components/VerantwortlicherBlock";
export const metadata: Metadata = {
title: "Datenschutzerklärung",
@@ -17,8 +19,11 @@ export const metadata: Metadata = {
export default async function DatenschutzPage() {
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("datenschutz", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
const [page, seller] = await Promise.all([getLegalPage("datenschutz", { draft: isPreview }), getCompanySettings()]);
// "1. Verantwortlicher" headings first — that block renders above the
// CMS content below (same reasoning as Impressum's own headings
// composition, see AnbieterAngaben.tsx).
const headings = [...verantwortlicherHeadings(), ...(page ? extractHeadings(page.content) : [])];
return (
<>
@@ -35,9 +40,16 @@ export default async function DatenschutzPage() {
>
Datenschutzerklärung
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
{page && <p className="text-body text-text-muted">Stand: {formatMonthYear(page.updatedAt)}</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} />
@@ -61,7 +73,11 @@ export default async function DatenschutzPage() {
</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">
{/* Name/Adresse/E-Mail kommen direkt aus company-settings, nicht
aus der CMS-Richtext unten — single-sourced, gleiche
Begründung wie Impressum's AnbieterAngaben.tsx. */}
{seller && <VerantwortlicherBlock seller={seller} />}
{page ? (
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
+97
View File
@@ -0,0 +1,97 @@
import type { ReactNode } from "react";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
// No circle badge, thicker uniform-weight black stroke, no fill — matching
// the site's established illustrative icon language (public/icon-trust-*.png,
// public/icon-step-*.png): bold outline glyphs standing on their own, not
// boxed into a badge shape. Replaces the earlier thin-stroke circled
// version, which didn't match anything else on the site.
function IconBadge({ children }: { children: ReactNode }) {
return (
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" stroke="#231f20" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="h-16 w-auto shrink-0">
{children}
</svg>
);
}
function IconMetal() {
// Pen/nib glyph — stands in for the metal body itself.
return (
<IconBadge>
<path d="M10 22l2.5-0.8L23 10.7l-2.7-2.7L9.8 19.5 9 22z" />
<path d="M19 9l3 3" />
</IconBadge>
);
}
function IconSparkle() {
// Engraving/laser mark — four-point sparkle, same glyph as before, just
// heavier stroke and unboxed to match the rest of the set.
return (
<IconBadge>
<path d="M16 7v5M16 20v5M7 16h5M20 16h5M10.5 10.5l3 3M18.5 18.5l3 3M21.5 10.5l-3 3M13.5 18.5l-3 3" />
</IconBadge>
);
}
function IconCompact() {
// Four corner brackets pointing inward — standard "minimize/fits
// anywhere" glyph, reads as compact more directly than a shield ever did.
return (
<IconBadge>
<path d="M11 8H8v3M21 8h3v3M11 24H8v-3M21 24h3v-3" />
</IconBadge>
);
}
function IconEveryday() {
// Sun — everyday/daily use.
return (
<IconBadge>
<circle cx="16" cy="16" r="6" />
<path d="M16 4v3M16 25v3M4 16h3M25 16h3M7.5 7.5l2 2M22.5 22.5l2 2M24.5 7.5l-2 2M9.5 22.5l-2 2" />
</IconBadge>
);
}
// "Was kann er?" section's full four-item breakdown (build, engraving,
// size, use case). Same card shape as todo-cards/components/HowItWorks.tsx
// — icon on top, centered, RevealGroup/RevealItem stagger, identical
// title/description typography — reused for visual consistency with the
// rest of the site rather than a one-off layout. 4 cards instead of
// HowItWorks' 3 numbered steps, no connecting arrows (independent facts,
// not a sequence).
const items = [
{ icon: IconMetal, title: "Metallgehäuse", desc: "Robust, wertig und angenehm in der Hand." },
{ icon: IconSparkle, title: "Lasergravur", desc: "„Der Eine.“ auf der einen Seite. „einfach produktiv.“ auf der anderen." },
{ icon: IconCompact, title: "Kompakt", desc: "Passt ans Notizbuch, auf den Schreibtisch oder in die Tasche." },
{ icon: IconEveryday, title: "Für jeden Tag", desc: "Für Notizen, Gedanken, Listen und alles, was du lieber aufschreibst, bevor du es wieder vergisst." },
];
export function Focus() {
return (
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="flex flex-col gap-2 items-center text-center mb-10">
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Was kann er?
</p>
<p className="text-body text-text-muted max-w-[42rem] w-full text-left md:text-center">
Schreiben. Und das ziemlich gut. Metallgehäuse, angenehmes Gewicht und eine klassische Kugelschreibermine kein besonderes Produktivitäts-Gadget, sondern einfach ein guter Stift, den du gern zur Hand nimmst.
</p>
</Reveal>
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-start sm:items-start w-full max-w-[75rem] mx-auto">
{items.map(({ icon: Icon, title, desc }) => (
<RevealItem key={title} className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none mx-auto">
<Icon />
<p className="font-semibold text-body text-text-primary">{title}</p>
<p className="text-body-sm text-text-primary w-full text-left md:text-center">{desc}</p>
</RevealItem>
))}
</RevealGroup>
</section>
);
}
+144
View File
@@ -0,0 +1,144 @@
import Link from "next/link";
import { AddToCartButton } from "../../components/AddToCartButton";
import { ProductGallery } from "../../components/ProductGallery";
import { Reveal } from "../../components/Reveal";
import { ProductName } from "../../components/ProductName";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
// The one bullet list on this page — after feedback that three separate
// lists across Hero/Focus/Pricing was too much, this is the single place
// features get enumerated, everywhere else stays prose. A subset of the
// "Was kann er?" section's four cards (Focus.tsx) — "Für jeden Tag" is a
// use case, not a build fact, so it's covered by the body copy instead.
const features = ["Massives Metallgehäuse", "Lasergravur: „Der Eine.“ & „einfach produktiv.“", "Kompakt genug für jede Tasche"];
// Same fix/reasoning as TodoKartenHero.tsx's IconCheck — icon-check.svg's
// fill can't be recolored from outside the SVG when loaded via <img
// src>/next/image, so this stays an inline stroke path per page.
function IconCheck() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// Same "compact early teaser + delivery-time repeated next to every buy
// button" reasoning as TodoKartenHero.tsx.
export async function Hero() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null;
const anyLowStock = product
? product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock)
: false;
return (
<section className="bg-bg-base w-full overflow-hidden">
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">
Startseite
</Link>
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">{product ? <ProductName name={product.name} /> : "Der Eine."}</span>
</p>
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
<div className="flex flex-col gap-2 items-start w-full">
<p
className="font-semibold text-h-page text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
Der Eine<span className="text-brand">.</span>
</p>
{product?.subline && (
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.subline}
</p>
)}
</div>
<p className="text-body text-text-body">
Manchmal reicht ein Stift und ein Stück Papier. Der Eine. ist ein schlichter Kugelschreiber aus Metall angenehm in der Hand, klein genug für die Tasche und gemacht für alles, was kurz raus aus dem Kopf und irgendwo hin muss.
</p>
<ul className="flex flex-col gap-3 items-start w-full">
{features.map((item) => (
<li key={item} className="flex gap-[0.625rem] items-start w-full">
<IconCheck />
<span className="flex-1 text-body text-text-primary">{item}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-1 items-start">
{product && (
<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">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
)}
{!product?.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
</div>
{product && (
<AddToCartButton
label="Der Eine. bestellen"
productId={product.id}
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
)}
</div>
</Reveal>
{/* max-w cap, not the full lg:col-span-7 width — the source photos
are only 338×338px (checked directly against the Payload media
API, no larger size exists), so letting ProductGallery stretch
to the full column blows them up hard. Capping the display
width keeps the upscale factor small enough that it doesn't
look broken; it can't fix the underlying resolution, only hide
it. Real fix is new source photos, not a layout tweak. */}
{product && product.gallery.length > 0 && (
<Reveal className="order-2 lg:order-none lg:col-span-7 flex lg:items-center px-[var(--layout-padding-x)] lg:px-0" delay={0.15}>
<div className="w-full max-w-[28rem] mx-auto lg:mx-0">
<ProductGallery image={product.image} gallery={product.gallery} alt={product.name} />
</div>
</Reveal>
)}
</div>
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Image from "next/image";
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug } from "../../lib/payload";
// Replaces the page's final price/buy bar entirely (explicit choice —
// Hero.tsx already has the primary buy CTA, this page doesn't need a
// second one at the very bottom too). Same "Passend dazu" card markup as
// blog/[slug]/page.tsx's own related-product card. Driven by
// Products.relatedProduct (2026-08-25, same field/pattern as
// Posts.relatedProduct, just product->product instead of post->product) —
// reusable on any product page by passing that page's own product slug,
// not hardcoded to "todo-karten" like the first version of this component.
export async function PasstDazu({ productSlug }: { productSlug: string }) {
const product = await getProductBySlug(productSlug);
const related = product?.relatedProduct;
if (!related?.href) return null;
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-12 sm:py-16">
<Reveal className="max-w-[48rem] mx-auto">
<Link
href={related.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={related.image} fill sizes="64px" className="object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
<p className="font-bold text-[0.8125rem] text-brand">Passt dazu:</p>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
<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)" }}
>
{related.name}
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{related.descriptionText}</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>
</section>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
// Closing buy banner — same image+price+button card as todo-cards' and
// tasse-die-pause's own Pricing.tsx, repeated here as a second, final
// call-to-action after Testimonials so a visitor who scrolled past the
// Hero's buy button without ordering gets another prompt right before the
// footer.
export async function Pricing() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
if (!product) return null;
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
<div className="relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image src={product.image} alt={product.name} fill sizes="(min-width: 1024px) 410px, 100vw" className="object-cover" />
{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-3 items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Der Eine<span className="text-brand">.</span> jetzt sichern
</p>
<p className="text-body-sm text-text-primary">
Ein guter Stift muss nicht kompliziert sein. Hol dir Der Eine. und hab immer etwas zur Hand, wenn dir gerade etwas durch den Kopf geht.
</p>
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<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
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<AddToCartButton
label="Der Eine. bestellen"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
productId={product.id}
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
</section>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { draftMode } from "next/headers";
import { TestimonialsGrid } from "../../components/TestimonialsGrid";
import { LiveTestimonialsGrid } from "../../components/LiveTestimonialsGrid";
import { getTestimonials } from "../../lib/payload";
// Payload-driven like todo-cards/newsletter/challenge's own testimonial
// grids (see Testimonials collection's "page" field) — no longer a
// hardcoded array now that "der-eine" is a valid page value there.
export async function Testimonials() {
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("der-eine", { draft: isPreview });
return isPreview ? (
<LiveTestimonialsGrid testimonials={testimonials} />
) : (
<TestimonialsGrid testimonials={testimonials} />
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { Hero } from "./components/Hero";
import { Focus } from "./components/Focus";
import { PasstDazu } from "./components/PasstDazu";
import { Testimonials } from "./components/Testimonials";
import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
import { getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
const title = "Der Eine. Für die eine Sache, die gerade zählt.";
// Description reads from the live product instead of a separately
// hardcoded string, unlike this page's own `title` — matches
// /tasse-die-pause's own pattern (see that page's comment). Two
// hand-maintained copies of the same text used to drift apart: this one
// used to say something different from Products.description (which feeds
// the cart/checkout/JSON-LD elsewhere) — same field, one source now.
export async function generateMetadata(): Promise<Metadata> {
const product = await getProductBySlug("stift-kugelschreiber");
const description = product?.descriptionText || undefined;
return {
title,
description,
alternates: { canonical: "/der-eine" },
openGraph: { title, description, url: "/der-eine" },
twitter: { title, description },
};
}
export default async function DerEinePage() {
const [product, seller] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getCompanySettings(),
]);
const productSchema = product
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-eine", seller)
: null;
return (
<>
{productSchema && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
)}
<main className="flex flex-col flex-1">
<Hero />
<Focus />
<Testimonials />
<Pricing />
<PasstDazu productSlug="stift-kugelschreiber" />
</main>
<Footer />
</>
);
}
@@ -0,0 +1,52 @@
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
// No product photo exists for this page (unlike /todo-cards, /lebensuhr) —
// this is a free monthly ritual, not something with packaging to shoot.
// Text + a Caveat-font quote carries the hero instead, same treatment
// RichText.tsx's Quote component already uses for a callout line.
export function DieSiebenHero() {
return (
<section className="bg-bg-base w-full">
<Reveal className="flex flex-col gap-6 items-start px-[var(--layout-padding-x)] pt-10 pb-12 sm:pt-12 sm:pb-16 max-w-[46rem] mx-auto text-center sm:items-center">
{/* Breadcrumb — left-aligned regardless of the centered content
below, same "not part of the centered block" split as
/todo-cards's and /lebensuhr's own hero. */}
<p className="self-start flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">
Startseite
</Link>
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">Die Sieben</span>
</p>
<div className="flex flex-col gap-3 items-center">
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Die Sieben<span className="text-brand">.</span>
</p>
<p className="font-semibold text-h4 text-text-muted" style={{ fontFamily: "var(--font-lora)" }}>
Das Monatsritual von einfach produktiv.
</p>
</div>
<p className="text-body text-text-body max-w-[32rem]">
Sieben kleine Dinge, die den Monat ein bisschen reicher machen.
</p>
<p
className="text-text-primary text-[1.75rem] leading-[1.2] mt-2"
style={{ fontFamily: "var(--font-caveat)" }}
>
&bdquo;Nicht alles, was zählt, steht auf einer ToDo-Liste.&ldquo;
</p>
</Reveal>
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Reveal } from "../../components/Reveal";
export function Philosophie() {
return (
<section className="w-full bg-bg-muted">
<Reveal className="flex flex-col gap-6 items-start px-[var(--layout-padding-x)] py-14 sm:py-16 max-w-[42rem] mx-auto">
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Worum es geht
</p>
<p className="text-body text-text-body">
Am Ende eines Monats weiß ich meistens ziemlich genau, was ich erledigt habe. Rechnungen
raus, Elternabend überstanden, drei Umzugskartons endlich ausgepackt, die seit Ostern im
Flur standen. Was ich in dieser Zeit eigentlich erlebt habe, kann ich dagegen kaum noch
erzählen. Irgendwann saß ich abends auf dem Sofa und mir fiel nichts ein, worüber ich
mich in den letzten vier Wochen wirklich gefreut hatte. Nicht, weil nichts Schönes
passiert wäre. Sondern weil ich es im Vorbeigehen nicht bemerkt hatte.
</p>
<p className="text-body text-text-body">
Daraus ist Die Sieben entstanden. Jeden Monat gibt es sieben kleine Einladungen nichts
Kompliziertes, kein Kurs, keine App, die man täglich öffnen muss. Mal ist es die Idee,
jemandem eine Sprachnachricht zu schicken statt einer Textnachricht. Mal geht es darum,
einmal in der Woche ohne Handy zu frühstücken, oder einen Ort in der eigenen Stadt zu
besuchen, an dem man noch nie war. Klein genug, dass man es wirklich macht. Konkret genug,
dass es nicht bei der guten Absicht steckenbleibt.
</p>
<p className="text-body text-text-body">
Man nimmt sich, was gerade passt. Eine Einladung, drei oder alle sieben es gibt kein
Richtig und kein Falsch dabei, keine Haken, die gesetzt werden müssen. Was nicht passt,
lässt man liegen, ohne schlechtes Gewissen. Die Sieben will nicht, dass du produktiver
wirst. Sie will, dass du am Ende des Monats mehr zu erzählen hast als nur, was fertig
geworden ist.
</p>
<p
className="font-semibold text-body text-text-primary border-l-2 border-brand pl-4"
>
Es geht nicht darum, möglichst viel zu erleben, sondern die Dinge bewusster
wahrzunehmen, die ohnehin schon da sind.
</p>
</Reveal>
</section>
);
}
@@ -0,0 +1,97 @@
import { Fragment } from "react";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
import { StepArrow } from "../../components/StepArrow";
// Hand-drawn inline SVGs, brand-orange stroke — same convention as
// /lebensuhr's own icon set (IconEnvelope etc.), used here instead of a
// photo since there's no product to shoot for a free monthly ritual.
function IconHand() {
return (
<svg width="55" height="48" viewBox="0 0 60 52" fill="none">
<rect x="38" y="12" width="6" height="18" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="30" y="6" width="6" height="24" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="22" y="10" width="6" height="20" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="14" y="16" width="6" height="14" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="12" y="28" width="34" height="18" rx="9" stroke="#f6a701" strokeWidth="2" />
<rect x="2" y="30" width="14" height="8" rx="4" stroke="#f6a701" strokeWidth="2" transform="rotate(-25 9 34)" />
</svg>
);
}
function IconEye() {
return (
<svg width="52" height="34" viewBox="0 0 52 34" fill="none">
<path
d="M2 17S12 2 26 2s24 15 24 15-10 15-24 15S2 17 2 17Z"
stroke="#f6a701"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="26" cy="17" r="7" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconShare() {
return (
<svg width="44" height="48" viewBox="0 0 44 48" fill="none">
<circle cx="8" cy="24" r="6" stroke="#f6a701" strokeWidth="2" />
<circle cx="36" cy="8" r="6" stroke="#f6a701" strokeWidth="2" />
<circle cx="36" cy="40" r="6" stroke="#f6a701" strokeWidth="2" />
<path d="M13.5 21 30.5 11M13.5 27l17 10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
const punkte = [
{
icon: <IconHand />,
title: "Nimm dir, was passt",
desc: "Eine Einladung, drei oder alle sieben es gibt kein Richtig oder Falsch dabei.",
},
{
icon: <IconEye />,
title: "Nichts zum Abhaken",
desc: "Die Sieben ist kein Programm. Es reicht, die Dinge bewusst wahrzunehmen.",
},
{
icon: <IconShare />,
title: "Teilen, wenn du magst",
desc: "Manche erzählen anderen von ihren Sieben nicht um sich zu messen, sondern um sich zu erinnern.",
},
];
export function SoFunktionierts() {
return (
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-14 sm:py-16 px-[var(--layout-padding-x)]">
<Reveal className="flex flex-col gap-2 items-center text-center">
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
So läuft es ab
</p>
<p className="text-body text-text-muted">Jeden Monat neu. Ohne Verpflichtung.</p>
</Reveal>
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[10rem]">
{punkte.map((punkt, i) => (
<Fragment key={punkt.title}>
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
<div className="flex items-center justify-center h-14 transition-transform duration-300 group-hover:scale-110">
{punkt.icon}
</div>
<p className="font-semibold text-body text-text-primary">{punkt.title}</p>
<p className="text-body-sm text-text-primary text-center">{punkt.desc}</p>
</RevealItem>
{i < punkte.length - 1 && (
<div className="flex items-center justify-center shrink-0 sm:mt-[1.5rem]">
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
</div>
)}
</Fragment>
))}
</RevealGroup>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { DieSiebenHero } from "./components/DieSiebenHero";
import { Philosophie } from "./components/Philosophie";
import { SoFunktionierts } from "./components/SoFunktionierts";
import { Footer } from "../components/Footer";
import { Newsletter } from "../components/Newsletter";
const title = "Die Sieben Das Monatsritual von einfach produktiv.";
const description =
"Sieben kleine Dinge, die den Monat ein bisschen reicher machen. Kein Programm, keine Pflicht nur eine Einladung, bewusster wahrzunehmen, was ohnehin schon da ist.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/die-sieben",
},
openGraph: {
title,
description,
url: "/die-sieben",
},
twitter: {
title,
description,
},
};
// Rebuilt from the old WordPress page of the same name — a free monthly
// ritual, not a product, so unlike /todo-cards or /lebensuhr there's no
// photo asset and no pricing/testimonials section here. The WordPress
// original also linked out to a monthly-changing "aktuelle Ausgabe" card,
// a printable template, and an archive of past months — none of that has
// a home in this codebase yet (no CMS collection backs it), so this build
// is the evergreen concept page only. Newsletter signup below stands in as
// the "stay in the loop" mechanism until/unless a real "monthly edition"
// content model gets built.
export default function DieSiebenPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<DieSiebenHero />
<Philosophie />
<SoFunktionierts />
<Newsletter
title="Nicht verpassen, wenn eine neue Sieben erscheint"
description="Ich schreibe dir, sobald es die Sieben für den nächsten Monat gibt ohne Spam, jederzeit abbestellbar."
/>
</main>
<Footer />
</>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ProductBlocks } from "../components/ProductBlocks";
import { Footer } from "../components/Footer";
import { getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
const title = "Einfach anfangen. Die Karte für die Dinge, die heute anstehen.";
// Description reads from the live product, same pattern as der-eine/
// tasse-die-pause — see der-eine/page.tsx's own comment on why this and
// the hand-written Hero prose are allowed to differ.
export async function generateMetadata(): Promise<Metadata> {
const product = await getProductBySlug("todo-starter");
const description = product?.descriptionText || undefined;
return {
title,
description,
alternates: { canonical: "/einfach-anfangen" },
openGraph: { title, description, url: "/einfach-anfangen" },
twitter: { title, description },
};
}
// First PDP driven entirely by Products.layout (see that field's own
// comment in Products.ts) instead of its own bespoke Hero/HowItWorks/
// Focus/Pricing components — those were deleted once this product's
// content was authored as blocks in the admin (see the PDP page-builder
// rollout). der-eine/todo-cards/tasse-die-pause are untouched and keep
// their own hand-coded components; nothing about them depends on this.
export default async function EinfachAnfangenPage() {
const [product, seller] = await Promise.all([
getProductBySlug("todo-starter"),
getCompanySettings(),
]);
if (!product) notFound();
const productSchema = buildProductSchema(product, "https://einfach-produktiv.mk360.de/einfach-anfangen", seller);
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
<main className="flex flex-col flex-1">
<ProductBlocks product={product} />
</main>
<Footer />
</>
);
}
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { useLivePreview } from "@payloadcms/live-preview-react";
import {
renderOrderConfirmationHtml,
renderPasswordResetHtml,
renderOrderStatusHtml,
renderBackInStockHtml,
ORDER_STATUS_EMAIL_ICON,
SAMPLE_ORDER,
SAMPLE_ORDER_MANUAL,
type EmailTemplateContent,
} from "../../../lib/emailTemplates";
import type { EmailTemplateType } from "../../../lib/payload";
@@ -34,26 +37,87 @@ export function LiveEmailPreviewClient({
depth: 0,
});
// Only order-confirmation has two meaningfully different rendered
// states (isManualPayment true/false change which blocks show at all,
// not just text) — every other type has one sample and no toggle.
const [sampleVariant, setSampleVariant] = useState<"paid" | "manual">("paid");
const orderSample = sampleVariant === "paid" ? SAMPLE_ORDER : SAMPLE_ORDER_MANUAL;
// 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)
? renderOrderConfirmationHtml(data, orderSample, 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,
);
: type === "back-in-stock"
? renderBackInStockHtml(
data,
"ToDo-Karten Set",
"https://einfach-produktiv.mk360.de/todo-cards",
null,
"https://payload.mk360.de/api/media/file/product-todo-karten.png",
)
: 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 }} />
{type === "order-confirmation" && (
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginBottom: 16 }}>
<button
type="button"
onClick={() => setSampleVariant("paid")}
style={{
padding: "8px 16px",
borderRadius: 999,
border: "1px solid #d1cec4",
background: sampleVariant === "paid" ? "#f6a701" : "#fff",
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
}}
>
Online bezahlt
</button>
<button
type="button"
onClick={() => setSampleVariant("manual")}
style={{
padding: "8px 16px",
borderRadius: 999,
border: "1px solid #d1cec4",
background: sampleVariant === "manual" ? "#f6a701" : "#fff",
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
}}
>
Vorkasse (Überweisung)
</button>
</div>
)}
{/* An iframe, not dangerouslySetInnerHTML into a plain div — `html`
here is a full `<body>...</body>` fragment (see emailTemplates.ts's
emailShell()), meant to become an actual email document. Dropped
directly into this page's own already-existing <body> via
dangerouslySetInnerHTML, that's a nested <body> tag — invalid
HTML the browser "fixes" unpredictably, which is why this preview
used to render broken (wrong background/padding/font, inline
styles not applying). An iframe gives the email HTML its own
real document, exactly like an actual email client would. */}
<iframe
srcDoc={`<!DOCTYPE html><html>${html}</html>`}
title="E-Mail-Vorschau"
style={{ width: "100%", height: "100vh", border: "none", display: "block" }}
/>
</div>
);
}
+15
View File
@@ -16,6 +16,11 @@ const VALID_TYPES: EmailTemplateType[] = [
"order-cancelled",
"order-return-requested",
"order-returned",
"order-tracking-added",
"order-tracking-corrected",
"order-delivered",
"payment-method-switched",
"back-in-stock",
];
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
@@ -23,6 +28,11 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
"order-cancelled": "Deine Bestellung wurde storniert",
"order-return-requested": "Deine Rücksendung wurde angefragt",
"order-returned": "Deine Retoure wurde bearbeitet",
"order-tracking-added": "Hier ist deine Sendungsnummer",
"order-tracking-corrected": "Korrigierte Sendungsnummer",
"order-delivered": "Dein Paket ist angekommen",
"payment-method-switched": "Erledigt!",
"back-in-stock": "Wieder da!",
};
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
@@ -31,6 +41,11 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
// 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.
//
// "back-in-stock" has its own renderer (renderBackInStockHtml) rather than
// sharing the generic order-status one below — it has no order at all, so
// no order-number line, and its CTA points at the product page ("Zum
// Produkt"), not /konto/bestellungen.
export default async function EmailPreviewPage({ params }: { params: Promise<{ type: string }> }) {
const { type } = await params;
if (!VALID_TYPES.includes(type as EmailTemplateType)) notFound();
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+75 -42
View File
@@ -2,10 +2,10 @@
/*
* Fluid design tokens — every value below scales continuously via clamp()
* between the Tablet breakpoint (768px) and Desktop (1440px). Below 768px
* the clamp() floor freezes the value automatically (no separate mobile
* media query needed for sizing — only for structural layout changes,
* see the `md:` breakpoint usage in components).
* between the structural breakpoint (640px) and Desktop (1440px). Below
* 640px the clamp() floor freezes the value automatically (no separate
* mobile media query needed for sizing — only for structural layout
* changes, see the `sm:` breakpoint usage in components).
*
* Spacing overrides Tailwind's own numeric scale (--spacing-4 etc.) at the
* exact numbers the styleguide already used (n times 0.25rem equals the
@@ -53,10 +53,10 @@
--shadow-modal: 0 8px 40px rgba(26, 26, 24, 0.16);
--shadow-sidebar: 0 2px 8px rgba(26, 26, 24, 0.08);
/* Type scale (fluid 768->1440) */
--text-display: clamp(2.75rem, 1.3214rem + 2.9762vw, 4rem);
/* Type scale (fluid 640->1440) */
--text-display: clamp(2.75rem, 1.75rem + 2.5vw, 4rem);
--text-display--line-height: 1.1;
--text-h-feature: clamp(1.875rem, 1.1607rem + 1.4881vw, 2.5rem);
--text-h-feature: clamp(1.875rem, 1.375rem + 1.25vw, 2.5rem);
--text-h-feature--line-height: 1.2;
/* New role, added for page-todo-karten's hero heading (48px Desktop) —
doesn't match any existing scale step (h-feature=40, h1=36), Figma's
@@ -67,21 +67,21 @@
etc.)" per styleguide.md §2.2 — a different, smaller role). Also used
by page-weekly-impulses's hero heading — same 48px role, reused
rather than minting a near-duplicate token. */
--text-h-page: clamp(2.5rem, 1.9286rem + 1.1905vw, 3rem);
--text-h-page: clamp(2.5rem, 2.1rem + 1vw, 3rem);
--text-h-page--line-height: 1.15;
--text-h1: clamp(1.75rem, 1.1786rem + 1.1905vw, 2.25rem);
--text-h1: clamp(1.75rem, 1.35rem + 1vw, 2.25rem);
--text-h1--line-height: 1.15;
--text-h2: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
--text-h2: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
--text-h2--line-height: 1.25;
--text-h-section: clamp(1.4375rem, 1.0804rem + 0.744vw, 1.75rem);
--text-h-section: clamp(1.4375rem, 1.1875rem + 0.625vw, 1.75rem);
--text-h-section--line-height: 1.3;
--text-h-emphasis: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
--text-h-emphasis: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
--text-h-emphasis--line-height: 1.4;
--text-h3: clamp(1.1875rem, 0.9732rem + 0.4464vw, 1.375rem);
--text-h3: clamp(1.1875rem, 1.0375rem + 0.375vw, 1.375rem);
--text-h3--line-height: 1.35;
--text-h-small: clamp(1.125rem, 0.9821rem + 0.2976vw, 1.25rem);
--text-h-small: clamp(1.125rem, 1.025rem + 0.25vw, 1.25rem);
--text-h-small--line-height: 1.4;
--text-h4: clamp(1rem, 0.8571rem + 0.2976vw, 1.125rem);
--text-h4: clamp(1rem, 0.9rem + 0.25vw, 1.125rem);
--text-h4--line-height: 1.4;
--text-body: 1rem;
--text-body--line-height: 1.6;
@@ -90,21 +90,21 @@
--text-label: 0.75rem;
--text-label--line-height: 1.4;
/* Spacing scale (fluid 768->1440) — overrides Tailwind's default n*0.25rem
/* Spacing scale (fluid 640->1440) — overrides Tailwind's default n*0.25rem
at these exact steps, so p-4/gap-6/px-20/etc. become fluid automatically */
--spacing-1: clamp(0.25rem, 0.25rem + 0vw, 0.25rem);
--spacing-2: clamp(0.5rem, 0.5rem + 0vw, 0.5rem);
--spacing-3: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
--spacing-4: clamp(0.875rem, 0.7321rem + 0.2976vw, 1rem);
--spacing-5: clamp(1rem, 0.7143rem + 0.5952vw, 1.25rem);
--spacing-6: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
--spacing-8: clamp(1.5rem, 0.9286rem + 1.1905vw, 2rem);
--spacing-10: clamp(1.75rem, 0.8929rem + 1.7857vw, 2.5rem);
--spacing-12: clamp(2rem, 0.8571rem + 2.381vw, 3rem);
--spacing-16: clamp(2.5rem, 0.7857rem + 3.5714vw, 4rem);
--spacing-20: clamp(3rem, 0.7143rem + 4.7619vw, 5rem);
--spacing-24: clamp(3.5rem, 0.6429rem + 5.9524vw, 6rem);
--spacing-30: clamp(4rem, 0rem + 8.3333vw, 7.5rem);
--spacing-3: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
--spacing-4: clamp(0.875rem, 0.775rem + 0.25vw, 1rem);
--spacing-5: clamp(1rem, 0.8rem + 0.5vw, 1.25rem);
--spacing-6: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
--spacing-8: clamp(1.5rem, 1.1rem + 1vw, 2rem);
--spacing-10: clamp(1.75rem, 1.15rem + 1.5vw, 2.5rem);
--spacing-12: clamp(2rem, 1.2rem + 2vw, 3rem);
--spacing-16: clamp(2.5rem, 1.3rem + 3vw, 4rem);
--spacing-20: clamp(3rem, 1.4rem + 4vw, 5rem);
--spacing-24: clamp(3.5rem, 1.5rem + 5vw, 6rem);
--spacing-30: clamp(4rem, 1.2rem + 7vw, 7.5rem);
/* Transitions */
--transition-fast: 150ms ease;
@@ -116,18 +116,22 @@
/* Layout — not part of Tailwind's numeric spacing scale, referenced via
arbitrary-value syntax (e.g. px-[var(--layout-padding-x)]) */
--layout-max-width: 75rem; /* 1200px */
--layout-padding-x: clamp(2rem, -1.4286rem + 7.1429vw, 5rem);
--layout-grid-gap: clamp(1rem, 0.4286rem + 1.1905vw, 1.5rem);
--layout-grid-gap-lg: clamp(1.25rem, 0.3929rem + 1.7857vw, 2rem);
--layout-padding-x: clamp(2rem, -0.4rem + 6vw, 5rem);
--layout-grid-gap: clamp(1rem, 0.6rem + 1vw, 1.5rem);
--layout-grid-gap-lg: clamp(1.25rem, 0.65rem + 1.5vw, 2rem);
/* One-off fluid values that aren't part of a broader scale */
--hero-avatar-size: clamp(2.25rem, 1.6071rem + 1.3393vw, 2.8125rem);
--hero-avatar-size: clamp(2.25rem, 1.8rem + 1.125vw, 2.8125rem);
/* Newsletter copy column — was a hard 38.25rem, which didn't leave enough
room for the form column at the 768px tablet floor (only 656px of
content width available for 612px column plus 56px gap), crushing the
input/button/privacy text to near-zero width and overflowing the page.
Scales down to 23rem at 768px instead. */
--newsletter-copy-width: clamp(23rem, 5.5714rem + 36.3095vw, 38.25rem);
room for the form column at the 640px structural floor (only ~576px of
content width available for a 612px column plus gap at that width),
crushing the input/button/privacy text to near-zero width and
overflowing the page. Scales down to 23rem at 640px instead. Note: the
inner input+button row still switches to a row layout at `lg:` (1024px),
not at the site's `sm:` (640px) structural line — even at this reduced
floor there isn't enough room for input+button side by side below
~900px, see Newsletter.tsx's own comment. */
--newsletter-copy-width: clamp(23rem, 10.8rem + 30.5vw, 38.25rem);
/* Divider section icons (arrow separators, sparkle) — previously hard
rem values while the "Klarheit/Fokus/Entlastung" text next to them
@@ -135,12 +139,41 @@
while the words scaled. Same fluid(minPx, maxPx) formula as the rest
of this file, floor ~82.5% of the Desktop value per the styleguide's
80-85% Mobile-ratio guidance. */
--divider-arrow-w: clamp(2.125rem, 1.5536rem + 1.1905vw, 2.625rem);
--divider-arrow-h: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
--divider-sparkle-w: clamp(1.5625rem, 1.1554rem + 0.8482vw, 1.91875rem);
--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);
--divider-arrow-w: clamp(2.125rem, 1.725rem + 1vw, 2.625rem);
--divider-arrow-h: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
--divider-sparkle-w: clamp(1.5625rem, 1.2775rem + 0.7125vw, 1.9188rem);
--divider-sparkle-h: clamp(2.0625rem, 1.7075rem + 0.8875vw, 2.5063rem);
--divider-sparkle-inner-w: clamp(1.4375rem, 1.2003rem + 0.593vw, 1.734rem);
--divider-sparkle-inner-h: clamp(1.9375rem, 1.5875rem + 0.875vw, 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.325rem + 0.75vw, 2rem);
}
/* This project's fluid() scale (see fluid.ts) is calibrated for the
640-1440px structural-to-Desktop range and floors out at the 640px 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 640px
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 {
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect x="0" y="0" width="64" height="64" rx="14" fill="#f6a701" />
<text
x="32"
y="46"
text-anchor="middle"
font-family="Georgia, 'Playfair Display', 'Times New Roman', serif"
font-weight="700"
font-style="italic"
font-size="42"
fill="#1a1a1a"
>e</text>
</svg>

After

Width:  |  Height:  |  Size: 383 B

+43 -23
View File
@@ -35,7 +35,8 @@ import type { TOCSection } from "../../components/SectionTOC";
// 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"];
const sections = ["Angaben zum Anbieter"];
if (seller.vatId) sections.push("Umsatzsteuer");
if (seller.registerCourt && seller.registerNumber) sections.push("Handelsregister");
if (seller.managingDirector) sections.push("Geschäftsführung");
sections.push("Verantwortlich für den Inhalt");
@@ -63,27 +64,44 @@ export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
return (
<div className="flex flex-col gap-4 w-full">
<Heading>Angaben zum Anbieter</Heading>
<P>{seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
<P>E-Mail: {seller.sellerEmail}</P>
{/* gap-1, not the outer container's own gap-4 — these 5 lines are one
continuous address block, not 5 separate paragraphs; the large
inter-section gap only belongs between a heading's own block and
the next, not between lines that visually belong together
(fixed 2026-07-24, same fix applied to every block below). */}
<div className="flex flex-col gap-1">
<P>{seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
<P>
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
</P>
</div>
<Heading>Umsatzsteuer</Heading>
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
<P>{seller.vatId}</P>
{seller.vatId && (
<>
<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>
<P>{seller.registerCourt}</P>
<P>{seller.registerNumber}</P>
{/* Optional/voluntary, not a Pflichtangabe — see
CompanySettings.ts's own comment on shareCapital. Only shows
if an admin deliberately filled it in. */}
{seller.shareCapital ? <P>Stammkapital: {seller.shareCapital.toLocaleString("de-DE")} </P> : null}
<div className="flex flex-col gap-1">
<P>{seller.registerCourt}</P>
<P>{seller.registerNumber}</P>
{/* Optional/voluntary, not a Pflichtangabe — see
CompanySettings.ts's own comment on shareCapital. Only shows
if an admin deliberately filled it in. */}
{seller.shareCapital ? <P>Stammkapital: {seller.shareCapital.toLocaleString("de-DE")} </P> : null}
</div>
</>
)}
@@ -100,12 +118,14 @@ export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
proprietorship/e.K., already a natural person's own name). No
OHG/KG general-partner fallback here — see this file's top
comment on why that field doesn't exist yet. */}
<P>{seller.managingDirector || seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
<div className="flex flex-col gap-1">
<P>{seller.managingDirector || seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
</div>
</div>
);
}
+8 -1
View File
@@ -6,7 +6,7 @@ import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC } from "../components/SectionTOC";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage, getCompanySettings } from "../lib/payload";
import { AnbieterAngaben, anbieterAngabenHeadings } from "./components/AnbieterAngaben";
@@ -42,6 +42,13 @@ export default async function ImpressumPage() {
<p className="text-body text-text-muted">Angaben gemäß § 5 DDG</p>
</Reveal>
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
comment. Outside the sidebar's `hidden lg:flex` wrapper below
(that wrapper's `hidden` would hide this too otherwise). */}
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
<MobileSectionTOC sections={headings} />
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
<SectionTOC sections={headings} />
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { PaymentStep } from "../../../../checkout/components/PaymentStep";
// Shown only for a still-unpaid Überweisung order (see page.tsx's own
// eligibility check, mirroring api/account/orders/[orderNumber]/
// switch-to-stripe/route.ts's authoritative one) — lets a customer switch
// to Kreditkarte/PayPal instead of waiting on their own bank transfer.
// Reuses PaymentStep (the exact same Stripe collection UI checkout uses)
// once this endpoint hands back a clientSecret — the order already
// exists, this only changes how it gets paid.
export function SwitchPaymentButton({ orderNumber }: { orderNumber: string }) {
const [state, setState] = useState<
| { step: "idle" }
| { step: "loading" }
| { step: "error"; reason: string }
| { step: "paying"; clientSecret: string; orderId: number; testMode: boolean; providerReference?: string }
>({ step: "idle" });
async function start() {
setState({ step: "loading" });
try {
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}/switch-to-stripe`, {
method: "POST",
});
const data = await res.json();
if (!data.ok) {
setState({ step: "error", reason: data.reason || "Umstellung fehlgeschlagen." });
return;
}
setState({
step: "paying",
clientSecret: data.clientSecret,
orderId: data.orderId,
testMode: Boolean(data.testMode),
providerReference: data.providerReference,
});
} catch {
setState({ step: "error", reason: "Umstellung gerade nicht möglich." });
}
}
if (state.step === "paying") {
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">Mit Kreditkarte/PayPal bezahlen</p>
<PaymentStep
clientSecret={state.clientSecret}
orderNumber={orderNumber}
orderId={state.orderId}
testMode={state.testMode}
providerReference={state.providerReference}
returnContext="account"
/>
</div>
);
}
return (
<div className="flex flex-col gap-2 items-start">
<button
type="button"
onClick={start}
disabled={state.step === "loading"}
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${state.step === "loading" ? "opacity-70 pointer-events-none" : ""}`}
>
{state.step === "loading" ? "…" : "Zahlungsart ändern"}
</button>
{state.step === "error" && <p className="text-label text-red-600">{state.reason}</p>}
</div>
);
}
+77 -16
View File
@@ -7,23 +7,35 @@ 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 { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout, getMediaUrlById } from "../../../lib/payload";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
import { buildTrackingUrl, CARRIER_LABELS, type Carrier } from "@einfach-produktiv/invoicing";
import { OrderActionButton } from "./components/OrderActionButton";
import { SwitchPaymentButton } from "./components/SwitchPaymentButton";
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
import { PaymentStatusBadge } from "../../components/PaymentStatusBadge";
export const metadata: Metadata = {
title: "Bestelldetails",
robots: { index: false, follow: true },
};
// Dynamic (was a static "Bestelldetails" title despite this being a
// per-order route) — just formats the already-known order number into
// the title, no extra fetch needed for a noindex account page.
export async function generateMetadata({
params,
}: {
params: Promise<{ orderNumber: string }>;
}): Promise<Metadata> {
const { orderNumber } = await params;
return {
title: `Bestellung ${decodeURIComponent(orderNumber)}`,
robots: { index: false, follow: true },
};
}
export default async function KontoBestellungDetailPage({ params }: { params: Promise<{ orderNumber: string }> }) {
const { orderNumber } = await params;
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber), true);
if (!order) notFound();
const address =
@@ -36,7 +48,16 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
: order.shippingStreet;
const action = customerOrderAction(order.status);
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const dhlReturnLabel = order.dhlReturnLabelMedia ? await getMediaUrlById(order.dhlReturnLabelMedia) : null;
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
// Same "Online-Zahlung" grouping/eligibility the switch-to-stripe route
// itself re-checks authoritatively — only offer the button when it
// would actually succeed.
const canSwitchPayment =
order.paymentProvider === "manual" &&
order.status === "received" &&
(order.paymentStatus === "pending" || order.paymentStatus === "not_applicable") &&
groupPaymentMethodsForCheckout(await getPaymentMethods()).some((m) => m.provider === "stripe");
return (
<>
@@ -59,6 +80,10 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<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">Zahlungsstatus</p>
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
</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>
@@ -67,7 +92,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{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>
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier as Carrier] ?? order.carrier})` : ""}</p>
{(() => {
const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
return trackingUrl ? (
@@ -81,12 +106,24 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</div>
)}
{dhlReturnLabel && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">DHL-Retourenschein{order.dhlReturnTrackingNumber ? ` (${order.dhlReturnTrackingNumber})` : ""}</p>
<a href={dhlReturnLabel.url} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
Retourenschein herunterladen
</a>
</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>
@@ -94,11 +131,20 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<p className="text-body-sm text-text-primary">
{order.zip} {order.city}, {order.country}
</p>
{order.vatId && (
<p className="text-body-sm text-text-muted">
USt-IdNr. {order.vatId}
{order.kleinunternehmer
? " · Kleinunternehmer gem. § 19 UStG"
: order.vatExempt && " · steuerfreie innergemeinschaftliche Lieferung"}
</p>
)}
</div>
{order.hasDifferentShippingAddress && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Lieferadresse</p>
{order.shippingCompanyName && <p className="text-body-sm text-text-primary">{order.shippingCompanyName}</p>}
<p className="text-body-sm text-text-primary">
{order.shippingFirstName} {order.shippingLastName}
</p>
@@ -106,6 +152,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<p className="text-body-sm text-text-primary">
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
</p>
{(order.shippingContactEmail || order.shippingContactPhone) && (
<p className="text-body-sm text-text-muted">
{[order.shippingContactEmail, order.shippingContactPhone].filter(Boolean).join(" · ")}
</p>
)}
</div>
)}
@@ -122,8 +173,9 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{item.quantity} × {item.productName}
{item.variantName ? ` (${item.variantName})` : ""}
</p>
<p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>
{!order.kleinunternehmer && <p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>}
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
{item.sku && <p className="text-label text-text-muted">Art.-Nr. {item.sku}</p>}
{item.returnQuantity > 0 && (
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
)}
@@ -165,7 +217,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
</div>
<VatBreakdown groups={taxBreakdown} />
{order.kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : (
<VatBreakdown groups={taxBreakdown} />
)}
</div>
</div>
@@ -195,12 +251,17 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
)}
</div>
{action && (
<OrderActionButton
orderNumber={order.orderNumber}
action={action}
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
/>
{(action || canSwitchPayment) && (
<div className="flex flex-wrap gap-3 items-start w-full">
{action && (
<OrderActionButton
orderNumber={order.orderNumber}
action={action}
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
/>
)}
{canSwitchPayment && <SwitchPaymentButton orderNumber={order.orderNumber} />}
</div>
)}
</Reveal>
</main>
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { CustomSelect } from "../../../components/CustomSelect";
// Three custom-styled dropdowns in a row instead of a wall of filter
// chips (tried first, reverted 2026-07-30 — with 7 status options + 5
// payment-status options + N years, a chip per option read as cluttered
// and ate a lot of vertical space) or plain native <select>s (tried next,
// also reverted the same day — a native <select>'s open options popup is
// rendered by the browser/OS and can't be styled at all, so it looked
// completely off-brand next to everything else on the page; see
// CustomSelect.tsx for the fully custom-styled replacement). Client
// Component only for the onChange→navigate wiring; the actual filtering
// still happens server-side in page.tsx via the same URL search params,
// so this stays a plain GET-style filter (shareable/bookmarkable/
// back-button-safe), not client-side state.
export function OrderFilters({
statusOptions,
paymentStatusOptions,
years,
}: {
statusOptions: { value: string; label: string }[];
paymentStatusOptions: { value: string; label: string }[];
years: string[];
}) {
const router = useRouter();
const searchParams = useSearchParams();
const status = searchParams.get("status") ?? "";
const paymentStatus = searchParams.get("paymentStatus") ?? "";
const year = searchParams.get("year") ?? "";
const hasAnyFilter = Boolean(status || paymentStatus || year);
const activeCount = [status, paymentStatus, year].filter(Boolean).length;
// Collapsed by default on mobile — with the account tab bar now also
// stacked above this (see KontoShell/AccountNav), 3 full-width dropdowns
// always visible left little room for the actual order list. Desktop
// (sm+) ignores this entirely and always shows the row inline, same as
// before. Starts expanded whenever a filter is already active (arriving
// via a shared/bookmarked filtered URL shouldn't hide what's applied) —
// a lazy initializer since it only needs to run once, on mount.
const [expanded, setExpanded] = useState(() => hasAnyFilter);
function setParam(key: string, value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set(key, value);
else params.delete(key);
const qs = params.toString();
router.push(qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen");
}
const yearOptions = years.map((y) => ({ value: y, label: y }));
return (
<div className="w-full">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="sm:hidden flex items-center justify-between w-full px-4 py-3 border border-border rounded-sm text-body-sm font-bold text-text-primary"
>
<span className="flex items-center gap-2">
Filter
{activeCount > 0 && (
<span className="flex items-center justify-center min-w-[1.1rem] h-[1.1rem] px-1 rounded-full bg-brand text-[0.6875rem] font-bold leading-none text-text-primary">
{activeCount}
</span>
)}
</span>
<span aria-hidden>{expanded ? "▴" : "▾"}</span>
</button>
<div
className={`${expanded ? "flex" : "hidden"} sm:flex flex-col sm:flex-row sm:items-center gap-3 w-full mt-3 sm:mt-0`}
>
<CustomSelect label="Alle Status" options={statusOptions} value={status} onChange={(v) => setParam("status", v)} />
<CustomSelect label="Alle Zahlungsstatus" options={paymentStatusOptions} value={paymentStatus} onChange={(v) => setParam("paymentStatus", v)} />
<CustomSelect label="Alle Jahre" options={yearOptions} value={year} onChange={(v) => setParam("year", v)} />
{hasAnyFilter && (
<button
type="button"
onClick={() => router.push("/konto/bestellungen")}
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors self-start sm:self-auto"
>
Zurücksetzen
</button>
)}
</div>
</div>
);
}

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