feat(ui): Fundament und Einkaufserlebnis visuell aufgewertet

- Fundament: feine Papierkoernung, Scrollbar/Cursor/Fokusring/Markierung in
  Markenfarben, gleich breite Ziffern fuer Preise, einlaufende Unterstriche
- Knoepfe fuellen sich beim Hover von unten wie ein Glas (Honig/Waldgruen),
  geben beim Klick nach
- Wabenmotiv aus dem Logo als Komponente (Raster, Markierung, Haekchen)
- In den Warenkorb: ein Honigtropfen fliegt im Bogen zum Korb, der Korb
  wippt, der Zaehler springt, erst dann oeffnet die Schublade; Knopf zeigt
  gezeichnetes Haekchen. Reduzierte Bewegung: Schublade oeffnet sofort.
- Produktkarten: Honigschimmer, Anheben, Serifen-Preis, gestaffeltes
  Erscheinen, Pfeil-Links
- Produktseite: mitlaufendes Bild auf Wabenraster, groesserer Titel und
  Preis, sich fuellende Geschmacksskala, Waben-Haekchen statt "✓"
- Shop-Filter als einheitliche Werkzeugleiste mit Schalter
- Warenkorb-Schublade: Dialog-Semantik, animierte Positionen, tickende
  Summe, gestalteter leerer Zustand

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 04:56:03 +02:00
1 parent 839bbff595
commit 3cb639c154
11 files changed
+621 -123

No files matched your search

+64 -2
View File
@@ -34,16 +34,56 @@
--font-serif: var(--font-cormorant), Georgia, serif;
--font-sans: var(--font-inter), system-ui, sans-serif;
/* Eine Bewegungssprache fuer die ganze Seite: selbstbewusstes Ankommen,
schnelles Gehen (siehe impeccable animate: exit faster than entrance). */
--ease-arrive: cubic-bezier(0.16, 1, 0.3, 1);
--ease-leave: cubic-bezier(0.7, 0, 0.84, 0);
}
html {
scroll-behavior: smooth;
/* Browserflaechen tragen die Marke mit: Scrollbar im Waldton statt Grau. */
scrollbar-color: color-mix(in oklab, var(--color-forest) 45%, transparent) transparent;
accent-color: var(--color-forest);
}
body {
background: var(--color-background);
color: var(--color-ink);
font-family: var(--font-sans);
caret-color: var(--color-honey-dark);
}
/* Feine Papierkoernung ueber allem: bricht die digitale Glaette der
Cremeflaechen, wie Etikettenpapier auf dem Glas. Fest, klickdurchlaessig,
sehr leise — auf dunklen Flaechen kaum, auf hellen gerade spuerbar. */
body::after {
content: "";
position: fixed;
inset: 0;
z-index: 70;
pointer-events: none;
opacity: 0.032;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.14 0 0 0 0 0.12 0 0 0 0 0.08 0 0 0 1 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
:focus-visible {
outline: 2px solid var(--color-honey-dark);
outline-offset: 3px;
}
a {
text-underline-offset: 0.22em;
text-decoration-thickness: 1px;
}
/* Preise, Mengen, Datumsangaben: gleich breite Ziffern, damit Spalten und
wechselnde Summen nicht springen. */
.tabular-nums,
[data-numeric] {
font-variant-numeric: tabular-nums;
}
h1,
@@ -66,8 +106,30 @@ p {
}
::selection {
background: var(--color-honey);
color: var(--color-cream);
/* Honig auf Creme erreichte nur ~2,6:1 — markierter Text war kaum lesbar. */
background: var(--color-honey-pale);
color: var(--color-forest);
}
/* Unterstrich, der beim Hover von links "einlaeuft" — fuer Textlinks in
Fliesstext und Navigation. */
.link-draw {
background-image: linear-gradient(currentColor, currentColor);
background-size: 0% 1px;
background-position: 0 100%;
background-repeat: no-repeat;
transition: background-size 350ms var(--ease-arrive);
}
.link-draw:hover,
.link-draw:focus-visible,
.link-draw[aria-current="page"] {
background-size: 100% 1px;
}
@media (prefers-reduced-motion: reduce) {
.link-draw {
transition: none;
}
}
/* Knopf eines gerade laufenden Server-Action-Formulars (components/
+31 -22
View File
@@ -23,6 +23,7 @@ import { allowedCountryCodes } from "@/lib/shipping-region";
import { resolveShippingCostCents } from "@/lib/shipping-calc";
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
import { TurnstileField } from "@/components/turnstile-widget";
import { HoneycombPattern, HexCheck } from "@/components/honeycomb";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { WissenSlug } from "@/i18n/dictionary-type";
@@ -245,22 +246,29 @@ export default async function ProductDetailPage(
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
/>
<Container className="grid md:grid-cols-2 gap-14">
<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
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { HexMark } from "@/components/honeycomb";
import { Container, SectionHeading } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { ProductCard } from "@/components/product-card";
@@ -106,19 +107,21 @@ export default async function ShopPage(props: PageProps<"/shop">) {
<Link
key={filter.label}
href={href}
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
aria-current={isActive ? "page" : undefined}
className={`inline-flex h-11 items-center gap-2 px-5 text-sm tracking-wide-caps border transition-[color,background-color,border-color] duration-300 ${
isActive
? "bg-forest text-cream border-forest"
: "border-ink/20 text-ink/70 hover:border-forest"
: "border-ink/15 text-ink/70 hover:border-forest hover:text-forest"
}`}
>
{isActive ? <HexMark className="h-2.5 w-2.5 text-honey-light" /> : null}
{filter.label}
</Link>
);
})}
</div>
<div className="mt-6 flex flex-wrap items-center gap-4">
<div className="mt-5 flex flex-wrap items-center gap-3 border-t border-ink/10 pt-5">
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen.
Die Suche ist bewusst Teil DIESES Formulars (statt nur der kleinen
@@ -131,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 ? (