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
+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>
);