Files
Wabenhain/src/components/site-chrome.tsx
T
Nick Szaunig 02d493dede fix: verbleibende Lint-Befunde ausraeumen (Lint jetzt ohne Beanstandung)
Vier offene Befunde standen einem sauberen Produktionslauf im Weg:

- recently-viewed-section und site-chrome riefen setState direkt im
  Effektrumpf auf, um einen Leerzustand herzustellen ("keine angesehenen
  Produkte" bzw. "Eingabe kuerzer als zwei Zeichen"). Beides ergibt sich
  unmittelbar aus vorhandenen Werten und wird jetzt beim Rendern
  abgeleitet — das spart je einen zusaetzlichen Renderdurchlauf. Bei der
  Suche verschwindet die Vorschlagsliste dadurch sofort beim Leeren des
  Feldes statt erst nach dem naechsten Effektlauf.

- STAT_KEYS im Admin-Dashboard wurde ausschliesslich als Typ verwendet,
  landete als `as const`-Array aber trotzdem im Bundle. Ersetzt durch
  einen Typ-Alias.

- Ein DHL-Test importierte DhlApiError, ohne es zu benutzen, und pruefte
  nur den Meldungstext — er haette damit jeden beliebigen anderen Fehler
  mit passendem Text durchgehen lassen. Er prueft jetzt zusaetzlich den
  Fehlertyp, wie seine beiden Geschwistertests.

Stand danach: eslint ohne Beanstandung (vorher 2 Fehler, 2 Warnungen),
tsc ohne Fehler, 201 Tests gruen, Produktions-Build erfolgreich.
2026-09-14 20:37:01 +02:00

544 lines
21 KiB
TypeScript

"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<HTMLElement | null>) {
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<HTMLElement>(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 (
<Link href="/" className="flex items-center gap-3 group">
<Logomark className="h-9 w-9 flex-shrink-0" />
<span className="flex flex-col leading-none">
<span className="text-2xl tracking-[0.2em] text-forest font-serif">
{companyName}
</span>
<span className="hidden xl:block text-[10px] tracking-[0.3em] uppercase text-honey-dark mt-1 whitespace-nowrap">
{t.footer.tagline}
</span>
</span>
</Link>
);
}
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<SearchSuggestion[]>([]);
const toggleButtonRef = useRef<HTMLButtonElement>(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 (
<div className="relative flex items-center">
<AnimatePresence>
{open ? (
<motion.form
initial={{ width: 0, opacity: 0 }}
animate={{ width: 180, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
action="/shop"
method="GET"
className="overflow-visible"
>
<input
type="search"
name="q"
value={query}
onChange={(e) => 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 ? (
<ul className="absolute top-full left-0 mt-2 w-72 bg-background border border-ink/10 shadow-[0_16px_32px_-16px_rgba(36,53,43,0.25)] z-50">
{visibleSuggestions.map((s) => (
<li key={s.slug}>
<Link
href={`/honig/${s.slug}`}
onClick={closeSearch}
className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60"
>
<span>{s.name}</span>
<span className="text-ink/70 whitespace-nowrap">{s.price}</span>
</Link>
</li>
))}
</ul>
) : null}
</motion.form>
) : null}
</AnimatePresence>
<button
ref={toggleButtonRef}
type="button"
onClick={() => (open ? closeSearch() : setOpen(true))}
aria-label={t.nav.openSearch}
className="flex items-center justify-center w-10 h-10 text-forest flex-shrink-0"
>
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</button>
</div>
);
}
function AccountIconButton() {
const { t } = useI18n();
return (
<Link
href="/konto"
aria-label={t.nav.account}
className="flex items-center justify-center w-10 h-10 text-forest"
>
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c1.5-4 5-6 8-6s6.5 2 8 6" />
</svg>
</Link>
);
}
function CartIconButton() {
const { t } = useI18n();
const items = useCartStore((s) => s.items);
const openCart = useCartStore((s) => s.openCart);
const count = cartItemCount(items);
return (
<button
onClick={openCart}
aria-label={t.nav.openCart(count)}
className="relative flex items-center justify-center w-10 h-10 text-forest"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6h15l-1.5 9h-12z" />
<path d="M6 6 5 3H2" />
<circle cx="9" cy="20" r="1" />
<circle cx="18" cy="20" r="1" />
</svg>
{count > 0 ? (
<span className="absolute -top-1 -right-1 bg-honey text-cream text-[10px] w-4 h-4 rounded-full flex items-center justify-center">
{count}
</span>
) : null}
</button>
);
}
export function Header({ companyName }: { companyName: string }) {
const { t } = useI18n();
const [mobileOpen, setMobileOpen] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
function closeMobileMenu() {
setMobileOpen(false);
menuButtonRef.current?.focus();
}
useEscapeKey(mobileOpen, closeMobileMenu);
useFocusTrap(mobileOpen, mobileMenuRef);
return (
<>
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur border-b border-ink/10">
{/*
Eigener, breiterer Wrapper statt der gemeinsamen <Container> (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.
*/}
<div className="mx-auto flex h-20 w-full max-w-[100rem] items-center justify-between px-6 2xl:px-10">
<Logo t={t} companyName={companyName} />
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
{mainNav.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-ink/80 hover:text-forest transition-colors whitespace-nowrap"
>
{t.nav[item.key]}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<div className="hidden md:block">
<LinkButton href="/shop" variant="primary" className="whitespace-nowrap">
{t.nav.discoverHoney}
</LinkButton>
</div>
<div className="hidden xl:flex items-center gap-1">
<LanguageSwitcher />
<SearchButton />
<AccountIconButton />
</div>
<CartIconButton />
<button
ref={menuButtonRef}
aria-label={t.nav.openMenu}
className="xl:hidden flex items-center justify-center w-10 h-10 text-forest"
onClick={() => setMobileOpen(true)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
</div>
</div>
</header>
{/*
Bewusst AUSSERHALB von <header>: 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.
*/}
<AnimatePresence>
{mobileOpen ? (
<motion.div
ref={mobileMenuRef}
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-50 bg-forest text-cream flex flex-col outline-none"
>
<Container className="flex items-center justify-between h-20">
<span className="flex items-center gap-3">
<Logomark className="h-8 w-8" variant="inverted" />
<span className="text-xl tracking-[0.2em] font-serif">
{companyName}
</span>
</span>
<div className="flex items-center gap-4">
<LanguageSwitcher className="!text-cream/70" />
<button
aria-label={t.nav.closeMenu}
className="w-10 h-10 flex items-center justify-center"
onClick={closeMobileMenu}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</Container>
<Container>
<form action="/shop" method="GET">
<input
type="search"
name="q"
placeholder={t.nav.searchPlaceholder}
className="w-full border-b border-cream/40 bg-transparent py-2 text-lg placeholder:text-cream/50 focus:outline-none"
/>
</form>
</Container>
<nav aria-label="Mobile Hauptnavigation" className="flex-1 flex flex-col items-start justify-center gap-6 px-10">
{mainNav.map((item, i) => (
<motion.div
key={item.href}
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 * i, duration: 0.3 }}
>
<Link
href={item.href}
onClick={() => setMobileOpen(false)}
className="text-3xl font-serif"
>
{t.nav[item.key]}
</Link>
</motion.div>
))}
<motion.div
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 * mainNav.length, duration: 0.3 }}
>
<Link
href="/konto"
onClick={() => setMobileOpen(false)}
className="text-3xl font-serif"
>
{t.nav.account}
</Link>
</motion.div>
</nav>
<Container className="pb-10">
<LinkButton
href="/shop"
variant="secondary"
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest w-full"
onClick={() => setMobileOpen(false)}
>
{t.nav.discoverHoney}
</LinkButton>
</Container>
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
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<Element | null>(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 <body> 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<HTMLElement>(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 (
<AnimatePresence>
{isOpen ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-ink/40 z-50"
onClick={closeCart}
/>
<motion.aside
ref={asideRef}
tabIndex={-1}
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 28, stiffness: 260 }}
className="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-background z-50 flex flex-col shadow-xl outline-none"
>
<div className="flex items-center justify-between px-6 h-20 border-b border-ink/10">
<h2 className="text-xl font-serif text-forest">{t.cart.title}</h2>
<button aria-label={t.cart.close} onClick={closeCart} className="w-9 h-9">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{items.length === 0 ? (
<p className="text-ink/70 mt-10 text-center">
{t.cart.empty}
</p>
) : (
<ul className="divide-y divide-ink/10">
{items.map((item) => {
const itemStock = stock?.[item.productId];
return (
<li key={item.productId} className="py-4 flex gap-4">
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
<div className="flex-1">
<p className="text-sm font-medium text-ink">{item.name}</p>
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
{/* 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). */}
<div aria-live="polite">
{itemStock !== undefined && itemStock === 0 ? (
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
) : itemStock !== undefined && itemStock <= 5 ? (
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
) : null}
</div>
<div className="flex items-center gap-3 mt-2">
<QuantityStepper
quantity={item.quantity}
onChange={(quantity) => setQuantity(item.productId, quantity)}
max={itemStock ?? 10}
size="sm"
/>
<button
onClick={() => removeItem(item.productId)}
className="text-xs text-ink/65 hover:text-ink underline"
>
{t.cart.remove}
</button>
</div>
</div>
<p className="text-sm text-ink">
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
</p>
</li>
);
})}
</ul>
)}
</div>
{items.length > 0 ? (
<div className="border-t border-ink/10 p-6 space-y-4">
<div className="flex justify-between text-sm">
<span>{t.cart.subtotal}</span>
<span>{formatPrice(total, "EUR", locale)}</span>
</div>
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
{t.cart.checkout}
</LinkButton>
<LinkButton
href="/warenkorb"
variant="secondary"
onClick={closeCart}
className="w-full"
>
{t.cart.viewCart}
</LinkButton>
</div>
) : null}
</motion.aside>
</>
) : null}
</AnimatePresence>
);
}
// Footer wurde nach @/components/site-footer.tsx ausgelagert (Server
// Component statt Teil dieser "use client"-Datei) — siehe Kommentar dort.