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
+594 -96

No files matched your search

+64 -2
View File
@@ -34,16 +34,56 @@
--font-serif: var(--font-cormorant), Georgia, serif;
--font-sans: var(--font-inter), system-ui, sans-serif;
/* Eine Bewegungssprache fuer die ganze Seite: selbstbewusstes Ankommen,
schnelles Gehen (siehe impeccable animate: exit faster than entrance). */
--ease-arrive: cubic-bezier(0.16, 1, 0.3, 1);
--ease-leave: cubic-bezier(0.7, 0, 0.84, 0);
}
html {
scroll-behavior: smooth;
/* Browserflaechen tragen die Marke mit: Scrollbar im Waldton statt Grau. */
scrollbar-color: color-mix(in oklab, var(--color-forest) 45%, transparent) transparent;
accent-color: var(--color-forest);
}
body {
background: var(--color-background);
color: var(--color-ink);
font-family: var(--font-sans);
caret-color: var(--color-honey-dark);
}
/* Feine Papierkoernung ueber allem: bricht die digitale Glaette der
Cremeflaechen, wie Etikettenpapier auf dem Glas. Fest, klickdurchlaessig,
sehr leise — auf dunklen Flaechen kaum, auf hellen gerade spuerbar. */
body::after {
content: "";
position: fixed;
inset: 0;
z-index: 70;
pointer-events: none;
opacity: 0.032;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.14 0 0 0 0 0.12 0 0 0 0 0.08 0 0 0 1 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
:focus-visible {
outline: 2px solid var(--color-honey-dark);
outline-offset: 3px;
}
a {
text-underline-offset: 0.22em;
text-decoration-thickness: 1px;
}
/* Preise, Mengen, Datumsangaben: gleich breite Ziffern, damit Spalten und
wechselnde Summen nicht springen. */
.tabular-nums,
[data-numeric] {
font-variant-numeric: tabular-nums;
}
h1,
@@ -66,8 +106,30 @@ p {
}
::selection {
background: var(--color-honey);
color: var(--color-cream);
/* Honig auf Creme erreichte nur ~2,6:1 — markierter Text war kaum lesbar. */
background: var(--color-honey-pale);
color: var(--color-forest);
}
/* Unterstrich, der beim Hover von links "einlaeuft" — fuer Textlinks in
Fliesstext und Navigation. */
.link-draw {
background-image: linear-gradient(currentColor, currentColor);
background-size: 0% 1px;
background-position: 0 100%;
background-repeat: no-repeat;
transition: background-size 350ms var(--ease-arrive);
}
.link-draw:hover,
.link-draw:focus-visible,
.link-draw[aria-current="page"] {
background-size: 100% 1px;
}
@media (prefers-reduced-motion: reduce) {
.link-draw {
transition: none;
}
}
/* Knopf eines gerade laufenden Server-Action-Formulars (components/
+26 -17
View File
@@ -23,6 +23,7 @@ import { allowedCountryCodes } from "@/lib/shipping-region";
import { resolveShippingCostCents } from "@/lib/shipping-calc";
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
import { TurnstileField } from "@/components/turnstile-widget";
import { HoneycombPattern, HexCheck } from "@/components/honeycomb";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { WissenSlug } from "@/i18n/dictionary-type";
@@ -245,22 +246,29 @@ export default async function ProductDetailPage(
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
/>
<Container className="grid md:grid-cols-2 gap-14">
<Container className="grid md:grid-cols-[1.1fr_1fr] gap-12 lg:gap-20">
{/* Das Glas bleibt beim Lesen der Details stehen, statt eine leere
Spalte zu hinterlassen — und liegt auf dem Wabenraster der Marke. */}
<div className="relative self-start md:sticky md:top-28">
<HoneycombPattern className="-inset-10 h-[calc(100%+5rem)] w-[calc(100%+5rem)] text-honey/25" size={26} />
<div className="relative">
<ProductGallery
images={product.images}
productName={product.name}
description={product.tasteTags.join(", ")}
/>
</div>
</div>
<div>
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
<p className="tracking-wide-caps text-xs text-honey-dark mb-4">
{company.name} · {t.common.categories[product.category]}
</p>
<h1 className="text-4xl text-forest">{product.name}</h1>
<h1 className="text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-forest">{product.name}</h1>
{/* Ohne gepflegte Noten sonst ein leerer Absatz, der nur Abstand
zwischen Ueberschrift und Bewertung erzeugt. */}
{product.tasteTags.length > 0 ? (
<p className="mt-2 text-ink/70">{product.tasteTags.join(" · ")}</p>
<p className="mt-3 font-serif text-xl italic text-ink/70">{product.tasteTags.join(" · ")}</p>
) : null}
{reviewSummary.count > 0 ? (
<Link href="#bewertungen" className="mt-4 flex items-center gap-2 text-sm">
@@ -273,13 +281,13 @@ export default async function ProductDetailPage(
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviews}</p>
)}
<div className="mt-6 flex items-baseline gap-3">
<span className="text-2xl text-forest">
<div className="mt-8 flex items-baseline gap-3">
<span className="font-serif text-4xl text-forest tabular-nums">
{formatPrice(product.priceCents, product.currency, locale)}
</span>
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
<span className="text-sm text-ink/65 tabular-nums">{product.weightGrams} g</span>
</div>
<p className="text-xs text-ink/65">
<p className="mt-1 text-xs text-ink/65 tabular-nums">
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
</p>
<Link
@@ -293,7 +301,7 @@ export default async function ProductDetailPage(
<p className="mt-2 text-xs text-ink/65">{t.productPage.giftHint}</p>
{isHoney ? (
<div className="mt-8 space-y-3">
<div className="mt-8 space-y-3 border-y border-ink/10 py-5">
<TasteScale label={t.product.tasteMild} value={product.intensityMild} />
<TasteScale label={t.product.tasteFloral} value={product.intensityFloral} />
<TasteScale label={t.product.tasteIntense} value={product.intensityKraeftig} />
@@ -302,7 +310,7 @@ export default async function ProductDetailPage(
{isPurchasable ? (
<>
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" />
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-sm" />
{lowStock ? (
<p className="mt-2 text-xs text-honey-dark">{t.productPage.onlyLeftInStock(stock)}</p>
) : null}
@@ -372,10 +380,11 @@ export default async function ProductDetailPage(
</p>
)}
<ul className="mt-8 space-y-2 text-sm text-ink/70">
<ul className="mt-8 grid gap-x-6 gap-y-2.5 text-sm text-ink/75 sm:grid-cols-2">
{(t.productPage.checklist[product.category] ?? t.productPage.checklist.HONIG).map((item) => (
<li key={item} className="flex items-center gap-2">
<span className="text-honey-dark">✓</span> {item}
<li key={item} className="flex items-start gap-2.5">
<HexCheck className="mt-px h-4 w-4 flex-shrink-0 text-honey-dark" />
{item}
</li>
))}
</ul>
@@ -428,7 +437,7 @@ export default async function ProductDetailPage(
<Container id="bewertungen" className="mt-20 max-w-3xl">
<ScrollReveal>
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
<h2 className="text-3xl md:text-4xl text-forest">{t.productPage.reviewsHeading}</h2>
</ScrollReveal>
@@ -504,11 +513,11 @@ export default async function ProductDetailPage(
{relatedProducts.length > 0 ? (
<Container className="mt-20">
<ScrollReveal>
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
<h2 className="text-3xl md:text-4xl text-forest mb-10">{t.productPage.relatedProductsHeading}</h2>
</ScrollReveal>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
{relatedProducts.map((related) => (
<ProductCard key={related.id} product={related} />
{relatedProducts.map((related, index) => (
<ProductCard key={related.id} product={related} index={index} />
))}
</div>
</Container>
+56 -11
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { HexMark } from "@/components/honeycomb";
import { Container, SectionHeading } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { ProductCard } from "@/components/product-card";
@@ -106,19 +107,21 @@ export default async function ShopPage(props: PageProps<"/shop">) {
<Link
key={filter.label}
href={href}
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
aria-current={isActive ? "page" : undefined}
className={`inline-flex h-11 items-center gap-2 px-5 text-sm tracking-wide-caps border transition-[color,background-color,border-color] duration-300 ${
isActive
? "bg-forest text-cream border-forest"
: "border-ink/20 text-ink/70 hover:border-forest"
: "border-ink/15 text-ink/70 hover:border-forest hover:text-forest"
}`}
>
{isActive ? <HexMark className="h-2.5 w-2.5 text-honey-light" /> : null}
{filter.label}
</Link>
);
})}
</div>
<div className="mt-6 flex flex-wrap items-center gap-4">
<div className="mt-5 flex flex-wrap items-center gap-3 border-t border-ink/10 pt-5">
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen.
Die Suche ist bewusst Teil DIESES Formulars (statt nur der kleinen
@@ -131,20 +134,36 @@ export default async function ShopPage(props: PageProps<"/shop">) {
<label htmlFor="shop-search" className="sr-only">
{t.shop.searchInputLabel}
</label>
<span className="relative">
<svg
viewBox="0 0 24 24"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ink/45"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
aria-hidden="true"
>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
<input
id="shop-search"
type="search"
name="q"
defaultValue={query ?? ""}
placeholder={t.nav.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
className="h-11 w-60 border border-ink/15 bg-background pl-10 pr-3 transition-colors placeholder:text-ink/45 hover:border-ink/35 focus:border-forest focus:outline-none"
/>
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
</span>
<label htmlFor="sortierung" className="ml-2 text-xs tracking-wide-caps text-ink/60">
{t.shop.sortLabel}
</label>
<span className="relative">
<select
id="sortierung"
name="sortierung"
defaultValue={sort ?? ""}
className="border border-ink/20 bg-background px-2 py-2"
className="h-11 appearance-none border border-ink/15 bg-background pl-3 pr-9 transition-colors hover:border-ink/35 focus:border-forest focus:outline-none"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
@@ -152,7 +171,21 @@ export default async function ShopPage(props: PageProps<"/shop">) {
</option>
))}
</select>
<button type="submit" className="border border-ink/20 px-3 py-2 hover:border-forest">
<svg
viewBox="0 0 24 24"
className="pointer-events-none absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink/55"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<button
type="submit"
className="h-11 bg-forest px-5 text-xs tracking-wide-caps text-cream transition-colors hover:bg-honey-dark"
>
{t.shop.apply}
</button>
</form>
@@ -163,12 +196,24 @@ export default async function ShopPage(props: PageProps<"/shop">) {
sortierung: sort,
verfuegbar: onlyAvailable ? undefined : "1",
})}
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
aria-pressed={onlyAvailable}
className={`ml-auto inline-flex h-11 items-center gap-2.5 px-4 text-xs tracking-wide-caps border transition-colors ${
onlyAvailable
? "bg-forest text-cream border-forest"
: "border-ink/20 text-ink/70 hover:border-forest"
: "border-ink/15 text-ink/70 hover:border-forest hover:text-forest"
}`}
>
{/* Kleiner Schalter statt reinem Textknopf — der Zustand ist auf einen Blick lesbar. */}
<span
aria-hidden="true"
className={`relative h-4 w-7 rounded-full transition-colors ${onlyAvailable ? "bg-honey" : "bg-ink/15"}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-cream shadow transition-[left] duration-300 ${
onlyAvailable ? "left-3.5" : "left-0.5"
}`}
/>
</span>
{t.shop.onlyAvailable}
</Link>
</div>
@@ -186,8 +231,8 @@ export default async function ShopPage(props: PageProps<"/shop">) {
) : null}
<h2 className="sr-only">{t.shop.productsHeading}</h2>
<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) => (
<ProductCard key={product.id} product={product} reviewSummary={reviewSummaries.get(product.id)} />
{products.map((product, index) => (
<ProductCard key={product.id} product={product} reviewSummary={reviewSummaries.get(product.id)} index={index} />
))}
</div>
{products.length === 0 ? (
+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>
);
}
+109 -27
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,17 +69,75 @@ 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>
))}
</motion.div>
</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>
);
}
@@ -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"
>
<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>
+99 -21
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"
>
<motion.svg
animate={basketControls}
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<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>
</motion.svg>
<AnimatePresence>
{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">
<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}
</span>
</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>
);
+10 -4
View File
@@ -25,7 +25,12 @@ interface CartState {
setHasHydrated: (hasHydrated: boolean) => void;
openCart: () => void;
closeCart: () => void;
addItem: (item: Omit<CartItem, "quantity">, quantity?: number) => void;
/**
* openDrawer: false laesst die Schublade zu — die Flug-Animation zum
* Warenkorb-Symbol (components/cart-flight.tsx) oeffnet sie erst, wenn
* der Tropfen gelandet ist.
*/
addItem: (item: Omit<CartItem, "quantity">, quantity?: number, options?: { openDrawer?: boolean }) => void;
removeItem: (productId: string) => void;
setQuantity: (productId: string, quantity: number) => void;
clear: () => void;
@@ -40,8 +45,9 @@ export const useCartStore = create<CartState>()(
setHasHydrated: (hasHydrated) => set({ hasHydrated }),
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
addItem: (item, quantity = 1) =>
addItem: (item, quantity = 1, options) =>
set((state) => {
const isOpen = options?.openDrawer === false ? state.isOpen : true;
const existing = state.items.find((i) => i.productId === item.productId);
if (existing) {
return {
@@ -50,10 +56,10 @@ export const useCartStore = create<CartState>()(
? { ...i, quantity: i.quantity + quantity }
: i
),
isOpen: true,
isOpen,
};
}
return { items: [...state.items, { ...item, quantity }], isOpen: true };
return { items: [...state.items, { ...item, quantity }], isOpen };
}),
removeItem: (productId) =>
set((state) => ({