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 ? (
|
||||
|
||||
Reference in new issue
Block a user