feat(ui): Sendungsverfolgung im Wabenstil
- 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]>
This commit is contained in:
1 parent
ee854bd77b
commit
baebf78ee4
3 files changed
+52
-15
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||||
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||||
import { getOrderForTracking } from "@/lib/account-data";
|
import { getOrderForTracking } from "@/lib/account-data";
|
||||||
import { formatPrice, formatDate } from "@/lib/format";
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
@@ -52,7 +52,8 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
|||||||
<SectionHeading eyebrow={t.trackOrderPage.eyebrow} title={t.trackOrderPage.title} />
|
<SectionHeading eyebrow={t.trackOrderPage.eyebrow} title={t.trackOrderPage.title} />
|
||||||
<p className="mt-4 text-ink/70 leading-relaxed">{t.trackOrderPage.intro}</p>
|
<p className="mt-4 text-ink/70 leading-relaxed">{t.trackOrderPage.intro}</p>
|
||||||
|
|
||||||
<form method="GET" className="mt-8 space-y-4">
|
<FormPanel className="mt-8">
|
||||||
|
<form method="GET" className="space-y-4">
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
<span className="text-ink/70">{t.trackOrderPage.orderNumberLabel}</span>
|
<span className="text-ink/70">{t.trackOrderPage.orderNumberLabel}</span>
|
||||||
<input
|
<input
|
||||||
@@ -77,6 +78,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
|||||||
</label>
|
</label>
|
||||||
<Button type="submit">{t.trackOrderPage.submit}</Button>
|
<Button type="submit">{t.trackOrderPage.submit}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</FormPanel>
|
||||||
|
|
||||||
{rateLimited ? (
|
{rateLimited ? (
|
||||||
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||||
@@ -91,7 +93,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{order ? (
|
{order ? (
|
||||||
<div className="mt-10 border border-ink/10 p-6">
|
<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 className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-forest">{order.orderNumber}</p>
|
<p className="text-forest">{order.orderNumber}</p>
|
||||||
|
|||||||
@@ -283,3 +283,21 @@ textarea::placeholder {
|
|||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Listeneintraege erscheinen gestaffelt (Delay per style am Element). */
|
||||||
|
@keyframes wh-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.motion-rise {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,13 @@ export interface ShipmentTrackingHistoryLabels {
|
|||||||
statuses: Record<DhlTrackingStatus, string>;
|
statuses: Record<DhlTrackingStatus, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_DOT: Record<DhlTrackingStatus, string> = {
|
// Ausgeschrieben: Tailwind erzeugt nur Klassen, die woertlich im Quelltext stehen.
|
||||||
"pre-transit": "bg-ink/30",
|
const STATUS_FILL: Record<DhlTrackingStatus, string> = {
|
||||||
transit: "bg-honey-dark",
|
"pre-transit": "fill-ink/30",
|
||||||
delivered: "bg-forest",
|
transit: "fill-honey-dark",
|
||||||
failure: "bg-red-500",
|
delivered: "fill-forest",
|
||||||
unknown: "bg-ink/30",
|
failure: "fill-red-500",
|
||||||
|
unknown: "fill-ink/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
|
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
|
||||||
@@ -74,15 +75,31 @@ export function ShipmentTrackingHistory({
|
|||||||
{info.events.length > 0 ? (
|
{info.events.length > 0 ? (
|
||||||
<ol className="mt-4">
|
<ol className="mt-4">
|
||||||
{info.events.map((event, i) => (
|
{info.events.map((event, i) => (
|
||||||
<li key={`${event.timestamp.getTime()}-${i}`} className="relative pl-5 pb-4 last:pb-0">
|
<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 ? (
|
{i < info.events.length - 1 ? (
|
||||||
<span className="absolute left-[4px] top-3 bottom-0 w-px bg-ink/15" aria-hidden="true" />
|
<span className="absolute left-[6px] top-4 bottom-0 w-px bg-ink/15" aria-hidden="true" />
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
{/* Neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange
|
||||||
className={`absolute left-0 top-1.5 w-2.5 h-2.5 rounded-full ${i === 0 ? STATUS_DOT[event.status] : "bg-ink/20"}`}
|
das Paket unterwegs ist), aeltere als leere Waben. */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 26"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
className={`absolute left-0 top-1 h-3.5 w-3.5 rounded-full ${
|
||||||
<p className={`text-sm ${i === 0 ? "text-ink" : "text-ink/70"}`}>{event.description}</p>
|
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">
|
<p className="mt-0.5 text-xs text-ink/55">
|
||||||
<time dateTime={event.timestamp.toISOString()}>{formatDateTime(event.timestamp, locale)}</time>
|
<time dateTime={event.timestamp.toISOString()}>{formatDateTime(event.timestamp, locale)}</time>
|
||||||
{event.location ? ` · ${event.location}` : null}
|
{event.location ? ` · ${event.location}` : null}
|
||||||
|
|||||||
Reference in new issue
Block a user