Property Detail

Listing page: action bar (save, collection, share), title, photos, overview + snapshot, map, details table, mortgage calculator and the schedule-a-tour / contact card.

Fully headlessClient component@pandaidx/widgets 1.14.1

Anatomy

Every piece of Property Detail as it nests in code. A root holds the data and state; its parts and hooks only work inside it. PropertyCompliance must render in every layout (MLS and consent rules).

<PandaProvider>Mounted once in app/layout.tsx
<PropertyDetail />complete widget

Listing page: action bar (save, collection, share), title, photos, overview + snapshot, map, details table, mortgage calculator and the…

<PropertyDetailRoot>Holds the data and state its parts and hooks read
<PropertyDetailHeader />

Title + price + actions row (compact, hideActions).

<PropertyDetailHeading />

Title, address, beds · baths · size and status (compact).

<PropertyDetailPrice />

Price, price-change tag and Est.

<PropertyDetailActions />

MLS Sheet / Video / Collection / Share / Request Showing (actions, layout bar | stack | floating).

<PropertyDetailGallery />

Photos (variant strip | grid | carousel, toggle, autoplay, interval); opens the full-screen viewer.

<PropertyDetailSections />

Data sections (sections, open, sectionStyle).

<PropertyDetailSection />

One data section, open and flat, anywhere (id: facts | remarks | brokerRemarks | financial | exterior | interior |…

<PropertyFacts />

Facts table (MLS#, type, style, year, lot, $/ft², county, subdivision, area, days on market, dates) in two columns…

<PropertyRemarks />

Public remarks + Listing by <office>.

<PropertyFinancial />

HOA, taxes, terms, legal description (sale listings).

<PropertyFeatures />

One features table: kind interior | exterior | additional.

<PropertyHighlights />

Highlights list (hidden when empty).

<PropertyWalkScore />

Walk / bike scores (hidden when missing).

<PropertyLocationMap />

Location section: heading (hideTitle), address + Get Directions and a 300px map.

<PropertyDetailMap />

The listing map alone (no heading, address or Get Directions), filling its box (give it a height): a custom gallery's…

<PropertySchools />

Nearby schools with ratings.

<PropertyMortgage />

Mortgage calculator prefilled with the price.

<PropertyDetailAgentCard />

Site agent + Request Showing call to action.

<PropertyCompliance />required

Listing by <office> + MLS and Panda disclaimers.

<PropertyDetailDisclaimer />

MLS + Panda disclaimers only (PropertyCompliance includes it).

</PropertyDetailRoot>
</PandaProvider>

Installation

  1. 1
    npm install <tarball or @pandaidx/widgets>
    Terminal
    npm install https://github.com/pandaidx/panda-widgets/releases/download/v1.14.1/pandaidx-widgets-1.14.1.tgz
  2. 2
    import "@pandaidx/widgets/styles.css" in app/layout.tsx
  3. 3
    Wrap the site in <PandaProvider publicKey apiUrl preset theme> (app/layout.tsx)
  4. 4
    In each page (Server Component) call the widget's server loader, then render the widget with its data
  5. 5
    Allow the images: next.config images.remotePatterns [{ protocol: "https", hostname: "**" }] (listing and blog photos come from several CDNs)

Usage

PropertyDetail
// app/properties/[id]/page.tsx
import { notFound } from "next/navigation"
import { PropertyDetail } from "@pandaidx/widgets/client"
import { fetchListingDetail } from "@pandaidx/widgets/server"
export default async function ListingPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const data = await fetchListingDetail({ publicKey: process.env.NEXT_PUBLIC_PANDA_PUBLIC_KEY!, apiUrl: process.env.NEXT_PUBLIC_PANDA_API_URL, listingId: id })
if (!data) notFound()
return <PropertyDetail {...data} searchPath="/properties" galleryVariant="grid" contactPlacement="sidebar" />
// Panda websites look: <PropertyDetail {...data} variant="panda" />
// Panda look, another layout: <PropertyDetail {...data} variant="panda" design={{ gallery: { variant: "carousel", autoplay: true }, actions: { style: "icon", placement: "gallery" }, agent: { placement: "inline" }, sectionStyle: "flat", sections: ["remarks", "facts", "location", "schools", "mortgage"] }} />
}

Examples

Root + parts in your own grid

PropertyDetailRoot
<PropertyDetailRoot listing={listing} application={application} requestShowingForm={requestShowingForm}>
<PropertyDetailGallery variant="grid" />
<div className="grid gap-10 lg:grid-cols-[1fr_360px]">
<div><PropertyDetailHeader /><PropertyDetailSections sectionStyle="flat" /></div>
<PropertyDetailAgentCard />
</div>
<PropertyDetailDisclaimer />
</PropertyDetailRoot>

Own gallery (mosaic 1 + 4) in the full page, with the shared viewer

PropertyDetailRoot
// components/listing-mosaic.tsx
"use client"
import Image from "next/image"
import { PropertyDetailGallery, usePropertyGallery } from "@pandaidx/widgets/client"
export const Mosaic = () => {
const { photos, alt, photoLoading, openPhotos, count, viewport } = usePropertyGallery()
// Phones keep the swipe carousel.
if (viewport === "mobile") return <PropertyDetailGallery />
return (
// The slot sets the default gallery height (416px) and keeps its bottom gap: the page below does not move.
<div className="grid h-(--panda-property-detail-gallery-height) grid-cols-4 grid-rows-2 gap-2">
{photos.slice(0, 5).map((src, i) => (
<button key={src} type="button" onClick={() => openPhotos(i)} className={i === 0 ? "relative col-span-2 row-span-2" : "relative"}>
<Image src={src} alt={alt(i)} fill sizes={i === 0 ? "50vw" : "25vw"} {...photoLoading(i)} className="object-cover" />
{i === 4 && count > 5 ? <span className="absolute inset-0 grid place-items-center bg-foreground/50 text-background">+{count - 5}</span> : null}
</button>
))}
</div>
)
}
// app/mls/[listingId]/page.tsx: everything else stays the Panda page
<PropertyDetail {...data} variant="panda" parts={{ gallery: <Mosaic /> }} />
// Colors and spacing: the site's DESIGN.md tokens (no raw colors or arbitrary values).

Own control with the hook

PropertyDetailRoot
const BookTour = () => {
const { openRequestShowing } = usePropertyDetail()
return <button onClick={openRequestShowing}>Book a tour</button>
}

Facts as cards with the headless hook

PropertyDetailRoot
"use client"
import { usePropertyFacts } from "@pandaidx/widgets/client"
export const FactCards = () => {
const facts = usePropertyFacts().group("facts")?.facts ?? []
return (
<dl className="grid grid-cols-2 gap-4 md:grid-cols-4">
{facts.map((fact) => (
<div key={fact.key} className="rounded-lg border p-4"><dt className="text-sm text-muted-foreground">{fact.label}</dt><dd className="text-lg">{fact.value}</dd></div>
))}
</dl>
)
}

API reference

PropertyDetail

Listing page: action bar (save, collection, share), title, photos, overview + snapshot, map, details table, mortgage calculator and the schedule-a-tour / contact card. variant="panda" renders the Panda websites listing page instead (sticky header with status, Est. Payment, MLS Sheet / Video / Add to Collection / Share / Request Showing; strip gallery with Photos/Map toggle and lightbox; accordion Facts, Remarks, Financial, Exterior, Interior, Additional, Location with Street View, Schools, Mortgage; MLS + Panda disclaimers; sticky agent card), shaped by design (or PandaProvider propertyDetail): section order / accordion or flat, action buttons (order, filled CTA, icon or labeled, under the price / over the photos / in the agent card), gallery strip / grid / carousel with autoplay, agent card sidebar / inline / none, copy and hint feedback. For a layout the design cannot express, compose the same parts yourself with PropertyDetailRoot. The layout props below only apply to the template 1 design.

Import from @pandaidx/widgets/client · renders inside PandaProvider

PropTypeDefaultDescription
listing*object—Listing document.
application*object—Public application (agent card, feed disclaimers).
requestShowingFormobject—Dynamic form of the tour / contact card; without it scheduling is disabled.
searchPathstring"/properties"Host search page (back link and breadcrumb).
layout"gallery-top" | "split""gallery-top"gallery-top: title and full-width photos above the content; split: photos + content left, title + contact card right.
galleryVariant"carousel" | "grid" | "masonry""carousel"Photo presentation: slider strip, one large photo + four tiles, or a masonry wall (all open a fullscreen viewer).
showMapbooleantrueShows the location map section.
showMortgageCalculatorbooleantrueShows the mortgage calculator section.
showSchoolsbooleanfalseShows a Nearby Schools section (GreatSchools search around the listing).
contactPlacement"sidebar" | "bottom""sidebar"Schedule-a-tour / contact card as a sticky sidebar or after the content.
sections"overview" | "map" | "details" | "mortgage" | "schools"["overview","map","details","mortgage","schools"]Order and visibility of the content blocks; omitted blocks are hidden.
classNamesobject—Host classes appended to slots: root, button, dialog.
preset"default" | "template1"—Preset for this widget only (defaults to the provider preset).
variant"default" | "template1" | "panda"—panda: the Panda websites listing page (default preset unless preset is set). default / template1: deprecated alias of preset (template 1 design).
themeobject—Token overrides for this widget only, merged over the provider theme (same shape as PandaProvider.theme).
viewport"desktop" | "mobile"—panda only: desktop (sticky header, agent sidebar) or mobile layout. Default: from the visitor's user agent like websites (server render is desktop); pass it from headers() to avoid the switch after hydration.
sheetHrefstring—panda only: MLS sheet route ({id} = listing path segment) or false to hide the button. Default: PandaProvider listingSheetPath.
designobject—panda only: design of this detail (section order and style, actions, gallery, agent card, header lines, feedback), merged over PandaProvider propertyDetail. Empty = the websites detail. To replace ONE piece keep this widget and use parts; compose PropertyDetailRoot only for a whole new layout.
partsobject—panda only: your own element in place of one part, keeping the rest of the page exactly (sticky header, container, columns, sections, disclaimer, mobile layout): { gallery: <Mosaic /> } (replaces the photos and the Photos / Map switch; build it with usePropertyGallery), { agent: <MyContactCard /> } (calls usePropertyDetail().openRequestShowing). The element renders inside the detail, so the hooks work. The same parts go in PropertySearch detail={{ variant: "panda", parts }} for the search dialog.

PropertyDetailRoot

Root of a Panda listing detail you lay out yourself: it holds the listing, the site, the design and the shared dialogs (Request Showing form, Contact Listing Agent, mortgage calculator, Add to Collection with sign-in), and tracks the listing view. Place these parts (same import) anywhere inside it, in any order, inside your own grid: PropertyDetailHeader (title + price + actions row; compact, hideActions), PropertyDetailHeading (title, address, beds · baths · size, status; compact), PropertyDetailPrice (price, price-change tag, Est. Payment → mortgage dialog; compact), PropertyDetailActions (actions = overrides of design.actions, layout = bar | stack | floating), PropertyDetailGallery (variant strip | grid | carousel, toggle, autoplay, interval, showButtons), PropertyDetailSections (sections, open, sectionStyle), PropertyDetailSection (one section by id, open and flat, anywhere), PropertyDetailAgentCard (agent + Request Showing CTA) and PropertyDetailDisclaimer (MLS + Panda disclaimers: required by the IDX rules, always render it). Every part takes className; they read design (the props override it per part) and follow the site tokens. usePropertyDetail() returns { listing, application, t, design, isOwner, offMarket, propertyUrl, openRequestShowing, openContactAgent, openMortgage, openCollections, openPhotos } for your own controls (e.g. a custom Book a tour button calling openRequestShowing), and usePropertyGallery() returns the photos + openPhotos to rebuild the gallery your own way (a mosaic, a hero) with the detail's full-screen viewer. The root has no page chrome (no container, sticky header bar or columns): you lay out the whole page. To change ONE piece (a custom gallery, your own agent card) keep PropertyDetail variant="panda" and pass parts={{ gallery: <Mosaic /> }} instead. Prefer PropertyDetail variant="panda" design when the design object covers the layout; the listing dialog of the search cards always uses the design, never a custom root.

Import from @pandaidx/widgets/client · renders inside PandaProvider

PropTypeDefaultDescription
listing*object—Listing document (off-market pages: fetchOffMarketListing().listing).
application*object—Public application (agent card, feed disclaimers, share links).
requestShowingFormobject—Dynamic form of the Request Showing dialog; without it the dialog shows that no form is available.
designobject—Design the parts read (actions, gallery, sections, header lines, feedback), merged over PandaProvider propertyDetail; each part's own props win over it.
viewport"desktop" | "mobile"—Desktop or mobile behavior of the parts (gallery switch); default from the visitor's user agent.
sheetHrefstring—MLS sheet route ({id} = listing path segment) or false to hide the MLS Sheet action. Default: PandaProvider listingSheetPath.
classNamestring—Classes of the wrapper around the parts: your layout (grid columns, gaps, max width).
children*node—Your layout of the detail parts.
preset"default" | "template1"—Preset for this widget only (defaults to the provider preset).
variant"default" | "template1"—Deprecated alias of preset.
themeobject—Token overrides for this widget only, merged over the provider theme (same shape as PandaProvider.theme).

Parts

<PropertyDetailHeader />

Title + price + actions row (compact, hideActions).

<PropertyDetailHeading />

Title, address, beds · baths · size and status (compact).

<PropertyDetailPrice />

Price, price-change tag and Est. Payment (opens the mortgage dialog).

<PropertyDetailActions />

MLS Sheet / Video / Collection / Share / Request Showing (actions, layout bar | stack | floating).

<PropertyDetailGallery />

Photos (variant strip | grid | carousel, toggle, autoplay, interval); opens the full-screen viewer.

<PropertyDetailSections />

Data sections (sections, open, sectionStyle).

<PropertyDetailSection />

One data section, open and flat, anywhere (id: facts | remarks | brokerRemarks | financial | exterior | interior | additional | highlights | walkscore | location | schools | mortgage; hideTitle). Renders nothing without data.

<PropertyFacts />

Facts table (MLS#, type, style, year, lot, $/ft², county, subdivision, area, days on market, dates) in two columns (hideTitle).

<PropertyRemarks />

Public remarks + Listing by <office>.

<PropertyFinancial />

HOA, taxes, terms, legal description (sale listings).

<PropertyFeatures />

One features table: kind interior | exterior | additional.

<PropertyHighlights />

Highlights list (hidden when empty).

<PropertyWalkScore />

Walk / bike scores (hidden when missing).

<PropertyLocationMap />

Location section: heading (hideTitle), address + Get Directions and a 300px map.

<PropertyDetailMap />

The listing map alone (no heading, address or Get Directions), filling its box (give it a height): a custom gallery's Map view. Same look as design.map; nothing without a location.

<PropertySchools />

Nearby schools with ratings.

<PropertyMortgage />

Mortgage calculator prefilled with the price.

<PropertyDetailAgentCard />

Site agent + Request Showing call to action.

<PropertyCompliance />required

Listing by <office> + MLS and Panda disclaimers. REQUIRED in every custom listing detail (parts, hooks or data); courtesy={false} when the layout already shows PropertyRemarks (it prints the courtesy line).

<PropertyDetailDisclaimer />

MLS + Panda disclaimers only (PropertyCompliance includes it).

Hooks

useNearbyPlaces()inside PropertyDetailRoot

useNearbyPlaces({ lat?, lng?, categories? }) → { categories: { key, label, color }[], active, setActive(key | null), places: { name, address, rating, lat, lng, photo… }[], loading, selected, select(place), coordinates } — your own "Around this home" list or chips inside PropertyDetailRoot (coordinates come from the listing); one cached request per category, nothing loads until setActive. Import from @pandaidx/widgets/client; never fetch /v1/nearby-places yourself.

usePropertyGallery()inside PropertyDetailRoot

{ photos: string[], count, title, alt(index), photoLoading(index) → { loading, fetchPriority? } (spread on each next/image: the first photo is the page's LCP, eager + high priority; the next four eager; the rest lazy), coordinates: [lng, lat] | null, openPhotos(index?), viewport: "desktop" | "mobile" } — import from @pandaidx/widgets/client; rebuild the photos your own way (its Map view: PropertyDetailMap in a box of the same height, kept mounted and cross-faded, so the switch does not move the page); openPhotos opens the detail's full-screen viewer (never mount a second viewer inside the root; the standalone PhotoLightbox export, controlled, is only for photos outside a listing detail)

usePropertyDetail()inside PropertyDetailRoot

{ listing, application, t, design, isOwner, offMarket, propertyUrl, openRequestShowing, requestShowingDialog: { open, onOpenChange } (bind your own dialog given as parts.requestShowing), openContactAgent, openMortgage, openCollections, openPhotos, closeDetail (closes the search dialog; null on a page) }

usePropertyFacts()inside PropertyDetailRoot

{ groups: { id: "facts" | "financial" | "exterior" | "interior" | "additional", title, facts: { key, label, value (formatted text), raw, column? }[] }[], group(id) } — every fact labelled in the site language and formatted, for cards, chips or your own tables

usePropertyOverview()inside PropertyDetailRoot

{ title, address, price, listPrice, previousListPrice, estimatedPayment, beds, baths, livingArea, status, statusInfo: { market: sale|rent, state: active|pending|closed|offmarket, standardStatus, closeDate, closePrice, tone: positive|warning|neutral } (build your own badge), forSale, remarks, highlights, photos, coordinates, directionsUrl, virtualTourUrl, compliance: { courtesy, feed, disclaimer }, openPhotos(i?), openRequestShowing(), openMortgage(), openCollections() } — rebuild the header / summary your own way

Server data

Loaders run in a Server Component and come from @pandaidx/widgets/server. Typed data is for a page you design yourself, rendered inside the root.

fetchListingDetail()widget loader

Listing + public application + request-showing form; returns null when the listing does not exist (call notFound()). Returns ListingDetailData | null → listing, application, requestShowingForm

getListingDetail()typed data

Typed listing detail for a page you design yourself: detail (title, price, estimated payment, status, remarks, photos, coordinates, fact groups labelled in locale, compliance) + the raw listing / application / requestShowingForm for <PropertyDetailRoot>. Same params as fetchListingDetail + locale. Wrap your markup in <PropertyDetailRoot {...data}> (viewer, Request Showing, collections) and render <PropertyCompliance />. Never fetch /v1/listings yourself.