"use client"; 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"; import { NewsletterModal } from "./NewsletterModal"; import { useCartFly } from "./CartFly"; const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem] const SCROLL_DURATION = 200; // ms function smoothScrollTo(targetY: number) { const startY = window.scrollY; const distance = targetY - startY; const startTime = performance.now(); function step(now: number) { const elapsed = now - startTime; const progress = Math.min(elapsed / SCROLL_DURATION, 1); // ease-out cubic — not ease-in-out. Ease-in-out's cubic ease-in half // stays nearly flat for the animation's first ~150-200ms, which read // as a delay before the scroll visibly starts. Ease-out moves // immediately and only decelerates into the landing. const ease = 1 - (1 - progress) ** 3; window.scrollTo(0, startY + distance * ease); if (progress < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } const navLinks = [ { label: "Werkzeuge", href: "#werkzeuge" }, { label: "Blog", href: "#blog" }, { label: "Über Björn", href: "#ueber-bjoern" }, { label: "Shop", href: "/shop" }, ]; 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), // /challenge (7-Tage-Challenge), and /newsletter (Impulse & Tipps) are // all tools listed there, even though none of these routes is nested // under /werkzeuge/. Their own Figma sources (page-todo-karten, // page-weekly-impulses, and the 7-Tage-Challenge card in section-tools) // mark "Werkzeuge" active in the nav for the same reason — confirmed by // page-weekly-impulses's own breadcrumb ("Startseite › Werkzeuge › // Impulse & Tipps") and active-underline position, same as page-todo-karten's. const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"]; 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(); const { registerCartIcon, pendingCount } = useCartFly(); const anchorRef = useRef(null); const [pulse, setPulse] = useState(false); const prevVisibleRef = useRef(0); const pathname = usePathname(); // Held back by pendingCount while a ball is mid-flight, so the badge only // "counts up" once the ball visually lands here — not the instant the // button is clicked (real cart data itself updates instantly elsewhere, // this is purely the badge's own display value). const visibleCount = Math.max(0, count - pendingCount); useEffect(() => { registerCartIcon(anchorRef.current); return () => registerCartIcon(null); }, [registerCartIcon]); useEffect(() => { if (visibleCount > prevVisibleRef.current) { setPulse(true); const t = setTimeout(() => setPulse(false), 350); prevVisibleRef.current = visibleCount; return () => clearTimeout(t); } prevVisibleRef.current = visibleCount; }, [visibleCount]); return ( { // Already on /cart — a real navigation here is a no-op, so treat // the click as "take me back to the top" instead of doing nothing. if (pathname === "/cart") { e.preventDefault(); smoothScrollTo(0); } }} aria-label={visibleCount > 0 ? `Warenkorb, ${visibleCount} Artikel` : "Warenkorb"} className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform" > {visibleCount > 0 && ( {visibleCount > 99 ? "99+" : visibleCount} )} ); } export function Navbar() { const pathname = usePathname(); const [scrolled, setScrolled] = useState(false); const [activeSection, setActiveSection] = useState(""); const [mobileOpen, setMobileOpen] = useState(false); const [newsletterOpen, setNewsletterOpen] = useState(false); const panelRef = useRef(null); const hamburgerRef = useRef(null); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 8); window.addEventListener("scroll", onScroll, { passive: true }); 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) const triggerY = window.scrollY + NAVBAR_HEIGHT + (window.innerHeight - NAVBAR_HEIGHT) / 3; let current = ""; for (const id of anchorIds) { const el = document.getElementById(id); if (!el) continue; const top = el.getBoundingClientRect().top + window.scrollY; if (top <= triggerY) current = id; } setActiveSection(current); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); 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( 'a[href], button:not([disabled])' ); focusables?.[0]?.focus({ preventScroll: true }); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); setMobileOpen(false); hamburgerRef.current?.focus({ preventScroll: true }); 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({ preventScroll: true }); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus({ preventScroll: true }); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [mobileOpen]); const closeMobile = () => setMobileOpen(false); return ( // Fragment, not just the
— NewsletterModal must NOT be a // descendant of it. `backdrop-blur-md` below is a `backdrop-filter`, // which (like `transform`) makes its element a new containing block // for `position: fixed` descendants per spec. Since the header only // gets that class once `scrolled` is true, the modal's `fixed inset-0` // backdrop silently stopped being fixed to the viewport and became // fixed to the 100px-tall header instead — centering math then ran // against that instead of the viewport, clipping the modal to the top // of the page. Exactly reproduced whenever the modal was opened while // scrolled (e.g. after clicking an anchor link), never at the very // top of the page (scrollY <= 8, no backdrop-blur yet) — which is why // it looked tied to "clicked an anchor first" rather than to scroll // position itself. <>
{/* 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. */} { e.preventDefault(); closeMobile(); smoothScrollTo(0); history.replaceState(null, "", "/"); } : closeMobile } > einfach produktiv {/* 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. */} {/* 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. */}
{/* CTA buttons — inline from md (768px) up, i.e. through both "Collapsed-CTA" and full Desktop tiers */}
{/* Opens the modal (matches the original Figma prototype: Newsletter-Button → Overlay newsletter-overlay) instead of navigating to /newsletter — that page is reached via the Werkzeuge "Impulse & Tipps" card's "Anmelden" link instead, a different, heavier entry point for a different context. */} 7-Tage-Challenge
{/* 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. */}
{/* Mobile drawer panel — toggleable below lg (see hamburger above) */}
7-Tage-Challenge
setNewsletterOpen(false)} /> ); }