Files
Wabenhain/src/components/site-chrome.tsx
T
Nick SzaunigandClaude Opus 5.5 4f442ec06d feat(admin): Logo und Favicon per Bild-Upload austauschbar
Neue Gruppe "Logo und Favicon" unter Admin > Inhalte mit drei Bildfeldern:
Logo (Kopfzeile), optionales Logo fuer dunkle Flaechen (Mobilmenue,
Fusszeile, Admin-Leiste; faellt sonst auf das normale Logo zurueck) und
Favicon. Leer bleibt alles wie bisher (gezeichnetes Waben-Zeichen bzw.
Standard-Icon).

- lib/brand.ts liest die Pfade und prueft sie erneut gegen das
  Upload-Muster, bevor sie in ein src-Attribut gelangen.
- Statische app/icon.svg und app/favicon.ico ersetzt durch dynamische
  app/icon.tsx (512 px PNG) und app/apple-icon.tsx (180 px auf Creme);
  lib/brand-icon.ts rendert Upload oder Standard-Icon per sharp und liest
  Dateien nur ueber resolveUploadPath. Cache-Control no-cache, damit ein
  neues Favicon ohne Tage Verzoegerung erscheint.
- /favicon.ico per Rewrite auf /icon, Manifest nutzt /icon.
- Hochgeladenes Logo mit fester Hoehe und begrenzter Breite (Kopfzeile
  mobil 56 px, ab sm 112 px), damit breite Wortmarken nichts sprengen.

Uploads bleiben auf Rasterbilder beschraenkt (kein SVG, XSS-Schutz).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:02:18 +02:00

756 lines
30 KiB
TypeScript

"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<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, logoSrc }: { t: Dictionary; companyName: string; logoSrc: string | null }) {
return (
<Link href="/" className="flex items-center gap-3 group">
{/* Kopfzeile ist bei 1280-1440 px knapp: breite Logos hier enger begrenzt. */}
<Logomark className="h-9 w-9 max-w-14 flex-shrink-0 sm:max-w-28" src={logoSrc} />
<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 ? (
// Legt sich UEBER die Navigation (absolut, von rechts aufklappend)
// statt sie beiseitezuschieben — vorher drueckte das Feld die
// Kopfzeile auseinander und erzeugte einen waagerechten Scrollbalken.
<motion.form
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 16 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
action="/shop"
method="GET"
// Deckende Unterlage ueber die volle Kopfzeilenhoehe, damit nichts
// von der Navigation darunter durchscheint.
className="absolute right-full top-1/2 mr-1 flex h-16 w-[21rem] -translate-y-1/2 items-center bg-background pl-6"
>
<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.5 text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
/>
{visibleSuggestions.length > 0 ? (
<motion.ul
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-full right-0 mt-3 w-80 bg-background border border-ink/10 py-2 shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)] z-50"
>
{visibleSuggestions.map((s, i) => (
<motion.li
key={s.slug}
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0, transition: { delay: i * 0.04 } }}
>
<Link
href={`/honig/${s.slug}`}
onClick={closeSearch}
className="group flex items-center justify-between gap-4 px-4 py-2.5 text-ink transition-colors hover:bg-honey/10"
>
<span className="font-serif text-lg leading-tight">
<HighlightMatch text={s.name} query={query} />
</span>
<span className="flex items-center gap-2 whitespace-nowrap text-sm text-ink/70 tabular-nums">
{s.price}
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5 -translate-x-1 opacity-0 transition-[opacity,transform] duration-300 group-hover:translate-x-0 group-hover:opacity-100"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</Link>
</motion.li>
))}
</motion.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"
>
<AnimatePresence mode="wait" initial={false}>
<motion.svg
key={open ? "close" : "search"}
initial={{ rotate: -45, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: 45, opacity: 0 }}
transition={{ duration: 0.15 }}
width="19"
height="19"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
{open ? (
<path d="M6 6l12 12M18 6L6 18" />
) : (
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</>
)}
</motion.svg>
</AnimatePresence>
</button>
</div>
);
}
/** 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)}
<mark className="bg-honey-pale/70 text-forest">{text.slice(index, index + needle.length)}</mark>
{text.slice(index + needle.length)}
</>
);
}
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);
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 (
<button
onClick={openCart}
aria-label={t.nav.openCart(count)}
data-cart-target=""
className="relative flex items-center justify-center w-10 h-10 text-forest transition-colors hover:text-honey-dark"
>
<motion.svg
animate={basketControls}
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" />
</motion.svg>
<AnimatePresence>
{count > 0 ? (
<motion.span
// Neuer key je Anzahl: der Zaehler "springt" bei jeder Aenderung.
key={count}
initial={{ scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.4, opacity: 0 }}
transition={{ type: "spring", stiffness: 520, damping: 18 }}
className="absolute -top-1 -right-1 bg-honey-dark text-cream text-[10px] min-w-4 h-4 px-1 rounded-full flex items-center justify-center tabular-nums"
>
{count}
</motion.span>
) : null}
</AnimatePresence>
</button>
);
}
/**
* 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<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 border-b transition-[transform,background-color,box-shadow,border-color] duration-500 ease-[var(--ease-arrive)] motion-reduce:transition-none ${
hidden ? "-translate-y-full" : "translate-y-0"
} ${
scrolled
? "bg-background/80 backdrop-blur-md border-transparent shadow-[0_10px_30px_-20px_rgba(36,53,43,0.45)]"
: "bg-background/95 backdrop-blur 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} logoSrc={logoSrc} />
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
{mainNav.map((item) => {
const active = isActivePath(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={`link-draw pb-1 text-sm whitespace-nowrap transition-colors ${
active ? "text-forest" : "text-ink/80 hover:text-forest"
}`}
>
{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 />
<CartFlight />
<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" src={logoInvertedSrc} />
<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)}
aria-current={isActivePath(pathname, item.href) ? "page" : undefined}
className={`flex items-center gap-3 text-3xl font-serif ${
isActivePath(pathname, item.href) ? "text-honey-light" : ""
}`}
>
{isActivePath(pathname, item.href) ? <HexMark className="h-3 w-3" /> : null}
{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="inverted"
className="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-forest/35 backdrop-blur-[2px] z-50"
onClick={closeCart}
/>
<motion.aside
ref={asideRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="cart-drawer-title"
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-[-24px_0_60px_-30px_rgba(36,53,43,0.45)] outline-none"
>
<div className="flex items-center justify-between px-6 h-20 border-b border-ink/10">
<h2 id="cart-drawer-title" className="flex items-baseline gap-2 text-2xl font-serif text-forest">
{t.cart.title}
{items.length > 0 ? (
<span className="font-sans text-xs text-ink/55 tabular-nums">({cartItemCount(items)})</span>
) : null}
</h2>
<button
aria-label={t.cart.close}
onClick={closeCart}
className="flex w-9 h-9 items-center justify-center text-ink/70 transition-[color,transform] duration-300 hover:rotate-90 hover:text-forest"
>
<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 ? (
// Leerer Korb als ruhiger Einstieg statt toter Flaeche: leere
// Wabe, eine Zeile, ein Weg zurueck in den Shop.
<div className="relative mt-16 flex flex-col items-center text-center">
<svg viewBox="0 0 100 100" className="h-20 w-20 text-honey" fill="none" aria-hidden="true">
<polygon
points="50,6 88,28 88,72 50,94 12,72 12,28"
stroke="currentColor"
strokeWidth="2"
strokeDasharray="6 5"
/>
</svg>
<p className="mt-6 font-serif text-2xl text-forest">{t.cart.empty}</p>
<LinkButton href="/shop" variant="secondary" onClick={closeCart} className="mt-8">
{t.nav.discoverHoney}
</LinkButton>
</div>
) : (
<ul className="divide-y divide-ink/10">
<AnimatePresence initial={false}>
{items.map((item, index) => {
const itemStock = stock?.[item.productId];
return (
<motion.li
key={item.productId}
layout
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0, transition: { delay: 0.12 + Math.min(index, 6) * 0.05, ease: [0.16, 1, 0.3, 1] } }}
exit={{ opacity: 0, x: 40, transition: { duration: 0.2 } }}
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="font-serif text-lg leading-tight text-forest">{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 tabular-nums">
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
</p>
</motion.li>
);
})}
</AnimatePresence>
</ul>
)}
</div>
{items.length > 0 ? (
<div className="border-t border-ink/10 p-6 space-y-4">
<div className="flex items-baseline justify-between">
<span className="text-sm text-ink/70">{t.cart.subtotal}</span>
<motion.span
// Summe "tickt" sichtbar bei jeder Aenderung der Menge.
key={total}
initial={{ opacity: 0.4, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="font-serif text-2xl text-forest tabular-nums"
>
{formatPrice(total, "EUR", locale)}
</motion.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.