9e5532be63
- New /shop overview, /cart (real cart state via useSyncExternalStore), /versand (shipping policy page with TOC) and /newsletter detail page - Cart: quantity/removal, order summary, related-products cross-sell with randomized picks, VersandModal quick-reference instead of navigating away, MwSt. disclosure next to unit prices - Add-to-cart UX: inline success feedback (green state) plus a fly-to-navbar-cart-icon animation (CartFlyProvider) with a delayed badge count-up; AddToCartButton no longer navigates straight to /cart - Shared lib/products.ts catalog and lib/shipping.ts constants (cost, free-shipping threshold, handling/transit days) so cart, trust badges and the versand page can never drift apart - Fix Navbar smooth-scroll easing (ease-out instead of ease-in-out, no more perceived start delay); compress newsletter modal photo 2.4MB -> 85KB to fix first-open jank
489 lines
20 KiB
TypeScript
489 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import Image from "next/image";
|
||
import { usePathname } from "next/navigation";
|
||
import { useCartCount } from "../lib/cart";
|
||
import { NewsletterModal } from "./NewsletterModal";
|
||
import { useCartFly } from "./CartFly";
|
||
|
||
const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem]
|
||
const SCROLL_DURATION = 800; // ms
|
||
|
||
function smoothScrollTo(targetY: number) {
|
||
const startY = window.scrollY;
|
||
const distance = targetY - startY;
|
||
const startTime = performance.now();
|
||
|
||
function step(now: number) {
|
||
const elapsed = now - startTime;
|
||
const progress = Math.min(elapsed / SCROLL_DURATION, 1);
|
||
// ease-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);
|
||
}
|
||
|
||
const navLinks = [
|
||
{ label: "Werkzeuge", href: "#werkzeuge" },
|
||
{ label: "Blog", href: "#blog" },
|
||
{ label: "Über Björn", href: "#ueber-bjoern" },
|
||
{ label: "Shop", href: "/shop" },
|
||
];
|
||
|
||
const anchorIds = navLinks
|
||
.filter((l) => l.href.startsWith("#"))
|
||
.map((l) => l.href.slice(1));
|
||
|
||
// "Werkzeuge" also covers standalone tool/product pages that live under
|
||
// the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten),
|
||
// /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}/`);
|
||
}
|
||
|
||
// 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() {
|
||
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);
|
||
|
||
useEffect(() => {
|
||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => window.removeEventListener("scroll", onScroll);
|
||
}, []);
|
||
|
||
// Handles landing on "/#werkzeuge" etc. after a cross-page nav click
|
||
// (e.g. clicking "Werkzeuge" from /todo-cards) or a direct URL load with
|
||
// a hash. Next.js's own built-in hash-scroll-after-navigation doesn't
|
||
// reliably apply the sticky navbar's height offset and can fire before
|
||
// the just-mounted page's layout has settled — this reimplements it with
|
||
// the exact same smoothScrollTo + NAVBAR_HEIGHT offset as the same-page
|
||
// click handlers below, so behavior is identical either way. Runs
|
||
// whenever pathname becomes "/" (covers both the initial load and a
|
||
// client-side transition landing here), a short delay lets layout
|
||
// settle first.
|
||
useEffect(() => {
|
||
if (pathname !== "/") return;
|
||
const hash = window.location.hash.slice(1);
|
||
if (!anchorIds.includes(hash)) return;
|
||
const timer = setTimeout(() => {
|
||
const el = document.getElementById(hash);
|
||
if (el) {
|
||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||
smoothScrollTo(top);
|
||
}
|
||
}, 50);
|
||
return () => clearTimeout(timer);
|
||
}, [pathname]);
|
||
|
||
useEffect(() => {
|
||
const onScroll = () => {
|
||
// trigger line = 1/3 from top of viewport (= 2/3 from bottom)
|
||
const triggerY = window.scrollY + NAVBAR_HEIGHT + (window.innerHeight - NAVBAR_HEIGHT) / 3;
|
||
let current = "";
|
||
for (const id of anchorIds) {
|
||
const el = document.getElementById(id);
|
||
if (!el) continue;
|
||
const top = el.getBoundingClientRect().top + window.scrollY;
|
||
if (top <= triggerY) current = id;
|
||
}
|
||
setActiveSection(current);
|
||
};
|
||
|
||
onScroll();
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => window.removeEventListener("scroll", onScroll);
|
||
}, []);
|
||
|
||
// Close on viewport resize past the structural breakpoint, so the drawer
|
||
// never lingers open behind the (now visible) desktop nav. The hamburger
|
||
// exists through both sub-768 ("Collapsed") and 768-1023 ("Collapsed-CTA")
|
||
// tiers, and only disappears at lg (1024) — see Navbar section of the plan.
|
||
useEffect(() => {
|
||
const onResize = () => {
|
||
if (window.innerWidth >= 1024) setMobileOpen(false);
|
||
};
|
||
window.addEventListener("resize", onResize);
|
||
return () => window.removeEventListener("resize", onResize);
|
||
}, []);
|
||
|
||
// Focus management: move focus into the panel on open, trap Tab within
|
||
// it, close + return focus to the hamburger button on Escape.
|
||
useEffect(() => {
|
||
if (!mobileOpen) return;
|
||
|
||
const panel = panelRef.current;
|
||
const focusables = panel?.querySelectorAll<HTMLElement>(
|
||
'a[href], button:not([disabled])'
|
||
);
|
||
focusables?.[0]?.focus();
|
||
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
setMobileOpen(false);
|
||
hamburgerRef.current?.focus();
|
||
return;
|
||
}
|
||
if (e.key !== "Tab" || !focusables || focusables.length === 0) return;
|
||
const first = focusables[0];
|
||
const last = focusables[focusables.length - 1];
|
||
if (e.shiftKey && document.activeElement === first) {
|
||
e.preventDefault();
|
||
last.focus();
|
||
} else if (!e.shiftKey && document.activeElement === last) {
|
||
e.preventDefault();
|
||
first.focus();
|
||
}
|
||
};
|
||
|
||
document.addEventListener("keydown", onKeyDown);
|
||
return () => document.removeEventListener("keydown", onKeyDown);
|
||
}, [mobileOpen]);
|
||
|
||
const closeMobile = () => setMobileOpen(false);
|
||
|
||
return (
|
||
<header
|
||
className={`sticky top-0 z-50 w-full h-[6.25rem] flex flex-col 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">
|
||
<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>
|
||
|
||
{/* Mobile drawer panel — toggleable below lg (see hamburger above) */}
|
||
<div
|
||
id="mobile-nav-panel"
|
||
ref={panelRef}
|
||
className={`lg:hidden w-full overflow-hidden transition-[max-height] duration-300 ease-in-out ${
|
||
mobileOpen ? "max-h-[30rem]" : "max-h-0"
|
||
}`}
|
||
>
|
||
<nav className="flex flex-col gap-6 px-8 pt-2 pb-6">
|
||
{navLinks.map((link) => {
|
||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||
return link.href.startsWith("#") ? (
|
||
<Link
|
||
key={link.href}
|
||
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-h4 font-semibold text-text-primary w-fit"
|
||
>
|
||
{link.label}
|
||
<span
|
||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||
}`}
|
||
/>
|
||
</Link>
|
||
) : (
|
||
<Link
|
||
key={link.href}
|
||
href={link.href}
|
||
onClick={closeMobile}
|
||
className="min-h-11 flex items-center text-h4 font-semibold text-text-primary"
|
||
>
|
||
{link.label}
|
||
</Link>
|
||
);
|
||
})}
|
||
</nav>
|
||
<div className="flex flex-col gap-3 px-8 pb-8">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
closeMobile();
|
||
setNewsletterOpen(true);
|
||
}}
|
||
className="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="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>
|
||
</div>
|
||
</div>
|
||
|
||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||
</header>
|
||
);
|
||
}
|