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:
+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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user