Add shop, cart, versand pages and Impulse & Tipps detail page

- New /shop overview, /cart (real cart state via useSyncExternalStore),
  /versand (shipping policy page with TOC) and /newsletter detail page
- Cart: quantity/removal, order summary, related-products cross-sell
  with randomized picks, VersandModal quick-reference instead of
  navigating away, MwSt. disclosure next to unit prices
- Add-to-cart UX: inline success feedback (green state) plus a
  fly-to-navbar-cart-icon animation (CartFlyProvider) with a delayed
  badge count-up; AddToCartButton no longer navigates straight to /cart
- Shared lib/products.ts catalog and lib/shipping.ts constants (cost,
  free-shipping threshold, handling/transit days) so cart, trust badges
  and the versand page can never drift apart
- Fix Navbar smooth-scroll easing (ease-out instead of ease-in-out, no
  more perceived start delay); compress newsletter modal photo
  2.4MB -> 85KB to fix first-open jank
This commit is contained in:
Marco
2026-07-19 14:42:22 +00:00
parent e4f0c66d7b
commit 9e5532be63
63 changed files with 2205 additions and 165 deletions
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { PRODUCTS, formatPrice } from "../../lib/products";
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
export function CartContent() {
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
const items = cart
.map((entry) => ({ entry, product: PRODUCTS[entry.id] }))
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
const shipping = items.length === 0 || subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_COST;
const total = subtotal + shipping;
return (
<>
{/* Page header */}
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
<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">Warenkorb</span>
</p>
<p
className="font-semibold text-h-feature text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Warenkorb
</p>
<p className="text-body text-text-muted">
{items.length > 0 ? "Schön, dass du da bist." : "Dein Warenkorb ist noch leer."}
</p>
</Reveal>
{items.length === 0 ? (
<Reveal className="flex flex-col gap-6 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
<p className="text-body text-text-body">
Schau dir unsere Produkte an und finde, was zu dir passt.
</p>
<Link
href="/shop"
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
>
<span aria-hidden></span>
<span className="font-bold text-body-sm">Weiter einkaufen</span>
</Link>
</Reveal>
) : (
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
{/* Cart card — lg:-only split from the sidebar (same "wide content
next to sidebar" shape as the Hero's image/text split, see
figma-to-nextjs skill Gotcha #5: Figma's 830px card alone
already exceeds the 768px Tablet floor, so md: would never
have had room for a real 2-column layout at Tablet widths
anyway). */}
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
{items.map(({ entry, product }, i) => (
<div key={product.id} className="w-full">
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
<div className="relative size-[9.375rem] shrink-0 rounded-sm overflow-hidden">
<Image src={product.image} alt={product.name} fill sizes="150px" className="object-cover" />
</div>
<div className="flex flex-col gap-[0.625rem] items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</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">
<span className="font-bold text-body-sm text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. MwSt.</span>
</p>
</div>
</div>
<div className="flex gap-4 items-center shrink-0 w-full sm:w-auto justify-between sm:justify-end">
<label className="sr-only" htmlFor={`qty-${product.id}`}>
Menge für {product.name}
</label>
<select
id={`qty-${product.id}`}
value={entry.qty}
onChange={(e) => setQuantity(product.id, Number(e.target.value))}
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
>
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<p className="font-bold text-h4 text-text-primary whitespace-nowrap">
{formatPrice(entry.qty * product.price)}
</p>
<button
type="button"
onClick={() => removeFromCart(product.id)}
aria-label={`${product.name} entfernen`}
className="text-text-muted hover:text-text-primary text-xl leading-none active:scale-90 transition-all"
>
×
</button>
</div>
</div>
</div>
))}
<div className="h-px bg-border w-full" />
<Link
href="/shop"
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
>
<span aria-hidden></span>
<span className="font-bold text-body-sm">Weiter einkaufen</span>
</Link>
</Reveal>
{/* Sidebar */}
<Reveal delay={0.1} className="w-full lg:w-[20.625rem] lg:shrink-0 flex flex-col gap-6 items-start">
<div className="bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Bestellübersicht
</p>
<div className="flex items-center w-full">
<span className="text-body-sm text-text-primary">Zwischensumme</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
</div>
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
Versand
<button
type="button"
onClick={() => setVersandOpen(true)}
className="text-text-muted hover:text-brand transition-colors"
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
>
<span aria-hidden></span>
</button>
</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
</span>
</div>
<p className="text-label text-text-muted">
{shipping === 0
? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands`
: "innerhalb Deutschlands"}
</p>
</div>
<div className="h-px bg-border w-full" />
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span
className="font-semibold text-h4 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Gesamtsumme
</span>
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
<p className="text-label text-text-muted">inkl. MwSt.</p>
</div>
<Link
href="/checkout"
className="w-full flex items-center justify-center py-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"
>
Zur Kasse gehen
</Link>
<div className="flex gap-[0.625rem] items-center justify-center w-full">
<img alt="" src="/icon-lock.svg" className="w-4 h-[1.125rem]" />
<span className="text-body-sm text-text-muted">Sichere Zahlung</span>
</div>
</div>
<div className="flex flex-col gap-4 items-start w-full">
{[
{ icon: "/icon-trust-leaf.png", text: "Nachhaltig produziert in Deutschland" },
{ icon: "/icon-trust-materials.png", text: "Hochwertige Materialien" },
{ icon: "/icon-trust-return.png", text: "14 Tage Rückgaberecht" },
].map((b) => (
<div key={b.text} className="flex gap-3 items-center w-full">
<img alt="" src={b.icon} className="size-[1.375rem] shrink-0 object-contain" />
<span className="flex-1 text-body-sm text-text-primary">{b.text}</span>
</div>
))}
</div>
</Reveal>
</div>
)}
{items.length > 0 && (
<Reveal className="flex flex-col items-start pb-10 px-[var(--layout-padding-x)] w-full">
<div className="bg-bg-muted flex gap-5 items-start p-6 rounded-md w-full lg:max-w-[51.875rem]">
<div className="flex flex-col gap-3 items-center justify-center shrink-0">
<img alt="" src="/icon-envelope-hint.png" className="h-[2.8125rem] w-16 object-contain" />
<div className="h-[0.1875rem] w-6 bg-brand" />
</div>
<div className="flex flex-col gap-2 items-start flex-1 min-w-0 text-text-primary">
<p className="text-body-sm leading-[1.45]">
Nach deiner Bestellung bekommst du regelmäßig Impulse & Tipps per E-Mail.
</p>
<p className="text-body-sm leading-[1.45]">
Für mehr Klarheit, Fokus und Struktur jede Woche.
</p>
</div>
</div>
</Reveal>
)}
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} />
</>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { PRODUCTS, RELATED_PRODUCT_IDS, formatPrice } from "../../lib/products";
import { Reveal } from "../../components/Reveal";
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { useCart } from "../../lib/cart";
const ALL_PRODUCT_IDS = Object.keys(PRODUCTS);
const DISPLAY_COUNT = 3;
function pickRandom(excludeIds: string[], count: number): string[] {
const pool = ALL_PRODUCT_IDS.filter((id) => !excludeIds.includes(id));
const shuffled = [...pool].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
export function RelatedProducts() {
const cart = useCart();
const hasItems = cart.length > 0;
const cartKey = cart
.map((i) => i.id)
.sort()
.join(",");
// Math.random() can't run during the render that has to match the SSR
// pass (that would be a hydration mismatch — server and client would
// pick different products) — so the first paint always shows the fixed
// RELATED_PRODUCT_IDS fallback, and a client-only effect swaps in the
// real, cart-aware random pick right after mount.
const [displayIds, setDisplayIds] = useState<string[]>(RELATED_PRODUCT_IDS);
// First run (mount/hydration): pick the initial random set. After that,
// don't reshuffle everything on every cart change — one of these cards'
// own AddToCartInlineButton adds to the cart without navigating away,
// and a full reshuffle would yank the other two cards out from under
// the user mid-browse. Instead, only swap out whichever displayed card
// just became "already in the cart" (no longer relevant to recommend)
// and top that one slot back up — the rest stay exactly where they were.
const pickedRef = useRef(false);
const swapTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(swapTimeoutRef.current), []);
useEffect(() => {
const cartIds = cartKey ? cartKey.split(",") : [];
if (!pickedRef.current) {
pickedRef.current = true;
setDisplayIds(pickRandom(cartIds, DISPLAY_COUNT));
return;
}
// Delayed by FEEDBACK_MS, not immediate — the add usually came from
// clicking one of THESE cards' own AddToCartInlineButton, which shows
// a ~2s green "Hinzugefügt ✓" state. Swapping the card out right away
// unmounts that button before the confirmation is ever visible.
clearTimeout(swapTimeoutRef.current);
swapTimeoutRef.current = setTimeout(() => {
setDisplayIds((prev) => {
const stillRelevant = prev.filter((id) => !cartIds.includes(id));
const missing = DISPLAY_COUNT - stillRelevant.length;
if (missing <= 0) return stillRelevant;
let replacements = pickRandom([...cartIds, ...stillRelevant], missing);
// The catalog only has 5 products — once the cart holds 3+
// distinct ones, "3 recommendations that aren't already in the
// cart" becomes mathematically impossible (5 - 3 in cart leaves
// only 2 to show). Falling back to re-suggesting something
// already in the cart (a normal "grab another one" pattern) beats
// silently shrinking the grid below DISPLAY_COUNT.
if (replacements.length < missing) {
const stillMissing = missing - replacements.length;
const fallback = pickRandom([...stillRelevant, ...replacements], stillMissing);
replacements = [...replacements, ...fallback];
}
return [...stillRelevant, ...replacements];
});
}, FEEDBACK_MS);
}, [cartKey]);
if (displayIds.length === 0) return null;
return (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="flex flex-col gap-2 items-center text-center">
<p
className="font-semibold text-h-section text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{hasItems ? "Passt perfekt dazu" : "Beliebte Produkte"}
</p>
<p className="text-body text-text-muted">
{hasItems ? "Weitere Tools für deinen klaren Alltag." : "Entdecke, was zu dir passt."}
</p>
</Reveal>
{/* No separate price-disclosure footnote here — the single
"* inkl. MwSt., zzgl. Versandkosten" note lives directly under
the cart's own product table instead (CartContent.tsx), close
enough on the same page view to cover these cards too.
Plain divs, not RevealGroup/RevealItem — this is the one grid on
the site whose items get swapped after the initial mount (see
the swap-in-place effect above). RevealItem has no viewport
trigger of its own; it only ever renders visible because it
inherits the parent RevealGroup's already-resolved "show" state
at first mount. A card swapped in later doesn't get a fresh
trigger and can end up stuck at its hidden variant
(opacity: 0) — invisible. Not worth chasing a fix for a
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayIds.map((id) => {
const product = PRODUCTS[id];
return (
<div
key={id}
className="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 768px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
<AddToCartInlineButton id={id} />
</div>
</div>
);
})}
</div>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
// robots: noindex — transactional page (mirrors a specific shopper's cart
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
// this wastes crawl budget and could surface cart state in search results.
export const metadata: Metadata = {
title: "Warenkorb",
description: "Dein Warenkorb bei einfach produktiv.",
robots: {
index: false,
follow: true,
},
};
export default function CartPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<CartContent />
<RelatedProducts />
<TrustRow />
</main>
<Footer />
</>
);
}
+75 -39
View File
@@ -178,47 +178,70 @@ export default function ChallengePage() {
{/* ── Hero ── */}
<section className="w-full flex items-stretch overflow-hidden" style={{ minHeight: "32rem" }}>
{/* Left: text + form */}
<Reveal className="flex flex-col justify-center gap-6 px-8 lg:px-[5rem] py-10 lg:py-14 w-full lg:w-[52%] lg:shrink-0">
{/* Left: text + form. justify-center removed from here — the
outer section's items-stretch already makes this column full
height, but centering its whole content (breadcrumb included)
made the breadcrumb's Y position drift depending on this
page's content length vs. /todo-cards's and /newsletter's own
(different) content lengths. Same fix as both of those. */}
{/* pt-10 md:pt-12 (not the original py-10 lg:py-14's top value) —
matches /todo-cards's and /newsletter's breadcrumb top offset
exactly; pb-10 lg:pb-14 keeps this page's original bottom
spacing, which was never the inconsistent part. */}
<Reveal className="flex flex-col gap-6 px-8 lg:px-[5rem] pt-10 md:pt-12 pb-10 lg:pb-14 w-full lg:w-[52%] lg:shrink-0">
{/* Breadcrumb */}
<p className="text-[0.875rem] text-[#888] flex items-center gap-1.5">
<Link href="/" className="hover:text-[#f6a701] transition-colors">Startseite</Link>
{/* Breadcrumb — 3 levels (Startseite Werkzeuge
7-Tage-Challenge), matching /todo-cards's and
/newsletter's pattern; was missing "Werkzeuge" in the
middle. Also switched from this page's hardcoded hex
colors to the shared text-body-sm/text-text-muted/
text-text-primary tokens the other two breadcrumbs use —
text-body-sm is 0.875rem, identical to the old literal
value, so this is a pure consistency fix, not a visual
change. Deliberately NOT part of the centered block below. */}
<p className="text-body-sm text-text-muted flex items-center gap-2">
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
<span></span>
<span className="text-[#222221]">7-Tage-Challenge</span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">Werkzeuge</Link>
<span></span>
<span className="text-text-primary">7-Tage-Challenge</span>
</p>
{/* Headline */}
<h1
className="font-semibold text-[#222221] leading-[1.15]"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(2.25rem, 5vw, 3.75rem)" }}
>
7 Tage.<br />
Mehr Klarheit.<br />
Weniger Stress.
</h1>
{/* Everything else — centered in the remaining vertical space,
same fix as /todo-cards's and /newsletter's Hero sections. */}
<div className="flex flex-col gap-6 flex-1 justify-center">
{/* Headline */}
<h1
className="font-semibold text-[#222221] leading-[1.15]"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(2.25rem, 5vw, 3.75rem)" }}
>
7 Tage.<br />
Mehr Klarheit.<br />
Weniger Stress.
</h1>
{/* Subtitle */}
<p className="text-[1rem] text-[#444] leading-[1.6] max-w-[30rem]">
Die 7-Tage-Challenge für deinen klaren Kopf und mehr Struktur im Alltag.
</p>
{/* Subtitle */}
<p className="text-[1rem] text-[#444] leading-[1.6] max-w-[30rem]">
Die 7-Tage-Challenge für deinen klaren Kopf und mehr Struktur im Alltag.
</p>
{/* Checklist */}
<ul className="flex flex-col gap-2">
{[
"7 kurze Impulse direkt in dein Postfach",
"Praktisch, umsetzbar und alltagstauglich",
"Für mehr Fokus, Ruhe und Klarheit",
].map((item) => (
<li key={item} className="flex items-start gap-2 text-[#222221] text-[0.95rem]">
<Check />
{item}
</li>
))}
</ul>
{/* Checklist */}
<ul className="flex flex-col gap-2">
{[
"7 kurze Impulse direkt in dein Postfach",
"Praktisch, umsetzbar und alltagstauglich",
"Für mehr Fokus, Ruhe und Klarheit",
].map((item) => (
<li key={item} className="flex items-start gap-2 text-[#222221] text-[0.95rem]">
<Check />
{item}
</li>
))}
</ul>
{/* Email form */}
<EmailCapture />
{/* Email form */}
<EmailCapture />
</div>
</Reveal>
{/* Right: hero image with badge — group + scale-105 on hover,
@@ -330,8 +353,14 @@ export default function ChallengePage() {
</section>
{/* ── Was andere sagen ── */}
{/* max-w-[1600px], not this page's other sections' 1280px — a
deliberate compromise with /todo-cards's and /newsletter's
unbounded fluid width, so all three testimonial sections cap
at the same width instead of Challenge's reading narrower on
wide viewports. Only this one section's cap changed, not the
rest of the page. */}
<section className="bg-white w-full py-14 lg:py-20">
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto flex flex-col gap-10">
<div className="px-8 lg:px-[5rem] max-w-[1600px] mx-auto flex flex-col gap-10">
<Reveal
className="font-semibold text-[#222221] text-center"
@@ -340,16 +369,23 @@ export default function ChallengePage() {
Was andere sagen
</Reveal>
{/* Same micro-interactions as /todo-cards's identically-styled
testimonial cards (kept in sync deliberately): hover-lift on
{/* Same style + micro-interactions as /todo-cards's and
/newsletter's identically-styled testimonial cards (kept in
sync deliberately): decorative quote-mark, hover-lift on
the card, avatar scale on the same hover via `group`. */}
<RevealGroup className="flex flex-col lg:flex-row gap-6">
{testimonials.map((t) => (
<RevealItem
key={t.name}
className="group flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
className="group relative flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<p className="text-[#222221] text-[0.95rem] leading-[1.6] flex-1">{t.quote}</p>
<span
aria-hidden
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
>
</span>
<p className="text-[#222221] text-[0.95rem] leading-[1.6] flex-1 pr-8">{t.quote}</p>
<div className="flex items-center gap-3">
<img
alt={t.name}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { addToCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
const FEEDBACK_MS = 2000;
const PRODUCT_ID = "todo-karten";
/**
* Shared by /todo-cards's Hero + pricing panel and Home's product
* spotlight. Used to navigate straight to /cart on click; now stays on the
* page instead (matching AddToCartInlineButton's behavior everywhere else)
* — adds to the cart, plays the fly-to-navbar-icon animation, and shows a
* brief inline success state instead of leaving the page.
*/
export function AddToCartButton({
label,
className,
}: {
label: string;
className?: string;
}) {
const [added, setAdded] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
const { fly } = useCartFly();
useEffect(() => () => clearTimeout(timeoutRef.current), []);
function handleClick() {
addToCart(PRODUCT_ID);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setAdded(false), FEEDBACK_MS);
}
const base =
className ??
"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 whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base";
// Trailing `!` — Tailwind v4's important-modifier syntax moved from a
// leading `!` to this trailing suffix — forces the success color to win
// over whatever bg-brand/text-text-primary each caller already baked
// into `base`, since appending on top can't rely on CSS source order
// the way branching a whole className (AddToCartInlineButton's
// approach) can when the base itself varies per caller.
const stateClasses = added ? "bg-success! hover:bg-success! text-white!" : "";
return (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
className={`${base} ${stateClasses}`}
>
{added ? "Hinzugefügt ✓" : label}
</button>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { addToCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
// Exported so consumers like RelatedProducts.tsx can delay their own
// follow-up UI changes (e.g. swapping out this exact card) until after
// the success state has actually had time to be seen.
export const FEEDBACK_MS = 2000;
/**
* Add-to-cart button that stays on the page (unlike AddToCartButton, which
* navigates to /cart) — used wherever a product grid lets you keep browsing,
* so a click needs its own success confirmation instead of relying on the
* navigation itself as feedback.
*/
export function AddToCartInlineButton({
id,
label = "In den Warenkorb",
className,
}: {
id: string;
label?: string;
className?: string;
}) {
const [added, setAdded] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
const { fly } = useCartFly();
useEffect(() => () => clearTimeout(timeoutRef.current), []);
function handleClick() {
addToCart(id);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setAdded(false), FEEDBACK_MS);
}
const base =
className ??
"flex items-center justify-between px-5 py-3 rounded-sm border w-full transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base";
// Branch the border/bg classes instead of appending an "override" on top
// of the default ones — Tailwind v4 has no leading-`!` important prefix
// anymore (it's a trailing `!` now), so two conflicting utilities like
// border-border/border-success both being present would silently race on
// CSS source order instead of one cleanly winning.
const stateClasses = added
? "border-success bg-success-subtle scale-[1.02]"
: "border-border hover:border-brand";
return (
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
<span
className={
"text-body-sm transition-colors " +
(added ? "font-semibold text-success" : "text-text-primary")
}
>
{added ? "Hinzugefügt ✓" : label}
</span>
<img alt="" src="/icon-cart-outline.png" className="h-[1.875rem] w-8 object-contain" />
</button>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import {
createContext,
useCallback,
useContext,
useRef,
useState,
type ReactNode,
} from "react";
type Ball = { id: number; startX: number; startY: number; endX: number; endY: number };
type CartFlyContextValue = {
registerCartIcon: (el: HTMLElement | null) => void;
fly: (sourceEl: HTMLElement) => void;
/** Real cart count minus this = what the navbar badge should currently
* show — held back for as long as a ball is still mid-flight, so the
* badge only "counts up" once the ball visually lands there. */
pendingCount: number;
};
const CartFlyContext = createContext<CartFlyContextValue | null>(null);
let ballSeq = 0;
/**
* Mounted once at the root (app/layout.tsx) so both the cart icon (in
* Navbar, one branch of the tree) and any add-to-cart button (in page
* content, a different branch) can share one flight target/queue via
* context instead of prop-drilling across unrelated component trees.
*/
export function CartFlyProvider({ children }: { children: ReactNode }) {
const cartIconRef = useRef<HTMLElement | null>(null);
const [balls, setBalls] = useState<Ball[]>([]);
const [pendingCount, setPendingCount] = useState(0);
const registerCartIcon = useCallback((el: HTMLElement | null) => {
cartIconRef.current = el;
}, []);
const fly = useCallback((sourceEl: HTMLElement) => {
const target = cartIconRef.current;
if (!target) return;
const from = sourceEl.getBoundingClientRect();
const to = target.getBoundingClientRect();
setBalls((prev) => [
...prev,
{
id: ++ballSeq,
startX: from.left + from.width / 2,
startY: from.top + from.height / 2,
endX: to.left + to.width / 2,
endY: to.top + to.height / 2,
},
]);
setPendingCount((n) => n + 1);
}, []);
function handleArrive(id: number) {
setBalls((prev) => prev.filter((b) => b.id !== id));
setPendingCount((n) => Math.max(0, n - 1));
}
return (
<CartFlyContext.Provider value={{ registerCartIcon, fly, pendingCount }}>
{children}
<AnimatePresence>
{balls.map((ball) => (
<motion.div
key={ball.id}
initial={{ x: ball.startX, y: ball.startY, opacity: 1, scale: 1.4 }}
animate={{
x: ball.endX,
y: ball.endY,
scale: 0.6,
opacity: 0.85,
}}
transition={{ duration: 0.85, ease: "easeIn" }}
onAnimationComplete={() => handleArrive(ball.id)}
style={{ marginLeft: -12, marginTop: -12 }}
className="fixed left-0 top-0 z-[100] size-6 rounded-full bg-brand pointer-events-none"
/>
))}
</AnimatePresence>
</CartFlyContext.Provider>
);
}
export function useCartFly() {
const ctx = useContext(CartFlyContext);
if (!ctx) throw new Error("useCartFly must be used within CartFlyProvider");
return ctx;
}
+1
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
const links = [
{ label: "Impressum", href: "/impressum" },
{ label: "Datenschutz", href: "/datenschutz" },
{ label: "Versand", href: "/versand" },
{ label: "AGB", href: "/agb" },
{ label: "Widerruf", href: "/widerruf" },
];
+86 -22
View File
@@ -5,6 +5,8 @@ import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useCartCount } from "../lib/cart";
import { NewsletterModal } from "./NewsletterModal";
import { useCartFly } from "./CartFly";
const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem]
const SCROLL_DURATION = 800; // ms
@@ -17,10 +19,11 @@ function smoothScrollTo(targetY: number) {
function step(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / SCROLL_DURATION, 1);
// ease-in-out cubic
const ease = progress < 0.5
? 4 * progress ** 3
: 1 - (-2 * progress + 2) ** 3 / 2;
// ease-out cubic — not ease-in-out. Ease-in-out's cubic ease-in half
// stays nearly flat for the animation's first ~150-200ms, which read
// as a delay before the scroll visibly starts. Ease-out moves
// immediately and only decelerates into the landing.
const ease = 1 - (1 - progress) ** 3;
window.scrollTo(0, startY + distance * ease);
if (progress < 1) requestAnimationFrame(step);
}
@@ -40,12 +43,15 @@ const anchorIds = navLinks
.map((l) => l.href.slice(1));
// "Werkzeuge" also covers standalone tool/product pages that live under
// the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten)
// and /challenge (7-Tage-Challenge) are both tools listed there, even
// though neither route is nested under /werkzeuge/. Their own Figma
// sources (page-todo-karten, and the 7-Tage-Challenge card in
// section-tools) mark "Werkzeuge" active in the nav for the same reason.
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge"];
// the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten),
// /challenge (7-Tage-Challenge), and /newsletter (Impulse & Tipps) are
// all tools listed there, even though none of these routes is nested
// under /werkzeuge/. Their own Figma sources (page-todo-karten,
// page-weekly-impulses, and the 7-Tage-Challenge card in section-tools)
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
// page-weekly-impulses's own breadcrumb ("Startseite Werkzeuge
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"];
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
if (href === "#werkzeuge") {
@@ -67,13 +73,54 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
// Badge only renders once something is actually in the cart.
function CartLink() {
const count = useCartCount();
const { registerCartIcon, pendingCount } = useCartFly();
const anchorRef = useRef<HTMLAnchorElement>(null);
const [pulse, setPulse] = useState(false);
const prevVisibleRef = useRef(0);
const pathname = usePathname();
// Held back by pendingCount while a ball is mid-flight, so the badge only
// "counts up" once the ball visually lands here — not the instant the
// button is clicked (real cart data itself updates instantly elsewhere,
// this is purely the badge's own display value).
const visibleCount = Math.max(0, count - pendingCount);
useEffect(() => {
registerCartIcon(anchorRef.current);
return () => registerCartIcon(null);
}, [registerCartIcon]);
useEffect(() => {
if (visibleCount > prevVisibleRef.current) {
setPulse(true);
const t = setTimeout(() => setPulse(false), 350);
prevVisibleRef.current = visibleCount;
return () => clearTimeout(t);
}
prevVisibleRef.current = visibleCount;
}, [visibleCount]);
return (
<Link
ref={anchorRef}
href="/cart"
aria-label={count > 0 ? `Warenkorb, ${count} Artikel` : "Warenkorb"}
onClick={(e) => {
// Already on /cart — a real navigation here is a no-op, so treat
// the click as "take me back to the top" instead of doing nothing.
if (pathname === "/cart") {
e.preventDefault();
smoothScrollTo(0);
}
}}
aria-label={visibleCount > 0 ? `Warenkorb, ${visibleCount} Artikel` : "Warenkorb"}
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg viewBox="0 0 32 30" className="h-7 w-7 text-text-primary" fill="none" aria-hidden="true">
<svg
viewBox="0 0 32 30"
className={"h-7 w-7 text-text-primary transition-transform duration-300 " + (pulse ? "scale-110" : "scale-100")}
fill="none"
aria-hidden="true"
>
<path
d="M2,2 H6 L9.2,17.7 a2.4,2.4 0 0 0 2.4,2 h11.6 a2.4,2.4 0 0 0 2.4,-1.9 L28,7.5 H8"
stroke="currentColor"
@@ -84,9 +131,14 @@ function CartLink() {
<circle cx="12" cy="25" r="1.6" fill="currentColor" />
<circle cx="24" cy="25" r="1.6" fill="currentColor" />
</svg>
{count > 0 && (
<span className="absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white">
{count > 99 ? "99+" : count}
{visibleCount > 0 && (
<span
className={
"absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white transition-transform duration-300 " +
(pulse ? "scale-125" : "scale-100")
}
>
{visibleCount > 99 ? "99+" : visibleCount}
</span>
)}
</Link>
@@ -98,6 +150,7 @@ export function Navbar() {
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
const [mobileOpen, setMobileOpen] = useState(false);
const [newsletterOpen, setNewsletterOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);
@@ -302,12 +355,18 @@ export function Navbar() {
{/* CTA buttons — inline from md (768px) up, i.e. through both
"Collapsed-CTA" and full Desktop tiers */}
<div className="hidden md:flex items-center gap-3 p-2">
<Link
href="/newsletter"
{/* Opens the modal (matches the original Figma prototype:
Newsletter-Button → Overlay newsletter-overlay) instead of
navigating to /newsletter — that page is reached via the
Werkzeuge "Impulse & Tipps" card's "Anmelden" link instead,
a different, heavier entry point for a different context. */}
<button
type="button"
onClick={() => setNewsletterOpen(true)}
className="px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
Newsletter
</Link>
</button>
<Link
href="/challenge"
className="px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all"
@@ -403,13 +462,16 @@ export function Navbar() {
})}
</nav>
<div className="flex flex-col gap-3 px-8 pb-8">
<Link
href="/newsletter"
onClick={closeMobile}
<button
type="button"
onClick={() => {
closeMobile();
setNewsletterOpen(true);
}}
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
Newsletter
</Link>
</button>
<Link
href="/challenge"
onClick={closeMobile}
@@ -419,6 +481,8 @@ export function Navbar() {
</Link>
</div>
</div>
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
</header>
);
}
+19 -5
View File
@@ -1,6 +1,21 @@
import { Reveal } from "./Reveal";
export function Newsletter() {
type NewsletterProps = {
title?: string;
description?: string;
};
/**
* Reused as-is (same bordered/muted panel + icon-decoration + form
* pattern) on both Home and /newsletter (the "Impulse & Tipps" page) —
* their Figma frames use the exact same newsletter-inner component
* instance, just with different copy, so title/description are props
* instead of a second near-duplicate component.
*/
export function Newsletter({
title = "Starte mit einer Woche voller Klarheit",
description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.",
}: NewsletterProps = {}) {
return (
<section className="py-16 w-full">
@@ -30,11 +45,10 @@ export function Newsletter() {
className="font-semibold text-h-section leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
Starte mit einer Woche voller Klarheit
{title}
</p>
<p className="font-normal text-body leading-6">
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge,
mit der du durch mehr Struktur weniger Stress spürst.
{description}
</p>
</div>
</div>
@@ -52,7 +66,7 @@ export function Newsletter() {
/>
<button
type="submit"
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all"
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
>
Jetzt anmelden
</button>
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useEffect, useRef } from "react";
import Image from "next/image";
import { AnimatePresence, motion } from "motion/react";
const features = [
{
icon: "/icon-sparkle-wrapper.svg",
title: "7 Tage. Ein Fokus.",
desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.",
},
{
icon: "/icon-checklist.png",
title: "Praktisch & umsetzbar.",
desc: "Direkt anwendbare Methoden für deinen Alltag.",
},
{
icon: "/icon-heart.png",
title: "Kein Spam. Versprochen.",
desc: "Nur wertvolle Inhalte, wenn du sie brauchst.",
},
];
/**
* The Navbar's "Newsletter" CTA opens this modal rather than navigating —
* matches the original Figma prototype (Newsletter-Button → Overlay
* newsletter-overlay), unlike the Werkzeuge "Impulse & Tipps" card's
* "Anmelden" link, which goes to the full /newsletter detail page instead.
* Different entry points, different weight: a quick-access nav CTA gets a
* lightweight in-place signup, a content card gets the full page.
*/
export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Body scroll lock while open.
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prevOverflow;
};
}, [open]);
// Focus management: move focus into the modal on open, trap Tab within
// it, close + return focus on Escape — same pattern as the Navbar's
// mobile drawer.
useEffect(() => {
if (!open) return;
closeButtonRef.current?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key !== "Tab") return;
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled])'
);
if (!focusables || focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
return (
// AnimatePresence, not a plain `if (!open) return null` — that would
// unmount the modal instantly on close with no way to play an exit
// animation first. Keeping it mounted (conditionally rendering the
// child) lets Framer Motion finish the fade-out before removing it.
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-[60] flex items-center justify-center p-4 md:p-8 bg-[rgba(134,134,134,0.9)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="newsletter-modal-heading"
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute top-6 right-6 z-10 size-6 flex items-center justify-center active:scale-90 transition-transform"
>
<img alt="" src="/icon-close.png" className="size-full object-contain" />
</button>
{/* modal-top: photo + copy/form, stacked below md */}
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
<Image
src="/newsletter-modal-photo.jpg"
alt="Notizbuch mit Kaffee und Stift"
fill
sizes="(min-width: 768px) 50vw, 100vw"
className="object-cover"
/>
</div>
<div className="flex flex-col gap-6 items-start justify-center flex-1 min-w-0 px-8 py-10 md:px-[4.6875rem] md:py-[6.25rem]">
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
itself is authored upside-down (matches how Newsletter.tsx
uses this exact same asset); without it the icon renders
flipped. */}
<div className="w-16 h-14 -rotate-4 -scale-y-100">
<img alt="" src="/newsletter-icon.svg" className="w-full h-full" />
</div>
<p
id="newsletter-modal-heading"
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-lora)" }}
>
Starte mit einer Woche voller Klarheit
</p>
<p className="text-body text-text-primary">
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
</p>
<form className="flex flex-col gap-5 items-start w-full">
<div className="flex flex-col gap-4 items-start w-full">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="w-full bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
/>
<button
type="submit"
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
>
Jetzt anmelden
</button>
</div>
<label className="flex gap-2 items-center w-full cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary">
Ich akzeptiere die Datenschutzerklärung.
</span>
</label>
</form>
</div>
</div>
{/* modal-bottom: 3 feature cards. Figma's export had items-end on
this row, but that's a byproduct of nested -scale-y-100
flip-wrappers Figma uses for baseline-grid tricks (visually
cancels out to top-alignment in the actual design) — taken
literally it bottom-aligned the icon with the last line of
description text instead of the title, which read as broken.
items-start + a fixed icon bounding box (icons have different
native proportions, e.g. the sparkle glyph isn't square) fixes
it without needing the flip trick. */}
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md:gap-6">
{features.map((f) => (
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
<div className="h-10 w-10 shrink-0 flex items-center justify-center">
<img alt="" src={f.icon} className="max-h-10 max-w-10 object-contain" />
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0">
<p
className="font-semibold text-h-small text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{f.title}
</p>
<p className="text-body text-text-primary w-full">{f.desc}</p>
</div>
</div>
))}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
/**
* Product teaser for ToDo-Karten, placed after the Werkzeuge section (not
* right after the Hero — that already has its own primary CTA, the
* 7-Tage-Challenge, and a second strong purchase CTA competing with it
* there would dilute focus). Werkzeuge already introduces ToDo-Karten as
* a concept with an "Entdecken" link; this is the natural next step —
* a concrete way to buy it, right where interest was just built, rather
* than dropped at the very top before the page has earned any trust.
* Not derived from a Figma frame (page-home never had this section) —
* a deliberate, code-only addition, styled to match the /todo-cards
* pricing panel it's a teaser for.
*/
export function ProductSpotlight() {
return (
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src="/product-todo-karten.png"
alt="ToDo-Karten Set"
fill
sizes="(min-width: 768px) 380px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
<p className="font-bold text-h-small text-brand">Neu im Shop</p>
<p
className="font-semibold text-h-section text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
ToDo-Karten Kleine Karten. Große Wirkung.
</p>
<p className="text-body text-text-body">
50 hochwertige Karten, die dir helfen, deinen Kopf frei zu bekommen und das Wesentliche zu sehen analog, minimalistisch, für jeden Tag.
</p>
<div className="flex gap-2 items-center">
<p className="font-bold text-h3 text-text-primary">12,90 </p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
{/* No className override — the section's bg is bg-bg-base now
(matches Tools/Blog above/below), same as AddToCartButton's
own default styling/ring-offset, so no override is needed
here. */}
<AddToCartButton label="In den Warenkorb" />
<Link
href="/todo-cards"
className="inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm border border-border text-body font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
>
Mehr erfahren
</Link>
</div>
</div>
</Reveal>
</section>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { FREE_SHIPPING_THRESHOLD, TOTAL_DAYS_DE } from "../lib/shipping";
import { formatPrice } from "../lib/products";
const items = [
{
icon: "/icon-trust-shipping.png",
title: "Schneller Versand",
desc: `In ${TOTAL_DAYS_DE.min}${TOTAL_DAYS_DE.max} Werktagen bei dir.`,
},
{
icon: "/icon-trust-free-shipping.png",
title: "Versandkostenfrei",
desc: `Ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} Bestellwert innerhalb DE.`,
},
{ icon: "/icon-trust-heart.png", title: "Mit Liebe verpackt", desc: "Für mehr Freude beim Auspacken." },
];
export function TrustRow() {
return (
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-center justify-center py-8 px-[var(--layout-padding-x)]">
{items.map((item, i) => (
<div key={item.title} className="flex items-center gap-6 md:gap-12">
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
<div className="flex gap-4 items-center">
<img alt="" src={item.icon} className="size-8 shrink-0 object-contain" />
<div className="flex flex-col gap-0.5 items-start">
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.desc}</p>
</div>
</div>
</div>
))}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef } from "react";
import { AnimatePresence, motion } from "motion/react";
import { VersandSections } from "../versand/components/VersandSections";
/**
* Quick-reference version of /versand, opened from the cart's order
* summary "Versand" info link — a full page navigation would pull you out
* of checkout, which is exactly what the link is there to avoid. Reuses
* VersandSections so the two never carry different numbers/copy.
*/
export function VersandModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prevOverflow;
};
}, [open]);
useEffect(() => {
if (!open) return;
closeButtonRef.current?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key !== "Tab") return;
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled])'
);
if (!focusables || focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-[60] flex items-center justify-center p-4 md:p-8 bg-[rgba(134,134,134,0.9)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="versand-modal-heading"
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[40rem] max-h-[85vh] overflow-y-auto"
>
<div className="sticky top-0 z-10 flex items-center justify-between px-8 py-6 bg-bg-base border-b border-border">
<p
id="versand-modal-heading"
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Versand
</p>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
aria-label="Schließen"
className="size-6 flex items-center justify-center active:scale-90 transition-transform text-text-muted hover:text-text-primary"
>
<span aria-hidden className="text-2xl leading-none">×</span>
</button>
</div>
<div className="px-8 py-6 pb-8">
<VersandSections />
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+5 -1
View File
@@ -36,6 +36,8 @@
--color-checkout-active: #f6a701;
--color-checkout-done: #1a1a18;
--color-toc-active-border: #f6a701;
--color-success: #2f8f4e;
--color-success-subtle: #e8f4ea;
/* Radius */
--radius-xs: 0.25rem;
@@ -58,7 +60,9 @@
token (Why-point 4). Tablet floor via the styleguide's 80-85% ratio,
rounded to 40px. "Product/tool page hero H1" role, distinct from
--text-h1 (already spoken for: "Seiten-H1 (Blog-Detail, Warenkorb,
etc.)" per styleguide.md §2.2 — a different, smaller role). */
etc.)" per styleguide.md §2.2 — a different, smaller role). Also used
by page-weekly-impulses's hero heading — same 48px role, reused
rather than minting a near-duplicate token. */
--text-h-page: clamp(2.5rem, 1.9286rem + 1.1905vw, 3rem);
--text-h-page--line-height: 1.15;
--text-h1: clamp(1.75rem, 1.1786rem + 1.1905vw, 2.25rem);
+6 -3
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Inter, Playfair_Display, Caveat, Lora } from "next/font/google";
import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
const inter = Inter({
variable: "--font-inter",
@@ -52,11 +53,13 @@ export default function RootLayout({
return (
<html
lang="de"
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased`}
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased scroll-smooth`}
>
<body className="min-h-full flex flex-col">
<Navbar />
{children}
<CartFlyProvider>
<Navbar />
{children}
</CartFlyProvider>
</body>
</html>
);
+65 -21
View File
@@ -1,11 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import { useSyncExternalStore } from "react";
const CART_KEY = "ep_cart";
const CART_EVENT = "ep-cart-updated";
const EMPTY_CART: CartItem[] = [];
type CartItem = { id: string; qty: number };
export type CartItem = { id: string; qty: number };
function readCart(): CartItem[] {
if (typeof window === "undefined") return [];
@@ -34,23 +35,66 @@ export function addToCart(id: string, qty = 1) {
writeCart(items);
}
// Reactive cart item count — 0 until the client hydrates and reads
// localStorage, then stays in sync across tabs ("storage") and same-tab
// updates (the CART_EVENT dispatched by addToCart, which "storage" alone
// doesn't fire for the tab that made the change).
export function useCartCount(): number {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(getCartCount());
const onUpdate = () => setCount(getCartCount());
window.addEventListener(CART_EVENT, onUpdate);
window.addEventListener("storage", onUpdate);
return () => {
window.removeEventListener(CART_EVENT, onUpdate);
window.removeEventListener("storage", onUpdate);
};
}, []);
return count;
export function removeFromCart(id: string) {
writeCart(readCart().filter((i) => i.id !== id));
}
// qty <= 0 removes the item outright — the cart page's quantity stepper
// never lets the visible count go below 1, but this keeps the function
// itself safe to call with any integer without a separate remove path.
export function setQuantity(id: string, qty: number) {
if (qty <= 0) {
removeFromCart(id);
return;
}
const items = readCart();
const existing = items.find((i) => i.id === id);
if (existing) existing.qty = qty;
writeCart(items);
}
// Cached-by-raw-string snapshot, not a fresh JSON.parse() every call —
// useSyncExternalStore (below) requires getSnapshot to return the SAME
// reference when the underlying data hasn't actually changed, or React
// treats every render as a change. readCart()'s plain JSON.parse would
// allocate a new array each call and break that.
let cachedRaw: string | null | undefined;
let cachedItems: CartItem[] = EMPTY_CART;
export function getCart(): CartItem[] {
if (typeof window === "undefined") return EMPTY_CART;
const raw = window.localStorage.getItem(CART_KEY);
if (raw === cachedRaw) return cachedItems;
cachedRaw = raw;
try {
cachedItems = raw ? JSON.parse(raw) : EMPTY_CART;
} catch {
cachedItems = EMPTY_CART;
}
return cachedItems;
}
function subscribe(onStoreChange: () => void) {
window.addEventListener(CART_EVENT, onStoreChange);
window.addEventListener("storage", onStoreChange);
return () => {
window.removeEventListener(CART_EVENT, onStoreChange);
window.removeEventListener("storage", onStoreChange);
};
}
// useSyncExternalStore, not useState+useEffect — localStorage is an
// external store outside React, and the previous approach (setState
// synchronously inside an effect body) causes an extra render and trips
// the react-hooks/set-state-in-effect lint rule. This is React's own
// recommended pattern for subscribing to exactly this kind of external
// store, and is correctly SSR-safe via the third (server snapshot)
// argument — 0 / EMPTY_CART until the client hydrates and reads
// localStorage for real.
export function useCartCount(): number {
return useSyncExternalStore(subscribe, getCartCount, () => 0);
}
export function useCart(): CartItem[] {
return useSyncExternalStore(subscribe, getCart, () => EMPTY_CART);
}
+65
View File
@@ -0,0 +1,65 @@
// Small in-code product catalog — this project has no commerce backend,
// so cart items (stored in localStorage as {id, qty} pairs, see cart.ts)
// need somewhere to look up name/price/photo/description by id. Only
// "todo-karten" has a real detail page (/todo-cards) right now; the
// other four exist because they're shown in Figma's page-cart "Passt
// perfekt dazu" row and are addable to the cart from there, same as the
// real product — they just don't have their own detail pages built yet
// (matching the project's established pattern of cross-linking to
// not-yet-built routes rather than leaving buttons disconnected).
export type Product = {
id: string;
name: string;
description: string;
price: number;
image: string;
href?: string;
};
export const PRODUCTS: Record<string, Product> = {
"todo-karten": {
id: "todo-karten",
name: "ToDo-Karten Set",
description: "50 ToDo-Karten für mehr Fokus und Klarheit im Alltag.",
price: 12.9,
image: "/product-todo-karten.png",
href: "/todo-cards",
},
"notizbuch-klarheit": {
id: "notizbuch-klarheit",
name: "Notizbuch Klarheit",
description: "Dein Begleiter für Gedanken, Notizen und neue Perspektiven.",
price: 9.9,
image: "/product-notizbuch-klarheit.png",
},
wochenplaner: {
id: "wochenplaner",
name: "Wochenplaner Überblick",
description: "Behalte deine Woche im Blick und setze klare Prioritäten.",
price: 14.9,
image: "/product-wochenplaner.png",
},
"notizbuch-fokus": {
id: "notizbuch-fokus",
name: "Notizbuch Fokus",
description: "Für mehr Konzentration und einen klaren Kopf im Alltag.",
price: 9.9,
image: "/product-notizbuch-fokus.png",
},
zielkarten: {
id: "zielkarten",
name: "Zielkarten Set",
description: "Definiere deine Ziele und behalte sie fest im Blick.",
price: 11.9,
image: "/product-zielkarten.png",
},
};
export const RELATED_PRODUCT_IDS = ["wochenplaner", "notizbuch-fokus", "zielkarten"];
// Shop overview grid order — mirrors page-shop-overview in Figma.
export const SHOP_PRODUCT_IDS = ["todo-karten", "wochenplaner", "notizbuch-fokus", "zielkarten"];
export function formatPrice(value: number): string {
return `${value.toFixed(2).replace(".", ",")}`;
}
+17
View File
@@ -0,0 +1,17 @@
// Single source of truth for shipping numbers/timeframes — consumed by
// both the cart's order summary (CartContent.tsx) and the /versand policy
// page, so the two can never drift apart.
export const SHIPPING_COST = 2.9;
export const FREE_SHIPPING_THRESHOLD = 39;
// Kept as an explicit range (not "so schnell wie möglich") per Art. 246a
// § 1 Abs. 1 Nr. 8 EGBGB — German law requires disclosing a concrete
// delivery timeframe before contract conclusion, and split into
// Bearbeitungszeit/Versanddauer so it's clear whether processing time is
// included in the stated number, not just a single ambiguous figure.
export const HANDLING_DAYS = { min: 1, max: 2 };
export const TRANSIT_DAYS_DE = { min: 2, max: 4 };
export const TOTAL_DAYS_DE = {
min: HANDLING_DAYS.min + TRANSIT_DAYS_DE.min,
max: HANDLING_DAYS.max + TRANSIT_DAYS_DE.max,
};
+73
View File
@@ -0,0 +1,73 @@
import { Fragment } from "react";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
const steps = [
{
icon: "/icon-weekly-step-1.png",
title: "1. Anmelden",
desc: "Trage dich mit deiner E-Mail-Adresse ein und bestätige kurz.",
},
{
icon: "/icon-weekly-step-2.png",
title: "2. E-Mail erhalten",
desc: "Jeden Mittwoch bekommst du deinen neuen Impuls direkt in dein Postfach.",
},
{
icon: "/icon-weekly-step-3.png",
title: "3. Lesen & umsetzen",
desc: "Lies in wenigen Minuten und setze die Tipps direkt in deinem Alltag um.",
},
{
icon: "/icon-weekly-step-4.png",
title: "4. Dranbleiben",
desc: "Kleine Schritte, jede Woche für mehr Klarheit, Fokus und Leichtigkeit.",
},
];
export function HowItWorks() {
return (
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 md: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 funktioniert&apos;s
</p>
<p className="text-body text-text-muted">
Wöchentliche Inspiration. Kein Aufwand für dich.
</p>
</Reveal>
{/* Same pattern as /todo-cards's HowItWorks: arrows always visible
(rotated to point down while stacked below md:), RevealGroup/
RevealItem stagger the steps in. lg:px-[10rem] mirrors Figma's
Desktop-only px-[160px] inset (no fluid token — pure aesthetic
narrowing, fine to just drop below lg:). */}
<RevealGroup className="flex flex-col md:flex-row gap-8 items-center md:items-start w-full lg:px-[10rem]">
{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 md:max-w-none">
<img
src={step.icon}
alt=""
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">
<img
alt=""
src="/icon-arrow-connector.svg"
className="w-6 h-6 rotate-90 md:w-10 md:h-3 md:rotate-0"
/>
</div>
)}
</Fragment>
))}
</RevealGroup>
</section>
);
}
@@ -0,0 +1,74 @@
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
const testimonials = [
{
quote:
"„Die wöchentlichen Impulse sind mein fester Start in die Woche. Kurz, hilfreich und immer genau richtig.“",
name: "Sarah M.",
role: "Marketing Managerin",
avatar: "/avatar-weekly-1.png",
},
{
quote: "„Endlich mal Tipps, die man wirklich umsetzen kann. Jeden Mittwoch freue ich mich auf die Mail.“",
name: "Thomas K.",
role: "Selbstständiger Berater",
avatar: "/avatar-weekly-2.png",
},
{
quote: "„Die Impulse helfen mir, den Fokus zu behalten und das Wesentliche nicht aus den Augen zu verlieren.“",
name: "Miriam L.",
role: "Projektleiterin",
avatar: "/avatar-weekly-3.png",
},
];
// Same card style as /challenge and /todo-cards's testimonials (bg-muted,
// no border, decorative quote-mark, quote on top with flex-1,
// avatar+name+role row pinned to the bottom, hover-lift + avatar scale) —
// this page's own Figma spec actually used a different bordered pattern,
// but site-wide consistency across all three testimonial sections was
// explicitly requested over per-page Figma fidelity here. No pagination
// dots either, for the same reason — neither of the other two pages has
// them. max-w-[1600px] matches Challenge's testimonial container cap —
// see the identical comment in /todo-cards's Testimonials.tsx.
export function Testimonials() {
return (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
<Reveal
className="font-semibold text-h-emphasis text-text-primary text-center"
style={{ fontFamily: "var(--font-lora)" }}
>
Was andere sagen
</Reveal>
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
{testimonials.map((t) => (
<RevealItem
key={t.name}
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<span
aria-hidden
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
>
</span>
<p className="flex-1 text-body-sm text-text-primary leading-[1.6] pr-8">{t.quote}</p>
<div className="flex items-center gap-3">
<div className="relative size-10 shrink-0 rounded-full overflow-hidden transition-transform duration-300 group-hover:scale-110">
<Image src={t.avatar} alt={t.name} fill sizes="40px" className="object-cover" />
</div>
<div>
<p className="font-semibold text-body-sm text-text-primary">{t.name}</p>
<p className="text-body-sm text-text-muted">{t.role}</p>
</div>
</div>
</RevealItem>
))}
</RevealGroup>
</div>
</section>
);
}
@@ -0,0 +1,45 @@
import Image from "next/image";
import { Reveal } from "../../components/Reveal";
const benefits = [
{ title: "Praktische Impulse", desc: "Konkrete Ideen, die du sofort umsetzen kannst." },
{ title: "Neue Perspektiven", desc: "Gedankenanstöße, die dir helfen, klarer zu sehen." },
{ title: "Bewährte Methoden", desc: "Einfache Strategien für mehr Fokus und Struktur." },
{ title: "Motivation & Erinnerung", desc: "Ein freundlicher Schub in die richtige Richtung." },
];
export function WeeklyBenefits() {
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="/weekly-photo.png"
alt="Notizbuch mit wöchentlichem Impuls"
fill
sizes="(min-width: 1024px) 42vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
<Reveal delay={0.1} className="flex flex-col gap-6 items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-emphasis text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
Das bekommst du jede Woche
</p>
<ul className="flex flex-col gap-4 items-start w-full">
{benefits.map((b) => (
<li key={b.title} className="flex gap-[0.625rem] items-start w-full">
<img alt="" src="/icon-check.svg" className="size-5 shrink-0 mt-0.5" />
<div className="flex flex-col gap-0.5 items-start flex-1 min-w-0">
<p className="font-semibold text-body text-text-primary">{b.title}</p>
<p className="text-body-sm text-text-muted">{b.desc}</p>
</div>
</li>
))}
</ul>
</Reveal>
</section>
);
}
@@ -0,0 +1,122 @@
import Link from "next/link";
import Image from "next/image";
import { Reveal } from "../../components/Reveal";
const checklist = [
"Jeden Mittwoch neue Impulse & Tipps",
"Kurz & knackig in 5 Minuten gelesen",
"Sofort umsetzbar für mehr Fokus und Struktur",
"Kostenlos und jederzeit abbestellbar",
];
export function WeeklyImpulsesHero() {
return (
<section className="bg-bg-base w-full overflow-hidden">
{/* Same lg:-only structural exception as Home/todo-cards Hero (see
figma-to-nextjs skill Gotcha #5) — a wide fixed-ratio photo next
to a text column gets too cramped at Tablet widths under the
site-wide md: convention. lg:items-center removed — see the
breadcrumb/centering comment below, same fix as /todo-cards. */}
{/* pt-10 md:pt-12, no lg override — same fix and same reasoning as
/todo-cards's Hero: this is now the only thing positioning the
breadcrumb, and it must match /todo-cards's and /challenge's
value exactly for the breadcrumb to land at the same Y everywhere. */}
<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 here (Startseite Werkzeuge Impulse &
Tipps), matching this page's actual Figma breadcrumb.
Deliberately NOT part of the centered block below — see its
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">Impulse & Tipps</span>
</p>
{/* Everything else — centered in the remaining vertical space at
lg+, same fix as /todo-cards's Hero: keeps the breadcrumb
above at a fixed, cross-page-consistent Y position instead of
its offset drifting with this page's larger content (pill
badge + 4 checklist items vs. /todo-cards's 3, no badge). */}
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
{/* Pill badge */}
<div className="flex gap-2 items-center border border-border rounded-full pl-3.5 pr-4 py-2">
<img alt="" src="/icon-envelope-small.svg" className="w-4 h-3 shrink-0" />
<span className="text-body-sm font-semibold text-text-primary whitespace-nowrap">
Jeden Mittwoch in deinem Postfach
</span>
</div>
{/* Heading — 48px Desktop, same new --text-h-page token as
/todo-cards's hero heading (see globals.css). */}
<p
className="font-semibold text-h-page text-text-primary leading-[1.1]"
style={{ fontFamily: "var(--font-playfair)" }}
>
Impulse & Tipps.
<br />
Wöchentliche Klarheit
<br />
für deinen Alltag.
</p>
<p className="text-body text-text-body">
Einmal pro Woche bekommst du konkrete Impulse, die dich wirklich weiterbringen kurz, praktisch und direkt umsetzbar.
</p>
<ul className="flex flex-col gap-3 items-start w-full">
{checklist.map((item) => (
<li key={item} className="flex gap-[0.625rem] items-center w-full">
<img alt="" src="/icon-check.svg" className="size-5 shrink-0" />
<span className="flex-1 text-body text-text-primary">{item}</span>
</li>
))}
</ul>
{/* Inline email capture — page-specific, simpler than the shared
Newsletter component's panel form (no button-adjacent styling
needed here, just input + submit inline). */}
<div className="flex gap-3 items-start w-full sm:w-auto">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="w-full sm:w-[17.5rem] bg-bg-base border border-border rounded-sm px-4 py-[0.8125rem] text-body-sm text-text-muted font-normal outline-none focus:border-brand transition-colors"
/>
<button
type="submit"
className="shrink-0 bg-brand rounded-sm px-6 py-[0.8125rem] font-bold text-body text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
>
Jetzt anmelden
</button>
</div>
<div className="flex gap-[0.375rem] items-center">
<img alt="" src="/icon-lock.svg" className="size-3.5" />
<span className="text-label text-text-muted">Keine Werbung. Jederzeit abbestellbar.</span>
</div>
</div>
</Reveal>
<Reveal
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[830/611] rounded-md overflow-hidden"
delay={0.15}
>
<Image
src="/hero-weekly-impulses.png"
alt="Notizbuch mit wöchentlichem Impuls, Kaffee und Stift"
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
</div>
</section>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { WeeklyImpulsesHero } from "./components/WeeklyImpulsesHero";
import { HowItWorks } from "./components/HowItWorks";
import { WeeklyBenefits } from "./components/WeeklyBenefits";
import { Testimonials } from "./components/Testimonials";
import { Newsletter } from "../components/Newsletter";
import { Footer } from "../components/Footer";
const title = "Impulse & Tipps Wöchentliche Klarheit für deinen Alltag";
const description =
"Einmal pro Woche bekommst du konkrete Impulse, die dich wirklich weiterbringen kurz, praktisch und direkt umsetzbar. Kostenlos, jeden Mittwoch, jederzeit abbestellbar.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/newsletter",
},
openGraph: {
title,
description,
url: "/newsletter",
images: ["/hero-weekly-impulses.png"],
},
twitter: {
title,
description,
images: ["/hero-weekly-impulses.png"],
},
};
// Route is "/newsletter" — not "/impulse-tipps" or similar — because that
// path already existed as a dangling link from two places (Navbar's
// "Newsletter" CTA button and Tools.tsx's "Impulse & Tipps" → "Anmelden"
// card). Both point at the same underlying thing (signing up for the
// weekly newsletter), so this page resolves both at once instead of
// picking a new URL and leaving one of them still dangling.
export default function NewsletterPage() {
return (
<>
<main className="flex flex-col flex-1">
<WeeklyImpulsesHero />
<HowItWorks />
<WeeklyBenefits />
<Testimonials />
<Newsletter
title="Wöchentliche Impulse für mehr Klarheit"
description="Melde dich jetzt an und erhalte jeden Mittwoch neue Impulse & Tipps direkt in dein Postfach."
/>
</main>
<Footer />
</>
);
}
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Hero } from "./components/Hero";
import { Divider } from "./components/Divider";
import { Tools } from "./components/Tools";
import { ProductSpotlight } from "./components/ProductSpotlight";
import { Blog } from "./components/Blog";
import { About } from "./components/About";
import { Newsletter } from "./components/Newsletter";
@@ -37,6 +38,7 @@ export default function Home() {
<Hero />
<Divider />
<Tools />
<ProductSpotlight />
<Blog />
<About />
<Newsletter />
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import Image from "next/image";
import { PRODUCTS, SHOP_PRODUCT_IDS, formatPrice } from "../../lib/products";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
export function ProductGrid() {
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
{SHOP_PRODUCT_IDS.map((id) => {
const product = PRODUCTS[id];
return (
<RevealItem
key={id}
className="group md:col-span-3 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 768px) 25vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
{product.href && (
<Link
href={product.href}
className="flex items-center gap-1 font-bold text-label text-brand hover:underline"
>
<span>Mehr erfahren</span>
<span aria-hidden></span>
</Link>
)}
{/* flex-1 spacer — pins every card's button to the same Y
regardless of the "Mehr erfahren" line only the
todo-karten card has (see figma-to-nextjs skill's
Tools.tsx equal-height lesson). */}
<div className="flex-1" />
<AddToCartInlineButton id={id} />
</div>
</RevealItem>
);
})}
</RevealGroup>
<p className="text-label text-text-muted pt-6">Alle Preise inkl. MwSt., zzgl. Versandkosten.</p>
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
export function ShopHeader() {
return (
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
<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">Shop</span>
</p>
<p
className="font-semibold text-h-feature text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Shop
</p>
<p className="text-body text-text-muted">
Alles, was du für mehr Klarheit im Alltag brauchst.
</p>
</Reveal>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { ShopHeader } from "./components/ShopHeader";
import { ProductGrid } from "./components/ProductGrid";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
export const metadata: Metadata = {
title: "Shop",
description:
"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",
description:
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
url: "/shop",
type: "website",
},
};
export default function ShopPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<ShopHeader />
<ProductGrid />
<TrustRow />
</main>
<Footer />
</>
);
}
@@ -1,31 +0,0 @@
"use client";
import Link from "next/link";
import { addToCart } from "../../lib/cart";
/**
* Shared by the Hero's "ToDo-Karten bestellen" and the pricing panel's "In
* den Warenkorb" — both are real add-to-cart actions per app/lib/cart.ts,
* not just decorative links, and both go to /cart per the click-through
* convention already established for page-todo-karten's two CTAs.
*/
export function AddToCartButton({
label,
className,
}: {
label: string;
className?: string;
}) {
return (
<Link
href="/cart"
onClick={() => addToCart("todo-karten")}
className={
className ??
"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 whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
}
>
{label}
</Link>
);
}
+5 -1
View File
@@ -1,6 +1,7 @@
import Image from "next/image";
import { AddToCartButton } from "./AddToCartButton";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { TOTAL_DAYS_DE } from "../../lib/shipping";
const bullets = [
"50 ToDo-Karten",
@@ -49,6 +50,9 @@ export function Pricing() {
<p className="font-bold text-h3 text-text-primary">12,90 </p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
</div>
<p className="text-label text-text-muted">
Lieferzeit: {TOTAL_DAYS_DE.min}{TOTAL_DAYS_DE.max} Werktage innerhalb Deutschlands
</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"
+23 -9
View File
@@ -23,16 +23,23 @@ const testimonials = [
},
];
// Card layout/style matches app/challenge/page.tsx's testimonial section
// exactly (bg-muted filled card, no border, no decorative quote-mark,
// quote on top with flex-1 pushing the avatar/name row to the bottom) —
// including its hover-lift + avatar-scale micro-interactions, kept in
// sync deliberately since both sections are meant to look and behave
// identically. Only the photos differ (this page's own Figma-sourced
// avatars, not Challenge's shared avatar-1/2/3.jpg).
// Card layout/style matches app/challenge/page.tsx's and
// app/newsletter/'s testimonial sections exactly (bg-muted filled card,
// no border, decorative quote-mark, quote on top with flex-1 pushing
// the avatar/name row to the bottom, hover-lift + avatar-scale) — all
// three kept in sync deliberately as one shared visual pattern, overriding
// each page's own (differing) Figma spec for this one section. Only the
// photos differ per page (each has its own Figma-sourced avatars).
// max-w-[1600px] on the inner wrapper matches Challenge's testimonial
// container cap — without it this section's fluid px-[layout-padding-x]
// alone has no upper bound, so cards kept growing wider than Challenge's
// past ~1440px viewports (capped at 1280px there) and read as
// inconsistently narrower on Challenge. 1600px is a deliberate compromise
// between the two, not either page's original value.
export function Testimonials() {
return (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
<Reveal
className="font-semibold text-h-emphasis text-text-primary text-center"
style={{ fontFamily: "var(--font-lora)" }}
@@ -44,9 +51,15 @@ export function Testimonials() {
{testimonials.map((t) => (
<RevealItem
key={t.name}
className="group md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<p className="flex-1 text-body-sm text-text-primary leading-[1.6]">{t.quote}</p>
<span
aria-hidden
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
>
</span>
<p className="flex-1 text-body-sm text-text-primary leading-[1.6] pr-8">{t.quote}</p>
<div className="flex items-center gap-3">
<div className="relative size-10 shrink-0 rounded-full overflow-hidden transition-transform duration-300 group-hover:scale-110">
<Image src={t.avatar} alt={t.name} fill sizes="40px" className="object-cover" />
@@ -59,6 +72,7 @@ export function Testimonials() {
</RevealItem>
))}
</RevealGroup>
</div>
</section>
);
}
+58 -33
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import Image from "next/image";
import { AddToCartButton } from "./AddToCartButton";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
const checklist = [
@@ -15,11 +15,27 @@ export function TodoKartenHero() {
{/* 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 under the site-wide md: convention. */}
<div className="flex flex-col lg:grid lg:grid-cols-12 lg:items-center gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12 lg:pt-0">
Tablet widths under the site-wide md: convention. 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 */}
{/* 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>
@@ -27,37 +43,46 @@ export function TodoKartenHero() {
<span className="text-text-primary">ToDo-Karten</span>
</p>
{/* 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
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Kleine Karten. Große Wirkung.
{/* 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
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Kleine Karten. Große Wirkung.
</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-center w-full">
<img alt="" src="/icon-check.svg" className="size-5 shrink-0" />
<span className="flex-1 text-body text-text-primary">{item}</span>
</li>
))}
</ul>
<AddToCartButton label="ToDo-Karten bestellen" />
</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-center w-full">
<img alt="" src="/icon-check.svg" className="size-5 shrink-0" />
<span className="flex-1 text-body text-text-primary">{item}</span>
</li>
))}
</ul>
<AddToCartButton label="ToDo-Karten bestellen" />
</Reveal>
{/* group + subtle scale-on-hover — a common, restrained "photo
+113
View File
@@ -0,0 +1,113 @@
import Link from "next/link";
import {
SHIPPING_COST,
FREE_SHIPPING_THRESHOLD,
HANDLING_DAYS,
TRANSIT_DAYS_DE,
TOTAL_DAYS_DE,
} from "../../lib/shipping";
import { formatPrice } from "../../lib/products";
// Single source of truth for both the full /versand page and the cart's
// quick-reference VersandModal — same section ids/titles/copy either way,
// so the two can never drift apart. `withAnchors` adds scroll-margin (for
// the full page's TOC) — the modal doesn't need it, it just scrolls its
// own dialog body.
export const VERSAND_SECTION_IDS = [
{ id: "lieferzeiten", title: "Lieferzeiten" },
{ id: "versandkosten", title: "Versandkosten" },
{ id: "liefergebiet", title: "Liefergebiet" },
{ id: "versanddienstleister", title: "Versanddienstleister" },
{ id: "transportrisiko", title: "Transportrisiko" },
{ id: "widerruf", title: "Rückgabe & Widerruf" },
] as const;
function Section({
id,
title,
withAnchor,
children,
}: {
id: string;
title: string;
withAnchor: boolean;
children: React.ReactNode;
}) {
return (
<div
id={id}
className={"flex flex-col gap-3 items-start w-full" + (withAnchor ? " scroll-mt-32" : "")}
>
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{title}
</p>
<div className="flex flex-col gap-2 items-start text-body text-text-body w-full">
{children}
</div>
</div>
);
}
export function VersandSections({ withAnchors = false }: { withAnchors?: boolean }) {
return (
<div className="flex flex-col gap-10 items-start w-full">
<Section id="lieferzeiten" title="Lieferzeiten" withAnchor={withAnchors}>
<p>
Nach Zahlungseingang bereiten wir deine Bestellung in der Regel innerhalb von{" "}
{HANDLING_DAYS.min}{HANDLING_DAYS.max} Werktagen zum Versand vor. Die Versanddauer
innerhalb Deutschlands beträgt anschließend zusätzlich {TRANSIT_DAYS_DE.min}
{TRANSIT_DAYS_DE.max} Werktage.
</p>
<p>
Damit ist deine Bestellung in der Regel nach {TOTAL_DAYS_DE.min}{TOTAL_DAYS_DE.max}{" "}
Werktagen bei dir, sofern auf der jeweiligen Produktseite nichts anderes angegeben ist.
Als Werktage gelten Montag bis Freitag, ausgenommen gesetzliche Feiertage.
</p>
</Section>
<Section id="versandkosten" title="Versandkosten" withAnchor={withAnchors}>
<p>
Die Versandkosten innerhalb Deutschlands betragen pauschal {formatPrice(SHIPPING_COST)}{" "}
pro Bestellung. Ab einem Bestellwert von {formatPrice(FREE_SHIPPING_THRESHOLD)} versenden
wir kostenlos.
</p>
<p>Alle angegebenen Preise verstehen sich inklusive der gesetzlichen Mehrwertsteuer.</p>
</Section>
<Section id="liefergebiet" title="Liefergebiet" withAnchor={withAnchors}>
<p>
Aktuell versenden wir ausschließlich innerhalb Deutschlands. Eine Lieferung ins Ausland
ist derzeit nicht möglich.
</p>
</Section>
<Section id="versanddienstleister" title="Versanddienstleister" withAnchor={withAnchors}>
<p>
Der Versand erfolgt über DHL. Sobald deine Bestellung das Lager verlässt, erhältst du
eine E-Mail mit der Sendungsverfolgung.
</p>
</Section>
<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.
</p>
</Section>
<Section id="widerruf" title="Rückgabe & Widerruf" withAnchor={withAnchors}>
<p>
Informationen zu Rückgabe, Rücksendekosten und deinem gesetzlichen Widerrufsrecht findest
du in unserer{" "}
<Link href="/widerruf" className="text-brand hover:underline">
Widerrufsbelehrung
</Link>
.
</p>
</Section>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useState } from "react";
import { VERSAND_SECTION_IDS } from "./VersandSections";
// lg:-only sidebar — same "wide fixed-width block next to content" shape
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
// #5): a 360px TOC card plus a readable content column already exceeds
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
// split at Tablet widths.
export function VersandTOC() {
const [active, setActive] = useState<string>(VERSAND_SECTION_IDS[0].id);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((entry) => entry.isIntersecting);
if (visible.length > 0) setActive(visible[0].target.id);
},
{ rootMargin: "-120px 0px -65% 0px", threshold: 0 }
);
VERSAND_SECTION_IDS.forEach(({ id }) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
return (
<nav className="hidden lg:flex flex-col gap-1 w-[22.5rem] shrink-0 bg-bg-base border border-border rounded-md p-6 sticky top-32 self-start">
<p className="text-label font-semibold text-text-muted uppercase tracking-wide mb-2">
Inhaltsverzeichnis
</p>
{VERSAND_SECTION_IDS.map(({ id, title }) => (
<a
key={id}
href={`#${id}`}
className={
"px-3 py-2 rounded-sm text-body-sm transition-colors border-l-2 " +
(active === id
? "border-toc-active-border bg-bg-muted text-text-primary font-semibold"
: "border-transparent text-text-muted hover:text-text-primary")
}
>
{title}
</a>
))}
</nav>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { VersandSections } from "./components/VersandSections";
import { VersandTOC } from "./components/VersandTOC";
export const metadata: Metadata = {
title: "Versand",
description:
"Versandkosten, Lieferzeiten und Liefergebiet für Bestellungen bei einfach produktiv.",
alternates: { canonical: "/versand" },
};
export default function VersandPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
<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">Versand</span>
</p>
<p
className="font-semibold text-h-feature text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Versand
</p>
<p className="text-body text-text-muted">
Transparent, fair und pünktlich alles zu Kosten, Lieferzeiten und Liefergebiet.
</p>
</Reveal>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
<VersandTOC />
<div className="w-full lg:flex-1 max-w-[45rem]">
<VersandSections withAnchors />
</div>
</div>
</main>
<Footer />
</>
);
}