Files
anouma/components/Navbar.tsx
T
maro 5d83c0dc1e Add calendar-based booking system: accounts, availability, admin calendar
- Customer accounts (separate auth collection) with /konto area
- Availability + AvailabilityOverrides collections driving real slot calculation
- BookingRequests with race-safe confirmation (Postgres advisory lock + transaction)
- Booking emails (request received, admin notify, confirmed, rejected, alternative proposed/accepted, cancelled)
- Confirmed online bookings auto-create a private linked video-call Event
- Custom Payload admin calendar view (month grid + day schedule)
- Wired booking widget into offer pages and /termin-buchen
2026-08-25 16:52:34 +02:00

328 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 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/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 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}
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={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}
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>
);
}