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:
1 parent
baebf78ee4
commit
d4bb6ec1cf
4 files changed
+125
-48
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user