- DHL-Verlauf: neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange unterwegs), aeltere als leere Waben, Eintraege erscheinen gestaffelt - Bestellung verfolgen: Suchformular in der Formular-Karte, Ergebnis als abgehobene Karte Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
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>;
|
|
}
|
|
|
|
// Ausgeschrieben: Tailwind erzeugt nur Klassen, die woertlich im Quelltext stehen.
|
|
const STATUS_FILL: Record<DhlTrackingStatus, string> = {
|
|
"pre-transit": "fill-ink/30",
|
|
transit: "fill-honey-dark",
|
|
delivered: "fill-forest",
|
|
failure: "fill-red-500",
|
|
unknown: "fill-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="motion-rise relative pl-7 pb-5 last:pb-0 animate-[wh-rise_0.45s_var(--ease-arrive)_both]"
|
|
style={{ animationDelay: `${Math.min(i, 8) * 70}ms` }}
|
|
>
|
|
{i < info.events.length - 1 ? (
|
|
<span className="absolute left-[6px] top-4 bottom-0 w-px bg-ink/15" aria-hidden="true" />
|
|
) : null}
|
|
{/* Neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange
|
|
das Paket unterwegs ist), aeltere als leere Waben. */}
|
|
<svg
|
|
viewBox="0 0 24 26"
|
|
aria-hidden="true"
|
|
className={`absolute left-0 top-1 h-3.5 w-3.5 rounded-full ${
|
|
i === 0 && event.status === "transit" ? "motion-pulse animate-[wh-pulse_2.2s_ease-out_infinite]" : ""
|
|
}`}
|
|
>
|
|
<polygon
|
|
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
|
className={i === 0 ? STATUS_FILL[event.status] : "fill-background"}
|
|
stroke={i === 0 ? "none" : "color-mix(in oklab, var(--color-ink) 30%, transparent)"}
|
|
strokeWidth="2"
|
|
/>
|
|
</svg>
|
|
<p className={`text-sm ${i === 0 ? "font-serif text-lg text-forest" : "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>
|
|
);
|
|
}
|