Files
Wabenhain/src/components/account-ui.tsx
T
Nick Szaunig c7927f2883 feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
2026-08-17 14:30:28 +02:00

223 lines
6.8 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-forest"-
* 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 flex items-start gap-4 border p-6 transition-all duration-300 hover:-translate-y-0.5 ${
accent
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_16px_32px_-20px_rgba(184,138,68,0.35)]"
: "border-ink/10 hover:border-forest hover:shadow-[0_16px_32px_-20px_rgba(36,53,43,0.25)]"
} ${className}`}
>
<span
className={`flex-shrink-0 flex items-center justify-center w-11 h-11 border transition-colors ${
accent ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
}`}
>
{icon}
</span>
<span>
<span className="block text-lg text-forest">{title}</span>
<span className="block mt-1 text-sm text-ink/70">{description}</span>
</span>
</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-forest/40 text-forest bg-forest/5",
ABHOLBEREIT: "border-forest/40 text-forest bg-forest/5",
ABGEHOLT: "border-forest/40 text-forest 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. */
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 border px-2.5 py-1 text-xs tracking-wide-caps ${style}`}>
{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">
<span
className={`flex-shrink-0 w-2.5 h-2.5 rounded-full ${isDone ? "bg-forest" : "bg-ink/20"}`}
aria-hidden="true"
/>
{i < steps.length - 1 ? (
<span
className={`ml-1 h-px flex-1 ${isLineDone ? "bg-forest" : "bg-ink/15"}`}
aria-hidden="true"
/>
) : null}
</div>
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-forest" : "text-ink/45"}`}>
{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-14 text-center">
<span className="inline-flex items-center justify-center w-12 h-12 border border-ink/15 text-ink/40 mx-auto">
{icon}
</span>
<p className="mt-4 text-forest">{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>
);
}