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.
544 lines
21 KiB
TypeScript
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.
|