{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "listing-1",
  "title": "Listing 1: property listing",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A property page: a photo gallery with buttons, thumbnails and arrow keys, price and the facts that matter, description and features, and a viewing request form with times to choose from, field errors and a clear result.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/media.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/listing-1/listing-1.tsx",
      "content": "// Ballmac UI: Listing 1. https://ui.ballmac.com/blocks/listing-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Bath, BedDouble, CalendarCheck, ChevronLeft, ChevronRight, Loader2, Ruler, UserRound } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { Media, type MediaSource } from \"@/components/ballmac/media\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Listing1Photo = {\n  /** A picture. Leave it out and generated artwork stands in, so the page looks finished before you have photos. */\n  media?: MediaSource\n  /** Describes the picture, for example \"The living room, with a bay window\". */\n  alt: string\n}\n\ntype Listing1Data = {\n  /** The street address, for example \"14 Garden Row\". */\n  address: string\n  city: string\n  /** Price as a number. */\n  price: number\n  /** \"For sale\" or \"To rent\". */\n  offer: \"For sale\" | \"To rent\"\n  /** Shown after the price when renting, such as \"per month\". */\n  per?: string\n  type: string\n  beds: number\n  baths: number\n  /** Floor area in square metres. */\n  area: number\n  description: string\n  features: string[]\n  photos: Listing1Photo[]\n  agent: { name: string; role: string; phone?: string }\n  /** Times someone can come to see it. */\n  viewings: { id: string; label: string }[]\n}\n\ntype Listing1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    listing?: Listing1Data | null\n    /** ISO currency code. */\n    currency?: string\n    /** Called with the chosen time and the person's details. Reject and the form keeps what was typed. */\n    onRequestViewing?: (request: { viewingId: string; name: string; email: string }) => void | Promise<void>\n  }\n\nconst sample: Listing1Data = {\n  address: \"14 Garden Row\",\n  city: \"Lisbon\",\n  price: 485000,\n  offer: \"For sale\",\n  type: \"Apartment\",\n  beds: 3,\n  baths: 2,\n  area: 118,\n  description: \"A bright corner apartment on the third floor of a restored 1920s building. Tall windows face south and west, the kitchen opens onto a dining area, and the main bedroom has its own bathroom. A shared courtyard garden and a storage room are included.\",\n  features: [\"South and west light\", \"Restored wooden floors\", \"Courtyard garden\", \"Storage room\", \"Lift\", \"Five minutes from the metro\"],\n  photos: [\n    { alt: \"The living room, with tall windows and wooden floors\" },\n    { alt: \"The kitchen and dining area\" },\n    { alt: \"The main bedroom\" },\n    { alt: \"The courtyard garden\" },\n  ],\n  agent: { name: \"Catarina Mendes\", role: \"Sales agent\", phone: \"+351 21 000 0000\" },\n  viewings: [{ id: \"v1\", label: \"Sat 25 Oct, 10:00\" }, { id: \"v2\", label: \"Sat 25 Oct, 11:30\" }, { id: \"v3\", label: \"Tue 28 Oct, 18:00\" }],\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]\">\n      <div className=\"grid gap-3\">\n        <Skeleton className=\"aspect-[16/10] rounded-2xl\" />\n        <div className=\"flex gap-2\">\n          {[0, 1, 2, 3].map((i) => (\n            <Skeleton key={i} className=\"h-16 w-24 rounded-lg\" />\n          ))}\n        </div>\n        <Skeleton className=\"h-32 rounded-xl\" />\n      </div>\n      <Skeleton className=\"h-96 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * A property page: a gallery you can step through with buttons or the arrow keys, the price and the facts that matter, the description and features, and a\n * form to ask for a viewing at one of the offered times. Pictures go through `Media`, with generated artwork until you have photos.\n */\nfunction Listing1({ listing = sample, currency = \"USD\", onRequestViewing, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Listing1Props) {\n  const id = React.useId()\n  const [photo, setPhoto] = React.useState(0)\n  const [slot, setSlot] = React.useState<string>(\"\")\n  const [name, setName] = React.useState(\"\")\n  const [email, setEmail] = React.useState(\"\")\n  const [touched, setTouched] = React.useState(false)\n  const [busy, setBusy] = React.useState(false)\n  const [result, setResult] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && !listing ? \"empty\" : state\n  const nameBad = touched && !name.trim()\n  const emailBad = touched && !/^\\S+@\\S+\\.\\S+$/.test(email)\n  const slotBad = touched && !slot\n\n  async function submit(e: React.FormEvent) {\n    e.preventDefault()\n    setTouched(true)\n    if (!name.trim() || !/^\\S+@\\S+\\.\\S+$/.test(email) || !slot || !listing) return\n    setBusy(true)\n    setResult(null)\n    try {\n      await onRequestViewing?.({ viewingId: slot, name: name.trim(), email })\n      setResult({ ok: true, text: `Requested for ${listing.viewings.find((v) => v.id === slot)?.label}. ${listing.agent.name} will confirm by email.` })\n    } catch (err) {\n      setResult({ ok: false, text: err instanceof Error ? err.message : \"The request was not sent. What you typed is still here.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n  const price = (n: number) => new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, maximumFractionDigits: 0 }).format(n)\n  const photos = listing?.photos ?? []\n  const go = (d: number) => setPhoto((p) => (p + d + photos.length) % Math.max(photos.length, 1))\n\n  return (\n    <section data-slot=\"listing-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the listing\" loading={<Skel />} empty={{ title: \"This listing is no longer available\", description: \"It may have been sold or taken down. Browse other properties from the search page.\", ...emptyState }} error={{ title: \"The listing could not be loaded\", description: \"Check your connection and try again.\", ...errorState }}>\n        {listing && (\n          <div className=\"grid gap-8 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]\">\n            <div className=\"grid min-w-0 content-start gap-6\">\n              <div className=\"grid gap-3\" role=\"group\" aria-roledescription=\"gallery\" aria-label={`Photos of ${listing.address}`} onKeyDown={(e) => { if (e.key === \"ArrowRight\") go(e.currentTarget.dir === \"rtl\" ? -1 : 1); if (e.key === \"ArrowLeft\") go(e.currentTarget.dir === \"rtl\" ? 1 : -1) }}>\n                <div className=\"bg-muted relative overflow-hidden rounded-2xl\">\n                  <Media key={photo} media={photos[photo]?.media} alt={photos[photo]?.alt ?? \"\"} aspect=\"16/10\" priority={photo === 0} fallback={<CoverArt seed={`${listing.address}-${photo}`} variant=\"mesh\" grid className=\"absolute inset-0\" />} />\n                  {photos.length > 1 && (\n                    <>\n                      <button type=\"button\" aria-label=\"Previous photo\" onClick={() => go(-1)} className=\"bg-background/90 focus-visible:ring-ring/50 absolute start-3 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-full border shadow outline-none focus-visible:ring-[3px]\">\n                        <ChevronLeft aria-hidden=\"true\" className=\"size-5 rtl:rotate-180\" />\n                      </button>\n                      <button type=\"button\" aria-label=\"Next photo\" onClick={() => go(1)} className=\"bg-background/90 focus-visible:ring-ring/50 absolute end-3 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-full border shadow outline-none focus-visible:ring-[3px]\">\n                        <ChevronRight aria-hidden=\"true\" className=\"size-5 rtl:rotate-180\" />\n                      </button>\n                      <p aria-live=\"polite\" className=\"bg-background/90 absolute end-3 bottom-3 rounded-full border px-2.5 py-1 text-xs font-medium tabular-nums\">{photo + 1} of {photos.length}</p>\n                    </>\n                  )}\n                </div>\n                {photos.length > 1 && (\n                  <ul className=\"flex gap-2 overflow-x-auto pb-1\">\n                    {photos.map((p, i) => (\n                      <li key={i} className=\"shrink-0\">\n                        <button type=\"button\" aria-label={`Show photo ${i + 1}: ${p.alt}`} aria-current={i === photo} onClick={() => setPhoto(i)} className={cn(\"focus-visible:ring-ring/50 relative block h-16 w-24 overflow-hidden rounded-lg border outline-none focus-visible:ring-[3px]\", i === photo ? \"ring-foreground ring-2\" : \"opacity-70 hover:opacity-100\")}>\n                          <Media media={p.media} alt=\"\" fill className=\"absolute inset-0\" fallback={<CoverArt seed={`${listing.address}-${i}`} variant=\"mesh\" className=\"absolute inset-0\" />} />\n                        </button>\n                      </li>\n                    ))}\n                  </ul>\n                )}\n              </div>\n\n              <div>\n                <p className=\"text-muted-foreground text-sm font-medium\">{listing.type} · {listing.offer}</p>\n                <h1 id={`${id}-title`} className=\"mt-1 text-3xl font-semibold tracking-[-0.035em] text-balance\">{listing.address}</h1>\n                <p className=\"text-muted-foreground\">{listing.city}</p>\n                <p className=\"mt-4 flex items-baseline gap-2\"><span className=\"text-3xl font-semibold tracking-tight tabular-nums\">{price(listing.price)}</span>{listing.per && <span className=\"text-muted-foreground text-sm\">{listing.per}</span>}</p>\n                <dl className=\"mt-5 grid grid-cols-3 gap-3\">\n                  {([[BedDouble, \"Bedrooms\", listing.beds], [Bath, \"Bathrooms\", listing.baths], [Ruler, \"Area\", `${listing.area} m²`]] as const).map(([Icon, label, v]) => (\n                    <div key={label} className=\"bg-card rounded-xl border p-3.5\">\n                      <dt className=\"text-muted-foreground flex items-center gap-1.5 text-xs\"><Icon aria-hidden=\"true\" className=\"size-3.5\" /> {label}</dt>\n                      <dd className=\"mt-1 text-lg font-semibold tracking-tight tabular-nums\">{v}</dd>\n                    </div>\n                  ))}\n                </dl>\n                <p className=\"text-muted-foreground mt-6 leading-relaxed text-pretty\">{listing.description}</p>\n                <ul className=\"mt-5 flex flex-wrap gap-2\" aria-label=\"Features\">\n                  {listing.features.map((f) => (\n                    <li key={f} className=\"rounded-full border px-3 py-1 text-sm\">{f}</li>\n                  ))}\n                </ul>\n              </div>\n            </div>\n\n            <aside className=\"min-w-0 lg:sticky lg:top-6 lg:self-start\">\n              <form onSubmit={submit} noValidate className=\"bg-card grid gap-4 rounded-2xl border p-5\">\n                <h2 className=\"flex items-center gap-2 text-lg font-semibold tracking-tight\"><CalendarCheck aria-hidden=\"true\" className=\"size-5\" /> Ask for a viewing</h2>\n                <fieldset className=\"grid gap-2\" aria-describedby={slotBad ? `${id}-slot-err` : undefined}>\n                  <legend className=\"mb-1 text-sm font-medium\">Choose a time</legend>\n                  {listing.viewings.map((v) => (\n                    <label key={v.id} className=\"relative block cursor-pointer\">\n                      <input type=\"radio\" name={`${id}-slot`} value={v.id} checked={slot === v.id} onChange={() => setSlot(v.id)} className=\"peer sr-only\" />\n                      <span className=\"peer-focus-visible:ring-ring/50 peer-checked:border-foreground peer-checked:bg-accent/50 flex items-center justify-between rounded-lg border px-3.5 py-2.5 text-sm tabular-nums peer-focus-visible:ring-[3px]\">{v.label}</span>\n                    </label>\n                  ))}\n                  {slotBad && <p id={`${id}-slot-err`} className=\"text-destructive text-xs font-medium\">Choose a time.</p>}\n                </fieldset>\n                <div className=\"grid gap-1.5\">\n                  <label htmlFor={`${id}-name`} className=\"text-sm font-medium\">Your name</label>\n                  <input id={`${id}-name`} value={name} onChange={(e) => setName(e.target.value)} autoComplete=\"name\" aria-invalid={nameBad} aria-describedby={nameBad ? `${id}-name-err` : undefined} className=\"bg-background focus-visible:ring-ring/50 aria-invalid:border-destructive h-10 rounded-lg border px-3 text-sm outline-none focus-visible:ring-[3px]\" />\n                  {nameBad && <p id={`${id}-name-err`} className=\"text-destructive text-xs font-medium\">Enter your name.</p>}\n                </div>\n                <div className=\"grid gap-1.5\">\n                  <label htmlFor={`${id}-email`} className=\"text-sm font-medium\">Email</label>\n                  <input id={`${id}-email`} type=\"email\" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete=\"email\" aria-invalid={emailBad} aria-describedby={emailBad ? `${id}-email-err` : undefined} className=\"bg-background focus-visible:ring-ring/50 aria-invalid:border-destructive h-10 rounded-lg border px-3 text-sm outline-none focus-visible:ring-[3px]\" />\n                  {emailBad && <p id={`${id}-email-err`} className=\"text-destructive text-xs font-medium\">Enter an email address like name@example.com.</p>}\n                </div>\n                <Button type=\"submit\" size=\"lg\" disabled={busy}>\n                  {busy && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Request viewing\n                </Button>\n                <p aria-live=\"polite\" role={result && !result.ok ? \"alert\" : undefined} className={cn(\"text-sm\", result && !result.ok && \"text-destructive font-medium\")}>{result?.text}</p>\n                <div className=\"flex items-center gap-3 border-t pt-4\">\n                  <span aria-hidden=\"true\" className=\"bg-muted flex size-10 items-center justify-center rounded-full\"><UserRound className=\"size-5\" /></span>\n                  <p className=\"text-sm\"><span className=\"block font-medium\">{listing.agent.name}</span><span className=\"text-muted-foreground\">{listing.agent.role}{listing.agent.phone ? <> · <span dir=\"ltr\">{listing.agent.phone}</span></> : null}</span></p>\n                </div>\n              </form>\n            </aside>\n          </div>\n        )}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Listing1, type Listing1Data, type Listing1Photo, type Listing1Props }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/listing-1/listing-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "real estate",
      "property",
      "listing",
      "estate agent",
      "gallery",
      "viewing",
      "rent",
      "homes"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A property listing page. <Listing1 listing={{ address, city, price, offer: 'For sale' | 'To rent', per?, type, beds, baths, area, description, features: string[], photos: [{ media?, alt }], agent: { name, role, phone? }, viewings: [{ id, label }] }} currency='USD' onRequestViewing={({ viewingId, name, email }) => Promise} />. Photos use Media with generated artwork until you pass media. listing={null} shows the no-longer-available state.",
      "whenToUse": [
        "An estate agent's or landlord's property page",
        "Any single-item page with a gallery, key facts and a booking request"
      ],
      "whenNotToUse": [
        "A search results grid; use directory-1",
        "A product page in a shop; use the ecommerce blocks"
      ],
      "composesWith": [
        "directory-1",
        "locations-1",
        "gallery-1",
        "header-1"
      ],
      "a11y": [
        {
          "keys": "Gallery",
          "action": "A group with a role description of gallery; Previous and Next buttons, a thumbnail button per photo naming its content, and the arrow keys; the position ('2 of 4') is a polite announcement"
        },
        {
          "keys": "Photos",
          "action": "Each has its own alt text; thumbnails are decorative"
        },
        {
          "keys": "Form",
          "action": "Labelled fields, a fieldset for the times, aria-invalid with a message under each missing field; the result is a polite announcement and an error is an alert"
        },
        {
          "keys": "Facts",
          "action": "A definition list with icons that are decoration"
        }
      ],
      "customization": [
        "currency",
        "photos with media and alt",
        "viewings you offer",
        "onRequestViewing; reject and what was typed stays"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "listing-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton for the gallery, facts and form under the page, announced as loading."
      },
      {
        "id": "empty",
        "title": "Listing unavailable",
        "example": "listing-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says the listing is no longer available and points back to search; pass listing={null}."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "listing-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying to check the connection, with Try again (onRetry)."
      }
    ],
    "examples": [
      "listing-1-demo",
      "listing-1-rent",
      "listing-1-loading",
      "listing-1-empty",
      "listing-1-error"
    ],
    "url": "https://ui.ballmac.com/components/listing-1"
  },
  "categories": [
    "blocks",
    "listing"
  ],
  "type": "registry:block"
}