43944d8cc8
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns
Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
included, broken down per rate when a cart spans more than one
(new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown
Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).
Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.
Checkout:
- Optional deviating shipping address (separate from the billing address
used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
address differs from billing
Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).
Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.
Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
662 lines
30 KiB
TypeScript
662 lines
30 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 { 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", "/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() {
|
||
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"
|
||
>
|
||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" 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>
|
||
);
|
||
}
|
||
|
||
// 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 }: { 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<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.
|
||
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. */}
|
||
<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">
|
||
<AccountLink />
|
||
<CartLink />
|
||
|
||
{/* 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="/challenge"
|
||
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"
|
||
>
|
||
7-Tage-Challenge
|
||
</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={() => 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%)" }}
|
||
exit={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||
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="/challenge"
|
||
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"
|
||
>
|
||
7-Tage-Challenge
|
||
</Link>
|
||
</motion.div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||
</>
|
||
);
|
||
}
|