Files
anouma/components/Navbar.tsx
T
maro 526a908c80
CI / test (push) Successful in 3m42s
Release / test (push) Successful in 3m44s
Release / build-and-deploy (push) Successful in 8m59s
Redesign: warmer, more editorial visual pass; remove stray em dashes
Replaces em dashes with en dashes across all hardcoded copy, emails, and
seed content, then reworks the site's visual language so it reads as
deliberately designed rather than assembled from generic components:

- New ArrowLink component replaces ad hoc "Mehr erfahren ->" links and
  ghost buttons; one shared FormField (TextField/TextAreaField) replaces
  7 near-duplicate field styles across every form
- Navbar loses its glassmorphism blur for a solid warm background, gets a
  subtle active-link underline and a softer, warmer mega-menu
- PageHeader supports per-page blob tone/position and a compact mode
  instead of an identical band on every interior page
- Homepage: the core offer is featured large instead of four equal cards,
  the philosophy section gets a single calmer accent blob, blog teasers
  now show their cover image like the real /aktuelles page does
- Booking calendar and widget: 44px touch targets throughout (month nav,
  day cells, time slots, cancel action), a visible "today" ring, and
  responsive padding so the calendar isn't cramped on small screens
- Quieter, warmer empty states on /termine and /aktuelles

No functional changes: all interaction logic (nav menus, forms, booking,
CMS data fetching) is untouched, only markup and styling.
2026-09-06 22:00:28 +02:00

337 lines
14 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { groupOffersByCategory } from "@/lib/angebote";
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
import { track } from "@/lib/analytics/client";
import { OrganicBlob } from "@/components/OrganicBlob";
import type { Offer } from "@/payload-types";
export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) {
const pathname = usePathname();
const groups = groupOffersByCategory(offers);
const [megaOpen, setMegaOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileAngeboteOpen, setMobileAngeboteOpen] = useState(false);
const closeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const navRef = useRef<HTMLDivElement>(null);
// Close open menus when the route changes, adjusted during render rather
// than in an effect (see https://react.dev/learn/you-might-not-need-an-effect).
const [lastPathname, setLastPathname] = useState(pathname);
if (pathname !== lastPathname) {
setLastPathname(pathname);
setMobileOpen(false);
setMegaOpen(false);
}
useEffect(() => {
document.body.style.overflow = mobileOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setMegaOpen(false);
setMobileOpen(false);
}
}
function onClick(e: MouseEvent) {
if (navRef.current && !navRef.current.contains(e.target as Node)) {
setMegaOpen(false);
}
}
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onClick);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onClick);
};
}, []);
function openMega() {
if (closeTimeout.current) clearTimeout(closeTimeout.current);
setMegaOpen(true);
}
function scheduleCloseMega() {
closeTimeout.current = setTimeout(() => setMegaOpen(false), 150);
}
return (
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
<Link
href="/"
className="font-serif text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
>
{siteConfig.name.toUpperCase()}
</Link>
<nav ref={navRef} className="hidden items-center gap-8 lg:flex" aria-label="Hauptnavigation">
{mainNav.map((item) =>
item.title === "Angebote" ? (
<div
key={item.href}
className="relative"
onMouseEnter={openMega}
onMouseLeave={scheduleCloseMega}
>
<button
type="button"
className="flex items-center gap-1 text-[15px] font-medium text-anouma-plum/90 transition-colors hover:text-anouma-mauve-dark"
aria-haspopup="true"
aria-expanded={megaOpen}
onClick={() => setMegaOpen((v) => !v)}
>
{item.title}
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
aria-hidden="true"
className={`transition-transform duration-300 ${megaOpen ? "rotate-180" : ""}`}
>
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</button>
<AnimatePresence>
{megaOpen && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 overflow-hidden rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-lg shadow-anouma-plum/10"
>
<OrganicBlob tone="cream" className="-right-16 -top-16 h-52 w-52 opacity-30" />
<div className="relative">
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<Link
key={item.href}
href={item.href}
onClick={() => track("navigation_clicked", { path: item.href })}
className={`relative pb-1 text-[15px] font-medium transition-colors after:absolute after:bottom-0 after:left-0 after:h-px after:bg-anouma-mauve-dark after:transition-all after:duration-300 after:content-[''] hover:text-anouma-mauve-dark hover:after:w-full ${
pathname === item.href ? "text-anouma-mauve-dark after:w-full" : "text-anouma-plum/90 after:w-0"
}`}
>
{item.title}
</Link>
)
)}
</nav>
<div className="hidden items-center gap-5 lg:flex">
<Link
href={customerName ? "/konto" : "/login"}
className="text-[15px] font-medium text-anouma-plum/90 transition-colors hover:text-anouma-mauve-dark"
>
{customerName ? "Mein Konto" : "Anmelden"}
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
>
{ctaNav.title}
</Link>
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full text-anouma-plum lg:hidden"
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
aria-expanded={mobileOpen}
onClick={() => setMobileOpen((v) => !v)}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<motion.path
animate={mobileOpen ? { d: "M5 5l14 14" } : { d: "M4 7h16" }}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
transition={{ duration: 0.25 }}
/>
<motion.path
animate={mobileOpen ? { opacity: 0 } : { opacity: 1, d: "M4 12h16" }}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
transition={{ duration: 0.2 }}
/>
<motion.path
animate={mobileOpen ? { d: "M5 19l14-14" } : { d: "M4 17h16" }}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
transition={{ duration: 0.25 }}
/>
</svg>
</button>
</div>
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-y-auto border-t border-anouma-taupe/15 bg-background lg:hidden"
style={{ maxHeight: "calc(100dvh - 5rem)" }}
>
<nav className="flex flex-col px-6 py-6" aria-label="Mobile Navigation">
{mainNav.map((item) =>
item.title === "Angebote" ? (
<div key={item.href} className="border-b border-anouma-taupe/10">
<button
type="button"
className="flex w-full items-center justify-between py-4 text-lg font-medium text-anouma-plum"
aria-expanded={mobileAngeboteOpen}
onClick={() => setMobileAngeboteOpen((v) => !v)}
>
{item.title}
<svg
width="12"
height="8"
viewBox="0 0 10 6"
fill="none"
aria-hidden="true"
className={`transition-transform duration-300 ${mobileAngeboteOpen ? "rotate-180" : ""}`}
>
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</button>
<AnimatePresence>
{mobileAngeboteOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden pb-4"
>
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category} className="mb-4">
<Link
href={groupHref}
className="block py-2 text-base font-medium text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="ml-3 space-y-1 border-l border-anouma-taupe/20 pl-4">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="block py-2 text-sm text-anouma-plum"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<Link
key={item.href}
href={item.href}
onClick={() => track("navigation_clicked", { path: item.href })}
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
>
{item.title}
</Link>
)
)}
<Link
href={customerName ? "/konto" : "/login"}
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
>
{customerName ? "Mein Konto" : "Anmelden"}
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
>
{ctaNav.title}
</Link>
</nav>
</motion.div>
)}
</AnimatePresence>
</header>
);
}