Add ToDo-Karten product page, fluid design tokens, and Navbar/Hero fixes

New /todo-cards page (Hero, How-it-works, Focus, Testimonials, Pricing)
built with the fluid clamp() token system (app/lib/fluid.ts) and
scroll-reveal animations (app/components/Reveal.tsx), matching styling
consistency with /challenge's testimonial section.

Navbar: page-aware active state and anchor-link navigation from any
route (not just "/"), fixed logo click to actually navigate instead of
silently rewriting the URL, fluid nav text size, custom hash-scroll
handling for cross-page anchor links.

Hero: responsive breakpoint fix for the text/image split at Tablet
widths, entrance animation for the brand's orange dot, removed a red
dot artifact from hero.png.

Also includes prior uncommitted work on About/Blog/Newsletter/Footer
and the cart lib (app/lib/cart.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-19 11:34:52 +00:00
parent 35670126ca
commit e4f0c66d7b
42 changed files with 1635 additions and 363 deletions
+49 -33
View File
@@ -1,72 +1,88 @@
import { Reveal } from "./Reveal";
export function About() {
return (
<section id="ueber-bjoern" className="bg-[#111] flex items-stretch w-full">
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
{/* Left: text content — relative + z-10 so it renders above the overlapping photo */}
<div className="flex-[1_0_0] flex flex-col gap-4 justify-center px-[5rem] py-8 min-w-0 relative z-10">
{/* Text content — relative + z-10 so it renders above the overlapping
photo at md+. Comes first in DOM at every breakpoint (no reorder
here — unlike Hero, there's no conversion CTA at stake). */}
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1_0_0] min-w-0 relative z-10">
{/* Large serif statement — width-constrained as per design */}
<p
className="font-semibold text-white text-[1.75rem] leading-normal w-[17.625rem]"
className="font-semibold text-bg-white text-h-section leading-normal w-[17.625rem]"
style={{ fontFamily: "var(--font-lora)" }}
>
Ich glaube nicht, dass Menschen mehr Disziplin brauchen.
</p>
{/* Quote row: script quote + golden divider + author bio */}
<div className="flex items-start justify-between w-full">
{/* Quote row: script quote / divider / author bio — side-by-side
from md+, stacked with a horizontal divider below md */}
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6 md:gap-0 w-full">
{/* Caveat script text with signature positioned below */}
<div className="relative flex-1" style={{ minHeight: "8rem" }}>
<div
className="font-normal text-[#f6a701] text-[2.25rem]"
className="font-normal text-brand text-[2.25rem]"
style={{ fontFamily: "var(--font-caveat)", lineHeight: "2rem" }}
>
<p>Ich glaube,</p>
<p>sie brauchen&nbsp;</p>
<p>weniger Reibung.</p>
</div>
{/* Signature — absolute, sits just below the three Caveat lines (3 × 32px = 96px) */}
{/* Signature underline — hand-drawn organic stroke, sits just below the three Caveat lines (3 × 32px = 96px) */}
<div
className="absolute overflow-hidden pointer-events-none"
className="absolute pointer-events-none"
style={{ top: "6.44rem", left: "1.5rem", width: "9.375rem", height: "1.375rem" }}
>
<img
alt=""
src="/about-signature.png"
className="absolute max-w-none"
style={{ width: "101.25%", height: "118.18%", left: "-0.62%", top: "-17.72%" }}
/>
<svg viewBox="0 0 150 22" fill="none" className="w-full h-full text-brand" aria-hidden="true">
{/* Thin, near-constant-width ease-out sweep, traced from the actual
Figma signature-image raster (node 4175:150, page-home) — a single
smooth concave rise, no waviness/taper, ending ~88% across. */}
<path
d="M3,10 C20,8.3 35,6.7 50,5.4 C70,3.7 85,2.2 100,1.1 C112,0.5 122,0.3 131,0.1"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
</div>
</div>
{/* Golden vertical divider + author lines */}
<div className="flex gap-6 items-start shrink-0">
<div className="bg-[#f6a701] w-[2px] h-24 shrink-0" />
<div
className="text-white font-normal whitespace-nowrap"
style={{ fontSize: "1rem", lineHeight: "1.5rem" }}
>
<p>Björn.</p>
<p>Führungskraft.</p>
<p>Familienmensch.</p>
<p>Gründer von einfach-produktiv.</p>
</div>
{/* Divider — horizontal full-width line below md, vertical
gold line beside the author bio from md+ */}
<div className="bg-brand w-full h-px md:w-[2px] md:h-24 md:mx-6 shrink-0" />
<div
className="text-bg-white font-normal whitespace-nowrap md:shrink-0"
style={{ fontSize: "1rem", lineHeight: "1.5rem" }}
>
<p>Björn.</p>
<p>Führungskraft.</p>
<p>Familienmensch.</p>
<p>Gründer von einfach-produktiv.</p>
</div>
</div>
</div>
</Reveal>
{/* Right: author photo — overlaps text column via -ml-32, gradient covers the overlap */}
<div className="flex-[1.4_0_0] relative overflow-hidden -ml-48" style={{ minHeight: "14rem" }}>
{/* Author photo — overlaps the text column via -ml-48 from md+ only
(that overlap trick has nothing to blend into once stacked);
plain full-width photo below the text on Mobile. */}
<Reveal
className="relative overflow-hidden w-full md:flex-[1.4_0_0] md:-ml-48"
style={{ minHeight: "14rem" }}
delay={0.15}
>
<img
alt="Björn"
src="/about-author.jpg"
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none"
/>
{/* Left gradient: wide enough to cover the text-column overlap */}
<div className="absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-[#111] to-transparent pointer-events-none" />
</div>
{/* Left gradient: wide enough to cover the text-column overlap — md+ only */}
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
</Reveal>
</section>
);
+48 -39
View File
@@ -1,5 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { getBlogPosts } from "../lib/payload";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
export async function Blog() {
const posts = await getBlogPosts(3);
@@ -11,107 +13,114 @@ export async function Blog() {
.map((post) => ({ ...post, href: `/blog/${post.slug}` }));
return (
<div id="blog" className="flex flex-col gap-[3rem] items-start pb-[4rem] w-full bg-[#f5f0e8]">
<div id="blog" className="flex flex-col gap-12 items-start pb-16 w-full bg-bg-base">
{/* Section header */}
<div className="flex flex-col gap-[0.5rem] items-start px-[5rem] w-full">
<p className="font-bold text-[#f6a701] text-[1.25rem]">
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
<p className="font-bold text-brand text-h-small">
Neue Impulse
</p>
<p
className="font-semibold text-[#222221] text-[1.75rem] leading-normal"
className="font-semibold text-text-primary text-h-section leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
Gedanken, Methoden &amp; Impulse
</p>
</div>
</Reveal>
{/* Posts grid */}
<div className="flex flex-col gap-[2.5rem] items-start px-[5rem] w-full">
<RevealGroup className="flex flex-col gap-10 items-start px-[var(--layout-padding-x)] w-full">
{/* Featured post — thumbnail flush left, card clips with overflow-hidden */}
<div className="w-full border border-[#d9d9d9] rounded-xl overflow-hidden flex gap-3">
<div className="relative h-[220px] shrink-0 w-[60%]">
<img
alt=""
src={featured.thumbnail ?? undefined}
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
{/* Featured post — image on top on Mobile, side-by-side from md+ */}
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
<div className="relative w-full aspect-video md:aspect-auto md:col-span-7 md:h-[220px] bg-bg-muted">
{featured.thumbnail && (
<Image
alt=""
src={featured.thumbnail}
fill
sizes="(min-width: 768px) 58vw, 100vw"
className="object-cover pointer-events-none"
/>
)}
</div>
<div className="flex flex-1 flex-col justify-between py-4 pr-4 min-w-0">
<div className="flex flex-col justify-between gap-4 py-4 px-4 md:pr-4 md:pl-4 md:col-span-5 min-w-0">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-[#777] text-[1rem] uppercase whitespace-nowrap">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{featured.category}</span>
<span></span>
<span>{featured.readTime} Min</span>
</div>
<div className="flex flex-col gap-4 text-[#222221]">
<div className="flex flex-col gap-4 text-text-primary">
<p
className="font-semibold text-[1.75rem] leading-normal"
className="font-semibold text-h-section leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
{featured.title}
</p>
<p className="font-normal text-[1rem] leading-6">
<p className="font-normal text-body leading-6 line-clamp-2 md:line-clamp-none">
{featured.excerpt}
</p>
</div>
</div>
<Link
href={featured.href}
className="font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:text-[#f6a701] transition-colors"
className="font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
>
Zum Beitrag
</Link>
</div>
</div>
</RevealItem>
{/* Secondary posts — thumbnail with own rounded-xl, card py-2 only */}
<div className="flex gap-3 w-full">
{/* Secondary posts — image on top on Mobile, side-by-side from md+ */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
{secondary.map((post) => (
<div
<RevealItem
key={post.id}
className="flex-1 border border-[#d9d9d9] rounded-xl flex gap-3 h-[230px] items-center py-2"
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
>
{/* Thumbnail: own border-radius so it sits neatly inside the padded card */}
<div className="rounded-xl overflow-hidden shrink-0 h-full w-[14.7rem]">
<img
alt=""
src={post.thumbnail ?? undefined}
className="w-full h-full object-cover pointer-events-none"
/>
<div className="relative w-full aspect-video md:aspect-auto md:col-span-2 md:h-[230px] bg-bg-muted">
{post.thumbnail && (
<Image
alt=""
src={post.thumbnail}
fill
sizes="(min-width: 768px) 29vw, 100vw"
className="object-cover pointer-events-none"
/>
)}
</div>
<div className="flex flex-1 flex-col justify-between h-full min-w-0 py-2 pr-2 text-[#222221]">
<div className="flex flex-col justify-between gap-4 py-3 px-4 md:col-span-3 min-w-0 text-text-primary">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-[#777] text-[1rem] uppercase whitespace-nowrap">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
<div className="flex flex-col gap-4">
<p
className="font-semibold text-[1.25rem] leading-normal"
className="font-semibold text-h-small leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
{post.title}
</p>
<p className="font-normal text-[1rem] leading-6">
<p className="font-normal text-body leading-6 line-clamp-1 md:line-clamp-none">
{post.excerpt}
</p>
</div>
</div>
<Link
href={post.href}
className="font-bold text-[1rem] whitespace-nowrap hover:text-[#f6a701] transition-colors"
className="font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
>
Zum Beitrag
</Link>
</div>
</div>
</RevealItem>
))}
</div>
</div>
</RevealGroup>
</div>
);
}
+61 -50
View File
@@ -1,57 +1,68 @@
export function Divider() {
import { Reveal } from "./Reveal";
function Word({ children }: { children: string }) {
return (
<div className="flex items-center justify-center pb-[1.25rem] pt-[3rem] w-full bg-[#f5f0e8]">
<div className="flex gap-[2rem] items-center overflow-clip shrink-0">
<p
className="font-bold leading-normal text-text-primary text-h2 whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
{children}
</p>
);
}
<p
className="font-bold leading-normal text-[#222221] text-[2rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
Klarheit
</p>
{/* Arrow separator (flipped) */}
<div className="flex items-center justify-center">
<div className="-scale-y-100 rotate-180">
<div className="h-[0.75rem] relative w-[2.625rem]">
<img alt="" src="/icon-separator.svg" className="absolute inset-0 w-full h-full" />
</div>
</div>
function Arrow() {
return (
<div className="flex items-center justify-center shrink-0">
<div className="-scale-y-100 rotate-180">
<div className="relative" style={{ height: "var(--divider-arrow-h)", width: "var(--divider-arrow-w)" }}>
<img alt="" src="/icon-separator.svg" className="absolute inset-0 w-full h-full" />
</div>
<p
className="font-bold leading-normal text-[#222221] text-[2rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
Fokus
</p>
{/* Arrow separator (flipped) */}
<div className="flex items-center justify-center">
<div className="-scale-y-100 rotate-180">
<div className="h-[0.75rem] relative w-[2.625rem]">
<img alt="" src="/icon-separator.svg" className="absolute inset-0 w-full h-full" />
</div>
</div>
</div>
<p
className="font-bold leading-normal text-[#222221] text-[2rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
Entlastung
</p>
{/* Sparkle icon */}
<div className="flex items-center justify-center h-[2.506rem] w-[1.919rem]">
<div style={{ rotate: "4.6deg" }}>
<div className="h-[2.375rem] relative w-[1.734rem]">
<img alt="" src="/icon-separator-right.svg" className="absolute inset-0 w-full h-full" />
</div>
</div>
</div>
</div>
</div>
);
}
export function Divider() {
return (
<Reveal className="flex items-center justify-center flex-wrap gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center">
{/* Word + its trailing icon are grouped into one shrink-0 flex unit
so flex-wrap only ever breaks BETWEEN pairs, never leaving an
arrow stranded alone on its own line — the arrows are always
visible now (previously hidden below md: entirely to sidestep
that exact problem), this fixes the root cause instead. */}
<div className="flex items-center gap-8 shrink-0">
<Word>Klarheit</Word>
<Arrow />
</div>
<div className="flex items-center gap-8 shrink-0">
<Word>Fokus</Word>
<Arrow />
</div>
<div className="flex items-center gap-8 shrink-0">
<Word>Entlastung</Word>
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
the text-h2 words next to it, previously hard rem values that
stayed visually static while the words scaled. */}
<div
className="flex items-center justify-center"
style={{ height: "var(--divider-sparkle-h)", width: "var(--divider-sparkle-w)" }}
>
<div style={{ rotate: "4.6deg" }}>
<div
className="relative"
style={{ height: "var(--divider-sparkle-inner-h)", width: "var(--divider-sparkle-inner-w)" }}
>
<img alt="" src="/icon-separator-right.svg" className="absolute inset-0 w-full h-full" />
</div>
</div>
</div>
</div>
</Reveal>
);
}
+12 -12
View File
@@ -9,43 +9,43 @@ const links = [
export function Footer() {
return (
<footer className="bg-[#111] flex flex-col items-center justify-end w-full mt-auto">
<footer className="bg-bg-dark flex flex-col items-center justify-end w-full mt-auto">
{/* 3px top divider — #30302c, not a border */}
{/* 3px top divider — not a border */}
<div className="bg-[#30302c] h-[3px] w-full shrink-0" />
{/* Footer inner — max-width 1280px, centered */}
<div className="flex flex-col items-start w-full max-w-[1280px] py-4">
<div className="flex flex-col items-center w-full max-w-[1280px] py-8 md:py-4">
{/* Three columns: logo | @handle | links */}
<div className="flex items-center justify-between px-16 w-full">
{/* Three groups: logo | @handle | links — stacked + centered below md */}
<div className="flex flex-col md:flex-row items-center md:justify-between gap-6 md:gap-0 px-8 md:px-16 w-full">
{/* Logo: "einfach produktiv" white + "." gold */}
<div className="flex items-center p-2 shrink-0">
<span
className="font-semibold text-white text-[1.25rem] leading-normal whitespace-nowrap"
className="font-semibold text-bg-white text-h-small leading-normal whitespace-nowrap"
style={{ fontFamily: "var(--font-lora)" }}
>
einfach produktiv
<span style={{ color: "#fdb705" }}>.</span>
<span className="text-brand">.</span>
</span>
</div>
{/* Social handle — Lora Regular (weight 400), 24px */}
{/* Social handle — Lora Regular (weight 400) */}
<p
className="font-normal text-white text-[1.5rem] text-center leading-[1.2] whitespace-nowrap shrink-0"
className="font-normal text-bg-white text-h-small text-center leading-[1.2] whitespace-nowrap shrink-0"
style={{ fontFamily: "var(--font-lora)", fontWeight: 400 }}
>
@einfach.produktiv
</p>
{/* Legal links — Inter Regular 14px, gap 24px */}
<div className="flex items-start gap-6 shrink-0">
{/* Legal links — wrap + center below md instead of a rigid row */}
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 shrink-0">
{links.map((link) => (
<Link
key={link.label}
href={link.href}
className="font-normal text-white text-[0.875rem] leading-[1.5] whitespace-nowrap hover:text-[#f6a701] transition-colors"
className="font-normal text-bg-white text-body-sm leading-[1.5] whitespace-nowrap hover:text-brand transition-colors"
>
{link.label}
</Link>
+119 -69
View File
@@ -1,81 +1,131 @@
import Link from "next/link";
import Image from "next/image";
import { PopIn, Reveal } from "./Reveal";
export function Hero() {
return (
<section className="bg-[#f5f0e8] flex items-center justify-between relative w-full overflow-hidden">
<section className="bg-bg-base w-full overflow-hidden">
{/* Structural breakpoint is lg: (1024px) here, not the site-wide md:
(768px) — a documented exception (see Gotcha in the figma-to-nextjs
skill). At md:col-span-5 the text column was only ~320px at
768-1023px viewports, too narrow for the heading/CTA/social-proof
row (which wrapped to 3 cramped lines). Staying stacked full-width
through the whole Tablet range and only splitting into the 5/7
grid once there's real room (≥1024px) fixes that without touching
the 5/7 ratio itself, which is fine once it has space. */}
<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">
{/* Left: text content */}
<div className="flex flex-[1_0_0] flex-col gap-[1.75rem] items-start min-w-px overflow-clip pl-[5rem] pr-[2.5rem] relative">
{/* Text content — first in DOM/visual order at every breakpoint so
the CTA stays above the fold on Mobile (deliberate exception to
the "keep DOM order" default, see Hero decision in the plan).
Reveal fires ~immediately since Hero is already in the initial
viewport — this doubles as the page's entrance animation. */}
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
{/* Heading — w-[min-content] forces wrap at widest word ("Produktivität") */}
<p
className="font-semibold leading-[0] min-w-full shrink-0 text-[0px] text-black w-[min-content] [word-break:break-word]"
style={{ fontFamily: "var(--font-playfair)" }}
>
<span className="leading-[4.5rem] text-[4rem]">Produktivität darf sich leicht anfühlen</span>
<span className="leading-[4.5rem] text-[4rem] text-[#fdb705]">.</span>
</p>
{/* Subheading */}
<p className="font-semibold leading-[2.375rem] min-w-full shrink-0 text-[#222221] text-[1.5rem] w-[min-content] [word-break:break-word] not-italic">
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
</p>
{/* CTA */}
<Link
href="/challenge"
className="flex gap-[1rem] items-center justify-center overflow-clip px-[1.5rem] py-[0.75rem] rounded-[0.5rem] shrink-0 bg-[#f6a701] hover:brightness-95 transition-all"
>
<span className="font-semibold leading-[2.375rem] text-[#222221] text-[1.375rem] whitespace-nowrap not-italic">
Starte mit der 7-Tage-Challenge
</span>
<div className="relative h-[1.1875rem] w-[1.5625rem] shrink-0">
<img
alt=""
src="/icon-check.svg"
className="absolute inset-0 w-full h-full"
/>
</div>
</Link>
{/* Social proof */}
<div className="flex gap-[0.75rem] items-start overflow-clip shrink-0 w-full">
{/* Avatars — gap 2px, not overlapping */}
<div className="flex gap-[0.125rem] items-center shrink-0">
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
<div
key={i}
className="relative w-[2.8125rem] h-[2.8125rem] rounded-full overflow-hidden shrink-0"
>
<Image src={src} alt="" fill className="object-cover" />
</div>
))}
</div>
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-[#222221] text-[1rem] [word-break:break-word]">
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
{/* Heading — forced break after "darf" below lg: (1024px),
natural wrap from lg: up. Below lg: the Hero is stacked
full-width and narrower per-viewport, where natural wrap
produced an awkward break — force it after "darf" there via
a responsive <br/> (visible by default, turned off at lg:+).
From lg: up the 5/12 grid's text column wraps fine on its
own, no forced break needed. */}
<p
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
<span className="text-display">
Produktivität darf<br className="lg:hidden" /> sich leicht anfühlen
</span>
{/* Brand's signature orange dot (also in the logo/footer) —
bouncy pop-in once the heading scrolls into view, timed to
land just after the Reveal's own 0.6s fade-up so it reads
as a deliberate flourish, not simultaneous with the text.
One-shot, not a looping pulse — continuous motion next to
the primary CTA would be distracting rather than "cool". */}
<PopIn className="text-display text-brand inline-block" delay={0.5}>
.
</PopIn>
</p>
</div>
{/* Subheading */}
<p className="font-semibold leading-[2.375rem] min-w-full shrink-0 text-text-primary text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
</p>
{/* CTA */}
<Link
href="/challenge"
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
>
<span className="font-semibold leading-[2.375rem] text-text-primary text-h3 whitespace-nowrap not-italic">
Starte mit der 7-Tage-Challenge
</span>
<div className="relative h-[1.1875rem] w-[1.5625rem] shrink-0">
<img
alt=""
src="/icon-check.svg"
className="absolute inset-0 w-full h-full"
/>
</div>
</Link>
{/* Social proof */}
<div className="flex gap-3 items-start overflow-clip shrink-0 w-full">
{/* Avatars — gap 2px, not overlapping */}
<div className="flex gap-[0.125rem] items-center shrink-0">
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
<div
key={i}
className="relative rounded-full overflow-hidden shrink-0"
style={{ width: "var(--hero-avatar-size)", height: "var(--hero-avatar-size)" }}
>
<Image src={src} alt="" fill sizes="46px" className="object-cover" />
</div>
))}
</div>
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body [word-break:break-word]">
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
</p>
</div>
</Reveal>
{/* Image — bleeds to the true edge at every breakpoint (never
padded). Below lg: (stacked layout) the full 887:583 aspect
ratio at 100vw would make the image ~600-900px tall and
dominate the page, so height is capped and object-cover crops
it into a supporting banner instead; at lg:+ (grid, image only
58% width) the full aspect ratio looks right again, so the cap
is lifted. A small negative top margin below lg: pulls it up to
slightly tuck under the text block (deliberately less than the
social-proof row's height, so it never covers the avatars/text).
Scoped to md:-only (mt-0 at base and again at lg:) — it's a
Tablet-specific touch, not a permanent effect. No shadow: a
plain box-shadow reads as a hard rectangular edge against the
existing corner/right/bottom mask-gradient fade below, which
looked worse than no shadow at all — tried and reverted. */}
<Reveal
className="order-2 lg:order-none lg:col-span-7 relative w-full aspect-[887/583] max-h-[16rem] md:max-h-[22rem] lg:max-h-none mt-0 md:-mt-6 lg:mt-0"
delay={0.15}
>
<Image
src="/hero.png"
alt=""
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover"
style={{
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
WebkitMaskComposite: "destination-in",
maskImage:
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
maskComposite: "intersect",
}}
/>
</Reveal>
</div>
{/* Right: hero image — gradient mask fades left + top edges into the cream background */}
<div
className="h-[36.4375rem] shrink-0 w-[55.4375rem]"
style={{
backgroundImage: "url('/hero.png')",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
WebkitMaskComposite: "destination-in",
maskImage:
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
maskComposite: "intersect",
}}
aria-hidden="true"
/>
</section>
);
}
+325 -54
View File
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useCartCount } from "../lib/cart";
const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem]
const SCROLL_DURATION = 800; // ms
@@ -37,9 +39,67 @@ const anchorIds = navLinks
.filter((l) => l.href.startsWith("#"))
.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"];
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
if (href === "#werkzeuge") {
return (
WERKZEUGE_ROUTES.some((route) => pathname.startsWith(route)) ||
(pathname === "/" && activeSection === "werkzeuge")
);
}
if (href.startsWith("#")) {
return pathname === "/" && activeSection === href.slice(1);
}
return pathname === href || pathname.startsWith(`${href}/`);
}
// Cart icon + count badge — traced from the Figma Navbar/Default component's
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
// the hamburger drawer below lg) since the cart is a primary nav affordance.
// Badge only renders once something is actually in the cart.
function CartLink() {
const count = useCartCount();
return (
<Link
href="/cart"
aria-label={count > 0 ? `Warenkorb, ${count} 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">
<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"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<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}
</span>
)}
</Link>
);
}
export function Navbar() {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
const [mobileOpen, setMobileOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
@@ -47,6 +107,30 @@ export function Navbar() {
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Handles landing on "/#werkzeuge" etc. after a cross-page nav click
// (e.g. clicking "Werkzeuge" from /todo-cards) or a direct URL load with
// a hash. Next.js's own built-in hash-scroll-after-navigation doesn't
// reliably apply the sticky navbar's height offset and can fire before
// the just-mounted page's layout has settled — this reimplements it with
// the exact same smoothScrollTo + NAVBAR_HEIGHT offset as the same-page
// click handlers below, so behavior is identical either way. Runs
// whenever pathname becomes "/" (covers both the initial load and a
// client-side transition landing here), a short delay lets layout
// settle first.
useEffect(() => {
if (pathname !== "/") return;
const hash = window.location.hash.slice(1);
if (!anchorIds.includes(hash)) return;
const timer = setTimeout(() => {
const el = document.getElementById(hash);
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
smoothScrollTo(top);
}
}, 50);
return () => clearTimeout(timer);
}, [pathname]);
useEffect(() => {
const onScroll = () => {
// trigger line = 1/3 from top of viewport (= 2/3 from bottom)
@@ -66,87 +150,274 @@ export function Navbar() {
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Close on viewport resize past the structural breakpoint, so the drawer
// never lingers open behind the (now visible) desktop nav. The hamburger
// exists through both sub-768 ("Collapsed") and 768-1023 ("Collapsed-CTA")
// tiers, and only disappears at lg (1024) — see Navbar section of the plan.
useEffect(() => {
const onResize = () => {
if (window.innerWidth >= 1024) setMobileOpen(false);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Focus management: move focus into the panel on open, trap Tab within
// it, close + return focus to the hamburger button on Escape.
useEffect(() => {
if (!mobileOpen) return;
const panel = panelRef.current;
const focusables = panel?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled])'
);
focusables?.[0]?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setMobileOpen(false);
hamburgerRef.current?.focus();
return;
}
if (e.key !== "Tab" || !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);
}, [mobileOpen]);
const closeMobile = () => setMobileOpen(false);
return (
<header
className={`sticky top-0 z-50 w-full h-[6.25rem] flex items-center px-[2rem] transition-[background-color,backdrop-filter] duration-300 ${
scrolled
? "bg-[#f5f0e8]/80 backdrop-blur-md"
: "bg-[#f5f0e8]"
className={`sticky top-0 z-50 w-full h-[6.25rem] flex flex-col transition-[background-color,backdrop-filter] duration-300 ${
scrolled || mobileOpen
? "bg-bg-base/80 backdrop-blur-md"
: "bg-bg-base"
}`}
>
<div className="w-full flex items-center justify-between">
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-8">
<div className="w-full flex items-center justify-between">
{/* Logo — smooth scroll to top */}
<a
href="/"
className="shrink-0"
onClick={(e) => {
e.preventDefault();
smoothScrollTo(0);
history.replaceState(null, "", "/");
}}
>
<Image
src="/logo.png"
alt="einfach produktiv"
width={181}
height={61}
priority
/>
</a>
{/* Nav links */}
<nav className="flex items-center gap-[3rem]">
{navLinks.map((link) =>
link.href.startsWith("#") ? (
<a
key={link.href}
href={link.href}
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(link.href.slice(1));
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
smoothScrollTo(top);
{/* Logo — real navigation to "/" from anywhere else; only when
already on "/" does it become a same-page smooth-scroll-to-top
instead (a full Link navigation there would be pointless).
Previously always preventDefault()'d and silently
history.replaceState()'d the URL to "/" without an actual
navigation — from another page that just rewrote the URL bar
while leaving that page's content on screen. */}
<Link
href="/"
className="shrink-0"
onClick={
pathname === "/"
? (e) => {
e.preventDefault();
closeMobile();
smoothScrollTo(0);
history.replaceState(null, "", "/");
}
history.replaceState(null, "", link.href);
}}
className={`text-[1.125rem] font-semibold tracking-[0.01125rem] transition-colors cursor-pointer ${
activeSection === link.href.slice(1)
? "text-[#f6a701]"
: "text-[#222221] hover:text-[#f6a701]"
: closeMobile
}
>
<Image
src="/logo.png"
alt="einfach produktiv"
width={181}
height={61}
priority
/>
</Link>
{/* Nav links — inline only at lg+ (1024px, full "Navbar/Default").
Between md and lg ("Collapsed-CTA") they move into the
hamburger drawer while the CTA buttons stay inline — matches
Figma's Navbar-Mobile component set exactly, see the plan.
Matches the Figma NavLink component's hover: text never
changes color, an underline (brand, 2px x 40px) fades in on
hover and stays on for the active section. */}
<nav className="hidden lg:flex items-center gap-12">
{navLinks.map((link) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
const underline = (
<span
className={`h-[2px] bg-brand transition-all duration-200 ${
isActive ? "w-10 opacity-100" : "w-full opacity-0 group-hover:w-10 group-hover:opacity-100"
}`}
/>
);
// Anchor links only get the custom in-page smooth-scroll
// behavior while actually on "/" — its target section
// doesn't exist on any other route. From elsewhere (e.g.
// /todo-cards), a plain navigation to "/#werkzeuge" is correct
// instead: the global `scroll-padding-top` in globals.css
// already offsets for the sticky navbar on a native hash
// landing, so no extra JS is needed for that case.
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
return (
<Link
key={link.href}
href={resolvedHref}
onClick={
isHomeAnchor
? (e) => {
e.preventDefault();
const el = document.getElementById(link.href.slice(1));
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
smoothScrollTo(top);
}
history.replaceState(null, "", link.href);
}
: undefined
}
className="group flex flex-col items-center gap-1 cursor-pointer"
>
<span className="text-h4 font-semibold text-text-primary tracking-[0.01125rem] whitespace-nowrap">
{link.label}
</span>
{underline}
</Link>
);
})}
</nav>
{/* Trailing controls — CTA buttons (md+), cart (always), hamburger
(below lg). Grouped so spacing stays consistent as individual
children hide/show across the three breakpoint tiers. */}
<div className="flex items-center gap-2">
<CartLink />
{/* 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"
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>
<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"
>
7-Tage-Challenge
</Link>
</div>
{/* Hamburger — visible below lg (1024px): covers both the fully
collapsed (<768) and "Collapsed-CTA" (768-1023) tiers, since
the nav links live in the drawer through both. */}
<button
ref={hamburgerRef}
type="button"
aria-expanded={mobileOpen}
aria-controls="mobile-nav-panel"
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
onClick={() => setMobileOpen((v) => !v)}
className="lg:hidden flex h-11 w-11 items-center justify-center shrink-0"
>
<span className="relative block h-4 w-6">
<span
className={`absolute left-0 top-0 block h-0.5 w-6 bg-text-primary transition-transform duration-200 ${
mobileOpen ? "translate-y-[7px] rotate-45" : ""
}`}
/>
<span
className={`absolute left-0 top-[7px] block h-0.5 w-6 bg-text-primary transition-opacity duration-200 ${
mobileOpen ? "opacity-0" : "opacity-100"
}`}
/>
<span
className={`absolute left-0 top-[14px] block h-0.5 w-6 bg-text-primary transition-transform duration-200 ${
mobileOpen ? "-translate-y-[7px] -rotate-45" : ""
}`}
/>
</span>
</button>
</div>
</div>
</div>
{/* Mobile drawer panel — toggleable below lg (see hamburger above) */}
<div
id="mobile-nav-panel"
ref={panelRef}
className={`lg:hidden w-full overflow-hidden transition-[max-height] duration-300 ease-in-out ${
mobileOpen ? "max-h-[30rem]" : "max-h-0"
}`}
>
<nav className="flex flex-col gap-6 px-8 pt-2 pb-6">
{navLinks.map((link) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
return link.href.startsWith("#") ? (
<Link
key={link.href}
href={resolvedHref}
onClick={
isHomeAnchor
? (e) => {
e.preventDefault();
closeMobile();
const el = document.getElementById(link.href.slice(1));
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
smoothScrollTo(top);
}
history.replaceState(null, "", link.href);
}
: closeMobile
}
className="min-h-11 flex flex-col justify-center gap-1 text-h4 font-semibold text-text-primary w-fit"
>
{link.label}
</a>
<span
className={`h-[2px] bg-brand transition-opacity duration-200 ${
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
}`}
/>
</Link>
) : (
<Link
key={link.href}
href={link.href}
className="text-[1.125rem] font-semibold text-[#222221] tracking-[0.01125rem] hover:text-[#f6a701] transition-colors"
onClick={closeMobile}
className="min-h-11 flex items-center text-h4 font-semibold text-text-primary"
>
{link.label}
</Link>
)
)}
);
})}
</nav>
{/* CTA buttons */}
<div className="flex items-center gap-[0.75rem] p-[0.5rem]">
<div className="flex flex-col gap-3 px-8 pb-8">
<Link
href="/newsletter"
className="px-[1.5rem] py-[1rem] rounded-[0.5rem] border border-[#868686] text-[1.125rem] font-bold text-[#222221] hover:bg-[#222221] hover:text-white transition-colors"
onClick={closeMobile}
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>
<Link
href="/challenge"
className="px-[1.5rem] py-[1rem] rounded-[0.5rem] bg-[#f6a701] text-[1.125rem] font-bold text-[#222221] hover:brightness-95 transition-all"
onClick={closeMobile}
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
7-Tage-Challenge
</Link>
</div>
</div>
</header>
);
+20 -18
View File
@@ -1,15 +1,17 @@
import { Reveal } from "./Reveal";
export function Newsletter() {
return (
<section className="py-16 w-full">
{/* Outer section padding — same 80px horizontal as all other sections */}
<div className="px-[5rem] w-full">
{/* Outer section padding — same fluid horizontal padding as all other sections */}
<div className="px-[var(--layout-padding-x)] w-full">
{/* Rounded card: cream bg, 32px inner padding, 56px gap between copy and form */}
<div className="bg-[#f8f3ec] flex gap-[3.5rem] items-center px-8 rounded-xl w-full">
{/* Rounded card: cream bg, stacks below md */}
<Reveal className="bg-bg-muted flex flex-col md:flex-row gap-8 md:gap-12 items-center px-8 py-8 md:py-0 rounded-md w-full">
{/* Left: copy — fixed width 612px so form always gets the remaining flex-1 */}
<div className="flex gap-8 items-start py-4 shrink-0 w-[38.25rem]">
{/* Left: copy — fixed width from md+ so the form always gets the remaining space */}
<div className="flex gap-8 items-start w-full md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
{/* Decorative envelope icon, tilted -4° as per design */}
<div className="flex items-center justify-center shrink-0 w-[4.23rem] h-[3.71rem]">
@@ -23,48 +25,48 @@ export function Newsletter() {
</div>
{/* Heading + body */}
<div className="flex flex-col gap-4 flex-1 min-w-0 text-[#222221]">
<div className="flex flex-col gap-4 flex-1 min-w-0 text-text-primary">
<p
className="font-semibold text-[1.75rem] leading-normal"
className="font-semibold text-h-section leading-normal"
style={{ fontFamily: "var(--font-lora)" }}
>
Starte mit einer Woche voller Klarheit
</p>
<p className="font-normal text-[1rem] leading-6">
<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.
</p>
</div>
</div>
{/* Right: form — takes remaining space, centered vertically */}
<div className="flex flex-1 items-center self-stretch min-w-0">
<div className="flex flex-1 flex-col gap-4 min-w-0">
{/* Right: form — takes remaining space, centered vertically from md+ */}
<div className="flex w-full md:flex-1 items-center md:self-stretch min-w-0">
<div className="flex flex-1 flex-col gap-4 min-w-0 w-full">
{/* Input + submit button side by side */}
<div className="flex gap-4 items-stretch w-full">
{/* Input + submit button — stacked below md, side by side from md+ */}
<div className="flex flex-col md:flex-row gap-4 items-stretch w-full">
<input
type="email"
placeholder="Deine E-Mail-Adresse"
className="flex-1 min-w-0 bg-white border border-[#d9d9d9] rounded-lg px-6 py-3 text-[1rem] text-[#868686] font-normal outline-none focus:border-[#f6a701] transition-colors"
className="flex-1 min-w-0 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="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1.125rem] text-[#222221] tracking-[0.18px] whitespace-nowrap hover:brightness-95 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"
>
Jetzt anmelden
</button>
</div>
{/* Privacy note */}
<p className="text-[0.75rem] text-[#222221] font-normal leading-normal">
<p className="text-label text-text-primary font-normal leading-normal">
Ich achte auf deine Daten. Kein Spam, jederzeit abbestellbar.
</p>
</div>
</div>
</div>
</Reveal>
</div>
</section>
);
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { motion, type Variants } from "motion/react";
import type { CSSProperties, ReactNode } from "react";
const fadeUp: Variants = {
hidden: { opacity: 0, y: 28 },
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
};
type RevealProps = {
children: ReactNode;
className?: string;
style?: CSSProperties;
/** Delay in seconds, e.g. for staggering siblings that aren't inside a RevealGroup. */
delay?: number;
};
/** Fades a section up into place once, the first time it scrolls into view. */
export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
return (
<motion.div
className={className}
style={style}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-80px" }}
variants={fadeUp}
transition={{ delay }}
>
{children}
</motion.div>
);
}
const staggerContainer: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.12 } },
};
/** Wraps a grid/row of cards — reveals children one-by-one as it scrolls into view. */
export function RevealGroup({ children, className }: { children: ReactNode; className?: string }) {
return (
<motion.div
className={className}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-80px" }}
variants={staggerContainer}
>
{children}
</motion.div>
);
}
/** Child item for use inside a RevealGroup — same fade-up motion, driven by the parent's stagger. */
export function RevealItem({ children, className }: { children: ReactNode; className?: string }) {
return (
<motion.div className={className} variants={fadeUp}>
{children}
</motion.div>
);
}
const popIn: Variants = {
hidden: { x: 140, y: -70, scale: 0.5, opacity: 0 },
show: {
// x is a single continuous glide (140 → 0), deliberately NOT
// keyframed in step with y's bounce schedule. It used to share y's
// per-segment alternating ease so it would decelerate to a dead stop
// at every bounce peak alongside y — physically correct for y (a
// ball's vertical speed really is zero at the top of each arc) but
// wrong for x, which has no reason to pause horizontally just because
// it's momentarily at peak height. That shared stop-start was read as
// "stops unnaturally in between". Giving x its own transition (below)
// decouples it completely, so it glides smoothly the whole time while
// y still bounces underneath it.
x: 0,
y: [-70, 0, -28, 0, -12, 0, -3, 0],
scale: [0.5, 1.05, 0.95, 1.03, 0.97, 1.02, 0.99, 1],
// Array, not a bare 1 — a scalar target here would fade opacity
// linearly across the *entire* 1s duration against the shared `times`
// grid below, so the dot would still be half-transparent through its
// first, fastest bounces. Snap to fully opaque at the first keyframe
// instead, matching how a real solid ball would look immediately.
opacity: [0, 1, 1, 1, 1, 1, 1, 1],
transition: {
// Applies to y/scale/opacity (anything without its own override
// below) — the actual bounce schedule.
default: {
duration: 1,
times: [0, 0.22, 0.4, 0.55, 0.68, 0.8, 0.9, 1],
// Per-segment, not a single ease for the whole animation — a real
// bounce accelerates while falling (gravity) and decelerates
// while rising toward each peak. Alternating fall (easeIn) / rise
// (easeOut), one entry per keyframe-to-keyframe transition.
ease: ["easeIn", "easeOut", "easeIn", "easeOut", "easeIn", "easeOut", "easeIn"],
},
// x's own transition — one smooth decelerating tween across the
// full duration, independent of the y bounce's stop-start rhythm.
x: { duration: 1, ease: "easeOut" },
},
},
};
/**
* One-shot "bouncing ball" drop-in for a small accent glyph (e.g. the
* brand's orange dot) — arrives from the top right and bounces to rest,
* not a looping pulse. `motion.span` needs a Client Component, same reason
* Reveal itself exists: keep "use client" isolated here so callers like
* Hero.tsx can stay Server Components.
*/
export function PopIn({ children, className, delay = 0 }: RevealProps) {
return (
<motion.span
className={className}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-80px" }}
variants={popIn}
transition={{ delay }}
>
{children}
</motion.span>
);
}
+31 -24
View File
@@ -1,23 +1,21 @@
import Link from "next/link";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
const tools = [
{
icon: { src: "/icon-rocket.svg", w: "3.375rem", h: "4.122rem", transform: "-scale-y-100" },
pr: "3.375rem",
title: "Mini-Challenge",
description: "In 7 Tagen zu mehr Klarheit. Kleine Gewohnheiten, die Großes bewirken.",
cta: { label: "Starten", href: "/challenge" },
},
{
icon: { src: "/icon-todo.svg", w: "3rem", h: "3.879rem", transform: "-scale-y-100" },
pr: "2rem",
title: "ToDo-Karten",
description: "Das Werkzeug für Fokus im Alltag. bringe Struktur in deine Aufgaben und gewinne Zeit zurück.",
cta: { label: "Entdecken", href: "/werkzeuge" },
cta: { label: "Entdecken", href: "/todo-cards" },
},
{
icon: { src: "/icon-newsletter.svg", w: "3rem", h: "2.579rem", transform: "-rotate-4 -scale-y-100" },
pr: "2rem",
title: "Impulse & Tipps",
description: "Wöchentliche Impulse mit konkreten Ideen und erprobten Tipps für weniger Reibung und mehr Leichtigkeit.",
cta: { label: "Anmelden", href: "/newsletter" },
@@ -26,28 +24,28 @@ const tools = [
export function Tools() {
return (
<div id="werkzeuge" className="flex flex-col gap-[3rem] items-start pb-[4rem] pt-[2rem] w-full bg-[#f5f0e8]">
<div id="werkzeuge" className="flex flex-col gap-12 items-start pb-16 pt-8 w-full bg-bg-base">
{/* Section header */}
<div className="flex flex-col gap-[0.5rem] items-start px-[5rem] w-full whitespace-nowrap overflow-clip">
<p className="font-bold text-[#f6a701] text-[1.25rem]">
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
<p className="font-bold text-brand text-h-small">
Meine Werkzeuge
</p>
<p
className="font-semibold text-[#222221] text-[1.75rem]"
className="font-semibold text-text-primary text-h-section"
style={{ fontFamily: "var(--font-lora)" }}
>
Werkzeuge für einen leichteren Alltag.
</p>
</div>
</Reveal>
{/* Tools grid */}
<div className="flex gap-[2.5rem] h-[12.5rem] items-center overflow-clip px-[5rem] w-full">
{/* Tools grid — 3 cols md+, stacked below md; internal icon+text layout
stays horizontal at every size, only the outer span changes */}
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
{tools.map((tool) => (
<div
<RevealItem
key={tool.title}
className="flex flex-[1_0_0] gap-[2rem] h-full items-start min-w-px overflow-clip relative"
style={{ paddingRight: tool.pr }}
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
>
{/* Icon */}
<div className="flex items-center justify-center shrink-0">
@@ -62,31 +60,40 @@ export function Tools() {
</div>
</div>
{/* Card content */}
<div
className="flex flex-[1_0_0] flex-col h-full items-start justify-between min-w-px overflow-clip p-[0.5rem] text-[#222221] [word-break:break-word]"
>
<div className="flex flex-col gap-[1rem] items-start w-full">
{/* Card content — self-stretch + h-full + justify-between so
the CTA always sits at the same bottom edge across all 3
cards, regardless of how many lines the description wraps
to. The parent RevealItem's items-start (needed to keep the
icon top-aligned, not stretched) otherwise stops this one
child from filling the grid-equalized row height — matches
the Figma card-content spec (h-full items-start
justify-between), which the earlier implementation had
dropped. gap-4 alongside justify-between guarantees a
minimum text→CTA gap even for the tallest card (the one
that defines the row height, and so has ~zero leftover
space for justify-between to distribute on its own). */}
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-start gap-4 min-w-0 text-text-primary [word-break:break-word]">
<div className="flex flex-col gap-4 items-start w-full">
<p
className="font-semibold leading-normal text-[1.75rem] w-full"
className="font-semibold leading-normal text-h-section w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{tool.title}
</p>
<p className="font-normal leading-[1.5rem] text-[1rem] w-full">
<p className="font-normal leading-[1.5rem] text-body w-full">
{tool.description}
</p>
</div>
<Link
href={tool.cta.href}
className="font-bold leading-normal text-[1rem] whitespace-nowrap hover:text-[#f6a701] transition-colors"
className="font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
>
{tool.cta.label}
</Link>
</div>
</div>
</RevealItem>
))}
</div>
</RevealGroup>
</div>
);