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:
+49
-33
@@ -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 </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
@@ -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 & 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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user