168 Commits

Author SHA1 Message Date
Marco ebbe15ef73 Revert PDP pricing-panel width cap 2026-08-30 00:44:14 +00:00
Marco 1e3ab794f3 PDP: cap pricing-panel CTA card width to match the text column (was edge-to-edge) 2026-08-30 00:42:45 +00:00
Marco d77d40a1bf PDP: moderate the hero->heading and icons->pricing gaps (compact, but wider than the heading-icons seam) 2026-08-30 00:40:37 +00:00
Marco 601c2f5694 PDP: tighten gap between 'Was kann er?' heading and its icon row (they read as two unrelated sections) 2026-08-30 00:37:15 +00:00
Marco 917795d810 PDP: restore breathing room after Hero + before pricing panel (both had shrunk to nothing/py-8) 2026-08-30 00:35:42 +00:00
Marco 807fda3d2a PDP: widen stepRow fact-cards back to 75rem (48rem read too squeezed) 2026-08-30 00:26:10 +00:00
Marco 2b3868b605 PDP hero: plain checkmarks for usps; spotlight image now links to PDP
- product.usps rendered via STEP_ICONS[usp.icon] (checkCircle — a
  circled glyph), inconsistent with the plain checkmark every other
  feature-bullet list on the site uses (der-eine's own original
  Hero.tsx, RichText/PageBlocks' own checklists). Reused PageBlocks'
  CheckIcon (now exported) instead — usp.icon stays stored, just
  unused for this particular list (reported 2026-08-30).
- ProductSpotlight's image had no link at all — every other product
  card (ProductGrid, RelatedProducts, MerklisteGrid, via ProductCard)
  already makes its image clickable. Same Link-wraps-only-the-image
  pattern as ProductCard.tsx (WishlistButton stays an unnested sibling).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-30 00:20:14 +00:00
Marco cefe2531d4 Hide RichText heading underline on PDPs; more breathing room after Hero
- der-eine's original "Was kann er?" heading was plain text, no
  decoration — RichText.tsx's shared heading converter always appends a
  small brand-underline span (wanted on blog/legal pages, not here).
  Hidden via a scoped CSS selector, same approach as the earlier icon-
  color overrides, not touching the shared component.
- Generic block spacing bumped from py-8 sm:py-12 to py-12 md:py-16,
  matching der-eine's own original Focus.tsx rhythm — felt too tight
  right after the Hero (reported 2026-08-30).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-30 00:18:07 +00:00
Marco a4606d26ad PDP richTextSection width cap; swap seal→vase, hourglass→star icons
- richTextSection on Products now renders inside the same max-w-[48rem]
  mx-auto the fact-cards/crossSell card already use — the "Was kann er?"
  heading sat edge-to-edge while the cards under it were capped, still
  reading as unconstrained (reported 2026-08-30).
- "Vintage-Porzellan"'s medallion and "Für den einen Moment"'s hourglass
  still didn't land (reported 2026-08-30) — replaced with a literal
  pottery/vase silhouette and a single star, reusing the same 'seal'/
  'hourglass' enum keys already assigned to Tasse's blocks so no new
  migration is needed, just new icon content behind the same keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-30 00:15:17 +00:00
Marco 3e5c7b1a12 PDP hero CTA: plain "In den Warenkorb", not "\${product.name} bestellen"
Matches the closing ProductPricingPanel's own label now — both buttons
say the same generic thing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-30 00:12:32 +00:00
Marco 9de2693961 Narrow PDP fact-card section to match blog body width
der-eine's original Focus.tsx used max-w-[75rem] — read too wide/loose
for a 3-4 card row once ported to the block system (reported
2026-08-30). Narrowed to max-w-[48rem], matching the blog body column
(RichText.tsx) and the crossSell "Passt dazu" card's own width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-30 00:12:02 +00:00
Marco 14d824491f Wire up real Live Preview for block-driven PDPs
Products.ts's admin.livePreview (added earlier this session) only made
the admin UI show a preview iframe — the actual page had no mechanism
to receive postMessage updates and re-render with unsaved draft data,
unlike Pages/Posts which already have this (LivePageContent.tsx/
LivePostContent.tsx). Edits in the backend were invisible in the
preview until an actual save (reported 2026-08-30).

New LiveProductContent.tsx mirrors LivePageContent.tsx's pattern
exactly: useLivePreview<PayloadProduct> + mapPayloadProduct, rendering
via ProductBlocks.tsx's now-exported renderProductBlock (sync) instead
of the full async ProductBlocks wrapper. testimonialsRef/crossSell
skipped in the live subset (need an async fetch a client component
can't perform inline) — same "editable subset only" scope Pages/Posts
already accept. shipping/tax/Kleinunternehmer context is fetched once
via the newly-exported getProductBlockContext() and passed in rather
than re-fetched per keystroke.

/der-eine and /tasse-die-pause now check draftMode() and swap in
LiveProductContent instead of the normal async ProductBlocks when
Payload's Live Preview iframe has it enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-30 00:09:07 +00:00
Marco 9c6b717920 Fix icon sizing regression; recolor filled dots; add seal/droplets/hourglass
- pen/sparkle/compact icons were carried over from der-eine's original
  32x32-viewBox SVGs but rendered at their native 32px instead of the
  64px der-eine's own IconBadge wrapper actually used — half the
  intended size on /der-eine's fact cards (reported 2026-08-30).
- The PDP black-icon override only recolored `stroke`; target's/
  ProductSpotlight-unrelated icons using a hardcoded `fill="#f6a701"`
  center dot (e.g. IconTarget) kept a stray yellow dot inside an
  otherwise-black icon on /einfach-anfangen. Added a second override
  targeting exactly that hardcoded fill value.
- 3 new icons (seal/droplets/hourglass) for Tasse "Die Pause."'s fact
  cards — cup/shield/checkCircle/pause didn't fit "Vintage-Porzellan"/
  "Spülmaschinenfest"/"Für den einen Moment" at all.
- ProductSpotlight (homepage) now uses the shared ProductBadge too —
  missed in the earlier badge-consolidation pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-30 00:06:16 +00:00
Marco 7babd0401f Cart: use the shared ProductBadge (was missing "Neu")
CartContent.tsx had its own fourth independent discount-badge
implementation, never updated for Products.badge — "Neu" showed on the
PDP and shop grid but not here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 23:59:11 +00:00
Marco 31b8e89ea4 Simplify crossSell to a single hand-picked product; badge everywhere; gallery cursor
- CrossSellBlock: dropped mode (automatic/manual) + hasMany products
  down to one required `product` relationship, same filterOptions-
  excludes-self dropdown as Products.relatedProduct — the "automatic,
  same category" mode added complexity a plain dropdown already covered
  better, and the frontend's card was always designed around one
  recommendation, not a grid (reported 2026-08-29).
- New shared ProductBadge component (app/components/ProductBadge.tsx),
  used by both ProductBlocks.tsx (PDP) and ProductCard.tsx (shop grid,
  cart's RelatedProducts, wishlist grid) — Products.badge ("Neu") only
  showed on the PDP before, now consistent everywhere a product's image
  renders a badge.
- ProductGallery's arrow/thumbnail buttons get an explicit cursor-pointer
  class.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 23:53:41 +00:00
Marco 48e73796b1 crossSell: match der-eine's/blog's own "Passt dazu" card style
ProductCard (price, wishlist heart, add-to-cart button) read as a
commerce grid, too busy for a single lightweight recommendation slot —
der-eine's own hand-coded PasstDazu.tsx (and the blog's "Passend dazu"
card) already had the right, more editorial look: bordered card,
thumbnail + name + description + "Entdecken" arrow, no price/buy
button. Reused that exact markup here instead, stacked rather than a
grid. getWishlistEnabled/ProductCard now unused in this file, removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 23:42:35 +00:00
Marco c98294a6a4 Migrate /der-eine onto the PDP block system
der-eine was the reference the whole PDP block-builder was designed to
match (gallery sizing, black stepRow-facts icons, dynamic CTA label —
see ProductBlocks.tsx). Migrated it onto its own system: hero body,
usps (the 3 hero features), a "Was kann er?" fact-card section (its own
4 icons — pen/sparkle/compact, new; sunHigh, reused — carried over into
the shared StepIcons set), pricing panel, automatic crossSell.
Testimonials.tsx dropped, not migrated (testimonialsRef removed from
the PDP builder earlier this session). Old Hero/Focus/Testimonials/
Pricing/PasstDazu components left as dead code, same as
/tasse-die-pause's own migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 23:40:21 +00:00
Marco f98536ff88 Remove ToDo-Karten product and its dedicated /todo-cards page
Product deleted from Payload (no orders referenced it), so its bespoke
page went with it — Navbar's WERKZEUGE_ROUTES and the sitemap no longer
list it. The blog's "3minuten" relatedProduct pointer and the homepage's
"ToDo-Karten" Werkzeuge card were cleared/removed in Payload directly
(no code change needed there — CMS content).

Also fixes a real bug found in the process: ProductBlocks.tsx's
AddToCartButton calls never passed productId, silently falling back to
its "todo-karten" default — every block-driven PDP's buy button (e.g.
"Die Pause.") was adding the wrong product to the cart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 23:29:15 +00:00
Marco 207f33724b ProductHero: match der-eine's gallery sizing, CTA label, icon color
der-eine is the reference the whole PDP block system is meant to look
like — three concrete deviations fixed:
- Gallery was full-bleed across the whole lg:col-span-7 column; der-eine
  caps it at max-w-[28rem], centered, vertically centered at lg: — same
  treatment now, same reasoning (der-eine's own Hero.tsx comment on why).
- Hero CTA was a generic "In den Warenkorb" instead of "${product.name}
  bestellen" like der-eine's/todo-cards' own buttons (the closing
  ProductPricingPanel button already said the generic label correctly —
  matches der-eine's own two-CTA pattern, hero named, pricing-panel
  generic).
- stepRow icons render brand-yellow (STEP_ICONS, shared with Pages.layout
  content pages that want that) instead of der-eine's black
  (Focus.tsx's #231f20). Recolored via a CSS descendant-selector override
  scoped to ProductBlocks.tsx's own stepRow case, not touching the shared
  icon set or PageBlocks.tsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 23:12:42 +00:00
Marco 3e0e6c220d Add cup/pause step icons
Companion to the payload-repo commit adding these to StepRowBlock's
enum — none of the existing 10 fit a physical drink-related product.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 23:08:49 +00:00
Marco 2686fd3106 Migrate /tasse-die-pause onto the PDP block system
First of the 3 legacy hand-coded PDPs migrated, now that Products.layout/
ProductBlocks.tsx exist. "Die Pause." got real final copy this session
(product doc's layout populated directly in Payload: hero body, a "So
einfach ist eine Pause" 3-step section, product facts as a pill list, a
Björn-quote contrast line, closing copy, pricing panel) — good first
candidate since its own page was still just a provisional scaffold.
Hero.tsx/Pricing.tsx in ./components are now dead code, left for a
follow-up cleanup pass rather than deleted here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 23:02:33 +00:00
Marco 52d0b8558e NewsletterModal: link to /newsletter for the full pitch
Quick-access signup stays in the modal; a small "mehr erfahren" link
under the description sends anyone not ready to type an email straight
to /newsletter (testimonials, how-it-works) instead of only being
reachable via the Navbar — the same gap the blog's newsletter-fallback
card closed for readers, closed here for the modal's own audience.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 22:59:37 +00:00
Marco 74bec632ca Frontend: render storySplit/faq/crossSell, gallery+badge+usps, qty stepper
Wires up the new PDP page-builder gaps (Products.layout backend already
built, migrated in a companion payload repo commit):

- storySplit/faq render via PageBlocks.tsx (shared with Pages.layout);
  crossSell is Products-only, resolved server-side in ProductBlocks.tsx
  (manual: getProductsByIds; automatic: active products sharing a
  category, falling back to any other active product).
- ProductGallery gets an optional `badge` overlay slot; ProductHero now
  uses it (previously a single plain <Image>, no badge at all) instead
  of duplicating a second image element.
- New shared ProductBadge helper (discount % / Ausverkauft / Neu) used
  by both ProductHero and ProductPricingPanel — only the pricing panel
  showed this before.
- product.usps (max-3 icon+text) renders in the hero, reusing
  StepRowBlock's icon set via STEP_ICONS.
- AddToCartButton gets an opt-in showQuantityStepper prop (default off,
  no behavior change for existing callers); enabled on both PDP
  buy buttons.
- quote gets a PDP-specific full-bleed brand-background treatment,
  scoped to ProductBlocks.tsx's own switch case so Pages.layout's
  existing quote styling (e.g. /ueber-mich) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 22:58:42 +00:00
Marco 43bffaa5c1 RichText: honor a link's newTab field
The link/autolink converters never read node.fields.newTab, so setting
"open in new tab" on a link in the Payload editor had no effect.
Needed now that the Die Sieben post's template-download link opens a
PNG in-place instead of navigating away from the article.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 22:35:32 +00:00
Marco 6f6a6119f5 Restore original wide photo for homepage About section; fix Weiterlesen
About section goes back to the original wide banner image (its own
new file, about-banner.jpg) — the new bjoern.png headshot is a square
close-up that never suited this wide/short strip. Blog posts keep the
new photo via about-author.jpg, unaffected.

Also fixes /blog/[slug]'s "Weiterlesen" card: it picked the first of
the 4 most-recent posts that wasn't the current one, so nearly every
post converged on the same one or two most-recent articles. Now picks
the chronologically next-older post (wrapping to the newest from the
oldest), so every post points somewhere different.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 22:17:29 +00:00
Marco 55d613d409 About section: switch author photo to object-contain
object-cover on the square about-author.jpg inside this wide/short
banner was scaling the image up to fill the width and cropping most
of the height away, blowing the face up to fill the entire strip.
Verified the exact crop with a sharp simulation before switching.
object-contain shows the whole photo at its real proportions instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 22:10:41 +00:00
Marco 193b85a3ca About section: shift author photo crop upward
about-author.jpg became a square close-up portrait; centered object-cover
in this wide, short banner was cropping straight into an extreme
eyes-to-chin close-up. Biasing the crop toward the top keeps more
forehead/hair in frame.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 22:07:46 +00:00
Marco 698a2d736e Update Björn's author photo (blog author bio, homepage About section)
New portrait supplied directly, replacing the previous headshot at the
same public/about-author.jpg path (used by /blog/[slug]'s two author
bio blocks and the homepage's About section).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 21:54:19 +00:00
Marco 7c842808cf Fall back to newsletter CTA when a post has no related product
The "Passend dazu" slot went empty for most posts (only 1 of 12 has
relatedProduct set), leaving /newsletter with no incoming link
anywhere on the site despite being a fully built page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 21:40:12 +00:00
Marco 06fee1739d Add auto-updating sitemap.xml and robots.txt
Sitemap combines hardcoded static routes with Payload's Pages and
published Posts collections (hourly ISR revalidation), so new CMS
pages/blog posts appear without a code change. Excludes noindex
transactional routes (checkout, cart, bestellbestaetigung,
newsletter-confirmed, konto/*) to avoid Search Console warnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
2026-08-29 21:28:42 +00:00
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
122 changed files with 7469 additions and 2495 deletions
+564 -885
View File
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@ function LockIcon() {
);
}
export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
export function EmailCapture({ buttonLabel = "Klarheits-Check starten" }: { buttonLabel?: string }) {
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
useNewsletterSignup("challenge");
@@ -5,6 +5,7 @@ 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";
@@ -18,12 +19,12 @@ export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/lebensuhr",
canonical: "/7-tage-klarheits-check",
},
openGraph: {
title,
description,
url: "/lebensuhr",
url: "/7-tage-klarheits-check",
images: ["/blog-featured.jpg"],
},
twitter: {
@@ -33,49 +34,6 @@ 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-1">
@@ -86,22 +44,22 @@ function Check() {
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.",
},
@@ -117,7 +75,7 @@ const benefits = [
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 (
<>
@@ -152,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">Lebensuhr</span>
<span className="text-text-primary">7-Tage-Klarheits-Check</span>
</p>
{/* Everything else centered in the remaining vertical space,
@@ -227,7 +185,7 @@ 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>
@@ -345,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>
);
}
+25 -6
View File
@@ -8,7 +8,9 @@ import { TrustRow } from "../components/TrustRow";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
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,7 +46,7 @@ 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
@@ -68,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>
)}
+1 -1
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();
+1 -1
View File
@@ -20,7 +20,7 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
}
const result = await toggleWishlistItem(session.token, productId, variant);
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 });
}
+2 -2
View File
@@ -8,8 +8,8 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
import { describeBundleContents } from "../../lib/bundleContents";
import { sendCriticalAlert } from "../../lib/alertAdmin";
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { checkVatIdViaVies } from "../../lib/vies";
import { 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";
@@ -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);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
import { checkVatIdViaVies } from "../../../lib/vies";
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
+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 });
}
+63 -16
View File
@@ -57,11 +57,17 @@ export default async function BlogDetailPage({
const post = await getPostBySlug(slug, { draft: isPreview });
if (!post) notFound();
// "Weiterlesen" — any other post, most recent first. Not the current
// one; falls back to nothing (section just doesn't render) rather than
// 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;
// "Weiterlesen" — the chronologically next-older post, wrapping around
// to the newest post from the oldest one. Not getBlogPosts()'s own
// -featured,-publishedAt order: picking "the first of the top 4 that
// isn't this one" made nearly every post's Weiterlesen converge on the
// same one or two most-recent posts (reported 2026-08-29). Sorting
// purely by publishedAt here instead makes every post point somewhere
// different, cycling through the whole archive one post at a time.
const allPosts = await getBlogPosts(100);
const byDate = [...allPosts].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
const currentIndex = byDate.findIndex((p) => p.slug === post.slug);
const nextPost = currentIndex === -1 || byDate.length < 2 ? null : byDate[(currentIndex + 1) % byDate.length];
const seller = await getCompanySettings();
const articleSchema = buildArticleSchema(post, seller);
@@ -125,16 +131,21 @@ export default async function BlogDetailPage({
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
{/* "Passend dazu" — per-post CMS content now (Posts.relatedProduct),
not a hardcoded flagship-product link. Hidden entirely if the
post has no related product, or that product has no detail
page to send "Entdecken" to. Matches the actual built Figma
frame exactly (node 4674:349, fetched via get_design_context) —
1px border-border, rounded-md, px-9/py-7 padding; an earlier
version guessed a plain borderless row instead, which
get_metadata's structural dump didn't reveal (frame-level
stroke/padding/radius aren't visible there, only
get_design_context shows those). */}
{post.relatedProduct?.href && (
not a hardcoded flagship-product link. If the post has no
related product (or that product has no detail page to send
"Entdecken" to), falls back to a "Nicht verpassen" newsletter
card instead of leaving this slot empty — /newsletter had no
incoming link anywhere in the site despite being a fully
built page (WERKZEUGE_ROUTES and TestimonialsPage already
treated it as a first-class destination), and blog readers
are exactly its target audience. Matches the actual built
Figma frame exactly (node 4674:349, fetched via
get_design_context) — 1px border-border, rounded-md,
px-9/py-7 padding; an earlier version guessed a plain
borderless row instead, which get_metadata's structural dump
didn't reveal (frame-level stroke/padding/radius aren't
visible there, only get_design_context shows those). */}
{post.relatedProduct?.href ? (
<Link
href={post.relatedProduct.href}
className="group flex items-center gap-4 sm:gap-6 border border-border rounded-md px-5 py-5 sm:px-9 sm:py-7 hover:border-brand transition-colors"
@@ -162,7 +173,7 @@ export default async function BlogDetailPage({
>
{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 mt-1 sm:mt-0">
Entdecken
@@ -178,6 +189,42 @@ export default async function BlogDetailPage({
</div>
</div>
</Link>
) : (
<Link
href="/newsletter"
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="/weekly-photo.png" 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">Nicht verpassen:</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)" }}
>
Wöchentliche Impulse
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">
Jeden Mittwoch neue Denkanstöße direkt ins Postfach kostenlos, jederzeit abbestellbar.
</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">
Abonnieren
<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>
+62 -91
View File
@@ -4,6 +4,7 @@ import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
import { Newsletter } from "../components/Newsletter";
import { Footer } from "../components/Footer";
import { BlogCategoryFilter } from "../components/BlogCategoryFilter";
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
import { formatDate } from "../lib/format";
@@ -31,11 +32,6 @@ function distinctCategories(posts: { categories: string[] }[]): string[] {
return Array.from(seen);
}
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";
}
export default async function BlogOverviewPage({
searchParams,
}: {
@@ -47,85 +43,43 @@ export default async function BlogOverviewPage({
const allCategories = blogFilterEnabled ? distinctCategories(allPosts) : [];
const posts =
activeCategories.length === 0 ? allPosts : allPosts.filter((post) => post.categories.some((c) => activeCategories.includes(c)));
const [featured, ...rest] = posts;
// 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 sm:flex-row items-center w-full min-h-[20rem] sm: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 sm:py-0 w-full sm:w-auto sm: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 sm:absolute sm:inset-y-0 sm:right-0 sm:w-[68%] h-56 sm:h-full">
<Image alt="" src="/hero.png" fill sizes="(min-width: 640px) 68vw, 100vw" className="object-cover object-top" />
<div className="hidden sm: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>
{/* Plain div, not <Reveal> — this bar sits right at/just past the
hero's bottom edge, exactly the "already near the initial
viewport top" position Reveal.tsx's own comment documents as a
whileInView(margin:"-80px") trap: the shrunk viewport can
permanently miss triggering "entered view" for an element
that's already visible without any further scroll, since
`once: true` never gets a second chance. The Hero brand dot
hit the identical bug and switched to a plain animate — this
filter bar doesn't need a scroll-reveal animation at all, so
it's simplest to just not wrap it in Reveal in the first place. */}
{/* relative z-20 — the featured-post card right below pulls itself
up by -mt-8 with its own z-10 to overlap the *hero's* bottom
edge (its original, intended design). Inserting this filter
bar between the hero and that card meant the same -mt-8 pull
now overlapped THIS bar instead, and the card's higher/equal
stacking rendered on top of it, visually hiding the chips
behind the card. z-20 keeps this bar above that overlap
regardless. */}
{allCategories.length > 1 && (
<div className="relative z-20 flex flex-wrap gap-2 w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-6">
{allCategories.map((category) => {
const active = activeCategories.includes(category);
return (
<Link
key={category}
href={buildCategoryHref(activeCategories, category)}
// bg-bg-muted, not bg-bg-base — border-border (#e5e0d8)
// on bg-base (#f8f5f1) is a ~2% lightness difference,
// nearly invisible as a pill outline; a filled muted
// background makes the chip read as a discrete control
// regardless of the border's own low contrast.
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
active
? "bg-brand border-brand text-text-primary"
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
}`}
>
{category}
</Link>
);
})}
{activeCategories.length > 0 && (
<Link
href="/blog"
className="inline-flex items-center px-3 py-1.5 text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
>
Zurücksetzen
</Link>
)}
</div>
)}
{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.
@@ -133,10 +87,11 @@ export default async function BlogOverviewPage({
)}
{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 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"
@@ -147,12 +102,12 @@ export default async function BlogOverviewPage({
)}
</div>
<div className="flex flex-col justify-center gap-3 p-8 sm: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.categories.join(", ")}</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">{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"
@@ -178,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">
@@ -188,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.categories.join(", ")}</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"
+52 -22
View File
@@ -13,6 +13,9 @@ 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 { ProductBadge } from "../../components/ProductBadge";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -163,7 +166,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>
@@ -227,32 +230,53 @@ export function CartContent({
sm: once the row layout kicks in and the image sits
beside the text instead. */}
<div className="relative w-full aspect-square sm:size-[9.375rem] sm:shrink-0 rounded-sm overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 640px) 150px, 100vw"
className="object-cover"
/>
{discount !== null && (
<span className="absolute top-2 left-2 rounded-full bg-brand px-2 py-0.5 text-label font-bold text-text-primary">
-{discount}%
</span>
{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"
/>
)}
<div className="absolute top-2 left-2">
<ProductBadge product={product} />
</div>
</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">
@@ -302,7 +326,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>
@@ -523,7 +547,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}
/>
</>
);
}
+28 -70
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, kleinunternehmer }: { defaultTaxRate: number; kleinunternehmer: boolean }) {
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, kleinunternehmer }: { defaultT
(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 sm:grid-cols-12 gap-6 sm: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 sm: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
@@ -148,61 +159,8 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
: ""
: "")
}
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 640px) 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>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
{/* Always rendered, text conditional — min-h reserves this
line's height in both states so cards in the same row
stay equal height regardless of low-stock status; this
component has no h-full/flex-1 spacer trick like
ProductGrid.tsx to absorb a variable-height line instead. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</div>
);
})}
/>
))}
</div>
</section>
);
+4 -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, getKleinunternehmer } 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,13 +20,14 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, 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
@@ -60,7 +61,7 @@ export default async function CartPage() {
showDiscountField={showDiscountField}
/>
</Suspense>
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
<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>
);
}
+93 -26
View File
@@ -13,13 +13,14 @@ import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CustomSelect } from "../../components/CustomSelect";
import { VersandModal } from "../../components/VersandModal";
import { AddressAutocomplete } from "./AddressAutocomplete";
import { VatBreakdown } from "../../components/VatBreakdown";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
import { PaymentStep } from "./PaymentStep";
import { dispatchAuthChanged } from "../../lib/auth";
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { normalizeVatId, isValidVatId, isValidPlz, plzInputPattern } from "@einfach-produktiv/invoicing";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import { validateEmailFormat } from "../../lib/email";
import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -36,8 +37,7 @@ import type { CustomerProfile } from "../../lib/customerAuth";
// here. `?? 4` only matters if a country somehow isn't in the map at all
// (shouldn't happen — the <select> options are built from the same list).
function plzPattern(country: string, plzDigitsMap: Record<string, number>): string {
const digits = plzDigitsMap[country] ?? 4;
return `\\d{${digits}}`;
return plzInputPattern(plzDigitsMap[country] ?? 4);
}
// Same rules as the pattern/required attributes each field already
@@ -51,7 +51,7 @@ function validateRequired(label: string, value: string): string {
function validateZip(value: string, country: string, plzDigitsMap: Record<string, number>): string {
if (!value.trim()) return "PLZ ist erforderlich.";
const digits = plzDigitsMap[country] ?? 4;
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
return isValidPlz(value, digits) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
}
function validatePackstationNumber(value: string): string {
@@ -279,6 +279,7 @@ export function CheckoutContent({
// this exact same VIES check server-side at submit time regardless —
// this state is a preview, never the source of truth.
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
const [dhlPostNumberStatus, setDhlPostNumberStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
// Flips true only after the hydration effect's setState calls have
// actually landed in a render — gates the write-back effect below so it
// never fires with the pre-hydration defaults first and briefly
@@ -396,6 +397,11 @@ export function CheckoutContent({
selectedShipping?.freeShippingThreshold !== undefined &&
subtotal >= selectedShipping.freeShippingThreshold;
const shipping = items.length === 0 || !cartHasShippableItem(items) || freeShipping ? 0 : selectedShipping?.price ?? 0;
// For VersandModal — same "lowest freeShippingThreshold among active
// methods" rule as CartContent.tsx/getTrustBadges(), independent of
// whichever method is currently selected in the radio list above.
const shippingMethodThresholds = shippingMethods.map((m) => m.freeShippingThreshold).filter((t): t is number => t !== null);
const lowestFreeShippingThreshold = shippingMethodThresholds.length > 0 ? Math.min(...shippingMethodThresholds) : null;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
const taxBreakdown = computeTaxBreakdown(
items.map(({ entry, product }) => ({
@@ -472,6 +478,42 @@ export function CheckoutContent({
}
}
// Live-checks the Packstation Postnummer against DHL, mirroring
// handleVatIdBlur's shape. `ok: false` from the proxy route covers both
// "DHL unreachable" and "this tenant doesn't have Postnummer-validation
// activated" (dhl-settings.postnummerEnabled off) — the latter is the
// common case for shops without a DHL integration, so it silently falls
// back to idle (format-only, already checked above) rather than showing
// an alarming "currently unavailable" message for a feature that was
// simply never turned on.
async function handlePostNumberBlur(e: React.FocusEvent<HTMLInputElement>) {
const input = e.target;
const value = input.value;
const formatError = validatePostNumber(value);
setFieldError("shippingPostNumber", formatError, input);
if (!value.trim() || formatError || !shippingFirstName.trim() || !shippingLastName.trim()) {
setDhlPostNumberStatus("idle");
return;
}
setDhlPostNumberStatus("checking");
try {
const res = await fetch("/api/checkout/validate-dhl-postnumber", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postNumber: value, firstName: shippingFirstName, lastName: shippingLastName }),
});
const data = await res.json();
if (!data.ok) {
setDhlPostNumberStatus("idle");
return;
}
setDhlPostNumberStatus(data.valid ? "valid" : "invalid");
if (!data.valid) input.focus();
} catch {
setDhlPostNumberStatus("idle");
}
}
// Logs into an existing account inline, without leaving /checkout —
// router.refresh() re-runs the page's Server Component, which re-reads
// the now-set session cookie and passes the resolved customerEmail back
@@ -977,13 +1019,16 @@ export function CheckoutContent({
Rechnungsadresse (an invoice needs a real postal address).
Packstation is only ever offered below, in the optional
"Abweichende Lieferadresse" section's own Lieferart toggle. */}
<FormField
<AddressAutocomplete
label="Straße und Hausnummer"
name="street"
type="text"
value={street}
onChange={(e) => setStreet(e.target.value)}
onChange={setStreet}
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
onSelectSuggestion={(s) => {
setZip(s.zip);
setCity(s.city);
}}
error={fieldErrors.street}
placeholder="Musterstraße 1"
autoComplete="street-address"
@@ -1115,12 +1160,15 @@ export function CheckoutContent({
</div>
</div>
{shippingDeliveryMethod === "address" ? (
<FormField
<AddressAutocomplete
label="Straße und Hausnummer"
type="text"
value={shippingStreet}
onChange={(e) => setShippingStreet(e.target.value)}
onChange={setShippingStreet}
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
onSelectSuggestion={(s) => {
setShippingZip(s.zip);
setShippingCity(s.city);
}}
error={fieldErrors.shippingStreet}
placeholder="Musterstraße 1"
autoComplete="off"
@@ -1144,21 +1192,34 @@ export function CheckoutContent({
title="Packstationsnummer muss aus 1 bis 3 Ziffern bestehen (1999)."
required
/>
<FormField
label="Postnummer"
type="text"
value={shippingPostNumber}
onChange={(e) => setShippingPostNumber(e.target.value)}
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value), e.target)}
error={fieldErrors.shippingPostNumber}
inputMode="numeric"
placeholder="1234567890"
autoComplete="off"
pattern="\d{6,10}"
maxLength={10}
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
required
/>
<div className="flex-1 min-w-0 flex flex-col gap-1">
<FormField
label="Postnummer"
type="text"
value={shippingPostNumber}
onChange={(e) => setShippingPostNumber(e.target.value)}
onBlur={handlePostNumberBlur}
error={fieldErrors.shippingPostNumber}
inputMode="numeric"
placeholder="1234567890"
autoComplete="off"
pattern="\d{6,10}"
maxLength={10}
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
required
wrapperClassName="w-full"
/>
{/* Silent when idle — covers both "not yet checked" and
"this shop has no DHL Postnummer-validation active",
same reasoning as handlePostNumberBlur's own comment. */}
<p className="text-label text-text-muted min-h-[1.05rem]">
{dhlPostNumberStatus === "checking" && "Postnummer wird bei DHL geprüft…"}
{dhlPostNumberStatus === "valid" && <span className="text-success"> Postnummer bestätigt</span>}
{dhlPostNumberStatus === "invalid" && (
<span className="text-red-600">DHL konnte Postnummer/Name-Kombination nicht bestätigen.</span>
)}
</p>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
@@ -1495,7 +1556,13 @@ export function CheckoutContent({
</Reveal>
</form>
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
<VersandModal
open={versandOpen}
onClose={() => setVersandOpen(false)}
shipping={shippingSettings}
shippingCost={shippingMethods[0]?.price ?? 0}
freeShippingThreshold={lowestFreeShippingThreshold}
/>
</>
);
}
+8 -1
View File
@@ -96,9 +96,16 @@ export function About() {
>
<Image
alt="Björn"
src="/about-author.jpg"
src="/about-banner.jpg"
fill
sizes="(min-width: 640px) 58vw, 100vw"
// Reverted to the original wide banner photo (2448x896) per
// 2026-08-29 request — the real bjoern.png headshot is a square
// close-up that doesn't suit this wide/short strip (see git
// history on this line for that whole detour). Blog posts keep
// using the new photo via about-author.jpg; this section alone
// goes back to its own dedicated file, about-banner.jpg, whose
// wide aspect was always the right shape for object-cover here.
className="object-cover object-center pointer-events-none"
/>
{/* Left gradient — from sm: up, not just lg:. Even without the
+85 -41
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,9 +18,11 @@ export function AddToCartButton({
label,
className,
productId = "todo-karten",
numericId,
outOfStock = false,
maxQty = null,
variants = [],
showQuantityStepper = false,
}: {
label: string;
className?: string;
@@ -27,6 +30,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;
@@ -37,8 +44,14 @@ export function AddToCartButton({
* `variants` prop; all three callers already fetch the full product
* server-side, so this is just threaded straight through. */
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
/** Opt-in +/- quantity control before the button, adding `qty` at once
* instead of one click per unit. Off by default — every existing caller
* (grid cards, spotlight) keeps today's exact one-click-adds-one
* behavior; only the PDP hero/pricing panel enable this. */
showQuantityStepper?: boolean;
}) {
const [added, setAdded] = useState(false);
const [qty, setQty] = useState(1);
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -50,14 +63,17 @@ export function AddToCartButton({
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
const qtyInCart = cart.find((i) => i.id === productId && i.variant === selectedVariant)?.qty ?? 0;
const remainingQty = currentMaxQty != null ? Math.max(0, currentMaxQty - qtyInCart) : null;
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
const disabled = currentlyOutOfStock || limitReached;
const clampedQty = remainingQty != null ? Math.min(qty, Math.max(1, remainingQty)) : qty;
function handleClick() {
if (disabled) return;
addToCart(productId, 1, selectedVariant);
addToCart(productId, showQuantityStepper ? clampedQty : 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
setQty(1);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setAdded(false), FEEDBACK_MS);
}
@@ -82,7 +98,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,46 +129,72 @@ 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. */}
{/* whitespace-nowrap — inherited by every stacked span below. On a
w-full button (e.g. this page's mobile layout), "Maximale Menge
im Warenkorb" is long enough to wrap to two lines without this,
and since every stacked span shares the same grid cell, that
inflated the row height for whichever text is actually showing
too — "Ausverkauft" rendered with a tall empty gap underneath it
(fixed 2026-07-24). */}
<span className="relative grid whitespace-nowrap">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
{showQuantityStepper && !currentlyOutOfStock && !limitReached && (
<div className="flex items-center gap-3 w-fit rounded-sm border border-border">
<button
type="button"
onClick={() => setQty((q) => Math.max(1, q - 1))}
disabled={qty <= 1}
aria-label="Menge verringern"
className="flex items-center justify-center size-9 shrink-0 text-body font-bold text-text-primary disabled:opacity-40 hover:bg-bg-muted transition-colors"
>
</button>
<span className="min-w-4 text-center text-body-sm text-text-primary tabular-nums">{clampedQty}</span>
<button
type="button"
onClick={() => setQty((q) => (remainingQty != null ? Math.min(remainingQty, q + 1) : q + 1))}
disabled={remainingQty != null && clampedQty >= remainingQty}
aria-label="Menge erhöhen"
className="flex items-center justify-center size-9 shrink-0 text-body font-bold text-text-primary disabled:opacity-40 hover:bg-bg-muted transition-colors"
>
+
</button>
</div>
)}
{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>
);
}
+103 -84
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,48 +32,67 @@ 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 sm+ */}
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 sm:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
<div className="relative w-full aspect-video sm:aspect-auto sm:col-span-7 sm:h-[220px] bg-bg-muted">
{featured.thumbnail && (
<Image
alt=""
src={featured.thumbnail}
fill
sizes="(min-width: 640px) 58vw, 100vw"
className="object-cover pointer-events-none"
/>
)}
</div>
<div className="flex flex-col justify-between gap-4 py-4 px-4 sm:pr-4 sm:pl-4 sm: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 sm: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>
{/* flex + arrow as its own span — see Tools.tsx's comment on
this same fix (→'s glyph baseline sits low next to text). */}
<Link
href={featured.href}
className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
>
<span aria-hidden></span>
<span>Zum Beitrag</span>
</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 through lg (1024px), side-by-side
@@ -82,50 +102,49 @@ export async function Blog() {
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 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 pointer-events-none"
/>
)}
</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>
<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>
{/* flex + arrow as its own span — see the featured post's
own Link above / Tools.tsx's comment on this same fix. */}
<Link
href={post.href}
className="flex items-center gap-1 font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
>
<span aria-hidden></span>
<span>Zum Beitrag</span>
</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;
}
+16 -1
View File
@@ -67,6 +67,16 @@ export function CustomSelect({
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);
@@ -96,7 +106,7 @@ export function CustomSelect({
}
return (
<div ref={rootRef} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<div ref={rootRef} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<button
type="button"
aria-haspopup="listbox"
@@ -121,6 +131,11 @@ export function CustomSelect({
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) => (
+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>
);
}
+1 -1
View File
@@ -103,7 +103,7 @@ export function Hero() {
image/banner above it on true Mobile), left-aligned with the
rest of the text column again from sm: up. */}
<Link
href="/lebensuhr"
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"
>
{/* Letting this wrap to two lines below lg: (tried 2026-07-24)
+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>
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { renderProductBlock, type ProductBlockContext } from "./ProductBlocks";
import { mapPayloadProduct, type PayloadProduct, type Product } from "../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Live-previewable subset of a Products document's `layout` — same
// "editable subset only" scope as LivePageContent.tsx/LivePostContent.tsx:
// `testimonialsRef` and `crossSell` are skipped here (both need an async
// fetch a client component can't perform inline — testimonialsRef isn't
// even registered on Products.layout anymore, kept out of the switch for
// the same reason; crossSell's related-product card just doesn't update
// live, shows correctly once saved and reloaded outside the iframe).
// shipping/tax/Kleinunternehmer are passed in once from the server page
// component instead of re-fetched on every keystroke — see
// getProductBlockContext's own comment.
export function LiveProductContent({
initialProduct,
shipping,
taxRate,
kleinunternehmer,
}: {
initialProduct: Product;
} & Omit<ProductBlockContext, "product">) {
const { data } = useLivePreview<PayloadProduct>({
initialData: initialProduct as unknown as PayloadProduct,
serverURL: PAYLOAD_URL,
depth: 2,
});
const product = data?.slug ? mapPayloadProduct(data) : initialProduct;
const ctx: ProductBlockContext = { product, shipping, taxRate, kleinunternehmer };
return <>{product.layout.map((block) => <div key={block.id}>{renderProductBlock(block, ctx)}</div>)}</>;
}
+99 -28
View File
@@ -7,7 +7,7 @@ import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { useCartCount } from "../lib/cart";
import { useWishlist } from "../lib/useWishlist";
import { SearchButton } from "./SearchOverlay";
import { SearchButton, SearchOverlay } from "./SearchOverlay";
import { AUTH_CHANGED_EVENT } from "../lib/auth";
import { NewsletterModal } from "./NewsletterModal";
import { useCartFly } from "./CartFly";
@@ -46,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" },
];
}
@@ -60,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", "/lebensuhr", "/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 = ["/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") {
@@ -134,23 +137,49 @@ function AccountLink() {
}
// Wishlist icon + count badge — only rendered by the caller when
// `wishlistEnabled` (CompanySettings), and even then hidden below `sm:`.
// Account+Cart are the only always-visible icons on true mobile (see the
// trailing-controls group's own comment: gap-2 there was already tuned
// specifically for exactly 2 icons) — a 3rd icon squeezed in at the
// smallest phone widths risks the exact nav-overflow class of bug
// documented in the figma-to-nextjs skill (computed hamburger/icon-row
// thresholds, not assumed ones). sm+ has real room to spare.
// `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 hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
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" fill="none" aria-hidden="true">
<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"
@@ -159,7 +188,12 @@ function WishlistLink() {
/>
</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">
<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>
)}
@@ -178,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
@@ -192,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);
@@ -260,6 +305,7 @@ export function Navbar({
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);
@@ -443,7 +489,7 @@ export function Navbar({
: "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
@@ -455,7 +501,7 @@ export function Navbar({
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) => {
@@ -467,11 +513,21 @@ export function Navbar({
: 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>
@@ -492,8 +548,17 @@ export function Navbar({
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. */}
<nav className="hidden lg:flex items-center gap-12">
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 = (
@@ -543,17 +608,22 @@ export function Navbar({
{/* 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">
{/* No gap between these two — each is already a 44px touch
target with the icon centered inside, so even gap-0 here
still leaves ~20px of visual space between the actual
glyphs. The outer gap-2 is what separates this pair from
the CTA-buttons/hamburger group that follows, and stays
untouched. Fixed 2026-07-24: gap-2 here on top of that
built-in padding read as too much space on mobile, where
these two icons are the only always-visible controls. */}
<div className="flex items-center 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 />}
{searchEnabled && <SearchButton onOpen={() => setSearchOpen(true)} />}
<AccountLink />
{wishlistEnabled && <WishlistLink />}
<CartLink />
@@ -747,6 +817,7 @@ export function Navbar({
</AnimatePresence>
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
{searchEnabled && <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />}
</>
);
}
+14
View File
@@ -213,6 +213,20 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
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>
{/* Quick-access signup here, full pitch (Testimonials, "So
funktioniert's") one click away for anyone not ready to
type an email yet — same "lightweight modal vs. full page"
split this file's own top comment describes, just also
offering the other side of it from within the modal
itself instead of only via the Navbar. */}
<Link
href="/newsletter"
onClick={onClose}
className="text-label text-text-muted underline hover:text-brand transition-colors -mt-3"
>
Mehr über die wöchentlichen Impulse erfahren
</Link>
{status === "success" ? (
<p className="text-body text-text-primary font-medium">{successMessage}</p>
) : (
+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>
);
}
+283
View File
@@ -0,0 +1,283 @@
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>
);
case "storySplit":
return (
<div
key={block.id}
className={`flex flex-col gap-8 lg:gap-10 items-center w-full ${block.imagePosition === "right" ? "lg:flex-row-reverse" : "lg:flex-row"}`}
>
{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>
)}
<div className="flex-1 w-full flex flex-col gap-4">
{block.heading && (
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
{block.heading}
</p>
)}
{block.content ? <RichText content={block.content} /> : null}
{block.bullets.length > 0 && (
<ul className="flex flex-col gap-2">
{block.bullets.map((bullet) => (
<li key={bullet.id} className="flex items-start gap-3">
<CheckIcon />
<p className="text-body text-text-body">{bullet.text}</p>
</li>
))}
</ul>
)}
</div>
</div>
);
case "faq":
return (
<div key={block.id} className="flex flex-col gap-3 w-full">
{block.items.map((item) => (
<details key={item.id} className="group border border-border rounded-md px-5 py-4 open:border-brand transition-colors">
<summary className="flex items-center justify-between gap-4 cursor-pointer list-none font-semibold text-body text-text-primary">
{item.question}
<svg
viewBox="0 0 20 20"
className="size-4 shrink-0 text-text-muted transition-transform duration-200 group-open:rotate-45"
fill="none"
aria-hidden="true"
>
<path d="M10 4v12M4 10h12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</summary>
<div className="pt-3 text-body-sm text-text-muted">
<RichText content={item.answer} />
</div>
</details>
))}
</div>
);
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.
export 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>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Product } from "../lib/payload";
import { discountPercent } from "../lib/format";
// Single shared badge — used everywhere a product's image shows one:
// PDP hero/pricing panel (ProductBlocks.tsx), shop grid, cart's
// RelatedProducts, wishlist grid (all via ProductCard.tsx). Previously
// each of those re-implemented the same Ausverkauft/discount logic
// separately, so Products.badge ("Neu") only showed up on the PDP —
// consolidated here so every surface stays in sync automatically
// (reported 2026-08-29). Priority: out-of-stock, then discount, then
// the one manual state (badge === "new") — never two pills at once.
// Discount/Ausverkauft are derived, never editor-set — see Products.ts's
// own field comment on why "new" is the only manual option.
export function ProductBadge({ product }: { product: Product }) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
if (fullyOutOfStock) {
return <span className="rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">Ausverkauft</span>;
}
if (discount !== null) {
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">-{discount}%</span>;
}
if (product.badge === "new") {
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">Neu</span>;
}
return null;
}
+419
View File
@@ -0,0 +1,419 @@
import Link from "next/link";
import Image from "next/image";
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
import {
getShippingSettings,
getDefaultTaxRatePercent,
getKleinunternehmer,
getTestimonials,
getProductsByIds,
} 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 { ProductGallery } from "./ProductGallery";
import { ProductBadge } from "./ProductBadge";
import { STEP_ICONS } from "./icons/StepIcons";
import { renderPageBlockSync, CheckIcon } 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 ctx = await getProductBlockContext(product);
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>
);
}
// Needs its own async fetch — same reasoning as testimonialsRef just
// above. A single hand-picked product (Payload dropdown, excludes
// itself) — same shape as Products.relatedProduct/the blog's
// relatedProduct, just editable per PDP block instead of a fixed
// shell field.
if (block.blockType === "crossSell") {
const [related] = await getProductsByIds([block.productId]);
if (!related) return null;
// Same bordered "Passt dazu" card as der-eine's own hand-coded
// PasstDazu.tsx (and the blog's "Passend dazu" card) — thumbnail +
// name + description + "Entdecken" arrow, no price/wishlist/buy
// button. Reads as editorial, not a commerce grid; ProductCard
// (price, wishlist heart, add-to-cart) was too busy here
// (reported 2026-08-29).
return (
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12 max-w-[48rem] mx-auto">
<Link
href={related.href ?? `/${related.id}`}
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 src={related.image} alt="" 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>
</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>;
}
// richTextSection→stepRow is always the "Was kann er?" heading
// directly above its icon row on a PDP — both getting their own full
// py-12/16 stacked (pb of one + pt of the other) put ~100-128px
// between a heading and the row it's introducing, reading as two
// unrelated sections instead of one (reported 2026-08-30). Trim the
// heading's own bottom gap so the two feel like one group, while
// keeping the outer edges (after Hero, before Pricing) generous.
const isPdpHeadingBeforeIcons = block.blockType === "richTextSection";
const isPdpIconsAfterHeading = block.blockType === "stepRow";
return (
<Reveal
key={block.id}
className={
isPdpHeadingBeforeIcons
? "w-full px-[var(--layout-padding-x)] pt-8 md:pt-10 pb-4 md:pb-6"
: isPdpIconsAfterHeading
? "w-full px-[var(--layout-padding-x)] pt-2 md:pt-4 pb-8 md:pb-10"
: "w-full px-[var(--layout-padding-x)] py-12 md:py-16"
}
>
{renderProductBlock(block, ctx)}
</Reveal>
);
})
);
return <>{rendered}</>;
}
export type ProductBlockContext = {
product: Product;
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
taxRate: number;
kleinunternehmer: boolean;
};
// Split out so LiveProductContent.tsx (the "use client" live-preview
// counterpart, same pattern as LivePageContent.tsx/renderPageBlockSync)
// can fetch this once itself instead of duplicating the three calls —
// shipping/tax/Kleinunternehmer status essentially never change between
// keystrokes in a preview session, unlike the product doc itself.
export async function getProductBlockContext(product: Product): Promise<ProductBlockContext> {
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
return { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
}
export function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
switch (block.blockType) {
case "productHero":
return <ProductHero {...ctx} body={block.body} />;
case "productPricingPanel":
return <ProductPricingPanel {...ctx} />;
// Needs an async fetch — handled by ProductBlocks' own outer
// Promise.all above, same as testimonialsRef. Never actually reached
// from there since both return their own JSX before calling this.
case "crossSell":
return null;
// Same max-w-[48rem] mx-auto as the stepRow fact-cards/crossSell card
// below — without this, a heading/prose block sat edge-to-edge while
// the cards under it were capped, reading as "still not width-limited"
// even after the cards themselves were (reported 2026-08-30).
// [&_h2>span]:hidden — RichText's heading converter always appends a
// small brand-underline span under every h2 (shared with blog/legal
// pages, where it's wanted); der-eine's own original heading had no
// such decoration, plain text only (reported 2026-08-30). Hidden via
// CSS rather than a RichText prop, same "override scoped to
// ProductBlocks.tsx, don't touch the shared renderer" approach as the
// quote/stepRow cases.
case "richTextSection":
return (
<div key={block.id} className="max-w-[48rem] mx-auto [&_h2>span]:hidden">
<RichText content={block.content} />
</div>
);
// Black, not brand-yellow — matching der-eine's own hand-coded Focus.tsx
// icons (#231f20 stroke), which this whole PDP system is meant to look
// like. STEP_ICONS' SVGs hardcode stroke="#f6a701" per-element (shared
// with Pages.layout's /lebensuhr, /3x3-system, /7-tage-klarheits-check,
// which DO want brand-yellow) — overriding via a CSS descendant
// selector here (SVG presentation attributes lose to any stylesheet
// rule) scopes the recolor to PDPs only, without touching the shared
// icon set or PageBlocks.tsx's own rendering.
// Parallel fact cards, not a connected sequence — icon on top,
// centered, no StepArrow connectors between them (those imply an
// order these facts don't have). Reuses stepRow's existing icon/
// title/description shape rather than a new block type; only the
// rendering diverges from Pages.layout's own connected-arrow stepRow
// (renderPageBlockSync), scoped to ProductBlocks.tsx. Icons black
// (#231f20), same override reasoning as the quote block below.
// max-w-[48rem] (blog-body width) read too squeezed for a 3-4-card
// row (reported 2026-08-30, same day it was narrowed) — back to
// der-eine's own original Focus.tsx width.
case "stepRow":
return (
<div key={block.id} className="flex flex-col sm:flex-row gap-8 items-start w-full max-w-[75rem] mx-auto">
{block.items.map((item) => {
const Icon = STEP_ICONS[item.icon];
return (
<div key={item.id} className="flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none mx-auto">
{Icon && (
<span className="flex items-center justify-center h-16 [&_svg_*]:!stroke-[#231f20] [&_[fill='#f6a701']]:!fill-[#231f20]">
{Icon()}
</span>
)}
<p className="font-semibold text-body text-text-primary">{item.title}</p>
<p className="text-body-sm text-text-primary w-full text-left md:text-center">{item.description}</p>
</div>
);
})}
</div>
);
// A PDP-specific full-bleed treatment for this one block type only —
// not editing the shared Quote.tsx/renderPageBlockSync, which would
// also restyle every Pages.layout quote (e.g. /ueber-mich's).
case "quote":
return (
<div key={block.id} className="bg-brand rounded-xl px-6 py-10 sm:px-12 sm:py-14 text-center">
<p className="font-semibold text-h-small sm:text-h3 text-text-primary leading-snug" style={{ fontFamily: "var(--font-lora)" }}>
{block.text}
</p>
</div>
);
// 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">
{/* pb-6 md:pb-8 — previously had no bottom padding at all, then
overcorrected to pb-10/12 (reported 2026-08-30 as "too much" once
seen outside the stale draft-mode render — see richTextSection's
own comment). Compact but still a real gap, more than the
heading→icons seam. */}
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12 pb-6 md:pb-8">
<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}
{/* Plain checkmark, not usp.icon's own STEP_ICONS lookup — a
circled checkCircle glyph didn't match the plain checkmark
every other feature-bullet list on the site uses (der-eine's
original Hero.tsx, RichText.tsx's/PageBlocks.tsx's own
checklists) — reported 2026-08-30. usp.icon is still stored
(may be used elsewhere later), just not used for this list. */}
{product.usps.length > 0 && (
<ul className="flex flex-col gap-2 w-full">
{product.usps.map((usp, i) => (
<li key={i} className="flex items-start gap-[0.625rem]">
<CheckIcon />
<p className="flex-1 text-body-sm text-text-body">{usp.text}</p>
</li>
))}
</ul>
)}
<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"
productId={product.id}
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
showQuantityStepper
/>
</div>
</Reveal>
{/* max-w-[28rem] + centered, not a full-bleed col-span-7 image —
matches der-eine's own Hero.tsx exactly (that file's own
comment: capping display width keeps a modest source photo
from blowing up). This system is meant to look like der-eine,
not the other way round. */}
<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} badge={<ProductBadge product={product} />} />
</div>
</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);
// pt-6 md:pt-8 — compact gap after the icons (stepRow's own pb-8/10
// already provides most of the seam); pb-12/16 kept generous below,
// same rhythm as the rest of the layout, before crossSell/footer.
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] pt-6 md:pt-8 pb-12 md:pb-16">
<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"
/>
<div className="absolute top-3 left-3">
<ProductBadge product={product} />
</div>
</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"
productId={product.id}
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
showQuantityStepper
/>
</div>
</Reveal>
</section>
);
}
+154
View File
@@ -0,0 +1,154 @@
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 { ProductBadge } from "./ProductBadge";
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" : ""}`}
/>
)}
<div className="absolute top-3 left-3">
{topLeftBadge !== undefined ? topLeftBadge : <ProductBadge product={product} />}
</div>
{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>
);
}
+115
View File
@@ -0,0 +1,115 @@
"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,
badge,
}: {
image: string;
gallery: string[];
alt: string;
/** Optional overlay (e.g. a discount/Ausverkauft/Neu pill) absolutely
* positioned over the main image — top-3.5/left-3.5, same corner every
* other badge on the site anchors to. Not part of this component's own
* data; the caller decides what, if anything, to show. */
badge?: React.ReactNode;
}) {
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" />
{badge && <div className="absolute top-3.5 left-3.5">{badge}</div>}
</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" />
{badge && <div className="absolute top-3.5 left-3.5">{badge}</div>}
<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 cursor-pointer"
>
<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 cursor-pointer"
>
<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 cursor-pointer ${
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>
</>
);
}
+83 -22
View File
@@ -1,11 +1,61 @@
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, getKleinunternehmer } from "../lib/payload";
import { WishlistButton } from "./WishlistButton";
import { ProductBadge } from "./ProductBadge";
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,11 +73,12 @@ import { effectiveTaxRate } from "../lib/cartTotals";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getSpotlightProduct(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getWishlistEnabled(),
]);
if (!product) return null;
@@ -53,23 +104,35 @@ export async function ProductSpotlight() {
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: 1024px) 380px, (min-width: 640px) 512px, 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>
{/* Link wraps only the image, not the whole card — WishlistButton
below is its own interactive element and stays a sibling, same
"no nested interactive elements" reasoning as ProductCard.tsx's
own image link (reported 2026-08-30: spotlight's image wasn't
clickable at all before this, unlike every product-grid card). */}
{product.href ? (
<Link href={product.href} aria-label={product.name} className="absolute inset-0 z-0">
<Image
src={image}
alt={product.name}
fill
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Link>
) : (
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>
)
<Image
src={image}
alt={product.name}
fill
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
<div className="absolute top-3 left-3">
<ProductBadge product={product} />
</div>
{wishlistEnabled && product.spotlightShowWishlist && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
)}
</div>
@@ -81,9 +144,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). */}
@@ -122,7 +183,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>
);
}
+149
View File
@@ -1,7 +1,10 @@
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";
// Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to
// Payload's own official React renderer + custom JSXConverters — needed
@@ -89,12 +92,29 @@ 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.
@@ -156,6 +176,20 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
},
listitem: ({ node, nodesToJSX }) => <li>{nodesToJSX({ nodes: node.children })}</li>,
link: ({ node, nodesToJSX }) => (
<a
href={node.fields?.url ?? "#"}
className="text-brand hover:underline"
{...(node.fields?.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{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>
@@ -240,6 +274,121 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
</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"
>
<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>
);
},
},
});
}
+48 -39
View File
@@ -7,45 +7,33 @@ import type { SearchResult } from "../api/search/route";
const DEBOUNCE_MS = 250;
export function SearchButton() {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open]);
// 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 (
<>
{/* hidden sm: — same reasoning as Navbar's WishlistLink: Account+Cart
are the only always-visible icons on true mobile, a 3rd icon
there risks the same computed nav-overflow class of bug the
figma-to-nextjs skill documents. sm+ has real room to spare. */}
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Suche öffnen"
className="hidden sm: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>
{open && <SearchOverlay onClose={() => setOpen(false)} />}
</>
<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>
);
}
function SearchOverlay({ onClose }: { onClose: () => void }) {
export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
@@ -53,8 +41,21 @@ function SearchOverlay({ onClose }: { onClose: () => void }) {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
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);
@@ -78,6 +79,8 @@ function SearchOverlay({ onClose }: { onClose: () => void }) {
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()}>
@@ -94,8 +97,14 @@ function SearchOverlay({ onClose }: { onClose: () => void }) {
placeholder="Produkte, Blogbeiträge…"
className="flex-1 min-w-0 bg-transparent outline-none text-h4 text-text-primary placeholder:text-text-muted"
/>
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 text-body-sm text-text-muted hover:text-brand transition-colors">
Esc
{/* 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>
+6 -8
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
@@ -34,7 +35,7 @@ export async function Tools() {
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 sm:grid-cols-12 gap-10 sm:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
<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}
@@ -87,17 +88,14 @@ export async function Tools() {
{tool.description}
</p>
</div>
{/* flex items-center + arrow as its own span, not inline text
— the → glyph sits low relative to the surrounding text's
cap-height in the font used here, off-center against the
label if it's just part of the same text node (fixed
2026-07-24, same pattern ProductGrid.tsx's "Mehr
erfahren" link already uses). */}
{/* 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="flex items-center gap-1 font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
>
<span aria-hidden></span>
<ArrowRightIcon />
<span>{tool.ctaLabel}</span>
</Link>
</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>
+40 -2
View File
@@ -14,10 +14,38 @@ 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);
@@ -47,9 +75,19 @@ export function WishlistButton({
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-transform active:scale-90 disabled:opacity-60 ${className}`}
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"}>
<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"
+283
View File
@@ -0,0 +1,283 @@
// 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>
);
}
function IconCup() {
// Mug + handle + steam — for "pour yourself a drink" steps (Tasse "Die
// Pause." was the first thing that needed this, none of the existing 10
// fit a physical cup product at all).
return (
<svg width="40" height="44" viewBox="0 0 40 44" fill="none">
<path d="M6 16h20v14a7 7 0 0 1-7 7h-6a7 7 0 0 1-7-7V16z" stroke="#f6a701" strokeWidth="2" strokeLinejoin="round" />
<path d="M26 20h3a5 5 0 0 1 0 10h-3" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<path d="M12 4c-2 2-2 3.5 0 5.5M20 4c-2 2-2 3.5 0 5.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconPen() {
// Pen/nib — same glyph as der-eine's own hand-coded Focus.tsx IconMetal
// (32x32 coords carried over unchanged), now available through the
// shared set for other PDPs — der-eine's PDP is the block-builder's
// reference template, its own icons belong in the shared library too.
return (
<svg width="64" height="64" viewBox="0 0 32 32" fill="none">
<path d="M10 22l2.5-0.8L23 10.7l-2.7-2.7L9.8 19.5 9 22z" stroke="#f6a701" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 9l3 3" stroke="#f6a701" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconSparkle() {
// Engraving/laser mark — same glyph as der-eine's own IconSparkle.
return (
<svg width="64" height="64" viewBox="0 0 32 32" fill="none">
<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"
stroke="#f6a701"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconCompact() {
// Four corner brackets pointing inward — same glyph as der-eine's own
// IconCompact ("fits anywhere").
return (
<svg width="64" height="64" viewBox="0 0 32 32" fill="none">
<path d="M11 8H8v3M21 8h3v3M11 24H8v-3M21 24h3v-3" stroke="#f6a701" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconPause() {
// Two rounded bars — "sit still for a moment" steps.
return (
<svg width="32" height="40" viewBox="0 0 32 40" fill="none">
<rect x="5" y="4" width="8" height="32" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="19" y="4" width="8" height="32" rx="3" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconVase() {
// Classic ornate pottery/vase silhouette (rim, neck, flared belly, foot)
// — a literal ceramic shape reads as "Vintage-Porzellan" far more
// directly than an abstract medallion did (reported 2026-08-30, the
// seal glyph still didn't land).
return (
<svg width="30" height="40" viewBox="0 0 30 40" fill="none">
<path
d="M10 3h10M11 3v4.5c0 1.6-1.5 2.6-3.4 4.6C4.5 15.5 3 19 3 23.5 3 32 8.5 37 15 37s12-5 12-13.5c0-4.5-1.5-8-4.6-11.4C20.5 10.1 19 9.1 19 7.5V3"
stroke="#f6a701"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M6 22c2 1.5 4.5 2.3 9 2.3s7-.8 9-2.3" stroke="#f6a701" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function IconStar() {
// A single starred moment — "Für den einen Moment" flags one thing as
// special, closer to what a star marks than an hourglass's literal
// "time passing" (reported 2026-08-30, hourglass still didn't land).
return (
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<path
d="M20 3l4.5 10.3L35 14.6l-8 7.6 2.2 10.8L20 27.6l-9.2 5.4L13 22.2 5 14.6l10.5-1.3L20 3z"
stroke="#f6a701"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function IconDroplets() {
// Two water drops — dishwasher-safe facts.
return (
<svg width="36" height="40" viewBox="0 0 36 40" fill="none">
<path
d="M13 4c4 5.5 6 9.2 6 12.5a6 6 0 1 1-12 0C7 13.2 9 9.5 13 4z"
stroke="#f6a701"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M25 17c3 4 4.3 6.6 4.3 8.9a4.3 4.3 0 1 1-8.6 0c0-2.3 1.3-4.9 4.3-8.9z"
stroke="#f6a701"
strokeWidth="1.6"
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,
cup: IconCup,
pause: IconPause,
pen: IconPen,
sparkle: IconSparkle,
compact: IconCompact,
seal: IconVase,
droplets: IconDroplets,
hourglass: IconStar,
};
// 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>
);
}
+14 -5
View File
@@ -7,7 +7,9 @@ import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
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,7 +40,7 @@ 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
@@ -68,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} />
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { draftMode } from "next/headers";
import { ProductBlocks, getProductBlockContext } from "../components/ProductBlocks";
import { LiveProductContent } from "../components/LiveProductContent";
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.";
// der-eine was the reference template the whole PDP block-builder system
// was designed to match (gallery sizing, black stepRow-facts icons — see
// ProductBlocks.tsx's own comments) — now migrated
// onto it itself, same as /tasse-die-pause before it. Testimonials.tsx
// dropped entirely rather than migrated: the PDP builder no longer offers
// testimonialsRef (removed 2026-08-29, unused everywhere; the planned
// replacement is a real reviews feature). Hero.tsx/Focus.tsx/
// Testimonials.tsx/Pricing.tsx/PasstDazu.tsx in ./components are now dead
// code, left for a follow-up cleanup pass rather than deleted here — same
// as /tasse-die-pause's own old components.
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 { isEnabled: isPreview } = await draftMode();
const [product, seller] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getCompanySettings(),
]);
if (!product) notFound();
const productSchema = buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-eine", seller);
const ctx = isPreview ? await getProductBlockContext(product) : null;
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
<main className="flex flex-col flex-1">
{ctx ? (
<LiveProductContent initialProduct={product} shipping={ctx.shipping} taxRate={ctx.taxRate} kleinunternehmer={ctx.kleinunternehmer} />
) : (
<ProductBlocks product={product} />
)}
</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 />
</>
);
}
@@ -6,6 +6,7 @@ import {
renderOrderConfirmationHtml,
renderPasswordResetHtml,
renderOrderStatusHtml,
renderBackInStockHtml,
ORDER_STATUS_EMAIL_ICON,
SAMPLE_ORDER,
SAMPLE_ORDER_MANUAL,
@@ -51,13 +52,21 @@ export function LiveEmailPreviewClient({
? 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" }}>
+7
View File
@@ -20,6 +20,7 @@ const VALID_TYPES: EmailTemplateType[] = [
"order-tracking-corrected",
"order-delivered",
"payment-method-switched",
"back-in-stock",
];
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
@@ -31,6 +32,7 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
"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
@@ -39,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();
+17 -7
View File
@@ -7,9 +7,9 @@ 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, getPaymentMethods, groupPaymentMethodsForCheckout } 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";
@@ -48,6 +48,7 @@ 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
@@ -79,19 +80,19 @@ 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">Zahlungsart</p>
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
</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>
</div>
</div>
{order.trackingNumber && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}</p>
<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 ? (
@@ -105,6 +106,15 @@ 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
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { CustomSelect } from "../../../components/CustomSelect";
@@ -31,6 +32,16 @@ export function OrderFilters({
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());
@@ -43,19 +54,38 @@ export function OrderFilters({
const yearOptions = years.map((y) => ({ value: y, label: y }));
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 w-full">
<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 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>
);
}
+12 -31
View File
@@ -3,7 +3,6 @@ import { Suspense } from "react";
import { redirect } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { formatPrice, formatDate } from "../../lib/format";
import {
getSessionCustomer,
@@ -14,7 +13,8 @@ import {
import { getOrderFilterEnabled } from "../../lib/payload";
import { OrderStatusBadge } from "../components/OrderStatusBadge";
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
import { LogoutButton } from "../components/LogoutButton";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
import { OrderFilters } from "./components/OrderFilters";
// robots: noindex — account area, same reasoning as /checkout.
@@ -60,15 +60,14 @@ export default async function KontoBestellungenPage({
]);
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[56rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Bestellungen
</p>
<p className="text-body text-text-muted">
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
</p>
<KontoShell>
<Reveal className="flex flex-col gap-6 items-start w-full">
<div className="flex flex-col gap-1 items-start">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Bestellungen
</p>
<AccountIdentity email={session.customer.email} customerNumber={session.customer.customerNumber} />
</div>
{orderFilterEnabled && availableYears.length > 0 && (
<Suspense fallback={null}>
@@ -135,25 +134,7 @@ export default async function KontoBestellungenPage({
</div>
)}
{/* self-center below sm — this row otherwise inherits the parent
Reveal's items-start (left-aligned); centered here per its
own request, but only the row itself (self-center keeps its
shrink-to-fit content width, doesn't stretch it to the full
parent width the way w-full/justify-center on the parent
would). Back to left-aligned (matching the rest of the page)
from sm up. */}
<div className="flex gap-6 self-center sm:self-start">
<Link href="/konto/profil" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Profil &amp; Adresse
</Link>
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Weiter einkaufen
</Link>
<LogoutButton />
</div>
</Reveal>
</main>
<Footer />
</>
</Reveal>
</KontoShell>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function AccountIdentity({ email, customerNumber }: { email: string; customerNumber: string }) {
return (
<p className="text-body-sm text-text-muted">
Eingeloggt als {email} (Kundennummer {customerNumber})
</p>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { clearCart } from "../../lib/cart";
import { dispatchAuthChanged } from "../../lib/auth";
// Persistent account navigation — a fixed sidebar (sm+) / horizontal tab
// bar (below sm) shown on every logged-in /konto/* page via KontoShell.
// Replaces the "Profil & Adresse / Weiter einkaufen / Abmelden" links that
// used to live at the bottom of the orders page's content — those became
// unreachable without scrolling past an arbitrarily long order list. Being
// part of the shell now (not page content), reachability no longer depends
// on how much is above it.
//
// No Merkliste entry — the Navbar's own wishlist heart icon already covers
// that (visible at every width, on every page, not just inside /konto/*),
// so a second entry here would just be a redundant path to the same page.
const NAV_ITEMS = [
{ href: "/konto/bestellungen", label: "Bestellungen" },
{ href: "/konto/profil", label: "Profil" },
] as const;
export function AccountNav() {
const pathname = usePathname();
const router = useRouter();
// Same sequence as the old LogoutButton (now folded in here, its one
// call site): clear the local cart (already mirrored server-side by
// CartSync, so safe to drop — the next login's mergeServerCartIntoLocal()
// restores it), tell already-mounted Client Components (Navbar's
// AccountLink) the auth state changed, leave the account area, then
// force a fresh Server Component render.
async function handleLogout() {
await fetch("/api/account/logout", { method: "POST" });
clearCart();
dispatchAuthChanged();
router.push("/");
router.refresh();
}
return (
<>
{/* Desktop/tablet — sidebar, sm+ (640px). Sits to the left of the
page content inside KontoShell's flex row. */}
<nav className="hidden sm:flex sm:flex-col sm:w-48 shrink-0 gap-1">
<p className="text-label font-bold text-text-muted uppercase tracking-wide px-3 pb-2">Mein Konto</p>
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-sm text-body-sm font-semibold transition-colors ${
pathname.startsWith(item.href) ? "bg-bg-muted text-text-primary" : "text-text-muted hover:text-text-primary"
}`}
>
{item.label}
</Link>
))}
<button
type="button"
onClick={handleLogout}
className="mt-4 px-3 py-2 text-left rounded-sm text-body-sm font-semibold text-red-600 hover:bg-red-50 transition-colors"
>
Abmelden
</button>
</nav>
{/* Mobile — horizontal tab bar, below sm. overflow-x-auto rather
than wrapping: 3 items at once already fits most phones, and a
scrollable single row reads clearly as "more tabs this way"
rather than a wrapped second line competing for attention with
the page content right below it. */}
<nav className="sm:hidden flex items-center gap-2 overflow-x-auto pb-1 -mx-[var(--layout-padding-x)] px-[var(--layout-padding-x)]">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className={`shrink-0 px-4 py-2 rounded-full text-body-sm font-bold whitespace-nowrap transition-colors ${
pathname.startsWith(item.href) ? "bg-brand text-text-primary" : "text-text-muted"
}`}
>
{item.label}
</Link>
))}
<button
type="button"
onClick={handleLogout}
className="shrink-0 px-4 py-2 rounded-full text-body-sm font-bold whitespace-nowrap text-red-600"
>
Abmelden
</button>
</nav>
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { Footer } from "../../components/Footer";
import { AccountNav } from "./AccountNav";
// Shared shell for every logged-in /konto/* page (bestellungen, merkliste,
// profil) — the <main>/<Footer> wrapping plus the persistent AccountNav
// were previously duplicated per page, with the equivalent of the nav
// buried as plain links at the bottom of the orders page's own content
// (unreachable once the order list got long enough to push it below the
// fold). Each page keeps its own auth-check/redirect and data-fetching
// exactly as before — this only replaces the outer chrome, not the
// page-specific logic each page still needs (e.g. merkliste's return-URL
// login redirect, profil's second profile-fetch redirect).
//
// "Eingeloggt als …" is NOT rendered here (even though `customer` used to
// be the only prop this shell needed) — it belongs directly under each
// page's own title so it reads as "page title, then who's logged in", not
// above the title before the page has even introduced itself. See
// AccountIdentity.tsx, rendered by each page right after its own heading.
export async function KontoShell({ children }: { children: ReactNode }) {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col sm:flex-row gap-6 sm:gap-10 w-full max-w-[75rem] mx-auto px-[var(--layout-padding-x)] pt-8 sm:pt-10 pb-16">
<AccountNav />
<div className="flex-1 min-w-0 flex flex-col gap-6">{children}</div>
</div>
</main>
<Footer />
</>
);
}
-29
View File
@@ -1,29 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { dispatchAuthChanged } from "../../lib/auth";
import { clearCart } from "../../lib/cart";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/account/logout", { method: "POST" });
// The local cart is already mirrored server-side by CartSync, so it's
// safe to clear it here — the next login's mergeServerCartIntoLocal()
// restores it from the server. Without this, the local cart survived
// logout untouched, and mergeServerCartIntoLocal()'s additive merge
// (existing.qty += qty) would add the already-synced server quantities
// on top of it on every login, doubling every logout/login cycle.
clearCart();
dispatchAuthChanged();
router.push("/");
router.refresh();
}
return (
<button type="button" onClick={handleLogout} className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Abmelden
</button>
);
}
@@ -1,12 +1,9 @@
"use client";
import Image from "next/image";
import { RevealGroup, RevealItem } from "../../../components/Reveal";
import { formatPrice, discountPercent } from "../../../lib/format";
import { AddToCartInlineButton } from "../../../components/AddToCartInlineButton";
import { WishlistButton } from "../../../components/WishlistButton";
import { formatDate } from "../../../lib/format";
import { ProductCard } from "../../../components/ProductCard";
import { useWishlist } from "../../../lib/useWishlist";
import { effectiveTaxRate } from "../../../lib/cartTotals";
import type { Product } from "../../../lib/payload";
// Client Component so removing an item (WishlistButton toggling it off)
@@ -27,62 +24,51 @@ export function MerklisteGrid({
kleinunternehmer: boolean;
}) {
const { items } = useWishlist();
const wishlistedIds = new Set(items.map((i) => i.productId));
// Preserve the wishlist's own order (most-recently-added-first, via
// `items`) rather than initialProducts' own order.
// `items`) rather than initialProducts' own order. Zipped with the
// originating wishlist item (not just the product) so purchasedAt stays
// attached — the earlier `.map().filter()` chain that only kept the
// product lost that association.
const productsByNumericId = new Map(initialProducts.map((p) => [p.numericId, p]));
const visibleProducts = items.map((i) => productsByNumericId.get(i.productId)).filter((p): p is Product => Boolean(p));
const visibleEntries = items
.map((item) => ({ item, product: productsByNumericId.get(item.productId) }))
.filter((entry): entry is { item: (typeof items)[number]; product: Product } => Boolean(entry.product));
if (visibleProducts.length === 0) {
if (visibleEntries.length === 0) {
return <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
}
return (
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{visibleProducts.map((product) => {
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;
return (
<RevealItem key={product.id} className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full">
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{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
// No items-start, and grid-auto-rows + subgrid on each RevealItem —
// 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.
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full">
{visibleEntries.map(({ item, product }) => (
<RevealItem key={`${product.id}-${item.variant}`} className="grid [grid-template-rows:subgrid] [grid-row:span_6]">
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled
// Already-purchased takes precedence over the default
// Ausverkauft/discount badge — a customer who already bought
// this doesn't need a restock notice, they need to know they
// already own it (and can still remove it manually via
// WishlistButton — this is a status note, not an
// auto-removal, see feedback discussion this implements).
// undefined (not purchased) falls through to ProductCard's
// own default Ausverkauft/discount badge.
topLeftBadge={
item.purchasedAt ? (
<span className="absolute top-3 left-3 rounded-full bg-success-subtle px-2.5 py-1 text-label font-bold text-success">
Gekauft am {formatDate(item.purchasedAt)}
</span>
)}
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
<div className="flex flex-col gap-1 items-start">
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
{/* No className override — AddToCartInlineButton's `className`
prop REPLACES its whole default styling (`?? defaultClass`,
not a merge), so passing just "w-full" here previously threw
away all the button's actual styling. Its default is
already `w-full`. */}
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
})}
) : undefined
}
/>
</RevealItem>
))}
</RevealGroup>
);
}
+16 -20
View File
@@ -1,10 +1,10 @@
import type { Metadata } from "next";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
import { MerklisteGrid } from "./components/MerklisteGrid";
// robots: noindex — account area, same reasoning as /konto/bestellungen.
@@ -19,7 +19,12 @@ export const metadata: Metadata = {
export default async function KontoMerklistePage() {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
// ?redirect= back to this page — landing on the generic account/orders
// overview after logging in from the wishlist icon (the common entry
// point, unlike most other /konto/* pages that are only ever reached
// once already logged in) would silently lose the wishlist context the
// customer actually came here for.
if (!session) redirect("/konto/login?redirect=%2Fkonto%2Fmerkliste");
// The feature can be switched off after a customer already had rows in
// their wishlist — 404 rather than showing a stale page for a feature
@@ -36,26 +41,17 @@ export default async function KontoMerklistePage() {
const initialProducts = await getProductsByIds(wishlistItems.map((i) => i.productId));
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[75rem] mx-auto">
<KontoShell>
<Reveal className="flex flex-col gap-6 items-start w-full">
<div className="flex flex-col gap-1 items-start">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Merkliste
</p>
<AccountIdentity email={session.customer.email} customerNumber={session.customer.customerNumber} />
</div>
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
<div className="flex gap-6 self-center sm:self-start">
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Meine Bestellungen
</Link>
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Weiter einkaufen
</Link>
</div>
</Reveal>
</main>
<Footer />
</>
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
</Reveal>
</KontoShell>
);
}
@@ -103,13 +103,6 @@ export function ProfileForm({
return (
<Reveal className="flex flex-col gap-6 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<p className="text-body-sm text-text-muted">
{profile.email} · Kundennummer {profile.customerNumber}
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
{/* Explicit heading, matching /checkout's own "1. Rechnungsadresse"
card title — without it, this section and the "Lieferadresse"
+15 -15
View File
@@ -1,9 +1,9 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import Link from "next/link";
import { Footer } from "../../components/Footer";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { getShippingCountries } from "../../lib/payload";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
import { ProfileForm } from "./components/ProfileForm";
import { PasswordForm } from "./components/PasswordForm";
import { VerificationBanner } from "./components/VerificationBanner";
@@ -29,19 +29,19 @@ export default async function KontoProfilPage({
const { verified } = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
Meine Bestellungen
</Link>
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
<PasswordForm email={profile.email} />
<AccountDataSection />
<KontoShell>
<div className="flex flex-col gap-10 items-start w-full max-w-[40rem]">
<div className="flex flex-col gap-1 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<AccountIdentity email={profile.email} customerNumber={session.customer.customerNumber} />
</div>
</main>
<Footer />
</>
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
<PasswordForm email={profile.email} />
<AccountDataSection />
</div>
</KontoShell>
);
}
+8 -2
View File
@@ -4,7 +4,8 @@ import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { CartSync } from "./components/CartSync";
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled } from "./lib/payload";
import { KlaroConsentManager } from "./components/KlaroConsentManager";
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled, getTrackingCodes } from "./lib/payload";
import { buildOrganizationSchema } from "./lib/structuredData";
const inter = Inter({
@@ -55,6 +56,9 @@ export async function generateMetadata(): Promise<Metadata> {
card: "summary_large_image",
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
},
verification: seo.googleSearchConsoleVerification
? { google: seo.googleSearchConsoleVerification }
: undefined,
};
}
@@ -67,11 +71,12 @@ export default async function RootLayout({
// just to know whether Navbar's "Shop" link should behave as an anchor
// to the homepage spotlight instead of a real /shop navigation (see
// Navbar.tsx/ProductSpotlight.tsx).
const [products, seller, wishlistEnabled, searchEnabled] = await Promise.all([
const [products, seller, wishlistEnabled, searchEnabled, trackingCodes] = await Promise.all([
getProducts(),
getCompanySettings(),
getWishlistEnabled(),
getSearchEnabled(),
getTrackingCodes(),
]);
const singleActiveProduct = products.filter((p) => p.active).length === 1;
// Organization JSON-LD on every page — one canonical node (@id) that
@@ -90,6 +95,7 @@ export default async function RootLayout({
>
<body className="min-h-full flex flex-col">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
<KlaroConsentManager codes={trackingCodes} />
<CartFlyProvider>
<CartSync />
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} searchEnabled={searchEnabled} />
+11 -1
View File
@@ -6,10 +6,15 @@ const product = (overrides: Partial<Product> = {}): Product => ({
id: "todo-karten",
numericId: 1,
name: "ToDo-Karten",
description: "",
subline: null,
layout: [],
description: null,
descriptionText: "",
sku: null,
price: 12.9,
compareAtPrice: null,
image: "",
gallery: [],
href: null,
active: true,
updatedAt: new Date().toISOString(),
@@ -18,12 +23,17 @@ const product = (overrides: Partial<Product> = {}): Product => ({
spotlightHeadline: null,
spotlightText: null,
spotlightImage: null,
spotlightShowWishlist: false,
variants: [],
outOfStock: false,
lowStock: false,
maxQty: null,
taxRatePercent: null,
noShippingCost: false,
categories: [],
relatedProduct: null,
usps: [],
badge: "none",
...overrides,
});
+60 -6
View File
@@ -132,17 +132,71 @@ export function useCart(): CartItem[] {
// logout — the local cart is always either empty (no guest additions since
// the last logout) or holds only genuinely new guest-session items, never a
// stale copy of what's already in the server cart, so this add never
// double-counts. CartSync then picks up the resulting change and pushes the
// merged cart back to the server on its own, closing the loop without a
// separate save call here.
// double-counts.
//
// Always pushes the resulting cart to the server itself at the end,
// explicitly — does NOT rely on CartSync's own change-triggered push.
// That was the bug (confirmed live 2026-08-01): logging in with an empty
// server cart but a non-empty guest cart means the merge loop below never
// actually runs (nothing in `data.cart` to add), so `writeCart` is never
// called, no change event fires, and CartSync's effect — which only
// re-fires when the cart *reference* actually changes — never pushes the
// guest cart to the server at all. The cart looked fine on the device
// that was already logged in, but never reached any other device. An
// explicit push here doesn't depend on anything having changed.
export async function mergeServerCartIntoLocal(): Promise<void> {
try {
const res = await fetch("/api/account/cart");
if (res.ok) {
const data: { cart?: CartItem[] } = await res.json();
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
}
} catch {
// Best-effort — a failed merge just means the server-side cart stays
// as it was; nothing local is lost either way. Still fall through to
// push below — the local (guest) cart is genuine either way.
}
try {
await fetch("/api/account/cart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cart: readCart() }),
});
} catch {
// Best-effort — CartSync's own push will retry on the next cart
// change, or the next login-time merge tries again.
}
}
// Cross-device sync for an *already* logged-in session — CartSync.tsx
// only ever pushes local→server, and mergeServerCartIntoLocal() above only
// ever runs at the moment of a fresh login/register, so a customer already
// signed in on device B never picked up a change made on device A. This
// covers that gap: called on mount and on window focus (CartSync.tsx), not
// just at login.
//
// Replaces the local cart outright with the server's — NOT an additive
// merge like mergeServerCartIntoLocal() above (that one is only safe
// right after login, when the local cart can't yet overlap the server
// one, see its own comment). An earlier version of this function only
// added/updated matching lines and never removed a local line the server
// no longer had, which meant a *removal* on device A never reached device
// B (confirmed live 2026-08-01 — adding synced, removing didn't). Server
// wins outright, including "server cart is now empty."
//
// Trade-off: a local addition made in the last few hundred ms — after
// this fetch went out but before CartSync's own 800ms debounce pushed it
// — could theoretically get overwritten by a pull that lands in between.
// Narrow enough (and self-healing on the next change/focus) to accept,
// consistent with every other "best-effort" sync path in this file.
export async function pullServerCart(): Promise<void> {
try {
const res = await fetch("/api/account/cart");
if (!res.ok) return;
const data: { cart?: CartItem[] } = await res.json();
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
writeCart(data.cart ?? []);
} catch {
// Best-effort — a failed merge just means the server-side cart stays
// as it was; nothing local is lost either way.
// Best-effort — a failed pull just means this device doesn't see
// another device's changes yet; nothing local is lost either way.
}
}
+59 -6
View File
@@ -361,8 +361,48 @@ export type WishlistItem = {
id: number;
productId: number;
variant: string;
/** ISO date of the most recent non-cancelled/non-returned order
* containing this exact (product, variant), or null if never bought.
* Deliberately never persisted on wishlist-items itself (that
* collection's own `update` access is hard-disabled — see its own
* comment) — always derived fresh from Orders on every wishlist read,
* same "read-only annotation" approach used elsewhere in this file. */
purchasedAt: string | null;
};
// Keyed `${productId}:${variant}` (empty-string variant included, matching
// WishlistItems' own key shape) → the most recent qualifying order's
// createdAt. "Qualifying" excludes `cancelled` (aborted/failed order, never
// actually fulfilled) and `returned` (customer no longer has the item) —
// everything else (received/processing/shipped/delivered/return_requested)
// still counts as "they did buy this," which is the plain-language meaning
// of the wishlist badge this feeds.
async function getPurchasedVariantMap(token: string, customerId: number): Promise<Map<string, string>> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
"where[status][not_in]": "cancelled,returned",
depth: "0",
limit: "200",
sort: "-createdAt",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return new Map();
const data: { docs?: { createdAt: string; items: { product: number; variantName?: string | null }[] }[] } = await res.json();
const map = new Map<string, string>();
for (const order of data.docs ?? []) {
for (const item of order.items) {
const key = `${item.product}:${item.variantName ?? ""}`;
// `sort: "-createdAt"` above means the first order seen per key is
// already the most recent — never overwrite with an older one.
if (!map.has(key)) map.set(key, order.createdAt);
}
}
return map;
}
// `variant` empty string, not undefined — matches WishlistItems.ts's own
// defaultValue: '' so the (customer, product, variant) unique index
// actually catches a duplicate add for a variant-less product too.
@@ -373,13 +413,19 @@ export async function getWishlist(token: string, customerId: number): Promise<Wi
limit: "200",
sort: "-createdAt",
});
const res = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
const [res, purchasedMap] = await Promise.all([
fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
}),
getPurchasedVariantMap(token, customerId),
]);
if (!res.ok) return [];
const data: { docs?: { id: number; product: number; variant?: string }[] } = await res.json();
return (data.docs ?? []).map((doc) => ({ id: doc.id, productId: doc.product, variant: doc.variant ?? "" }));
return (data.docs ?? []).map((doc) => {
const variant = doc.variant ?? "";
return { id: doc.id, productId: doc.product, variant, purchasedAt: purchasedMap.get(`${doc.product}:${variant}`) ?? null };
});
}
// Toggles a single (product, variant) — tries to create first; a 400 here
@@ -389,17 +435,19 @@ export async function getWishlist(token: string, customerId: number): Promise<Wi
// toggle (the common case, adding something new, only needs one request).
export async function toggleWishlistItem(
token: string,
customerId: number,
productId: number,
variant: string,
): Promise<{ ok: true; wishlisted: boolean } | { ok: false }> {
const createRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items`, {
method: "POST",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ product: productId, variant }),
body: JSON.stringify({ customer: customerId, product: productId, variant }),
});
if (createRes.ok) return { ok: true, wishlisted: true };
const findParams = new URLSearchParams({
"where[customer][equals]": String(customerId),
"where[product][equals]": String(productId),
"where[variant][equals]": variant,
depth: "0",
@@ -674,6 +722,11 @@ export type CustomerOrderDetail = CustomerOrder & {
correctionInvoiceIssuedAt: string | null;
carrier: string | null;
trackingNumber: string | null;
// Raw media id, not populated — this fetch stays depth=0 (see this
// function's own comment on why), so the order-detail page resolves the
// actual download URL itself via a separate media lookup when present.
dhlReturnLabelMedia: number | null;
dhlReturnTrackingNumber: string | null;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
+31
View File
@@ -336,6 +336,7 @@ export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
"order-tracking-corrected": "📦",
"order-delivered": "🎉",
"payment-method-switched": "💳",
"back-in-stock": "🔔",
};
export function renderOrderStatusHtml(
@@ -360,6 +361,36 @@ export function renderOrderStatusHtml(
return emailShell(icon, escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
// Separate from renderOrderStatusHtml — a back-in-stock mail has no order
// at all (it fires from a "notify me" signup, not a purchase), so no
// "Bestellnummer" line, and the CTA points at the product page, not
// /konto/bestellungen. Mirrors lib/jobs/sendBackInStockEmails.ts on the
// Payload backend, which is where the real send actually happens (see
// this file's own top-of-file comment on why the two aren't literally
// shared code).
export function renderBackInStockHtml(
template: EmailTemplateContent,
productLabel: string,
productUrl: string,
seller: CompanySettings | null,
productImageUrl: string | null = null,
): string {
const body = `
${paragraphs(template.bodyText, "center")}
${productImageUrl ? `<p style="text-align:center;margin:0 0 12px;"><img src="${productImageUrl}" width="64" height="64" alt="" style="display:inline-block;width:64px;height:64px;border-radius:8px;object-fit:cover;border:1px solid ${BORDER};" /></p>` : ""}
<p style="text-align:center;font-size:13px;color:${TEXT_MUTED};margin:0 0 12px;">${escapeHtml(productLabel)}</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
<tr>
<td style="background:${BRAND};border-radius:6px;">
<a href="${productUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Zum Produkt</a>
</td>
</tr>
</table>
`;
return emailShell("🔔", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, seller: CompanySettings | null): string {
const body = `
${paragraphs(template.bodyText, "center")}
+9
View File
@@ -31,3 +31,12 @@ export function formatDate(iso: string): string {
const day = String(d.getDate()).padStart(2, "0");
return `${day}. ${MONTHS_DE[d.getMonth()]} ${d.getFullYear()}`;
}
// "Juli 2026" — for legal pages' "Stand: …" line, derived from the
// LegalPages doc's own updatedAt so it always reflects when the template
// was actually last edited, instead of a hand-typed string an admin has
// to remember to bump on every content change.
export function formatMonthYear(iso: string): string {
const d = new Date(iso);
return `${MONTHS_DE[d.getMonth()]} ${d.getFullYear()}`;
}
+193
View File
@@ -0,0 +1,193 @@
import type { TrackingCode } from "./payload";
// Klaro's own config shape (kiprotect/klaro, see node_modules/klaro's
// dist/config.js for the fully-annotated reference) — only the fields we
// actually set are typed here, not Klaro's full surface.
export type KlaroConfig = {
version: number;
elementID: string;
styling: Record<string, string | string[]>;
storageMethod: "cookie";
cookieName: string;
cookieExpiresAfterDays: number;
default: boolean;
mustConsent: boolean;
acceptAll: boolean;
hideDeclineAll: boolean;
noticeAsModal: boolean;
disablePoweredBy: boolean;
translations: Record<string, Record<string, unknown>>;
services: {
name: string;
title: string;
purposes: string[];
required: boolean;
default: boolean;
onlyOnce: boolean;
callback: (consent: boolean) => void;
}[];
};
// "google-maps" is a fixed name, not id-based like every other provider —
// GoogleMapsEmbed.tsx has to hardcode a `data-name` matching this exact
// string for Klaro's own contextual-consent DOM scan
// (renderContextualConsentNotices in node_modules/klaro) to find it, and
// there's no per-row distinguishing ID the way GA/FB/GTM each have one
// (a Maps embed URL is per-page, not stored on this row at all — see
// TrackingCodes.ts's own field comment). Only ever create ONE
// google-maps row in the admin; a second one would collide on this name.
export function serviceName(code: TrackingCode): string {
if (code.provider === "google-maps") return "google-maps";
return `tracking-code-${code.id}`;
}
function providerTitle(code: TrackingCode): string {
switch (code.provider) {
case "google-analytics":
return "Google Analytics";
case "facebook-pixel":
return "Facebook Pixel";
case "google-tag-manager":
return "Google Tag Manager";
case "google-maps":
return "Google Maps";
default:
return "Sonstiges Tracking-Skript";
}
}
// Shown per service in the modal's expanded list — without this, Klaro
// only ever shows the bare provider title (see providerTitle above),
// which is thin for real TTDSG transparency ("what does this actually
// do") and reads as less trustworthy than a CMP that explains itself.
// Generic per-provider text, not per-row — a second Google Analytics row
// would get the identical description, which is fine since the "what
// GA does" fact doesn't change between rows, only which property it's
// wired to (already visible via the row's own `name` if an admin sets a
// distinguishing one).
function providerDescription(code: TrackingCode): string {
switch (code.provider) {
case "google-analytics":
return "Erfasst anonymisierte Nutzungsstatistiken (z. B. welche Seiten wie oft besucht werden), damit wir verstehen, was bei euch ankommt. Setzt die Cookies _ga (Laufzeit 2 Jahre, unterscheidet Besucher:innen) und _ga_<Container-ID> (2 Jahre, Sitzungsstatus).";
case "facebook-pixel":
return "Misst, ob ein Besuch von hier zu einer Anzeige bei Facebook/Instagram passt, und hilft uns, Werbung zielgerichteter zu schalten. Setzt den Cookie _fbp (3 Monate, eigene Domain); ist ein Facebook-Konto im selben Browser aktiv, setzt facebook.com zusätzlich den Cookie fr (3 Monate).";
case "google-tag-manager":
return "Lädt weitere Mess-/Marketing-Skripte je nach Konfiguration nach, ohne dass wir dafür jedes Mal den Code der Seite ändern müssen. Setzt selbst keine eigenen Cookies — welche Cookies letztlich gesetzt werden, hängt von den darüber geladenen Diensten ab (siehe deren eigene Einträge hier).";
case "google-maps":
return "Bindet eine interaktive Karte von Google ein. Beim Laden überträgt dein Browser deine IP-Adresse an Google und Google setzt eigene Cookies (u. a. NID), deren genauer Umfang außerhalb unserer Kontrolle liegt.";
default:
return "Ein zusätzliches Skript, das diese Seite einbindet.";
}
}
// `onAccept` is threaded in rather than importing loadTrackingCode.ts
// directly here — this module only builds a plain config object (no DOM
// access), kept separate from the actual script-injection side effect so
// it stays trivially testable/reusable if the loading mechanism ever
// changes.
export function buildKlaroConfig(codes: TrackingCode[], onAccept: (code: TrackingCode) => void): KlaroConfig {
const serviceTranslations: Record<string, { title: string; description: string }> = {};
for (const code of codes) {
serviceTranslations[serviceName(code)] = { title: providerTitle(code), description: providerDescription(code) };
}
return {
version: 1,
elementID: "klaro",
// 'light' + 'bottom' + 'left' — a compact corner notice (Klaro's own
// default max-width, ~400px), not a full-width bar. 'wide' (tried
// first) stretched it across nearly the whole viewport, which read as
// too dominant compared to a typical bottom-left corner CMP.
// Extra keys beyond `theme` override individual CSS custom properties
// (Klaro's injectStyles() applies any non-'theme' `styling` key as a
// `--<key>` var, see node_modules/klaro's own utils/styling.js) — this
// is the color pass; the border/shadow/typography pass lives in
// KlaroConsentManager.tsx's <KlaroTheme> instead (a real CSS override,
// needed because Klaro's own default border/spacing/font still read as
// an obviously-bolted-on library widget even with the right colors).
// dark1/light1 are (confusingly named) the notice's background/text
// colors, not a dark-mode toggle. green1 colors the accept button.
// blue1/blue2 color the "Einstellungen" (.cm-btn-info) button and
// in-modal links — Klaro's default blue clashed with the brand just as
// much as its default green did, easy to miss since it only shows
// once the modal (not just the notice) is open.
styling: {
theme: ["light", "bottom", "left"],
dark1: "#fffdf8", // --color-bg-paper — notice/modal background
dark2: "#e5e0d8", // --color-border
// NOT a light background tint despite the name — Klaro's own
// .cm-list-description rule (each service's description text in
// the modal) uses dark3 as a TEXT color, not a background. A near-
// white value here (tried first) made every service description
// almost invisible against the modal's own light background —
// confirmed via screenshot 2026-08-02. --color-text-muted instead.
dark3: "#6b6b69",
light1: "#1a1a18", // --color-text-primary — notice/modal text
light2: "#6b6b69", // --color-text-muted
light3: "#1a1a18",
green1: "#f6a701", // --color-brand — accept button
green2: "#d99000",
blue1: "#e5e0d8",
blue2: "#6b6b69",
"button-text-color": "#1a1a18",
"notice-max-width": "380px",
},
storageMethod: "cookie",
cookieName: "klaro-consent",
cookieExpiresAfterDays: 180,
default: false,
mustConsent: false,
acceptAll: true,
hideDeclineAll: false,
noticeAsModal: false,
// Removes the "Realisiert mit Klaro!" attribution link in the modal —
// BSD-3-Clause has no on-page-attribution requirement (only source/
// binary copyright-notice retention), this is just the maintainers'
// own polite ask via a dedicated config flag, not a license term.
disablePoweredBy: true,
translations: {
de: {
consentModal: {
title: "Deine Cookie-Einstellungen",
description: "Hier siehst du genau, was wir einsetzen — und entscheidest frei, was du erlaubst. Nichts davon läuft, bevor du zustimmst.",
},
consentNotice: {
description: "Wir verwenden ein paar Cookies, damit diese Seite reibungslos läuft und wir sehen, was bei euch gut ankommt.",
learnMore: "Einstellungen",
},
purposes: {
necessary: "Notwendig",
analytics: "Analyse",
marketing: "Marketing",
content: "Inhalte",
},
...serviceTranslations,
},
},
services: codes.map((code) => ({
name: serviceName(code),
title: providerTitle(code),
purposes: [code.consentCategory],
required: code.consentCategory === "necessary",
default: code.consentCategory === "necessary",
// For "google-maps", `onAccept` below is a no-op (see
// loadTrackingCode.ts) — an embed's src isn't a script this app
// injects, Klaro's OWN contextual-consent DOM scan
// (renderContextualConsentNotices, runs as part of Klaro.render())
// finds any `data-name="google-maps"` iframe on the page and
// blanks/restores its `src` itself, purely by service name +
// consent state. This service entry's only job is telling Klaro
// that name exists and which purpose gates it.
//
// A restock/page-reload shouldn't re-fire the loader for a service
// the visitor already accepted in an earlier session within this
// same page load — Klaro still calls `callback` once per accepted
// service per page load either way, `onlyOnce` just governs repeat
// toggling within one session.
onlyOnce: true,
callback: (consent: boolean) => {
if (consent) onAccept(code);
},
})),
};
}
+81
View File
@@ -0,0 +1,81 @@
import type { TrackingCode } from "./payload";
// Imperative <script> injection, not next/script — this fires from
// KlaroConsentManager.tsx's per-service `callback(consent)`, i.e. at an
// arbitrary point after mount (whenever a visitor actually accepts), not
// declaratively on every render. Idempotent via the DOM id check, since
// Klaro's `onlyOnce: true` (see klaroConfig.ts) already tries to guarantee
// a single callback-with-consent-true per page load, but this is a cheap
// second guard against ever injecting the same script twice.
function injectScript(id: string, src?: string, inlineJs?: string) {
if (document.getElementById(id)) return;
const script = document.createElement("script");
script.id = id;
script.async = true;
if (src) script.src = src;
else script.textContent = inlineJs ?? "";
document.head.appendChild(script);
}
// One loader per TrackingCodes.provider (google-analytics/facebook-pixel/
// google-tag-manager/other) — same fixed loader snippets per provider as
// the Payload backend's own admin.description promises, 'other' is the
// only one whose content is admin-supplied rather than a fixed snippet
// (trusted deliberately, see TrackingCodes.ts on the backend).
export function loadTrackingCode(code: TrackingCode): void {
// No-op — Google Maps isn't a script this app injects. Klaro's own
// contextual-consent DOM scan handles any `data-name="google-maps"`
// iframe (GoogleMapsEmbed.tsx) independently, purely off the service's
// consent state — see klaroConfig.ts's own comment on this service.
if (code.provider === "google-maps") return;
const baseId = `tracking-code-${code.id}`;
if (code.provider === "google-analytics" && code.measurementId) {
injectScript(`${baseId}-loader`, `https://www.googletagmanager.com/gtag/js?id=${code.measurementId}`);
injectScript(
`${baseId}-init`,
undefined,
`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${code.measurementId}');`,
);
return;
}
if (code.provider === "facebook-pixel" && code.pixelId) {
injectScript(
baseId,
undefined,
`!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${code.pixelId}');
fbq('track', 'PageView');`,
);
return;
}
if (code.provider === "google-tag-manager" && code.containerId) {
injectScript(
baseId,
undefined,
`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${code.containerId}');`,
);
return;
}
if (code.provider === "other" && code.customScript) {
injectScript(baseId, undefined, code.customScript);
}
}
+529 -12
View File
@@ -181,10 +181,27 @@ export type Product = {
// slug-based call site.
numericId: number;
name: string;
description: string;
// Short tagline shown under the name on cards/cart/PDP hero — see
// Products.ts's own field comment. null/"" means show nothing extra.
subline: string | null;
// Raw Lexical richText JSON — rendered formatted (bold/italic/multiple
// paragraphs) on the PDP via the shared <RichText> component. Plain-text
// consumers (Passend-dazu cards, JSON-LD) use `descriptionText` below
// instead of re-deriving text from this themselves.
description: unknown | null;
descriptionText: string;
// Not shown anywhere in the UI today — only consumed by
// buildProductSchema (JSON-LD's Product.sku), a real Schema.org property
// Google's rich-result validator looks for.
sku: string | null;
price: number;
compareAtPrice: number | null;
image: string;
// Extra photos beyond `image` above, for ProductGallery.tsx's
// thumbnail-strip UI — empty for the vast majority of products (opt-in
// per product, see Products.ts's own field comment). Never includes
// `image` itself; ProductGallery treats `image` as always-slide-zero.
gallery: string[];
href: string | null;
// `active` is opt-in for callers to filter by, not applied inside
// getProducts()/getProductBySlug() themselves — cart, checkout, order
@@ -198,8 +215,18 @@ export type Product = {
spotlight: boolean;
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
// Lexical richText JSON (switched from plain textarea 2026-08-25 so
// admins can bold parts of the teaser) — rendered by
// ProductSpotlight.tsx's own minimal inline converter, not the full
// article-oriented RichText.tsx (that one adds block-level heading/
// paragraph spacing meant for blog posts/legal pages, wrong fit for a
// single teaser paragraph).
spotlightText: unknown | null;
spotlightImage: string | null;
// Per-product opt-in for a wishlist heart on the homepage spotlight —
// independent of (in addition to) the global wishlistEnabled toggle,
// which still gates the feature site-wide regardless of this flag.
spotlightShowWishlist: boolean;
// Plain booleans, not the raw stock/threshold numbers — the public API
// has no reason to leak exact stock counts, callers only ever need
// "can this be bought right now". `outOfStock` on the product itself
@@ -231,24 +258,55 @@ export type Product = {
// exempts the individual product, not the whole cart.
noShippingCost: boolean;
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
// Empty for a product that predates this field or was never tagged —
// treated as "matches every category filter" by the shop grid rather
// than "matches none", since an uncategorized product shouldn't just
// disappear the moment a category filter is applied.
categories: string[];
// Optional cross-sell pick (Products.relatedProduct) — powers a "Passt
// dazu" card on the product's own detail page, same field/pattern as
// Posts.relatedProduct. null means no card. Only one level deep (this
// related product's own relatedProduct is never resolved/shown) — see
// mapPayloadProduct's own comment.
relatedProduct: Product | null;
// Optional PDP page-builder content — see Products.ts's own `layout`
// field comment. Empty for every existing hand-coded PDP (der-eine,
// todo-cards, tasse-die-pause); a product's own page.tsx decides whether
// to render this or its bespoke components, this type doesn't enforce
// either.
layout: ProductBlock[];
// Fixed PDP-shell fields, not part of `layout` — always render the same
// way regardless of block order, per the page-builder spec's "shell vs.
// blocks" split. Max 3 enforced server-side (Products.usps maxRows).
usps: { icon: string; text: string }[];
// "new" is the only manual state — discount/out-of-stock/low-stock
// badges are derived automatically elsewhere (discountPercent()/
// outOfStock/lowStock above); see Products.ts's own field comment for
// why those two aren't also manual options here.
badge: "none" | "new";
};
type PayloadProduct = {
export type PayloadProduct = {
id: number;
name: string;
subline: string | null;
slug: string;
description: string | null;
layout: PayloadProductBlock[] | null;
description: unknown | null;
sku: string | null;
price: number;
compareAtPrice: number | null;
image: { url: string } | number | null;
gallery: ({ url: string } | number)[] | null;
detailHref: string | null;
active: boolean;
updatedAt: string;
spotlight: boolean;
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightText: unknown | null;
spotlightImage: { url: string } | number | null;
spotlightShowWishlist: boolean;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
@@ -265,6 +323,10 @@ type PayloadProduct = {
lowStockThreshold: number | null;
}[]
| null;
categories: ({ name: string } | number)[] | null;
relatedProduct: PayloadProduct | number | null;
usps: { icon: string; text: string }[] | null;
badge: "none" | "new" | null;
};
// A product/variant is only actually unbuyable when it opted into
@@ -290,19 +352,42 @@ function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowB
return trackInventory && !allowBackorder ? (stock ?? 0) : null;
}
type LexicalTextNode = {
text?: string;
children?: LexicalTextNode[];
};
// Mirrors the backend's own Posts.ts extractPlainText (same shape, same
// space-joined fallback for multiple paragraphs/children) — kept as a
// separate copy rather than a shared package since it's a few lines and
// the two run in different runtimes (Payload server vs. this frontend).
function extractPlainText(node: LexicalTextNode | null | undefined): string {
if (!node) return "";
if (typeof node.text === "string") return node.text;
if (Array.isArray(node.children)) return node.children.map(extractPlainText).join(" ");
return "";
}
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
// one place instead of duplicating the same field mapping, which is
// exactly the kind of drift this session's Shipping Settings work was
// about eliminating elsewhere.
export function mapPayloadProduct(product: PayloadProduct): Product {
const descriptionRoot = (product.description as { root?: LexicalTextNode } | null)?.root;
return {
id: product.slug,
numericId: product.id,
name: product.name,
description: product.description ?? "",
subline: product.subline || null,
description: product.description ?? null,
descriptionText: extractPlainText(descriptionRoot),
sku: product.sku || null,
price: product.price,
compareAtPrice: product.compareAtPrice ?? null,
image: typeof product.image === "object" && product.image ? product.image.url : "",
gallery: (product.gallery ?? [])
.map((g) => (typeof g === "object" && g ? g.url : null))
.filter((url): url is string => Boolean(url)),
href: product.detailHref || null,
active: product.active,
updatedAt: product.updatedAt,
@@ -312,6 +397,7 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
spotlightText: product.spotlightText || null,
spotlightImage:
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
spotlightShowWishlist: product.spotlightShowWishlist,
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
@@ -324,6 +410,16 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold),
maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder),
})),
categories: (product.categories ?? [])
.map((c) => (typeof c === "object" && c ? c.name : null))
.filter((name): name is string => Boolean(name)),
// Recursive call is safe here — depth=2 on the originating fetch means
// this nested object's OWN relatedProduct is never populated (stays a
// raw id or absent), so the recursion bottoms out after exactly one level.
relatedProduct: typeof product.relatedProduct === "object" && product.relatedProduct ? mapPayloadProduct(product.relatedProduct) : null,
layout: (product.layout ?? []).map(mapPayloadProductBlock),
usps: product.usps ?? [],
badge: product.badge ?? "none",
};
}
@@ -392,6 +488,21 @@ export async function getProductImagesByIds(ids: number[]): Promise<Map<number,
return map;
}
// Resolves a bare media id to its download URL — used by
// /konto/bestellungen/[orderNumber] for order.dhlReturnLabelMedia, which
// stays a raw id on the order fetch itself (that fetch is deliberately
// depth=0, see getCustomerOrderDetail's own comment) rather than bumping
// that fetch's depth just for this one occasional field. `no-store`, not
// ISR-cached like getProductImagesByIds — a return label is a one-off,
// account-specific document, not shared/reusable content worth caching.
export async function getMediaUrlById(id: number): Promise<{ url: string; filename: string } | null> {
const res = await fetch(`${PAYLOAD_URL}/api/media/${id}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { url?: string; filename?: string } = await res.json();
if (!data.url) return null;
return { url: data.url, filename: data.filename ?? "download.pdf" };
}
// Derived from getProducts() (same 60s-ISR-cached fetch every other
// discovery surface already uses) instead of its own separate Payload
// query — also what lets the auto-spotlight rule below just be a plain
@@ -761,7 +872,7 @@ export async function getWerkzeugeCards(): Promise<WerkzeugeCard[]> {
}));
}
export type TestimonialsPage = "todo-cards" | "newsletter" | "challenge";
export type TestimonialsPage = "todo-cards" | "newsletter" | "klarheits-check" | "der-eine" | "einfach-anfangen";
export type Testimonial = { id: number; quote: string; name: string; role: string; avatar: string };
@@ -786,10 +897,10 @@ export function mapPayloadTestimonial(doc: PayloadTestimonial): Testimonial {
}
// Powers the identically-styled customer testimonial grids on /todo-cards,
// /newsletter, and /challenge (see TestimonialsGrid.tsx) — previously 3
// hardcoded arrays kept manually in sync. The single-quote "photo band"
// testimonials on /not-found and /bestellbestaetigung are a different
// shape (no avatar/role) and are not part of this collection.
// /newsletter, /7-tage-klarheits-check, and /der-eine (see TestimonialsGrid.tsx) —
// previously hardcoded arrays kept manually in sync. The single-quote
// "photo band" testimonials on /not-found and /bestellbestaetigung are a
// different shape (no avatar/role) and are not part of this collection.
export async function getTestimonials(page: TestimonialsPage, options?: { draft?: boolean }): Promise<Testimonial[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
@@ -816,14 +927,23 @@ export type LegalPage = {
type: LegalPageType;
title: string;
content: unknown;
// Only populated for pages that need a dynamically-rendered section
// MID-document (currently just AGB's "Vertragspartner", sourced from
// company-settings — see VertragspartnerBlock.tsx). null everywhere
// else, including pages like Datenschutz whose one dynamic section sits
// at the very start and so can just be prepended instead of split.
contentPart2: unknown | null;
attachment: { url: string; title: string } | null;
updatedAt: string;
};
type PayloadLegalPage = {
type: LegalPageType;
title: string;
content: unknown;
contentPart2?: unknown;
attachment: { url: string; title: string } | number | null;
updatedAt: string;
};
export async function getLegalPage(type: LegalPageType, options?: { draft?: boolean }): Promise<LegalPage | null> {
@@ -847,10 +967,12 @@ export async function getLegalPage(type: LegalPageType, options?: { draft?: bool
type: doc.type,
title: doc.title,
content: doc.content,
contentPart2: doc.contentPart2 ?? null,
attachment:
typeof doc.attachment === "object" && doc.attachment
? { url: doc.attachment.url, title: doc.attachment.title }
: null,
updatedAt: doc.updatedAt,
};
}
@@ -864,7 +986,8 @@ export type EmailTemplateType =
| "order-tracking-added"
| "order-tracking-corrected"
| "order-delivered"
| "payment-method-switched";
| "payment-method-switched"
| "back-in-stock";
type PayloadEmailTemplate = {
type: EmailTemplateType;
@@ -1090,6 +1213,7 @@ export type SeoSettings = {
titleTemplate: string | null;
defaultDescription: string | null;
defaultOgImage: string | null;
googleSearchConsoleVerification: string | null;
};
// Fallback matches the values hardcoded in app/layout.tsx before this field
@@ -1102,6 +1226,7 @@ const SEO_SETTINGS_FALLBACK: SeoSettings = {
defaultDescription:
"Kleine Impulse, praktische Werkzeuge und ehrliche Gedanken für mehr Klarheit im Alltag weil du auch noch ein Leben hast.",
defaultOgImage: "/og-image.png",
googleSearchConsoleVerification: null,
};
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
@@ -1123,6 +1248,7 @@ export async function getSeoSettings(): Promise<SeoSettings> {
seoTitleTemplate?: string | null;
seoDefaultDescription?: string | null;
seoDefaultOgImage?: { url?: string } | number | null;
googleSearchConsoleVerification?: string | null;
}[];
} = await res.json();
const doc = data.docs?.[0];
@@ -1133,5 +1259,396 @@ export async function getSeoSettings(): Promise<SeoSettings> {
defaultDescription: doc.seoDefaultDescription || SEO_SETTINGS_FALLBACK.defaultDescription,
defaultOgImage:
(typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage,
googleSearchConsoleVerification:
doc.googleSearchConsoleVerification || SEO_SETTINGS_FALLBACK.googleSearchConsoleVerification,
};
}
// Powers app/r/[code]/route.ts and app/sticker/[code]/route.ts — a static
// short link (e.g. printed on a QR code) that redirects to a `targetPath`
// editable in Payload at any time, so the QR code itself never needs
// reprinting. `cache: "no-store"` (unlike this file's other public-catalog
// fetches) since a stale hit here would send a visitor to a since-changed
// target, and the PATCH below needs the just-fetched id/clickCount, not a
// 60s-old ISR snapshot.
type PayloadRedirect = { id: number; targetPath: string; clickCount: number };
// `urlPrefix` filters the match to whichever fixed frontend route is
// actually calling this — Redirects.urlPrefix is otherwise just an admin
// label, not something either route enforced, so a code edited to
// urlPrefix "/sticker" in Payload would silently keep resolving under
// /r/<code> too without this. Each call site passes its own literal
// prefix (see route.ts files), so this actually makes the field mean
// something rather than only decorate the admin list.
export async function resolveAndTrackRedirect(code: string, urlPrefix: "/r" | "/sticker"): Promise<string | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[code][equals]": code,
"where[urlPrefix][equals]": urlPrefix,
"where[active][equals]": "true",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/redirects?${params}`, { cache: "no-store" });
if (!res.ok) {
console.error(`resolveAndTrackRedirect: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: PayloadRedirect[] } = await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
fetch(`${PAYLOAD_URL}/api/redirects/${doc.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "",
},
body: JSON.stringify({ clickCount: doc.clickCount + 1, lastClickedAt: new Date().toISOString() }),
}).catch((err) => console.error("resolveAndTrackRedirect: click-tracking PATCH failed", err));
return doc.targetPath;
}
export type TrackingCode = {
id: number;
provider: "google-analytics" | "facebook-pixel" | "google-tag-manager" | "google-maps" | "other";
consentCategory: "necessary" | "analytics" | "marketing" | "content";
measurementId: string | null;
pixelId: string | null;
containerId: string | null;
customScript: string | null;
};
// Public read (TrackingCodes.ts's own access.read: () => true), unlike
// most of this file's other Company-Settings-adjacent fetchers — no
// x-order-service-secret header needed. Only `active: true` rows, since
// TrackingScripts.tsx has no reason to even know an inactive one exists.
// Consent-gating itself happens in TrackingScripts.tsx, not here — this
// fetcher runs server-side (no cookie access), the gating decision is a
// client-only concern (useConsent()).
export async function getTrackingCodes(): Promise<TrackingCode[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
depth: "0",
limit: "50",
});
const res = await fetch(`${PAYLOAD_URL}/api/tracking-codes?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getTrackingCodes: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: TrackingCode[] } = await res.json();
return Array.isArray(data.docs) ? data.docs : [];
}
// ── Products.layout (PDP page builder) ─────────────────────────────────────
// Shares 9 of PageBlock's own block types verbatim (richTextSection/
// stepRow/quote/image/icon/pillList/checklistImage/table/ctaCard — same
// Payload Block configs, reused a second time on Products.layout, see that
// field's own comment) plus two product-specific ones that don't exist on
// Pages: productHero (the one PDP hero variant for now — just a body
// richText, everything else about the hero comes from the product itself)
// and productPricingPanel (the closing buy panel, no fields of its own).
export type ProductBlock =
| { blockType: "productHero"; id: string; body: unknown | null }
| { blockType: "productPricingPanel"; id: string }
| { blockType: "richTextSection"; id: string; content: unknown }
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
| { blockType: "quote"; id: string; text: string; label: string | null }
| { blockType: "image"; id: string; image: string | null; caption: string | null }
| { blockType: "icon"; id: string; icon: string }
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
| { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string }
| {
blockType: "storySplit";
id: string;
image: string | null;
imagePosition: "left" | "right";
heading: string | null;
content: unknown;
bullets: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
// Not resolved to full Product objects here — same reasoning as
// testimonialsRef not resolving its testimonials in this mapper: needs
// its own async fetch (automatic mode queries by category; manual mode
// needs getProductsByIds), done in ProductBlocks.tsx's own outer
// Promise.all, mirroring how that file already special-cases
// testimonialsRef for the exact same reason.
| { blockType: "crossSell"; id: string; productId: number };
type PayloadProductBlock =
| { blockType: "productHero"; id: string; body?: unknown | null }
| { blockType: "productPricingPanel"; id: string }
| { blockType: "richTextSection"; id: string; content: unknown }
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
| { blockType: "quote"; id: string; text: string; label?: string | null }
| { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
| { blockType: "icon"; id: string; icon: string }
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
| { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string }
| {
blockType: "storySplit";
id: string;
image: PayloadPageImageField;
imagePosition?: "left" | "right" | null;
heading?: string | null;
content?: unknown;
bullets?: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
| { blockType: "crossSell"; id: string; product: number | { id: number } };
function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
switch (block.blockType) {
case "productHero":
return { blockType: "productHero", id: block.id, body: block.body ?? null };
case "image":
return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
case "checklistImage":
return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
case "quote":
return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
case "ctaCard":
return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
case "stepRow":
return {
blockType: "stepRow",
id: block.id,
items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
};
case "storySplit":
return {
blockType: "storySplit",
id: block.id,
image: mapPayloadPageImage(block.image),
imagePosition: block.imagePosition ?? "left",
heading: block.heading ?? null,
content: block.content ?? null,
bullets: block.bullets ?? [],
};
case "faq":
return { blockType: "faq", id: block.id, items: block.items };
case "crossSell":
return {
blockType: "crossSell",
id: block.id,
productId: typeof block.product === "object" ? block.product.id : block.product,
};
default:
return block;
}
}
// ── Pages (generic slug-routed content pages / page builder) ──────────────
// Powers app/[slug]/page.tsx — the first generic catch-all route in this
// repo (every other content page today is its own static directory).
// `layout` is a Payload native `type: 'blocks'` field (docker/payload/src/
// collections/Pages.ts), a different shape from Posts.content's Lexical-
// embedded Blocks (see RichText.tsx's own comment) — each block here is a
// full page section, not an inline prose element.
export type PageBlock =
| { blockType: "hero"; id: string; headline: string; subline: string | null; image: string | null; ctaLabel: string | null; ctaHref: string | null }
| { blockType: "richTextSection"; id: string; content: unknown }
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
| { blockType: "quote"; id: string; text: string; label: string | null }
| { blockType: "image"; id: string; image: string | null; caption: string | null }
| { blockType: "icon"; id: string; icon: string }
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
| { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string }
| {
blockType: "storySplit";
id: string;
image: string | null;
imagePosition: "left" | "right";
heading: string | null;
content: unknown;
bullets: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] };
export type Page = {
id: number;
title: string;
slug: string;
layout: PageBlock[];
seoTitle: string | null;
seoDescription: string | null;
seoImage: string | null;
};
type PayloadPageImageField = { url?: string | null } | number | null;
type PayloadPageBlock =
| { blockType: "hero"; id: string; headline: string; subline?: string | null; image?: PayloadPageImageField; ctaLabel?: string | null; ctaHref?: string | null }
| { blockType: "richTextSection"; id: string; content: unknown }
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
| { blockType: "quote"; id: string; text: string; label?: string | null }
| { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
| { blockType: "icon"; id: string; icon: string }
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
| { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string }
| {
blockType: "storySplit";
id: string;
image: PayloadPageImageField;
imagePosition?: "left" | "right" | null;
heading?: string | null;
content?: unknown;
bullets?: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] };
export type PayloadPage = {
id: number;
title: string;
slug: string;
layout: PayloadPageBlock[];
seoTitle?: string | null;
seoDescription?: string | null;
seoImage?: { url: string } | number | null;
};
function mapPayloadPageImage(ref: PayloadPageImageField): string | null {
return typeof ref === "object" && ref && typeof ref.url === "string" ? ref.url : null;
}
function mapPayloadPageBlock(block: PayloadPageBlock): PageBlock {
switch (block.blockType) {
case "image":
return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
case "checklistImage":
return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
case "quote":
return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
case "ctaCard":
return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
case "stepRow":
return {
blockType: "stepRow",
id: block.id,
items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
};
case "hero":
return {
blockType: "hero",
id: block.id,
headline: block.headline,
subline: block.subline ?? null,
image: mapPayloadPageImage(block.image ?? null),
ctaLabel: block.ctaLabel ?? null,
ctaHref: block.ctaHref ?? null,
};
case "storySplit":
return {
blockType: "storySplit",
id: block.id,
image: mapPayloadPageImage(block.image),
imagePosition: block.imagePosition ?? "left",
heading: block.heading ?? null,
content: block.content ?? null,
bullets: block.bullets ?? [],
};
case "faq":
return { blockType: "faq", id: block.id, items: block.items };
default:
return block;
}
}
// Shared by getPageBySlug() and LivePageContent.tsx (re-maps the raw
// document useLivePreview receives via postMessage using this same logic)
// — same reuse pattern as mapPayloadPost.
export function mapPayloadPage(doc: PayloadPage): Page {
return {
id: doc.id,
title: doc.title,
slug: doc.slug,
layout: (doc.layout ?? []).map(mapPayloadPageBlock),
seoTitle: doc.seoTitle || null,
seoDescription: doc.seoDescription || null,
seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : null,
};
}
export async function getPageBySlug(slug: string, options?: { draft?: boolean }): Promise<Page | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[slug][equals]": slug,
depth: "2",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/pages?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
if (!res.ok) {
console.error(`getPageBySlug: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: PayloadPage[] } = await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
return mapPayloadPage(doc);
}
export type SitemapEntry = { slug: string; updatedAt: string };
// Backs app/sitemap.ts. Pages has no status/draft field (see Pages.ts) —
// every doc that exists is live — so unlike getBlogPosts there's no
// `where[status]` filter needed here.
export async function getAllPageSlugs(): Promise<SitemapEntry[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
depth: "0",
limit: "100",
});
const res = await fetch(`${PAYLOAD_URL}/api/pages?${params}`, { next: { revalidate: 3600 } });
if (!res.ok) {
console.error(`getAllPageSlugs: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: { slug: string; updatedAt: string }[] } = await res.json();
return (data.docs ?? []).map((doc) => ({ slug: doc.slug, updatedAt: doc.updatedAt }));
}
// Same shape as getAllPageSlugs but for Posts, which — unlike Pages — does
// have a draft/scheduled state, so this filters to `published` the same
// way getBlogPosts does (the sitemap must never list a post that 404s).
export async function getAllPostSlugs(): Promise<SitemapEntry[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[status][equals]": "published",
depth: "0",
limit: "100",
});
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, { next: { revalidate: 3600 } });
if (!res.ok) {
console.error(`getAllPostSlugs: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: { slug: string; updatedAt: string }[] } = await res.json();
return (data.docs ?? []).map((doc) => ({ slug: doc.slug, updatedAt: doc.updatedAt }));
}
-14
View File
@@ -1,14 +0,0 @@
// Single source of truth for shipping numbers — consumed by both the
// cart's order summary (CartContent.tsx) and the /versand policy page, so
// the two can never drift apart.
//
// The delivery-timeframe numbers (handling/transit days, Art. 246a § 1
// Abs. 1 Nr. 8 EGBGB) used to live here too, but now come from Payload's
// Shipping Settings collection instead (see lib/payload.ts's
// getShippingSettings()) — that duplication is exactly why the
// ShippingMethods CMS title once carried a different, drifted day-range
// than this file did. Cost/threshold below have the same drift risk
// against Payload's ShippingMethods price/freeShippingThreshold fields,
// not yet addressed here.
export const SHIPPING_COST = 2.9;
export const FREE_SHIPPING_THRESHOLD = 39;
+48
View File
@@ -0,0 +1,48 @@
// Thin client for the backend's DHL custom endpoints (src/lib/endpoints/
// dhlValidatePostNumber.ts, dhlAutocompleteAddress.ts) — own copy per repo,
// unlike vies.ts/vatId.ts/tracking.ts (now shared via @einfach-produktiv/invoicing),
// since this proxies to Payload endpoints specific to this repo's own DHL wiring,
// not portable logic.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export async function validateDhlPostNumber(args: {
postNumber: string;
firstName: string;
lastName: string;
}): Promise<{ ok: true; valid: boolean } | { ok: false; reason: string }> {
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, ...args });
try {
const res = await fetch(`${PAYLOAD_URL}/api/dhl/validate-post-number?${params}`, {
signal: AbortSignal.timeout(8000),
});
const data = await res.json();
if (!res.ok || !data.ok) return { ok: false, reason: data.reason ?? "Postnummer konnte nicht geprüft werden." };
return { ok: true, valid: Boolean(data.valid) };
} catch {
return { ok: false, reason: "Postnummer-Prüfung ist gerade nicht erreichbar." };
}
}
export type DhlAddressSuggestion = {
street: string;
houseNumber?: string;
zip: string;
city: string;
country: string;
};
export async function autocompleteDhlAddress(query: string): Promise<DhlAddressSuggestion[]> {
if (query.trim().length < 3) return [];
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, query });
try {
const res = await fetch(`${PAYLOAD_URL}/api/dhl/autocomplete-address?${params}`, {
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
if (!res.ok || !data.ok) return [];
return data.suggestions as DhlAddressSuggestion[];
} catch {
return [];
}
}
+25
View File
@@ -0,0 +1,25 @@
// Server-only — imported exclusively by app/api/stock-notifications/route.ts.
// Reuses ORDER_SERVICE_SECRET rather than a new dedicated secret, matching
// StockNotifications.ts's own choice on the backend (see that collection's
// comment) — it's already the general-purpose "frontend server calling
// into Payload" credential used by getWishlistEnabled/getSearchEnabled/etc.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || "";
export async function createStockNotification(email: string, productId: number, variantName: string): Promise<{ ok: boolean; reason?: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/stock-notifications`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-order-service-secret": SERVICE_SECRET },
body: JSON.stringify({ email, product: productId, variantName }),
});
if (res.ok) return { ok: true };
// The (tenant, email, product, variantName) unique index rejects a
// second signup for the same thing with a 400 — read as "already on the
// list", not a real error, so the UI can still show success rather than
// a confusing failure for someone who's already signed up.
if (res.status === 400) return { ok: true };
console.error(`createStockNotification: Payload returned ${res.status} ${res.statusText}`);
return { ok: false, reason: "Eintragen hat nicht geklappt. Bitte versuch es später erneut." };
}
+25 -4
View File
@@ -23,7 +23,7 @@ export function buildOrganizationSchema(seller: CompanySettings | null): Record<
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: "einfach produktiv",
name: "einfach produktiv.",
url: SITE_URL,
};
}
@@ -53,9 +53,10 @@ export function buildProductSchema(product: Product, url: string, seller: Compan
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.image,
description: product.descriptionText,
image: product.gallery.length > 0 ? [product.image, ...product.gallery] : product.image,
url,
...(product.sku ? { sku: product.sku } : {}),
// No reviews/ratings system exists yet — `aggregateRating` is
// optional in the spec and deliberately omitted rather than faked;
// add it here once real reviews exist, not before.
@@ -91,7 +92,7 @@ export function buildArticleSchema(
// "Björn" is already hardcoded in the page's own author-bio block,
// matched here rather than left out entirely.
author: { "@type": "Person", name: "Björn" },
publisher: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv" },
publisher: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv." },
};
}
@@ -100,3 +101,23 @@ export function buildArticleSchema(
// directly (no need for a shared component around one line of JSX, and
// keeps this file free of "use client"/React concerns so server
// components can import it without issue).
// Generic fallback for content pages with no bespoke schema type of their
// own (Article for blog posts, Product for PDPs) — the Payload Pages
// collection (page builder) serves arbitrary content types, so a generic
// WebPage node is the right shape rather than guessing at a more specific
// one from the blocks it happens to contain.
export function buildWebPageSchema(
page: { title: string; seoDescription: string | null; slug: string },
seller: CompanySettings | null,
): Record<string, unknown> {
const url = `${SITE_URL}/${page.slug}`;
return {
"@context": "https://schema.org",
"@type": "WebPage",
name: page.title,
url,
...(page.seoDescription ? { description: page.seoDescription } : {}),
isPartOf: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv." },
};
}
-26
View File
@@ -1,26 +0,0 @@
// Mirrors the Payload backend's own src/lib/tracking.ts — same carrier
// set/labels/URL patterns, kept in sync by hand (two separate
// deployments, no shared package). Used to render a clickable tracking
// link on /konto/bestellungen/[orderNumber]; the backend's copy builds
// the same link for the order-shipped email.
export const CARRIER_LABELS: Record<string, string> = {
dhl: "DHL",
dpd: "DPD",
hermes: "Hermes",
ups: "UPS",
gls: "GLS",
other: "Sonstiger Versanddienstleister",
};
const CARRIER_TRACKING_URL: Record<string, (trackingNumber: string) => string> = {
dhl: (n) => `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(n)}`,
dpd: (n) => `https://tracking.dpd.de/status/de_DE/parcel/${encodeURIComponent(n)}`,
hermes: (n) => `https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation/#${encodeURIComponent(n)}`,
ups: (n) => `https://www.ups.com/track?loc=de_DE&tracknum=${encodeURIComponent(n)}`,
gls: (n) => `https://www.gls-pakete.de/sendungsverfolgung?trackingNumber=${encodeURIComponent(n)}`,
};
export function buildTrackingUrl(carrier: string | null | undefined, trackingNumber: string | null | undefined): string | null {
if (!carrier || !trackingNumber) return null;
return CARRIER_TRACKING_URL[carrier]?.(trackingNumber) ?? null;
}
+4 -4
View File
@@ -11,7 +11,7 @@ import { useCallback, useEffect, useState } from "react";
// an item, without a shared cache library.
const WISHLIST_EVENT = "ep-wishlist-updated";
type WishlistItem = { id: number; productId: number; variant: string };
type WishlistItem = { id: number; productId: number; variant: string; purchasedAt: string | null };
let cachedItems: WishlistItem[] | null = null;
@@ -63,7 +63,7 @@ export function useWishlist() {
const wasWishlisted = cachedItems?.some((i) => i.productId === productId && i.variant === variant) ?? false;
const optimistic = wasWishlisted
? (cachedItems ?? []).filter((i) => !(i.productId === productId && i.variant === variant))
: [...(cachedItems ?? []), { id: -1, productId, variant }];
: [...(cachedItems ?? []), { id: -1, productId, variant, purchasedAt: null }];
cachedItems = optimistic;
setItems(optimistic);
@@ -74,7 +74,7 @@ export function useWishlist() {
body: JSON.stringify({ productId, variant }),
});
if (res.status === 401) {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant, purchasedAt: null }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
return { ok: false as const, unauthorized: true as const };
}
@@ -88,7 +88,7 @@ export function useWishlist() {
broadcast();
return { ok: true as const, wishlisted: data.wishlisted ?? !wasWishlisted };
} catch {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant, purchasedAt: null }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
return { ok: false as const };
}
-15
View File
@@ -1,15 +0,0 @@
// Mirrors the backend's own USt-IdNr. validation exactly (Orders.ts/
// Customers.ts/CompanySettings.ts in the Payload repo) — kept as a plain
// client+server-safe helper here since this repo's frontend needs the same
// check twice (checkout's instant client-side pattern + api/checkout's own
// server-side re-validation, same "never trust the client" reasoning as
// every other checkout field).
const VAT_ID_PATTERN = /^[A-Z]{2}[A-Z0-9]{2,12}$/;
export function normalizeVatId(value: string): string {
return value.toUpperCase().trim();
}
export function isValidVatId(value: string): boolean {
return VAT_ID_PATTERN.test(value);
}
-58
View File
@@ -1,58 +0,0 @@
// Server-only — calls the European Commission's public VIES REST API to
// confirm an EU VAT ID is actually registered, not just correctly
// formatted (see lib/vatId.ts's own comment: format alone is never
// enough to zero-rate an invoice). Confirmed live and working against
// the real endpoint 2026-07-23 (POST {countryCode, vatNumber} →
// {valid: boolean, ...}) — this is the Commission's own documented REST
// API, not a guess.
const VIES_URL = "https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number";
export type ViesCheckResult =
| { ok: true; valid: boolean; name: string | null; address: string | null }
| { ok: false; reason: string };
// `vatNumber` must NOT include the country prefix (VIES wants it split
// out) — callers pass the full "DE123456789"-shaped id and this function
// does the splitting, since every call site already has the normalized
// full id (see lib/vatId.ts's normalizeVatId()) rather than the two parts
// separately.
export async function checkVatIdViaVies(vatId: string): Promise<ViesCheckResult> {
const countryCode = vatId.slice(0, 2);
const vatNumber = vatId.slice(2);
if (!countryCode || !vatNumber) return { ok: false, reason: "Ungültiges USt-IdNr.-Format." };
try {
// 8s timeout — VIES is a shared EU-wide government service with no
// uptime SLA to this shop; a slow/unreachable response must not hang
// checkout indefinitely. Callers treat `ok: false` as "couldn't
// confirm" and fail closed (no exemption), never as "confirmed invalid".
const res = await fetch(VIES_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ countryCode, vatNumber }),
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return { ok: false, reason: `VIES antwortete mit ${res.status}` };
const data: { actionSucceed?: boolean; valid?: boolean; name?: string; address?: string; errorWrappers?: { error?: string }[] } = await res.json();
// VIES answers 200 even when it couldn't actually perform the check —
// `actionSucceed: false` (e.g. `MS_UNAVAILABLE`, the member state's own
// national gateway being temporarily down — Germany's in particular is
// known to do this) means "couldn't confirm", not "confirmed invalid".
// Without this check a `MS_UNAVAILABLE` response fell through to
// `Boolean(data.valid)` on a body that has no `valid` field at all,
// silently reading as `valid: false` — a real, currently-registered VAT
// ID would then look rejected instead of "VIES unavailable, try again".
if (data.actionSucceed === false) {
const reason = data.errorWrappers?.[0]?.error ?? "VIES konnte die Anfrage nicht bearbeiten.";
return { ok: false, reason: `VIES: ${reason}` };
}
return {
ok: true,
valid: Boolean(data.valid),
name: data.name && data.name !== "---" ? data.name : null,
address: data.address && data.address !== "---" ? data.address : null,
};
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : "VIES ist gerade nicht erreichbar." };
}
}
+35 -7
View File
@@ -40,6 +40,12 @@ export default function NewsletterConfirmedPage() {
>
Bestätigt!
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Deine Newsletter-Anmeldung ist eingegangen.
</p>
<div className="h-[0.125rem] w-8 bg-brand" />
<div className="flex flex-col gap-4 text-left text-body text-text-muted max-w-[28rem] pt-2">
@@ -54,10 +60,19 @@ export default function NewsletterConfirmedPage() {
<div className="flex flex-col gap-3 bg-bg-muted rounded-md px-6 py-5">
<p className="font-semibold text-text-primary">🧭 Dein Klarheitskompass</p>
<p>Du erhältst ihn in einer separaten E-Mail in den nächsten Minuten er hilft dir, kurz innezuhalten und dich zu fragen:</p>
<ul className="flex flex-col gap-1 list-disc pl-5">
<li>Was beschäftigt mich gerade?</li>
<li>Was ist wirklich wichtig?</li>
<li>Worauf möchte ich meinen Fokus legen?</li>
<ul className="flex flex-col gap-2">
<li className="flex items-start gap-2">
<CheckIcon />
<span>Was beschäftigt mich gerade?</span>
</li>
<li className="flex items-start gap-2">
<CheckIcon />
<span>Was ist wirklich wichtig?</span>
</li>
<li className="flex items-start gap-2">
<CheckIcon />
<span>Worauf möchte ich meinen Fokus legen?</span>
</li>
</ul>
</div>
@@ -66,13 +81,16 @@ export default function NewsletterConfirmedPage() {
Beobachte in den nächsten Tagen einfach einmal, womit du deine Aufmerksamkeit
verbringst. Nicht bewerten. Nur wahrnehmen.
</p>
<p>Bis Sonntag</p>
<p className="text-text-primary font-semibold">Björn</p>
<p>
Bis Sonntag
<br />
<span className="text-text-primary font-semibold">Björn</span>
</p>
</div>
<Link
href="/shop"
className="inline-flex items-center justify-center py-4 px-8 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
className="inline-flex items-center justify-center px-7 py-4 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-h4 text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
>
Jetzt stöbern
</Link>
@@ -83,3 +101,13 @@ export default function NewsletterConfirmedPage() {
</>
);
}
// Same checkmark used by PageBlocks.tsx's checklistImage block (and
// /lebensuhr's, /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>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { redirect, notFound } from "next/navigation";
import { NextRequest } from "next/server";
import { resolveAndTrackRedirect } from "../../lib/payload";
// A static short link (e.g. printed on a QR code) that always resolves to
// whatever `targetPath` is currently set on the matching Redirects document
// in Payload — the QR code itself never needs reprinting when the target
// changes. `redirect()` (not permanentRedirect()) issues a 307, deliberately
// non-cacheable client-side since the target can change at any time.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ code: string }> }) {
const { code } = await params;
const targetPath = await resolveAndTrackRedirect(code, "/r");
// Defense in depth — Redirects.targetPath is already validated in Payload
// to start with "/", same open-redirect guard as api/preview/route.ts.
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
notFound();
}
redirect(targetPath);
}
+16
View File
@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
const SITE_URL = "https://einfach-produktiv.mk360.de";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/checkout", "/cart", "/konto", "/bestellbestaetigung", "/newsletter-confirmed", "/email-preview", "/company-settings-preview", "/api"],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
// Category checkboxes (union — any checked category matches) plus a
// separate "Nur verfügbare Produkte" availability toggle, sharing one
// component since both are simple instant-apply checkboxes writing to the
// same /shop searchParams (no submit button, same as the price slider —
// see PriceRangeFilter.tsx). Categories are derived from the active
// product set by the caller (ProductGrid.tsx), same "distinct values from
// what's actually in use" pattern as the blog's category chips — an
// uncategorized product still matches every category filter (see
// Product.categories' own comment in lib/payload.ts), so this list never
// needs an "Alle" fallback entry.
export function CategoryFilter({ categories }: { categories: string[] }) {
const router = useRouter();
const searchParams = useSearchParams();
const activeCategories = (searchParams.get("categories") ?? "").split(",").filter(Boolean);
const inStockOnly = searchParams.get("inStock") === "1";
function push(params: URLSearchParams) {
const qs = params.toString();
router.push(qs ? `/shop?${qs}` : "/shop");
}
function toggleCategory(category: string) {
const next = activeCategories.includes(category)
? activeCategories.filter((c) => c !== category)
: [...activeCategories, category];
const params = new URLSearchParams(searchParams.toString());
if (next.length > 0) params.set("categories", next.join(","));
else params.delete("categories");
push(params);
}
function toggleInStock() {
const params = new URLSearchParams(searchParams.toString());
if (inStockOnly) params.delete("inStock");
else params.set("inStock", "1");
push(params);
}
return (
<div className="flex flex-col gap-2 w-full">
{categories.length > 1 && (
<div className="flex flex-col gap-2 w-full">
<p className="font-bold text-body-sm text-text-primary">Kategorie</p>
{categories.map((category) => (
<label key={category} className="flex items-center gap-2 text-body-sm text-text-primary cursor-pointer">
<input
type="checkbox"
checked={activeCategories.includes(category)}
onChange={() => toggleCategory(category)}
className="h-4 w-4 rounded-sm border-border accent-brand cursor-pointer"
/>
{category}
</label>
))}
</div>
)}
<label className="flex items-center gap-2 text-body-sm text-text-primary cursor-pointer">
<input
type="checkbox"
checked={inStockOnly}
onChange={toggleInStock}
className="h-4 w-4 rounded-sm border-border accent-brand cursor-pointer"
/>
Nur verfügbare Produkte
</label>
</div>
);
}
+86 -47
View File
@@ -2,79 +2,118 @@
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { formatPrice } from "../../lib/format";
// Real min/max range (not preset toggle buckets, tried first and
// reverted 2026-07-30 — "keine toggle badges") — two plain number inputs,
// submitted via a small Client Component's router.push. Still a plain
// URL search param underneath (?minPrice=&maxPrice=), so the result stays
// shareable/bookmarkable like every other filter on the site.
export function PriceRangeFilter({ catalogMin, catalogMax }: { catalogMin: number; catalogMax: number }) {
// Two overlapping native <input type="range"> thumbs (a plain CSS trick —
// track transparent/pointer-events-none, only the thumb itself clickable
// via the ::-webkit-slider-thumb/::-moz-range-thumb pseudo-elements) —
// replaced the earlier two-number-input version (2026-08-01) for a more
// direct "drag to filter" feel. `onInput` updates the visual position on
// every drag frame; the URL/navigation only commits on `onChange` (fires
// once, on mouse-up/key-up), so dragging doesn't spam router.push.
export function PriceRangeFilter({
catalogMin,
catalogMax,
layout = "bar",
}: {
catalogMin: number;
catalogMax: number;
/** "bar" (default): horizontal row used above the grid at <lg (see
* ProductGrid.tsx's mobile/tablet filter bar). "sidebar": stacked to
* fit the lg:+ left sidebar column instead. */
layout?: "bar" | "sidebar";
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") ?? "");
const [maxPrice, setMaxPrice] = useState(searchParams.get("maxPrice") ?? "");
const paramMin = searchParams.get("minPrice");
const paramMax = searchParams.get("maxPrice");
const [minPrice, setMinPrice] = useState(paramMin ? Number(paramMin) : catalogMin);
const [maxPrice, setMaxPrice] = useState(paramMax ? Number(paramMax) : catalogMax);
const hasFilter = Boolean(searchParams.get("minPrice") || searchParams.get("maxPrice"));
const hasFilter = Boolean(paramMin || paramMax);
const sidebar = layout === "sidebar";
function apply(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (minPrice) params.set("minPrice", minPrice);
if (maxPrice) params.set("maxPrice", maxPrice);
function commit(nextMin: number, nextMax: number) {
const params = new URLSearchParams(searchParams.toString());
if (nextMin > catalogMin) params.set("minPrice", String(nextMin));
else params.delete("minPrice");
if (nextMax < catalogMax) params.set("maxPrice", String(nextMax));
else params.delete("maxPrice");
const qs = params.toString();
router.push(qs ? `/shop?${qs}` : "/shop");
}
function reset() {
setMinPrice("");
setMaxPrice("");
router.push("/shop");
setMinPrice(catalogMin);
setMaxPrice(catalogMax);
const params = new URLSearchParams(searchParams.toString());
params.delete("minPrice");
params.delete("maxPrice");
const qs = params.toString();
router.push(qs ? `/shop?${qs}` : "/shop");
}
// Clamped against each other while dragging — a thumb can't be pushed
// past its sibling, so the range never visually inverts.
function onMinInput(value: number) {
setMinPrice(Math.min(value, maxPrice));
}
function onMaxInput(value: number) {
setMaxPrice(Math.max(value, minPrice));
}
const range = catalogMax - catalogMin || 1;
const minPct = ((minPrice - catalogMin) / range) * 100;
const maxPct = ((maxPrice - catalogMin) / range) * 100;
const thumbClasses =
"absolute w-full m-0 appearance-none bg-transparent pointer-events-none " +
"[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-sm " +
"[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-brand [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer " +
"[&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:bg-transparent";
return (
<form onSubmit={apply} className="flex flex-wrap items-end gap-3 w-full pb-6">
<label className="flex flex-col gap-1">
<span className="text-label text-text-muted">Von</span>
<div className={sidebar ? "flex flex-col gap-3 items-stretch w-full" : "flex flex-col gap-2 w-full pb-6 sm:max-w-xs"}>
{sidebar && <p className="font-bold text-body-sm text-text-primary">Preis</p>}
<div className="flex items-center justify-between text-label text-text-muted">
<span>{formatPrice(minPrice)}</span>
<span>{formatPrice(maxPrice)}</span>
</div>
<div className="relative h-4 flex items-center">
<div className="absolute inset-x-0 h-1 rounded-full bg-bg-muted" />
<div className="absolute h-1 rounded-full bg-brand" style={{ left: `${minPct}%`, right: `${100 - maxPct}%` }} />
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMin}`}
type="range"
aria-label="Mindestpreis"
min={catalogMin}
max={catalogMax}
step="0.1"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className="w-24 border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors"
onInput={(e) => onMinInput(Number(e.currentTarget.value))}
onChange={() => commit(minPrice, maxPrice)}
className={thumbClasses}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-label text-text-muted">Bis</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMax}`}
type="range"
aria-label="Höchstpreis"
min={catalogMin}
max={catalogMax}
step="0.1"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="w-24 border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors"
onInput={(e) => onMaxInput(Number(e.currentTarget.value))}
onChange={() => commit(minPrice, maxPrice)}
className={thumbClasses}
/>
</label>
<span className="text-body-sm text-text-muted"></span>
<button
type="submit"
className="px-4 py-2 rounded-sm bg-brand text-body-sm font-bold text-text-primary hover:brightness-95 active:scale-[0.97] transition-all"
>
Anwenden
</button>
</div>
{hasFilter && (
<button
type="button"
onClick={reset}
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
className="self-start text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
>
Zurücksetzen
</button>
)}
</form>
</div>
);
}
+111 -112
View File
@@ -1,12 +1,8 @@
import Link from "next/link";
import Image from "next/image";
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled, getShopFilterEnabled } from "../../lib/payload";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { formatPrice, discountPercent } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
import { WishlistButton } from "../../components/WishlistButton";
import { ProductCard } from "../../components/ProductCard";
import { PriceRangeFilter } from "./PriceRangeFilter";
import { CategoryFilter } from "./CategoryFilter";
// Server Component — fetches straight from Payload (getProducts(), ISR
// cached 60s) rather than going through the client-side useProducts()
@@ -14,13 +10,20 @@ import { PriceRangeFilter } from "./PriceRangeFilter";
// there's no reason to pay for a client fetch when a server one already
// gives faster first paint and no loading flash.
// Products have no category taxonomy today (only Posts do) — a "N
// checkboxes" category sidebar isn't buildable against real data yet, so
// this only covers price, the one dimension that already exists on every
// product. A real min/max range (PriceRangeFilter.tsx), not preset toggle
// buckets — tried buckets-as-toggle-chips first, reverted 2026-07-30
// ("keine toggle badges").
export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?: string; maxPrice?: string } }) {
// Three filter dimensions: price (PriceRangeFilter.tsx, a real min/max
// slider — preset toggle buckets were tried first and reverted 2026-07-30,
// "keine toggle badges"), category (CategoryFilter.tsx, since
// `products.categories` now exists — same distinct-values-in-use pattern
// as the blog's category chips), and availability (in-stock only).
function isProductFullyOutOfStock(product: { outOfStock: boolean; variants: { outOfStock: boolean }[] }): boolean {
return product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
}
export async function ProductGrid({
searchParams,
}: {
searchParams?: { minPrice?: string; maxPrice?: string; categories?: string; inStock?: string };
}) {
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled, shopFilterEnabled] = await Promise.all([
getProducts(),
getShippingSettings(),
@@ -44,111 +47,107 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
const catalogMax = Math.max(...catalogPrices);
const minPrice = shopFilterEnabled && searchParams?.minPrice ? Number(searchParams.minPrice) : null;
const maxPrice = shopFilterEnabled && searchParams?.maxPrice ? Number(searchParams.maxPrice) : null;
const products = allActiveProducts.filter((p) => (minPrice === null || p.price >= minPrice) && (maxPrice === null || p.price <= maxPrice));
const allCategories = Array.from(new Set(allActiveProducts.flatMap((p) => p.categories))).sort((a, b) => a.localeCompare(b, "de"));
const activeCategories =
shopFilterEnabled && searchParams?.categories ? searchParams.categories.split(",").filter(Boolean) : [];
const inStockOnly = shopFilterEnabled && searchParams?.inStock === "1";
const anyOutOfStock = allActiveProducts.some(isProductFullyOutOfStock);
const products = allActiveProducts.filter((p) => {
if (minPrice !== null && p.price < minPrice) return false;
if (maxPrice !== null && p.price > maxPrice) return false;
// Uncategorized products (categories.length === 0) always match — see
// Product.categories' own comment in lib/payload.ts.
if (activeCategories.length > 0 && p.categories.length > 0 && !p.categories.some((c) => activeCategories.includes(c))) {
return false;
}
if (inStockOnly && isProductFullyOutOfStock(p)) return false;
return true;
});
const hasSidebarFilter = shopFilterEnabled && (catalogMin !== catalogMax || allCategories.length > 1 || anyOutOfStock);
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
{shopFilterEnabled && catalogMin !== catalogMax && <PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />}
{/* <lg: filters (if any) sit as their own bar above the grid same
as before. lg+: they move into a left sidebar instead (see aside
below), so this is hidden there to avoid rendering twice. */}
{hasSidebarFilter && (
<div className="lg:hidden flex flex-col gap-4 pb-10">
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />
<CategoryFilter categories={allCategories} />
</div>
)}
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
{/* lg:flex a real left sidebar only once there's an actual filter
to put in it (hasSidebarFilter); with no filter, the grid alone
fills the row exactly as before, no empty reserved column. */}
<div className={hasSidebarFilter ? "lg:flex lg:gap-10 w-full" : "w-full"}>
{hasSidebarFilter && (
<aside className="hidden lg:flex lg:flex-col lg:w-56 shrink-0 gap-6">
{/* Same label treatment as AccountNav.tsx's "Mein Konto"
heading this sidebar is visually modeled on that one. */}
<p className="text-label font-bold text-text-muted uppercase tracking-wide px-3 pb-2">Filter</p>
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} layout="sidebar" />
<CategoryFilter categories={allCategories} />
</aside>
)}
{/* 2-up from the mobile breakpoint (sm, 640px) through 1023px was
sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a
card through that tablet range. 4-up now only kicks in at lg
(1024px), true mobile (below sm) unchanged. */}
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
{products.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// A varianted product only reads as "ausverkauft" overall once
// every one of its variants is — a single sold-out variant just
// shows as such in the picker itself (AddToCartInlineButton),
// not as a blanket badge that would misleadingly suggest the
// whole product is unavailable while other variants still are.
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
// Mirrors fullyOutOfStock's "any vs. every" split — a varianted
// product reads as low-stock as soon as one variant is, since a
// shopper landing on the grid hasn't picked a variant yet.
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<RevealItem
key={product.id}
className="group lg:col-span-3 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
<div className="flex-1 min-w-0 flex flex-col">
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte für diese Filter gefunden.</p>}
{/* 2-up from the mobile breakpoint (sm, 640px) through 1023px was
sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a
card through that tablet range. lg+ is now 3-up (col-span-4 of
12) rather than 4-up narrowed to leave room for the sidebar
filter alongside it (see hasSidebarFilter above); with no
filter active the grid still renders at this same 3-up density,
simplest to keep one fixed lg: density rather than branching
the whole grid on hasSidebarFilter too. */}
{/* key'd on the rendered product set same fix, same reason as
blog/page.tsx's RevealGroup: `whileInView` only fires once per
component instance, and a filter change (price/category/
inStock) re-renders this same page in place rather than
remounting it, so RevealGroup itself never naturally unmounts
between filter states. Without the key, freshly swapped-in
RevealItems mounted into an already-settled parent and stayed
stuck at opacity 0 the grid going blank/white after
applying a filter. */}
{/* No items-start here (was set before, silently defeating
ProductCard's own h-full, built exactly for equal-height
cards) default items-stretch is what makes every card in a
row match the tallest one overall. That alone doesn't align
each card's internal rows (e.g. the price sitting right
after a subline that wraps to 2 lines on one card but 1 on
its neighbor) see ProductCard.tsx's own comment on the
subgrid rows (grid-auto-rows below) that solves that part:
each RevealItem here spans the same 6 row-tracks and passes
them down via grid-template-rows: subgrid, so row heights are
genuinely shared, no fixed line-clamp/truncation needed. */}
<RevealGroup
key={products.map((p) => p.id).join(",")}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full"
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{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" />
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
<div className="flex flex-col gap-1 items-start">
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
</div>
{/* Always rendered, text conditional not a conditional
block so this line's height (min-h as a cross-browser
safety net for the empty case) is identical whether or
not the product is low-stock. See AddToCartButton.tsx's
own comment: an earlier text-based low-stock hint here
broke equal card heights across the grid, which is why
it moved to the image-overlay pill in the first place. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
{product.href && (
<Link
href={product.href}
className="flex items-center gap-1 font-bold text-label text-text-primary hover:text-brand transition-colors"
>
<span>Mehr erfahren</span>
<span aria-hidden></span>
</Link>
)}
{/* flex-1 spacer pins every card's button to the same Y
regardless of the "Mehr erfahren" line only products with
a detail page have (see figma-to-nextjs skill's Tools.tsx
equal-height lesson). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
})}
</RevealGroup>
{products.map((product) => (
<RevealItem key={product.id} className="lg:col-span-4 grid [grid-template-rows:subgrid] [grid-row:span_6]">
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled={wishlistEnabled}
wishlistRevealOnHover
belowPrice={
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
}
/>
</RevealItem>
))}
</RevealGroup>
</div>
</div>
</section>
);
}

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