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
+64
-2
@@ -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/
|
||||
|
||||
@@ -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">
|
||||
<ProductGallery
|
||||
images={product.images}
|
||||
productName={product.name}
|
||||
description={product.tasteTags.join(", ")}
|
||||
/>
|
||||
<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>
|
||||
|
||||
+73
-28
@@ -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,28 +134,58 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
<label htmlFor="shop-search" className="sr-only">
|
||||
{t.shop.searchInputLabel}
|
||||
</label>
|
||||
<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"
|
||||
/>
|
||||
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
|
||||
<select
|
||||
id="sortierung"
|
||||
name="sortierung"
|
||||
defaultValue={sort ?? ""}
|
||||
className="border border-ink/20 bg-background px-2 py-2"
|
||||
<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="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"
|
||||
/>
|
||||
</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="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}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="border border-ink/20 px-3 py-2 hover:border-forest">
|
||||
{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 ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => ({
|
||||
|
||||
Reference in new issue
Block a user