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:
1 parent
839bbff595
commit
3cb639c154
11 files changed
+621
-123
No files matched your search
+102
-24
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user