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.
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-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.
|