Listings
Listing Detail
A listing page: title, price and figures, a wide photo, then description, detail lists, gallery and video beside the agent, a tour form and nearby listings.
Layout and motion from the Findit template — see it in context"use client";
import * as React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { FlipButton } from "@/components/ui/flip-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LiteYouTube } from "@/components/ui/lite-youtube";
import { Textarea } from "@/components/ui/textarea";
import { ZoomCard } from "@/components/ui/zoom-card";
import { cn } from "@/lib/utils";
/*
* Listing Detail — the main part of a listing page: the title with its chips and price, the key figures and area, a
* wide photo; then the description, features, detail lists, location, gallery and video beside a sidebar with the
* agent, a tour request form and more listings nearby.
* Source: marcframe/findit → components/sections/PropertyMainSection.tsx (+ design-system DetailList, YouTubeEmbed,
* Form, PropertyCard).
*
* Layout kept from Framer: the price sits beside the title from 768px, aligned to its bottom; a line, then the figures
* and the area side by side from 768px; the photo is 1140:646. Below 1024px the sidebar follows the content; from
* 1024px the content fills the row and the sidebar is 375px wide, 96px apart. Detail lists: label and value on a row
* with a line under it, two columns from 768px. Gallery: the first photo across two columns, the rest two a row
* (1140:646). Sidebar cards sit on the muted surface; the nearby listings are two a row from 768px, one in the sidebar.
* Motion kept: the buttons roll their label (flip-button); nearby listing photos grow to 105% on hover (zoom-card);
* the video loads YouTube only when played (lite-youtube, with your own poster so nothing is requested before).
* Behavior kept: the tour form shows a spinner while sending, a thank-you and clears on success, an error on failure,
* announced to screen readers. Sending is yours: pass `onTourSubmit`; without it the form only pretends to send.
* Changed: Findit's icon on every detail row is dropped; the property id and name fields become one read-only field.
*/
type Image = { src: string; alt: string };
type Chip = { label: string; href?: string };
export type ListingDetailProps = {
title?: string;
/** "For sale", "For rent"… — the filled chip. */
type?: Chip;
category?: Chip;
/** Formatted, e.g. "$7,900". */
price?: string;
/** After the price, e.g. "/month" for rentals. */
priceSuffix?: string;
beds?: number;
baths?: number;
/** Floor area with its unit. */
area?: string;
location?: string;
image?: Image;
/** Paragraphs. */
description?: string[];
features?: string[];
/** Titled lists of label / value rows ("Details", "Amenities"…). */
facts?: { title: string; rows: { label: string; value: string }[] }[];
address?: string;
/** Map link for the location button; opens in a new tab. */
mapHref?: string;
gallery?: Image[];
/** YouTube tour; `poster` is your own still, so YouTube loads only on play. */
video?: { url: string; poster: string; title: string };
agent?: {
name: string;
image: string;
/** Office or area, under the name. */
detail?: string;
href?: string;
contacts: { label: string; value: string; href?: string }[];
};
more?: { title: string; href: string; address: string; price: string; image: Image }[];
/** Called with the form's data; throw (or reject) to show the error state. */
onTourSubmit?: (data: FormData) => Promise<void> | void;
labels?: {
figures: string;
beds: string;
baths: string;
area: string;
location: string;
description: string;
features: string;
map: string;
gallery: string;
video: string;
play: string;
agent: string;
profile: string;
tourTitle: string;
tourText: string;
property: string;
name: string;
email: string;
phone: string;
message: string;
submit: string;
sending: string;
success: string;
error: string;
/** "{location}" is replaced with the listing's area. */
more: string;
};
className?: string;
};
const photo = (id: string, w = 1400) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=80`;
const defaults = {
title: "Palm Court Villa",
type: { label: "For rent", href: "/properties?type=rent" },
category: { label: "Villas", href: "/properties?category=villas" },
price: "$7,900",
priceSuffix: "/month",
beds: 4,
baths: 4,
area: "3,300 sqft",
location: "Coral Hills",
image: { src: photo("photo-1564013799919-ab600027ffc6", 2000), alt: "White house with a pool and palm trees" },
description: [
"A bright four-bedroom villa on a quiet, palm-lined street, ten minutes from the beach and the Coral Hills shops. The living spaces open onto a long terrace and a heated pool, and every bedroom has its own bathroom.",
"Rented fully furnished, with a gardener and pool care included. Available from the first of next month for a minimum of twelve months.",
],
features: [
"Heated pool and sun terrace",
"Open kitchen with an island and a pantry",
"Four en-suite bedrooms",
"Two-car garage with an EV charger",
"Fibre internet and smart thermostats",
],
facts: [
{
title: "Details",
rows: [
{ label: "Floor area", value: "3,300 sqft" },
{ label: "Bedrooms", value: "4" },
{ label: "Bathrooms", value: "4" },
{ label: "Floors", value: "2" },
{ label: "Furnishing", value: "Furnished" },
{ label: "Garden", value: "0.3 acres" },
{ label: "Built", value: "2016" },
{ label: "Renovated", value: "2023" },
],
},
{
title: "Amenities",
rows: [
{ label: "Heating", value: "Underfloor" },
{ label: "Air conditioning", value: "Yes" },
{ label: "Fireplace", value: "Yes" },
{ label: "Parking", value: "2 cars" },
{ label: "Internet", value: "Fibre" },
{ label: "Pets", value: "Allowed" },
],
},
],
address: "18 Palm Court, Coral Hills",
mapHref: "https://maps.google.com/?q=Palm+Court+Coral+Hills",
gallery: [
{ src: photo("photo-1600607687939-ce8a6c25118c"), alt: "Living room with a white sofa beside a wooden wall" },
{ src: photo("photo-1484154218962-a197022b5858", 900), alt: "White kitchen with an island and black pendant lights" },
{ src: photo("photo-1505691938895-1758d7feb511", 900), alt: "White sofa below a large painting" },
{ src: photo("photo-1560448204-e02f11c3d0e2", 900), alt: "Bright living room with tall windows" },
{ src: photo("photo-1502672260266-1c1ef2d93688", 900), alt: "Living room with plants and a grey sofa" },
],
video: {
url: "https://www.youtube.com/watch?v=F3VRCHigfzo",
poster: photo("photo-1564013799919-ab600027ffc6", 1200),
title: "Palm Court Villa — video tour",
},
agent: {
name: "Maya Collins",
image: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=144&h=144&q=80",
detail: "Harbor Homes, Coral Hills office",
href: "/agents/maya-collins",
contacts: [
{ label: "Office", value: "+1 (555) 014 2290", href: "tel:+15550142290" },
{ label: "Mobile", value: "+1 (555) 017 8834", href: "tel:+15550178834" },
{ label: "Email", value: "maya@harborhomes.example", href: "mailto:maya@harborhomes.example" },
],
},
more: [
{
title: "Terrace Villa",
href: "/properties/terrace-villa",
address: "7 Seaview Road, Coral Hills",
price: "$2,300,000",
image: { src: photo("photo-1580587771525-78b9dba3b914", 800), alt: "Modern house with terraces above a swimming pool" },
},
{
title: "Marble House",
href: "/properties/marble-house",
address: "161 Bayview Drive, Coral Hills",
price: "$8,500/mo",
image: { src: photo("photo-1600596542815-ffad4c1539a9", 800), alt: "White modern villa with a swimming pool" },
},
],
labels: {
figures: "Features",
beds: "Beds",
baths: "Baths",
area: "Area",
location: "Location",
description: "Description",
features: "Highlights",
map: "View on map",
gallery: "Gallery",
video: "Video tour",
play: "Play the video tour",
agent: "Listed by",
profile: "View profile",
tourTitle: "Schedule a tour",
tourText: "Pick a time that suits you — the agent confirms within a day.",
property: "Property",
name: "Your name",
email: "Email",
phone: "Phone",
message: "Message",
submit: "Request a tour",
sending: "Sending…",
success: "Thank you — we'll be in touch",
error: "Something went wrong",
more: "More in {location}",
},
};
type Status = "idle" | "pending" | "success" | "error";
export function ListingDetail({
title = defaults.title,
type = defaults.type,
category = defaults.category,
price = defaults.price,
priceSuffix = defaults.priceSuffix,
beds = defaults.beds,
baths = defaults.baths,
area = defaults.area,
location = defaults.location,
image = defaults.image,
description = defaults.description,
features = defaults.features,
facts = defaults.facts,
address = defaults.address,
mapHref = defaults.mapHref,
gallery = defaults.gallery,
video = defaults.video,
agent = defaults.agent,
more = defaults.more,
onTourSubmit,
labels = defaults.labels,
className,
}: ListingDetailProps) {
return (
<section className={cn("pt-12 pb-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)">
<div className="flex flex-col gap-7.5">
<div className="flex flex-col gap-5 md:flex-row md:items-end md:justify-between md:gap-12">
<div className="flex flex-col gap-2.5">
{(type || category) && (
<div className="flex items-center gap-1.5">
{type && <ChipLink chip={type} filled />}
{category && <ChipLink chip={category} />}
</div>
)}
<h1 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h1>
</div>
{price && (
<p className="flex shrink-0 items-baseline gap-1">
<span className="font-heading text-h2 font-semibold tracking-tight">{price}</span>
{priceSuffix && <span className="font-medium text-muted-foreground">{priceSuffix}</span>}
</p>
)}
</div>
<div className="flex flex-col gap-5 border-t pt-7.5 md:flex-row md:items-center md:gap-12">
<div className="flex flex-col gap-2.5">
<p className="text-sm text-muted-foreground">{labels.figures}</p>
<dl className="flex flex-wrap items-center gap-x-5 gap-y-1 text-muted-foreground">
<Figure icon={<BedIcon />} term={labels.beds} value={beds} />
<Figure icon={<BathIcon />} term={labels.baths} value={baths} />
<Figure icon={<AreaIcon />} term={labels.area} value={area} />
</dl>
</div>
<div className="flex flex-col gap-2.5">
<p className="text-sm text-muted-foreground">{labels.location}</p>
<p className="flex items-center gap-1.5">
<PinIcon />
{location}
</p>
</div>
</div>
</div>
{image && <img src={image.src} alt={image.alt} className="aspect-[1140/646] w-full rounded-xl bg-muted object-cover" />}
<div className="flex flex-col gap-16 md:gap-20 lg:flex-row lg:items-start lg:gap-24">
<div className="flex min-w-0 flex-col gap-12 lg:flex-1">
{description.length > 0 && (
<Part title={labels.description}>
<div className="flex flex-col gap-4 text-pretty text-muted-foreground">
{description.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</Part>
)}
{features.length > 0 && (
<Part title={labels.features}>
<ul className="grid gap-2.5 md:grid-cols-2 md:gap-x-12">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<CheckIcon />
{feature}
</li>
))}
</ul>
</Part>
)}
{facts.map((group) => (
<Part key={group.title} title={group.title}>
<dl className="grid md:grid-cols-2 md:gap-x-12">
{group.rows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-5 border-b py-2.5">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="text-right">{row.value}</dd>
</div>
))}
</dl>
</Part>
))}
{address && (
<Part title={labels.location} className="items-start">
<p className="text-muted-foreground">{address}</p>
{mapHref && <FlipButton href={mapHref}>{labels.map}</FlipButton>}
</Part>
)}
{gallery.length > 0 && (
<Part title={labels.gallery}>
<div className="grid gap-2.5 md:grid-cols-2">
{gallery.map((shot, i) => (
<img
key={shot.src}
src={shot.src}
alt={shot.alt}
loading="lazy"
className={cn("aspect-[1140/646] w-full rounded-xl bg-muted object-cover", i === 0 && "md:col-span-2")}
/>
))}
</div>
</Part>
)}
{video && (
<Part title={labels.video}>
<LiteYouTube url={video.url} title={video.title} poster={video.poster} playLabel={labels.play} className="w-full rounded-xl" />
</Part>
)}
</div>
<aside className="flex w-full flex-col gap-7.5 lg:w-93.75 lg:shrink-0">
{agent && (
<Card title={labels.agent}>
<div className="flex items-center gap-2.5">
<img src={agent.image} alt="" className="size-18 shrink-0 rounded-full bg-background object-cover" />
<div className="flex min-w-0 flex-col gap-1">
<h3 className="font-heading text-lg font-semibold tracking-tight">{agent.name}</h3>
{agent.detail && <p className="text-sm text-foreground/65">{agent.detail}</p>}
</div>
</div>
{agent.contacts.length > 0 && (
<dl className="border-t text-sm">
{agent.contacts.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-2.5 border-b py-2">
<dt>{row.label}</dt>
<dd className="min-w-0 truncate text-foreground/65">
{row.href ? (
<a href={row.href} className="transition-colors hover:text-foreground hover:underline">
{row.value}
</a>
) : (
row.value
)}
</dd>
</div>
))}
</dl>
)}
{agent.href && (
<FlipButton href={agent.href} className="w-full">
{labels.profile}
</FlipButton>
)}
</Card>
)}
<Card title={labels.tourTitle} text={labels.tourText}>
<TourForm listing={title} onSubmit={onTourSubmit} labels={labels} />
</Card>
{more.length > 0 && (
<Card title={labels.more.replace("{location}", location)}>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-1">
{more.map((listing) => (
<article key={listing.href} className="flex flex-col overflow-hidden rounded-xl bg-background">
<Link href={listing.href} tabIndex={-1} aria-hidden className="block">
<ZoomCard className="aspect-[4/3] rounded-xl">
<img src={listing.image.src} alt={listing.image.alt} className="object-cover" />
</ZoomCard>
</Link>
<Link
href={listing.href}
className="flex flex-col gap-1.5 p-5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<h3 className="font-heading text-lg font-semibold tracking-tight">{listing.title}</h3>
<p className="truncate text-sm text-muted-foreground">{listing.address}</p>
<p className="font-medium">{listing.price}</p>
</Link>
</article>
))}
</div>
</Card>
)}
</aside>
</div>
</div>
</section>
);
}
function TourForm({
listing,
onSubmit,
labels,
}: {
listing: string;
onSubmit?: (data: FormData) => Promise<void> | void;
labels: NonNullable<ListingDetailProps["labels"]>;
}) {
const [status, setStatus] = React.useState<Status>("idle");
/* Remounting the form clears every field. */
const [formKey, setFormKey] = React.useState(0);
const id = React.useId();
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setStatus("pending");
try {
if (onSubmit) await onSubmit(data);
else await new Promise((resolve) => setTimeout(resolve, 900));
setFormKey((key) => key + 1);
setStatus("success");
} catch {
setStatus("error");
}
}
const fields = [
{ name: "name", label: labels.name, autoComplete: "name", required: true },
{ name: "email", label: labels.email, type: "email", autoComplete: "email", required: true },
{ name: "phone", label: labels.phone, type: "tel", autoComplete: "tel" },
];
const buttonLabel =
status === "pending" ? labels.sending : status === "success" ? labels.success : status === "error" ? labels.error : labels.submit;
return (
<>
<form
key={formKey}
onSubmit={submit}
onInput={() => (status === "success" || status === "error") && setStatus("idle")}
className="flex flex-col gap-5"
>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-listing`}>{labels.property}</Label>
<Input id={`${id}-listing`} name="listing" defaultValue={listing} readOnly className="h-10 bg-background text-muted-foreground" />
</div>
{fields.map((field) => (
<div key={field.name} className="flex flex-col gap-2">
<Label htmlFor={`${id}-${field.name}`}>
{field.label}
{field.required && (
<span aria-hidden className="text-foreground/65">
*
</span>
)}
</Label>
<Input
id={`${id}-${field.name}`}
name={field.name}
type={field.type ?? "text"}
required={field.required}
autoComplete={field.autoComplete}
className="h-10 bg-background"
/>
</div>
))}
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-message`}>{labels.message}</Label>
<Textarea id={`${id}-message`} name="message" className="min-h-30 bg-background" />
</div>
<Button
type="submit"
size="lg"
disabled={status === "pending"}
className={cn("h-10 w-full", status === "error" && "bg-destructive/10 text-destructive hover:bg-destructive/20")}
>
{status === "pending" && (
<svg viewBox="0 0 16 16" className="size-4 animate-spin motion-reduce:animate-none" fill="none" aria-hidden>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
{buttonLabel}
</Button>
</form>
<p role="status" className="sr-only">
{status === "success" ? labels.success : status === "error" ? labels.error : ""}
</p>
</>
);
}
/* A part of the content column: its heading (h2), then the content 20px below. */
function Part({ title, className, children }: { title: string; className?: string; children: React.ReactNode }) {
return (
<div className={cn("flex flex-col gap-5", className)}>
<h2 className="font-heading text-h3 font-semibold tracking-tight">{title}</h2>
{children}
</div>
);
}
/* A sidebar card on the muted surface. */
function Card({ title, text, children }: { title: string; text?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-7.5 rounded-xl bg-muted p-5">
<div className="flex flex-col gap-2.5">
<h2 className="font-heading text-h3 font-semibold tracking-tight">{title}</h2>
{text && <p className="text-sm text-foreground/65">{text}</p>}
</div>
{children}
</div>
);
}
function ChipLink({ chip, filled }: { chip: Chip; filled?: boolean }) {
const classes = cn(
"rounded-full px-3 py-1.5 text-xs font-medium",
filled ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
);
return chip.href ? (
<Link href={chip.href} className={cn(classes, "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50")}>
{chip.label}
</Link>
) : (
<span className={classes}>{chip.label}</span>
);
}
function Figure({ icon, term, value }: { icon: React.ReactNode; term: string; value: React.ReactNode }) {
return (
<div className="flex items-center gap-1">
{icon}
<dt>{term}:</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
const iconProps = {
"aria-hidden": true,
viewBox: "0 0 20 20",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round",
strokeLinejoin: "round",
className: "size-5 shrink-0 text-muted-foreground",
} as const;
function PinIcon() {
return (
<svg {...iconProps}>
<path d="M10 17.5s5.5-4.6 5.5-9.25a5.5 5.5 0 0 0-11 0C4.5 12.9 10 17.5 10 17.5z" />
<circle cx="10" cy="8.25" r="2" />
</svg>
);
}
function BedIcon() {
return (
<svg {...iconProps}>
<path d="M2.5 15.5v-10M2.5 12.5h15v3M17.5 12.5v-2.5a2.5 2.5 0 0 0-2.5-2.5H9v5M5.75 10a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5z" />
</svg>
);
}
function BathIcon() {
return (
<svg {...iconProps}>
<path d="M2.5 10h15v2a4 4 0 0 1-4 4h-7a4 4 0 0 1-4-4v-2zM5 10V4.75A1.75 1.75 0 0 1 8.25 3.9M6 16l-1 1.5M14 16l1 1.5" />
</svg>
);
}
function AreaIcon() {
return (
<svg {...iconProps}>
<path d="M3 7V3h4M13 3h4v4M17 13v4h-4M7 17H3v-4" />
</svg>
);
}
function CheckIcon() {
return (
<svg {...iconProps} className="mt-0.5 size-5 shrink-0 text-primary">
<path d="M4.5 10.5l3.5 3.5 7.5-8" />
</svg>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/listing-detail.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/flip-button.json https://framebrick.co/r/lite-youtube.json https://framebrick.co/r/zoom-card.json2. Add the shadcn/ui primitives it uses.
npx shadcn@latest add button input label textarea3. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json4. Copy the block into
components/listing-detail.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { FlipButton } from "@/components/ui/flip-button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { LiteYouTube } from "@/components/ui/lite-youtube"; import { Textarea } from "@/components/ui/textarea"; import { ZoomCard } from "@/components/ui/zoom-card"; import { cn } from "@/lib/utils"; /* * Listing Detail — the main part of a listing page: the title with its chips and price, the key figures and area, a * wide photo; then the description, features, detail lists, location, gallery and video beside a sidebar with the * agent, a tour request form and more listings nearby. * Source: marcframe/findit → components/sections/PropertyMainSection.tsx (+ design-system DetailList, YouTubeEmbed, * Form, PropertyCard). * * Layout kept from Framer: the price sits beside the title from 768px, aligned to its bottom; a line, then the figures * and the area side by side from 768px; the photo is 1140:646. Below 1024px the sidebar follows the content; from * 1024px the content fills the row and the sidebar is 375px wide, 96px apart. Detail lists: label and value on a row * with a line under it, two columns from 768px. Gallery: the first photo across two columns, the rest two a row * (1140:646). Sidebar cards sit on the muted surface; the nearby listings are two a row from 768px, one in the sidebar. * Motion kept: the buttons roll their label (flip-button); nearby listing photos grow to 105% on hover (zoom-card); * the video loads YouTube only when played (lite-youtube, with your own poster so nothing is requested before). * Behavior kept: the tour form shows a spinner while sending, a thank-you and clears on success, an error on failure, * announced to screen readers. Sending is yours: pass `onTourSubmit`; without it the form only pretends to send. * Changed: Findit's icon on every detail row is dropped; the property id and name fields become one read-only field. */ type Image = { src: string; alt: string }; type Chip = { label: string; href?: string }; export type ListingDetailProps = { title?: string; /** "For sale", "For rent"… — the filled chip. */ type?: Chip; category?: Chip; /** Formatted, e.g. "$7,900". */ price?: string; /** After the price, e.g. "/month" for rentals. */ priceSuffix?: string; beds?: number; baths?: number; /** Floor area with its unit. */ area?: string; location?: string; image?: Image; /** Paragraphs. */ description?: string[]; features?: string[]; /** Titled lists of label / value rows ("Details", "Amenities"…). */ facts?: { title: string; rows: { label: string; value: string }[] }[]; address?: string; /** Map link for the location button; opens in a new tab. */ mapHref?: string; gallery?: Image[]; /** YouTube tour; `poster` is your own still, so YouTube loads only on play. */ video?: { url: string; poster: string; title: string }; agent?: { name: string; image: string; /** Office or area, under the name. */ detail?: string; href?: string; contacts: { label: string; value: string; href?: string }[]; }; more?: { title: string; href: string; address: string; price: string; image: Image }[]; /** Called with the form's data; throw (or reject) to show the error state. */ onTourSubmit?: (data: FormData) => Promise<void> | void; labels?: { figures: string; beds: string; baths: string; area: string; location: string; description: string; features: string; map: string; gallery: string; video: string; play: string; agent: string; profile: string; tourTitle: string; tourText: string; property: string; name: string; email: string; phone: string; message: string; submit: string; sending: string; success: string; error: string; /** "{location}" is replaced with the listing's area. */ more: string; }; className?: string; }; const photo = (id: string, w = 1400) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=80`; const defaults = { title: "Palm Court Villa", type: { label: "For rent", href: "/properties?type=rent" }, category: { label: "Villas", href: "/properties?category=villas" }, price: "$7,900", priceSuffix: "/month", beds: 4, baths: 4, area: "3,300 sqft", location: "Coral Hills", image: { src: photo("photo-1564013799919-ab600027ffc6", 2000), alt: "White house with a pool and palm trees" }, description: [ "A bright four-bedroom villa on a quiet, palm-lined street, ten minutes from the beach and the Coral Hills shops. The living spaces open onto a long terrace and a heated pool, and every bedroom has its own bathroom.", "Rented fully furnished, with a gardener and pool care included. Available from the first of next month for a minimum of twelve months.", ], features: [ "Heated pool and sun terrace", "Open kitchen with an island and a pantry", "Four en-suite bedrooms", "Two-car garage with an EV charger", "Fibre internet and smart thermostats", ], facts: [ { title: "Details", rows: [ { label: "Floor area", value: "3,300 sqft" }, { label: "Bedrooms", value: "4" }, { label: "Bathrooms", value: "4" }, { label: "Floors", value: "2" }, { label: "Furnishing", value: "Furnished" }, { label: "Garden", value: "0.3 acres" }, { label: "Built", value: "2016" }, { label: "Renovated", value: "2023" }, ], }, { title: "Amenities", rows: [ { label: "Heating", value: "Underfloor" }, { label: "Air conditioning", value: "Yes" }, { label: "Fireplace", value: "Yes" }, { label: "Parking", value: "2 cars" }, { label: "Internet", value: "Fibre" }, { label: "Pets", value: "Allowed" }, ], }, ], address: "18 Palm Court, Coral Hills", mapHref: "https://maps.google.com/?q=Palm+Court+Coral+Hills", gallery: [ { src: photo("photo-1600607687939-ce8a6c25118c"), alt: "Living room with a white sofa beside a wooden wall" }, { src: photo("photo-1484154218962-a197022b5858", 900), alt: "White kitchen with an island and black pendant lights" }, { src: photo("photo-1505691938895-1758d7feb511", 900), alt: "White sofa below a large painting" }, { src: photo("photo-1560448204-e02f11c3d0e2", 900), alt: "Bright living room with tall windows" }, { src: photo("photo-1502672260266-1c1ef2d93688", 900), alt: "Living room with plants and a grey sofa" }, ], video: { url: "https://www.youtube.com/watch?v=F3VRCHigfzo", poster: photo("photo-1564013799919-ab600027ffc6", 1200), title: "Palm Court Villa — video tour", }, agent: { name: "Maya Collins", image: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=144&h=144&q=80", detail: "Harbor Homes, Coral Hills office", href: "/agents/maya-collins", contacts: [ { label: "Office", value: "+1 (555) 014 2290", href: "tel:+15550142290" }, { label: "Mobile", value: "+1 (555) 017 8834", href: "tel:+15550178834" }, { label: "Email", value: "maya@harborhomes.example", href: "mailto:maya@harborhomes.example" }, ], }, more: [ { title: "Terrace Villa", href: "/properties/terrace-villa", address: "7 Seaview Road, Coral Hills", price: "$2,300,000", image: { src: photo("photo-1580587771525-78b9dba3b914", 800), alt: "Modern house with terraces above a swimming pool" }, }, { title: "Marble House", href: "/properties/marble-house", address: "161 Bayview Drive, Coral Hills", price: "$8,500/mo", image: { src: photo("photo-1600596542815-ffad4c1539a9", 800), alt: "White modern villa with a swimming pool" }, }, ], labels: { figures: "Features", beds: "Beds", baths: "Baths", area: "Area", location: "Location", description: "Description", features: "Highlights", map: "View on map", gallery: "Gallery", video: "Video tour", play: "Play the video tour", agent: "Listed by", profile: "View profile", tourTitle: "Schedule a tour", tourText: "Pick a time that suits you — the agent confirms within a day.", property: "Property", name: "Your name", email: "Email", phone: "Phone", message: "Message", submit: "Request a tour", sending: "Sending…", success: "Thank you — we'll be in touch", error: "Something went wrong", more: "More in {location}", }, }; type Status = "idle" | "pending" | "success" | "error"; export function ListingDetail({ title = defaults.title, type = defaults.type, category = defaults.category, price = defaults.price, priceSuffix = defaults.priceSuffix, beds = defaults.beds, baths = defaults.baths, area = defaults.area, location = defaults.location, image = defaults.image, description = defaults.description, features = defaults.features, facts = defaults.facts, address = defaults.address, mapHref = defaults.mapHref, gallery = defaults.gallery, video = defaults.video, agent = defaults.agent, more = defaults.more, onTourSubmit, labels = defaults.labels, className, }: ListingDetailProps) { return ( <section className={cn("pt-12 pb-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)"> <div className="flex flex-col gap-7.5"> <div className="flex flex-col gap-5 md:flex-row md:items-end md:justify-between md:gap-12"> <div className="flex flex-col gap-2.5"> {(type || category) && ( <div className="flex items-center gap-1.5"> {type && <ChipLink chip={type} filled />} {category && <ChipLink chip={category} />} </div> )} <h1 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h1> </div> {price && ( <p className="flex shrink-0 items-baseline gap-1"> <span className="font-heading text-h2 font-semibold tracking-tight">{price}</span> {priceSuffix && <span className="font-medium text-muted-foreground">{priceSuffix}</span>} </p> )} </div> <div className="flex flex-col gap-5 border-t pt-7.5 md:flex-row md:items-center md:gap-12"> <div className="flex flex-col gap-2.5"> <p className="text-sm text-muted-foreground">{labels.figures}</p> <dl className="flex flex-wrap items-center gap-x-5 gap-y-1 text-muted-foreground"> <Figure icon={<BedIcon />} term={labels.beds} value={beds} /> <Figure icon={<BathIcon />} term={labels.baths} value={baths} /> <Figure icon={<AreaIcon />} term={labels.area} value={area} /> </dl> </div> <div className="flex flex-col gap-2.5"> <p className="text-sm text-muted-foreground">{labels.location}</p> <p className="flex items-center gap-1.5"> <PinIcon /> {location} </p> </div> </div> </div> {image && <img src={image.src} alt={image.alt} className="aspect-[1140/646] w-full rounded-xl bg-muted object-cover" />} <div className="flex flex-col gap-16 md:gap-20 lg:flex-row lg:items-start lg:gap-24"> <div className="flex min-w-0 flex-col gap-12 lg:flex-1"> {description.length > 0 && ( <Part title={labels.description}> <div className="flex flex-col gap-4 text-pretty text-muted-foreground"> {description.map((paragraph) => ( <p key={paragraph}>{paragraph}</p> ))} </div> </Part> )} {features.length > 0 && ( <Part title={labels.features}> <ul className="grid gap-2.5 md:grid-cols-2 md:gap-x-12"> {features.map((feature) => ( <li key={feature} className="flex items-start gap-2.5"> <CheckIcon /> {feature} </li> ))} </ul> </Part> )} {facts.map((group) => ( <Part key={group.title} title={group.title}> <dl className="grid md:grid-cols-2 md:gap-x-12"> {group.rows.map((row) => ( <div key={row.label} className="flex items-center justify-between gap-5 border-b py-2.5"> <dt className="text-muted-foreground">{row.label}</dt> <dd className="text-right">{row.value}</dd> </div> ))} </dl> </Part> ))} {address && ( <Part title={labels.location} className="items-start"> <p className="text-muted-foreground">{address}</p> {mapHref && <FlipButton href={mapHref}>{labels.map}</FlipButton>} </Part> )} {gallery.length > 0 && ( <Part title={labels.gallery}> <div className="grid gap-2.5 md:grid-cols-2"> {gallery.map((shot, i) => ( <img key={shot.src} src={shot.src} alt={shot.alt} loading="lazy" className={cn("aspect-[1140/646] w-full rounded-xl bg-muted object-cover", i === 0 && "md:col-span-2")} /> ))} </div> </Part> )} {video && ( <Part title={labels.video}> <LiteYouTube url={video.url} title={video.title} poster={video.poster} playLabel={labels.play} className="w-full rounded-xl" /> </Part> )} </div> <aside className="flex w-full flex-col gap-7.5 lg:w-93.75 lg:shrink-0"> {agent && ( <Card title={labels.agent}> <div className="flex items-center gap-2.5"> <img src={agent.image} alt="" className="size-18 shrink-0 rounded-full bg-background object-cover" /> <div className="flex min-w-0 flex-col gap-1"> <h3 className="font-heading text-lg font-semibold tracking-tight">{agent.name}</h3> {agent.detail && <p className="text-sm text-foreground/65">{agent.detail}</p>} </div> </div> {agent.contacts.length > 0 && ( <dl className="border-t text-sm"> {agent.contacts.map((row) => ( <div key={row.label} className="flex items-center justify-between gap-2.5 border-b py-2"> <dt>{row.label}</dt> <dd className="min-w-0 truncate text-foreground/65"> {row.href ? ( <a href={row.href} className="transition-colors hover:text-foreground hover:underline"> {row.value} </a> ) : ( row.value )} </dd> </div> ))} </dl> )} {agent.href && ( <FlipButton href={agent.href} className="w-full"> {labels.profile} </FlipButton> )} </Card> )} <Card title={labels.tourTitle} text={labels.tourText}> <TourForm listing={title} onSubmit={onTourSubmit} labels={labels} /> </Card> {more.length > 0 && ( <Card title={labels.more.replace("{location}", location)}> <div className="grid gap-5 md:grid-cols-2 lg:grid-cols-1"> {more.map((listing) => ( <article key={listing.href} className="flex flex-col overflow-hidden rounded-xl bg-background"> <Link href={listing.href} tabIndex={-1} aria-hidden className="block"> <ZoomCard className="aspect-[4/3] rounded-xl"> <img src={listing.image.src} alt={listing.image.alt} className="object-cover" /> </ZoomCard> </Link> <Link href={listing.href} className="flex flex-col gap-1.5 p-5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <h3 className="font-heading text-lg font-semibold tracking-tight">{listing.title}</h3> <p className="truncate text-sm text-muted-foreground">{listing.address}</p> <p className="font-medium">{listing.price}</p> </Link> </article> ))} </div> </Card> )} </aside> </div> </div> </section> ); } function TourForm({ listing, onSubmit, labels, }: { listing: string; onSubmit?: (data: FormData) => Promise<void> | void; labels: NonNullable<ListingDetailProps["labels"]>; }) { const [status, setStatus] = React.useState<Status>("idle"); /* Remounting the form clears every field. */ const [formKey, setFormKey] = React.useState(0); const id = React.useId(); async function submit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); const data = new FormData(event.currentTarget); setStatus("pending"); try { if (onSubmit) await onSubmit(data); else await new Promise((resolve) => setTimeout(resolve, 900)); setFormKey((key) => key + 1); setStatus("success"); } catch { setStatus("error"); } } const fields = [ { name: "name", label: labels.name, autoComplete: "name", required: true }, { name: "email", label: labels.email, type: "email", autoComplete: "email", required: true }, { name: "phone", label: labels.phone, type: "tel", autoComplete: "tel" }, ]; const buttonLabel = status === "pending" ? labels.sending : status === "success" ? labels.success : status === "error" ? labels.error : labels.submit; return ( <> <form key={formKey} onSubmit={submit} onInput={() => (status === "success" || status === "error") && setStatus("idle")} className="flex flex-col gap-5" > <div className="flex flex-col gap-2"> <Label htmlFor={`${id}-listing`}>{labels.property}</Label> <Input id={`${id}-listing`} name="listing" defaultValue={listing} readOnly className="h-10 bg-background text-muted-foreground" /> </div> {fields.map((field) => ( <div key={field.name} className="flex flex-col gap-2"> <Label htmlFor={`${id}-${field.name}`}> {field.label} {field.required && ( <span aria-hidden className="text-foreground/65"> * </span> )} </Label> <Input id={`${id}-${field.name}`} name={field.name} type={field.type ?? "text"} required={field.required} autoComplete={field.autoComplete} className="h-10 bg-background" /> </div> ))} <div className="flex flex-col gap-2"> <Label htmlFor={`${id}-message`}>{labels.message}</Label> <Textarea id={`${id}-message`} name="message" className="min-h-30 bg-background" /> </div> <Button type="submit" size="lg" disabled={status === "pending"} className={cn("h-10 w-full", status === "error" && "bg-destructive/10 text-destructive hover:bg-destructive/20")} > {status === "pending" && ( <svg viewBox="0 0 16 16" className="size-4 animate-spin motion-reduce:animate-none" fill="none" aria-hidden> <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" /> <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> </svg> )} {buttonLabel} </Button> </form> <p role="status" className="sr-only"> {status === "success" ? labels.success : status === "error" ? labels.error : ""} </p> </> ); } /* A part of the content column: its heading (h2), then the content 20px below. */ function Part({ title, className, children }: { title: string; className?: string; children: React.ReactNode }) { return ( <div className={cn("flex flex-col gap-5", className)}> <h2 className="font-heading text-h3 font-semibold tracking-tight">{title}</h2> {children} </div> ); } /* A sidebar card on the muted surface. */ function Card({ title, text, children }: { title: string; text?: string; children: React.ReactNode }) { return ( <div className="flex flex-col gap-7.5 rounded-xl bg-muted p-5"> <div className="flex flex-col gap-2.5"> <h2 className="font-heading text-h3 font-semibold tracking-tight">{title}</h2> {text && <p className="text-sm text-foreground/65">{text}</p>} </div> {children} </div> ); } function ChipLink({ chip, filled }: { chip: Chip; filled?: boolean }) { const classes = cn( "rounded-full px-3 py-1.5 text-xs font-medium", filled ? "bg-primary text-primary-foreground" : "bg-muted text-foreground", ); return chip.href ? ( <Link href={chip.href} className={cn(classes, "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50")}> {chip.label} </Link> ) : ( <span className={classes}>{chip.label}</span> ); } function Figure({ icon, term, value }: { icon: React.ReactNode; term: string; value: React.ReactNode }) { return ( <div className="flex items-center gap-1"> {icon} <dt>{term}:</dt> <dd className="text-foreground">{value}</dd> </div> ); } const iconProps = { "aria-hidden": true, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", className: "size-5 shrink-0 text-muted-foreground", } as const; function PinIcon() { return ( <svg {...iconProps}> <path d="M10 17.5s5.5-4.6 5.5-9.25a5.5 5.5 0 0 0-11 0C4.5 12.9 10 17.5 10 17.5z" /> <circle cx="10" cy="8.25" r="2" /> </svg> ); } function BedIcon() { return ( <svg {...iconProps}> <path d="M2.5 15.5v-10M2.5 12.5h15v3M17.5 12.5v-2.5a2.5 2.5 0 0 0-2.5-2.5H9v5M5.75 10a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5z" /> </svg> ); } function BathIcon() { return ( <svg {...iconProps}> <path d="M2.5 10h15v2a4 4 0 0 1-4 4h-7a4 4 0 0 1-4-4v-2zM5 10V4.75A1.75 1.75 0 0 1 8.25 3.9M6 16l-1 1.5M14 16l1 1.5" /> </svg> ); } function AreaIcon() { return ( <svg {...iconProps}> <path d="M3 7V3h4M13 3h4v4M17 13v4h-4M7 17H3v-4" /> </svg> ); } function CheckIcon() { return ( <svg {...iconProps} className="mt-0.5 size-5 shrink-0 text-primary"> <path d="M4.5 10.5l3.5 3.5 7.5-8" /> </svg> ); }
Usage
import { ListingDetail } from "@/components/listing-detail";
<ListingDetail
title="Palm Court Villa"
type={{ label: "For rent", href: "/properties?type=rent" }}
price="$7,900"
priceSuffix="/month"
image={{ src: "/listings/palm-court.jpg", alt: "…" }}
video={{ url: "https://www.youtube.com/watch?v=…", poster: "/listings/palm-court-tour.jpg", title: "Palm Court Villa — video tour" }}
onTourSubmit={async (data) => {
await fetch("/api/tour", { method: "POST", body: data });
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | The page's h1. |
| type / category | { label, href? } | — | Chips above the title: filled, then muted. |
| price / priceSuffix | string | — | Beside the title from 768px; the suffix ("/month") is muted. |
| beds / baths / area / location | number | string | — | The figures and the area under the line. |
| image | { src, alt } | — | The wide photo (1140:646). |
| description / features | string[] | — | Paragraphs, and highlights with a check in two columns from 768px. |
| facts | { title, rows: { label, value }[] }[] | Details, Amenities | Titled lists of rows, two columns from 768px. |
| address / mapHref | string | — | The location part; the map link opens in a new tab. |
| gallery | { src, alt }[] | — | The first photo across two columns, the rest two a row. |
| video | { url, poster, title } | — | A YouTube tour; with your own poster nothing is requested from YouTube before play. |
| agent | { name, image, detail?, href?, contacts } | — | Sidebar card: portrait, name, a muted line, contact rows and a profile button. |
| more | { title, href, address, price, image }[] | — | Nearby listings in the last sidebar card. |
| onTourSubmit | (data: FormData) => Promise<void> | void | — | Sends the tour request (listing, name, email, phone, message); throw to show the error state. Without it the form pretends to send. |
| labels | object | — | Every heading, label and button text; "{location}" in more is replaced. |
| className | string | — | Classes for the <section>. |
- Below 1024px the sidebar follows the content; from 1024px the sidebar is 375px wide beside it. Headings: the title is h1, every part and sidebar card an h2.
- The tour form shows a spinner while sending, a thank-you and clears on success, an error on failure — announced to screen readers; typing again resets the button.
- Buttons roll their label (flip-button); nearby listing photos grow to 105% on hover (zoom-card); the video loads YouTube only when played (lite-youtube).
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called ListingDetail (Next.js, client component) with shadcn Button, Input, Label and Textarea. - <section className="pt-12 pb-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-12. - Header: chips (type bg-primary, category bg-muted; links) over an h1 (font-heading text-h2 semibold); the price (same size) with a muted suffix beside it from 768px (items-end). A border-t row (pt-7.5): "Features" with Beds / Baths / Area and icons as a <dl>, and "Location" with a pin. - A 1140:646 photo, rounded-xl. - Then a column / from 1024px a row (gap-24): content (flex-1, parts gap-12, each an h2 text-h3 over its content): description paragraphs, highlights (check icons, 2 columns from 768px), detail lists (<dl> rows with border-b py-2.5, label muted left, value right; 2 columns from 768px), location with a map button, a gallery (first photo spanning 2 columns, 1140:646), a YouTube facade that loads the player on click with your own poster. - Sidebar (lg: 375px): cards on bg-muted rounded-xl p-5: agent (72px round photo, name, muted line, contact rows, full-width profile button); a tour form (read-only property field, name*, email*, phone, message; submit with pending / success / error states and a role="status"); nearby listings (photo 4:3 zooming to 1.05 on hover, title, address, price; 2 columns from 768px, 1 in the sidebar). - Buttons roll their label on hover. Only shadcn variables and block tokens (secondary text on bg-muted is text-foreground/65: muted-foreground falls under 4.5:1 there); plain <img> with alt text.











