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:
@@ -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") {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,7 +17,6 @@ const STATIC_ROUTES = [
|
||||
"/blog",
|
||||
"/shop",
|
||||
"/newsletter",
|
||||
"/todo-cards",
|
||||
"/der-eine",
|
||||
"/tasse-die-pause",
|
||||
"/die-sieben",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user