Jede zweite Ueberschrift trug eine kleine Versalien-Zeile mit Wabenpunkt
("• STIMMEN", "• AKTUELL VERFUEGBAR", "• KUNDENKONTO" ...). Auf 29 Seiten
und Abschnitten wiederholt las sich das wie eine Vorlage. SectionHeading
rendert sie nicht mehr, die Prop ist entfernt; die Ueberschrift traegt den
Abschnitt allein.
Die eyebrow-Texte, die weiterhin als Seitentitel, Bildbeschriftung oder
Breadcrumb dienen, bleiben; 18 danach ungenutzte Eintraege sind aus Typ und
beiden Woerterbuechern entfernt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
197 lines
8.3 KiB
TypeScript
197 lines
8.3 KiB
TypeScript
import type { Metadata } from "next";
|
||
import Link from "next/link";
|
||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||
import { getOrderForTracking } from "@/lib/account-data";
|
||
import { formatPrice, formatDate } from "@/lib/format";
|
||
import { getCarrierTrackingUrl, isDhlCarrier } from "@/lib/shipment-tracking";
|
||
import { getDhlTracking, type DhlTrackingResult } from "@/lib/dhl-tracking";
|
||
import { ShipmentTrackingHistory } from "@/components/shipment-tracking-history";
|
||
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 locale = await getLocale();
|
||
const t = getDictionary(locale);
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Erst NACH erfolgreicher Pruefung von Bestellnummer + E-Mail bei DHL
|
||
// nachfragen: sonst liesse sich diese Seite als offener Proxy fuer beliebige
|
||
// Sendungsabfragen missbrauchen und das DHL-Kontingent leerraeumen.
|
||
let dhlTracking: DhlTrackingResult = { kind: "disabled" };
|
||
if (order?.shipment?.trackingNumber && isDhlCarrier(order.shipment.carrier)) {
|
||
dhlTracking = await getDhlTracking({
|
||
trackingNumber: order.shipment.trackingNumber,
|
||
postalCode: order.zip,
|
||
locale,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="py-20">
|
||
<Container className="max-w-xl">
|
||
<SectionHeading title={t.trackOrderPage.title} />
|
||
<p className="mt-4 text-ink/70 leading-relaxed">{t.trackOrderPage.intro}</p>
|
||
|
||
<FormPanel className="mt-8">
|
||
<form method="GET" className="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>
|
||
</FormPanel>
|
||
|
||
{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 bg-background p-6 shadow-[0_24px_50px_-34px_rgba(36,53,43,0.45)] sm:p-8">
|
||
<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, locale)}</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, locale))
|
||
: t.ordersPage.shippingPending
|
||
: order.pickup?.pickedUpAt
|
||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
|
||
: order.pickup?.readyAt
|
||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
|
||
: 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} ×{" "}
|
||
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-forest hover:underline">
|
||
{item.productName}
|
||
</Link>
|
||
</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}
|
||
|
||
<ShipmentTrackingHistory
|
||
result={dhlTracking}
|
||
locale={locale}
|
||
labels={{
|
||
heading: t.shipmentInfo.historyHeading,
|
||
ariaLabel: t.shipmentInfo.historyAriaLabel,
|
||
estimatedDelivery: t.shipmentInfo.estimatedDelivery,
|
||
notYetScanned: t.shipmentInfo.notYetScanned,
|
||
unavailable: t.shipmentInfo.historyUnavailable,
|
||
dataSource: t.shipmentInfo.dataSource,
|
||
statuses: t.shipmentInfo.statuses,
|
||
}}
|
||
/>
|
||
|
||
<div className="mt-5 flex items-center justify-between gap-4">
|
||
<p className="font-serif text-2xl text-forest tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</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>
|
||
);
|
||
}
|