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

+20 -12
View File
@@ -1,7 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Container, SectionHeading, Button } from "@/components/ui"; import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
import { HexMark } from "@/components/honeycomb";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { AccountEmptyState, AddressIcon } from "@/components/account-ui"; import { AccountEmptyState, AddressIcon } from "@/components/account-ui";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
@@ -27,21 +28,28 @@ export default async function KontoAdressenPage() {
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} /> <SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
<div className="mt-10 space-y-4"> <div className="mt-10 grid gap-4 sm:grid-cols-2">
{addresses.map((address) => ( {addresses.map((address) => (
<div <div
key={address.id} key={address.id}
className="border border-ink/10 p-6 flex justify-between gap-4 transition-colors hover:border-ink/25" className={`relative flex flex-col justify-between gap-6 border p-6 transition-[border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] ${
address.isDefault
? "border-honey/60 bg-honey/[0.05] shadow-[0_18px_34px_-26px_rgba(184,138,68,0.5)]"
: "border-ink/10 hover:border-forest/40"
}`}
> >
{address.isDefault ? (
<span className="absolute right-4 top-4 inline-flex items-center gap-1.5 text-[11px] tracking-wide-caps text-honey-dark">
<HexMark className="h-2.5 w-2.5" />
{t.addressesPage.standard}
</span>
) : null}
<div className="flex gap-4"> <div className="flex gap-4">
<span className="flex-shrink-0 flex items-center justify-center w-11 h-11 border border-ink/15 text-forest"> <span className="flex-shrink-0 flex items-center justify-center w-11 h-11 border border-ink/15 text-forest">
<AddressIcon /> <AddressIcon />
</span> </span>
<div> <div>
<p className="text-forest"> <p className="font-serif text-xl text-forest">{address.label}</p>
{address.label}{" "}
{address.isDefault ? <span className="text-honey-dark text-xs">· {t.addressesPage.standard}</span> : null}
</p>
<p className="text-sm text-ink/70 mt-1"> <p className="text-sm text-ink/70 mt-1">
{address.firstName} {address.lastName} {address.firstName} {address.lastName}
<br /> <br />
@@ -51,7 +59,7 @@ export default async function KontoAdressenPage() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2 text-sm flex-shrink-0"> <div className="flex items-center gap-5 border-t border-ink/10 pt-4 text-sm">
{!address.isDefault ? ( {!address.isDefault ? (
<form action={setDefaultAddressAction}> <form action={setDefaultAddressAction}>
<input type="hidden" name="addressId" value={address.id} /> <input type="hidden" name="addressId" value={address.id} />
@@ -78,9 +86,9 @@ export default async function KontoAdressenPage() {
) : null} ) : null}
</div> </div>
<div className="mt-14 border-t border-ink/10 pt-10"> <FormPanel className="mt-14 max-w-xl">
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2> <h2 className="text-2xl text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
<form action={addAddressAction} className="space-y-4 max-w-md"> <form action={addAddressAction} className="space-y-4">
<Field <Field
name="label" name="label"
label={t.addressesPage.labelField} label={t.addressesPage.labelField}
@@ -109,7 +117,7 @@ export default async function KontoAdressenPage() {
</label> </label>
<Button type="submit">{t.addressesPage.save}</Button> <Button type="submit">{t.addressesPage.save}</Button>
</form> </form>
</div> </FormPanel>
</Container> </Container>
</div> </div>
); );
@@ -1,5 +1,6 @@
import { redirect, notFound } from "next/navigation"; import { redirect, notFound } from "next/navigation";
import { Container, SectionHeading, Button } from "@/components/ui"; import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
import { HexMark } from "@/components/honeycomb";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -46,15 +47,17 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
<BackLink href="/konto/bestellungen" label={t.retourePage.backLabel} /> <BackLink href="/konto/bestellungen" label={t.retourePage.backLabel} />
<SectionHeading eyebrow={t.retourePage.orderEyebrow(order.orderNumber)} title={t.retourePage.title} /> <SectionHeading eyebrow={t.retourePage.orderEyebrow(order.orderNumber)} title={t.retourePage.title} />
<ul className="mt-8 text-sm text-ink/70 space-y-1"> <FormPanel className="mt-8">
<ul className="divide-y divide-ink/10 border-y border-ink/10 text-sm text-ink/75">
{order.items.map((item) => ( {order.items.map((item) => (
<li key={item.id}> <li key={item.id} className="flex items-center gap-3 py-3">
{item.quantity} × {item.productName} <HexMark className="h-2.5 w-2.5 text-honey" />
<span className="tabular-nums text-ink/55">{item.quantity} ×</span>
<span className="font-serif text-lg text-forest">{item.productName}</span>
</li> </li>
))} ))}
</ul> </ul>
<form action={requestReturnAction} className="mt-6 space-y-4"> <form action={requestReturnAction} className="mt-6 space-y-4">
<input type="hidden" name="orderId" value={order.id} /> <input type="hidden" name="orderId" value={order.id} />
<div> <div>
@@ -73,6 +76,7 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
</div> </div>
<Button type="submit">{t.retourePage.submit}</Button> <Button type="submit">{t.retourePage.submit}</Button>
</form> </form>
</FormPanel>
</Container> </Container>
</div> </div>
); );
+17 -14
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { HoneycombPattern } from "@/components/honeycomb";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider"; import { useI18n } from "@/i18n/i18n-provider";
@@ -31,31 +32,33 @@ export function CookieConsentBanner() {
<AnimatePresence> <AnimatePresence>
{visible ? ( {visible ? (
<motion.div <motion.div
initial={{ y: 100, opacity: 0 }} initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.6 } }}
exit={{ y: 100, opacity: 0 }} exit={{ y: 24, opacity: 0, transition: { duration: 0.2 } }}
transition={{ duration: 0.3 }}
role="dialog" role="dialog"
aria-label={t.cookieConsent.dialogLabel} 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"> <HoneycombPattern className="text-honey-light/[0.1]" size={18} fade="right" />
<p className="text-sm text-cream/85 leading-relaxed flex-1"> <div className="relative px-6 py-6 flex flex-col gap-5">
<p className="text-sm text-cream/85 leading-relaxed">
{t.cookieConsent.bannerTextBeforeLink} {t.cookieConsent.bannerTextBeforeLink}
<Link href="/cookie-richtlinie" className="underline"> <Link href="/cookie-richtlinie" className="underline">
{t.cookieConsent.policyLinkLabel} {t.cookieConsent.policyLinkLabel}
</Link> </Link>
{t.cookieConsent.bannerTextAfterLink} {t.cookieConsent.bannerTextAfterLink}
</p> </p>
<div className="flex flex-shrink-0 flex-wrap gap-3"> {/* Beide Wahlmoeglichkeiten gleich gewichtet (Datenschutzbehoerden
<Button verlangen, dass Ablehnen nicht schwerer faellt als Zustimmen).
variant="inverted" Vorher war "Alle akzeptieren" Waldgruen auf Waldgruen und damit
className="whitespace-nowrap" kaum als Knopf erkennbar. */}
onClick={() => choose("essential")} <div className="grid grid-cols-2 gap-3">
> <Button variant="inverted" className="whitespace-nowrap px-4" onClick={() => choose("essential")}>
{t.cookieConsent.essentialOnly} {t.cookieConsent.essentialOnly}
</Button> </Button>
<Button className="whitespace-nowrap" onClick={() => choose("all")}> <Button variant="inverted" className="whitespace-nowrap px-4" onClick={() => choose("all")}>
{t.cookieConsent.acceptAll} {t.cookieConsent.acceptAll}
</Button> </Button>
</div> </div>
+79 -17
View File
@@ -143,13 +143,19 @@ function SearchButton() {
<div className="relative flex items-center"> <div className="relative flex items-center">
<AnimatePresence> <AnimatePresence>
{open ? ( {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 <motion.form
initial={{ width: 0, opacity: 0 }} initial={{ opacity: 0, x: 16 }}
animate={{ width: 180, opacity: 1 }} animate={{ opacity: 1, x: 0 }}
exit={{ width: 0, opacity: 0 }} exit={{ opacity: 0, x: 16 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
action="/shop" action="/shop"
method="GET" 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 <input
type="search" type="search"
@@ -159,23 +165,45 @@ function SearchButton() {
autoFocus autoFocus
autoComplete="off" autoComplete="off"
placeholder={t.nav.searchPlaceholder} 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 ? ( {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"> <motion.ul
{visibleSuggestions.map((s) => ( initial={{ opacity: 0, y: -6 }}
<li key={s.slug}> 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 <Link
href={`/honig/${s.slug}`} href={`/honig/${s.slug}`}
onClick={closeSearch} 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="font-serif text-lg leading-tight">
<span className="text-ink/70 whitespace-nowrap">{s.price}</span> <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> </Link>
</li> </motion.li>
))} ))}
</ul> </motion.ul>
) : null} ) : null}
</motion.form> </motion.form>
) : null} ) : null}
@@ -187,15 +215,49 @@ function SearchButton() {
aria-label={t.nav.openSearch} aria-label={t.nav.openSearch}
className="flex items-center justify-center w-10 h-10 text-forest flex-shrink-0" 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"> <AnimatePresence mode="wait" initial={false}>
<circle cx="11" cy="11" r="7" /> <motion.svg
<path d="m20 20-3.5-3.5" /> key={open ? "close" : "search"}
</svg> 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> </button>
</div> </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() { function AccountIconButton() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (