"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";
const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem]
const SCROLL_DURATION = 800; // 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-in-out cubic
const ease = progress < 0.5
? 4 * progress ** 3
: 1 - (-2 * progress + 2) ** 3 / 2;
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)
// 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 (
0 ? `Warenkorb, ${count} Artikel` : "Warenkorb"}
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
{count > 0 && (
{count > 99 ? "99+" : count}
)}
);
}
export function Navbar() {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
const [mobileOpen, setMobileOpen] = 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();
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 (
{/* 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
}
>
{/* 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 */}
Newsletter
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) */}