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,227 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { updateOrderStatusAction, updateShipmentTrackingAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { id } = await props.params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
|
||||
payment: true,
|
||||
shipment: true,
|
||||
pickup: true,
|
||||
invoice: true,
|
||||
customer: { select: { email: true, firstName: true, lastName: true } },
|
||||
discountCode: { select: { code: true } },
|
||||
},
|
||||
});
|
||||
if (!order) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl text-forest">{order.orderNumber}</h1>
|
||||
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<form action={updateOrderStatusAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={order.status}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminOrderDetailPage.saveStatus}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{order.giftMessage ? (
|
||||
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
|
||||
<p className="whitespace-pre-wrap text-ink">{order.giftMessage}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.customerHeading}</h2>
|
||||
<p>{order.firstName} {order.lastName}</p>
|
||||
<p className="text-ink/70">{order.email}</p>
|
||||
{order.phone ? <p className="text-ink/70">{order.phone}</p> : null}
|
||||
{order.customer ? (
|
||||
<p className="mt-2 text-xs text-honey-dark">{t.adminOrderDetailPage.registeredAccount}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-ink/65">{t.adminOrderDetailPage.guestOrder}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">
|
||||
{order.fulfillmentMethod === "VERSAND"
|
||||
? t.adminOrderDetailPage.shippingAddressHeading
|
||||
: t.adminOrderDetailPage.pickupHeading}
|
||||
</h2>
|
||||
{order.fulfillmentMethod === "VERSAND" ? (
|
||||
<>
|
||||
<p>{order.street}</p>
|
||||
<p>{order.zip} {order.city}</p>
|
||||
<p className="mt-2 text-xs text-ink/65">
|
||||
{order.shipment?.shippedAt
|
||||
? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||
: t.adminOrderDetailPage.notShippedYet}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-ink/65">
|
||||
{order.pickup?.pickedUpAt
|
||||
? t.adminOrderDetailPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||
: order.pickup?.readyAt
|
||||
? t.adminOrderDetailPage.readySince(formatDate(order.pickup.readyAt))
|
||||
: t.adminOrderDetailPage.notPickedUpYet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.fulfillmentMethod === "VERSAND" ? (
|
||||
<div className="bg-background border border-ink/10 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.trackingHeading}</h2>
|
||||
<form action={updateShipmentTrackingAction} className="flex flex-wrap items-end gap-4">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminOrderDetailPage.carrierLabel}</span>
|
||||
<input
|
||||
name="carrier"
|
||||
defaultValue={order.shipment?.carrier ?? ""}
|
||||
placeholder={t.adminOrderDetailPage.carrierPlaceholder}
|
||||
className="mt-1 w-48 border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminOrderDetailPage.trackingNumberLabel}</span>
|
||||
<input
|
||||
name="trackingNumber"
|
||||
defaultValue={order.shipment?.trackingNumber ?? ""}
|
||||
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminOrderDetailPage.saveTracking}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.itemsHeading}</h2>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||
<tr>
|
||||
<th className="pb-2">{t.adminOrderDetailPage.colProduct}</th>
|
||||
<th className="pb-2">{t.adminOrderDetailPage.colBatch}</th>
|
||||
<th className="pb-2 text-right">{t.adminOrderDetailPage.colQuantity}</th>
|
||||
<th className="pb-2 text-right">{t.adminOrderDetailPage.colTotal}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{order.items.map((item) => (
|
||||
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="py-2">
|
||||
<Link href={`/admin/produkte/${item.productId}`} className="text-forest underline">
|
||||
{item.productName}
|
||||
</Link>{" "}
|
||||
<span className="text-ink/65">({item.weightGrams} g)</span>
|
||||
</td>
|
||||
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2 text-right">{formatPrice(item.totalCents, order.currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className="w-56 space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.adminOrderDetailPage.subtotalLabel}</span>
|
||||
<span>{formatPrice(order.subtotalCents, order.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.adminOrderDetailPage.shippingLabel}</span>
|
||||
<span>
|
||||
{order.shippingCents === 0
|
||||
? t.adminOrderDetailPage.freeShipping
|
||||
: formatPrice(order.shippingCents, order.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{order.discountCents > 0 ? (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">
|
||||
{t.adminOrderDetailPage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""}
|
||||
</span>
|
||||
<span>−{formatPrice(order.discountCents, order.currency)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between text-forest pt-1 border-t border-ink/10">
|
||||
<span>{t.adminOrderDetailPage.totalLabel}</span>
|
||||
<span>{formatPrice(order.totalCents, order.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.paymentHeading}</h2>
|
||||
{order.payment ? (
|
||||
<>
|
||||
<p>{order.payment.status}</p>
|
||||
<p className="text-ink/70">{formatPrice(order.payment.amountCents, order.payment.currency)}</p>
|
||||
{order.payment.paidAt ? (
|
||||
<p className="text-xs text-ink/65 mt-1">
|
||||
{t.adminOrderDetailPage.paidOn(formatDate(order.payment.paidAt))}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-ink/65">{t.adminOrderDetailPage.noPaymentYet}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.invoiceHeading}</h2>
|
||||
{order.invoice ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{order.invoice.invoiceNumber}</span>
|
||||
<a
|
||||
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
|
||||
className="text-forest underline text-sm"
|
||||
>
|
||||
{t.adminOrderDetailPage.downloadPdf}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { OrderStatusBadge } from "@/components/account-ui";
|
||||
import { updateOrderStatusAction } from "../actions";
|
||||
import type { OrderStatus } from "@/generated/prisma/client";
|
||||
import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { q, status } = await props.searchParams;
|
||||
const query = typeof q === "string" ? q.trim() : "";
|
||||
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
||||
|
||||
const orders = await prisma.order.findMany({
|
||||
where: {
|
||||
...(statusFilter ? { status: statusFilter } : {}),
|
||||
...(query
|
||||
? {
|
||||
OR: [
|
||||
{ orderNumber: insensitiveContains(query) },
|
||||
{ email: insensitiveContains(query) },
|
||||
{ lastName: insensitiveContains(query) },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
|
||||
|
||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||
<label className="text-sm">
|
||||
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
placeholder={t.adminOrdersPage.searchPlaceholder}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={statusFilter ?? ""}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{t.adminOrdersPage.statusAll}</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminOrdersPage.filterButton}
|
||||
</button>
|
||||
{query || statusFilter ? (
|
||||
<Link href="/admin/bestellungen" className="text-sm text-ink/65 underline">
|
||||
{t.adminOrdersPage.resetFilter}
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||
<tr>
|
||||
<th className="p-4">{t.adminOrdersPage.colNumber}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colCustomer}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colDate}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colItems}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colTotal}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colStatus}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">
|
||||
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
|
||||
{order.orderNumber}
|
||||
</Link>
|
||||
{order.giftMessage ? (
|
||||
<span className="ml-1.5" title={t.adminOrdersPage.giftOrderTitle}>
|
||||
🎁
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{order.firstName} {order.lastName}
|
||||
<br />
|
||||
<span className="text-ink/65 text-xs">{order.email}</span>
|
||||
</td>
|
||||
<td className="p-4">{formatDate(order.createdAt)}</td>
|
||||
<td className="p-4">{order.items.length}</td>
|
||||
<td className="p-4">{formatPrice(order.totalCents, order.currency)}</td>
|
||||
<td className="p-4">
|
||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={order.status}
|
||||
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="text-xs text-forest underline">
|
||||
{t.adminOrdersPage.save}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 ? (
|
||||
<p className="p-6 text-ink/65">
|
||||
{query || statusFilter ? t.adminOrdersPage.noResultsFiltered : t.adminOrdersPage.noOrdersYet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user