feat(ui): Fundament und Einkaufserlebnis visuell aufgewertet

- Fundament: feine Papierkoernung, Scrollbar/Cursor/Fokusring/Markierung in
  Markenfarben, gleich breite Ziffern fuer Preise, einlaufende Unterstriche
- Knoepfe fuellen sich beim Hover von unten wie ein Glas (Honig/Waldgruen),
  geben beim Klick nach
- Wabenmotiv aus dem Logo als Komponente (Raster, Markierung, Haekchen)
- In den Warenkorb: ein Honigtropfen fliegt im Bogen zum Korb, der Korb
  wippt, der Zaehler springt, erst dann oeffnet die Schublade; Knopf zeigt
  gezeichnetes Haekchen. Reduzierte Bewegung: Schublade oeffnet sofort.
- Produktkarten: Honigschimmer, Anheben, Serifen-Preis, gestaffeltes
  Erscheinen, Pfeil-Links
- Produktseite: mitlaufendes Bild auf Wabenraster, groesserer Titel und
  Preis, sich fuellende Geschmacksskala, Waben-Haekchen statt "✓"
- Shop-Filter als einheitliche Werkzeugleiste mit Schalter
- Warenkorb-Schublade: Dialog-Semantik, animierte Positionen, tickende
  Summe, gestalteter leerer Zustand

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 04:56:03 +02:00
1 parent 839bbff595
commit 3cb639c154
11 files changed
+621 -123

No files matched your search

+102 -24
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useState, useEffect, useRef, type RefObject } from "react";
import { AnimatePresence, motion } from "motion/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";
@@ -13,6 +13,7 @@ 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 type { Dictionary } from "@/i18n/dictionary-type";
const FOCUSABLE_SELECTOR =
@@ -214,24 +215,58 @@ function CartIconButton() {
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)}
className="relative flex items-center justify-center w-10 h-10 text-forest"
data-cart-target=""
className="relative flex items-center justify-center w-10 h-10 text-forest transition-colors hover:text-honey-dark"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<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" />
</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}
</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>
);
}
@@ -286,6 +321,7 @@ export function Header({ companyName }: { companyName: string }) {
<AccountIconButton />
</div>
<CartIconButton />
<CartFlight />
<button
ref={menuButtonRef}
aria-label={t.nav.openMenu}
@@ -439,21 +475,33 @@ export function CartDrawer() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-ink/40 z-50"
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-xl outline-none"
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 className="text-xl font-serif text-forest">{t.cart.title}</h2>
<button aria-label={t.cart.close} onClick={closeCart} className="w-9 h-9">
<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>
@@ -462,18 +510,39 @@ export function CartDrawer() {
<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>
// 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">
{items.map((item) => {
<AnimatePresence initial={false}>
{items.map((item, index) => {
const itemStock = stock?.[item.productId];
return (
<li key={item.productId} className="py-4 flex gap-4">
<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="text-sm font-medium text-ink">{item.name}</p>
<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
@@ -503,21 +572,30 @@ export function CartDrawer() {
</button>
</div>
</div>
<p className="text-sm text-ink">
<p className="text-sm text-ink tabular-nums">
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
</p>
</li>
</motion.li>
);
})}
</AnimatePresence>
</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 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}