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]>
756 lines
30 KiB
TypeScript
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.
|