{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transfer-preview",
  "title": "Transfer Preview",
  "description": "The marketplace transfer-proposal flow: 70° cinematic camera choreography over a route, pulsing endpoint pins, and a bottom dock with source/destination cards, animated truck stats, a volume slider and a save-proposal callback.",
  "dependencies": [
    "@untitledui/icons@^0.0.22",
    "motion@^12.42.2",
    "react-map-gl@^8.1.1"
  ],
  "registryDependencies": [
    "@nx-ui/button",
    "@nx-ui/route-tool",
    "@nx-ui/slider",
    "@nx-ui/spinner",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/maps/transfer-preview.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ChevronDown,\n  ChevronUp,\n  MarkerPin01,\n  NavigationPointer01,\n  Package,\n  Truck01,\n  XClose,\n} from \"@untitledui/icons\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport type { MapRef } from \"react-map-gl/mapbox\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Slider } from \"@/components/ui/slider\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport {\n  formatRouteDistance,\n  formatRouteDuration,\n  RouteWaypointPin,\n  type RouteData,\n} from \"@/components/blocks/maps/route-tool\"\n\n/**\n * transfer-preview — the marketplace transfer-proposal flow for nx-ui maps.\n *\n * When a source and a destination listing are linked by a route, the map\n * enters *transfer mode*: the camera pitches to 70° and flies to frame the\n * route in 3D, the listings render as pulsing numbered pins joined by the\n * yellow route line, and a three-card dock rises from the bottom — source\n * card (green, with a volume slider) → animated truck stats → destination\n * card (red) — with a \"Save transfer proposal\" call to action.\n *\n * Parts:\n *   • useTransferCamera() — the camera choreography, faithful to the\n *     marketplace timings: pitch ease 70°/800 ms → +200 ms fitBounds to the\n *     route bbox (1000 ms, pitch 70, deep bottom padding so the route frames\n *     above the dock) → corrective pitch ease at 1100 ms — and the reverse\n *     restore on exit. Reduced motion collapses every move to a jump cut.\n *   • <TransferWaypointPins> — the numbered green/red endpoint pins\n *     (MapView children; pair with route-tool's <RouteLineLayer>).\n *   • <TransferPreviewDock> — the bottom dock (MapView `overlay` slot):\n *     focus is moved into it on entry and restored on exit, and Escape exits\n *     transfer mode from anywhere.\n *\n * Data flows in via props and out via callbacks (`onSave` receives the\n * proposal payload) — no backend coupling. See the maps/transfer-preview\n * showcase page for the full composition.\n */\n\nexport interface TransferListing {\n  id: string\n  title: string\n  /** Display location, e.g. \"Winchester, Hampshire\". */\n  location: string\n  /** [longitude, latitude] */\n  coordinates: [number, number]\n  /** Available volume in cubic metres — the slider ceiling on the source. */\n  availableVolumeM3: number\n  /** Quantity line, e.g. \"5,400 t (≈ 3,200 m³)\". */\n  quantityLabel?: string\n}\n\nexport interface TransferProposal {\n  sourceListingId: string\n  destinationListingId: string\n  transferVolumeM3: number\n  routeDistanceMeters: number\n  routeDurationSeconds: number\n  truckCapacityM3: number\n  status: \"DRAFT\"\n}\n\n/** ~20-tonne rigid tipper. */\nexport const DEFAULT_TRUCK_CAPACITY_M3 = 15\n\n/** The extra bottom camera padding that frames the route above the dock. */\nexport const TRANSFER_PREVIEW_BOTTOM_PADDING = 320\n\n// ---------------------------------------------------------------------------\n// Camera choreography\n// ---------------------------------------------------------------------------\n\n/** Padded [[minLng, minLat], [maxLng, maxLat]] bounds of a coordinate list. */\nexport function transferRouteBounds(\n  coordinates: [number, number][] | number[][]\n): [[number, number], [number, number]] | null {\n  if (coordinates.length === 0) return null\n  let minLng = Infinity\n  let minLat = Infinity\n  let maxLng = -Infinity\n  let maxLat = -Infinity\n  for (const [lng, lat] of coordinates) {\n    minLng = Math.min(minLng, lng)\n    maxLng = Math.max(maxLng, lng)\n    minLat = Math.min(minLat, lat)\n    maxLat = Math.max(maxLat, lat)\n  }\n  return [\n    [minLng, minLat],\n    [maxLng, maxLat],\n  ]\n}\n\nexport interface UseTransferCameraOptions {\n  mapRef: React.RefObject<MapRef | null>\n  /** Transfer mode on/off — drives the enter/exit choreography. */\n  active: boolean\n  /** Route geometry coordinates (fall back to the two endpoints while the\n   * route is still loading). */\n  routeCoordinates: [number, number][] | number[][]\n  /** Bottom camera padding while the dock is up. */\n  bottomPadding?: number\n}\n\n/**\n * The transfer-mode camera. On activation it snapshots the current pitch,\n * eases to 70°, then flies to the route bounds; on exit it eases back to the\n * saved pitch. Returns `flyToRouteBounds` for the dock's reset-view button.\n */\nexport function useTransferCamera({\n  mapRef,\n  active,\n  routeCoordinates,\n  bottomPadding = TRANSFER_PREVIEW_BOTTOM_PADDING,\n}: UseTransferCameraOptions) {\n  const reduce = useReducedMotion() ?? false\n  const savedPitchRef = React.useRef<number | null>(null)\n  const coordsRef = React.useRef(routeCoordinates)\n  coordsRef.current = routeCoordinates\n\n  const flyToRouteBounds = React.useCallback(() => {\n    const map = mapRef.current?.getMap()\n    const bounds = transferRouteBounds(coordsRef.current)\n    if (!map || !bounds) return\n    const padding = map.getPadding()\n    map.fitBounds(bounds, {\n      padding: {\n        top: padding.top || 80,\n        bottom: bottomPadding,\n        left: padding.left || 80,\n        right: padding.right || 80,\n      },\n      duration: reduce ? 0 : 1000,\n      pitch: 70,\n    })\n    // Belt and braces: Mapbox occasionally lands shy of the target pitch, so\n    // re-assert it just after the animation (marketplace timing: 1100 ms).\n    window.setTimeout(\n      () => {\n        if (Math.abs(map.getPitch() - 70) > 1) {\n          map.easeTo({ pitch: 70, duration: reduce ? 0 : 400 })\n        }\n      },\n      reduce ? 50 : 1100\n    )\n  }, [mapRef, bottomPadding, reduce])\n\n  // Enter: save pitch → ease to 70° (800 ms) → fly to bounds 200 ms later.\n  // Exit/unmount (the effect cleanup): ease back to the saved pitch.\n  React.useEffect(() => {\n    if (!active) return\n    const map = mapRef.current?.getMap()\n    if (!map) return\n    savedPitchRef.current = map.getPitch()\n    map.easeTo({ pitch: 70, duration: reduce ? 0 : 800 })\n    const timer = window.setTimeout(flyToRouteBounds, reduce ? 0 : 200)\n    return () => {\n      window.clearTimeout(timer)\n      const restored = savedPitchRef.current ?? 0\n      savedPitchRef.current = null\n      mapRef.current\n        ?.getMap()\n        ?.easeTo({ pitch: restored, duration: reduce ? 0 : 800 })\n    }\n  }, [active, mapRef, flyToRouteBounds, reduce])\n\n  // Re-frame when the real route geometry lands while transfer mode is up\n  // (the first fly may have framed the straight-line fallback).\n  const routeKey =\n    routeCoordinates.length > 1\n      ? `${routeCoordinates.length}:${routeCoordinates[0]?.join()}:${routeCoordinates[routeCoordinates.length - 1]?.join()}`\n      : \"\"\n  const activeRef = React.useRef(active)\n  activeRef.current = active\n  React.useEffect(() => {\n    if (activeRef.current && routeKey) flyToRouteBounds()\n  }, [routeKey, flyToRouteBounds])\n\n  return { flyToRouteBounds }\n}\n\n// ---------------------------------------------------------------------------\n// Map pins (render inside the map)\n// ---------------------------------------------------------------------------\n\nexport interface TransferWaypointPinsProps {\n  source: TransferListing\n  destination: TransferListing\n}\n\n/** Pin 1 (green) on the source, pin 2 (red) on the destination, both with\n * pulsing halos — the transfer-mode endpoints. */\nexport function TransferWaypointPins({\n  source,\n  destination,\n}: TransferWaypointPinsProps) {\n  return (\n    <>\n      <RouteWaypointPin\n        longitude={source.coordinates[0]}\n        latitude={source.coordinates[1]}\n        index={0}\n        role=\"origin\"\n      />\n      <RouteWaypointPin\n        longitude={destination.coordinates[0]}\n        latitude={destination.coordinates[1]}\n        index={1}\n        role=\"destination\"\n      />\n    </>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Dock internals\n// ---------------------------------------------------------------------------\n\nconst TONE_CLASSES = {\n  source:\n    \"border-green-200 bg-green-50/80 dark:border-green-500/25 dark:bg-green-950/40\",\n  destination:\n    \"border-red-200 bg-red-50/80 dark:border-red-500/25 dark:bg-red-950/40\",\n} as const\n\nconst TONE_BADGE_CLASSES = {\n  source: \"text-green-700 dark:text-green-300\",\n  destination: \"text-red-700 dark:text-red-300\",\n} as const\n\nexport interface TransferListingCardProps {\n  listing: TransferListing\n  tone: keyof typeof TONE_CLASSES\n  /** Volume slider (source card only). */\n  volumeM3?: number\n  onVolumeChange?: (volumeM3: number) => void\n  volumeStep?: number\n  className?: string\n}\n\n/** One end of the transfer: green-tinted source (with the volume slider) or\n * red-tinted destination. */\nexport function TransferListingCard({\n  listing,\n  tone,\n  volumeM3,\n  onVolumeChange,\n  volumeStep = 0.1,\n  className,\n}: TransferListingCardProps) {\n  const showSlider = tone === \"source\" && onVolumeChange !== undefined\n  return (\n    <div\n      className={cn(\n        \"flex min-w-0 flex-col gap-1.5 rounded-lg border p-3\",\n        TONE_CLASSES[tone],\n        className\n      )}\n    >\n      <p\n        className={cn(\n          \"text-[10px] font-semibold tracking-wide uppercase\",\n          TONE_BADGE_CLASSES[tone]\n        )}\n      >\n        {tone === \"source\" ? \"Source\" : \"Destination\"}\n      </p>\n      <p className=\"line-clamp-2 text-sm font-semibold text-foreground\">\n        {listing.title}\n      </p>\n      <p className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\">\n        <MarkerPin01 className=\"size-3 shrink-0\" />\n        <span className=\"truncate\">{listing.location}</span>\n      </p>\n      {listing.quantityLabel ? (\n        <p className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\">\n          <Package className=\"size-3 shrink-0\" />\n          <span className=\"truncate\">{listing.quantityLabel}</span>\n        </p>\n      ) : null}\n      {showSlider ? (\n        <div className=\"mt-1 flex flex-col gap-1.5\">\n          <Slider\n            aria-label=\"Transfer volume\"\n            value={[Math.min(volumeM3 ?? 0, listing.availableVolumeM3)]}\n            min={0}\n            max={listing.availableVolumeM3}\n            step={volumeStep}\n            onValueChange={(value) =>\n              onVolumeChange?.(\n                Array.isArray(value) ? (value[0] ?? 0) : (value as number)\n              )\n            }\n          />\n          <p className=\"text-xs font-medium text-secondary-foreground tabular-nums\">\n            Transfer volume {(volumeM3 ?? 0).toFixed(1)} m³\n          </p>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport interface TransferStatsProps {\n  route: RouteData | null\n  loading?: boolean\n  volumeM3: number\n  /** Slider ceiling — volumes above it raise the over-capacity warning. */\n  availableVolumeM3?: number\n  truckCapacityM3?: number\n  distanceUnit?: \"km\" | \"mi\"\n  className?: string\n}\n\n/** The centre flourish: cargo dots flowing through a yellow truck badge, over\n * a Distance / Loads / Duration stat row. */\nexport function TransferStats({\n  route,\n  loading,\n  volumeM3,\n  availableVolumeM3,\n  truckCapacityM3 = DEFAULT_TRUCK_CAPACITY_M3,\n  distanceUnit = \"mi\",\n  className,\n}: TransferStatsProps) {\n  const loads = Math.max(1, Math.ceil(volumeM3 / truckCapacityM3))\n  const overCapacity =\n    availableVolumeM3 !== undefined && volumeM3 > availableVolumeM3\n\n  return (\n    <div className={cn(\"flex min-w-0 flex-col justify-center gap-2\", className)}>\n      {/* Flowing cargo dots → truck. Decorative, so hidden from readers and\n          stilled under reduced motion (the dots keep a static presence). */}\n      <div className=\"relative flex h-10 items-center justify-center\" aria-hidden>\n        <span className=\"absolute inset-x-1 top-1/2 -translate-y-1/2 border-t-2 border-dashed border-accent-400/50\" />\n        {Array.from({ length: 16 }, (_, index) => (\n          <span\n            key={index}\n            className=\"absolute top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent-400 motion-safe:animate-pulse\"\n            style={{\n              left: `${4 + (index * 92) / 15}%`,\n              animationDelay: `${index * 0.1}s`,\n              animationDuration: \"1.5s\",\n            }}\n          />\n        ))}\n        <span className=\"relative z-10 flex size-9 items-center justify-center rounded-full bg-accent-400 text-brand-600 shadow-md ring-4 ring-background/80\">\n          <Truck01 className=\"size-5\" />\n        </span>\n      </div>\n\n      <div className=\"grid grid-cols-3 gap-2 text-center\">\n        <div>\n          <p className=\"text-[10px] font-medium tracking-wide text-muted-foreground uppercase\">\n            Distance\n          </p>\n          <p className=\"text-sm font-semibold text-foreground tabular-nums\">\n            {route ? formatRouteDistance(route.distanceMeters, distanceUnit) : \"—\"}\n          </p>\n        </div>\n        <div>\n          <p className=\"text-[10px] font-medium tracking-wide text-muted-foreground uppercase\">\n            Loads\n          </p>\n          <p className=\"text-sm font-semibold text-foreground tabular-nums\">\n            {volumeM3 > 0 ? `${loads} ${loads === 1 ? \"truck\" : \"trucks\"}` : \"—\"}\n          </p>\n        </div>\n        <div>\n          <p className=\"text-[10px] font-medium tracking-wide text-muted-foreground uppercase\">\n            Duration\n          </p>\n          <p className=\"text-sm font-semibold text-foreground tabular-nums\">\n            {route ? formatRouteDuration(route.durationSeconds) : \"—\"}\n          </p>\n        </div>\n      </div>\n\n      {loading ? (\n        <p className=\"inline-flex items-center justify-center gap-1.5 text-xs text-muted-foreground\">\n          <Spinner className=\"size-3\" />\n          Calculating route…\n        </p>\n      ) : overCapacity ? (\n        <p className=\"text-center text-xs font-medium text-text-warning-primary\">\n          Volume exceeds what the source has available.\n        </p>\n      ) : (\n        <p className=\"text-center text-xs text-muted-foreground tabular-nums\">\n          Transfer volume {volumeM3.toFixed(1)} m³\n        </p>\n      )}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// The dock\n// ---------------------------------------------------------------------------\n\nexport interface TransferPreviewDockProps {\n  source: TransferListing\n  destination: TransferListing\n  route: RouteData | null\n  loading?: boolean\n  volumeM3: number\n  onVolumeChange: (volumeM3: number) => void\n  truckCapacityM3?: number\n  distanceUnit?: \"km\" | \"mi\"\n  /** Fired with the proposal payload — wire it to your own persistence. */\n  onSave?: (proposal: TransferProposal) => void\n  /** Exit transfer mode (close button, Escape). */\n  onExit: () => void\n  /** Re-frame the route (the dock's navigation button); usually\n   * `useTransferCamera().flyToRouteBounds`. */\n  onResetView?: () => void\n  saveLabel?: string\n  className?: string\n}\n\n/**\n * The bottom transfer dock. Positions itself along the bottom of the nearest\n * positioned ancestor (drop it in MapView's `overlay` slot). Rises with a\n * 300 ms slide (a jump cut under reduced motion), takes focus on entry,\n * restores it on exit, and exits on Escape. Minimises to a pill.\n */\nexport function TransferPreviewDock({\n  source,\n  destination,\n  route,\n  loading,\n  volumeM3,\n  onVolumeChange,\n  truckCapacityM3 = DEFAULT_TRUCK_CAPACITY_M3,\n  distanceUnit = \"mi\",\n  onSave,\n  onExit,\n  onResetView,\n  saveLabel = \"Save transfer proposal\",\n  className,\n}: TransferPreviewDockProps) {\n  const reduce = useReducedMotion() ?? false\n  const [minimised, setMinimised] = React.useState(false)\n  const regionRef = React.useRef<HTMLDivElement>(null)\n\n  const onExitRef = React.useRef(onExit)\n  onExitRef.current = onExit\n\n  // Focus management + keyboard escape: move focus into the dock when it\n  // rises, exit transfer mode on Escape from anywhere, and hand focus back\n  // to wherever the user was when the dock closes.\n  React.useEffect(() => {\n    const previous = document.activeElement as HTMLElement | null\n    regionRef.current?.focus({ preventScroll: true })\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") onExitRef.current()\n    }\n    document.addEventListener(\"keydown\", onKeyDown)\n    return () => {\n      document.removeEventListener(\"keydown\", onKeyDown)\n      previous?.focus?.({ preventScroll: true })\n    }\n  }, [])\n\n  const proposal: TransferProposal | null = route\n    ? {\n        sourceListingId: source.id,\n        destinationListingId: destination.id,\n        transferVolumeM3: Math.round(volumeM3 * 10) / 10,\n        routeDistanceMeters: route.distanceMeters,\n        routeDurationSeconds: route.durationSeconds,\n        truckCapacityM3,\n        status: \"DRAFT\",\n      }\n    : null\n\n  return (\n    <div\n      data-slot=\"transfer-preview-dock\"\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 bottom-3 z-30 flex justify-center px-3\",\n        className\n      )}\n    >\n      <motion.div\n        initial={{ y: 100, opacity: 0 }}\n        animate={{ y: 0, opacity: 1 }}\n        transition={{ duration: reduce ? 0 : 0.3, ease: \"easeOut\" }}\n        className=\"pointer-events-auto w-full max-w-3xl\"\n      >\n        {minimised ? (\n          <div className=\"flex justify-center\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              size=\"sm\"\n              onClick={() => setMinimised(false)}\n              className=\"bg-background/90 shadow-lg backdrop-blur-md\"\n            >\n              <Truck01 />\n              Transfer preview\n              <ChevronUp />\n            </Button>\n          </div>\n        ) : (\n          <div\n            ref={regionRef}\n            role=\"region\"\n            aria-label=\"Transfer preview\"\n            tabIndex={-1}\n            className=\"flex flex-col gap-3 rounded-xl border border-border/70 bg-background/90 p-3.5 shadow-2xl backdrop-blur-md outline-none sm:p-4\"\n          >\n            <div className=\"flex items-center justify-between gap-2\">\n              <p className=\"text-sm font-semibold text-foreground\">\n                Transfer preview\n                <span className=\"ml-2 hidden text-xs font-normal text-muted-foreground sm:inline\">\n                  Esc to exit\n                </span>\n              </p>\n              <span className=\"flex items-center gap-0.5\">\n                {onResetView ? (\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon-xs\"\n                    aria-label=\"Reset view to route\"\n                    onClick={onResetView}\n                    className=\"text-fg-quaternary hover:text-foreground\"\n                  >\n                    <NavigationPointer01 />\n                  </Button>\n                ) : null}\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon-xs\"\n                  aria-label=\"Minimise transfer preview\"\n                  onClick={() => setMinimised(true)}\n                  className=\"text-fg-quaternary hover:text-foreground\"\n                >\n                  <ChevronDown />\n                </Button>\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon-xs\"\n                  aria-label=\"Exit transfer preview\"\n                  onClick={onExit}\n                  className=\"text-fg-quaternary hover:text-foreground\"\n                >\n                  <XClose />\n                </Button>\n              </span>\n            </div>\n\n            <div className=\"grid grid-cols-1 gap-3 md:grid-cols-3\">\n              <TransferListingCard\n                listing={source}\n                tone=\"source\"\n                volumeM3={volumeM3}\n                onVolumeChange={onVolumeChange}\n              />\n              <div className=\"flex flex-col justify-center gap-2.5\">\n                <TransferStats\n                  route={route}\n                  loading={loading}\n                  volumeM3={volumeM3}\n                  availableVolumeM3={source.availableVolumeM3}\n                  truckCapacityM3={truckCapacityM3}\n                  distanceUnit={distanceUnit}\n                />\n                <Button\n                  type=\"button\"\n                  size=\"sm\"\n                  disabled={!proposal || volumeM3 <= 0}\n                  onClick={() => proposal && onSave?.(proposal)}\n                >\n                  {saveLabel}\n                </Button>\n              </div>\n              <TransferListingCard listing={destination} tone=\"destination\" />\n            </div>\n          </div>\n        )}\n      </motion.div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/maps/transfer-preview.tsx"
    }
  ],
  "type": "registry:block"
}