Remove ToDo-Karten product and its dedicated /todo-cards page

Product deleted from Payload (no orders referenced it), so its bespoke
page went with it — Navbar's WERKZEUGE_ROUTES and the sitemap no longer
list it. The blog's "3minuten" relatedProduct pointer and the homepage's
"ToDo-Karten" Werkzeuge card were cleared/removed in Payload directly
(no code change needed there — CMS content).

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
This commit is contained in:
Marco
2026-08-29 23:29:15 +00:00
parent 207f33724b
commit f98536ff88
8 changed files with 22 additions and 556 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ function getNavLinks(singleActiveProduct: boolean) {
// /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"];
const WERKZEUGE_ROUTES = ["/7-tage-klarheits-check", "/newsletter", "/der-eine", "/tasse-die-pause", "/lebensuhr", "/3x3-system"];
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
if (href === "#werkzeuge") {
+21 -2
View File
@@ -149,10 +149,27 @@ function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): Reac
// selector here (SVG presentation attributes lose to any stylesheet
// rule) scopes the recolor to PDPs only, without touching the shared
// icon set or PageBlocks.tsx's own rendering.
// Parallel fact cards, not a connected sequence — matches der-eine's
// own hand-coded Focus.tsx ("Was kann er?") exactly: icon on top,
// centered, no StepArrow connectors between them (those imply an
// order these facts don't have). Reuses stepRow's existing icon/
// title/description shape rather than a new block type; only the
// rendering diverges from Pages.layout's own connected-arrow stepRow
// (renderPageBlockSync), scoped to ProductBlocks.tsx. Icons black
// (#231f20), same override reasoning as the quote block below.
case "stepRow":
return (
<div key={block.id} className="[&_svg_*]:!stroke-[#231f20]">
{renderPageBlockSync(block as PageBlock)}
<div key={block.id} className="flex flex-col sm:flex-row gap-8 items-start w-full max-w-[75rem] mx-auto">
{block.items.map((item) => {
const Icon = STEP_ICONS[item.icon];
return (
<div key={item.id} className="flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none mx-auto">
{Icon && <span className="flex items-center justify-center h-16 [&_svg_*]:!stroke-[#231f20]">{Icon()}</span>}
<p className="font-semibold text-body text-text-primary">{item.title}</p>
<p className="text-body-sm text-text-primary w-full text-left md:text-center">{item.description}</p>
</div>
);
})}
</div>
);
// A PDP-specific full-bleed treatment for this one block type only —
@@ -278,6 +295,7 @@ function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: Pro
<AddToCartButton
label={`${product.name} bestellen`}
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
productId={product.id}
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
@@ -358,6 +376,7 @@ function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: P
<AddToCartButton
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
productId={product.id}
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
-1
View File
@@ -17,7 +17,6 @@ const STATIC_ROUTES = [
"/blog",
"/shop",
"/newsletter",
"/todo-cards",
"/der-eine",
"/tasse-die-pause",
"/die-sieben",
-56
View File
@@ -1,56 +0,0 @@
import Image from "next/image";
import { Reveal } from "../../components/Reveal";
const bullets = [
"Set mit 50 hochwertigen ToDo-Karten",
"Viel Platz für Gedanken, Aufgaben und Notizen",
"Passt in jede Tasche überall einsetzbar",
"Nachhaltig produziert in Deutschland",
"Inklusive Mini-Anleitung mit Tipps für den Start",
];
// Inline, brand-orange stroke — same fix/reasoning as TodoKartenHero.tsx's
// own IconCheck (icon-check.svg's fill can't be recolored from outside
// the SVG when loaded via <img src>/next/image).
function IconCheck() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function Focus() {
return (
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="group relative w-full lg:w-[42%] lg:shrink-0 aspect-[580/328] rounded-md overflow-hidden">
<Image
src="/focus-todo-karten.png"
alt="Notizbuch mit ToDo-Karten in natürlichem Licht"
fill
sizes="(min-width: 1024px) 42vw, 100vw"
className="object-cover 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)" }}
>
Für mehr Fokus im Alltag
</p>
<p className="text-body text-text-body">
ToDo-Karten helfen dir, den Überblick zu behalten, Entscheidungen zu vereinfachen und deinen Tag bewusst zu gestalten.
</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>
);
}
-96
View File
@@ -1,96 +0,0 @@
import { Fragment } from "react";
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
import { StepArrow } from "../../components/StepArrow";
// Each icon's own real pixel dimensions (not uniformly square, e.g.
// icon-step-2 is 180x168) — needed so the `h-16 w-auto` sizing below infers
// the correct aspect ratio instead of stretching/squashing to a guessed one.
const steps = [
{
icon: "/icon-step-1.png",
width: 165,
height: 177,
title: "1. Aufschreiben",
desc: "Notiere deine Aufgaben und alles, was dir im Kopf herumgeht.",
},
{
icon: "/icon-step-2.png",
width: 180,
height: 168,
title: "2. Priorisieren",
desc: "Wähle deine 3 wichtigsten Aufgaben für heute aus und schreibe sie auf.",
},
{
icon: "/icon-step-3.png",
width: 180,
height: 177,
title: "3. Umsetzen",
desc: "Konzentriere dich auf das Wesentliche und hake ab, was wirklich zählt.",
},
];
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 funktioniert es
</p>
<p className="text-body text-text-muted">
Ein paar Minuten am Morgen mehr Klarheit den ganzen Tag.
</p>
</Reveal>
{/* Arrows always visible (see Divider lesson — hiding them below a
breakpoint isn't the fix), just rotated 90° to point down while
stacked below sm:. lg:px-[16.25rem] mirrors Figma's Desktop-only
px-[260px] inset — that value doesn't get its own fluid token
since it's a pure aesthetic narrowing, not something that breaks
if it's just absent below lg:. RevealGroup/RevealItem stagger the
3 steps in on scroll, same pattern as Tools.tsx's card grid —
arrows aren't part of the stagger (purely decorative connectors,
not content), they just sit in the DOM between items. */}
<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">
{/* Explicit per-icon width/height (real pixel dimensions, not
a guessed/uniform size) — matters since one of the three
source PNGs isn't square (icon-step-2 is 180x168); passing
the real intrinsic size is what avoids next/image's
"width or height modified, but not the other" warning that
a wrong/guessed size would trigger against the `h-16
w-auto` sizing below. group-hover scale is a small "step
acknowledges you" touch — the whole text column is the
hover target, not just the icon, so it's easy to trigger
without precise pointing. */}
<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 && (
// sm:mt-[1.625rem] (26px) centers the arrow on the h-16
// (64px) icon above it — (64 - arrow's own 16px height) / 2
// — same margin-based centering technique as Challenge's
// step connector, not just the same icon asset. Bigger below
// sm: (w-8 h-8, was w-6 h-6) per explicit feedback.
<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>
);
}
-137
View File
@@ -1,137 +0,0 @@
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
const bullets = [
"50 ToDo-Karten",
"Hochwertiges Papier (120 g/m²)",
"Format: 105 x 148 mm (A6)",
"Inklusive Mini-Anleitung",
"Nachhaltig produziert in Deutschland",
];
// Price/photo come from Payload (same "todo-karten" product the shop/cart
// use), not a hardcoded literal — see ProductSpotlight.tsx for the same
// reasoning; the bullet list stays hand-written since it's spec detail,
// not something the Products collection models.
export async function Pricing() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("todo-karten"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
if (!product) return null;
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// Same "any vs. every" split as ProductGrid.tsx/ProductSpotlight.tsx.
// A deactivated product (product.active === false) isn't caught by
// either — the shop grid/spotlight filter those out before they'd ever
// reach this page, but this page resolves a product regardless of
// active status (existing links to it should still 200, see
// mapPayloadProduct's own comment in lib/payload.ts) — so it needs its
// own explicit check here to read as "Ausverkauft" rather than fully
// buyable (fixed 2026-07-24, feedback: deactivated should look
// sold-out on its own detail page).
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">
{/* Three flex children (photo, info, price/CTA) competing for space
— same "big image next to text" shape as the Hero/Focus, so the
same lg:-only structural exception applies here too. */}
<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="ToDo-Karten Set"
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)" }}
>
ToDo-Karten Set
</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">
{/* MwSt./Versand disclosure on its own line, not crammed into
the price row itself — "inkl. X% MwSt. zzgl. Versand" is
long enough (once the rate is spelled out) that sharing a
row with the price in this panel's narrow fixed-width
column wrapped it mid-sentence onto a second line. */}
<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>
{/* Single product, no grid siblings to stay equal-height with —
plain conditional line, same reasoning as ProductSpotlight.tsx. */}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
{/* No "Sichere Zahlung" trust note here (unlike Cart/Checkout) —
this is an add-to-cart step, not the actual payment step, so
a payment-security reassurance is premature here and just
duplicates the one shown later at checkout. */}
{/* variants={[]} when deactivated — AddToCartButton's own
outOfStock prop is ignored whenever variants is non-empty (it
defers to each variant's own outOfStock flag instead, see its
currentlyOutOfStock calc), so a deactivated product with
in-stock variants would otherwise still render as buyable. */}
<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={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
</section>
);
}
@@ -1,199 +0,0 @@
import Link from "next/link";
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";
const checklist = [
"Klarer Fokus auf das, was wirklich zählt",
"Flexibel einsetzbar jeden Tag",
"Minimalistisch, analog, effektiv",
];
// Inline, brand-orange stroke — icon-check.svg's fill is hardcoded to
// #222221 via an internal CSS var that only resolves inside the SVG's own
// document, so it can't be recolored from the host page when loaded via
// <img src>/next/image. Same simple checkmark path as Challenge's own
// Check() component, for the same orange-checkmark look (fixed 2026-07-24).
function IconCheck() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// Same "todo-karten" product Pricing.tsx reads further down the page —
// this is just a compact early teaser so the hero's CTA isn't asking for
// a click without saying what it costs. Delivery time is repeated here too
// (not just in Pricing.tsx) since this is also a purchase CTA — Art. 246a
// §1 Abs.1 Nr.8 EGBGB's delivery-date disclosure needs to sit next to every
// buy button, not just one of them.
export async function TodoKartenHero() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("todo-karten"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null;
// Same "any vs. every" split as ProductGrid.tsx/Pricing.tsx.
const anyLowStock = product
? product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock)
: false;
return (
<section className="bg-bg-base w-full overflow-hidden">
{/* Same lg:-only structural exception as the Home Hero (see
figma-to-nextjs skill Gotcha #5) — a wide fixed-ratio photo next
to a text column is exactly the shape that gets too cramped at
Tablet widths even under the site-wide sm: (640px) structural
consolidation. lg:items-center
removed (default grid align-items is stretch) — that's what lets
the breadcrumb below sit at a fixed Y position across every hero
section on the site instead of shifting per page depending on how
much content each one has above the fold. */}
{/* pt-10 md:pt-12, no lg override — with lg:items-center gone, this
padding is now the ONLY thing positioning the breadcrumb
vertically at every tier (previously lg:pt-0 relied on centering
to add its own implicit offset instead). Kept flat past md so it
matches /newsletter's and /challenge's identical top offset. */}
<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">
{/* Breadcrumb — 3 levels (Startseite Werkzeuge ToDo-Karten),
matching /newsletter's pattern; was missing "Startseite" as
the first crumb. Deliberately NOT part of the centered block
below — see the wrapper's comment. */}
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">
Startseite
</Link>
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">{product ? <ProductName name={product.name} /> : "ToDo-Karten"}</span>
</p>
{/* Everything else — centered in the remaining vertical space at
lg+ (the column now stretches to match the image's height,
since lg:items-center was removed above; flex-1 + justify-center
here centers this block within that leftover space instead of
the breadcrumb-inclusive whole column, which is what made the
breadcrumb's Y position drift per page depending on total
content height). */}
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
{/* Heading — 48px Desktop doesn't match any existing type-scale
step, uses the new --text-h-page token (see globals.css). */}
<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)" }}
>
ToDo-Karten<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">
Ein einfaches Werkzeug, das dir hilft, deinen Kopf frei zu bekommen und das Wesentliche zu sehen.
</p>
<ul className="flex flex-col gap-3 items-start w-full">
{checklist.map((item) => (
<li key={item} className="flex gap-[0.625rem] items-start w-full">
<IconCheck />
<span className="flex-1 text-body text-text-primary">{item}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-1 items-start">
{product && (
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
)}
{!product?.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{/* Single product, no grid siblings to stay equal-height with —
plain conditional line, same reasoning as ProductSpotlight.tsx/Pricing.tsx. */}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
</div>
{product && (
// variants={[]} when deactivated — see Pricing.tsx's own
// comment on why AddToCartButton's outOfStock prop alone
// isn't enough once variants is non-empty.
<AddToCartButton
label="ToDo-Karten bestellen"
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
)}
</div>
</Reveal>
{/* group + subtle scale-on-hover — a common, restrained "photo
feels alive" micro-interaction; overflow-hidden on the wrapper
(not the image) keeps the zoom from spilling past the rounded
corners. delay={0.15} matches the Home Hero's text→image
stagger — both fire ~immediately since Hero is already in the
initial viewport, so this doubles as the page's entrance.
Falls back to the curated static hero photo (a lifestyle shot,
"in Anwendung, mit Notizbuch und Kaffee" — deliberately not
just the plain catalog image) whenever this product has no
`gallery` entries — the common case, and zero visual change
from before ProductGallery existed. Only switches to the
gallery once someone actually adds extra photos in the admin. */}
{product && product.gallery.length > 0 ? (
<Reveal className="order-2 lg:order-none lg:col-span-7" delay={0.15}>
<ProductGallery image={product.image} gallery={product.gallery} alt={product.name} />
</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="/hero-todo-karten.png"
alt="ToDo-Karten in Anwendung, mit Notizbuch und Kaffee"
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
)}
</div>
</section>
);
}
-64
View File
@@ -1,64 +0,0 @@
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { TodoKartenHero } from "./components/TodoKartenHero";
import { HowItWorks } from "./components/HowItWorks";
import { Focus } from "./components/Focus";
import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
import { TestimonialsGrid } from "../components/TestimonialsGrid";
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
import { getTestimonials, getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
const title = "ToDo-Karten Kleine Karten. Große Wirkung.";
const description =
"Ein einfaches Werkzeug, das dir hilft, deinen Kopf frei zu bekommen und das Wesentliche zu sehen. 50 hochwertige ToDo-Karten für mehr Fokus im Alltag.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/todo-cards",
},
openGraph: {
title,
description,
url: "/todo-cards",
images: ["/hero-todo-karten.png"],
},
twitter: {
title,
description,
images: ["/hero-todo-karten.png"],
},
};
export default async function TodoCardsPage() {
const { isEnabled: isPreview } = await draftMode();
const [testimonials, product, seller] = await Promise.all([
getTestimonials("todo-cards", { draft: isPreview }),
getProductBySlug("todo-karten"),
getCompanySettings(),
]);
const productSchema = product ? buildProductSchema(product, "https://einfach-produktiv.mk360.de/todo-cards", seller) : null;
return (
<>
{productSchema && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
)}
<main className="flex flex-col flex-1">
<TodoKartenHero />
<HowItWorks />
<Focus />
{isPreview ? (
<LiveTestimonialsGrid testimonials={testimonials} />
) : (
<TestimonialsGrid testimonials={testimonials} />
)}
<Pricing />
</main>
<Footer />
</>
);
}