feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen
/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an, statt Kundschaft auf dhl.de weiterzuleiten. - lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout, Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min) - Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail, damit die Seite nicht als offener Proxy zu DHL dient - Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link) - Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt - Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
2659db64bd
commit
01eaca39dc
10 files changed
+674
-5
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import type { DhlTrackingResult, DhlTrackingStatus } from "@/lib/dhl-tracking";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
|
||||
export interface ShipmentTrackingHistoryLabels {
|
||||
heading: string;
|
||||
ariaLabel: string;
|
||||
estimatedDelivery: (date: string) => string;
|
||||
notYetScanned: string;
|
||||
unavailable: string;
|
||||
dataSource: string;
|
||||
statuses: Record<DhlTrackingStatus, string>;
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<DhlTrackingStatus, string> = {
|
||||
"pre-transit": "bg-ink/30",
|
||||
transit: "bg-honey-dark",
|
||||
delivered: "bg-forest",
|
||||
failure: "bg-red-500",
|
||||
unknown: "bg-ink/30",
|
||||
};
|
||||
|
||||
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
|
||||
"pre-transit": "text-ink/70",
|
||||
transit: "text-honey-dark",
|
||||
delivered: "text-forest",
|
||||
failure: "text-red-700",
|
||||
unknown: "text-ink/70",
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeigt den DHL-Sendungsverlauf im eigenen Layout, damit Kundschaft nicht
|
||||
* auf dhl.de wechseln muss. Rendert fuer "disabled" nichts — solange kein
|
||||
* Tracking-Key hinterlegt ist, bleibt die Seite exakt wie bisher (Nummer +
|
||||
* Link). Alle Texte aus der DHL-Antwort landen ausschliesslich als
|
||||
* React-Textknoten im DOM, nie als HTML.
|
||||
*/
|
||||
export function ShipmentTrackingHistory({
|
||||
result,
|
||||
locale,
|
||||
labels,
|
||||
}: {
|
||||
result: DhlTrackingResult;
|
||||
locale: Locale;
|
||||
labels: ShipmentTrackingHistoryLabels;
|
||||
}) {
|
||||
if (result.kind === "disabled") return null;
|
||||
|
||||
if (result.kind !== "ok") {
|
||||
return (
|
||||
<section className="mt-5 border-t border-ink/10 pt-5">
|
||||
<h3 className="text-sm text-forest">{labels.heading}</h3>
|
||||
<p className="mt-2 text-xs text-ink/65 leading-relaxed">
|
||||
{result.kind === "not-found" ? labels.notYetScanned : labels.unavailable}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { info } = result;
|
||||
return (
|
||||
<section className="mt-5 border-t border-ink/10 pt-5" aria-label={labels.ariaLabel}>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm text-forest">{labels.heading}</h3>
|
||||
<span className={`text-xs ${STATUS_TEXT[info.status]}`}>{labels.statuses[info.status]}</span>
|
||||
</div>
|
||||
|
||||
{info.estimatedDelivery && info.status !== "delivered" ? (
|
||||
<p className="mt-1 text-xs text-ink/65">
|
||||
{labels.estimatedDelivery(formatDate(info.estimatedDelivery, locale))}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{info.events.length > 0 ? (
|
||||
<ol className="mt-4">
|
||||
{info.events.map((event, i) => (
|
||||
<li key={`${event.timestamp.getTime()}-${i}`} className="relative pl-5 pb-4 last:pb-0">
|
||||
{i < info.events.length - 1 ? (
|
||||
<span className="absolute left-[4px] top-3 bottom-0 w-px bg-ink/15" aria-hidden="true" />
|
||||
) : null}
|
||||
<span
|
||||
className={`absolute left-0 top-1.5 w-2.5 h-2.5 rounded-full ${i === 0 ? STATUS_DOT[event.status] : "bg-ink/20"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className={`text-sm ${i === 0 ? "text-ink" : "text-ink/70"}`}>{event.description}</p>
|
||||
<p className="mt-0.5 text-xs text-ink/55">
|
||||
<time dateTime={event.timestamp.toISOString()}>{formatDateTime(event.timestamp, locale)}</time>
|
||||
{event.location ? ` · ${event.location}` : null}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-ink/65">{labels.notYetScanned}</p>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-[11px] text-ink/45">{labels.dataSource}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user