"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 { AnimatePresence, motion } from "motion/react"; import { useCartCount } from "../lib/cart"; import { useWishlist } from "../lib/useWishlist"; import { SearchButton, SearchOverlay } from "./SearchOverlay"; import { AUTH_CHANGED_EVENT } from "../lib/auth"; 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", "/lebensuhr", "/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() { const [loggedIn, setLoggedIn] = useState(null); useEffect(() => { function checkAuth() { fetch("/api/account/me") .then((res) => setLoggedIn(res.ok)) .catch(() => setLoggedIn(false)); } checkAuth(); // Navbar lives in the root layout and never unmounts across // navigations, so this effect only ever runs once on its own — // router.refresh() (called after login/logout) re-fetches Server // Component data but doesn't re-run an already-mounted Client // Component's effects. AUTH_CHANGED_EVENT is dispatched explicitly by // every login/logout call site (see dispatchAuthChanged() in // ../lib/auth) so this stays in sync without a hard reload. window.addEventListener(AUTH_CHANGED_EVENT, checkAuth); return () => window.removeEventListener(AUTH_CHANGED_EVENT, checkAuth); }, []); const href = loggedIn ? "/konto/bestellungen" : "/konto/login"; return ( {/* -translate-y-0.5 — the glyph's own bounding box centers fine mathematically, but the round head (light, isolated) versus the wide shoulders (heavier, at the bottom) reads as optically bottom-heavy next to the cart icon, sitting visibly lower. Nudged up to match (fixed 2026-07-24). */} {/* 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 && } ); } // Wishlist icon + count badge — only rendered by the caller when // `wishlistEnabled` (CompanySettings). Visible at every width, alongside // Search/Account/Cart — all 4 stay full 44px touch targets even on the // smallest phones (shrinking them was tried and reverted: it made them // hard to hit accurately). The logo shrinks further below 375px instead // to make room — confirmed via an actual Playwright viewport sweep down // to 320px that this fits without wrapping/overflow (see git history). function WishlistLink() { const { count } = useWishlist(); const [pulse, setPulse] = useState(false); const prevCountRef = useRef(0); // Same guard as CartLink's own — useWishlist() starts from an empty // cached/SSR-safe list and only fills in the real count once its own // fetch resolves client-side, so without this the badge pulsed on every // page load/reload the instant that first real count arrived, not just // on an actual add/remove during the session. const hasMountedRef = useRef(false); useEffect(() => { if (!hasMountedRef.current) { hasMountedRef.current = true; prevCountRef.current = count; return; } if (count !== prevCountRef.current) { setPulse(true); const t = setTimeout(() => setPulse(false), 350); prevCountRef.current = count; return () => clearTimeout(t); } }, [count]); return ( 0 ? `Merkliste, ${count} Artikel` : "Merkliste"} 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} )} ); } // 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); // useCartCount's getServerSnapshot is always 0 (see cart.ts) so hydration // always transitions 0 -> the real count on first render — without this // guard that transition alone satisfied "visibleCount > prevVisibleRef" // and pulsed the badge on every single page load/reload, not just an // actual in-session cart change. const hasMountedRef = useRef(false); 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 (!hasMountedRef.current) { hasMountedRef.current = true; prevVisibleRef.current = visibleCount; return; } 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, wishlistEnabled, searchEnabled, }: { singleActiveProduct: boolean; wishlistEnabled: boolean; searchEnabled: boolean; }) { const pathname = usePathname(); const [scrolled, setScrolled] = useState(false); const [activeSection, setActiveSection] = useState(""); const [mobileOpen, setMobileOpen] = useState(false); const [newsletterOpen, setNewsletterOpen] = useState(false); const [searchOpen, setSearchOpen] = 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]); // Background scroll lock while the fullscreen panel is open — same // wheel/touchmove interception as NewsletterModal.tsx (see that // component's own comment on why this approach over overflow:hidden or // position:fixed on body). Needed now that the panel actually covers the // viewport instead of pushing page content down in normal flow. useEffect(() => { if (!mobileOpen) return; const isInsidePanel = (target: EventTarget | null) => target instanceof Node && !!panelRef.current?.contains(target); const onWheel = (e: WheelEvent) => { if (!isInsidePanel(e.target)) e.preventDefault(); }; const onTouchMove = (e: TouchEvent) => { if (!isInsidePanel(e.target)) e.preventDefault(); }; document.addEventListener("wheel", onWheel, { passive: false }); document.addEventListener("touchmove", onTouchMove, { passive: false }); return () => { document.removeEventListener("wheel", onWheel); document.removeEventListener("touchmove", onTouchMove); }; }, [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. <>
mobileOpen && setMobileOpen(false)} className={`sticky top-0 z-50 w-full h-[6.25rem] transition-[background-color,backdrop-filter] duration-300 ${ scrolled || mobileOpen ? "bg-bg-base/80 backdrop-blur-md" : "bg-bg-base" }`} >
{/* 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 } > {/* Fixed width/height are the real file dimensions (Next Image needs them for optimization/layout); the wrapping Link's own w-[112px] min-[375px]:w-[130px] sm:w-[181px] + h-auto here is what actually shrinks the rendered logo below 640px — there wasn't enough header width for 4 full-44px icons + hamburger otherwise (confirmed via an actual Playwright viewport sweep down to 320px, not assumed) — the icons themselves are never shrunk (see WishlistLink's own comment on why), so the logo is what gives on the very smallest phones instead. */} 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. Deliberate exception to the site-wide sm: (640px) structural consolidation (see the 640px-breakpoint plan): this md:/lg: 3-tier scheme (hamburger-only <768, hamburger+inline-CTAs 768-1023, full-inline ≥1024) is untouched by that migration. Horizontal nav-link overflow is a different failure mode than the vertical grid/flex reflows the rest of the site has — there's no fluid token that shrinks link text to make a full inline nav fit at 640px, and nothing here was ever tied to the fluid token scale the way Hero/About/etc. were. The gap itself, though, does scale fluidly — just on its own 1024-1300px range (this nav's own floor/ceiling, only ever relevant while it's actually visible), not the site-wide 640-1440px scale: clamp(1.5rem, 8.696vw - 4.065rem, 3rem) is gap-6 (1.5rem) at exactly 1024px, gap-12 (3rem) from 1300px up, and eases linearly between — the fixed 48px gap read as too wide right where the nav labels themselves have the least room (just above 1024px). */} {/* 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. */}
{/* No gap between these — each is a full 44px touch target (never shrunk, even on the smallest phones — a smaller target was tried and reverted for being hard to tap accurately) with the icon centered inside, so even gap-0 here still leaves visual space between the actual glyphs. All 4 icons (Search/Account/Wishlist/Cart) are visible at every width, including true mobile — the 375px-and-below size step exists specifically so all 4 plus the hamburger fit without wrapping/overflow on the narrowest real phone viewports (checked at 320px). The outer gap-2 is what separates this group from the CTA-buttons/hamburger group that follows. */}
{searchEnabled && setSearchOpen(true)} />} {wishlistEnabled && }
{/* 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. */} Mein 3x3-System
{/* 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. */}
{/* Fullscreen mobile panel — a sibling of
, deliberately NOT nested inside it (same reason as NewsletterModal, see the top-of-file comment: `mobileOpen` gives the header its own backdrop-blur, which would make it a new containing block for any `position: fixed` descendant and break the panel's fixed-to- viewport positioning). Circular clip-path reveal expanding from the hamburger's own corner (top-right) — the growing circle naturally sweeps toward the opposite corner (bottom-left) last, reading as the diagonal wipe this is going for without needing a literal diagonal clip polygon. `vmax` (not %) for the radius so full coverage holds regardless of viewport aspect ratio. */} {mobileOpen && (
{/* md:hidden — these two duplicate the inline CTA pair that's already visible in the header itself from md (768px) up (see "Trailing controls" above); only genuinely missing below that, where the inline pair is hidden and the panel is these buttons' only way to reach them. No login/account CTA here (removed — Nutzer-Entscheidung: that's already reachable via the account icon in the header itself, outside this panel, no need to duplicate it inside). */} Mein 3x3-System
)}
setNewsletterOpen(false)} /> {searchEnabled && setSearchOpen(false)} />} ); }