"use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useState, useEffect, useRef, type RefObject } from "react"; import { AnimatePresence, motion, useAnimationControls } from "motion/react"; import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store"; import { useCartStock } from "@/hooks/use-cart-stock"; import { formatPrice } from "@/lib/format"; import { mainNav } from "@/lib/site-config"; import { Container, LinkButton } from "@/components/ui"; import { Logomark } from "@/components/logomark"; import { CartItemThumbnail } from "@/components/cart-item-thumbnail"; import { QuantityStepper } from "@/components/quantity-stepper"; import { LanguageSwitcher } from "@/components/language-switcher"; import { useI18n } from "@/i18n/i18n-provider"; import { CartFlight, onCartLanded } from "@/components/cart-flight"; import { HexMark } from "@/components/honeycomb"; import type { Dictionary } from "@/i18n/dictionary-type"; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; /** * Haelt den Tab-Fokus innerhalb eines geoeffneten Overlays (Cart-Drawer, * mobiles Menue) — sonst koennte man sich per Tab zu Links "hinter" dem * abgedunkelten Hintergrund durchtabben, die dort unsichtbar, aber technisch * weiterhin fokussierbar sind. */ function useFocusTrap(active: boolean, containerRef: RefObject) { useEffect(() => { if (!active) return; // Fokus beim Oeffnen aktiv in den Container verschieben — sonst bleibt er // auf dem jetzt visuell durch den Hintergrund verdeckten Ausloeser-Button // stehen, und Tastaturnutzer:innen muessten sich erst durch den Rest der // (fuer sie unsichtbaren) Seite tabben, bevor sie ueberhaupt im Overlay // ankommen. Der Container selbst (tabindex=-1), nicht z.B. der // Schliessen-Button, damit eine Screenreader-Ansage nicht sofort mit // "Schliessen" beginnt. containerRef.current?.focus(); function handleKeyDown(event: KeyboardEvent) { const container = containerRef.current; if (event.key !== "Tab" || !container) return; const focusable = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (el) => el.offsetParent !== null ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [active, containerRef]); } /** Schliesst ein Overlay (Suche, Cart-Drawer, mobiles Menue) per Escape-Taste. */ function useEscapeKey(active: boolean, onEscape: () => void) { useEffect(() => { if (!active) return; function handleKeyDown(event: KeyboardEvent) { if (event.key === "Escape") onEscape(); } document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [active, onEscape]); } function Logo({ t, companyName, logoSrc }: { t: Dictionary; companyName: string; logoSrc: string | null }) { return ( {/* Kopfzeile ist bei 1280-1440 px knapp: breite Logos hier enger begrenzt. */} {companyName} {t.footer.tagline} ); } interface SearchSuggestion { slug: string; name: string; price: string; } function SearchButton() { const { t } = useI18n(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [suggestions, setSuggestions] = useState([]); const toggleButtonRef = useRef(null); // "Eingabe zu kurz" ergibt sich unmittelbar aus query und wird beim // Rendern abgeleitet, statt im Effekt per setState hergestellt zu werden — // ein setState direkt im Effektrumpf loest einen zusaetzlichen // Renderdurchlauf aus, nur um einen ohnehin feststehenden Zustand zu // erzeugen. Nebeneffekt: beim Leeren des Feldes verschwindet die Liste // sofort, statt erst nach dem naechsten Effektlauf. const visibleSuggestions = query.trim().length < 2 ? [] : suggestions; useEffect(() => { const trimmed = query.trim(); if (trimmed.length < 2) return; const controller = new AbortController(); const timeout = setTimeout(() => { fetch(`/api/shop/search-suggestions?q=${encodeURIComponent(trimmed)}`, { signal: controller.signal, }) .then((res) => res.json()) .then((data: { suggestions?: SearchSuggestion[] }) => setSuggestions(data.suggestions ?? [])) .catch(() => {}); }, 200); return () => { clearTimeout(timeout); controller.abort(); }; }, [query]); function closeSearch() { setOpen(false); setQuery(""); setSuggestions([]); toggleButtonRef.current?.focus(); } useEscapeKey(open, closeSearch); return (
{open ? ( // Legt sich UEBER die Navigation (absolut, von rechts aufklappend) // statt sie beiseitezuschieben — vorher drueckte das Feld die // Kopfzeile auseinander und erzeugte einen waagerechten Scrollbalken. setQuery(e.target.value)} autoFocus autoComplete="off" placeholder={t.nav.searchPlaceholder} className="w-full border-b border-forest bg-transparent px-2 py-1.5 text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none" /> {visibleSuggestions.length > 0 ? ( {visibleSuggestions.map((s, i) => ( {s.price} ))} ) : null} ) : null}
); } /** Hebt den gesuchten Begriff im Vorschlag hervor — der Blick findet sofort, warum er passt. */ function HighlightMatch({ text, query }: { text: string; query: string }) { const needle = query.trim(); const index = needle ? text.toLowerCase().indexOf(needle.toLowerCase()) : -1; if (index < 0) return <>{text}; return ( <> {text.slice(0, index)} {text.slice(index, index + needle.length)} {text.slice(index + needle.length)} ); } function AccountIconButton() { const { t } = useI18n(); return ( ); } function CartIconButton() { const { t } = useI18n(); const items = useCartStore((s) => s.items); const openCart = useCartStore((s) => s.openCart); const count = cartItemCount(items); const basketControls = useAnimationControls(); // Landet ein Honigtropfen (components/cart-flight.tsx), faengt der Korb // ihn sichtbar auf: kurzes Einsinken und Nachwippen. useEffect( () => onCartLanded(() => { void basketControls.start({ y: [0, 3, -2, 0], rotate: [0, -8, 5, 0], transition: { duration: 0.45, ease: "easeOut" }, }); }), [basketControls] ); return ( ); } /** * Kopfzeile reagiert aufs Scrollen: ab etwas Abstand zum Seitenanfang * bekommt sie Schatten und staerkeres Glas; beim Weiterlesen nach unten * weicht sie aus, beim kleinsten Hochscrollen ist sie sofort wieder da. * Waehrend Warenkorb oder Menue offen sind, bleibt sie stehen. */ function useHeaderScrollState(locked: boolean) { const [scrolled, setScrolled] = useState(false); const [hidden, setHidden] = useState(false); useEffect(() => { let lastY = window.scrollY; let frame = 0; const onScroll = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { const y = window.scrollY; setScrolled(y > 12); // Kleine Zitterbewegungen (Trackpad) ignorieren. if (Math.abs(y - lastY) > 6) { setHidden(y > lastY && y > 240); lastY = y; } }); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => { cancelAnimationFrame(frame); window.removeEventListener("scroll", onScroll); }; }, []); return { scrolled, hidden: hidden && !locked }; } function isActivePath(pathname: string, href: string): boolean { return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`); } export function Header({ companyName, logoSrc = null, logoInvertedSrc = null, }: { companyName: string; logoSrc?: string | null; logoInvertedSrc?: string | null; }) { const { t } = useI18n(); const pathname = usePathname(); const [mobileOpen, setMobileOpen] = useState(false); const cartOpen = useCartStore((s) => s.isOpen); const { scrolled, hidden } = useHeaderScrollState(mobileOpen || cartOpen); const menuButtonRef = useRef(null); const mobileMenuRef = useRef(null); function closeMobileMenu() { setMobileOpen(false); menuButtonRef.current?.focus(); } useEscapeKey(mobileOpen, closeMobileMenu); useFocusTrap(mobileOpen, mobileMenuRef); return ( <> {/* Bewusst AUSSERHALB von
: backdrop-blur auf dem Header erzeugt einen eigenen CSS-Containing-Block fuer position:fixed-Nachfahren (wie transform/filter). Ein "fixed inset-0" Overlay INNERHALB des Headers wuerde sich dadurch relativ zum Header statt zum Viewport positionieren und den Seiteninhalt durchscheinen lassen, statt ihn vollflaechig abzudecken. */} {mobileOpen ? ( {companyName}
setMobileOpen(false)} > {t.nav.discoverHoney}
) : null}
); } export function CartDrawer() { const { t, locale } = useI18n(); const isOpen = useCartStore((s) => s.isOpen); const closeCart = useCartStore((s) => s.closeCart); const items = useCartStore((s) => s.items); const triggerRef = useRef(null); // CartIconButton (der Ausloeser) und CartDrawer sind ueber den Zustand-Store // entkoppelt, nicht ueber Props verbunden — deshalb wird beim Oeffnen einfach // gemerkt, welches Element gerade fokussiert war (typischerweise der // angeklickte Button), um den Fokus beim Schliessen dorthin zurueckzugeben, // statt ihn auf verpuffen zu lassen. useEffect(() => { if (isOpen) { triggerRef.current = document.activeElement; } else { if (triggerRef.current instanceof HTMLElement) { triggerRef.current.focus(); } triggerRef.current = null; } }, [isOpen]); const setQuantity = useCartStore((s) => s.setQuantity); const removeItem = useCartStore((s) => s.removeItem); const total = cartTotalCents(items); const asideRef = useRef(null); // Live-Bestand fuer die Positionen im Drawer — ohne das wuerde ein // zwischenzeitlich ausverkaufter Artikel hier unauffaellig weiter als // normal bestellbar erscheinen (siehe use-cart-stock.ts). const stock = useCartStock(items.map((item) => item.productId)); useEscapeKey(isOpen, closeCart); useFocusTrap(isOpen, asideRef); return ( {isOpen ? ( <>

{t.cart.title} {items.length > 0 ? ( ({cartItemCount(items)}) ) : null}

{items.length === 0 ? ( // Leerer Korb als ruhiger Einstieg statt toter Flaeche: leere // Wabe, eine Zeile, ein Weg zurueck in den Shop.

{t.cart.empty}

{t.nav.discoverHoney}
) : (
    {items.map((item, index) => { const itemStock = stock?.[item.productId]; return (

    {item.name}

    {item.weightGrams} g

    {/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von "kein Hinweis" zu "Ausverkauft"/"Nur noch N" bestehen, damit Screenreader die neu erscheinende Warnung tatsaechlich ansagen statt sie stumm zu uebergehen (ein erst mit dem Hinweis selbst eingefuegtes aria-live-Element wird von mancher AT ignoriert). */}
    {itemStock !== undefined && itemStock === 0 ? (

    {t.product.outOfStock}

    ) : itemStock !== undefined && itemStock <= 5 ? (

    {t.product.onlyLeft(itemStock)}

    ) : null}
    setQuantity(item.productId, quantity)} max={itemStock ?? 10} size="sm" />

    {formatPrice(item.priceCents * item.quantity, "EUR", locale)}

    ); })}
)}
{items.length > 0 ? (
{t.cart.subtotal} {formatPrice(total, "EUR", locale)}
{t.cart.checkout} {t.cart.viewCart}
) : null}
) : null}
); } // Footer wurde nach @/components/site-footer.tsx ausgelagert (Server // Component statt Teil dieser "use client"-Datei) — siehe Kommentar dort.