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