feat(ui): Kundenkonto im Wabenstil
- Konto-Kacheln: Icon in einer Wabe, die sich beim Hover mit Honig fuellt, einlaufender Pfeil, Serifen-Titel - Bestell-Statusleiste: Waben statt Punkte, erledigte Abschnitte fliessen nacheinander voll, aktueller Schritt pulsiert sanft - Status-Badge mit pulsierendem Punkt bei laufenden Bestellungen - Leere Zustaende mit gestrichelter Wabe und Serifen-Titel - Formularfelder: einheitlicher Honig-Schein beim Fokus - Groessere Abschnittstitel im Konto, Bestellsummen in Serifen-Preisschrift - Nur CSS-Animationen (Komponenten rendern auch auf Admin-Serverseiten); reduzierte Bewegung schaltet Puls und Fuellen ab Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
3de17ca070
commit
6ac7cc5d1f
5 files changed
+133
-27
No files matched your search
@@ -179,7 +179,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between gap-4">
|
<div className="mt-5 flex items-center justify-between gap-4">
|
||||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
<p className="font-serif text-2xl text-forest tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||||
{order.invoice ? (
|
{order.invoice ? (
|
||||||
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
||||||
{t.trackOrderPage.viewInvoice}
|
{t.trackOrderPage.viewInvoice}
|
||||||
|
|||||||
@@ -203,3 +203,57 @@ p {
|
|||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Konto: erledigte Abschnitte der Statusleiste "fliessen" nacheinander voll. */
|
||||||
|
@keyframes wh-grow {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aktueller Schritt / laufender Status: sanfter Puls um die Wabe. */
|
||||||
|
@keyframes wh-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in oklab, var(--color-honey) 55%, transparent);
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 7px transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sechseck als Form fuer Icon-Flaechen (gleiche Geometrie wie Logomark). */
|
||||||
|
.clip-hex {
|
||||||
|
clip-path: polygon(50% 0, 93.3% 25%, 93.3% 75%, 50% 100%, 6.7% 75%, 6.7% 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.motion-grow,
|
||||||
|
.motion-pulse {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formularfelder: weicher Honig-Schein beim Fokus statt nur einer
|
||||||
|
Randfarbe — auf allen Formularen einheitlich, ohne jedes einzeln
|
||||||
|
anzufassen. Viele Felder setzen focus:outline-none; der Schein
|
||||||
|
(box-shadow) bleibt davon unberuehrt. */
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
transition:
|
||||||
|
border-color 200ms var(--ease-arrive),
|
||||||
|
box-shadow 250ms var(--ease-arrive);
|
||||||
|
}
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-honey) 22%, transparent);
|
||||||
|
}
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: color-mix(in oklab, var(--color-ink) 45%, transparent);
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ export default async function KontoBestellungenPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
||||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
<p className="font-serif text-2xl text-forest tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{order.invoice ? (
|
{order.invoice ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default async function KontoPage() {
|
|||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
|
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
|
||||||
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
|
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
|
||||||
<form action={loginAction} className="space-y-4 md:pr-12">
|
<form action={loginAction} className="space-y-4 md:pr-12">
|
||||||
<h2 className="text-lg text-forest">{t.account.login}</h2>
|
<h2 className="text-3xl text-forest">{t.account.login}</h2>
|
||||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||||
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
||||||
{/* Das Turnstile-Widget ist ein iframe mit FESTER Breite (rund
|
{/* Das Turnstile-Widget ist ein iframe mit FESTER Breite (rund
|
||||||
@@ -55,7 +55,7 @@ export default async function KontoPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form action={registerAction} className="space-y-4 md:pl-12">
|
<form action={registerAction} className="space-y-4 md:pl-12">
|
||||||
<h2 className="text-lg text-forest">{t.account.register}</h2>
|
<h2 className="text-3xl text-forest">{t.account.register}</h2>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
|
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
|
||||||
<Field name="lastName" label={t.account.lastNameLabel} required autoComplete="family-name" />
|
<Field name="lastName" label={t.account.lastNameLabel} required autoComplete="family-name" />
|
||||||
@@ -176,7 +176,7 @@ export default async function KontoPage() {
|
|||||||
|
|
||||||
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg text-forest">{t.account.newsletterHeading}</h2>
|
<h2 className="text-2xl text-forest">{t.account.newsletterHeading}</h2>
|
||||||
<p className="mt-1 text-sm text-ink/70">
|
<p className="mt-1 text-sm text-ink/70">
|
||||||
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
||||||
</p>
|
</p>
|
||||||
@@ -190,7 +190,7 @@ export default async function KontoPage() {
|
|||||||
|
|
||||||
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg text-forest">{t.account.logoutAllDevicesHeading}</h2>
|
<h2 className="text-2xl text-forest">{t.account.logoutAllDevicesHeading}</h2>
|
||||||
<p className="mt-1 text-sm text-ink/70">
|
<p className="mt-1 text-sm text-ink/70">
|
||||||
{t.account.logoutAllDevicesDescription}
|
{t.account.logoutAllDevicesDescription}
|
||||||
</p>
|
</p>
|
||||||
@@ -208,10 +208,10 @@ export default async function KontoPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2 className="text-lg text-forest mb-4">{t.account.privacy.heading}</h2>
|
<h2 className="text-2xl text-forest mb-4">{t.account.privacy.heading}</h2>
|
||||||
<div className="border border-ink/10 p-6 flex items-center justify-between gap-4">
|
<div className="border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm text-forest">{t.account.privacy.downloadDataHeading}</h3>
|
<h3 className="text-lg text-forest">{t.account.privacy.downloadDataHeading}</h3>
|
||||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
||||||
@@ -220,7 +220,7 @@ export default async function KontoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
|
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
|
||||||
<h3 className="text-sm text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
<h3 className="text-lg text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
||||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
|
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
|
||||||
<ConfirmForm
|
<ConfirmForm
|
||||||
action={deleteOwnAccountAction}
|
action={deleteOwnAccountAction}
|
||||||
|
|||||||
@@ -24,23 +24,41 @@ export function AccountNavCard({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={`group flex items-start gap-4 border p-6 transition-all duration-300 hover:-translate-y-0.5 ${
|
className={`group relative flex items-start gap-5 border p-6 transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-1 ${
|
||||||
accent
|
accent
|
||||||
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_16px_32px_-20px_rgba(184,138,68,0.35)]"
|
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_22px_40px_-24px_rgba(184,138,68,0.45)]"
|
||||||
: "border-ink/10 hover:border-forest hover:shadow-[0_16px_32px_-20px_rgba(36,53,43,0.25)]"
|
: "border-ink/10 bg-background hover:border-forest/60 hover:shadow-[0_22px_40px_-24px_rgba(36,53,43,0.35)]"
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
>
|
>
|
||||||
|
{/* Icon in einer Wabe, die sich beim Hover mit Honig fuellt. */}
|
||||||
|
<span className="relative flex-shrink-0 flex items-center justify-center w-12 h-[3.4rem]">
|
||||||
|
<span aria-hidden="true" className={`clip-hex absolute inset-0 ${accent ? "bg-honey/30" : "bg-ink/10"}`} />
|
||||||
<span
|
<span
|
||||||
className={`flex-shrink-0 flex items-center justify-center w-11 h-11 border transition-colors ${
|
aria-hidden="true"
|
||||||
accent ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
|
className="clip-hex absolute inset-[1.5px] bg-background transition-colors duration-500 group-hover:bg-honey"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`relative transition-colors duration-500 group-hover:text-cream ${
|
||||||
|
accent ? "text-honey-dark" : "text-forest"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
|
||||||
<span className="block text-lg text-forest">{title}</span>
|
|
||||||
<span className="block mt-1 text-sm text-ink/70">{description}</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex-1 pt-1">
|
||||||
|
<span className="block font-serif text-2xl leading-tight text-forest">{title}</span>
|
||||||
|
<span className="block mt-1.5 text-sm text-ink/70">{description}</span>
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="mt-2 h-4 w-4 flex-shrink-0 -translate-x-2 text-forest opacity-0 transition-[opacity,transform] duration-500 ease-[var(--ease-arrive)] 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>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,10 +75,19 @@ const ORDER_STATUS_STYLES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Farbcodiertes Status-Label statt reinem Fließtext, damit der Bestellstatus auf einen Blick erkennbar ist. */
|
/** Farbcodiertes Status-Label statt reinem Fließtext, damit der Bestellstatus auf einen Blick erkennbar ist. */
|
||||||
|
/** Status, bei denen gerade etwas passiert — ihr Punkt pulsiert sanft. */
|
||||||
|
const ORDER_STATUS_IN_PROGRESS = new Set(["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"]);
|
||||||
|
|
||||||
export function OrderStatusBadge({ status, label }: { status: string; label: string }) {
|
export function OrderStatusBadge({ status, label }: { status: string; label: string }) {
|
||||||
const style = ORDER_STATUS_STYLES[status] ?? "border-ink/20 text-ink/70";
|
const style = ORDER_STATUS_STYLES[status] ?? "border-ink/20 text-ink/70";
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center border px-2.5 py-1 text-xs tracking-wide-caps ${style}`}>
|
<span className={`inline-flex items-center gap-2 border px-2.5 py-1 text-xs tracking-wide-caps ${style}`}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`h-1.5 w-1.5 rounded-full bg-current ${
|
||||||
|
ORDER_STATUS_IN_PROGRESS.has(status) ? "motion-pulse animate-[wh-pulse_2s_ease-out_infinite]" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -128,18 +155,34 @@ export function OrderStatusTimeline({
|
|||||||
return (
|
return (
|
||||||
<div key={step.key} role="listitem" className="flex-1 min-w-0 relative">
|
<div key={step.key} role="listitem" className="flex-1 min-w-0 relative">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span
|
<svg
|
||||||
className={`flex-shrink-0 w-2.5 h-2.5 rounded-full ${isDone ? "bg-forest" : "bg-ink/20"}`}
|
viewBox="0 0 24 26"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
className={`h-3.5 w-3.5 flex-shrink-0 rounded-full ${
|
||||||
|
i === currentIndex && i < steps.length - 1 ? "motion-pulse animate-[wh-pulse_2.2s_ease-out_infinite]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||||
|
fill={isDone ? "var(--color-honey)" : "var(--color-background)"}
|
||||||
|
stroke={isDone ? "var(--color-honey-dark)" : "color-mix(in oklab, var(--color-ink) 25%, transparent)"}
|
||||||
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
|
</svg>
|
||||||
{i < steps.length - 1 ? (
|
{i < steps.length - 1 ? (
|
||||||
|
<span className="relative ml-1 h-[2px] flex-1 overflow-hidden rounded-full bg-ink/10" aria-hidden="true">
|
||||||
|
{isLineDone ? (
|
||||||
|
// Erledigte Abschnitte fliessen nacheinander voll — der
|
||||||
|
// Fortschritt wird beim Oeffnen der Seite nachvollzogen.
|
||||||
<span
|
<span
|
||||||
className={`ml-1 h-px flex-1 ${isLineDone ? "bg-forest" : "bg-ink/15"}`}
|
className="motion-grow absolute inset-0 origin-left bg-honey animate-[wh-grow_0.55s_var(--ease-arrive)_both]"
|
||||||
aria-hidden="true"
|
style={{ animationDelay: `${150 + i * 140}ms` }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-forest" : "text-ink/45"}`}>
|
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-forest" : "text-ink/55"}`}>
|
||||||
{step.label}
|
{step.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,11 +205,20 @@ export function AccountEmptyState({
|
|||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-10 border border-dashed border-ink/15 px-8 py-14 text-center">
|
<div className="mt-10 border border-dashed border-ink/15 px-8 py-16 text-center">
|
||||||
<span className="inline-flex items-center justify-center w-12 h-12 border border-ink/15 text-ink/40 mx-auto">
|
<span className="relative mx-auto flex h-16 w-14 items-center justify-center text-honey-dark">
|
||||||
{icon}
|
<svg viewBox="0 0 24 26" aria-hidden="true" className="absolute inset-0 h-full w-full text-honey">
|
||||||
|
<polygon
|
||||||
|
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.8"
|
||||||
|
strokeDasharray="2 1.6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="relative">{icon}</span>
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-4 text-forest">{title}</p>
|
<p className="mt-5 font-serif text-2xl text-forest">{title}</p>
|
||||||
{description ? (
|
{description ? (
|
||||||
<p className="mt-1 text-sm text-ink/70 max-w-sm mx-auto">{description}</p>
|
<p className="mt-1 text-sm text-ink/70 max-w-sm mx-auto">{description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in new issue
Block a user