bc40e22910
Search icon now gated behind the new backend toggle, same pattern as wishlistEnabled — off by default. Fixed: blog category filter bar was invisible — it was wrapped in <Reveal>, which sits right at/just past the hero's bottom edge, the exact "already near the initial viewport" position Reveal.tsx's own comment documents as a whileInView(margin:"-80px") trap (an element already visible without scrolling can permanently never register as "entered view", since `once: true` never gets a second chance). Now a plain div, no scroll-reveal animation needed for a filter bar anyway. Fixed: wishlist count showing one behind on the Navbar badge — useWishlist.ts's toggle() broadcast the "refetch me" event immediately after the optimistic local update, before the POST request was even sent. Another instance's resulting refetch could race the actual server-side write, get the pre-toggle list, and then never get told to refetch again. Broadcast now only fires after the request settles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
753 lines
34 KiB
TypeScript
753 lines
34 KiB
TypeScript
"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 } 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<boolean | null>(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 (
|
||
<Link
|
||
href={href}
|
||
aria-label={loggedIn ? "Mein Konto (eingeloggt)" : "Anmelden"}
|
||
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||
>
|
||
{/* -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). */}
|
||
<svg viewBox="0 0 24 24" className="h-7 w-7 text-text-primary -translate-y-0.5" fill="none" aria-hidden="true">
|
||
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.8" />
|
||
<path d="M4.5 20c1.2-4 4-6 7.5-6s6.3 2 7.5 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||
</svg>
|
||
{/* 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 && <span aria-hidden className="absolute bottom-1 h-[2px] w-4 bg-brand rounded-full" />}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// Wishlist icon + count badge — only rendered by the caller when
|
||
// `wishlistEnabled` (CompanySettings), and even then hidden below `sm:`.
|
||
// Account+Cart are the only always-visible icons on true mobile (see the
|
||
// trailing-controls group's own comment: gap-2 there was already tuned
|
||
// specifically for exactly 2 icons) — a 3rd icon squeezed in at the
|
||
// smallest phone widths risks the exact nav-overflow class of bug
|
||
// documented in the figma-to-nextjs skill (computed hamburger/icon-row
|
||
// thresholds, not assumed ones). sm+ has real room to spare.
|
||
function WishlistLink() {
|
||
const { count } = useWishlist();
|
||
|
||
return (
|
||
<Link
|
||
href="/konto/merkliste"
|
||
aria-label={count > 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
|
||
className="relative hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||
>
|
||
<svg viewBox="0 0 20 18" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||
<path
|
||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// 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<HTMLAnchorElement>(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 (
|
||
<Link
|
||
ref={anchorRef}
|
||
href="/cart"
|
||
onClick={(e) => {
|
||
// 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"
|
||
>
|
||
<svg
|
||
viewBox="0 0 32 30"
|
||
className={"h-7 w-7 text-text-primary transition-transform duration-300 " + (pulse ? "scale-110" : "scale-100")}
|
||
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>
|
||
{visibleCount > 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 transition-transform duration-300 " +
|
||
(pulse ? "scale-125" : "scale-100")
|
||
}
|
||
>
|
||
{visibleCount > 99 ? "99+" : visibleCount}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
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 panelRef = useRef<HTMLDivElement>(null);
|
||
const hamburgerRef = useRef<HTMLButtonElement>(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<HTMLElement>(
|
||
'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 <header> — 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.
|
||
<>
|
||
<header
|
||
// The mobile panel used to be a child of this element and needed the
|
||
// header itself to be a flexible column that could grow for it — it's
|
||
// now a fixed-position sibling instead (see that panel's own comment
|
||
// on why), so this is back to a plain fixed-height bar.
|
||
//
|
||
// onClick here closes the drawer on any click that lands on the bar
|
||
// itself (logo/cart/account/CTAs already call closeMobile() from
|
||
// their own onClick, so this mainly covers clicking empty space in
|
||
// the bar) — safe as a catch-all specifically because the panel is
|
||
// a sibling, not a descendant, so a click inside the open drawer
|
||
// never bubbles up to this handler. The hamburger's own onClick
|
||
// stops propagation so toggling it open doesn't immediately get
|
||
// undone by this same handler.
|
||
onClick={() => 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"
|
||
}`}
|
||
>
|
||
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-8">
|
||
<div className="w-full flex items-center justify-between">
|
||
|
||
{/* 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, "", "/");
|
||
}
|
||
: 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.
|
||
|
||
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. */}
|
||
<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">
|
||
{/* No gap between these two — each is already a 44px touch
|
||
target with the icon centered inside, so even gap-0 here
|
||
still leaves ~20px of visual space between the actual
|
||
glyphs. The outer gap-2 is what separates this pair from
|
||
the CTA-buttons/hamburger group that follows, and stays
|
||
untouched. Fixed 2026-07-24: gap-2 here on top of that
|
||
built-in padding read as too much space on mobile, where
|
||
these two icons are the only always-visible controls. */}
|
||
<div className="flex items-center">
|
||
{searchEnabled && <SearchButton />}
|
||
<AccountLink />
|
||
{wishlistEnabled && <WishlistLink />}
|
||
<CartLink />
|
||
</div>
|
||
|
||
{/* 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">
|
||
{/* 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. */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setNewsletterOpen(true)}
|
||
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
|
||
</button>
|
||
<Link
|
||
href="/3x3-system"
|
||
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"
|
||
>
|
||
Mein 3x3-System
|
||
</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={(e) => {
|
||
e.stopPropagation();
|
||
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>
|
||
|
||
</header>
|
||
|
||
{/* Fullscreen mobile panel — a sibling of <header>, 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. */}
|
||
<AnimatePresence>
|
||
{mobileOpen && (
|
||
<motion.div
|
||
id="mobile-nav-panel"
|
||
ref={panelRef}
|
||
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
|
||
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
|
||
// Own (slower, gentler) transition for the exit — the shared
|
||
// ease-out curve above is front-loaded (fast start, slow
|
||
// finish), which reads great for the reveal but meant the
|
||
// close shrank most of the way almost immediately, then
|
||
// lingered on a barely-visible sliver — felt abrupt rather
|
||
// than smooth. easeInOut plus a longer duration spreads the
|
||
// shrink evenly instead.
|
||
exit={{ clipPath: "circle(0vmax at 100% 0%)", transition: { duration: 0.7, ease: "easeInOut" } }}
|
||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||
>
|
||
<div className="flex flex-col min-h-full pt-[6.25rem]">
|
||
<nav className="flex flex-col flex-1 items-center justify-center gap-6 px-8 py-10 text-center">
|
||
{navLinks.map((link, i) => {
|
||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||
// Staggered fade+rise entrance, timed to land after the
|
||
// clip-path reveal has visibly opened up — same
|
||
// "fancy but restrained" register as the rest of this
|
||
// codebase's motion usage (Reveal.tsx et al.), not a
|
||
// separate animation language just for this panel.
|
||
const linkMotionProps = {
|
||
initial: { opacity: 0, y: 12 },
|
||
animate: { opacity: 1, y: 0 },
|
||
transition: { delay: 0.15 + i * 0.05, duration: 0.3, ease: "easeOut" as const },
|
||
};
|
||
return link.href.startsWith("#") ? (
|
||
<motion.div key={link.href} {...linkMotionProps}>
|
||
<Link
|
||
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-h-feature font-semibold text-text-primary w-fit"
|
||
style={{ fontFamily: "var(--font-lora)" }}
|
||
>
|
||
{link.label}
|
||
<span
|
||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||
}`}
|
||
/>
|
||
</Link>
|
||
</motion.div>
|
||
) : (
|
||
<motion.div key={link.href} {...linkMotionProps}>
|
||
<Link
|
||
href={link.href}
|
||
onClick={closeMobile}
|
||
className="min-h-11 flex items-center text-h-feature font-semibold text-text-primary"
|
||
style={{ fontFamily: "var(--font-lora)" }}
|
||
>
|
||
{link.label}
|
||
</Link>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</nav>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.15 + navLinks.length * 0.05, duration: 0.3, ease: "easeOut" }}
|
||
className="flex flex-col gap-3 px-8 pb-16 pt-10"
|
||
>
|
||
{/* 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). */}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
closeMobile();
|
||
setNewsletterOpen(true);
|
||
}}
|
||
className="md:hidden 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
|
||
</button>
|
||
<Link
|
||
href="/3x3-system"
|
||
onClick={closeMobile}
|
||
className="md:hidden 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"
|
||
>
|
||
Mein 3x3-System
|
||
</Link>
|
||
</motion.div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||
</>
|
||
);
|
||
}
|