From 3cb639c154fd40a0217473b7b196e240ff17a4d0 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 25 Sep 2026 04:56:03 +0200 Subject: [PATCH] feat(ui): Fundament und Einkaufserlebnis visuell aufgewertet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/app/globals.css | 66 +++++++++- src/app/honig/[slug]/page.tsx | 53 ++++---- src/app/shop/page.tsx | 101 +++++++++++----- src/components/cart-flight.tsx | 99 +++++++++++++++ src/components/home-sections-a.tsx | 3 +- src/components/honeycomb.tsx | 89 ++++++++++++++ src/components/product-card.tsx | 140 +++++++++++++++++----- src/components/site-chrome.tsx | 126 +++++++++++++++---- src/components/sticky-add-to-cart-bar.tsx | 9 +- src/components/ui.tsx | 44 +++++-- src/store/cart-store.ts | 14 ++- 11 files changed, 621 insertions(+), 123 deletions(-) create mode 100644 src/components/cart-flight.tsx create mode 100644 src/components/honeycomb.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 6981bcf..1773f5e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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,"); +} + +: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/ diff --git a/src/app/honig/[slug]/page.tsx b/src/app/honig/[slug]/page.tsx index ab2edba..2a1b405 100644 --- a/src/app/honig/[slug]/page.tsx +++ b/src/app/honig/[slug]/page.tsx @@ -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(/ - - + + {/* Das Glas bleibt beim Lesen der Details stehen, statt eine leere + Spalte zu hinterlassen — und liegt auf dem Wabenraster der Marke. */} +
+ +
+ +
+
-

+

{company.name} · {t.common.categories[product.category]}

-

{product.name}

+

{product.name}

{/* Ohne gepflegte Noten sonst ein leerer Absatz, der nur Abstand zwischen Ueberschrift und Bewertung erzeugt. */} {product.tasteTags.length > 0 ? ( -

{product.tasteTags.join(" · ")}

+

{product.tasteTags.join(" · ")}

) : null} {reviewSummary.count > 0 ? ( @@ -273,13 +281,13 @@ export default async function ProductDetailPage(

{t.productPage.noReviews}

)} -
- +
+ {formatPrice(product.priceCents, product.currency, locale)} - {product.weightGrams} g + {product.weightGrams} g
-

+

{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}

{t.productPage.giftHint}

{isHoney ? ( -
+
@@ -302,7 +310,7 @@ export default async function ProductDetailPage( {isPurchasable ? ( <> - + {lowStock ? (

{t.productPage.onlyLeftInStock(stock)}

) : null} @@ -372,10 +380,11 @@ export default async function ProductDetailPage(

)} -
    +
      {(t.productPage.checklist[product.category] ?? t.productPage.checklist.HONIG).map((item) => ( -
    • - ✓ {item} +
    • + + {item}
    • ))}
    @@ -428,7 +437,7 @@ export default async function ProductDetailPage( -

    {t.productPage.reviewsHeading}

    +

    {t.productPage.reviewsHeading}

    @@ -504,11 +513,11 @@ export default async function ProductDetailPage( {relatedProducts.length > 0 ? ( -

    {t.productPage.relatedProductsHeading}

    +

    {t.productPage.relatedProductsHeading}

    - {relatedProducts.map((related) => ( - + {relatedProducts.map((related, index) => ( + ))}
    diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx index dbfb903..cabe766 100644 --- a/src/app/shop/page.tsx +++ b/src/app/shop/page.tsx @@ -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">) { + {isActive ? : null} {filter.label} ); })}
-
+
{/* 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">) { - - - + + + + + + + @@ -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. */} +
@@ -186,8 +231,8 @@ export default async function ShopPage(props: PageProps<"/shop">) { ) : null}

{t.shop.productsHeading}

- {products.map((product) => ( - + {products.map((product, index) => ( + ))}
{products.length === 0 ? ( diff --git a/src/components/cart-flight.tsx b/src/components/cart-flight.tsx new file mode 100644 index 0000000..0a44e43 --- /dev/null +++ b/src/components/cart-flight.tsx @@ -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("[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 = + ''; + 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; +} diff --git a/src/components/home-sections-a.tsx b/src/components/home-sections-a.tsx index afb0db6..8f3b833 100644 --- a/src/components/home-sections-a.tsx +++ b/src/components/home-sections-a.tsx @@ -27,11 +27,12 @@ export async function CurrentHarvest({ />
- {products.map((product) => ( + {products.map((product, index) => ( ))}
diff --git a/src/components/honeycomb.tsx b/src/components/honeycomb.tsx new file mode 100644 index 0000000..a783370 --- /dev/null +++ b/src/components/honeycomb.tsx @@ -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 = { + 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 ( + + ); +} + +/** Kleines Sechseck als Aufzaehlungs- und Statuszeichen (statt Unicode-Glyphen). */ +export function HexMark({ className = "h-3 w-3", filled = true }: { className?: string; filled?: boolean }) { + return ( + + ); +} + +/** Haekchen im Sechseck — fuer Vorteilslisten (ersetzt das Textzeichen "✓"). */ +export function HexCheck({ className = "h-4 w-4" }: { className?: string }) { + return ( + + ); +} diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 581d0a8..81b99f4 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -2,13 +2,14 @@ import Link from "next/link"; import { useState } from "react"; -import { motion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { ContentImage } from "@/components/content-image"; import { QuantityStepper } from "@/components/quantity-stepper"; import { StarRating } from "@/components/star-rating"; import { Button } from "@/components/ui"; import { formatPrice, formatPricePerKg } from "@/lib/format"; import { useCartStore } from "@/store/cart-store"; +import { launchCartFlight } from "@/components/cart-flight"; import { useI18n } from "@/i18n/i18n-provider"; import type { ProductWithRelations } from "@/lib/data"; import type { ProductCategory } from "@/generated/prisma/client"; @@ -68,20 +69,78 @@ export function TasteScale({ {label} -
+ {/* Ausloeser ist die ganze Skala: die Fuellsegmente starten mit + Breite 0 und wuerden selbst nie als "sichtbar" gemeldet. */} + {Array.from({ length: 5 }, (_, i) => ( + > + {i < value ? ( + // Die Skala "fuellt sich" beim Erscheinen Segment fuer Segment + // wie ein Glas — sichtbar, dass es um Intensitaet geht. + + ) : null} + ))} -
+
); } +/** + * 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 ( + + + {justAdded ? ( + + ) : null} + {/* Das Woerterbuch haengt ein "✓" an — hier zeichnet das SVG das Haekchen. */} + {justAdded ? done.replace(/\s*✓\s*$/, "") : idle} + + + ); +} + function toCartItem(product: ProductWithRelations) { return { productId: product.id, @@ -113,13 +172,14 @@ export function AddToCartButton({ ); @@ -151,13 +211,14 @@ export function AddToCartWithQuantity({
@@ -168,10 +229,13 @@ export function AddToCartWithQuantity({ export function ProductCard({ product, reviewSummary, + index = 0, }: { product: ProductWithRelations; /** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */ reviewSummary?: { average: number | null; count: number }; + /** Position im Raster — staffelt das Erscheinen (gedeckelt, damit lange Listen nicht warten lassen). */ + index?: number; }) { const { t, locale } = useI18n(); const stock = product.inventory?.quantity ?? 0; @@ -180,21 +244,21 @@ export function ProductCard({ + {/* Warmer Honigschimmer, der beim Hover diagonal ueber das Glas + zieht — wie Licht, das durch Honig faellt. */} +
-

+

{formatPrice(item.priceCents * item.quantity, "EUR", locale)}

- + ); })} + )}
{items.length > 0 ? (
-
- {t.cart.subtotal} - {formatPrice(total, "EUR", locale)} +
+ {t.cart.subtotal} + + {formatPrice(total, "EUR", locale)} +
{t.cart.checkout} diff --git a/src/components/sticky-add-to-cart-bar.tsx b/src/components/sticky-add-to-cart-bar.tsx index a4876ed..564cea7 100644 --- a/src/components/sticky-add-to-cart-bar.tsx +++ b/src/components/sticky-add-to-cart-bar.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { Button } from "@/components/ui"; import { formatPrice } from "@/lib/format"; import { useCartStore } from "@/store/cart-store"; +import { launchCartFlight } from "@/components/cart-flight"; import { useI18n } from "@/i18n/i18n-provider"; import type { ProductWithRelations } from "@/lib/data"; @@ -37,12 +38,13 @@ export function StickyAddToCartBar({

{product.name}

-

{formatPrice(product.priceCents, product.currency, locale)}

+

{formatPrice(product.priceCents, product.currency, locale)}

{inStock ? (
); diff --git a/src/store/cart-store.ts b/src/store/cart-store.ts index 5d5143a..51fc1ee 100644 --- a/src/store/cart-store.ts +++ b/src/store/cart-store.ts @@ -25,7 +25,12 @@ interface CartState { setHasHydrated: (hasHydrated: boolean) => void; openCart: () => void; closeCart: () => void; - addItem: (item: Omit, 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, quantity?: number, options?: { openDrawer?: boolean }) => void; removeItem: (productId: string) => void; setQuantity: (productId: string, quantity: number) => void; clear: () => void; @@ -40,8 +45,9 @@ export const useCartStore = create()( setHasHydrated: (hasHydrated) => set({ hasHydrated }), openCart: () => set({ isOpen: true }), closeCart: () => set({ isOpen: false }), - addItem: (item, quantity = 1) => + addItem: (item, quantity = 1, options) => set((state) => { + const isOpen = options?.openDrawer === false ? state.isOpen : true; const existing = state.items.find((i) => i.productId === item.productId); if (existing) { return { @@ -50,10 +56,10 @@ export const useCartStore = create()( ? { ...i, quantity: i.quantity + quantity } : i ), - isOpen: true, + isOpen, }; } - return { items: [...state.items, { ...item, quantity }], isOpen: true }; + return { items: [...state.items, { ...item, quantity }], isOpen }; }), removeItem: (productId) => set((state) => ({