- Next.js 16 App Router site with the ANOUMA design system - Payload CMS (PostgreSQL) for offers, events, posts and page content - WebRTC video-call system with custom signaling server - SMTP email reminders and booking-request notifications - Customer accounts, calendar-based availability, and booking workflow
316 lines
13 KiB
TypeScript
316 lines
13 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 type { Offer } from "@/payload-types";
|
|
|
|
export function Navbar({ offers }: { offers: Offer[] }) {
|
|
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/85 backdrop-blur-md">
|
|
<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 rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-xl shadow-anouma-plum/10"
|
|
>
|
|
<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>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
) : (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
|
|
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
|
|
}`}
|
|
>
|
|
{item.title}
|
|
</Link>
|
|
)
|
|
)}
|
|
</nav>
|
|
|
|
<div className="hidden lg:block">
|
|
<Link
|
|
href={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}
|
|
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
|
|
>
|
|
{item.title}
|
|
</Link>
|
|
)
|
|
)}
|
|
<Link
|
|
href={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>
|
|
);
|
|
}
|