feat(ui): Adressen, Retoure, Suche und Cookie-Hinweis

- Adressen im Zweierraster, Standardadresse mit Honigrahmen und Wabenmarke,
  neues Adressformular in der Formular-Karte
- Retoure: Positionen als Liste mit Waben, Formular in der Karte
- Suche: Vorschlaege gleiten gestaffelt herein, Suchbegriff hervorgehoben,
  Lupe wird zum Schliessen-Symbol; das Feld legt sich ueber die Navigation
  statt sie zu verschieben (vorher: waagerechter Scrollbalken)
- Cookie-Hinweis als schwebende Karte; "Nur notwendige" und "Alle
  akzeptieren" gleich gewichtet (vorher war Akzeptieren Waldgruen auf
  Waldgruen und kaum als Knopf erkennbar)

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 13:24:07 +02:00
1 parent baebf78ee4
commit d4bb6ec1cf
4 files changed
+125 -48

No files matched your search

+17 -14
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { HoneycombPattern } from "@/components/honeycomb";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider";
@@ -31,31 +32,33 @@ export function CookieConsentBanner() {
<AnimatePresence>
{visible ? (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ duration: 0.3 }}
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.6 } }}
exit={{ y: 24, opacity: 0, transition: { duration: 0.2 } }}
role="dialog"
aria-label={t.cookieConsent.dialogLabel}
className="fixed inset-x-0 bottom-0 z-50 bg-forest text-cream border-t border-cream/20"
// Schwebende Karte statt bildschirmbreiter Leiste: verdeckt weniger
// Inhalt, bleibt aber unuebersehbar.
className="fixed inset-x-4 bottom-4 z-50 overflow-hidden bg-forest text-cream shadow-[0_24px_60px_-20px_rgba(36,53,43,0.7)] sm:left-6 sm:right-auto sm:bottom-6 sm:max-w-md"
>
<div className="mx-auto max-w-6xl px-6 py-6 flex flex-col md:flex-row md:items-center gap-4">
<p className="text-sm text-cream/85 leading-relaxed flex-1">
<HoneycombPattern className="text-honey-light/[0.1]" size={18} fade="right" />
<div className="relative px-6 py-6 flex flex-col gap-5">
<p className="text-sm text-cream/85 leading-relaxed">
{t.cookieConsent.bannerTextBeforeLink}
<Link href="/cookie-richtlinie" className="underline">
{t.cookieConsent.policyLinkLabel}
</Link>
{t.cookieConsent.bannerTextAfterLink}
</p>
<div className="flex flex-shrink-0 flex-wrap gap-3">
<Button
variant="inverted"
className="whitespace-nowrap"
onClick={() => choose("essential")}
>
{/* Beide Wahlmoeglichkeiten gleich gewichtet (Datenschutzbehoerden
verlangen, dass Ablehnen nicht schwerer faellt als Zustimmen).
Vorher war "Alle akzeptieren" Waldgruen auf Waldgruen und damit
kaum als Knopf erkennbar. */}
<div className="grid grid-cols-2 gap-3">
<Button variant="inverted" className="whitespace-nowrap px-4" onClick={() => choose("essential")}>
{t.cookieConsent.essentialOnly}
</Button>
<Button className="whitespace-nowrap" onClick={() => choose("all")}>
<Button variant="inverted" className="whitespace-nowrap px-4" onClick={() => choose("all")}>
{t.cookieConsent.acceptAll}
</Button>
</div>
+79 -17
View File
@@ -143,13 +143,19 @@ function SearchButton() {
<div className="relative flex items-center">
<AnimatePresence>
{open ? (
// Legt sich UEBER die Navigation (absolut, von rechts aufklappend)
// statt sie beiseitezuschieben — vorher drueckte das Feld die
// Kopfzeile auseinander und erzeugte einen waagerechten Scrollbalken.
<motion.form
initial={{ width: 0, opacity: 0 }}
animate={{ width: 180, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 16 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
action="/shop"
method="GET"
className="overflow-visible"
// Deckende Unterlage ueber die volle Kopfzeilenhoehe, damit nichts
// von der Navigation darunter durchscheint.
className="absolute right-full top-1/2 mr-1 flex h-16 w-[21rem] -translate-y-1/2 items-center bg-background pl-6"
>
<input
type="search"
@@ -159,23 +165,45 @@ function SearchButton() {
autoFocus
autoComplete="off"
placeholder={t.nav.searchPlaceholder}
className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none"
className="w-full border-b border-forest bg-transparent px-2 py-1.5 text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
/>
{visibleSuggestions.length > 0 ? (
<ul className="absolute top-full left-0 mt-2 w-72 bg-background border border-ink/10 shadow-[0_16px_32px_-16px_rgba(36,53,43,0.25)] z-50">
{visibleSuggestions.map((s) => (
<li key={s.slug}>
<motion.ul
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-full right-0 mt-3 w-80 bg-background border border-ink/10 py-2 shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)] z-50"
>
{visibleSuggestions.map((s, i) => (
<motion.li
key={s.slug}
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0, transition: { delay: i * 0.04 } }}
>
<Link
href={`/honig/${s.slug}`}
onClick={closeSearch}
className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60"
className="group flex items-center justify-between gap-4 px-4 py-2.5 text-ink transition-colors hover:bg-honey/10"
>
<span>{s.name}</span>
<span className="text-ink/70 whitespace-nowrap">{s.price}</span>
<span className="font-serif text-lg leading-tight">
<HighlightMatch text={s.name} query={query} />
</span>
<span className="flex items-center gap-2 whitespace-nowrap text-sm text-ink/70 tabular-nums">
{s.price}
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5 -translate-x-1 opacity-0 transition-[opacity,transform] duration-300 group-hover:translate-x-0 group-hover:opacity-100"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</Link>
</li>
</motion.li>
))}
</ul>
</motion.ul>
) : null}
</motion.form>
) : null}
@@ -187,15 +215,49 @@ function SearchButton() {
aria-label={t.nav.openSearch}
className="flex items-center justify-center w-10 h-10 text-forest flex-shrink-0"
>
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<AnimatePresence mode="wait" initial={false}>
<motion.svg
key={open ? "close" : "search"}
initial={{ rotate: -45, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: 45, opacity: 0 }}
transition={{ duration: 0.15 }}
width="19"
height="19"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
{open ? (
<path d="M6 6l12 12M18 6L6 18" />
) : (
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</>
)}
</motion.svg>
</AnimatePresence>
</button>
</div>
);
}
/** Hebt den gesuchten Begriff im Vorschlag hervor — der Blick findet sofort, warum er passt. */
function HighlightMatch({ text, query }: { text: string; query: string }) {
const needle = query.trim();
const index = needle ? text.toLowerCase().indexOf(needle.toLowerCase()) : -1;
if (index < 0) return <>{text}</>;
return (
<>
{text.slice(0, index)}
<mark className="bg-honey-pale/70 text-forest">{text.slice(index, index + needle.length)}</mark>
{text.slice(index + needle.length)}
</>
);
}
function AccountIconButton() {
const { t } = useI18n();
return (