{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "order-tracking",
  "title": "Order Tracking",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Where the parcel is: a status chip, the arrival window, a vertical timeline of milestones and the carrier's updates. Carrier webhooks arrive out of order and twice, so events are de-duplicated by id and sorted by when they happened — sortTrackingEvents and trackingProgress live in lib/tracking-events.ts, with no \"use client\", so a webhook handler runs the same code. The current node is ringed with a live pulse, passed ones solid ink, those to come hollow grey; a carrier problem turns the chip to Needs attention and says why. Rules fill as the parcel moves and the update log grows to its measured height (Motion; a fade under reduced motion). Loading and empty states included.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "badge",
    "skeleton"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/order-tracking/order-tracking.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpRight, Check, CircleAlert } from \"lucide-react\"\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport { Badge } from \"@/components/ui/badge\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport {\n  DEFAULT_TRACKING_STEPS,\n  sortTrackingEvents,\n  trackingProgress,\n  type TrackingEvent,\n  type TrackingStatus,\n  type TrackingStep,\n} from \"@/lib/tracking-events\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * Where the parcel is: a status chip, when it arrives, a vertical timeline\n * of the shipment's milestones, and the carrier's updates under it.\n *\n *   <OrderTracking events={carrierEvents} carrier={{ name: \"UPS\", trackingNumber, url }} estimate={{ from, to }} />\n *\n * Carrier webhooks arrive out of order and sometimes twice, so the block\n * never trusts arrival order: events are de-duplicated by id and sorted by\n * the time they happened — `sortTrackingEvents` and `trackingProgress` in\n * `@/lib/tracking-events`, a file with no \"use client\", so your webhook\n * handler can run the same code.\n * The latest milestone is the current node — ringed, with a live pulse;\n * milestones before it are solid ink; those to come are hollow grey. State\n * is never colour alone: a check, a ring, words. When the rule between two\n * nodes fills it draws down to the next (Motion; a fade under reduced\n * motion), and the full update log grows to its measured height.\n * Timestamps show in the visitor's own time zone once hydrated; calendar\n * dates (the estimate) in UTC, so they never slip a day. The estimate is two\n * formatted days joined with a dash, not `formatRange`, whose spacing\n * differs between Node's ICU and the browser's.\n */\n\nexport type { TrackingEvent, TrackingMilestone, TrackingStatus, TrackingStep } from \"@/lib/tracking-events\"\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\nconst FADE = { duration: 0.15 } as const\n\nconst subscribeNothing = () => () => {}\n\n/* ─── block ────────────────────────────────────────────────────────────── */\n\nexport interface OrderTrackingProps extends React.HTMLAttributes<HTMLElement> {\n  /** The carrier's updates, in any order, duplicates and all. */\n  events: TrackingEvent[]\n  carrier?: { name: string; trackingNumber?: string; url?: string }\n  /** Calendar dates, \"2026-10-21\": the window it should arrive in. */\n  estimate?: { from: string; to?: string }\n  /** Your milestones, if not the five standard ones. */\n  steps?: TrackingStep[]\n  /** `undefined` follows the visitor's locale, once hydrated. */\n  locale?: string\n  /** How many updates show before \"Show all\". Defaults to 3. */\n  visibleUpdates?: number\n  loading?: boolean\n}\n\nexport function OrderTracking({\n  events,\n  carrier,\n  estimate,\n  steps = DEFAULT_TRACKING_STEPS,\n  locale: localeProp,\n  visibleUpdates = 3,\n  loading = false,\n  className,\n  ...props\n}: OrderTrackingProps) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  const reduce = useReducedMotion()\n  const uid = React.useId()\n  const locale = localeProp ?? (hydrated ? undefined : \"en-US\")\n  const timeZone = hydrated ? undefined : \"UTC\"\n  const [showAll, setShowAll] = React.useState(false)\n\n  const sorted = React.useMemo(() => sortTrackingEvents(events, steps), [events, steps])\n  const progress = React.useMemo(() => trackingProgress(events, steps), [events, steps])\n  const day = React.useMemo(\n    () => new Intl.DateTimeFormat(locale, { weekday: \"short\", month: \"short\", day: \"numeric\", timeZone: \"UTC\" }),\n    [locale]\n  )\n  const stamp = React.useMemo(\n    () => new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\", hour: \"numeric\", minute: \"2-digit\", timeZone }),\n    [locale, timeZone]\n  )\n  const calendar = (iso: string) => new Date(`${iso}T00:00:00Z`)\n  const arrival = estimate\n    ? !estimate.to || estimate.to === estimate.from\n      ? day.format(calendar(estimate.from))\n      : `${day.format(calendar(estimate.from))} – ${day.format(calendar(estimate.to))}`\n    : null\n\n  const currentStep = steps[progress.current]\n  const deliveredAt = progress.reachedAt.delivered\n  const headline: React.ReactNode = progress.delivered ? (\n    deliveredAt ? (\n      <>\n        Delivered{\" \"}\n        <time dateTime={deliveredAt} suppressHydrationWarning>\n          {stamp.format(new Date(deliveredAt))}\n        </time>\n      </>\n    ) : (\n      \"Delivered\"\n    )\n  ) : arrival ? (\n    `Arrives ${arrival}`\n  ) : (\n    (currentStep?.label ?? \"Preparing your order\")\n  )\n  const chip = progress.issue ? \"Needs attention\" : (currentStep?.label ?? \"Not shipped yet\")\n\n  // Newest first in the log.\n  const log = [...sorted].reverse()\n  const shown = showAll ? log : log.slice(0, visibleUpdates)\n  const labelFor = (status: TrackingStatus) =>\n    status === \"exception\" ? \"Update\" : (steps.find((s) => s.status === status)?.label ?? status)\n\n  if (loading) {\n    return (\n      <section data-slot=\"order-tracking\" aria-busy className={cn(\"flex flex-col gap-8\", className)} {...props}>\n        <span className=\"sr-only\" role=\"status\">\n          Loading tracking\n        </span>\n        <div aria-hidden className=\"flex flex-col gap-3\">\n          <Skeleton className=\"h-5 w-24 rounded-none\" />\n          <Skeleton className=\"h-7 w-64 rounded-none\" />\n          <Skeleton className=\"h-3.5 w-48 rounded-none\" />\n        </div>\n        <div aria-hidden className=\"flex flex-col gap-7\">\n          {steps.map((s) => (\n            <div key={s.status} className=\"flex items-center gap-4\">\n              <Skeleton className=\"size-5 rounded-full\" />\n              <Skeleton className=\"h-3.5 w-32 rounded-none\" />\n            </div>\n          ))}\n        </div>\n      </section>\n    )\n  }\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <section data-slot=\"order-tracking\" aria-labelledby={`${uid}-title`} className={cn(\"flex flex-col gap-8\", className)} {...props}>\n        {/* Status, when, and who's carrying it. */}\n        <header data-slot=\"order-tracking-header\" className=\"flex flex-col gap-3\">\n          <Badge\n            variant={progress.issue ? \"destructive\" : progress.delivered ? \"default\" : \"outline\"}\n            className=\"rounded-none px-2 py-0.5 text-[11px] tracking-[0.14em] uppercase\"\n          >\n            {progress.issue ? <CircleAlert aria-hidden /> : progress.delivered ? <Check aria-hidden /> : null}\n            {chip}\n          </Badge>\n          <h2 id={`${uid}-title`} className=\"text-2xl font-medium tracking-tight tabular-nums\">\n            {headline}\n          </h2>\n          {carrier ? (\n            <p className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground\">\n              <span className=\"text-foreground\">{carrier.name}</span>\n              {carrier.trackingNumber ? <span className=\"font-mono text-[12.5px]\">{carrier.trackingNumber}</span> : null}\n              {carrier.url ? (\n                <a\n                  href={carrier.url}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  className=\"inline-flex items-center gap-1 underline underline-offset-4 hover:text-foreground\"\n                >\n                  Track on {carrier.name}\n                  <ArrowUpRight className=\"size-3.5\" aria-hidden />\n                  <span className=\"sr-only\">(opens in a new tab)</span>\n                </a>\n              ) : null}\n            </p>\n          ) : null}\n          {progress.issue ? (\n            <p role=\"status\" className=\"flex items-start gap-2 border-l-2 border-destructive py-1 pl-3 text-sm\">\n              {progress.issue.description ?? \"The carrier reported a problem with this delivery.\"}\n              {progress.issue.location ? <span className=\"text-muted-foreground\"> · {progress.issue.location}</span> : null}\n            </p>\n          ) : null}\n        </header>\n\n        {sorted.length === 0 ? (\n          <p data-slot=\"order-tracking-empty\" className=\"border-y py-5 text-sm text-muted-foreground\">\n            Tracking appears here once your order ships{arrival ? ` — it should arrive ${arrival}` : \"\"}.\n          </p>\n        ) : (\n          <>\n            {/* The milestones. */}\n            <ol data-slot=\"order-tracking-steps\" className=\"flex flex-col\">\n              {steps.map((step, i) => {\n                const state = i < progress.current ? \"done\" : i === progress.current ? \"current\" : \"pending\"\n                const at = progress.reachedAt[step.status]\n                const last = i === steps.length - 1\n                const trouble = state === \"current\" && progress.issue\n                return (\n                  <li\n                    key={step.status}\n                    data-state={state}\n                    aria-current={state === \"current\" ? \"step\" : undefined}\n                    className=\"relative flex gap-4 pb-7 last:pb-0\"\n                  >\n                    {/* The rule to the next node: grey, filled in ink once passed. */}\n                    {!last ? (\n                      <span aria-hidden className=\"absolute top-6 bottom-1 left-[9.5px] w-px bg-border\">\n                        <motion.span\n                          className=\"absolute inset-0 origin-top bg-foreground\"\n                          initial={false}\n                          animate={reduce ? { opacity: i < progress.current ? 1 : 0 } : { scaleY: i < progress.current ? 1 : 0 }}\n                          transition={reduce ? FADE : MORPH}\n                        />\n                      </span>\n                    ) : null}\n                    <span className=\"relative mt-0.5 grid size-5 shrink-0 place-items-center\">\n                      {state === \"current\" && !progress.delivered ? (\n                        <span\n                          aria-hidden\n                          className={cn(\n                            \"absolute inset-0 rounded-full motion-safe:animate-ping\",\n                            trouble ? \"bg-destructive/40\" : \"bg-primary/30\"\n                          )}\n                        />\n                      ) : null}\n                      <span\n                        aria-hidden\n                        className={cn(\n                          \"relative grid size-5 place-items-center rounded-full border\",\n                          state === \"done\" && \"border-foreground bg-foreground text-background\",\n                          state === \"current\" &&\n                            (trouble\n                              ? \"border-destructive bg-background text-destructive ring-2 ring-destructive ring-offset-2 ring-offset-background\"\n                              : progress.delivered\n                                ? \"border-foreground bg-foreground text-background\"\n                                : \"border-primary bg-primary ring-2 ring-primary ring-offset-2 ring-offset-background\"),\n                          state === \"pending\" && \"border-border bg-background\"\n                        )}\n                      >\n                        {state === \"done\" || (state === \"current\" && progress.delivered) ? <Check className=\"size-3\" strokeWidth={3} /> : null}\n                        {trouble ? <span className=\"text-[11px] font-bold\">!</span> : null}\n                      </span>\n                    </span>\n                    <span className=\"flex min-w-0 flex-col gap-0.5\">\n                      <span className={cn(\"text-sm\", state === \"pending\" ? \"text-muted-foreground\" : \"font-medium\")}>{step.label}</span>\n                      {at ? (\n                        <time dateTime={at} suppressHydrationWarning className=\"text-[13px] text-muted-foreground tabular-nums\">\n                          {stamp.format(new Date(at))}\n                        </time>\n                      ) : null}\n                      <span className=\"sr-only\">\n                        {state === \"done\" ? \"done\" : state === \"current\" ? (trouble ? \"current, needs attention\" : \"current\") : \"not yet\"}\n                      </span>\n                    </span>\n                  </li>\n                )\n              })}\n            </ol>\n\n            {/* The carrier's updates, newest first. */}\n            <div data-slot=\"order-tracking-updates\" className=\"flex flex-col\">\n              <h3 className=\"border-b pb-2 text-xs tracking-[0.14em] text-muted-foreground uppercase\">Updates</h3>\n              <ul id={`${uid}-updates`}>\n                <AnimatePresence initial={false}>\n                  {shown.map((event) => (\n                    <motion.li\n                      key={event.id}\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n                      animate={reduce ? { opacity: 1 } : { opacity: 1, height: \"auto\" }}\n                      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n                      transition={reduce ? FADE : MORPH}\n                      className=\"overflow-hidden\"\n                    >\n                      <div className=\"grid grid-cols-[7.5rem_minmax(0,1fr)] gap-x-4 border-b py-3 text-sm sm:grid-cols-[9rem_minmax(0,1fr)]\">\n                        <time dateTime={event.at} suppressHydrationWarning className=\"text-muted-foreground tabular-nums\">\n                          {stamp.format(new Date(event.at))}\n                        </time>\n                        <span className=\"flex flex-col gap-0.5\">\n                          <span className={cn(event.status === \"exception\" && \"text-destructive\")}>\n                            {event.description ?? labelFor(event.status)}\n                          </span>\n                          {event.location ? <span className=\"text-[13px] text-muted-foreground\">{event.location}</span> : null}\n                        </span>\n                      </div>\n                    </motion.li>\n                  ))}\n                </AnimatePresence>\n              </ul>\n              {log.length > visibleUpdates ? (\n                <button\n                  type=\"button\"\n                  aria-expanded={showAll}\n                  aria-controls={`${uid}-updates`}\n                  onClick={() => setShowAll((s) => !s)}\n                  className=\"mt-3 self-start text-sm underline underline-offset-4 hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                >\n                  {showAll ? \"Show fewer updates\" : `Show all ${log.length} updates`}\n                </button>\n              ) : null}\n            </div>\n          </>\n        )}\n      </section>\n    </MotionConfig>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/order-tracking.tsx"
    },
    {
      "path": "src/registry/ecomcn/order-tracking/tracking-events.ts",
      "content": "/*\n * A shipment's progress from carrier events — in any order, duplicates and\n * all. No \"use client\" and no React: run it where the webhooks land, to\n * store events in order, and the order-tracking block runs the same code.\n *\n *   const { current, issue, delivered } = trackingProgress(events)\n */\n\nexport type TrackingMilestone = \"ordered\" | \"dispatched\" | \"in-transit\" | \"out-for-delivery\" | \"delivered\"\n/** A milestone, or a problem the carrier reports: a failed attempt, a delay. */\nexport type TrackingStatus = TrackingMilestone | \"exception\"\n\nexport interface TrackingEvent {\n  /** The carrier's event id — the same update sent twice is shown once. */\n  id: string\n  status: TrackingStatus\n  /** ISO timestamp of when it happened, not when the webhook arrived. */\n  at: string\n  /** \"Departed facility\", \"Delivery attempted — no one home\". */\n  description?: string\n  /** \"Leipzig, DE\". */\n  location?: string\n}\n\nexport interface TrackingStep {\n  status: TrackingMilestone\n  label: string\n}\n\nexport const DEFAULT_TRACKING_STEPS: TrackingStep[] = [\n  { status: \"ordered\", label: \"Ordered\" },\n  { status: \"dispatched\", label: \"Dispatched\" },\n  { status: \"in-transit\", label: \"In transit\" },\n  { status: \"out-for-delivery\", label: \"Out for delivery\" },\n  { status: \"delivered\", label: \"Delivered\" },\n]\n\n/**\n * The events once, in the order they happened. A repeated id keeps its last\n * copy (a carrier's correction); equal times keep the milestone order.\n */\nexport function sortTrackingEvents(events: TrackingEvent[], steps: TrackingStep[] = DEFAULT_TRACKING_STEPS) {\n  const byId = new Map<string, TrackingEvent>()\n  for (const event of events) byId.set(event.id, event)\n  const rank = (s: TrackingStatus) => (s === \"exception\" ? steps.length : steps.findIndex((step) => step.status === s))\n  return [...byId.values()].sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || rank(a.status) - rank(b.status))\n}\n\nexport interface TrackingProgress {\n  /** Index into the steps of the milestone the parcel is at; -1 before any. */\n  current: number\n  /** When each milestone was first reached, if the carrier said so. */\n  reachedAt: Partial<Record<TrackingMilestone, string>>\n  /** The latest event, when it's a problem reported after the last milestone. */\n  issue: TrackingEvent | null\n  delivered: boolean\n}\n\n/** Where the parcel is, from events in any order. */\nexport function trackingProgress(events: TrackingEvent[], steps: TrackingStep[] = DEFAULT_TRACKING_STEPS): TrackingProgress {\n  const sorted = sortTrackingEvents(events, steps)\n  const reachedAt: TrackingProgress[\"reachedAt\"] = {}\n  let current = -1\n  let issue: TrackingEvent | null = null\n  for (const event of sorted) {\n    if (event.status === \"exception\") {\n      issue = event\n      continue\n    }\n    const index = steps.findIndex((s) => s.status === event.status)\n    if (index === -1) continue\n    reachedAt[event.status] ??= event.at\n    // The carrier's latest word is the truth — even a step back, after a re-route.\n    current = index\n    issue = null\n  }\n  return { current, reachedAt, issue, delivered: steps[current]?.status === \"delivered\" }\n}\n",
      "type": "registry:lib",
      "target": "lib/tracking-events.ts"
    }
  ],
  "categories": [
    "ecommerce",
    "post-purchase"
  ],
  "type": "registry:block"
}