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.
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 widgetListing 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 />requiredListing by <office> + MLS and Panda disclaimers.
<PropertyDetailDisclaimer />MLS + Panda disclaimers only (PropertyCompliance includes it).
</PropertyDetailRoot></PandaProvider>Installation
- 1npm install <tarball or @pandaidx/widgets>Terminalnpm install https://github.com/pandaidx/panda-widgets/releases/download/v1.14.1/pandaidx-widgets-1.14.1.tgz
- 2import "@pandaidx/widgets/styles.css" in app/layout.tsx
- 3Wrap the site in <PandaProvider publicKey apiUrl preset theme> (app/layout.tsx)
- 4In each page (Server Component) call the widget's server loader, then render the widget with its data
- 5Allow the images: next.config images.remotePatterns [{ protocol: "https", hostname: "**" }] (listing and blog photos come from several CDNs)
Usage
// app/properties/[id]/page.tsximport { 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 paramsconst 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 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
// 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
const BookTour = () => {const { openRequestShowing } = usePropertyDetail()return <button onClick={openRequestShowing}>Book a tour</button>}
Facts as cards with the headless hook
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| listing* | object | — | Listing document. |
| application* | object | — | Public application (agent card, feed disclaimers). |
| requestShowingForm | object | — | Dynamic form of the tour / contact card; without it scheduling is disabled. |
| searchPath | string | "/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). |
| showMap | boolean | true | Shows the location map section. |
| showMortgageCalculator | boolean | true | Shows the mortgage calculator section. |
| showSchools | boolean | false | Shows 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. |
| classNames | object | — | 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). |
| theme | object | — | 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. |
| sheetHref | string | — | panda only: MLS sheet route ({id} = listing path segment) or false to hide the button. Default: PandaProvider listingSheetPath. |
| design | object | — | 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. |
| parts | object | — | 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
| Prop | Type | Default | Description |
|---|---|---|---|
| listing* | object | — | Listing document (off-market pages: fetchOffMarketListing().listing). |
| application* | object | — | Public application (agent card, feed disclaimers, share links). |
| requestShowingForm | object | — | Dynamic form of the Request Showing dialog; without it the dialog shows that no form is available. |
| design | object | — | 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. |
| sheetHref | string | — | MLS sheet route ({id} = listing path segment) or false to hide the MLS Sheet action. Default: PandaProvider listingSheetPath. |
| className | string | — | 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. |
| theme | object | — | 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 />requiredListing 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 PropertyDetailRootuseNearbyPlaces({ 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 loaderListing + public application + request-showing form; returns null when the listing does not exist (call notFound()). Returns ListingDetailData | null → listing, application, requestShowingForm
getListingDetail()typed dataTyped 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.