{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "listing-card",
  "title": "Listing Card",
  "description": "The shared listing presentation: a standalone card and the map popup with the select-for-exchange call to action, drawing one body — operator, place, status/category badges, gate-fee range and lazily fetched waste types and EWC codes — plus the badge atoms the panel rows reuse.",
  "dependencies": [
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/badge",
    "@nx-ui/button",
    "@nx-ui/map-marker",
    "@nx-ui/marketplace-types",
    "@nx-ui/skeleton",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/marketplace/listing-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ArrowsRight,\n  BankNote01,\n  Building07,\n  MarkerPin01,\n  SwitchHorizontal01,\n  XClose,\n} from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport { MapPopupCard } from \"@/components/blocks/maps/map-marker\"\nimport {\n  categoryLabel,\n  formatGateFeeRange,\n  isActiveListingStatus,\n  listingTone,\n  type ListingFeature,\n  type MarketplaceListing,\n} from \"@/components/blocks/marketplace/marketplace-types\"\n\n/**\n * listing-card — the shared listing presentation for the marketplace family.\n *\n * One vocabulary of listing chrome used by both surfaces that show a listing\n * in full: the map popup (<ListingPopup>, built on map-marker's popup card)\n * and any standalone card (<ListingCard>). Plus the small shared atoms the\n * listings panel rows reuse: <ListingTypeBadge>, <ListingStatusBadge> and\n * <GateFeeChip>.\n *\n * The popup owns the select-for-exchange handoff: with no exchange in\n * progress it offers \"Select for exchange\"; once a source is armed, every\n * other listing's popup flips to \"Preview transfer here\", which completes the\n * pair and launches the transfer-preview flow. Detail-only fields (waste\n * types, EWC codes) are fetched lazily from `<endpoint>/<id>` when a detail\n * endpoint is provided — the list payload stays lean.\n */\n\n// ---------------------------------------------------------------------------\n// Atoms\n// ---------------------------------------------------------------------------\n\nexport function ListingTypeBadge({\n  type,\n  className,\n}: {\n  type: string\n  className?: string\n}) {\n  const tone = listingTone(type)\n  return (\n    <Badge variant=\"outline\" className={cn(\"max-w-full gap-1.5\", className)}>\n      <span\n        aria-hidden\n        className=\"size-2 shrink-0 rounded-full\"\n        style={{ backgroundColor: tone.color }}\n      />\n      <span className=\"truncate\">{type}</span>\n    </Badge>\n  )\n}\n\nexport function ListingStatusBadge({\n  status,\n  className,\n}: {\n  status: string\n  className?: string\n}) {\n  const active = isActiveListingStatus(status)\n  return (\n    <Badge variant={active ? \"success\" : \"gray\"} className={className}>\n      {active ? \"Active\" : status}\n    </Badge>\n  )\n}\n\nexport function GateFeeChip({\n  listing,\n  className,\n}: {\n  listing: MarketplaceListing\n  className?: string\n}) {\n  if (listing.gateFeeMid == null) return null\n  return (\n    <Badge variant=\"gray\" className={cn(\"gap-1 tabular-nums\", className)}>\n      <BankNote01 />£{Math.round(listing.gateFeeMid)}/t\n    </Badge>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Lazy detail fetch (waste types + EWC sample live only on detail responses)\n// ---------------------------------------------------------------------------\n\nexport interface ListingDetailExtras {\n  wasteTypesAccepted: string | null\n  acceptedEwcCodesSample: string | null\n}\n\nexport function useListingDetail(\n  id: string | null,\n  detailEndpoint?: string\n): { detail: ListingDetailExtras | null; loading: boolean } {\n  const [detail, setDetail] = React.useState<ListingDetailExtras | null>(null)\n  const [loading, setLoading] = React.useState(false)\n\n  React.useEffect(() => {\n    setDetail(null)\n    if (!id || !detailEndpoint) return\n    const controller = new AbortController()\n    setLoading(true)\n    fetch(`${detailEndpoint}/${id}`, { signal: controller.signal })\n      .then((response) => response.json())\n      .then((json: { success: boolean; data?: ListingFeature }) => {\n        if (controller.signal.aborted) return\n        if (json.success && json.data) {\n          setDetail({\n            wasteTypesAccepted: json.data.properties.wasteTypesAccepted ?? null,\n            acceptedEwcCodesSample: json.data.properties.acceptedEwcCodesSample ?? null,\n          })\n        }\n        setLoading(false)\n      })\n      .catch(() => {\n        if (!controller.signal.aborted) setLoading(false)\n      })\n    return () => controller.abort()\n  }, [id, detailEndpoint])\n\n  return { detail, loading }\n}\n\n// ---------------------------------------------------------------------------\n// Shared body\n// ---------------------------------------------------------------------------\n\nfunction ListingBody({\n  listing,\n  detail,\n  detailLoading,\n}: {\n  listing: MarketplaceListing\n  detail?: ListingDetailExtras | null\n  detailLoading?: boolean\n}) {\n  const place = [listing.subRegion ?? listing.region, listing.postcode]\n    .filter(Boolean)\n    .join(\" · \")\n  const fees = formatGateFeeRange(listing)\n  const ewcCodes = detail?.acceptedEwcCodesSample\n    ? detail.acceptedEwcCodesSample.split(\";\").map((code) => code.trim()).filter(Boolean)\n    : []\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {listing.operator ? (\n        <p className=\"inline-flex items-start gap-1.5 text-xs text-muted-foreground\">\n          <Building07 className=\"mt-0.5 size-3.5 shrink-0\" />\n          <span className=\"line-clamp-2\">{listing.operator}</span>\n        </p>\n      ) : null}\n      <p className=\"inline-flex items-center gap-1.5 text-xs text-muted-foreground\">\n        <MarkerPin01 className=\"size-3.5 shrink-0\" />\n        <span className=\"truncate\">{place || listing.region}</span>\n      </p>\n      <div className=\"flex flex-wrap items-center gap-1.5\">\n        <ListingStatusBadge status={listing.status} />\n        <Badge variant=\"gray\">{categoryLabel(listing.category)}</Badge>\n      </div>\n      {fees ? (\n        <p className=\"text-xs text-secondary-foreground tabular-nums\">\n          <span className=\"font-semibold\">Gate fee</span> {fees}\n        </p>\n      ) : (\n        <p className=\"text-xs text-muted-foreground\">No published gate fees</p>\n      )}\n      {detailLoading ? (\n        <div className=\"flex flex-col gap-1.5\" aria-hidden>\n          <Skeleton className=\"h-3 w-4/5\" />\n          <Skeleton className=\"h-3 w-3/5\" />\n        </div>\n      ) : detail ? (\n        <>\n          {detail.wasteTypesAccepted ? (\n            <p className=\"line-clamp-2 text-xs text-muted-foreground\">\n              <span className=\"font-semibold text-secondary-foreground\">Accepts</span>{\" \"}\n              {detail.wasteTypesAccepted}\n            </p>\n          ) : null}\n          {ewcCodes.length > 0 ? (\n            <div className=\"flex flex-wrap gap-1\" aria-label=\"Accepted EWC codes\">\n              {ewcCodes.slice(0, 6).map((code) => (\n                <span\n                  key={code}\n                  className=\"rounded-sm bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground\"\n                >\n                  {code}\n                </span>\n              ))}\n              {ewcCodes.length > 6 ? (\n                <span className=\"text-[10px] text-muted-foreground\">\n                  +{ewcCodes.length - 6} more\n                </span>\n              ) : null}\n            </div>\n          ) : null}\n        </>\n      ) : null}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Standalone card\n// ---------------------------------------------------------------------------\n\nexport interface ListingCardProps {\n  listing: MarketplaceListing\n  /** Base listings endpoint for the lazy detail fetch, e.g. \"/api/geo/listings\". */\n  detailEndpoint?: string\n  footer?: React.ReactNode\n  className?: string\n}\n\n/** A standalone listing card (list detail, saved lists, demos). */\nexport function ListingCard({\n  listing,\n  detailEndpoint,\n  footer,\n  className,\n}: ListingCardProps) {\n  const { detail, loading } = useListingDetail(listing.id, detailEndpoint)\n  return (\n    <div\n      data-slot=\"listing-card\"\n      className={cn(\n        \"flex w-full max-w-sm flex-col gap-2.5 rounded-xl border border-border bg-background p-4 shadow-xs\",\n        className\n      )}\n    >\n      <div className=\"flex flex-col gap-1.5\">\n        <p className=\"text-sm font-semibold text-foreground\">{listing.name}</p>\n        <ListingTypeBadge type={listing.type} className=\"self-start\" />\n      </div>\n      <ListingBody listing={listing} detail={detail} detailLoading={loading} />\n      {footer}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Map popup\n// ---------------------------------------------------------------------------\n\nexport interface ListingPopupProps {\n  listing: MarketplaceListing\n  onClose: () => void\n  /** Base listings endpoint for the lazy detail fetch. */\n  detailEndpoint?: string\n  /** Exchange handoff: the currently armed source id, if any. */\n  exchangeSourceId?: string | null\n  onStartExchange?: (id: string) => void\n  onCompleteExchange?: (id: string) => void\n  onCancelExchange?: () => void\n  className?: string\n}\n\n/**\n * The map popup for a selected listing — the shared card body plus the\n * select-for-exchange call to action. Render inside the map (it wraps\n * map-marker's <MapPopupCard>).\n */\nexport function ListingPopup({\n  listing,\n  onClose,\n  detailEndpoint,\n  exchangeSourceId,\n  onStartExchange,\n  onCompleteExchange,\n  onCancelExchange,\n  className,\n}: ListingPopupProps) {\n  const { detail, loading } = useListingDetail(listing.id, detailEndpoint)\n  const isSource = exchangeSourceId === listing.id\n  const canComplete = Boolean(exchangeSourceId) && !isSource\n  const showExchange = onStartExchange !== undefined\n\n  return (\n    <MapPopupCard\n      longitude={listing.longitude}\n      latitude={listing.latitude}\n      onClose={onClose}\n      offset={20}\n      title={listing.name}\n      subtitle={listing.type}\n      className={className}\n      footer={\n        showExchange ? (\n          isSource ? (\n            <div className=\"flex items-center justify-between gap-2\">\n              <span className=\"inline-flex items-center gap-1.5 text-xs font-medium text-secondary-foreground\">\n                <SwitchHorizontal01 className=\"size-3.5 text-fg-quaternary\" />\n                Source — now pick a destination\n              </span>\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon-xs\"\n                aria-label=\"Cancel exchange\"\n                onClick={onCancelExchange}\n                className=\"text-fg-quaternary hover:text-foreground\"\n              >\n                <XClose />\n              </Button>\n            </div>\n          ) : canComplete ? (\n            <Button\n              type=\"button\"\n              size=\"xs\"\n              className=\"w-full\"\n              data-testid=\"complete-exchange\"\n              onClick={() => onCompleteExchange?.(listing.id)}\n            >\n              <ArrowsRight />\n              Preview transfer here\n            </Button>\n          ) : (\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              size=\"xs\"\n              className=\"w-full\"\n              data-testid=\"start-exchange\"\n              onClick={() => onStartExchange?.(listing.id)}\n            >\n              <SwitchHorizontal01 />\n              Select for exchange\n            </Button>\n          )\n        ) : undefined\n      }\n    >\n      <ListingBody listing={listing} detail={detail} detailLoading={loading} />\n    </MapPopupCard>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/marketplace/listing-card.tsx"
    }
  ],
  "type": "registry:block"
}