Files
Wabenhain/src/components/account-ui.tsx
T
Nick SzaunigandClaude Opus 5.5 6950657c91 feat(ui): Dunkelmodus fuer Shop und Admin mit Umschalter
Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem
Satz Klassen entstehen:
- heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme
- surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen
- forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe,
  Admin-Leiste), cream der helle Text darauf
- honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird
  im Dunkelmodus heller und bleibt lesbar
Codemod ueber src: text-/border-forest -> *-heading, bg-cream ->
bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die
Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich.

Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in
Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme"
(nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme
schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der
Systemeinstellung. Gewaehlter und automatischer Dunkelblock in
globals.css werden per Test auf Gleichheit geprueft.

Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile
steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt
neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst
ab 1680 px.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:12:44 +02:00

275 lines
9.5 KiB
TypeScript

import Link from "next/link";
import type { ReactNode } from "react";
/**
* Verlinkte Karte fuer die Konto-Uebersicht (Bestellungen, Adressen, ...).
* Ersetzt den zuvor identischen, icon-losen "border + hover:border-heading"-
* Kartenstil, der auf jeder Konto-Seite gleich aussah.
*/
export function AccountNavCard({
href,
icon,
title,
description,
accent = false,
className = "",
}: {
href: string;
icon: ReactNode;
title: string;
description: string;
accent?: boolean;
className?: string;
}) {
return (
<Link
href={href}
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
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_22px_40px_-24px_rgba(184,138,68,0.45)]"
: "border-ink/10 bg-background hover:border-heading/60 hover:shadow-[0_22px_40px_-24px_rgba(36,53,43,0.35)]"
} ${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
aria-hidden="true"
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-heading"
}`}
>
{icon}
</span>
</span>
<span className="flex-1 pt-1">
<span className="block font-serif text-2xl leading-tight text-heading">{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-heading 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>
);
}
const ORDER_STATUS_STYLES: Record<string, string> = {
NEU: "border-ink/20 text-ink/70",
BEZAHLT: "border-honey/40 text-honey-dark bg-honey/5",
IN_VORBEREITUNG: "border-honey/40 text-honey-dark bg-honey/5",
VERSANDBEREIT: "border-honey/40 text-honey-dark bg-honey/5",
VERSENDET: "border-heading/40 text-heading bg-forest/5",
ABHOLBEREIT: "border-heading/40 text-heading bg-forest/5",
ABGEHOLT: "border-heading/40 text-heading bg-forest/5",
STORNIERT: "border-red-200 text-red-700 bg-red-50",
};
/** 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 }) {
const style = ORDER_STATUS_STYLES[status] ?? "border-ink/20 text-ink/70";
return (
<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}
</span>
);
}
const VERSAND_STEP_KEYS = ["NEU", "BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "VERSENDET"] as const;
const ABHOLUNG_STEP_KEYS = ["NEU", "BEZAHLT", "IN_VORBEREITUNG", "ABHOLBEREIT", "ABGEHOLT"] as const;
export interface OrderStatusTimelineLabels {
ordered: string;
paid: string;
preparing: string;
readyToShip: string;
shipped: string;
readyForPickup: string;
pickedUp: string;
cancelled: string;
ariaLabel: string;
}
/**
* Visuelle Fortschrittsanzeige statt reinem Status-Badge — nimmt das
* Punkt/Linie-Muster der Chargen-Seite (app/charge/[code]) auf, damit
* Kundschaft auf einen Blick sieht, wie weit die Bestellung bereits ist,
* statt nur den aktuellen Einzelstatus als Wort zu lesen. Nimmt uebersetzte
* Labels als Prop entgegen (statt selbst zu uebersetzen), damit diese Datei
* server-sicher bleibt — sie wird auch von Admin-Server-Seiten importiert.
*/
export function OrderStatusTimeline({
status,
fulfillmentMethod,
labels,
}: {
status: string;
fulfillmentMethod: "VERSAND" | "ABHOLUNG";
labels: OrderStatusTimelineLabels;
}) {
if (status === "STORNIERT") {
return (
<div className="flex items-center gap-2 text-sm text-red-700">
<span className="w-2 h-2 rounded-full bg-red-500" aria-hidden="true" />
{labels.cancelled}
</div>
);
}
const stepLabels: Record<(typeof VERSAND_STEP_KEYS)[number] | (typeof ABHOLUNG_STEP_KEYS)[number], string> = {
NEU: labels.ordered,
BEZAHLT: labels.paid,
IN_VORBEREITUNG: labels.preparing,
VERSANDBEREIT: labels.readyToShip,
VERSENDET: labels.shipped,
ABHOLBEREIT: labels.readyForPickup,
ABGEHOLT: labels.pickedUp,
};
const stepKeys = fulfillmentMethod === "VERSAND" ? VERSAND_STEP_KEYS : ABHOLUNG_STEP_KEYS;
const steps = stepKeys.map((key) => ({ key, label: stepLabels[key] }));
const currentIndex = steps.findIndex((step) => step.key === status);
return (
<div className="flex items-start gap-x-1" role="list" aria-label={labels.ariaLabel}>
{steps.map((step, i) => {
const isDone = currentIndex >= 0 && i <= currentIndex;
const isLineDone = i < currentIndex;
return (
<div key={step.key} role="listitem" className="flex-1 min-w-0 relative">
<div className="flex items-center">
<svg
viewBox="0 0 24 26"
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 ? (
<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
className="motion-grow absolute inset-0 origin-left bg-honey animate-[wh-grow_0.55s_var(--ease-arrive)_both]"
style={{ animationDelay: `${150 + i * 140}ms` }}
/>
) : null}
</span>
) : null}
</div>
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-heading" : "text-ink/55"}`}>
{step.label}
</p>
</div>
);
})}
</div>
);
}
/** Leerer-Zustand mit Icon statt eines einzelnen, unauffälligen Fließtext-Satzes. */
export function AccountEmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="mt-10 border border-dashed border-ink/15 px-8 py-16 text-center">
<span className="relative mx-auto flex h-16 w-14 items-center justify-center text-honey-dark">
<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>
<p className="mt-5 font-serif text-2xl text-heading">{title}</p>
{description ? (
<p className="mt-1 text-sm text-ink/70 max-w-sm mx-auto">{description}</p>
) : null}
{action ? <div className="mt-6">{action}</div> : null}
</div>
);
}
const ICON_PROPS = {
width: 20,
height: 20,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
export function OrderIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M4 8l8-4 8 4-8 4-8-4z" />
<path d="M4 8v8l8 4 8-4V8" />
<path d="M12 12v8" />
</svg>
);
}
export function AddressIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 21s7-7.5 7-12a7 7 0 1 0-14 0c0 4.5 7 12 7 12z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
);
}
export function HeartIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 20s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 5c-2.5 4.5-9.5 9-9.5 9z" />
</svg>
);
}
export function ShieldIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3z" />
</svg>
);
}