{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "delivery-tracker",
  "title": "Delivery Tracker",
  "description": "A page that routes a delivery from a depot to a drop-off, fills in the travelled part of the line as a courier marker moves along it, and keeps a living arrival card: the time, what is left and each stage of the trip. Calls /route once per branch; Restart replays it. Stops are Ottawa Public Library branches (City of Ottawa open data).",
  "dependencies": [
    "@unmap/routing"
  ],
  "registryDependencies": [
    "https://registry.unmap.dev/r/map.json",
    "https://registry.unmap.dev/r/map-context.json",
    "https://registry.unmap.dev/r/route-line.json",
    "https://registry.unmap.dev/r/marker-popup.json",
    "card",
    "toggle-group",
    "button",
    "alert",
    "spinner"
  ],
  "files": [
    {
      "path": "registry/blocks/delivery-tracker/page.tsx",
      "content": "/**\n * /delivery-tracker: the delivery tracker block as a Next.js App Router page, full-bleed (the\n * block keeps its own gutter). The key comes from NEXT_PUBLIC_UNMAP_KEY in .env.local; installing\n * the block added the empty line, paste your key.\n */\nimport { DeliveryTracker } from \"@/components/ui/delivery-tracker\";\n\nexport const metadata = { title: \"Track a delivery\" };\n\nexport default function DeliveryTrackerPage() {\n  return (\n    <main className=\"h-dvh\">\n      <DeliveryTracker apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/delivery-tracker/page.tsx"
    },
    {
      "path": "registry/blocks/delivery-tracker/delivery-tracker.tsx",
      "content": "\"use client\";\n\n/**\n * <DeliveryTracker> - a delivery-tracking page in one component. A full-bleed map with a living\n * arrival card floating on it (a bottom sheet on a phone): pick a branch, start the delivery, and\n * the route fills in behind a courier moving along it while the card counts down to the arrival\n * time and ticks off each stage of the trip.\n *\n * Composes the registry's map, route-line and marker-popup items on your shadcn Card, ToggleGroup,\n * Button, Alert and Spinner, in your tokens. \"Start delivery\" calls /route once per branch; the\n * courier is then played back along the route's own geometry at `speed` times real time, and a\n * restart replays the route it already has. Swap the playback for your fleet's live positions to\n * track real couriers.\n *\n * The depot and drop-offs are Ottawa Public Library branches (City of Ottawa open data, credited).\n * Installed by `npx shadcn add` with its page at app/delivery-tracker/page.tsx. Edit freely.\n */\n\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { Router } from \"@unmap/routing\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Map } from \"@/components/ui/map\";\nimport { Marker, MarkerLabel } from \"@/components/ui/marker-popup\";\nimport { RouteLine } from \"@/components/ui/route-line\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { useMap } from \"@/lib/map-context\";\n\n// --- delivery-tracker helpers (kept identical across both lanes) ---\nexport type TrackerLang = \"en\" | \"fr\";\n\n/** Where the trip is: on the way, almost there, or delivered. */\nexport type TrackerStage = \"way\" | \"soon\" | \"done\";\n\nexport interface TrackerStop {\n  id: string;\n  name: string;\n  name_fr: string;\n  address: string;\n  lngLat: [number, number];\n}\n\n/** A box on screen, as getBoundingClientRect() gives it. */\nexport interface TrackerRect {\n  left: number;\n  top: number;\n  right: number;\n  bottom: number;\n  width: number;\n  height: number;\n}\n\n/** Camera padding in pixels, as fitBounds takes it. */\nexport interface TrackerPadding {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n}\n\n/**\n * Ottawa Public Library branches, from the City of Ottawa open data item \"Library Locations and\n * Hours (Ottawa Public Libraries) API\" (d64cd889fa7d4852845d9d9e208fe1ea), retrieved 2026-09-29.\n * The Main branch is the depot; five nearby branches are the drop-offs.\n */\nexport const DEPOT: TrackerStop = {\n  id: \"main\",\n  name: \"Main\",\n  name_fr: \"Centrale\",\n  address: \"120 Metcalfe, Ottawa, ON K1P 5M2\",\n  lngLat: [-75.695396, 45.4203],\n};\n\nexport const DROP_OFFS: TrackerStop[] = [\n  { id: \"rideau\", name: \"Rideau\", name_fr: \"Rideau\", address: \"377 Rideau, Ottawa, ON K1N 5Y6\", lngLat: [-75.683098, 45.430099] },\n  { id: \"sunnyside\", name: \"Sunnyside\", name_fr: \"Sunnyside\", address: \"1049 Bank, Ottawa, ON K1S 3W9\", lngLat: [-75.683899, 45.3955] },\n  { id: \"rosemount\", name: \"Rosemount\", name_fr: \"Rosemount\", address: \"18 Rosemount, Ottawa, ON K1Y 1P4\", lngLat: [-75.726315, 45.401637] },\n  { id: \"st-laurent\", name: \"St-Laurent\", name_fr: \"St-Laurent\", address: \"515 Côté, Ottawa, ON K1K 0Z8\", lngLat: [-75.647903, 45.436199] },\n  { id: \"vanier\", name: \"Vanier\", name_fr: \"Vanier\", address: \"310 Pères Blancs, Ottawa, ON K1L 7L5\", lngLat: [-75.6604, 45.444302] },\n];\n\n/** Who the stops come from, and the licence's own credit sentence (its name stays English in French). */\nexport const STOPS_CREDIT = {\n  name: \"Ottawa Public Library branches, City of Ottawa open data\",\n  name_fr: \"Succursales de la Bibliothèque publique d'Ottawa, données ouvertes de la Ville d'Ottawa\",\n  url: \"https://open.ottawa.ca/datasets/d64cd889fa7d4852845d9d9e208fe1ea\",\n  licence: \"Contains information licensed under the Open Government Licence – City of Ottawa.\",\n  licence_fr: \"Contient de l'information visée par l'Open Government Licence – City of Ottawa.\",\n  licenceUrl: \"https://ottawa.ca/en/city-hall/open-transparent-and-accountable-government/open-data/open-data-licence-version-20\",\n};\n\n/** Who the route comes from, and the licence it carries. */\nexport const ROUTING_CREDIT = {\n  sourceUrl: \"https://www.openstreetmap.org/copyright\",\n  licenceUrl: \"https://opendatacommons.org/licenses/odbl/\",\n};\n\n/** How often the courier moves, in milliseconds. */\nexport const TICK_MS = 100;\n/** Inside this many seconds of the drop-off, the trip is almost there. */\nexport const SOON_SECONDS = 120;\n/** The playback speed when none is given, and the least one accepted: trip seconds per real second. */\nexport const DEFAULT_SPEED = 30;\nexport const MIN_SPEED = 1;\n\n/** Downtown Ottawa. */\nexport const DOWNTOWN_OTTAWA: [number, number] = [-75.69, 45.42];\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const TRACKER_MAP_ID = \"delivery-tracker\";\n/** The route's source id and layer-id prefix. */\nexport const TRACKER_ROUTE_ID = \"delivery-tracker-route\";\n\n/** The panel's inset from the map edge, and the gutter the camera keeps. */\nexport const GUTTER = 24;\n/** The gutter a framed route keeps: room for the names beside the pins. */\nexport const FRAME_GUTTER = 48;\n/** Camera moves, in milliseconds. Reduced motion jumps. */\nexport const CAMERA_MS = 800;\n/** The closest a route is framed. */\nexport const ROUTE_MAX_ZOOM = 15;\n/** The least map, in pixels, a padded camera must have before it tightens its gutter. */\nconst MIN_FRAME = 120;\n/** The tightened gutter, and the least strip the camera keeps to frame in beside a tall panel. */\nconst TIGHT = 8;\nconst MIN_STRIP = 32;\n/** How far the default pin stands above its point, in pixels. */\nconst PIN_ROOM = 40;\n\n/** Where a reader gets an unmap key: the no-key state's first action. */\nexport const KEY_URL = \"https://unmap.dev/dashboard\";\n\nexport const TRACKER_COPY = {\n  en: {\n    title: \"Track a delivery\",\n    from: (depot: string) => `From ${depot}`,\n    empty: \"Pick a branch, then start the delivery.\",\n    dropOff: \"Deliver to\",\n    start: \"Start delivery\",\n    restart: \"Restart\",\n    loading: \"Finding the route...\",\n    arrival: \"Arrival\",\n    arrivesAt: \"Arrives at\",\n    deliveredAt: \"Delivered at\",\n    left: (minutes: number) => `${minutes} min left`,\n    soon: \"Less than a minute left\",\n    toGo: (distance: string) => `${distance} to go`,\n    progress: \"Delivery progress\",\n    departs: (depot: string) => `Departs ${depot}`,\n    departed: (depot: string) => `Departed ${depot}`,\n    onTheWay: \"On the way\",\n    almost: \"Almost there\",\n    at: (stop: string) => `At ${stop}`,\n    stepDone: \"(done)\",\n    courier: \"Your parcel\",\n    replay: (speed: number) => `Played back at ${speed}×.`,\n    replayHow: \"One route request per branch; the courier replays its geometry.\",\n    announceStart: (stop: string, time: string, left: string) => `Delivery to ${stop} started. Arrives at ${time}, ${left}.`,\n    announceSoon: (stop: string, left: string) => `Almost at ${stop}: ${left}.`,\n    announceDone: (stop: string, time: string) => `Delivered to ${stop} at ${time}.`,\n    fold: \"Hide details\",\n    unfold: \"Show details\",\n    retry: \"Try again\",\n    stopsCredit: \"Branches:\",\n    routesCredit: \"Routes:\",\n    osm: \"OpenStreetMap contributors\",\n    odbl: \"ODbL\",\n    noKeyTitle: \"Add your unmap key to load the map.\",\n    getKey: \"Get a key\",\n    noKey:\n      'Pass apiKey. On Next.js, set NEXT_PUBLIC_UNMAP_KEY in .env.local. On Nuxt, set NUXT_PUBLIC_UNMAP_KEY in .env. If you installed with unmap add, nuxt.config already declares runtimeConfig: { public: { unmapKey: \"\" } }; otherwise add it there. Then restart the dev server.',\n    outside: \"That trip leaves Canada. Routing covers Canada.\",\n    noRoute: \"No road connects those places.\",\n    busy: \"Too many requests. Try again in a moment.\",\n    keyRefused: \"The API key was refused. Check it and try again.\",\n    unavailable: \"The service didn't answer. Try again in a moment.\",\n    failed: \"The request failed. Try again.\",\n  },\n  fr: {\n    title: \"Suivre une livraison\",\n    from: (depot: string) => `Depuis ${depot}`,\n    empty: \"Choisissez une succursale, puis lancez la livraison.\",\n    dropOff: \"Livrer à\",\n    start: \"Lancer la livraison\",\n    restart: \"Recommencer\",\n    loading: \"Calcul de l'itinéraire...\",\n    arrival: \"Arrivée\",\n    arrivesAt: \"Arrivée prévue à\",\n    deliveredAt: \"Livré à\",\n    left: (minutes: number) => `${minutes} min restantes`,\n    soon: \"Moins d'une minute\",\n    toGo: (distance: string) => `${distance} à parcourir`,\n    progress: \"Progression de la livraison\",\n    departs: (depot: string) => `Départ de ${depot}`,\n    departed: (depot: string) => `Départ de ${depot}`,\n    onTheWay: \"En route\",\n    almost: \"Arrivée imminente\",\n    at: (stop: string) => `À ${stop}`,\n    stepDone: \"(terminé)\",\n    courier: \"Votre colis\",\n    replay: (speed: number) => `Relecture à ${speed}×.`,\n    replayHow: \"Une requête d'itinéraire par succursale ; le livreur en rejoue le tracé.\",\n    announceStart: (stop: string, time: string, left: string) => `Livraison vers ${stop} lancée. Arrivée prévue à ${time}, ${left}.`,\n    announceSoon: (stop: string, left: string) => `Presque à ${stop} : ${left}.`,\n    announceDone: (stop: string, time: string) => `Livré à ${stop} à ${time}.`,\n    fold: \"Masquer les détails\",\n    unfold: \"Afficher les détails\",\n    retry: \"Réessayer\",\n    stopsCredit: \"Succursales :\",\n    routesCredit: \"Itinéraires :\",\n    osm: \"contributeurs d'OpenStreetMap\",\n    odbl: \"ODbL\",\n    noKeyTitle: \"Ajoutez votre clé unmap pour charger la carte.\",\n    getKey: \"Obtenir une clé\",\n    noKey:\n      'Passez apiKey. Avec Next.js, définissez NEXT_PUBLIC_UNMAP_KEY dans .env.local. Avec Nuxt, définissez NUXT_PUBLIC_UNMAP_KEY dans .env. Si vous avez installé le bloc avec unmap add, nuxt.config déclare déjà runtimeConfig: { public: { unmapKey: \"\" } } ; sinon, ajoutez-le. Redémarrez ensuite le serveur de développement.',\n    outside: \"Ce trajet sort du Canada. Les itinéraires couvrent le Canada.\",\n    noRoute: \"Aucune route ne relie ces lieux.\",\n    busy: \"Trop de requêtes. Réessayez dans un instant.\",\n    keyRefused: \"La clé API a été refusée. Vérifiez-la et réessayez.\",\n    unavailable: \"Le service n'a pas répondu. Réessayez dans un instant.\",\n    failed: \"La requête a échoué. Réessayez.\",\n  },\n};\n\nexport function stopName(stop: TrackerStop, lang: TrackerLang): string {\n  return lang === \"fr\" ? stop.name_fr : stop.name;\n}\n\nconst EARTH_RADIUS_M = 6_371_008.8;\n\nfunction haversineMetres(a: [number, number], b: [number, number]): number {\n  const toRadians = (degrees: number) => (degrees * Math.PI) / 180;\n  const dLat = toRadians(b[1] - a[1]);\n  const dLng = toRadians(b[0] - a[0]);\n  const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(a[1])) * Math.cos(toRadians(b[1])) * Math.sin(dLng / 2) ** 2;\n  return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));\n}\n\nexport function lineLength(coordinates: [number, number][]): number {\n  let total = 0;\n  for (let index = 1; index < coordinates.length; index++) total += haversineMetres(coordinates[index - 1]!, coordinates[index]!);\n  return total;\n}\n\n/** The point `fraction` (0 to 1) of the way along the line, by distance. */\nexport function pointAt(coordinates: [number, number][], fraction: number): [number, number] {\n  const first = coordinates[0]!;\n  const last = coordinates[coordinates.length - 1]!;\n  if (coordinates.length < 2 || fraction <= 0) return first;\n  if (fraction >= 1) return last;\n  const target = lineLength(coordinates) * fraction;\n  let travelled = 0;\n  for (let index = 1; index < coordinates.length; index++) {\n    const a = coordinates[index - 1]!;\n    const b = coordinates[index]!;\n    const step = haversineMetres(a, b);\n    if (step > 0 && travelled + step >= target) {\n      const t = (target - travelled) / step;\n      return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];\n    }\n    travelled += step;\n  }\n  return last;\n}\n\n/** \"14:10\" (fr: \"14 h 10\"), in the reader's time zone. Call it only in the browser, never while rendering on the server. */\nexport function formatClock(time: number, lang: TrackerLang): string {\n  return new Intl.DateTimeFormat(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", { hour: \"2-digit\", minute: \"2-digit\", hourCycle: \"h23\" }).format(time);\n}\n\nexport function formatRemaining(seconds: number, lang: TrackerLang): string {\n  const minutes = Math.round(seconds / 60);\n  return minutes < 1 ? TRACKER_COPY[lang].soon : TRACKER_COPY[lang].left(minutes);\n}\n\n/** \"900 m\", \"1.8 km\" (fr: \"1,8 km\"). */\nexport function formatDistance(metres: number, lang: TrackerLang): string {\n  if (metres < 1000) return `${Math.round(metres)} m`;\n  const km = new Intl.NumberFormat(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", { minimumFractionDigits: 1, maximumFractionDigits: 1 });\n  return `${km.format(metres / 1000)} km`;\n}\n\n/** Where the trip is, from the travelled fraction and the trip's length in seconds. */\nexport function trackerStage(progress: number, durationSeconds: number): TrackerStage {\n  if (progress >= 1) return \"done\";\n  return durationSeconds * (1 - progress) <= SOON_SECONDS ? \"soon\" : \"way\";\n}\n\n/**\n * The fraction the map and the card show. Under reduced motion it moves a whole trip minute at a\n * time, so the courier jumps from place to place and the line fills in steps, never gliding.\n */\nexport function shownProgress(progress: number, durationSeconds: number, reduced: boolean): number {\n  if (!reduced || progress >= 1) return progress;\n  return (Math.floor((progress * durationSeconds) / 60) * 60) / durationSeconds;\n}\n\n/** [[west, south], [east, north]] around a line, for fitBounds. A loop: spreading a long route into Math.min overflows the stack. */\nexport function lineBounds(coordinates: [number, number][]): [[number, number], [number, number]] {\n  let west = Infinity;\n  let south = Infinity;\n  let east = -Infinity;\n  let north = -Infinity;\n  for (const [lng, lat] of coordinates) {\n    if (lng < west) west = lng;\n    if (lng > east) east = lng;\n    if (lat < south) south = lat;\n    if (lat > north) north = lat;\n  }\n  return [\n    [west, south],\n    [east, north],\n  ];\n}\n\n/** Every branch the block knows, framed when the map opens. */\nexport const STOPS_BOUNDS = lineBounds([DEPOT, ...DROP_OFFS].map((stop) => stop.lngLat));\n\n/**\n * Camera padding that keeps what the camera frames clear of the floating panel: its width on the\n * left when it floats beside the map, its height at the bottom when it is a sheet across it. An\n * even gutter when nothing is measured. When the full gutter would leave too little map (a tall\n * sheet on a small phone), the gutter tightens and the frame stays in the strip the panel leaves,\n * never behind the panel.\n */\nexport function panelPadding(map: TrackerRect, panel: TrackerRect | null, gutter = GUTTER): TrackerPadding {\n  const even = { top: gutter, right: gutter, bottom: gutter, left: gutter };\n  if (!panel || panel.width <= 0 || panel.height <= 0 || map.width <= 0 || map.height <= 0) return even;\n  const sheet = panel.width >= map.width * 0.6;\n  const cover = Math.round(sheet ? map.bottom - panel.top : panel.right - map.left);\n  const size = sheet ? map.height : map.width;\n  if (size - 2 * gutter - cover >= MIN_FRAME) return sheet ? { ...even, bottom: cover + gutter } : { ...even, left: cover + gutter };\n  const near = Math.min(cover + TIGHT, size - TIGHT - MIN_STRIP);\n  return sheet ? { ...even, top: TIGHT, bottom: near } : { ...even, left: near, right: TIGHT };\n}\n\n/** Which side of a pin its name sits: toward the other end, so a framed trip keeps both names inside the frame. */\nexport function labelSide(stop: TrackerStop, other: TrackerStop): \"left\" | \"right\" {\n  return stop.lngLat[0] <= other.lngLat[0] ? \"right\" : \"left\";\n}\n\n/**\n * Room above a framed route for the drop-off pin, which stands above its point: added to the top\n * padding when the map has it to spare, so the pin's head never sits under the edge or a control.\n */\nexport function withPinRoom(padding: TrackerPadding, mapHeight: number): TrackerPadding {\n  const spare = mapHeight - padding.top - padding.bottom - MIN_STRIP;\n  return { ...padding, top: padding.top + Math.max(0, Math.min(PIN_ROOM, spare)) };\n}\n\n/**\n * What to tell the reader about a failed request: the place, the key or the rate when the gateway\n * says so; a service that did not answer for a 5xx or a request that never got an HTTP answer.\n */\nexport function requestError(caught: unknown, lang: TrackerLang): string {\n  const copy = TRACKER_COPY[lang];\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { code?: unknown; status?: unknown }) : {};\n  const status = typeof failure.status === \"number\" ? failure.status : null;\n  if (failure.code === \"outside_coverage\") return copy.outside;\n  if (failure.code === \"no_route\") return copy.noRoute;\n  if (status === 429) return copy.busy;\n  if (status === 401 || status === 403) return copy.keyRefused;\n  if (status === null || status >= 500) return copy.unavailable;\n  return copy.failed;\n}\n\nexport function isAbort(caught: unknown): boolean {\n  return typeof caught === \"object\" && caught !== null && (caught as { name?: unknown }).name === \"AbortError\";\n}\n\n/** True when the reader asked for less motion: the camera jumps and the courier moves in steps. */\nexport function prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches === true;\n}\n\n/** The playback speed to use: the one given, never below MIN_SPEED; the default when it is not a number. */\nexport function playbackSpeed(speed: number): number {\n  return Number.isFinite(speed) ? Math.max(MIN_SPEED, speed) : DEFAULT_SPEED;\n}\n\n/** Plays a trip on wall time: how many trip seconds are behind the parcel, whatever the timer's cadence. */\nexport interface TripClock {\n  /** Trip seconds played so far. The same value between ticks, so it is safe as a store snapshot. */\n  get(): number;\n  /** Called on every tick while the trip plays, and when it starts, stops or finishes. */\n  subscribe(listener: () => void): () => void;\n  /** Plays a trip of `totalSeconds` from its start at `speed` trip seconds per real second. */\n  run(totalSeconds: number, speed: number): void;\n  /** Carries on from where the trip is, at a new speed. */\n  setSpeed(speed: number): void;\n  /** Freezes the trip where it is and lets go of the timer. */\n  stop(): void;\n}\n\n/**\n * One timer for the whole block, ticking every TICK_MS while a trip plays. The time played is\n * worked out from the clock, not from the ticks, so a slow tab never slows the parcel. While the\n * page is hidden the trip pauses and the timer stops; it resumes where it was when the page is\n * seen again. Framework-free: React reads it through useSyncExternalStore, Vue through a ref.\n */\nexport function createTripClock(now: () => number = () => performance.now()): TripClock {\n  const listeners = new Set<() => void>();\n  let total = 0;\n  let speed = MIN_SPEED;\n  // Trip seconds banked before `since`, and whether the clock is running from `since`.\n  let banked = 0;\n  let since = 0;\n  let running = false;\n  // A trip is on: running, or paused while the page is hidden.\n  let playing = false;\n  let snapshot = 0;\n  let timer: ReturnType<typeof setInterval> | null = null;\n  const played = () => Math.min(total, banked + (running ? ((now() - since) / 1000) * speed : 0));\n  const emit = () => {\n    snapshot = played();\n    listeners.forEach((listener) => listener());\n  };\n  const halt = () => {\n    if (timer !== null) clearInterval(timer);\n    timer = null;\n  };\n  const hidden = () => typeof document !== \"undefined\" && document.visibilityState === \"hidden\";\n  const pause = () => {\n    banked = played();\n    running = false;\n    halt();\n  };\n  const resume = () => {\n    since = now();\n    running = true;\n    halt();\n    timer = setInterval(tick, TICK_MS);\n  };\n  const onVisibility = () => {\n    if (!playing) return;\n    if (hidden()) pause();\n    else if (!running) resume();\n    emit();\n  };\n  const listen = (on: boolean) => {\n    if (typeof document === \"undefined\") return;\n    if (on) document.addEventListener(\"visibilitychange\", onVisibility);\n    else document.removeEventListener(\"visibilitychange\", onVisibility);\n  };\n  const stop = () => {\n    pause();\n    playing = false;\n    listen(false);\n  };\n  function tick() {\n    emit();\n    if (snapshot >= total) stop();\n  }\n  return {\n    get: () => snapshot,\n    subscribe(listener) {\n      listeners.add(listener);\n      return () => listeners.delete(listener);\n    },\n    run(totalSeconds, nextSpeed) {\n      stop();\n      total = Math.max(0, totalSeconds);\n      speed = playbackSpeed(nextSpeed);\n      banked = 0;\n      playing = true;\n      listen(true);\n      if (hidden()) running = false;\n      else resume();\n      emit();\n    },\n    setSpeed(nextSpeed) {\n      banked = played();\n      since = now();\n      speed = playbackSpeed(nextSpeed);\n    },\n    stop() {\n      stop();\n      emit();\n    },\n  };\n}\n\n/** \"10 min left\" as the middle of a sentence: \"10 min left\", \"less than a minute left\". */\nexport function midSentence(text: string): string {\n  return text.charAt(0).toLowerCase() + text.slice(1);\n}\n// --- end delivery-tracker helpers ---\n\nexport interface DeliveryTrackerProps {\n  /** unmap API key. The page passes NEXT_PUBLIC_UNMAP_KEY. With none, the block asks for one and loads nothing. */\n  apiKey: string;\n  /** Gateway base URL. Defaults to the unmap production gateway. */\n  gateway?: string;\n  /** Playback speed: trip seconds per real second, at least 1. Defaults to 30, so a ten-minute trip plays in twenty seconds. */\n  speed?: number;\n  /** Language of the copy, the times and the stop names. Defaults to \"en\". */\n  lang?: TrackerLang;\n  className?: string;\n}\n\n/** A route as /route answered it, kept per branch so a restart replays it without asking again. */\ntype Answer = { coordinates: [number, number][]; distanceMeters: number; durationSeconds: number };\n/** A delivery on its way: the route, when it left and where it goes. */\ntype Delivery = Answer & { startedAt: number; stop: TrackerStop };\ntype StepState = \"done\" | \"current\" | \"upcoming\";\n\nexport function DeliveryTracker({ apiKey, gateway, speed = DEFAULT_SPEED, lang = \"en\", className }: DeliveryTrackerProps) {\n  if (!apiKey.trim()) {\n    const copy = TRACKER_COPY[lang];\n    // Where the map would be: the block's gutter, the Alert at a readable width, the first action first.\n    return (\n      <div\n        data-unmap-block=\"delivery-tracker\"\n        data-state=\"no-key\"\n        className={[\"flex h-full min-h-[480px] items-start justify-center bg-muted/40 p-4 sm:items-center sm:p-6\", className].filter(Boolean).join(\" \")}\n      >\n        <Alert role=\"alert\" className=\"w-full max-w-md motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n          <AlertTitle className=\"line-clamp-none\">{copy.noKeyTitle}</AlertTitle>\n          <AlertDescription className=\"flex flex-col items-start gap-3\">\n            <a className=\"font-medium text-primary underline underline-offset-4\" href={KEY_URL} target=\"_blank\" rel=\"noreferrer\">\n              {copy.getKey}\n            </a>\n            <p>{copy.noKey}</p>\n          </AlertDescription>\n        </Alert>\n      </div>\n    );\n  }\n  return (\n    <div data-unmap-block=\"delivery-tracker\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map id={TRACKER_MAP_ID} apiKey={apiKey} gateway={gateway} center={DOWNTOWN_OTTAWA} zoom={13} lang={lang} className=\"h-full\">\n        <Tracker apiKey={apiKey} gateway={gateway} speed={playbackSpeed(speed)} lang={lang} />\n      </Map>\n    </div>\n  );\n}\n\n/**\n * Where the trip is, read from the clock. Only the components that call this re-render as the\n * parcel moves: the line and the parcel on the map, the headline and the stages. The branch list\n * and the credits stay put.\n */\nfunction useTrip(clock: TripClock, delivery: Delivery | null) {\n  const played = useSyncExternalStore(clock.subscribe, clock.get, () => 0);\n  if (!delivery) return { progress: 0, stage: null, remaining: 0 };\n  const total = delivery.durationSeconds;\n  const progress = shownProgress(Math.min(1, played / total), total, prefersReducedMotion());\n  return { progress, stage: trackerStage(progress, total), remaining: total * (1 - progress) };\n}\n\n/** The route filling in, and the parcel on it. */\nfunction TripOnMap({ clock, delivery, lang, side }: { clock: TripClock; delivery: Delivery; lang: TrackerLang; side: \"left\" | \"right\" }) {\n  const { progress, stage } = useTrip(clock, delivery);\n  const courier = useMemo(() => (stage === \"done\" ? null : pointAt(delivery.coordinates, progress)), [delivery, progress, stage]);\n  return (\n    <>\n      <RouteLine mapId={TRACKER_MAP_ID} id={TRACKER_ROUTE_ID} coordinates={delivery.coordinates} progress={progress} />\n      {courier ? (\n        <Marker\n          mapId={TRACKER_MAP_ID}\n          lngLat={courier}\n          markerContent={\n            <span className=\"relative flex size-6 items-center justify-center\">\n              <span aria-hidden=\"true\" className=\"absolute inset-0 rounded-full bg-primary/25 motion-safe:animate-ping\" />\n              <span className=\"relative size-3.5 rounded-full border-2 border-background bg-primary\" />\n            </span>\n          }\n        >\n          {/* The parcel's name trails behind it, on the side away from the depot's, so the two never meet as it leaves. */}\n          <MarkerLabel position={side}>{TRACKER_COPY[lang].courier}</MarkerLabel>\n        </Marker>\n      ) : null}\n    </>\n  );\n}\n\n/** The headline: when the parcel arrives, what is left, and how much of the trip is behind it. */\nfunction Headline({ clock, delivery, lang }: { clock: TripClock; delivery: Delivery; lang: TrackerLang }) {\n  const copy = TRACKER_COPY[lang];\n  const { progress, stage, remaining } = useTrip(clock, delivery);\n  const done = stage === \"done\";\n  // Clock text exists only once a delivery started, in the browser: the server render has none.\n  const arrival = formatClock(delivery.startedAt + delivery.durationSeconds * 1000, lang);\n  return (\n    <section aria-label={copy.arrival} className=\"flex flex-col gap-2 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n      <div className=\"flex items-baseline gap-2\">\n        {done ? (\n          <span aria-hidden=\"true\" className=\"text-2xl leading-none text-primary\">\n            ✓\n          </span>\n        ) : null}\n        <p data-testid=\"tracker-eta\" className=\"flex items-baseline gap-2\">\n          <span className=\"text-sm text-muted-foreground\">{done ? copy.deliveredAt : copy.arrivesAt}</span>{\" \"}\n          <span className=\"text-3xl leading-none font-semibold tracking-tight tabular-nums\">{arrival}</span>\n        </p>\n      </div>\n      <p data-testid=\"tracker-left\" className=\"text-sm tabular-nums\">\n        {done ? (\n          <span className=\"text-muted-foreground\">{`${stopName(delivery.stop, lang)}, ${delivery.stop.address.split(\",\")[0]}`}</span>\n        ) : (\n          <>\n            <span className=\"font-medium\">{formatRemaining(remaining, lang)}</span>\n            <span className=\"text-muted-foreground\">{`, ${copy.toGo(formatDistance(delivery.distanceMeters * (1 - progress), lang))}`}</span>\n          </>\n        )}\n      </p>\n      {/* The line on the map, echoed: how much of the trip is behind the parcel. */}\n      <div aria-hidden=\"true\" className=\"h-1 w-full overflow-hidden rounded-full bg-muted\">\n        <div className=\"h-full rounded-full bg-primary\" style={{ width: `${progress * 100}%` }} />\n      </div>\n    </section>\n  );\n}\n\n/**\n * The trip's four stages. Always there, so the card keeps its shape: before a delivery they\n * preview the trip to the picked branch, none of them reached.\n */\nfunction Stages({ clock, delivery, target, lang }: { clock: TripClock; delivery: Delivery | null; target: TrackerStop; lang: TrackerLang }) {\n  const copy = TRACKER_COPY[lang];\n  const { stage } = useTrip(clock, delivery);\n  const done = stage === \"done\";\n  const depot = stopName(DEPOT, lang);\n  const steps: Array<{ key: string; label: string; time: string; state: StepState }> = delivery\n    ? [\n        { key: \"departed\", label: copy.departed(depot), time: formatClock(delivery.startedAt, lang), state: \"done\" },\n        { key: \"way\", label: copy.onTheWay, time: \"\", state: stage === \"way\" ? \"current\" : \"done\" },\n        { key: \"soon\", label: copy.almost, time: \"\", state: stage === \"soon\" ? \"current\" : done ? \"done\" : \"upcoming\" },\n        {\n          key: \"at\",\n          label: copy.at(stopName(delivery.stop, lang)),\n          time: formatClock(delivery.startedAt + delivery.durationSeconds * 1000, lang),\n          state: done ? \"done\" : \"upcoming\",\n        },\n      ]\n    : [\n        { key: \"departed\", label: copy.departs(depot), time: \"\", state: \"upcoming\" },\n        { key: \"way\", label: copy.onTheWay, time: \"\", state: \"upcoming\" },\n        { key: \"soon\", label: copy.almost, time: \"\", state: \"upcoming\" },\n        { key: \"at\", label: copy.at(stopName(target, lang)), time: \"\", state: \"upcoming\" },\n      ];\n  return (\n    <ol aria-label={copy.progress} className=\"flex flex-col\">\n      {steps.map((step, index) => (\n        <li\n          key={step.key}\n          data-state={step.state}\n          aria-current={step.state === \"current\" ? \"step\" : undefined}\n          className=\"relative flex items-center gap-3 py-1 text-sm data-[state=upcoming]:text-muted-foreground data-[state=current]:font-medium\"\n        >\n          {/* The rail between stages, filled once the stage above it is behind the parcel. */}\n          {index < steps.length - 1 ? (\n            <span\n              aria-hidden=\"true\"\n              data-state={step.state}\n              className=\"absolute top-1/2 left-2 h-full -translate-x-1/2 border-l-2 border-dashed border-border data-[state=done]:border-solid data-[state=done]:border-primary\"\n            />\n          ) : null}\n          <span\n            aria-hidden=\"true\"\n            data-state={step.state}\n            className=\"relative flex size-4 shrink-0 items-center justify-center rounded-full border-2 border-muted-foreground bg-background text-[0.625rem] leading-none data-[state=current]:border-primary data-[state=done]:border-primary data-[state=done]:bg-primary data-[state=done]:text-primary-foreground\"\n          >\n            {step.state === \"done\" ? \"✓\" : step.state === \"current\" ? <span className=\"size-1.5 rounded-full bg-primary\" /> : null}\n          </span>\n          <span className=\"min-w-0 flex-1\">\n            {step.label}\n            {step.state === \"done\" ? <span className=\"sr-only\"> {copy.stepDone}</span> : null}\n          </span>\n          {/* Always rendered, so a time arriving never reflows the row. */}\n          <span className=\"min-w-10 shrink-0 text-right text-xs text-muted-foreground tabular-nums\">{step.time}</span>\n        </li>\n      ))}\n    </ol>\n  );\n}\n\n/** Tells the block when the trip reaches a new stage, so it speaks once per stage, never per tick. */\nfunction StageWatch({\n  clock,\n  delivery,\n  onStage,\n}: {\n  clock: TripClock;\n  delivery: Delivery;\n  onStage: (delivery: Delivery, stage: TrackerStage, remaining: number) => void;\n}) {\n  const { stage, remaining } = useTrip(clock, delivery);\n  useEffect(() => {\n    if (stage) onStage(delivery, stage, remaining);\n    // Only a new stage (or a new delivery) is news.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [delivery, stage]);\n  return null;\n}\n\n/** Everything that needs the map. It renders inside <Map>, where useMap() can see it. */\nfunction Tracker({ apiKey, gateway, speed, lang }: { apiKey: string; gateway?: string; speed: number; lang: TrackerLang }) {\n  const copy = TRACKER_COPY[lang];\n  const map = useMap(TRACKER_MAP_ID);\n  const router = useMemo(() => new Router({ key: apiKey, gateway }), [apiKey, gateway]);\n  // Each branch's route, asked once per key: a restart replays it rather than paying for it again.\n  const routes = useMemo(() => {\n    void router;\n    return new globalThis.Map<string, Answer>();\n  }, [router]);\n  const [clock] = useState(() => createTripClock());\n  const [stopId, setStopId] = useState(DROP_OFFS[0]!.id);\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const [delivery, setDelivery] = useState<Delivery | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  // One polite sentence per stage of the trip, never one per tick.\n  const [said, setSaid] = useState(\"\");\n  // On a phone the sheet can fold to the headline, to give the map back.\n  const [folded, setFolded] = useState(false);\n  const abortRef = useRef<AbortController | null>(null);\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const actionRef = useRef<HTMLButtonElement | null>(null);\n  const announcedFor = useRef<{ delivery: Delivery | null; stage: TrackerStage | null }>({ delivery: null, stage: null });\n  const bodyId = useId();\n  const addressIds = useId();\n\n  const picked = DROP_OFFS.find((stop) => stop.id === stopId) ?? DROP_OFFS[0]!;\n  const target = delivery?.stop ?? picked;\n  const isFolded = folded && delivery !== null;\n\n  function padding(gutter = GUTTER): TrackerPadding {\n    const container = map?.getContainer();\n    if (!container) return panelPadding({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }, null, gutter);\n    return panelPadding(container.getBoundingClientRect(), panelRef.current?.getBoundingClientRect() ?? null, gutter);\n  }\n  const duration = () => (prefersReducedMotion() ? 0 : CAMERA_MS);\n  function frame(coordinates: [number, number][]) {\n    const height = map?.getContainer().getBoundingClientRect().height ?? 0;\n    map?.fitBounds(lineBounds(coordinates), { padding: withPinRoom(padding(FRAME_GUTTER), height), maxZoom: ROUTE_MAX_ZOOM, duration: duration() });\n  }\n\n  useEffect(\n    () => () => {\n      abortRef.current?.abort();\n      abortRef.current = null;\n      clock.stop();\n    },\n    [clock],\n  );\n\n  useEffect(() => {\n    clock.setSpeed(speed);\n  }, [clock, speed]);\n\n  // The map opens on every branch, clear of the panel.\n  useEffect(() => {\n    const height = map?.getContainer().getBoundingClientRect().height ?? 0;\n    map?.fitBounds(STOPS_BOUNDS, { padding: withPinRoom(padding(FRAME_GUTTER), height), maxZoom: ROUTE_MAX_ZOOM, duration: 0 });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map]);\n\n  // A new route is framed once it is on screen, so the panel has its final size.\n  useEffect(() => {\n    if (delivery) frame(delivery.coordinates);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [delivery]);\n\n  // Folding or unfolding the sheet changes how much map there is: frame the route again.\n  const wasFolded = useRef(isFolded);\n  useEffect(() => {\n    if (wasFolded.current === isFolded) return;\n    wasFolded.current = isFolded;\n    if (delivery) frame(delivery.coordinates);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isFolded]);\n\n  // Say when the delivery starts, when it is almost there, and when it lands; nothing in between.\n  function onStage(current: Delivery, stage: TrackerStage, remaining: number) {\n    const before = announcedFor.current;\n    if (before.delivery === current && before.stage === stage) return;\n    announcedFor.current = { delivery: current, stage };\n    const name = stopName(current.stop, lang);\n    const arrival = formatClock(current.startedAt + current.durationSeconds * 1000, lang);\n    const left = midSentence(formatRemaining(remaining, lang));\n    if (stage === \"done\") setSaid(copy.announceDone(name, arrival));\n    else if (before.delivery !== current) setSaid(copy.announceStart(name, arrival, left));\n    else if (stage === \"soon\") setSaid(copy.announceSoon(name, left));\n  }\n\n  function choose(value: unknown) {\n    const next = DROP_OFFS.find((stop) => stop.id === value);\n    if (next) setStopId(next.id);\n  }\n\n  function unhover(id: string) {\n    setHoveredId((current) => (current === id ? null : current));\n  }\n\n  /** Plays a route from its start: the clock first, so nothing reads the last trip's time against this one. */\n  function begin(stop: TrackerStop, answer: Answer) {\n    clock.run(answer.durationSeconds, speed);\n    setDelivery({ ...answer, startedAt: Date.now(), stop });\n  }\n\n  async function start() {\n    abortRef.current?.abort();\n    abortRef.current = null;\n    const stop = picked;\n    setError(null);\n    setFolded(false);\n    const known = routes.get(stop.id);\n    if (known) {\n      setLoading(false);\n      begin(stop, known);\n      return;\n    }\n    const controller = new AbortController();\n    abortRef.current = controller;\n    // The running parcel stops where it is; its route and stages stay until the new route lands.\n    clock.stop();\n    setLoading(true);\n    setSaid(copy.loading);\n    try {\n      const result = await router.route(DEPOT.lngLat, stop.lngLat, { mode: \"auto\", signal: controller.signal });\n      if (abortRef.current !== controller) return;\n      const answer: Answer = {\n        coordinates: result.geometry.coordinates as [number, number][],\n        distanceMeters: result.distanceMeters,\n        durationSeconds: Math.max(1, result.durationSeconds),\n      };\n      routes.set(stop.id, answer);\n      begin(stop, answer);\n    } catch (caught) {\n      if (abortRef.current === controller && !isAbort(caught)) {\n        setError(requestError(caught, lang));\n        setSaid(\"\");\n      }\n    } finally {\n      if (abortRef.current === controller) {\n        abortRef.current = null;\n        setLoading(false);\n      }\n    }\n  }\n\n  function retry() {\n    // The alert goes away with the retry: focus lands on the action, which stays where it is.\n    actionRef.current?.focus();\n    void start();\n  }\n\n  const depotSide = labelSide(DEPOT, target);\n  const targetSide = labelSide(target, DEPOT);\n  const courierSide = depotSide === \"right\" ? \"left\" : \"right\";\n\n  return (\n    <>\n      {delivery ? <TripOnMap clock={clock} delivery={delivery} lang={lang} side={courierSide} /> : null}\n      {delivery ? <StageWatch clock={clock} delivery={delivery} onStage={onStage} /> : null}\n      {/* The depot: a dot in --foreground. The drop-off: your pin in --primary. The other branches: quiet rings. Each named where it matters. */}\n      <Marker\n        mapId={TRACKER_MAP_ID}\n        lngLat={DEPOT.lngLat}\n        markerContent={<span className=\"block size-4 rounded-full border-[3px] border-background bg-foreground\" />}\n      >\n        <MarkerLabel position={depotSide}>{stopName(DEPOT, lang)}</MarkerLabel>\n      </Marker>\n      {DROP_OFFS.map((stop) => {\n        if (stop.id === target.id) {\n          return (\n            <Marker key={`${stop.id}:target`} mapId={TRACKER_MAP_ID} lngLat={stop.lngLat} onClick={() => choose(stop.id)}>\n              <MarkerLabel position={targetSide}>{stopName(stop, lang)}</MarkerLabel>\n            </Marker>\n          );\n        }\n        const lit = stop.id === picked.id || stop.id === hoveredId;\n        return (\n          <Marker\n            key={`${stop.id}:ring`}\n            mapId={TRACKER_MAP_ID}\n            lngLat={stop.lngLat}\n            onClick={() => choose(stop.id)}\n            markerContent={\n              // Hovering the ring lights its row in the list, as hovering the row names the ring.\n              <span\n                data-lit={lit}\n                onPointerEnter={() => setHoveredId(stop.id)}\n                onPointerLeave={() => unhover(stop.id)}\n                className=\"block size-3 rounded-full border-2 border-muted-foreground bg-background transition-transform data-[lit=true]:scale-125 data-[lit=true]:border-primary\"\n              />\n            }\n          >\n            {lit ? <MarkerLabel position=\"right\">{stopName(stop, lang)}</MarkerLabel> : null}\n          </Marker>\n        );\n      })}\n      <p data-testid=\"tracker-announce\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </p>\n      {/* A card floating on the map at 24 px; below `sm`, a sheet across the bottom (at most half the map) that clears the attribution. */}\n      <div\n        ref={panelRef}\n        className=\"absolute inset-x-4 bottom-10 z-10 flex max-h-[50%] flex-col sm:inset-x-auto sm:top-6 sm:bottom-auto sm:left-6 sm:max-h-[calc(100%-3rem)] sm:w-[22rem] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-300 sm:motion-safe:slide-in-from-bottom-0 sm:motion-safe:slide-in-from-left-4\"\n      >\n        <Card className=\"flex min-h-0 flex-1 flex-col\">\n          <CardHeader>\n            <div className=\"flex items-center justify-between gap-2\">\n              <CardTitle role=\"heading\" aria-level={2} className=\"min-w-0\">\n                {copy.title}\n              </CardTitle>\n              {delivery ? (\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"shrink-0 sm:hidden\"\n                  aria-expanded={!isFolded}\n                  aria-controls={bodyId}\n                  onClick={() => setFolded((current) => !current)}\n                >\n                  {isFolded ? copy.unfold : copy.fold}\n                </Button>\n              ) : null}\n            </div>\n            <CardDescription data-folded={isFolded} className=\"flex items-center gap-2 max-sm:data-[folded=true]:hidden\">\n              <span aria-hidden=\"true\" className=\"size-3 shrink-0 rounded-full bg-foreground\" />\n              {copy.from(stopName(DEPOT, lang))}\n              <span className=\"min-w-0 truncate\">{`· ${DEPOT.address.split(\",\")[0]}`}</span>\n            </CardDescription>\n          </CardHeader>\n          <CardContent className=\"flex min-h-0 flex-1 flex-col gap-4\">\n            {/* The headline: always there and tall enough for the answer, an error and its retry, so nothing jumps when it arrives. */}\n            <div data-testid=\"tracker-answer\" className=\"flex min-h-20 shrink-0 flex-col justify-center gap-2\">\n              {error ? (\n                <Alert variant=\"destructive\" role=\"alert\">\n                  <AlertDescription className=\"flex flex-row flex-wrap items-center justify-between gap-2\">\n                    <span className=\"min-w-0\">{error}</span>\n                    <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"shrink-0\" onClick={retry}>\n                      {copy.retry}\n                    </Button>\n                  </AlertDescription>\n                </Alert>\n              ) : loading ? (\n                <p className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                  <Spinner /> <span>{copy.loading}</span>\n                </p>\n              ) : delivery ? (\n                <Headline clock={clock} delivery={delivery} lang={lang} />\n              ) : (\n                <p className=\"text-sm text-muted-foreground\">{copy.empty}</p>\n              )}\n            </div>\n            {/* Below the headline the branches, the trip's stages and the credits scroll; the action stays put. Every section is always there, so Start and Restart never move anything. */}\n            <div\n              id={bodyId}\n              data-testid=\"tracker-body\"\n              data-folded={isFolded}\n              className=\"-mx-2 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain px-2 max-sm:data-[folded=true]:hidden\"\n            >\n              {/*\n                Stock toggle-group.tsx keeps `orientation` for its own styling and does not hand it\n                to Base UI, so the arrow keys stay Left and Right; pass `orientation={orientation}`\n                to ToggleGroupPrimitive there and Up and Down move through this list.\n              */}\n              <ToggleGroup\n                aria-label={copy.dropOff}\n                orientation=\"vertical\"\n                spacing={1}\n                value={[stopId]}\n                onValueChange={(value: unknown[]) => choose(value[0])}\n                className=\"w-full flex-col items-stretch\"\n              >\n                {DROP_OFFS.map((stop, index) => {\n                  const on = stop.id === stopId;\n                  const addressId = `${addressIds}-${index}`;\n                  return (\n                    <ToggleGroupItem\n                      key={stop.id}\n                      value={stop.id}\n                      aria-describedby={addressId}\n                      data-lit={hoveredId === stop.id}\n                      onPointerEnter={() => setHoveredId(stop.id)}\n                      onPointerLeave={() => unhover(stop.id)}\n                      onFocus={() => setHoveredId(stop.id)}\n                      onBlur={() => unhover(stop.id)}\n                      className=\"h-auto w-full justify-start gap-3 py-2 text-left aria-pressed:font-semibold data-[lit=true]:bg-muted\"\n                    >\n                      {/* Picked is marked by a check as well as the fill, and matches the pin on the map. */}\n                      <span\n                        aria-hidden=\"true\"\n                        data-on={on}\n                        className=\"flex size-4 shrink-0 items-center justify-center rounded-full border-2 border-muted-foreground bg-background text-[0.625rem] leading-none data-[on=true]:border-primary data-[on=true]:bg-primary data-[on=true]:text-primary-foreground\"\n                      >\n                        {on ? \"✓\" : null}\n                      </span>\n                      <span className=\"min-w-0\">{stopName(stop, lang)}</span>{\" \"}\n                      <span id={addressId} aria-hidden=\"true\" className=\"ml-auto min-w-0 truncate text-xs font-normal text-muted-foreground\">\n                        {stop.address.split(\",\")[0]}\n                      </span>\n                    </ToggleGroupItem>\n                  );\n                })}\n              </ToggleGroup>\n              <div data-testid=\"tracker-stages\" className=\"flex flex-col gap-2 border-t pt-3\">\n                <Stages clock={clock} delivery={delivery} target={target} lang={lang} />\n                <p className=\"text-xs text-muted-foreground\">\n                  {copy.replay(speed)} {copy.replayHow}\n                </p>\n              </div>\n              <div data-testid=\"tracker-credits\" className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n                <p>\n                  {copy.stopsCredit}{\" \"}\n                  <a className=\"underline underline-offset-2\" href={STOPS_CREDIT.url} target=\"_blank\" rel=\"noreferrer\">\n                    {lang === \"fr\" ? STOPS_CREDIT.name_fr : STOPS_CREDIT.name}\n                  </a>\n                  .{\" \"}\n                  <a className=\"underline underline-offset-2\" href={STOPS_CREDIT.licenceUrl} target=\"_blank\" rel=\"noreferrer\">\n                    {lang === \"fr\" ? STOPS_CREDIT.licence_fr : STOPS_CREDIT.licence}\n                  </a>\n                </p>\n                <p>\n                  {copy.routesCredit}{\" \"}\n                  <a className=\"underline underline-offset-2\" href={ROUTING_CREDIT.sourceUrl} target=\"_blank\" rel=\"noreferrer\">\n                    {copy.osm}\n                  </a>\n                  ,{\" \"}\n                  <a className=\"underline underline-offset-2\" href={ROUTING_CREDIT.licenceUrl} target=\"_blank\" rel=\"noreferrer\">\n                    {copy.odbl}\n                  </a>\n                  .\n                </p>\n              </div>\n            </div>\n            {/* aria-disabled, not disabled, while routing: a disabled button would drop the reader's focus. */}\n            <Button\n              ref={actionRef}\n              type=\"button\"\n              data-testid=\"tracker-action\"\n              aria-disabled={loading || undefined}\n              data-folded={isFolded}\n              className=\"w-full shrink-0 aria-disabled:opacity-50 max-sm:data-[folded=true]:hidden\"\n              onClick={() => {\n                if (!loading) void start();\n              }}\n            >\n              {delivery && delivery.stop.id === stopId ? copy.restart : copy.start}\n            </Button>\n          </CardContent>\n        </Card>\n      </div>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "envVars": {
    "NEXT_PUBLIC_UNMAP_KEY": ""
  },
  "docs": "Paste your unmap key into NEXT_PUBLIC_UNMAP_KEY in .env.local, then open /delivery-tracker. Guide: https://unmap.dev/docs/frontend/blocks#track-a-delivery",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}