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
+77 -41
View File
@@ -1,5 +1,30 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Footer } from "../components/Footer";
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
const title = "7-Tage-Challenge Mehr Klarheit in 7 Tagen";
const description =
"Die 7-Tage-Challenge für deinen klaren Kopf und mehr Struktur im Alltag: 7 kurze, praktische Impulse direkt ins Postfach für mehr Fokus, Ruhe und Klarheit.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/challenge",
},
openGraph: {
title,
description,
url: "/challenge",
images: ["/blog-featured.jpg"],
},
twitter: {
title,
description,
images: ["/blog-featured.jpg"],
},
};
function IconEnvelope() {
return (
@@ -132,7 +157,7 @@ function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: str
/>
<button
type="submit"
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 transition-all"
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2"
>
{buttonLabel}
</button>
@@ -154,7 +179,7 @@ export default function ChallengePage() {
<section className="w-full flex items-stretch overflow-hidden" style={{ minHeight: "32rem" }}>
{/* Left: text + form */}
<div 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">
<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">
{/* Breadcrumb */}
<p className="text-[0.875rem] text-[#888] flex items-center gap-1.5">
@@ -194,14 +219,17 @@ export default function ChallengePage() {
{/* Email form */}
<EmailCapture />
</div>
</Reveal>
{/* Right: hero image with badge */}
<div className="hidden lg:block flex-1 relative overflow-hidden">
{/* Right: hero image with badge — group + scale-105 on hover,
same restrained "photo feels alive" treatment as the Home/
todo-cards hero images; delay={0.15} mirrors their text→image
stagger. */}
<Reveal className="hidden lg:block group flex-1 relative overflow-hidden" delay={0.15}>
<img
alt="7-Tage-Challenge"
src="/blog-featured.jpg"
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none"
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none transition-transform duration-500 group-hover:scale-105"
/>
{/* Badge */}
<div className="absolute top-8 right-8 w-[7.5rem] h-[7.5rem] rounded-full bg-white shadow-md flex flex-col items-center justify-center text-center p-3 gap-1">
@@ -213,7 +241,7 @@ export default function ChallengePage() {
<path d="M1 1l9 8 9-8" stroke="#f6a701" strokeWidth="1.5" />
</svg>
</div>
</div>
</Reveal>
</section>
@@ -221,7 +249,7 @@ export default function ChallengePage() {
<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 items-center gap-12">
<div className="flex flex-col items-center gap-2 text-center">
<Reveal className="flex flex-col items-center gap-2 text-center">
<h2
className="font-semibold text-[#222221]"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
@@ -229,26 +257,26 @@ export default function ChallengePage() {
So funktioniert die Challenge
</h2>
<p className="text-[1rem] text-[#666]">Jeden Tag ein Impuls. In nur wenigen Minuten.</p>
</div>
</Reveal>
<div className="flex flex-col lg:flex-row items-start lg:items-start gap-8 lg:gap-2 w-full">
<RevealGroup className="flex flex-col lg:flex-row items-start lg:items-start gap-8 lg:gap-2 w-full">
{steps.flatMap((step, i) => [
<div key={step.title} className="flex lg:flex-col items-start lg:items-center gap-4 lg:gap-5 flex-1 min-w-0">
<div className="flex items-center justify-center w-16 h-14 shrink-0">
<RevealItem key={step.title} className="group flex lg:flex-col items-start lg:items-center gap-4 lg:gap-5 flex-1 min-w-0">
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
{step.icon}
</div>
<div className="flex flex-col gap-1 lg:text-center">
<p className="font-semibold text-[#222221] text-[1rem]">{step.title}</p>
<p className="text-[0.875rem] text-[#666] leading-[1.5]">{step.desc}</p>
</div>
</div>,
</RevealItem>,
i < steps.length - 1 ? (
<div key={`arrow-${i}`} className="hidden lg:flex items-center shrink-0 mt-5">
<ArrowRight />
</div>
) : null,
])}
</div>
</RevealGroup>
</div>
</section>
@@ -257,7 +285,7 @@ export default function ChallengePage() {
<section className="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="flex flex-col items-center gap-2 text-center">
<Reveal className="flex flex-col items-center gap-2 text-center">
<h2
className="font-semibold text-[#222221]"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
@@ -265,32 +293,37 @@ export default function ChallengePage() {
Das erwartet dich
</h2>
<p className="text-[1rem] text-[#666]">7 Tage. 7 Impulse. Für mehr Klarheit in deinem Alltag.</p>
</div>
</Reveal>
<div className="flex flex-col lg:flex-row gap-10 lg:gap-16 items-center">
{/* Image */}
<div className="w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden" style={{ minHeight: "18rem" }}>
<Reveal
className="group w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden"
style={{ minHeight: "18rem" }}
>
<img
alt="Notizbuch"
src="/challenge-content.jpg"
className="w-full h-full object-cover"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
style={{ minHeight: "18rem" }}
/>
</div>
</Reveal>
{/* Checklist */}
<ul className="flex flex-col gap-5 flex-1">
{benefits.map((b) => (
<li key={b.title} className="flex items-start gap-3">
<Check />
<div className="flex flex-col gap-0.5">
<p className="font-semibold text-[#222221] text-[1rem]">{b.title}</p>
<p className="text-[0.9rem] text-[#555] leading-[1.5]">{b.desc}</p>
</div>
</li>
))}
</ul>
<Reveal delay={0.1} className="flex-1 w-full">
<ul className="flex flex-col gap-5">
{benefits.map((b) => (
<li key={b.title} className="flex items-start gap-3">
<Check />
<div className="flex flex-col gap-0.5">
<p className="font-semibold text-[#222221] text-[1rem]">{b.title}</p>
<p className="text-[0.9rem] text-[#555] leading-[1.5]">{b.desc}</p>
</div>
</li>
))}
</ul>
</Reveal>
</div>
</div>
@@ -300,34 +333,37 @@ export default function ChallengePage() {
<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">
<h2
<Reveal
className="font-semibold text-[#222221] text-center"
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
>
Was andere sagen
</h2>
</Reveal>
<div className="flex flex-col lg:flex-row gap-6">
{/* Same micro-interactions as /todo-cards's identically-styled
testimonial cards (kept in sync deliberately): 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) => (
<div
<RevealItem
key={t.name}
className="flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4"
className="group 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>
<div className="flex items-center gap-3">
<img
alt={t.name}
src={t.avatar}
className="w-10 h-10 rounded-full object-cover shrink-0"
className="w-10 h-10 rounded-full object-cover shrink-0 transition-transform duration-300 group-hover:scale-110"
/>
<div>
<p className="font-semibold text-[#222221] text-[0.9rem]">{t.name}</p>
<p className="text-[#777] text-[0.8rem]">{t.role}</p>
</div>
</div>
</div>
</RevealItem>
))}
</div>
</RevealGroup>
</div>
</section>
@@ -335,7 +371,7 @@ export default function ChallengePage() {
{/* ── Bottom CTA ── */}
<section className="w-full py-14 lg:py-16">
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto">
<div className="bg-[#f8f3ec] rounded-xl flex flex-col lg:flex-row gap-8 lg:gap-[3.5rem] items-start lg:items-center px-6 lg:px-10 py-8">
<Reveal className="bg-[#f8f3ec] rounded-xl flex flex-col lg:flex-row gap-8 lg:gap-[3.5rem] items-start lg:items-center px-6 lg:px-10 py-8">
{/* Left: icon + copy */}
<div className="flex gap-5 items-start flex-1 min-w-0">
@@ -359,11 +395,11 @@ export default function ChallengePage() {
</div>
{/* Right: form */}
<div className="w-full lg:w-[24rem] lg:shrink-0">
<div className="w-full lg:w-[30rem] lg:shrink-0">
<EmailCapture />
</div>
</div>
</Reveal>
</div>
</section>
+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>
);
+134 -11
View File
@@ -1,15 +1,138 @@
@import "tailwindcss";
:root {
--background: #f5f0e8;
--foreground: #222221;
/*
* Fluid design tokens — every value below scales continuously via clamp()
* between the Tablet breakpoint (768px) and Desktop (1440px). Below 768px
* the clamp() floor freezes the value automatically (no separate mobile
* media query needed for sizing — only for structural layout changes,
* see the `md:` breakpoint usage in components).
*
* Spacing overrides Tailwind's own numeric scale (--spacing-4 etc.) at the
* exact numbers the styleguide already used (n times 0.25rem equals the
* desktop value), so every existing p-4, gap-6, px-20 (etc.) utility
* becomes fluid for free.
*/
@theme {
/* Fonts */
--font-sans: var(--font-inter), system-ui, sans-serif;
--font-display: var(--font-playfair), Georgia, serif;
--font-serif: var(--font-lora), Georgia, serif;
--font-script: var(--font-caveat), cursive;
/* Colors */
--color-brand: #f6a701;
--color-brand-hover: #cf8e01; /* verified against the live Figma NavButtonFilled/Hover variant */
--color-bg-base: #f8f5f1;
--color-bg-white: #ffffff;
--color-bg-muted: #f3efe9;
--color-bg-dark: #1a1a18;
--color-text-primary: #1a1a18;
--color-text-body: #2c2c2a;
--color-text-muted: #6b6b69;
--color-text-light: #9b9896;
--color-text-on-dark: #ffffff;
--color-border: #e5e0d8;
--color-overlay: rgba(134, 134, 134, 0.55);
--color-checkout-active: #f6a701;
--color-checkout-done: #1a1a18;
--color-toc-active-border: #f6a701;
/* Radius */
--radius-xs: 0.25rem;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
/* Shadows */
--shadow-card: 0 2px 12px rgba(26, 26, 24, 0.06);
--shadow-modal: 0 8px 40px rgba(26, 26, 24, 0.16);
--shadow-sidebar: 0 2px 8px rgba(26, 26, 24, 0.08);
/* Type scale (fluid 768->1440) */
--text-display: clamp(2.75rem, 1.3214rem + 2.9762vw, 4rem);
--text-display--line-height: 1.1;
--text-h-feature: clamp(1.875rem, 1.1607rem + 1.4881vw, 2.5rem);
--text-h-feature--line-height: 1.2;
/* New role, added for page-todo-karten's hero heading (48px Desktop) —
doesn't match any existing scale step (h-feature=40, h1=36), Figma's
own measured value takes priority over eyeballing it into a near
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). */
--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);
--text-h1--line-height: 1.15;
--text-h2: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
--text-h2--line-height: 1.25;
--text-h-section: clamp(1.4375rem, 1.0804rem + 0.744vw, 1.75rem);
--text-h-section--line-height: 1.3;
--text-h-emphasis: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
--text-h-emphasis--line-height: 1.4;
--text-h3: clamp(1.1875rem, 0.9732rem + 0.4464vw, 1.375rem);
--text-h3--line-height: 1.35;
--text-h-small: clamp(1.125rem, 0.9821rem + 0.2976vw, 1.25rem);
--text-h-small--line-height: 1.4;
--text-h4: clamp(1rem, 0.8571rem + 0.2976vw, 1.125rem);
--text-h4--line-height: 1.4;
--text-body: 1rem;
--text-body--line-height: 1.6;
--text-body-sm: 0.875rem;
--text-body-sm--line-height: 1.55;
--text-label: 0.75rem;
--text-label--line-height: 1.4;
/* Spacing scale (fluid 768->1440) — overrides Tailwind's default n*0.25rem
at these exact steps, so p-4/gap-6/px-20/etc. become fluid automatically */
--spacing-1: clamp(0.25rem, 0.25rem + 0vw, 0.25rem);
--spacing-2: clamp(0.5rem, 0.5rem + 0vw, 0.5rem);
--spacing-3: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
--spacing-4: clamp(0.875rem, 0.7321rem + 0.2976vw, 1rem);
--spacing-5: clamp(1rem, 0.7143rem + 0.5952vw, 1.25rem);
--spacing-6: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
--spacing-8: clamp(1.5rem, 0.9286rem + 1.1905vw, 2rem);
--spacing-10: clamp(1.75rem, 0.8929rem + 1.7857vw, 2.5rem);
--spacing-12: clamp(2rem, 0.8571rem + 2.381vw, 3rem);
--spacing-16: clamp(2.5rem, 0.7857rem + 3.5714vw, 4rem);
--spacing-20: clamp(3rem, 0.7143rem + 4.7619vw, 5rem);
--spacing-24: clamp(3.5rem, 0.6429rem + 5.9524vw, 6rem);
--spacing-30: clamp(4rem, 0rem + 8.3333vw, 7.5rem);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 400ms ease;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
:root {
/* Layout — not part of Tailwind's numeric spacing scale, referenced via
arbitrary-value syntax (e.g. px-[var(--layout-padding-x)]) */
--layout-max-width: 75rem; /* 1200px */
--layout-padding-x: clamp(2rem, -1.4286rem + 7.1429vw, 5rem);
--layout-grid-gap: clamp(1rem, 0.4286rem + 1.1905vw, 1.5rem);
--layout-grid-gap-lg: clamp(1.25rem, 0.3929rem + 1.7857vw, 2rem);
/* One-off fluid values that aren't part of a broader scale */
--hero-avatar-size: clamp(2.25rem, 1.6071rem + 1.3393vw, 2.8125rem);
/* Newsletter copy column — was a hard 38.25rem, which didn't leave enough
room for the form column at the 768px tablet floor (only 656px of
content width available for 612px column plus 56px gap), crushing the
input/button/privacy text to near-zero width and overflowing the page.
Scales down to 23rem at 768px instead. */
--newsletter-copy-width: clamp(23rem, 5.5714rem + 36.3095vw, 38.25rem);
/* Divider section icons (arrow separators, sparkle) — previously hard
rem values while the "Klarheit/Fokus/Entlastung" text next to them
used the fluid text-h2 token, so the icons visually stayed still
while the words scaled. Same fluid(minPx, maxPx) formula as the rest
of this file, floor ~82.5% of the Desktop value per the styleguide's
80-85% Mobile-ratio guidance. */
--divider-arrow-w: clamp(2.125rem, 1.5536rem + 1.1905vw, 2.625rem);
--divider-arrow-h: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
--divider-sparkle-w: clamp(1.5625rem, 1.1554rem + 0.8482vw, 1.91875rem);
--divider-sparkle-h: clamp(2.0625rem, 1.5554rem + 1.0565vw, 2.50625rem);
--divider-sparkle-inner-w: clamp(1.4375rem, 1.09875rem + 0.706vw, 1.734rem);
--divider-sparkle-inner-h: clamp(1.9375rem, 1.4375rem + 1.0417vw, 2.375rem);
}
html {
@@ -17,7 +140,7 @@ html {
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
background: var(--color-bg-base);
color: var(--color-text-body);
font-family: var(--font-sans);
}
+20 -12
View File
@@ -1,16 +1,12 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Playfair_Display, Caveat, Lora } from "next/font/google";
import { Inter, Playfair_Display, Caveat, Lora } from "next/font/google";
import "./globals.css";
import { Navbar } from "./components/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const playfair = Playfair_Display({
@@ -32,8 +28,20 @@ const lora = Lora({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL("https://einfach-produktiv.mk360.de"),
title: {
default: "einfach produktiv. Werkzeuge und Impulse für einen leichteren Alltag",
template: "%s | einfach produktiv.",
},
description: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
openGraph: {
siteName: "einfach produktiv.",
locale: "de_DE",
type: "website",
},
twitter: {
card: "summary_large_image",
},
};
export default function RootLayout({
@@ -43,8 +51,8 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased`}
lang="de"
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Navbar />
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
const CART_KEY = "ep_cart";
const CART_EVENT = "ep-cart-updated";
type CartItem = { id: string; qty: number };
function readCart(): CartItem[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(CART_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function writeCart(items: CartItem[]) {
window.localStorage.setItem(CART_KEY, JSON.stringify(items));
window.dispatchEvent(new Event(CART_EVENT));
}
export function getCartCount(): number {
return readCart().reduce((sum, item) => sum + item.qty, 0);
}
export function addToCart(id: string, qty = 1) {
const items = readCart();
const existing = items.find((i) => i.id === id);
if (existing) existing.qty += qty;
else items.push({ id, qty });
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;
}
+18
View File
@@ -0,0 +1,18 @@
const MIN_VW = 768;
const MAX_VW = 1440;
/**
* Fluid clamp() between two px values, scaling with viewport width from
* `minVw` to `maxVw`. Below `minVw` the clamp() floor freezes the value
* that's the "Tablet-Floor" behavior baked into the formula itself, no
* separate mobile media query needed for sizing.
*/
export function fluid(minPx: number, maxPx: number, minVw = MIN_VW, maxVw = MAX_VW): string {
const slope = (maxPx - minPx) / (maxVw - minVw);
const yIntercept = minPx - slope * minVw;
const minRem = minPx / 16;
const maxRem = maxPx / 16;
const yRem = yIntercept / 16;
const slopeVw = slope * 100;
return `clamp(${minRem}rem, ${yRem}rem + ${slopeVw}vw, ${maxRem}rem)`;
}
+24
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { Hero } from "./components/Hero";
import { Divider } from "./components/Divider";
import { Tools } from "./components/Tools";
@@ -6,6 +7,29 @@ import { About } from "./components/About";
import { Newsletter } from "./components/Newsletter";
import { Footer } from "./components/Footer";
const title = "einfach produktiv. Produktivität darf sich leicht anfühlen";
const description =
"Werkzeuge, Impulse und die 7-Tage-Challenge für Menschen mit Familie, Verantwortung und wenig Zeit. Mehr Klarheit und Fokus im Alltag einfach produktiv.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/",
},
openGraph: {
title,
description,
url: "/",
images: ["/hero.png"],
},
twitter: {
title,
description,
images: ["/hero.png"],
},
};
export default function Home() {
return (
<>
@@ -0,0 +1,31 @@
"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>
);
}
+45
View File
@@ -0,0 +1,45 @@
import Image from "next/image";
import { Reveal } from "../../components/Reveal";
const bullets = [
"Set mit 50 hochwertigen ToDo-Karten",
"Viel Platz für Gedanken, Aufgaben und Notizen",
"Passt in jede Tasche überall einsetzbar",
"Nachhaltig produziert in Deutschland",
"Inklusive Mini-Anleitung mit Tipps für den Start",
];
export function Focus() {
return (
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="group relative w-full lg:w-[42%] lg:shrink-0 aspect-[580/328] rounded-md overflow-hidden">
<Image
src="/focus-todo-karten.png"
alt="Notizbuch mit ToDo-Karten in natürlichem Licht"
fill
sizes="(min-width: 1024px) 42vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
<Reveal className="flex flex-col gap-6 items-start flex-1 min-w-0 w-full" delay={0.1}>
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Für mehr Fokus im Alltag
</p>
<p className="text-body text-text-body">
ToDo-Karten helfen dir, den Überblick zu behalten, Entscheidungen zu vereinfachen und deinen Tag bewusst zu gestalten.
</p>
<ul className="flex flex-col gap-3 items-start w-full">
{bullets.map((b) => (
<li key={b} className="flex gap-[0.625rem] items-center w-full">
<img alt="" src="/icon-check.svg" className="size-5 shrink-0" />
<span className="flex-1 font-semibold text-body text-text-primary">{b}</span>
</li>
))}
</ul>
</Reveal>
</section>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { Fragment } from "react";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
const steps = [
{
icon: "/icon-step-1.png",
title: "1. Aufschreiben",
desc: "Notiere deine Aufgaben und alles, was dir im Kopf herumgeht.",
},
{
icon: "/icon-step-2.png",
title: "2. Priorisieren",
desc: "Wähle deine 3 wichtigsten Aufgaben für heute aus und schreibe sie auf.",
},
{
icon: "/icon-step-3.png",
title: "3. Umsetzen",
desc: "Konzentriere dich auf das Wesentliche und hake ab, was wirklich zählt.",
},
];
export function HowItWorks() {
return (
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 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 einfach funktioniert es
</p>
<p className="text-body text-text-muted">
Ein paar Minuten am Morgen mehr Klarheit den ganzen Tag.
</p>
</Reveal>
{/* Arrows always visible (see Divider lesson hiding them below a
breakpoint isn't the fix), just rotated 90° to point down while
stacked below md:. lg:px-[16.25rem] mirrors Figma's Desktop-only
px-[260px] inset that value doesn't get its own fluid token
since it's a pure aesthetic narrowing, not something that breaks
if it's just absent below lg:. RevealGroup/RevealItem stagger the
3 steps in on scroll, same pattern as Tools.tsx's card grid
arrows aren't part of the stagger (purely decorative connectors,
not content), they just sit in the DOM between items. */}
<RevealGroup className="flex flex-col md:flex-row gap-8 items-center md:items-start w-full lg:px-[16.25rem]">
{steps.map((step, i) => (
<Fragment key={step.title}>
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs md:max-w-none">
{/* Plain <img>, not next/image one of the three source PNGs
isn't square (icon-step-2 is 68.6x64), and next/image's
required width/height props fought with Tailwind's
`img { height: auto }` preflight reset (console warning:
"width or height modified, but not the other"). A fixed
h-16 + w-auto lets each icon keep its own real aspect
ratio without that conflict matches how every other
small decorative icon in this codebase (Tools.tsx etc.)
is already a plain <img>, not next/image. group-hover
scale is a small "step acknowledges you" touch the
whole text column is the hover target, not just the icon,
so it's easy to trigger without precise pointing. */}
<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>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Image from "next/image";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "../../components/Reveal";
const bullets = [
"50 ToDo-Karten",
"Hochwertiges Papier (120 g/m²)",
"Format: 105 x 148 mm (A6)",
"Inklusive Mini-Anleitung",
"Nachhaltig produziert in Deutschland",
];
export function Pricing() {
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
{/* Three flex children (photo, info, price/CTA) competing for space
same "big image next to text" shape as the Hero/Focus, so the
same lg:-only structural exception applies here too. */}
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src="/product-todo-karten.png"
alt="ToDo-Karten Set"
fill
sizes="(min-width: 1024px) 410px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
ToDo-Karten Set
</p>
<ul className="flex flex-col gap-[0.375rem] items-start">
{bullets.map((b) => (
<li key={b} className="flex gap-2 items-center">
<img alt="" src="/icon-bullet-dot.svg" className="size-1 shrink-0" />
<span className="text-body-sm text-text-primary">{b}</span>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<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>
<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"
/>
<div className="flex gap-[0.375rem] items-center">
<img alt="" src="/icon-lock.svg" className="size-3.5" />
<span className="text-body-sm text-text-primary">Sichere Zahlung</span>
</div>
</div>
</Reveal>
</section>
);
}
@@ -0,0 +1,64 @@
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
const testimonials = [
{
quote:
"„Die ToDo-Karten sind mein täglicher Begleiter. Endlich mehr Fokus auf das, was wirklich wichtig ist.“",
name: "Sarah M.",
role: "Marketing Managerin",
avatar: "/avatar-todo-1.png",
},
{
quote: "„Ein simples Konzept, das mein Arbeiten komplett verändert hat. Klare Empfehlung!“",
name: "Thomas K.",
role: "Selbstständiger Berater",
avatar: "/avatar-todo-2.png",
},
{
quote: "„Weniger Chaos im Kopf, mehr Struktur im Alltag. Die Karten helfen mir jeden Tag.“",
name: "Miriam L.",
role: "Projektleiterin",
avatar: "/avatar-todo-3.png",
},
];
// 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).
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)]">
<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 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>
<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>
</section>
);
}
@@ -0,0 +1,85 @@
import Link from "next/link";
import Image from "next/image";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "../../components/Reveal";
const checklist = [
"Klarer Fokus auf das, was wirklich zählt",
"Flexibel einsetzbar jeden Tag",
"Minimalistisch, analog, effektiv",
];
export function TodoKartenHero() {
return (
<section className="bg-bg-base w-full overflow-hidden">
{/* Same lg:-only structural exception as the Home Hero (see
figma-to-nextjs skill Gotcha #5) a wide fixed-ratio photo next
to a text column is exactly the shape that gets too cramped at
Tablet widths 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">
<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 */}
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<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.
</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" />
</Reveal>
{/* group + subtle scale-on-hover a common, restrained "photo
feels alive" micro-interaction; overflow-hidden on the wrapper
(not the image) keeps the zoom from spilling past the rounded
corners. delay={0.15} matches the Home Hero's textimage
stagger both fire ~immediately since Hero is already in the
initial viewport, so this doubles as the page's entrance. */}
<Reveal
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
delay={0.15}
>
<Image
src="/hero-todo-karten.png"
alt="ToDo-Karten in Anwendung, mit Notizbuch und Kaffee"
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
</div>
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { TodoKartenHero } from "./components/TodoKartenHero";
import { HowItWorks } from "./components/HowItWorks";
import { Focus } from "./components/Focus";
import { Testimonials } from "./components/Testimonials";
import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
const title = "ToDo-Karten Kleine Karten. Große Wirkung.";
const description =
"Ein einfaches Werkzeug, das dir hilft, deinen Kopf frei zu bekommen und das Wesentliche zu sehen. 50 hochwertige ToDo-Karten für mehr Fokus im Alltag.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/todo-cards",
},
openGraph: {
title,
description,
url: "/todo-cards",
images: ["/hero-todo-karten.png"],
},
twitter: {
title,
description,
images: ["/hero-todo-karten.png"],
},
};
export default function TodoCardsPage() {
return (
<>
<main className="flex flex-col flex-1">
<TodoKartenHero />
<HowItWorks />
<Focus />
<Testimonials />
<Pricing />
</main>
<Footer />
</>
);
}