{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "listings-panel",
  "title": "Listings Panel",
  "description": "The right-hand Opportunities overlay: a virtualised, keyboard-navigable listings list with a live in-view count header, sort menu, tone-ringed selection and bidirectional hover/select sync with the map; collapses to an edge tab on desktop and a bottom sheet on mobile.",
  "dependencies": [
    "@tanstack/react-virtual@^3.14.5",
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/badge",
    "@nx-ui/button",
    "@nx-ui/dropdown-menu",
    "@nx-ui/marketplace-types",
    "@nx-ui/skeleton",
    "@nx-ui/use-marketplace",
    "@nx-ui/use-mobile",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/marketplace/listings-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useVirtualizer } from \"@tanstack/react-virtual\"\nimport {\n  ChevronDown,\n  ChevronRight,\n  ChevronUp,\n  List,\n  MarkerPin01,\n  SwitchVertical01,\n} from \"@untitledui/icons\"\n\nimport { useIsMobile } from \"@/hooks/use-mobile\"\nimport { cn } from \"@/lib/utils\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport {\n  formatDistanceKm,\n  formatGateFee,\n  isActiveListingStatus,\n  LISTING_SORTS,\n  listingTone,\n  type ListingSort,\n  type MarketplaceListing,\n} from \"@/components/blocks/marketplace/marketplace-types\"\nimport type { Marketplace } from \"@/components/blocks/marketplace/use-marketplace\"\n\n/**\n * listings-panel — the right-hand \"Opportunities\" overlay for the marketplace\n * map.\n *\n * A virtualised list (@tanstack/react-virtual) of the listings currently in\n * view, kept in bidirectional sync with the map through the use-marketplace\n * hook: hovering a row highlights its pin, selecting a row flies the camera\n * and opens the popup, and selecting a pin scrolls its row into view.\n *\n *   • Count header — \"312 of 4,935 in view\" — updates with every pan, filter\n *     and reach-filter change, with a truncation note when the server capped\n *     the result set.\n *   • Sort menu: nearest to centre / name / gate fee / recently updated.\n *   • Keyboard: rows are real buttons; ↑/↓ move between rows (across the\n *     virtual window) and Enter selects.\n *   • Collapses to an edge tab on desktop; on mobile it becomes a bottom\n *     sheet behind a \"Show n in view\" pill. It slides fully away while the\n *     transfer preview is running.\n */\n\nexport interface ListingsPanelProps {\n  marketplace: Marketplace\n  title?: string\n  className?: string\n  defaultCollapsed?: boolean\n}\n\n// ---------------------------------------------------------------------------\n// Row\n// ---------------------------------------------------------------------------\n\nconst ListingRow = React.memo(function ListingRow({\n  listing,\n  index,\n  hovered,\n  selected,\n  showDistance,\n  onHover,\n  onSelect,\n}: {\n  listing: MarketplaceListing\n  index: number\n  hovered: boolean\n  selected: boolean\n  showDistance: boolean\n  onHover: (id: string | null) => void\n  onSelect: (id: string) => void\n}) {\n  const tone = listingTone(listing.type)\n  const active = isActiveListingStatus(listing.status)\n  const fee = formatGateFee(listing)\n  const place = [listing.subRegion ?? listing.region, listing.postcode]\n    .filter(Boolean)\n    .join(\" · \")\n\n  return (\n    <button\n      type=\"button\"\n      data-listing-id={listing.id}\n      data-hovered={hovered || undefined}\n      data-selected={selected || undefined}\n      onMouseEnter={() => onHover(listing.id)}\n      onMouseLeave={() => onHover(null)}\n      onFocus={() => onHover(listing.id)}\n      onBlur={() => onHover(null)}\n      onClick={() => onSelect(listing.id)}\n      className={cn(\n        \"flex w-full cursor-pointer flex-col gap-1 rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors\",\n        \"hover:bg-muted focus-visible:ring-4 focus-visible:ring-ring/25 focus-visible:outline-none\",\n        hovered && !selected && \"bg-muted\",\n        selected && \"border-border bg-muted/60 shadow-xs ring-2\"\n      )}\n      style={selected ? ({ \"--tw-ring-color\": `${tone.color}59` } as React.CSSProperties) : undefined}\n    >\n      <span className=\"flex w-full items-start gap-2\">\n        <span\n          aria-hidden\n          className=\"mt-1 size-2.5 shrink-0 rounded-full ring-2 ring-background\"\n          style={{ backgroundColor: tone.color }}\n        />\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block truncate text-xs font-semibold text-foreground\">\n            {listing.name}\n          </span>\n          {listing.operator ? (\n            <span className=\"block truncate text-[11px] text-muted-foreground\">\n              {listing.operator}\n            </span>\n          ) : null}\n        </span>\n        {showDistance && listing.distanceKm !== undefined ? (\n          <span className=\"shrink-0 text-[11px] text-muted-foreground tabular-nums\">\n            {formatDistanceKm(listing.distanceKm)}\n          </span>\n        ) : null}\n      </span>\n      <span className=\"flex w-full items-center gap-1.5 pl-4.5\">\n        <MarkerPin01 className=\"size-3 shrink-0 text-fg-quaternary\" aria-hidden />\n        <span className=\"min-w-0 truncate text-[11px] text-muted-foreground\">\n          {place || listing.region}\n        </span>\n      </span>\n      <span className=\"flex w-full flex-wrap items-center gap-1 pl-4.5\">\n        <Badge variant=\"outline\" className=\"h-4.5 px-1.5 text-[10px]\">\n          <span className=\"truncate\">{listing.type}</span>\n        </Badge>\n        {!active ? (\n          <Badge variant=\"gray\" className=\"h-4.5 px-1.5 text-[10px]\">\n            {listing.status}\n          </Badge>\n        ) : null}\n        {fee ? (\n          <Badge variant=\"gray\" className=\"h-4.5 px-1.5 text-[10px] tabular-nums\">\n            {fee}\n          </Badge>\n        ) : null}\n      </span>\n    </button>\n  )\n})\n\n// ---------------------------------------------------------------------------\n// The panel\n// ---------------------------------------------------------------------------\n\nexport function ListingsPanel({\n  marketplace,\n  title = \"Opportunities\",\n  className,\n  defaultCollapsed = false,\n}: ListingsPanelProps) {\n  const isMobile = useIsMobile()\n  const [collapsed, setCollapsed] = React.useState(defaultCollapsed)\n  const [mobileOpen, setMobileOpen] = React.useState(false)\n\n  const {\n    visibleListings,\n    meta,\n    loading,\n    initialLoading,\n    sort,\n    setSort,\n    hoveredId,\n    setHoveredId,\n    selectedId,\n    select,\n    transferActive,\n    activeFilterCount,\n    clearFilters,\n  } = marketplace\n\n  const scrollRef = React.useRef<HTMLDivElement>(null)\n  const virtualizer = useVirtualizer({\n    count: visibleListings.length,\n    getScrollElement: () => scrollRef.current,\n    estimateSize: () => 96,\n    overscan: 8,\n    getItemKey: (index) => visibleListings[index]?.id ?? index,\n    gap: 4,\n  })\n\n  // Map pin selected → scroll its row into view.\n  React.useEffect(() => {\n    if (!selectedId) return\n    const index = visibleListings.findIndex((listing) => listing.id === selectedId)\n    if (index >= 0) virtualizer.scrollToIndex(index, { align: \"auto\" })\n  }, [selectedId, visibleListings, virtualizer])\n\n  const handleSelect = React.useCallback(\n    (id: string) => select(id, { flyTo: true }),\n    [select]\n  )\n\n  // ↑/↓ move focus between rows, hopping the virtual window as needed.\n  const handleListKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return\n      const target = event.target as HTMLElement\n      const current = Number(target.closest(\"[data-index]\")?.getAttribute(\"data-index\"))\n      if (!Number.isFinite(current)) return\n      event.preventDefault()\n      const next = Math.min(\n        Math.max(current + (event.key === \"ArrowDown\" ? 1 : -1), 0),\n        visibleListings.length - 1\n      )\n      if (next === current) return\n      virtualizer.scrollToIndex(next, { align: \"auto\" })\n      requestAnimationFrame(() => {\n        scrollRef.current\n          ?.querySelector<HTMLButtonElement>(`[data-index=\"${next}\"] button`)\n          ?.focus()\n      })\n    },\n    [visibleListings.length, virtualizer]\n  )\n\n  const countHeader = initialLoading ? (\n    <Skeleton className=\"h-4 w-28\" />\n  ) : (\n    <p\n      className=\"text-xs text-muted-foreground tabular-nums\"\n      data-testid=\"listings-count\"\n      aria-live=\"polite\"\n    >\n      <span className=\"font-semibold text-foreground\">\n        {visibleListings.length.toLocaleString()}\n      </span>{\" \"}\n      of {(meta?.total ?? 0).toLocaleString()} in view\n      {meta?.truncated ? (\n        <span className=\"text-text-warning-primary\"> · capped</span>\n      ) : null}\n    </p>\n  )\n\n  const listBody = (\n    <div\n      ref={scrollRef}\n      onKeyDown={handleListKeyDown}\n      className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-1.5 pb-1.5\"\n    >\n      {initialLoading ? (\n        <div className=\"flex flex-col gap-2 p-1.5\" aria-hidden>\n          {Array.from({ length: 8 }, (_, index) => (\n            <div key={index} className=\"flex flex-col gap-1.5 rounded-lg p-2\">\n              <Skeleton className=\"h-3.5 w-4/5\" />\n              <Skeleton className=\"h-3 w-3/5\" />\n              <Skeleton className=\"h-3 w-2/5\" />\n            </div>\n          ))}\n        </div>\n      ) : visibleListings.length === 0 ? (\n        <div className=\"flex flex-col items-center gap-2 rounded-lg border border-dashed border-border p-6 text-center\">\n          <p className=\"text-sm font-medium text-foreground\">No listings in view</p>\n          <p className=\"text-xs text-muted-foreground\">\n            Pan or zoom the map{activeFilterCount > 0 ? \", or relax the filters.\" : \".\"}\n          </p>\n          {activeFilterCount > 0 ? (\n            <Button type=\"button\" variant=\"secondary\" size=\"xs\" onClick={clearFilters}>\n              Clear all filters\n            </Button>\n          ) : null}\n        </div>\n      ) : (\n        <div\n          className=\"relative w-full\"\n          style={{ height: virtualizer.getTotalSize() }}\n        >\n          {virtualizer.getVirtualItems().map((item) => {\n            const listing = visibleListings[item.index]\n            if (!listing) return null\n            return (\n              <div\n                key={item.key}\n                ref={virtualizer.measureElement}\n                data-index={item.index}\n                className=\"absolute inset-x-0 top-0\"\n                style={{ transform: `translateY(${item.start}px)` }}\n              >\n                <ListingRow\n                  listing={listing}\n                  index={item.index}\n                  hovered={hoveredId === listing.id}\n                  selected={selectedId === listing.id}\n                  showDistance={sort === \"distance\"}\n                  onHover={setHoveredId}\n                  onSelect={handleSelect}\n                />\n              </div>\n            )\n          })}\n        </div>\n      )}\n    </div>\n  )\n\n  const header = (\n    <div className=\"flex items-center justify-between gap-2 px-3 pt-2.5 pb-2\">\n      <div className=\"min-w-0\">\n        <h2 className=\"text-sm font-semibold text-foreground\">{title}</h2>\n        {countHeader}\n      </div>\n      <div className=\"flex items-center gap-0.5\">\n        <DropdownMenu>\n          <DropdownMenuTrigger\n            render={\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon-sm\"\n                aria-label=\"Sort listings\"\n                className=\"text-fg-quaternary hover:text-foreground\"\n              >\n                <SwitchVertical01 />\n              </Button>\n            }\n          />\n          <DropdownMenuContent align=\"end\" className=\"w-56\">\n            <DropdownMenuGroup>\n              <DropdownMenuLabel>Sort by</DropdownMenuLabel>\n              <DropdownMenuRadioGroup\n                value={sort}\n                onValueChange={(value) => setSort(value as ListingSort)}\n              >\n                {LISTING_SORTS.map((option) => (\n                  <DropdownMenuRadioItem key={option.id} value={option.id}>\n                    {option.label}\n                  </DropdownMenuRadioItem>\n                ))}\n              </DropdownMenuRadioGroup>\n            </DropdownMenuGroup>\n          </DropdownMenuContent>\n        </DropdownMenu>\n        {isMobile ? (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            aria-label=\"Hide listings\"\n            onClick={() => setMobileOpen(false)}\n            className=\"text-fg-quaternary hover:text-foreground\"\n          >\n            <ChevronDown />\n          </Button>\n        ) : (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            aria-label=\"Collapse listings panel\"\n            onClick={() => setCollapsed(true)}\n            className=\"text-fg-quaternary hover:text-foreground\"\n          >\n            <ChevronRight />\n          </Button>\n        )}\n      </div>\n    </div>\n  )\n\n  const refreshHint = loading && !initialLoading ? (\n    <div className=\"pointer-events-none absolute inset-x-0 top-0 h-0.5 overflow-hidden rounded-t-xl\" aria-hidden>\n      <div className=\"h-full w-full animate-pulse bg-brand-500/60\" />\n    </div>\n  ) : null\n\n  // --- Mobile: bottom sheet -------------------------------------------------\n  if (isMobile) {\n    return (\n      <>\n        {!mobileOpen && !transferActive ? (\n          <div className=\"pointer-events-none absolute inset-x-0 bottom-4 z-20 flex justify-center\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              size=\"sm\"\n              onClick={() => setMobileOpen(true)}\n              className=\"pointer-events-auto bg-background/90 shadow-lg backdrop-blur-md\"\n            >\n              <List />\n              Show {visibleListings.length.toLocaleString()} in view\n              <ChevronUp />\n            </Button>\n          </div>\n        ) : null}\n        <div\n          data-slot=\"listings-panel\"\n          inert={!mobileOpen || transferActive || undefined}\n          className={cn(\n            \"absolute inset-x-0 bottom-0 z-30 flex h-[46svh] flex-col rounded-t-2xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur-md transition-transform duration-300 motion-reduce:transition-none\",\n            (!mobileOpen || transferActive) && \"translate-y-[calc(100%+1rem)]\",\n            className\n          )}\n        >\n          <div className=\"relative\">\n            {refreshHint}\n            <div className=\"mx-auto mt-2 h-1 w-9 rounded-full bg-border\" aria-hidden />\n            {header}\n          </div>\n          {listBody}\n        </div>\n      </>\n    )\n  }\n\n  // --- Desktop: right overlay -----------------------------------------------\n  return (\n    <>\n      {collapsed && !transferActive ? (\n        <div className=\"absolute top-1/2 right-0 z-20 -translate-y-1/2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            size=\"sm\"\n            aria-label=\"Expand listings panel\"\n            onClick={() => setCollapsed(false)}\n            className=\"rounded-r-none border-r-0 bg-background/90 shadow-lg backdrop-blur-md\"\n          >\n            <List />\n            {visibleListings.length.toLocaleString()}\n          </Button>\n        </div>\n      ) : null}\n      <div\n        data-slot=\"listings-panel\"\n        inert={collapsed || transferActive || undefined}\n        className={cn(\n          \"absolute top-3 right-3 bottom-3 z-20 flex w-80 flex-col overflow-hidden rounded-xl border border-border/70 bg-background/95 shadow-lg backdrop-blur-md transition-transform duration-300 motion-reduce:transition-none\",\n          (collapsed || transferActive) && \"translate-x-[calc(100%+1rem)]\",\n          className\n        )}\n      >\n        {refreshHint}\n        {header}\n        {listBody}\n      </div>\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/marketplace/listings-panel.tsx"
    }
  ],
  "type": "registry:block"
}