72 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Photos: product.image/gallery repointed (via direct DB write, see
reference_payload_direct_db_access) to two new Payload media docs
cropped directly from the mockup — the previous gallery was mismatched
supplier stock photos of a different-colored pen. Lifestyle.tsx's
desk photo is a static /public asset (page decoration showing the pen
next to another product, not a photo of the pen alone, so it doesn't
belong in Products.gallery).
2026-08-25 13:10:00 +00:00
Marco 4fa80d6bfb Allow bold formatting in the spotlight product's marketing text
Products.spotlightText switches from a plain textarea to a richText field
(Lexical, same default editor as every other richText field here) so
admins get a formatting toolbar. Rendered via a small dedicated inline
converter (bold + paragraphs only) rather than the full block-oriented
RichText.tsx, which is styled for article-length content (blog posts,
legal pages) and would add heading/paragraph spacing this teaser doesn't
want.
2026-08-25 12:50:00 +00:00
Marco eb160cac61 Preserve paragraph breaks in the spotlight product's text
spotlightText is a plain textarea, and default HTML whitespace handling
collapsed admin-entered paragraph breaks into one run-on line.
2026-08-25 12:36:07 +00:00
Marco 13fb37eb5e Rename Der Alltagsstift back to Der Eine., move route to /der-eine
New copy throughout (hero, "Was kann er?", the "Warum Der Eine.?" brand-story
section replacing the old phone-vs-pen scenario grid, final CTA) plus the
Payload product's name/detailHref updated to match (done directly via psql,
no Payload admin API key available — see reference_payload_direct_db_access).
2026-08-25 12:36:03 +00:00
48 changed files with 2015 additions and 554 deletions
+30
View File
@@ -1903,6 +1903,36 @@ one; a second `google-maps` row would collide on that name. To actually
use it: `<GoogleMapsEmbed src="<Google Maps embed URL>" title="..." />`
anywhere.
## Short links & QR redirects
`app/r/[code]/route.ts` and `app/sticker/[code]/route.ts` are static
short-link routes (e.g. printed on a flyer or a physical sticker's QR
code) that resolve a `code` against Payload's `redirects` collection and
307-redirect (`redirect()`, deliberately not `permanentRedirect()` — the
target can change at any time and this must never be client-cached) to
that doc's `targetPath`. Both routes call the same
`resolveAndTrackRedirect(code, urlPrefix)` in `app/lib/payload.ts`, which
also fire-and-forget PATCHes `clickCount`/`lastClickedAt` on every hit
(tracking failure only logs — never worth stranding a visitor over).
- **`/r/[code]`** is the default scheme for all new short links —
`einfach-produktiv.com/r/aktion-sommer`.
- **`/sticker/[code]`** exists only because a batch of physical stickers
was already printed as `einfach-produktiv.com/sticker/<code>` (`echt`,
`geheimnis`, `fokus`) before `/r/[code]` existed and can't be
reprinted — same collection, same lookup, just a second fixed prefix.
- The Redirects doc's `urlPrefix` field (`/r` or `/sticker`) isn't just an
admin label — `resolveAndTrackRedirect` filters on it too, so a code is
only reachable under whichever prefix its doc is actually set to.
Changing `urlPrefix` in Payload immediately changes which route serves
that code.
- Defense in depth: `targetPath` must start with `/` and not `//` (open-
redirect guard, same check `app/api/preview/route.ts` does), enforced
both in Payload's own field validation and again in each route.ts before
calling `redirect()`.
- A deactivated (`active: false`) doc — or no matching doc at all —
renders a plain 404 (`notFound()`), not a redirect to some fallback.
## Product image gallery
`ProductGallery.tsx` — main image + thumbnail strip, swappable on click OR
@@ -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 />
</>
);
}
+1 -1
View File
@@ -162,7 +162,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
+22 -44
View File
@@ -57,46 +57,27 @@ export default async function BlogOverviewPage({
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]">
{/* Breadcrumb + text-h-feature heading same treatment as
/shop's ShopHeader.tsx and every legal page, this hero was
the one page missing both (its own bespoke photo-bleed
layout predates that convention being established
elsewhere). */}
<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>
{/* Moved into the hero's own text column (was a separate full-
width bar below the entire hero, including the photo) on
mobile especially (text stacks above the photo here), that
pushed the filters below a full extra screen's worth of
hero image before they were even visible. Living right
under the description keeps them in view immediately, on
every width, no scrolling past the photo needed. */}
{allCategories.length > 1 && (
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
)}
</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>
{posts.length === 0 && (
@@ -106,14 +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.
// 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="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
<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"
+4 -3
View File
@@ -14,6 +14,7 @@ import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { VatBreakdown } from "../../components/VatBreakdown";
import { ArrowLeftIcon } from "../../components/ArrowLeftIcon";
import { ProductName } from "../../components/ProductName";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -260,7 +261,7 @@ export function CartContent({
className="font-semibold text-h-small text-text-primary hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
<ProductName name={product.name} />
{entry.variant ? ` (${entry.variant})` : ""}
</Link>
) : (
@@ -268,15 +269,15 @@ export function CartContent({
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
<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">
+8 -2
View File
@@ -129,7 +129,13 @@ export function RelatedProducts({
(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 items-start grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{/* 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}
@@ -139,7 +145,7 @@ export function RelatedProducts({
wishlistEnabled={wishlistEnabled}
wishlistRevealOnHover
className={
"sm:col-span-4 " +
"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
+102 -84
View File
@@ -32,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"
>
<ArrowRightIcon />
<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
@@ -83,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"
>
<ArrowRightIcon />
<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>
+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)
+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>
</>
);
}
+6 -3
View File
@@ -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 = ["/todo-cards", "/7-tage-klarheits-check", "/newsletter", "/der-eine", "/tasse-die-pause", "/lebensuhr", "/3x3-system"];
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
if (href === "#werkzeuge") {
@@ -521,7 +524,7 @@ export function Navbar({
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"
+227
View File
@@ -0,0 +1,227 @@
import Image from "next/image";
import Link from "next/link";
import type { PageBlock } from "../lib/payload";
import { getTestimonials } from "../lib/payload";
import { RichText } from "./RichText";
import { Quote } from "./Quote";
import { StepArrow } from "./StepArrow";
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
import { TestimonialsGrid } from "./TestimonialsGrid";
// Renders a Payload Pages document's `layout` blocks field — structurally
// parallel to RichText.tsx's own `blocks: {...}` converter map, but one
// level up (full page sections, not inline Lexical nodes). Each block
// component reuses the exact same visual treatment already hand-built on
// /lebensuhr, /3x3-system, and /7-tage-klarheits-check, rather than
// inventing new styling — this is what those pages' repeated patterns get
// consolidated into for CMS-driven pages going forward.
export async function PageBlocks({ blocks }: { blocks: PageBlock[] }) {
const rendered = await Promise.all(
blocks.map(async (block) => {
if (block.blockType === "testimonialsRef") {
const testimonials = await getTestimonials(block.page);
if (testimonials.length === 0) return null;
return <TestimonialsGrid key={block.id} testimonials={testimonials} />;
}
return renderPageBlockSync(block);
})
);
return <>{rendered}</>;
}
// Every block EXCEPT testimonialsRef (needs its own async data fetch,
// which a client component can't await — see LivePageContent.tsx, which
// uses this directly and simply skips that one block type in preview,
// same "live-previewable subset" scope-cut LivePostContent.tsx already
// makes for its own author-bio/"Weiterlesen" chrome).
export function renderPageBlockSync(block: PageBlock): React.ReactNode {
switch (block.blockType) {
// Same "font-playfair clamp headline" treatment every hand-built page
// (/lebensuhr, /3x3-system, /ueber-mich) used for its own H1 before
// this block existed — see HeroBlock.ts's own comment on why the
// headline moved out of Pages.title and into here.
case "hero": {
const headline = (
<p
className={
block.image
? "font-semibold text-[clamp(2rem,1.393rem+1.5vw,2.75rem)] text-text-primary leading-[1.15]"
: "font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
}
style={{ fontFamily: "var(--font-playfair)" }}
>
{block.headline}
</p>
);
return (
<div key={block.id} className="flex flex-col gap-4 items-start">
{/* Optional round portrait beside the headline e.g. /ueber-mich's
"Hallo, ich bin Björn." size-20 (80px) read as barely-there/
easy to miss next to a full headline bumped to size-32
(128px) so it actually registers as a real photo, not a
small decorative blob. */}
{block.image ? (
<div className="flex items-center gap-6">
<div className="relative size-32 shrink-0 rounded-full overflow-hidden ring-1 ring-border">
<Image alt="" src={block.image} fill sizes="128px" className="object-cover" />
</div>
{headline}
</div>
) : (
headline
)}
{/* text-primary + text-h-emphasis, not text-muted body same
weight class as the homepage Hero's own subheading
("Ich helfe dir..."), which a plain muted text-body read too
quiet next to. */}
{block.subline && (
<p className="font-semibold text-h-emphasis text-text-primary leading-[1.4]">{block.subline}</p>
)}
{block.ctaLabel && block.ctaHref && (
<Link
href={block.ctaHref}
className="inline-flex items-center gap-2 bg-brand hover:brightness-95 active:scale-[0.97] transition-all rounded-sm px-6 py-3 font-semibold text-body text-text-primary"
>
{block.ctaLabel}
</Link>
)}
</div>
);
}
case "richTextSection":
return <RichText key={block.id} content={block.content} />;
case "quote":
return <Quote key={block.id} label={block.label}>{block.text}</Quote>;
case "icon": {
const SymbolIcon = SYMBOLIC_ICONS[block.icon];
return SymbolIcon ? <div key={block.id}>{SymbolIcon()}</div> : null;
}
case "image":
if (!block.image) return null;
return (
<div key={block.id} className="flex flex-col gap-2 w-full">
<div className="relative w-full aspect-[3/2] rounded-xl overflow-hidden bg-bg-muted">
<Image alt={block.caption ?? ""} src={block.image} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-contain" />
</div>
{block.caption && <p className="text-body-sm text-text-muted text-center">{block.caption}</p>}
</div>
);
case "pillList":
return (
<div key={block.id} className="flex flex-wrap gap-2">
{block.items.map((item) => (
<span key={item.id} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">{item.label}</span>
))}
</div>
);
case "checklistImage":
return (
<div key={block.id} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
{block.image && (
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
<Image alt="" src={block.image} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
</div>
)}
<ul className="flex-1 w-full flex flex-col gap-4">
{block.items.map((item) => (
<li key={item.id} className="flex items-start gap-3">
<CheckIcon />
<p className="text-body text-text-body">{item.text}</p>
</li>
))}
</ul>
</div>
);
case "table":
return (
<div key={block.id} className="bg-bg-muted rounded-xl overflow-hidden w-full">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b-2 border-brand">
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.labelHeader}</th>
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.valueHeader}</th>
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={row.id} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
case "stepRow": {
const icons = block.items.map((item) => STEP_ICONS[item.icon]);
return (
<div key={block.id} className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
{block.items.flatMap((item, i) => [
<div key={item.id} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
{icons[i]?.()}
</div>
<div className="flex flex-col gap-1 text-center">
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
</div>
</div>,
i < block.items.length - 1 ? (
<div key={`arrow-${item.id}`} className="flex items-center justify-center shrink-0 lg:mt-5">
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
</div>
) : null,
])}
</div>
);
}
case "testimonialsRef":
// Needs an async fetch — handled by PageBlocks (server) above.
// Skipped in the client-side live-preview renderer.
return null;
case "ctaCard":
return (
<Link
key={block.id}
href={block.href}
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
>
<div className="flex flex-col gap-1">
<p className="font-bold text-[0.8125rem] text-brand">{block.eyebrow}</p>
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{block.title}</p>
{block.description && <p className="text-body-sm text-text-muted">{block.description}</p>}
</div>
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
);
default:
// Unknown/malformed block — same "skip rather than crash" convention
// RichText.tsx's own blocks use.
return null;
}
}
// Same checkmark used by /lebensuhr's and /7-tage-klarheits-check's own
// checklists.
function CheckIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+251
View File
@@ -0,0 +1,251 @@
import Link from "next/link";
import Image from "next/image";
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
import { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getTestimonials } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
import { AddToCartButton } from "./AddToCartButton";
import { ProductName } from "./ProductName";
import { RichText } from "./RichText";
import { Reveal } from "./Reveal";
import { renderPageBlockSync } from "./PageBlocks";
import { TestimonialsGrid } from "./TestimonialsGrid";
// Renders a Payload Products document's `layout` blocks field — same
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
// this delegates to directly for every block type the two fields share
// (richTextSection/stepRow/quote/image/icon/pillList/checklistImage/table/
// ctaCard are the exact same Block configs, just registered a second time
// on Products.layout — see that field's own comment in Products.ts). Only
// `productHero` and `productPricingPanel` are product-specific, since they
// need the live product/shipping/tax context that a generic content page
// doesn't have.
export async function ProductBlocks({ product }: { product: Product }) {
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const ctx: ProductBlockContext = { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
const rendered = await Promise.all(
product.layout.map(async (block) => {
// Needs its own async data fetch — same reason PageBlocks.tsx
// handles this one block type in its own outer async map instead of
// the sync switch below (renderProductBlock/renderPageBlockSync).
if (block.blockType === "testimonialsRef") {
const testimonials = await getTestimonials(block.page);
if (testimonials.length === 0) return null;
return (
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-6">
<TestimonialsGrid testimonials={testimonials} />
</div>
);
}
// productHero/productPricingPanel render their own full section
// (edge-to-edge image, own Reveal, own padding rhythm) — matching
// the hand-coded PDPs (todo-cards etc.) precisely needed each of
// those, unlike a generic content block, so they opt out of this
// shared wrapper entirely rather than fighting it.
if (block.blockType === "productHero" || block.blockType === "productPricingPanel") {
return <div key={block.id}>{renderProductBlock(block, ctx)}</div>;
}
return (
<Reveal key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12">
{renderProductBlock(block, ctx)}
</Reveal>
);
})
);
return <>{rendered}</>;
}
type ProductBlockContext = {
product: Product;
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
taxRate: number;
kleinunternehmer: boolean;
};
function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
switch (block.blockType) {
case "productHero":
return <ProductHero {...ctx} body={block.body} />;
case "productPricingPanel":
return <ProductPricingPanel {...ctx} />;
// Every other block type is identical to Pages.layout's own — same
// Block config, reused a second time (see this file's own comment).
default:
return renderPageBlockSync(block as PageBlock);
}
}
// Matches der-eine/todo-cards' own hand-coded Hero.tsx exactly — same
// section/grid/Reveal/padding structure, same edge-to-edge image with
// hover-scale — so a block-driven PDP hero doesn't visually stand apart
// from its hand-coded siblings. Only the body prose differs: it's this
// block's own `body` richText instead of hardcoded JSX.
function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: ProductBlockContext & { body: unknown }) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<section className="bg-bg-base w-full overflow-hidden">
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">
Startseite
</Link>
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">
<ProductName name={product.name} />
</span>
</p>
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
<div className="flex flex-col gap-2 items-start w-full">
<p className="font-semibold text-h-page text-text-primary" style={{ fontFamily: "var(--font-playfair)" }}>
<ProductName name={product.name} />
</p>
{product.subline && (
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
{product.subline}
</p>
)}
</div>
{body ? <RichText content={body} /> : null}
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
</div>
<AddToCartButton
label="In den Warenkorb"
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
<Reveal
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
delay={0.15}
>
<Image
src={product.image}
alt={product.name}
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
</div>
</section>
);
}
// Matches todo-cards' own hand-coded Pricing.tsx exactly — see that
// file's own comment on why this closing panel looks the way it does.
function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: ProductBlockContext) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 410px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
<ProductName name={product.name} />
</p>
{product.subline && <p className="text-body-sm text-text-primary">{product.subline}</p>}
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<AddToCartButton
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
</section>
);
}
+52 -42
View File
@@ -5,6 +5,7 @@ import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
import { AddToCartInlineButton } from "./AddToCartInlineButton";
import { WishlistButton } from "./WishlistButton";
import { ProductName } from "./ProductName";
import type { Product } from "../lib/payload";
// Single shared card markup for every product grid (ProductGrid,
@@ -53,8 +54,20 @@ export function ProductCard({
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 flex flex-col h-full transition-transform duration-300 hover:-translate-y-1 ${className}`}
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
@@ -97,49 +110,46 @@ export function ProductCard({
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
)}
</div>
<div className="flex flex-col gap-3 items-start px-5 pb-5 pt-4 w-full flex-1">
<div className="flex flex-col gap-1 items-start w-full">
{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)" }}
>
{product.name}
</Link>
) : (
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
)}
</div>
<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>
{belowPrice}
{/* Always rendered, text conditional reserved height keeps every
card in a row equal height regardless of low-stock status. An
earlier version omitted this for fully-out-of-stock products
(this line can never apply there), on the theory that it was
dead space but NotifyMeForm's single-row redesign (see its
own comment) now matches AddToCartInlineButton's button height
exactly, which made THIS line the only remaining source of a
mismatch: omitting it made the sold-out card shorter than its
siblings instead of taller. Keeping it unconditional is what
actually gets an exact match now. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
{/* flex-1 spacer pins every card's button to the same Y
regardless of whether the title/category line wraps. */}
<div className="flex-1" />
<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}
+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>
</>
);
}
+49 -3
View File
@@ -1,3 +1,4 @@
import { Fragment, type ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
@@ -7,6 +8,53 @@ import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, get
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),
@@ -86,9 +134,7 @@ export async function ProductSpotlight() {
>
{product.spotlightHeadline || product.name}
</p>
<p className="text-body text-text-body">
{product.spotlightText || product.description}
</p>
<SpotlightText content={product.spotlightText} fallback={product.descriptionText} />
{/* MwSt./Versand disclosure on its own line, not crammed into
the price row itself same reasoning as todo-cards'
Pricing.tsx (identical text, same narrow-column risk). */}
+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>
);
}
+135
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.
@@ -250,6 +270,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>
);
},
},
});
}
+159
View File
@@ -0,0 +1,159 @@
// Shared registry of the site's hand-drawn brand-line icons, used by
// StepArrow-connected icon rows (/lebensuhr's phase row, /3x3-system's
// category row, /7-tage-klarheits-check's "So funktioniert"). Previously
// each page defined its own copy of these locally — consolidated here so
// the Payload page-builder's `stepRow` block can select one by a fixed
// key (see PageBlocks.tsx), and so the icons aren't duplicated per page
// anymore. The Payload StepRowBlock's `icon` select options MUST stay in
// sync with STEP_ICON_KEYS below by hand — no automatic sync between the
// two repos.
function IconSunrise() {
return (
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
<line x1="4" y1="30" x2="40" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<path d="M13 30a9 9 0 0 1 18 0" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="22" y1="4" x2="22" y2="10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="10" x2="13" y2="14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="35" y1="10" x2="31" y2="14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconGrowthBars() {
return (
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
<line x1="4" y1="36" x2="40" y2="36" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<rect x="7" y="25" width="7" height="11" rx="1" stroke="#f6a701" strokeWidth="2" />
<rect x="18.5" y="16" width="7" height="20" rx="1" stroke="#f6a701" strokeWidth="2" />
<rect x="30" y="6" width="7" height="30" rx="1" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconSunHigh() {
return (
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="8" stroke="#f6a701" strokeWidth="2" />
<line x1="20" y1="2" x2="20" y2="7" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="20" y1="33" x2="20" y2="38" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="20" x2="7" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="33" y1="20" x2="38" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="7" y1="7" x2="10.5" y2="10.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="29.5" y1="29.5" x2="33" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="33" y1="7" x2="29.5" y2="10.5" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="10.5" y1="29.5" x2="7" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconEnvelope() {
return (
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
<path d="M2 2l24 22L50 2" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconMailLines() {
return (
<svg width="56" height="44" viewBox="0 0 56 44" fill="none">
<line x1="2" y1="12" x2="12" y2="12" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="20" x2="9" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="28" x2="7" y2="28" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<rect x="13" y="2" width="41" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
<path d="M13 2l20.5 19L54 2" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconNotepad() {
return (
<svg width="46" height="52" viewBox="0 0 46 52" fill="none">
<rect x="2" y="4" width="32" height="42" rx="2" stroke="#f6a701" strokeWidth="2" />
<line x1="9" y1="16" x2="27" y2="16" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="23" x2="24" y2="23" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="30" x2="20" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<circle cx="37" cy="40" r="7" stroke="#f6a701" strokeWidth="2" />
<line x1="37" y1="27" x2="37" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconCheckCircle() {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
<path d="M14 24l7.5 7.5L34 16" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconTarget() {
return (
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="16" stroke="#f6a701" strokeWidth="2" />
<circle cx="20" cy="20" r="9" stroke="#f6a701" strokeWidth="2" />
<circle cx="20" cy="20" r="2" fill="#f6a701" />
</svg>
);
}
function IconTrendingUp() {
return (
<svg width="44" height="40" viewBox="0 0 44 40" fill="none">
<path d="M4 30l10-10 6 6 14-14" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M24 10h10v10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconShield() {
return (
<svg width="36" height="40" viewBox="0 0 36 40" fill="none">
<path d="M18 3l14 5v9c0 9-6 16-14 20-8-4-14-11-14-20v-9l14-5z" stroke="#f6a701" strokeWidth="2" strokeLinejoin="round" />
</svg>
);
}
// Keys MUST match the Payload StepRowBlock's `icon` select field options
// (docker/payload/src/blocks/StepRowBlock.ts) exactly.
export const STEP_ICONS: Record<string, () => React.ReactNode> = {
sunrise: IconSunrise,
growthBars: IconGrowthBars,
sunHigh: IconSunHigh,
envelope: IconEnvelope,
mailLines: IconMailLines,
notepad: IconNotepad,
checkCircle: IconCheckCircle,
target: IconTarget,
trendingUp: IconTrendingUp,
shield: IconShield,
};
// Same symbolic clock icon /lebensuhr uses above its "24 Stunden" heading
// — purely decorative (no numbers/data), unlike the STEP_ICONS above which
// always sit inside an icon-above-text row. Keys MUST match the Payload
// IconBlock's `icon` select field options (docker/payload/src/blocks/
// IconBlock.ts) exactly.
function IconClockSymbol() {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg) => {
const rad = (deg * Math.PI) / 180;
const x1 = 24 + 16 * Math.sin(rad), y1 = 24 - 16 * Math.cos(rad);
const x2 = 24 + 18.5 * Math.sin(rad), y2 = 24 - 18.5 * Math.cos(rad);
return <line key={deg} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#f6a701" strokeWidth="1.5" strokeLinecap="round" />;
})}
<line x1="24" y1="24" x2="17" y2="15" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<line x1="24" y1="24" x2="32" y2="15" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
<circle cx="24" cy="24" r="1.8" fill="#f6a701" />
</svg>
);
}
export const SYMBOLIC_ICONS: Record<string, () => React.ReactNode> = {
clockSymbol: IconClockSymbol,
};
-75
View File
@@ -1,75 +0,0 @@
import Image from "next/image";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug } from "../../lib/payload";
// Only visually-verifiable claims (Metallkorpus/Chromringe from the
// product photos) — no engraving/color claim, both still unconfirmed
// (see plan notes: Farbvariante/Gravur-Merkmal open as of 2026-08-24).
const bullets = [
"Korpus aus Metall, kein Plastik",
"Liegt gut in der Hand",
"Passt in jede Tasche",
];
// Same fix/reasoning as Hero.tsx's own IconCheck.
function IconCheck() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// Unlike todo-cards/components/Focus.tsx, this product has no dedicated
// lifestyle photo yet — only 3 supplier catalog macro shots exist, all
// 338×338px (no larger source, confirmed against the Payload media API).
// Rather than object-cover-stretching a small square into a wide rectangle
// (blurs badly, and is exactly what Pricing.tsx's photo slot already does —
// see plan notes on the "einheitlich" feedback), this uses a contained,
// padded square tile so the source resolution reads as a deliberate macro
// crop instead of a blown-up thumbnail. Uses gallery[1] (the chrome-rings
// close-up) — deliberately not gallery[0], which shows an assortment
// including a black pen and would imply a color variant that isn't
// confirmed yet (see Focus's bullets comment).
export async function Focus() {
const product = await getProductBySlug("stift-kugelschreiber");
if (!product) return null;
const photo = product.gallery[1] ?? product.image;
return (
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
{/* Fixed max-width, not a viewport-percentage width same reasoning
as Hero.tsx's gallery cap: the source is only 338×338px, and a
percentage width grows past that on wide screens, exaggerating
the upscale blur. */}
<Reveal className="group relative w-full max-w-[20rem] lg:shrink-0 aspect-square rounded-md overflow-hidden bg-bg-muted p-10">
<Image
src={photo}
alt={product.name}
fill
sizes="320px"
className="object-contain transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
<Reveal className="flex flex-col gap-6 items-start flex-1 min-w-0 w-full" delay={0.1}>
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Ein Stift, der bleibt
</p>
<p className="text-body text-text-body">
Die meisten Kugelschreiber landen irgendwann in einer Schublade und werden nicht wieder rausgeholt. Der Alltagsstift soll man behalten.
</p>
<ul className="flex flex-col gap-3 items-start w-full">
{bullets.map((b) => (
<li key={b} className="flex gap-[0.625rem] items-start w-full">
<IconCheck />
<span className="flex-1 font-semibold text-body text-text-primary">{b}</span>
</li>
))}
</ul>
</Reveal>
</section>
);
}
@@ -1,72 +0,0 @@
import { Fragment } from "react";
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
import { StepArrow } from "../../components/StepArrow";
// Same 3 generic step icons as todo-cards/components/HowItWorks.tsx — their
// artwork (pen tip, checklist, arrow) reads generically enough to carry a
// different 3-step flow without looking mismatched.
const steps = [
{
icon: "/icon-step-1.png",
width: 165,
height: 177,
title: "1. Griffbereit halten",
desc: "Der Alltagsstift liegt dort, wo du ihn brauchst Tasche, Schreibtisch, Notizbuch.",
},
{
icon: "/icon-step-2.png",
width: 180,
height: 168,
title: "2. Sofort notieren",
desc: "Du musst nicht erst dein Handy entsperren oder eine App öffnen.",
},
{
icon: "/icon-step-3.png",
width: 180,
height: 177,
title: "3. Weitermachen",
desc: "Notiert ist notiert, du machst einfach da weiter, wo du warst.",
},
];
export function HowItWorks() {
return (
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 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 einfach ist er im Alltag
</p>
<p className="text-body text-text-muted">
Du nimmst ihn, schreibst, steckst ihn wieder ein. Mehr braucht es nicht.
</p>
</Reveal>
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[16.25rem]">
{steps.map((step, i) => (
<Fragment key={step.title}>
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
<Image
src={step.icon}
alt=""
width={step.width}
height={step.height}
className="h-16 w-auto object-contain transition-transform duration-300 group-hover:scale-110"
/>
<p className="font-semibold text-body text-text-primary">{step.title}</p>
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
</RevealItem>
{i < steps.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>
);
}
-54
View File
@@ -1,54 +0,0 @@
import type { Metadata } from "next";
import { Hero } from "./components/Hero";
import { HowItWorks } from "./components/HowItWorks";
import { Focus } from "./components/Focus";
import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
import { getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
const title = "Der Alltagsstift Ein Stift, der einfach da ist, wenn du ihn brauchst.";
const description =
"Der Kugelschreiber für die eine Sache, die heute zählt. Schlicht, hochwertig und immer griffbereit.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/der-alltagsstift",
},
openGraph: {
title,
description,
url: "/der-alltagsstift",
},
twitter: {
title,
description,
},
};
export default async function DerAlltagsstiftPage() {
const [product, seller] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getCompanySettings(),
]);
const productSchema = product
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-alltagsstift", seller)
: null;
return (
<>
{productSchema && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
)}
<main className="flex flex-col flex-1">
<Hero />
<HowItWorks />
<Focus />
<Pricing />
</main>
<Footer />
</>
);
}
+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>
);
}
@@ -2,15 +2,17 @@ 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";
const checklist = [
"Funktioniert ohne Strom oder Internet",
"Aus Metall, kein Einwegstift",
"Passt in jede Tasche",
];
// 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
@@ -51,7 +53,7 @@ export async function Hero() {
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">Der Alltagsstift</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">
@@ -60,22 +62,24 @@ export async function Hero() {
className="font-semibold text-h-page text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
Der Alltagsstift<span className="text-brand">.</span>
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Ein Stift, der einfach da ist, wenn du ihn brauchst.
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">
Der Kugelschreiber für die eine Sache, die heute zählt. Schlicht, hochwertig und immer griffbereit.
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">
{checklist.map((item) => (
{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>
@@ -109,7 +113,8 @@ export async function Hero() {
{product && (
<AddToCartButton
label="Alltagsstift bestellen"
label="Der Eine. bestellen"
productId={product.id}
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
@@ -127,7 +132,7 @@ export async function Hero() {
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" delay={0.15}>
<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>
+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>
);
}
@@ -5,17 +5,11 @@ import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKle
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
// Only visually-verifiable claims — same reasoning as Focus.tsx's bullets
// (no engraving/color claim, both unconfirmed as of 2026-08-24).
const bullets = [
"Kugelschreiber aus Metall",
"Chromfarbene Details",
"Handlich, für jede Tasche",
];
// Price/photo come from Payload (same "stift-kugelschreiber" product the
// shop/cart use), not a hardcoded literal — same reasoning as
// todo-cards/components/Pricing.tsx.
// 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"),
@@ -33,22 +27,8 @@ export async function Pricing() {
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">
{/* object-contain on a bg-bg-base tile, not object-cover the
source is a 338×338px supplier macro shot (no larger original
exists), so stretching it across a 410×227 landscape crop like
before blows it up and blurs it. A contained square reads as a
deliberate close-up instead. Uses product.image (id 85, the
confident macro tip shot) distinct from Focus.tsx's photo
(gallery[1], the chrome-rings shot) so the two sections don't
repeat the same frame. */}
<div className="group relative w-full lg:w-[15.625rem] lg:shrink-0 aspect-square rounded-sm overflow-hidden bg-bg-base">
<Image
src={product.image}
alt="Der Alltagsstift"
fill
sizes="(min-width: 1024px) 250px, 100vw"
className="object-contain p-4 transition-transform duration-500 group-hover:scale-105"
/>
<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
@@ -67,16 +47,11 @@ export async function Pricing() {
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Der Alltagsstift
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>
<ul className="flex flex-col gap-[0.375rem] items-start">
{bullets.map((b) => (
<li key={b} className="flex gap-2 items-center">
<Image alt="" src="/icon-bullet-dot.svg" width={4} height={4} className="size-1 shrink-0" />
<span className="text-body-sm text-text-primary">{b}</span>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
@@ -102,8 +77,9 @@ export async function Pricing() {
</div>
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<AddToCartButton
label="In den Warenkorb"
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}
+18
View File
@@ -0,0 +1,18 @@
import { draftMode } from "next/headers";
import { TestimonialsGrid } from "../../components/TestimonialsGrid";
import { LiveTestimonialsGrid } from "../../components/LiveTestimonialsGrid";
import { getTestimonials } from "../../lib/payload";
// Payload-driven like todo-cards/newsletter/challenge's own testimonial
// grids (see Testimonials collection's "page" field) — no longer a
// hardcoded array now that "der-eine" is a valid page value there.
export async function Testimonials() {
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("der-eine", { draft: isPreview });
return isPreview ? (
<LiveTestimonialsGrid testimonials={testimonials} />
) : (
<TestimonialsGrid testimonials={testimonials} />
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { Hero } from "./components/Hero";
import { Focus } from "./components/Focus";
import { PasstDazu } from "./components/PasstDazu";
import { Testimonials } from "./components/Testimonials";
import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
import { getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
const title = "Der Eine. Für die eine Sache, die gerade zählt.";
// Description reads from the live product instead of a separately
// hardcoded string, unlike this page's own `title` — matches
// /tasse-die-pause's own pattern (see that page's comment). Two
// hand-maintained copies of the same text used to drift apart: this one
// used to say something different from Products.description (which feeds
// the cart/checkout/JSON-LD elsewhere) — same field, one source now.
export async function generateMetadata(): Promise<Metadata> {
const product = await getProductBySlug("stift-kugelschreiber");
const description = product?.descriptionText || undefined;
return {
title,
description,
alternates: { canonical: "/der-eine" },
openGraph: { title, description, url: "/der-eine" },
twitter: { title, description },
};
}
export default async function DerEinePage() {
const [product, seller] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getCompanySettings(),
]);
const productSchema = product
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-eine", seller)
: null;
return (
<>
{productSchema && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
)}
<main className="flex flex-col flex-1">
<Hero />
<Focus />
<Testimonials />
<Pricing />
<PasstDazu productSlug="stift-kugelschreiber" />
</main>
<Footer />
</>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function DieSiebenHero() {
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
Das Monatsritual von einfach produktiv.
</p>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@ 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 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.";
+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 />
</>
);
}
@@ -39,15 +39,18 @@ export function MerklisteGrid({
}
return (
<RevealGroup className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
// 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}`}>
<RevealItem key={`${product.id}-${item.variant}`} className="grid [grid-template-rows:subgrid] [grid-row:span_6]">
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled
className="h-full"
// 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
+6 -1
View File
@@ -6,7 +6,11 @@ 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: "",
@@ -27,6 +31,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
taxRatePercent: null,
noShippingCost: false,
categories: [],
relatedProduct: null,
...overrides,
});
+271 -19
View File
@@ -181,7 +181,19 @@ 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;
@@ -203,7 +215,13 @@ 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,
@@ -245,13 +263,28 @@ export type Product = {
// 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[];
};
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;
@@ -262,7 +295,7 @@ type PayloadProduct = {
spotlight: boolean;
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightText: unknown | null;
spotlightImage: { url: string } | number | null;
spotlightShowWishlist: boolean;
trackInventory: boolean;
@@ -282,6 +315,7 @@ type PayloadProduct = {
}[]
| null;
categories: ({ name: string } | number)[] | null;
relatedProduct: PayloadProduct | number | null;
};
// A product/variant is only actually unbuyable when it opted into
@@ -307,16 +341,36 @@ 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 : "",
@@ -348,6 +402,11 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
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),
};
}
@@ -800,7 +859,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 };
@@ -825,10 +884,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,
@@ -1192,20 +1251,27 @@ export async function getSeoSettings(): Promise<SeoSettings> {
};
}
// Powers app/r/[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.
// 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 };
// PATCH failure only logs — click tracking is informational, never worth
// stranding a visitor on a broken link over.
export async function resolveAndTrackRedirect(code: string): Promise<string | null> {
// `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",
});
@@ -1266,3 +1332,189 @@ export async function getTrackingCodes(): Promise<TrackingCode[]> {
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 };
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 };
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 })),
};
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 };
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 };
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,
};
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);
}
+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." },
};
}
+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>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { resolveAndTrackRedirect } from "../../lib/payload";
// 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);
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.
+13 -3
View File
@@ -114,12 +114,23 @@ export async function ProductGrid({
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 items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full"
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"
>
{products.map((product) => (
<RevealItem key={product.id} className="lg:col-span-4">
<RevealItem key={product.id} className="lg:col-span-4 grid [grid-template-rows:subgrid] [grid-row:span_6]">
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
@@ -131,7 +142,6 @@ export async function ProductGrid({
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
}
className="h-full"
/>
</RevealItem>
))}
+1 -1
View File
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
alternates: { canonical: "/shop" },
openGraph: {
title: "Shop | einfach produktiv",
title: "Shop | einfach produktiv.",
description:
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
url: "/shop",
+20
View File
@@ -0,0 +1,20 @@
import { redirect, notFound } from "next/navigation";
import { NextRequest } from "next/server";
import { resolveAndTrackRedirect } from "../../lib/payload";
// Legacy sticker QR codes already printed as https://einfach-produktiv.com/sticker/<code>
// before the /r/[code] short-link scheme existed — can't be reprinted, so
// this mirrors app/r/[code]/route.ts's exact lookup (same Redirects
// collection, matched by `code` + urlPrefix "/sticker") under the fixed
// /sticker prefix instead. Keeps a single admin-editable/toggleable source
// of truth for both.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ code: string }> }) {
const { code } = await params;
const targetPath = await resolveAndTrackRedirect(code, "/sticker");
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
notFound();
}
redirect(targetPath);
}
+59
View File
@@ -0,0 +1,59 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { TrustRow } from "../components/TrustRow";
// Placeholder landing page for the /sticker/[code] QR redirects (see that
// route) — targetPath for those Redirects docs currently points here until
// a dedicated campaign page exists; swap targetPath in Payload once it does.
export const metadata: Metadata = {
title: "Schön, dass du da bist",
description: "Du hast einen einfach produktiv Sticker gescannt.",
};
export default function StickerPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-4 items-center text-center pt-24 pb-16 px-[var(--layout-padding-x)] w-full">
<div className="flex items-center justify-center size-14 rounded-full bg-brand/10 text-brand shrink-0">
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
<path d="M9 3.5h6a1.5 1.5 0 0 1 1.5 1.5v14.5L12 16l-4.5 3.5V5A1.5 1.5 0 0 1 9 3.5Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
</div>
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
Schön, dass du da bist!
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Du hast einen unserer Sticker entdeckt.
</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">
<p>Hallo,</p>
<p>
schön, dass dich der Sticker hierher geführt hat. Schau dich gerne um hier
findest du alles, was einfach produktiv ausmacht.
</p>
</div>
<Link
href="/shop"
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>
</Reveal>
<TrustRow />
</main>
<Footer />
</>
);
}
+18 -8
View File
@@ -2,6 +2,8 @@ import Link from "next/link";
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { RichText } from "../../components/RichText";
import { ProductName } from "../../components/ProductName";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
@@ -39,18 +41,25 @@ export async function Hero() {
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">{product?.name ?? "Tasse"}</span>
<span className="text-text-primary">{product ? <ProductName name={product.name} /> : "Tasse"}</span>
</p>
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
<p
className="font-semibold text-h-page text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
{product?.name ?? "Tasse"}
</p>
<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)" }}
>
{product ? <ProductName name={product.name} /> : "Tasse"}
</p>
{product?.subline && (
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
{product.subline}
</p>
)}
</div>
{product?.description && <p className="text-body text-text-body">{product.description}</p>}
{product?.description ? <RichText content={product.description} /> : null}
<div className="flex flex-col gap-1 items-start">
{product && (
@@ -79,6 +88,7 @@ export async function Hero() {
{product && (
<AddToCartButton
label="In den Warenkorb"
productId={product.id}
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
+6 -2
View File
@@ -1,6 +1,8 @@
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { RichText } from "../../components/RichText";
import { ProductName } from "../../components/ProductName";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
@@ -46,9 +48,10 @@ export async function Pricing() {
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
<ProductName name={product.name} />
</p>
{product.description && <p className="text-body-sm text-text-primary">{product.description}</p>}
{product.subline && <p className="text-body-sm font-semibold text-text-primary">{product.subline}</p>}
{product.description ? <RichText content={product.description} /> : null}
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
@@ -76,6 +79,7 @@ export async function Pricing() {
<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={!product.active || product.outOfStock}
maxQty={product.maxQty}
+2 -2
View File
@@ -10,8 +10,8 @@ import { buildProductSchema } from "../lib/structuredData";
// tagline, unlike every other bespoke page here — see Hero.tsx's comment.
export async function generateMetadata(): Promise<Metadata> {
const product = await getProductBySlug("tasse-die-pause");
const title = product ? `${product.name} einfach produktiv` : "Tasse einfach produktiv";
const description = product?.description ?? undefined;
const title = product ? `${product.name} einfach produktiv.` : "Tasse einfach produktiv.";
const description = product?.descriptionText || undefined;
return {
title,
description,
+10 -7
View File
@@ -3,6 +3,7 @@ import Image from "next/image";
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";
@@ -77,7 +78,7 @@ export async function TodoKartenHero() {
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">ToDo-Karten</span>
<span className="text-text-primary">{product ? <ProductName name={product.name} /> : "ToDo-Karten"}</span>
</p>
{/* Everything else centered in the remaining vertical space at
@@ -97,12 +98,14 @@ export async function TodoKartenHero() {
>
ToDo-Karten<span className="text-brand">.</span>
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Kleine Karten. Große Wirkung.
</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">
+1 -1
View File
@@ -111,7 +111,7 @@ export function VersandSections({
<Section id="transportrisiko" title="Transportrisiko" withAnchor={withAnchors}>
<p>
Bestellst du als Verbraucher:in, trägt das Transportrisiko bei Beschädigung oder Verlust
während des Versands grundsätzlich einfach produktiv nicht du.
während des Versands grundsätzlich einfach produktiv. nicht du.
</p>
</Section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB