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.
This commit is contained in:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -0,0 +1,179 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ReorderButton } from "@/components/reorder-button";
|
||||
import { AccountEmptyState, OrderStatusBadge, OrderStatusTimeline, OrderIcon } from "@/components/account-ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getOrdersForCustomer } from "@/lib/account-data";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Bestellungen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Deckt sich mit RETURN_ELIGIBLE_STATUSES in bestellungen/actions.ts.
|
||||
const RETURN_ELIGIBLE_STATUSES = [
|
||||
"BEZAHLT",
|
||||
"IN_VORBEREITUNG",
|
||||
"VERSANDBEREIT",
|
||||
"VERSENDET",
|
||||
"ABHOLBEREIT",
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const orders = await getOrdersForCustomer(customer.id);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.ordersPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<AccountEmptyState
|
||||
icon={<OrderIcon />}
|
||||
title={t.ordersPage.emptyTitle}
|
||||
description={t.ordersPage.emptyDescription}
|
||||
action={<LinkButton href="/shop">{t.cartPage.goToShop}</LinkButton>}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-10 space-y-6">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="border border-ink/10 p-6 transition-colors hover:border-ink/25"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-forest">{order.orderNumber}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||
<p className="mt-1.5 text-xs text-ink/65">
|
||||
{order.fulfillmentMethod === "VERSAND"
|
||||
? order.shipment?.shippedAt
|
||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||
: t.ordersPage.shippingPending
|
||||
: order.pickup?.pickedUpAt
|
||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||
: order.pickup?.readyAt
|
||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
||||
: t.ordersPage.pickupPending}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-5 max-w-md">
|
||||
<OrderStatusTimeline
|
||||
status={order.status}
|
||||
fulfillmentMethod={order.fulfillmentMethod}
|
||||
labels={{
|
||||
ordered: t.orderStatusTimeline.ordered,
|
||||
paid: t.orderStatusTimeline.paid,
|
||||
preparing: t.orderStatusTimeline.preparing,
|
||||
readyToShip: t.orderStatusTimeline.readyToShip,
|
||||
shipped: t.orderStatusTimeline.shipped,
|
||||
readyForPickup: t.orderStatusTimeline.readyForPickup,
|
||||
pickedUp: t.orderStatusTimeline.pickedUp,
|
||||
cancelled: t.orderStatusTimeline.cancelled,
|
||||
ariaLabel: t.orderStatusTimeline.ariaLabel,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{order.shipment?.carrier && order.shipment.trackingNumber ? (
|
||||
<p className="mt-3 text-xs text-ink/65">
|
||||
{t.shipmentInfo.carrierLabel}: {order.shipment.carrier} · {t.shipmentInfo.trackingNumberLabel}:{" "}
|
||||
{order.shipment.trackingNumber}
|
||||
{(() => {
|
||||
const url = getCarrierTrackingUrl(order.shipment.carrier, order.shipment.trackingNumber);
|
||||
return url ? (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-forest underline">
|
||||
{t.shipmentInfo.trackPackage}
|
||||
</a>
|
||||
</>
|
||||
) : null;
|
||||
})()}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{order.giftMessage ? (
|
||||
<p className="mt-3 text-xs text-ink/65 bg-honey/10 border border-honey-dark/20 px-3 py-2">
|
||||
🎁 {t.ordersPage.giftMessagePrefix} „{order.giftMessage}“
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{order.invoice ? (
|
||||
<Link
|
||||
href={`/rechnung/${order.invoice.invoiceNumber}`}
|
||||
className="text-sm text-forest underline"
|
||||
>
|
||||
{t.ordersPage.viewInvoice}
|
||||
</Link>
|
||||
) : null}
|
||||
{order.returnRequest ? (
|
||||
<span className="text-sm text-ink/65">
|
||||
{returnStatusLabels[order.returnRequest.status] ?? order.returnRequest.status}
|
||||
</span>
|
||||
) : RETURN_ELIGIBLE_STATUSES.includes(order.status) ? (
|
||||
<Link
|
||||
href={`/konto/bestellungen/${order.id}/retoure`}
|
||||
className="text-sm text-forest underline"
|
||||
>
|
||||
{t.ordersPage.requestReturn}
|
||||
</Link>
|
||||
) : null}
|
||||
<ReorderButton
|
||||
items={order.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
slug: item.product.slug,
|
||||
name: item.productName,
|
||||
weightGrams: item.weightGrams,
|
||||
priceCents: item.unitPriceCents,
|
||||
quantity: item.quantity,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user