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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user