Files
Wabenhain/src/components/site-chrome.tsx
T
Nick Szaunig 0a44fecbe1 perf: Warenkorb-Miniaturen ueber den Bildoptimierer laden
Die letzte Stelle, an der ein hochgeladenes Bild noch als rohes <img>
ausgeliefert wurde. Der Warenkorb lud damit fuer JEDE Position das
vollstaendig gespeicherte Bild — bis MAX_IMAGE_DIMENSION Kantenlaenge, also
durchaus ein paar hundert Kilobyte — nur um es auf 64 bzw. 80 Pixel zu
verkleinern. Bei mehreren Positionen summierte sich das spuerbar, gerade im
Mobilfunknetz, und betraf sowohl die Warenkorb-Schublade als auch die
Warenkorbseite.

Der bisherige eslint-disable-Kommentar begruendete das rohe <img> mit einer
"noch unbekannten externen Bildquelle". Das trifft nicht zu: das Bild kommt
aus Product.images[0] und liegt damit immer same-origin unter /uploads/...
(beim Speichern geprueft von isValidUploadPath in lib/uploads.ts).
next/image ist dafuer zustaendig und liefert jetzt eine 80px-Variante statt
der Originaldatei.

Beide Verwendungsstellen bekommen dieselbe leichte Eckenrundung wie die
uebrigen Bilder; vorher war die Schublade rounded-sm und die Warenkorbseite
gar nicht gerundet.
2026-09-14 20:55:50 +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-lg" />
<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.