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-serif: var(--font-cormorant), Georgia, serif;
|
||||||
--font-sans: var(--font-inter), system-ui, sans-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 {
|
html {
|
||||||
scroll-behavior: smooth;
|
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 {
|
body {
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
font-family: var(--font-sans);
|
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,
|
h1,
|
||||||
@@ -66,8 +106,30 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-honey);
|
/* Honig auf Creme erreichte nur ~2,6:1 — markierter Text war kaum lesbar. */
|
||||||
color: var(--color-cream);
|
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/
|
/* 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 { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||||
import { TurnstileField } from "@/components/turnstile-widget";
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
|
import { HoneycombPattern, HexCheck } from "@/components/honeycomb";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import type { WissenSlug } from "@/i18n/dictionary-type";
|
import type { WissenSlug } from "@/i18n/dictionary-type";
|
||||||
@@ -245,22 +246,29 @@ export default async function ProductDetailPage(
|
|||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
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">
|
||||||
<ProductGallery
|
{/* Das Glas bleibt beim Lesen der Details stehen, statt eine leere
|
||||||
images={product.images}
|
Spalte zu hinterlassen — und liegt auf dem Wabenraster der Marke. */}
|
||||||
productName={product.name}
|
<div className="relative self-start md:sticky md:top-28">
|
||||||
description={product.tasteTags.join(", ")}
|
<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>
|
<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]}
|
{company.name} · {t.common.categories[product.category]}
|
||||||
</p>
|
</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
|
{/* Ohne gepflegte Noten sonst ein leerer Absatz, der nur Abstand
|
||||||
zwischen Ueberschrift und Bewertung erzeugt. */}
|
zwischen Ueberschrift und Bewertung erzeugt. */}
|
||||||
{product.tasteTags.length > 0 ? (
|
{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}
|
) : null}
|
||||||
{reviewSummary.count > 0 ? (
|
{reviewSummary.count > 0 ? (
|
||||||
<Link href="#bewertungen" className="mt-4 flex items-center gap-2 text-sm">
|
<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>
|
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviews}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 flex items-baseline gap-3">
|
<div className="mt-8 flex items-baseline gap-3">
|
||||||
<span className="text-2xl text-forest">
|
<span className="font-serif text-4xl text-forest tabular-nums">
|
||||||
{formatPrice(product.priceCents, product.currency, locale)}
|
{formatPrice(product.priceCents, product.currency, locale)}
|
||||||
</span>
|
</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>
|
</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)}
|
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
@@ -293,7 +301,7 @@ export default async function ProductDetailPage(
|
|||||||
<p className="mt-2 text-xs text-ink/65">{t.productPage.giftHint}</p>
|
<p className="mt-2 text-xs text-ink/65">{t.productPage.giftHint}</p>
|
||||||
|
|
||||||
{isHoney ? (
|
{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.tasteMild} value={product.intensityMild} />
|
||||||
<TasteScale label={t.product.tasteFloral} value={product.intensityFloral} />
|
<TasteScale label={t.product.tasteFloral} value={product.intensityFloral} />
|
||||||
<TasteScale label={t.product.tasteIntense} value={product.intensityKraeftig} />
|
<TasteScale label={t.product.tasteIntense} value={product.intensityKraeftig} />
|
||||||
@@ -302,7 +310,7 @@ export default async function ProductDetailPage(
|
|||||||
|
|
||||||
{isPurchasable ? (
|
{isPurchasable ? (
|
||||||
<>
|
<>
|
||||||
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" />
|
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-sm" />
|
||||||
{lowStock ? (
|
{lowStock ? (
|
||||||
<p className="mt-2 text-xs text-honey-dark">{t.productPage.onlyLeftInStock(stock)}</p>
|
<p className="mt-2 text-xs text-honey-dark">{t.productPage.onlyLeftInStock(stock)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -372,10 +380,11 @@ export default async function ProductDetailPage(
|
|||||||
</p>
|
</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) => (
|
{(t.productPage.checklist[product.category] ?? t.productPage.checklist.HONIG).map((item) => (
|
||||||
<li key={item} className="flex items-center gap-2">
|
<li key={item} className="flex items-start gap-2.5">
|
||||||
<span className="text-honey-dark">✓</span> {item}
|
<HexCheck className="mt-px h-4 w-4 flex-shrink-0 text-honey-dark" />
|
||||||
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -428,7 +437,7 @@ export default async function ProductDetailPage(
|
|||||||
|
|
||||||
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
||||||
<ScrollReveal>
|
<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>
|
</ScrollReveal>
|
||||||
|
|
||||||
|
|
||||||
@@ -504,11 +513,11 @@ export default async function ProductDetailPage(
|
|||||||
{relatedProducts.length > 0 ? (
|
{relatedProducts.length > 0 ? (
|
||||||
<Container className="mt-20">
|
<Container className="mt-20">
|
||||||
<ScrollReveal>
|
<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>
|
</ScrollReveal>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||||
{relatedProducts.map((related) => (
|
{relatedProducts.map((related, index) => (
|
||||||
<ProductCard key={related.id} product={related} />
|
<ProductCard key={related.id} product={related} index={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
+73
-28
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { HexMark } from "@/components/honeycomb";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
import { Container, SectionHeading } from "@/components/ui";
|
||||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { ProductCard } from "@/components/product-card";
|
import { ProductCard } from "@/components/product-card";
|
||||||
@@ -106,19 +107,21 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
|||||||
<Link
|
<Link
|
||||||
key={filter.label}
|
key={filter.label}
|
||||||
href={href}
|
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
|
isActive
|
||||||
? "bg-forest text-cream border-forest"
|
? "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}
|
{filter.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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,
|
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
|
||||||
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen.
|
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen.
|
||||||
Die Suche ist bewusst Teil DIESES Formulars (statt nur der kleinen
|
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">
|
<label htmlFor="shop-search" className="sr-only">
|
||||||
{t.shop.searchInputLabel}
|
{t.shop.searchInputLabel}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<span className="relative">
|
||||||
id="shop-search"
|
<svg
|
||||||
type="search"
|
viewBox="0 0 24 24"
|
||||||
name="q"
|
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ink/45"
|
||||||
defaultValue={query ?? ""}
|
fill="none"
|
||||||
placeholder={t.nav.searchPlaceholder}
|
stroke="currentColor"
|
||||||
className="border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
strokeWidth="1.6"
|
||||||
/>
|
aria-hidden="true"
|
||||||
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
|
>
|
||||||
<select
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
id="sortierung"
|
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
|
||||||
name="sortierung"
|
</svg>
|
||||||
defaultValue={sort ?? ""}
|
<input
|
||||||
className="border border-ink/20 bg-background px-2 py-2"
|
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}
|
{t.shop.apply}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -163,12 +196,24 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
|||||||
sortierung: sort,
|
sortierung: sort,
|
||||||
verfuegbar: onlyAvailable ? undefined : "1",
|
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
|
onlyAvailable
|
||||||
? "bg-forest text-cream border-forest"
|
? "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}
|
{t.shop.onlyAvailable}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,8 +231,8 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
|||||||
) : null}
|
) : null}
|
||||||
<h2 className="sr-only">{t.shop.productsHeading}</h2>
|
<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">
|
<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)} />
|
<ProductCard key={product.id} product={product} reviewSummary={reviewSummaries.get(product.id)} index={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{products.length === 0 ? (
|
{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>
|
</ScrollReveal>
|
||||||
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
<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
|
<ProductCard
|
||||||
key={product.id}
|
key={product.id}
|
||||||
product={product}
|
product={product}
|
||||||
reviewSummary={reviewSummaries?.get(product.id)}
|
reviewSummary={reviewSummaries?.get(product.id)}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { ContentImage } from "@/components/content-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||||
import { StarRating } from "@/components/star-rating";
|
import { StarRating } from "@/components/star-rating";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { formatPrice, formatPricePerKg } from "@/lib/format";
|
import { formatPrice, formatPricePerKg } from "@/lib/format";
|
||||||
import { useCartStore } from "@/store/cart-store";
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { launchCartFlight } from "@/components/cart-flight";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
import type { ProductWithRelations } from "@/lib/data";
|
import type { ProductWithRelations } from "@/lib/data";
|
||||||
import type { ProductCategory } from "@/generated/prisma/client";
|
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"}`}>
|
<span className={`w-16 ${tone === "inverted" ? "text-cream/70" : "text-ink/70"}`}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</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) => (
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-1.5 w-5 rounded-full ${
|
className={`relative h-1.5 w-6 overflow-hidden rounded-full ${
|
||||||
i < value ? "bg-honey" : tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
|
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>
|
</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) {
|
function toCartItem(product: ProductWithRelations) {
|
||||||
return {
|
return {
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
@@ -113,13 +172,14 @@ export function AddToCartButton({
|
|||||||
<Button
|
<Button
|
||||||
variant={variant}
|
variant={variant}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
addItem(toCartItem(product), quantity);
|
launchCartFlight(event.currentTarget);
|
||||||
|
addItem(toCartItem(product), quantity, { openDrawer: false });
|
||||||
setJustAdded(true);
|
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>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
@@ -151,13 +211,14 @@ export function AddToCartWithQuantity({
|
|||||||
<motion.div whileTap={{ scale: 0.97 }} className="flex-1">
|
<motion.div whileTap={{ scale: 0.97 }} className="flex-1">
|
||||||
<Button
|
<Button
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
addItem(toCartItem(product), quantity);
|
launchCartFlight(event.currentTarget);
|
||||||
|
addItem(toCartItem(product), quantity, { openDrawer: false });
|
||||||
setJustAdded(true);
|
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>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,10 +229,13 @@ export function AddToCartWithQuantity({
|
|||||||
export function ProductCard({
|
export function ProductCard({
|
||||||
product,
|
product,
|
||||||
reviewSummary,
|
reviewSummary,
|
||||||
|
index = 0,
|
||||||
}: {
|
}: {
|
||||||
product: ProductWithRelations;
|
product: ProductWithRelations;
|
||||||
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
|
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
|
||||||
reviewSummary?: { average: number | null; count: number };
|
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 { t, locale } = useI18n();
|
||||||
const stock = product.inventory?.quantity ?? 0;
|
const stock = product.inventory?.quantity ?? 0;
|
||||||
@@ -180,21 +244,21 @@ export function ProductCard({
|
|||||||
<motion.article
|
<motion.article
|
||||||
// Siehe scroll-reveal.tsx: Gegenstueck zum noscript-Block im Root-Layout.
|
// Siehe scroll-reveal.tsx: Gegenstueck zum noscript-Block im Root-Layout.
|
||||||
data-reveal=""
|
data-reveal=""
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-60px" }}
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: Math.min(index, 5) * 0.08 }}
|
||||||
className="group"
|
className="group flex flex-col"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/honig/${product.slug}`}
|
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
|
<ContentImage
|
||||||
src={product.images[0] ?? null}
|
src={product.images[0] ?? null}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
tone={categoryTone[product.category]}
|
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={
|
label={
|
||||||
product.harvest
|
product.harvest
|
||||||
? `${product.harvest.year} · ${product.harvest.type}`
|
? `${product.harvest.year} · ${product.harvest.type}`
|
||||||
@@ -202,19 +266,25 @@ export function ProductCard({
|
|||||||
}
|
}
|
||||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
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 ? (
|
{stock === 0 ? (
|
||||||
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
<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}
|
{t.product.outOfStock}
|
||||||
</span>
|
</span>
|
||||||
) : stock <= 5 ? (
|
) : 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)}
|
{t.product.onlyLeft(stock)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="mt-4">
|
<div className="mt-5 flex flex-1 flex-col">
|
||||||
<Link href={`/honig/${product.slug}`}>
|
<Link href={`/honig/${product.slug}`} className="self-start">
|
||||||
<h3 className="text-xl text-forest">{product.name}</h3>
|
<h3 className="link-draw text-2xl leading-tight text-forest">{product.name}</h3>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-ink/70 mt-1">
|
<p className="text-sm text-ink/70 mt-1">
|
||||||
{product.tasteTags.join(" · ")}
|
{product.tasteTags.join(" · ")}
|
||||||
@@ -229,13 +299,13 @@ export function ProductCard({
|
|||||||
{(reviewSummary?.average ?? 0).toFixed(1)} ({reviewSummary?.count ?? 0})
|
{(reviewSummary?.average ?? 0).toFixed(1)} ({reviewSummary?.count ?? 0})
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-auto flex items-baseline justify-between pt-3">
|
||||||
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
<span className="text-sm text-ink/65 tabular-nums">{product.weightGrams} g</span>
|
||||||
<span className="text-base text-forest">
|
<span className="font-serif text-2xl text-forest tabular-nums">
|
||||||
{formatPrice(product.priceCents, product.currency, locale)}
|
{formatPrice(product.priceCents, product.currency, locale)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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)}
|
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
||||||
</p>
|
</p>
|
||||||
{stock > 0 ? (
|
{stock > 0 ? (
|
||||||
@@ -243,9 +313,21 @@ export function ProductCard({
|
|||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href={`/honig/${product.slug}`}
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
|
|||||||
+102
-24
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState, useEffect, useRef, type RefObject } from "react";
|
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 { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
||||||
import { useCartStock } from "@/hooks/use-cart-stock";
|
import { useCartStock } from "@/hooks/use-cart-stock";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
@@ -13,6 +13,7 @@ import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
|||||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
import { CartFlight, onCartLanded } from "@/components/cart-flight";
|
||||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR =
|
const FOCUSABLE_SELECTOR =
|
||||||
@@ -214,24 +215,58 @@ function CartIconButton() {
|
|||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
const openCart = useCartStore((s) => s.openCart);
|
const openCart = useCartStore((s) => s.openCart);
|
||||||
const count = cartItemCount(items);
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={openCart}
|
onClick={openCart}
|
||||||
aria-label={t.nav.openCart(count)}
|
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 6h15l-1.5 9h-12z" />
|
||||||
<path d="M6 6 5 3H2" />
|
<path d="M6 6 5 3H2" />
|
||||||
<circle cx="9" cy="20" r="1" />
|
<circle cx="9" cy="20" r="1" />
|
||||||
<circle cx="18" cy="20" r="1" />
|
<circle cx="18" cy="20" r="1" />
|
||||||
</svg>
|
</motion.svg>
|
||||||
{count > 0 ? (
|
<AnimatePresence>
|
||||||
<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 > 0 ? (
|
||||||
{count}
|
<motion.span
|
||||||
</span>
|
// Neuer key je Anzahl: der Zaehler "springt" bei jeder Aenderung.
|
||||||
) : null}
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,6 +321,7 @@ export function Header({ companyName }: { companyName: string }) {
|
|||||||
<AccountIconButton />
|
<AccountIconButton />
|
||||||
</div>
|
</div>
|
||||||
<CartIconButton />
|
<CartIconButton />
|
||||||
|
<CartFlight />
|
||||||
<button
|
<button
|
||||||
ref={menuButtonRef}
|
ref={menuButtonRef}
|
||||||
aria-label={t.nav.openMenu}
|
aria-label={t.nav.openMenu}
|
||||||
@@ -439,21 +475,33 @@ export function CartDrawer() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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}
|
onClick={closeCart}
|
||||||
/>
|
/>
|
||||||
<motion.aside
|
<motion.aside
|
||||||
ref={asideRef}
|
ref={asideRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="cart-drawer-title"
|
||||||
initial={{ x: "100%" }}
|
initial={{ x: "100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "100%" }}
|
exit={{ x: "100%" }}
|
||||||
transition={{ type: "spring", damping: 28, stiffness: 260 }}
|
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">
|
<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>
|
<h2 id="cart-drawer-title" className="flex items-baseline gap-2 text-2xl font-serif text-forest">
|
||||||
<button aria-label={t.cart.close} onClick={closeCart} className="w-9 h-9">
|
{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">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M6 6l12 12M18 6L6 18" />
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -462,18 +510,39 @@ export function CartDrawer() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-ink/70 mt-10 text-center">
|
// Leerer Korb als ruhiger Einstieg statt toter Flaeche: leere
|
||||||
{t.cart.empty}
|
// Wabe, eine Zeile, ein Weg zurueck in den Shop.
|
||||||
</p>
|
<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">
|
<ul className="divide-y divide-ink/10">
|
||||||
{items.map((item) => {
|
<AnimatePresence initial={false}>
|
||||||
|
{items.map((item, index) => {
|
||||||
const itemStock = stock?.[item.productId];
|
const itemStock = stock?.[item.productId];
|
||||||
return (
|
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" />
|
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-lg" />
|
||||||
<div className="flex-1">
|
<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>
|
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||||
{/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe
|
{/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe
|
||||||
use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von
|
use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von
|
||||||
@@ -503,21 +572,30 @@ export function CartDrawer() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-ink">
|
<p className="text-sm text-ink tabular-nums">
|
||||||
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</motion.li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<div className="border-t border-ink/10 p-6 space-y-4">
|
<div className="border-t border-ink/10 p-6 space-y-4">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex items-baseline justify-between">
|
||||||
<span>{t.cart.subtotal}</span>
|
<span className="text-sm text-ink/70">{t.cart.subtotal}</span>
|
||||||
<span>{formatPrice(total, "EUR", locale)}</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>
|
</div>
|
||||||
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
|
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
|
||||||
{t.cart.checkout}
|
{t.cart.checkout}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { useCartStore } from "@/store/cart-store";
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { launchCartFlight } from "@/components/cart-flight";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
import type { ProductWithRelations } from "@/lib/data";
|
import type { ProductWithRelations } from "@/lib/data";
|
||||||
|
|
||||||
@@ -37,12 +38,13 @@ export function StickyAddToCartBar({
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm text-ink">{product.name}</p>
|
<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>
|
</div>
|
||||||
{inStock ? (
|
{inStock ? (
|
||||||
<Button
|
<Button
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
|
launchCartFlight(event.currentTarget);
|
||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
@@ -52,7 +54,8 @@ export function StickyAddToCartBar({
|
|||||||
priceCents: product.priceCents,
|
priceCents: product.priceCents,
|
||||||
image: product.images[0] ?? null,
|
image: product.images[0] ?? null,
|
||||||
},
|
},
|
||||||
1
|
1,
|
||||||
|
{ openDrawer: false }
|
||||||
);
|
);
|
||||||
setJustAdded(true);
|
setJustAdded(true);
|
||||||
window.setTimeout(() => setJustAdded(false), 1500);
|
window.setTimeout(() => setJustAdded(false), 1500);
|
||||||
|
|||||||
+34
-10
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
import { HexMark } from "@/components/honeycomb";
|
||||||
|
|
||||||
export function Container({
|
export function Container({
|
||||||
id,
|
id,
|
||||||
@@ -19,14 +20,22 @@ export function Container({
|
|||||||
|
|
||||||
type ButtonVariant = "primary" | "secondary" | "ghost";
|
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> = {
|
const buttonStyles: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary: `${fillBase} bg-forest bg-[linear-gradient(var(--color-honey-dark),var(--color-honey-dark))] text-cream border border-forest hover:border-honey-dark`,
|
||||||
"bg-forest text-cream hover:bg-[#1a271f] border border-forest",
|
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`,
|
||||||
secondary:
|
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink hover:bg-ink/[0.03]",
|
||||||
"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",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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> {
|
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
}
|
}
|
||||||
@@ -39,7 +48,7 @@ export function LinkButton({
|
|||||||
}: LinkButtonProps) {
|
}: LinkButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -59,7 +68,7 @@ export function Button({
|
|||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -93,14 +102,29 @@ export function SectionHeading({
|
|||||||
>
|
>
|
||||||
{eyebrow ? (
|
{eyebrow ? (
|
||||||
<p
|
<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}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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 ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-4
@@ -25,7 +25,12 @@ interface CartState {
|
|||||||
setHasHydrated: (hasHydrated: boolean) => void;
|
setHasHydrated: (hasHydrated: boolean) => void;
|
||||||
openCart: () => void;
|
openCart: () => void;
|
||||||
closeCart: () => 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;
|
removeItem: (productId: string) => void;
|
||||||
setQuantity: (productId: string, quantity: number) => void;
|
setQuantity: (productId: string, quantity: number) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
@@ -40,8 +45,9 @@ export const useCartStore = create<CartState>()(
|
|||||||
setHasHydrated: (hasHydrated) => set({ hasHydrated }),
|
setHasHydrated: (hasHydrated) => set({ hasHydrated }),
|
||||||
openCart: () => set({ isOpen: true }),
|
openCart: () => set({ isOpen: true }),
|
||||||
closeCart: () => set({ isOpen: false }),
|
closeCart: () => set({ isOpen: false }),
|
||||||
addItem: (item, quantity = 1) =>
|
addItem: (item, quantity = 1, options) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
const isOpen = options?.openDrawer === false ? state.isOpen : true;
|
||||||
const existing = state.items.find((i) => i.productId === item.productId);
|
const existing = state.items.find((i) => i.productId === item.productId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return {
|
return {
|
||||||
@@ -50,10 +56,10 @@ export const useCartStore = create<CartState>()(
|
|||||||
? { ...i, quantity: i.quantity + quantity }
|
? { ...i, quantity: i.quantity + quantity }
|
||||||
: i
|
: i
|
||||||
),
|
),
|
||||||
isOpen: true,
|
isOpen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { items: [...state.items, { ...item, quantity }], isOpen: true };
|
return { items: [...state.items, { ...item, quantity }], isOpen };
|
||||||
}),
|
}),
|
||||||
removeItem: (productId) =>
|
removeItem: (productId) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
Reference in new issue
Block a user