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.
223 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|