"use client"; import Link from "next/link"; import { useState, useEffect, useRef, type RefObject } from "react"; import { AnimatePresence, motion } 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 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 }: { t: Dictionary; companyName: string }) { return ( {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 ? ( setQuery(e.target.value)} autoFocus autoComplete="off" placeholder={t.nav.searchPlaceholder} className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none" /> {visibleSuggestions.length > 0 ? (
    {visibleSuggestions.map((s) => (
  • {s.name} {s.price}
  • ))}
) : null}
) : null}
); } 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); return ( ); } export function Header({ companyName }: { companyName: string }) { const { t } = useI18n(); const [mobileOpen, setMobileOpen] = useState(false); const menuButtonRef = useRef(null); const mobileMenuRef = useRef(null); function closeMobileMenu() { setMobileOpen(false); menuButtonRef.current?.focus(); } useEscapeKey(mobileOpen, closeMobileMenu); useFocusTrap(mobileOpen, mobileMenuRef); return ( <>
{/* Eigener, breiterer Wrapper statt der gemeinsamen (max-w-6xl): Logo + 7-Punkte-Navigation + CTA-Button + Icons brauchen mehr Raum als die 1152px-Inhaltsspalte der restlichen Seite hergibt — mit Container stiessen Navigation und Button ab 1280px Breite ohne jeden Abstand aneinander. */}
{t.nav.discoverHoney}
{/* 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 ? (

{t.cart.empty}

) : (
    {items.map((item) => { 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.