e4f0c66d7b
New /todo-cards page (Hero, How-it-works, Focus, Testimonials, Pricing) built with the fluid clamp() token system (app/lib/fluid.ts) and scroll-reveal animations (app/components/Reveal.tsx), matching styling consistency with /challenge's testimonial section. Navbar: page-aware active state and anchor-link navigation from any route (not just "/"), fixed logo click to actually navigate instead of silently rewriting the URL, fluid nav text size, custom hash-scroll handling for cross-page anchor links. Hero: responsive breakpoint fix for the text/image split at Tablet widths, entrance animation for the brand's orange dot, removed a red dot artifact from hero.png. Also includes prior uncommitted work on About/Blog/Newsletter/Footer and the cart lib (app/lib/cart.ts). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
425 lines
17 KiB
TypeScript
425 lines
17 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";
|
|
|
|
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-in-out cubic
|
|
const ease = progress < 0.5
|
|
? 4 * progress ** 3
|
|
: 1 - (-2 * progress + 2) ** 3 / 2;
|
|
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)
|
|
// and /challenge (7-Tage-Challenge) are both tools listed there, even
|
|
// though neither route is nested under /werkzeuge/. Their own Figma
|
|
// sources (page-todo-karten, and the 7-Tage-Challenge card in
|
|
// section-tools) mark "Werkzeuge" active in the nav for the same reason.
|
|
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge"];
|
|
|
|
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();
|
|
return (
|
|
<Link
|
|
href="/cart"
|
|
aria-label={count > 0 ? `Warenkorb, ${count} 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" 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>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
export function Navbar() {
|
|
const pathname = usePathname();
|
|
const [scrolled, setScrolled] = useState(false);
|
|
const [activeSection, setActiveSection] = useState("");
|
|
const [mobileOpen, setMobileOpen] = 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">
|
|
<Link
|
|
href="/newsletter"
|
|
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
|
|
</Link>
|
|
<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">
|
|
<Link
|
|
href="/newsletter"
|
|
onClick={closeMobile}
|
|
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
|
|
</Link>
|
|
<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>
|
|
</header>
|
|
);
|
|
}
|