"use client"; import { useEffect, useMemo, 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); } // "Shop" becomes an in-page anchor to the homepage's ProductSpotlight // section (id="spotlight") instead of a real /shop navigation whenever // exactly 1 product is active — same reasoning as the other anchor links, // #werkzeuge/#ueber-bjoern already have (a full catalog grid is // degenerate UX with only 1 item to show). Passed down from // app/layout.tsx, which is the one place already fetching the product // catalog for this decision. function getNavLinks(singleActiveProduct: boolean) { return [ { label: "Werkzeuge", href: "#werkzeuge" }, { label: "Blog", href: "/blog" }, { label: "Über Björn", href: "#ueber-bjoern" }, { label: "Shop", href: singleActiveProduct ? "#spotlight" : "/shop" }, ]; } // "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}/`); } // Account icon — no Figma source exists for this yet (added outside the // normal Figma-first workflow, see the assistant's own project notes on // why: without it there was no reachable way to log in at all once the // cart was empty and no recent order existed — /checkout's own login // toggle never even renders in that state, see CheckoutContent.tsx's // early "Warenkorb ist leer" return). Fetches auth state client-side via // /api/account/me rather than through the server-rendered layout — this // component's parent (app/layout.tsx) is otherwise static/ISR-cacheable, // and reading the session cookie there (next/headers' cookies()) would // force the entire site into per-request dynamic rendering just for this. // `loggedIn === null` is the brief "not checked yet" state on first paint. function AccountLink({ variant }: { variant: "icon" | "mobile" }) { const [loggedIn, setLoggedIn] = useState(null); useEffect(() => { fetch("/api/account/me") .then((res) => setLoggedIn(res.ok)) .catch(() => setLoggedIn(false)); }, []); const href = loggedIn ? "/konto/bestellungen" : "/konto/login"; if (variant === "mobile") { return ( {loggedIn ? "Mein Konto" : "Anmelden"} ); } return ( {/* Only real "am I logged in?" signal on the site outside /konto itself — same brand-colored underline language as the desktop nav links' active-state indicator, so it reads as consistent rather than a new visual idiom. */} {loggedIn && } ); } // 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({ singleActiveProduct }: { singleActiveProduct: boolean }) { 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); const navLinks = useMemo(() => getNavLinks(singleActiveProduct), [singleActiveProduct]); const anchorIds = useMemo( () => navLinks.filter((l) => l.href.startsWith("#")).map((l) => l.href.slice(1)), [navLinks] ); 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. // // The no-hash branch below matters just as much: since the Navbar lives // in the root layout (never unmounts across navigations), Next.js's // default Link scroll behavior treats "/" as already-visible and leaves // the current scrollY untouched instead of resetting to top (see // next/dist/docs .../link.md's "maintain scroll position" default). // Landing on Home from a page scrolled halfway down (e.g. clicking the // logo from a scrolled /shop) then visually "lands" wherever that old // offset happens to fall in Home's layout — often right around the // Werkzeuge section — instead of at the top. Forcing scrollTo(0, 0) here // makes a plain logo/Home navigation always start at the top, exactly // like the same-page click handler below already does. useEffect(() => { if (pathname !== "/") return; const hash = window.location.hash.slice(1); if (!anchorIds.includes(hash)) { window.scrollTo(0, 0); 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, anchorIds]); 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); }, [anchorIds]); // 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)} /> ); }