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:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container, SectionHeading, Button } from "@/components/ui";
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
import { getOrderForTracking } from "@/lib/account-data";
import { formatPrice, formatDate } from "@/lib/format";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Bestellung verfolgen", robots: { index: false } };
export const dynamic = "force-dynamic";
const TRACK_ATTEMPTS_PER_WINDOW = 10;
const TRACK_WINDOW_MS = 15 * 60 * 1000;
export default async function TrackOrderPage(props: PageProps<"/bestellung-verfolgen">) {
const t = getDictionary(await getLocale());
const { order: orderNumber, email } = await props.searchParams;
const hasQuery = typeof orderNumber === "string" && typeof email === "string" && orderNumber && email;
let order = null;
let rateLimited = false;
if (hasQuery) {
const ip = await getClientIp();
if (rateLimit(`track-order:${ip}`, TRACK_ATTEMPTS_PER_WINDOW, TRACK_WINDOW_MS)) {
order = await getOrderForTracking(orderNumber, email);
} else {
rateLimited = true;
}
}
return (
<div className="py-20">
<Container className="max-w-xl">
<SectionHeading eyebrow={t.trackOrderPage.eyebrow} title={t.trackOrderPage.title} />
<p className="mt-4 text-ink/70 leading-relaxed">{t.trackOrderPage.intro}</p>
<form method="GET" className="mt-8 space-y-4">
<label className="block text-sm">
<span className="text-ink/70">{t.trackOrderPage.orderNumberLabel}</span>
<input
name="order"
type="text"
defaultValue={typeof orderNumber === "string" ? orderNumber : ""}
placeholder={t.trackOrderPage.orderNumberPlaceholder}
required
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.account.emailLabel}</span>
<input
name="email"
type="email"
defaultValue={typeof email === "string" ? email : ""}
required
autoComplete="email"
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
/>
</label>
<Button type="submit">{t.trackOrderPage.submit}</Button>
</form>
{rateLimited ? (
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.trackOrderPage.rateLimitError}
</p>
) : null}
{hasQuery && !order && !rateLimited ? (
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.trackOrderPage.notFoundError}
</p>
) : null}
{order ? (
<div className="mt-10 border border-ink/10 p-6">
<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={t.orderStatusBadge[order.status as keyof typeof t.orderStatusBadge] ?? 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">
<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}
<div className="mt-5 flex items-center justify-between gap-4">
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p>
{order.invoice ? (
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
{t.trackOrderPage.viewInvoice}
</Link>
) : null}
</div>
</div>
) : null}
</Container>
</div>
);
}