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

+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useEffect } from "react";
import { useCartStore } from "@/store/cart-store";
const FLIGHT_EVENT = "wh:cart-flight";
const LAND_EVENT = "wh:cart-landed";
const FLIGHT_MS = 700;
/**
* Startet den Flug eines Honigtropfens vom geklickten Knopf zum
* Warenkorb-Symbol. Aufrufer legen den Artikel mit openDrawer: false in den
* Store — geoeffnet wird die Schublade erst bei der Landung (CartFlight).
*/
export function launchCartFlight(from: Element): void {
const rect = from.getBoundingClientRect();
window.dispatchEvent(
new CustomEvent(FLIGHT_EVENT, { detail: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } })
);
}
/** Fuer das Warenkorb-Symbol: meldet jede Landung (zum Wippen). */
export function onCartLanded(handler: () => void): () => void {
window.addEventListener(LAND_EVENT, handler);
return () => window.removeEventListener(LAND_EVENT, handler);
}
/**
* Der Hoehepunkt des Einkaufens: ein Tropfen in Wabenform loest sich vom
* Knopf, fliegt im Bogen zum Warenkorb und "tropft" hinein. Danach wippt
* der Korb, der Zaehler springt, und die Schublade oeffnet sich — Ursache
* und Wirkung werden sichtbar verbunden, statt dass die Schublade ohne
* Vorwarnung von der Seite hereinschiebt.
*
* Reduzierte Bewegung / Symbol nicht sichtbar (z.B. weggescrollter Header
* auf kleinen Geraeten): kein Flug, die Schublade oeffnet sofort.
*/
export function CartFlight() {
const openCart = useCartStore((s) => s.openCart);
useEffect(() => {
const land = () => {
window.dispatchEvent(new Event(LAND_EVENT));
window.setTimeout(openCart, 180);
};
const onFlight = (event: Event) => {
const { x, y } = (event as CustomEvent<{ x: number; y: number }>).detail;
const target = document.querySelector<HTMLElement>("[data-cart-target]");
const targetRect = target?.getBoundingClientRect();
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const targetVisible =
targetRect && targetRect.width > 0 && targetRect.bottom > 0 && targetRect.top < window.innerHeight;
if (reduceMotion || !targetRect || !targetVisible) {
land();
return;
}
const tx = targetRect.left + targetRect.width / 2;
const ty = targetRect.top + targetRect.height / 2;
const dx = tx - x;
const dy = ty - y;
// Scheitel des Bogens: ueber der hoeheren der beiden Positionen.
const lift = Math.min(-80, dy / 2 - 120);
const drop = document.createElement("div");
drop.setAttribute("aria-hidden", "true");
drop.style.cssText = `position:fixed;left:${x - 11}px;top:${y - 12}px;width:22px;height:24px;z-index:80;pointer-events:none;will-change:transform,opacity;`;
// Fester Markup-String ohne jede Nutzereingabe — kein XSS-Pfad.
drop.innerHTML =
'<svg viewBox="0 0 24 26" width="22" height="24"><defs><radialGradient id="wh-honey" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#e4cfa4"/><stop offset="0.45" stop-color="#b88a44"/><stop offset="1" stop-color="#8a6531"/></radialGradient></defs><polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="url(#wh-honey)" stroke="#8a6531" stroke-width="1"/><ellipse cx="8.5" cy="8.5" rx="2.4" ry="1.4" fill="#fff" opacity="0.55"/></svg>';
document.body.appendChild(drop);
const animation = drop.animate(
[
{ transform: "translate(0, 0) scale(0.4) rotate(0deg)", opacity: 0 },
{ transform: "translate(0, -18px) scale(1.15) rotate(-12deg)", opacity: 1, offset: 0.12 },
{ transform: `translate(${dx * 0.5}px, ${lift}px) scale(1) rotate(90deg)`, offset: 0.55 },
{ transform: `translate(${dx}px, ${dy}px) scale(0.35) rotate(180deg)`, opacity: 0.9 },
],
{ duration: FLIGHT_MS, easing: "cubic-bezier(0.45, 0, 0.2, 1)" }
);
animation.onfinish = () => {
drop.remove();
land();
};
animation.oncancel = () => {
drop.remove();
land();
};
};
window.addEventListener(FLIGHT_EVENT, onFlight);
return () => window.removeEventListener(FLIGHT_EVENT, onFlight);
}, [openCart]);
return null;
}
+2 -1
View File
@@ -27,11 +27,12 @@ export async function CurrentHarvest({
/>
</ScrollReveal>
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
{products.map((product) => (
{products.map((product, index) => (
<ProductCard
key={product.id}
product={product}
reviewSummary={reviewSummaries?.get(product.id)}
index={index}
/>
))}
</div>
+89
View File
@@ -0,0 +1,89 @@
import { useId } from "react";
/**
* Wabenraster als Markensignatur: dieselbe spitz stehende Sechseckform wie
* im Logomark, als feines Linienmuster. Liegt absolut im naechsten
* relativen Elternteil, ist rein dekorativ (aria-hidden) und laeuft ueber
* eine Maske zu den Raendern hin aus, damit es nie als harte Kachelkante
* endet.
*
* Farbe ueber currentColor (text-*-Klasse am Aufrufer), Staerke ueber
* opacity — so passt dasselbe Muster auf Creme wie auf Waldgruen.
*/
export function HoneycombPattern({
className = "",
size = 22,
fade = "radial",
}: {
className?: string;
/** Radius eines Sechsecks in px. */
size?: number;
/** Wohin das Muster auslaeuft. */
fade?: "radial" | "right" | "bottom" | "none";
}) {
const id = useId().replace(/:/g, "");
const r = size;
const w = Math.sqrt(3) * r;
const tileH = 3 * r;
// Zwei Sechsecke je Kachel (versetzte Reihen), spitz nach oben wie im Logo.
const hex = (cx: number, cy: number) =>
Array.from({ length: 6 }, (_, i) => {
const angle = (Math.PI / 3) * i - Math.PI / 2;
return `${(cx + r * Math.cos(angle)).toFixed(2)},${(cy + r * Math.sin(angle)).toFixed(2)}`;
}).join(" ");
const masks: Record<typeof fade, string | undefined> = {
radial: "radial-gradient(ellipse at center, black 20%, transparent 75%)",
right: "linear-gradient(to left, black, transparent 80%)",
bottom: "linear-gradient(to top, black, transparent 85%)",
none: undefined,
};
return (
<svg
aria-hidden="true"
className={`pointer-events-none absolute inset-0 h-full w-full ${className}`}
style={masks[fade] ? { maskImage: masks[fade], WebkitMaskImage: masks[fade] } : undefined}
>
<defs>
<pattern id={`wabe-${id}`} width={w} height={tileH} patternUnits="userSpaceOnUse">
<polygon points={hex(w / 2, r)} fill="none" stroke="currentColor" strokeWidth="1" />
<polygon points={hex(0, r * 2.5)} fill="none" stroke="currentColor" strokeWidth="1" />
<polygon points={hex(w, r * 2.5)} fill="none" stroke="currentColor" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#wabe-${id})`} />
</svg>
);
}
/** Kleines Sechseck als Aufzaehlungs- und Statuszeichen (statt Unicode-Glyphen). */
export function HexMark({ className = "h-3 w-3", filled = true }: { className?: string; filled?: boolean }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<polygon
points="12,2 20.66,7 20.66,17 12,22 3.34,17 3.34,7"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
</svg>
);
}
/** Haekchen im Sechseck — fuer Vorteilslisten (ersetzt das Textzeichen "✓"). */
export function HexCheck({ className = "h-4 w-4" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
<polygon
points="12,2 20.66,7 20.66,17 12,22 3.34,17 3.34,7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path d="M8.2 12.3l2.5 2.5 5-5.3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+111 -29
View File
@@ -2,13 +2,14 @@
import Link from "next/link";
import { useState } from "react";
import { motion } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { ContentImage } from "@/components/content-image";
import { QuantityStepper } from "@/components/quantity-stepper";
import { StarRating } from "@/components/star-rating";
import { Button } from "@/components/ui";
import { formatPrice, formatPricePerKg } from "@/lib/format";
import { useCartStore } from "@/store/cart-store";
import { launchCartFlight } from "@/components/cart-flight";
import { useI18n } from "@/i18n/i18n-provider";
import type { ProductWithRelations } from "@/lib/data";
import type { ProductCategory } from "@/generated/prisma/client";
@@ -68,20 +69,78 @@ export function TasteScale({
<span className={`w-16 ${tone === "inverted" ? "text-cream/70" : "text-ink/70"}`}>
{label}
</span>
<div className="flex gap-1">
{/* Ausloeser ist die ganze Skala: die Fuellsegmente starten mit
Breite 0 und wuerden selbst nie als "sichtbar" gemeldet. */}
<motion.div
className="flex gap-1"
role="img"
aria-label={`${label}: ${value} / 5`}
initial="empty"
whileInView="full"
viewport={{ once: true }}
>
{Array.from({ length: 5 }, (_, i) => (
<span
key={i}
className={`h-1.5 w-5 rounded-full ${
i < value ? "bg-honey" : tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
className={`relative h-1.5 w-6 overflow-hidden rounded-full ${
tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
}`}
/>
>
{i < value ? (
// Die Skala "fuellt sich" beim Erscheinen Segment fuer Segment
// wie ein Glas — sichtbar, dass es um Intensitaet geht.
<motion.span
className="absolute inset-0 origin-left rounded-full bg-honey"
variants={{
empty: { scaleX: 0 },
full: { scaleX: 1, transition: { duration: 0.35, delay: 0.15 + i * 0.09, ease: [0.16, 1, 0.3, 1] } },
}}
/>
) : null}
</span>
))}
</div>
</motion.div>
</div>
);
}
/**
* Knopfbeschriftung, die nach dem Hinzufuegen zu "Hinzugefuegt" mit
* gezeichnetem Haekchen wechselt — die Bestaetigung bleibt am Ort des
* Klicks, waehrend der Tropfen zum Warenkorb fliegt.
*/
function AddedLabel({ justAdded, idle, done }: { justAdded: boolean; idle: string; done: string }) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={justAdded ? "done" : "idle"}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18 }}
className="inline-flex items-center gap-2"
>
{justAdded ? (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, delay: 0.05 }}
/>
</svg>
) : null}
{/* Das Woerterbuch haengt ein "✓" an — hier zeichnet das SVG das Haekchen. */}
{justAdded ? done.replace(/\s*✓\s*$/, "") : idle}
</motion.span>
</AnimatePresence>
);
}
function toCartItem(product: ProductWithRelations) {
return {
productId: product.id,
@@ -113,13 +172,14 @@ export function AddToCartButton({
<Button
variant={variant}
className="w-full"
onClick={() => {
addItem(toCartItem(product), quantity);
onClick={(event) => {
launchCartFlight(event.currentTarget);
addItem(toCartItem(product), quantity, { openDrawer: false });
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 1500);
window.setTimeout(() => setJustAdded(false), 1800);
}}
>
{justAdded ? t.product.added : t.product.addToCart}
<AddedLabel justAdded={justAdded} idle={t.product.addToCart} done={t.product.added} />
</Button>
</motion.div>
);
@@ -151,13 +211,14 @@ export function AddToCartWithQuantity({
<motion.div whileTap={{ scale: 0.97 }} className="flex-1">
<Button
className="w-full h-full"
onClick={() => {
addItem(toCartItem(product), quantity);
onClick={(event) => {
launchCartFlight(event.currentTarget);
addItem(toCartItem(product), quantity, { openDrawer: false });
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 1500);
window.setTimeout(() => setJustAdded(false), 1800);
}}
>
{justAdded ? t.product.added : t.product.addToCart}
<AddedLabel justAdded={justAdded} idle={t.product.addToCart} done={t.product.added} />
</Button>
</motion.div>
</div>
@@ -168,10 +229,13 @@ export function AddToCartWithQuantity({
export function ProductCard({
product,
reviewSummary,
index = 0,
}: {
product: ProductWithRelations;
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
reviewSummary?: { average: number | null; count: number };
/** Position im Raster — staffelt das Erscheinen (gedeckelt, damit lange Listen nicht warten lassen). */
index?: number;
}) {
const { t, locale } = useI18n();
const stock = product.inventory?.quantity ?? 0;
@@ -180,21 +244,21 @@ export function ProductCard({
<motion.article
// Siehe scroll-reveal.tsx: Gegenstueck zum noscript-Block im Root-Layout.
data-reveal=""
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5 }}
className="group"
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: Math.min(index, 5) * 0.08 }}
className="group flex flex-col"
>
<Link
href={`/honig/${product.slug}`}
className="relative block overflow-hidden transition-shadow duration-500 group-hover:shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)]"
className="relative block overflow-hidden transition-[box-shadow,transform] duration-500 ease-[var(--ease-arrive)] group-hover:-translate-y-1 group-hover:shadow-[0_28px_50px_-24px_rgba(36,53,43,0.5)]"
>
<ContentImage
src={product.images[0] ?? null}
alt={product.name}
tone={categoryTone[product.category]}
className="aspect-[4/5] w-full transition-transform duration-500 group-hover:scale-[1.03]"
className="aspect-[4/5] w-full transition-transform duration-700 ease-[var(--ease-arrive)] group-hover:scale-[1.05]"
label={
product.harvest
? `${product.harvest.year} · ${product.harvest.type}`
@@ -202,19 +266,25 @@ export function ProductCard({
}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
{/* Warmer Honigschimmer, der beim Hover diagonal ueber das Glas
zieht — wie Licht, das durch Honig faellt. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 -translate-x-full bg-[linear-gradient(115deg,transparent_30%,rgba(228,207,164,0.35)_48%,rgba(184,138,68,0.18)_55%,transparent_70%)] transition-transform duration-1000 ease-[var(--ease-arrive)] group-hover:translate-x-full motion-reduce:hidden"
/>
{stock === 0 ? (
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
{t.product.outOfStock}
</span>
) : stock <= 5 ? (
<span className="absolute top-3 left-3 bg-honey text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
<span className="absolute top-3 left-3 bg-honey-dark text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
{t.product.onlyLeft(stock)}
</span>
) : null}
</Link>
<div className="mt-4">
<Link href={`/honig/${product.slug}`}>
<h3 className="text-xl text-forest">{product.name}</h3>
<div className="mt-5 flex flex-1 flex-col">
<Link href={`/honig/${product.slug}`} className="self-start">
<h3 className="link-draw text-2xl leading-tight text-forest">{product.name}</h3>
</Link>
<p className="text-sm text-ink/70 mt-1">
{product.tasteTags.join(" · ")}
@@ -229,13 +299,13 @@ export function ProductCard({
{(reviewSummary?.average ?? 0).toFixed(1)} ({reviewSummary?.count ?? 0})
</span>
</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
<span className="text-base text-forest">
<div className="mt-auto flex items-baseline justify-between pt-3">
<span className="text-sm text-ink/65 tabular-nums">{product.weightGrams} g</span>
<span className="font-serif text-2xl text-forest tabular-nums">
{formatPrice(product.priceCents, product.currency, locale)}
</span>
</div>
<p className="text-xs text-ink/65">
<p className="text-xs text-ink/65 text-right tabular-nums">
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
</p>
{stock > 0 ? (
@@ -243,9 +313,21 @@ export function ProductCard({
) : null}
<Link
href={`/honig/${product.slug}`}
className="mt-4 inline-block text-sm tracking-wide-caps text-forest border-b border-forest/40 pb-0.5 hover:border-forest"
className="group/more mt-4 inline-flex items-center gap-2 self-start text-sm tracking-wide-caps text-forest"
>
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
<span className="link-draw pb-0.5">
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
</span>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5 transition-transform duration-300 ease-[var(--ease-arrive)] group-hover/more:translate-x-1"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
aria-hidden="true"
>
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
</div>
</motion.article>
+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}
+6 -3
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Button } from "@/components/ui";
import { formatPrice } from "@/lib/format";
import { useCartStore } from "@/store/cart-store";
import { launchCartFlight } from "@/components/cart-flight";
import { useI18n } from "@/i18n/i18n-provider";
import type { ProductWithRelations } from "@/lib/data";
@@ -37,12 +38,13 @@ export function StickyAddToCartBar({
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-ink">{product.name}</p>
<p className="text-sm text-forest">{formatPrice(product.priceCents, product.currency, locale)}</p>
<p className="text-sm text-forest tabular-nums">{formatPrice(product.priceCents, product.currency, locale)}</p>
</div>
{inStock ? (
<Button
className="flex-shrink-0"
onClick={() => {
onClick={(event) => {
launchCartFlight(event.currentTarget);
addItem(
{
productId: product.id,
@@ -52,7 +54,8 @@ export function StickyAddToCartBar({
priceCents: product.priceCents,
image: product.images[0] ?? null,
},
1
1,
{ openDrawer: false }
);
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 1500);
+34 -10
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { HexMark } from "@/components/honeycomb";
export function Container({
id,
@@ -19,14 +20,22 @@ export function Container({
type ButtonVariant = "primary" | "secondary" | "ghost";
// Signatur-Hover: der Knopf fuellt sich von unten wie ein Glas — beim
// Primaerknopf mit Honig, beim Sekundaerknopf mit Waldgruen. Umgesetzt als
// Hintergrund-Verlauf, dessen Hoehe waechst (kein Layout-Eigenschafts-
// Tweening, kein zusaetzliches Element).
const fillBase =
"bg-no-repeat bg-bottom bg-[length:100%_0%] hover:bg-[length:100%_100%] focus-visible:bg-[length:100%_100%] motion-reduce:transition-colors";
const buttonStyles: Record<ButtonVariant, string> = {
primary:
"bg-forest text-cream hover:bg-[#1a271f] border border-forest",
secondary:
"bg-transparent text-forest border border-forest hover:bg-forest hover:text-cream",
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink",
primary: `${fillBase} bg-forest bg-[linear-gradient(var(--color-honey-dark),var(--color-honey-dark))] text-cream border border-forest hover:border-honey-dark`,
secondary: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-forest),var(--color-forest))] text-forest border border-forest hover:text-cream focus-visible:text-cream`,
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink hover:bg-ink/[0.03]",
};
const buttonBase =
"inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-size,border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] active:scale-[0.97] active:duration-100";
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
variant?: ButtonVariant;
}
@@ -39,7 +48,7 @@ export function LinkButton({
}: LinkButtonProps) {
return (
<Link
className={`inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] ${buttonStyles[variant]} ${className}`}
className={`${buttonBase} ${buttonStyles[variant]} ${className}`}
{...props}
>
{children}
@@ -59,7 +68,7 @@ export function Button({
}: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 ${buttonStyles[variant]} ${className}`}
className={`${buttonBase} disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 disabled:hover:bg-[length:100%_0%] disabled:hover:shadow-none ${buttonStyles[variant]} ${className}`}
{...props}
>
{children}
@@ -93,14 +102,29 @@ export function SectionHeading({
>
{eyebrow ? (
<p
className={`tracking-wide-caps text-xs mb-3 ${tone === "inverted" ? "text-honey-light" : "text-honey-dark"}`}
className={`flex items-center gap-2 tracking-wide-caps text-xs mb-4 ${
align === "center" ? "justify-center" : ""
} ${tone === "inverted" ? "text-honey-light" : "text-honey-dark"}`}
>
<HexMark className="h-2.5 w-2.5 flex-shrink-0" />
{eyebrow}
</p>
) : null}
<Heading className="text-3xl md:text-4xl text-forest">{title}</Heading>
<Heading
className={`text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] ${
tone === "inverted" ? "text-cream" : "text-forest"
}`}
>
{title}
</Heading>
{subtitle ? (
<p className="mt-4 text-ink/70 leading-relaxed">{subtitle}</p>
<p
className={`mt-5 max-w-[60ch] leading-relaxed ${align === "center" ? "mx-auto" : ""} ${
tone === "inverted" ? "text-cream/75" : "text-ink/70"
}`}
>
{subtitle}
</p>
) : null}
</div>
);