Files
einfach-produktiv/app/components/Navbar.tsx
T
Marco 9e5532be63 Add shop, cart, versand pages and Impulse & Tipps detail page
- 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
2026-07-19 14:42:22 +00:00

489 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}