/real-estate/listings/[id]Listing page
One listing, drawn with the kit rather than photographed: the facts with the price per m² against its city, the interest it has drawn week by week, its price history, the agent with a face and a number to call, the next viewing, and the deal — Mark under offer and Mark sold through server actions that refuse a listing already off the market.
Installs at /real-estate/listings/[id], one static page per listing through generateStaticParams; with no params — the docs preview renders the default export bare — it opens on the open listing with the most enquiries as the book stands, read per render, and an unknown id is a notFound(). Listings stays current and the trail leads back to it. listingRecord gathers the db.listings row, the db.members row holding it with their whole book, and the open listings in its city. A listing records one asking price and its interest as totals, so the rest are rules over the row: it is cut 2.5% every 45 days on the market, three times at most, so its history runs from today's price over 0.975 per cut to today's price — each earlier price rounded the way an agent sets one, to the nearest $100 under $500,000 and the nearest $1,000 above, and each cut printed as the rule's 2.5% — and a closing ends it; its weeks are the dashboard's weeklyInterest (agents-real-estate): the views and enquiries shared over the days of its life in each week, scattered by seeded("real-estate-listing/<id>") and adding up to the totals, the weeks the Agents page sums over each book; the price per m² is set against the median of the city's open listings — the others, and this one while it is on the market — and a city's only open listing says so, while a closed one in a city with none open says it has nothing to compare with; and the agent card is the dashboard's one agent summary, printed with the Agents page's labels, so the two agree on every figure. No photograph anywhere: the kind's glyph in ink stands for the listing. Everything a sale changes reads one client state — the status pill and the line under the title, the deal card, the city comparison, the price history, the agent's book and the viewing — so a listing marked sold here reads sold in all of them at once, with no refresh; only the interest chart keeps the complete weeks it was drawn with. markUnderOffer takes an offer on a listing on the market and refuses one under offer, sold or let; markSold closes it today; a refusal is an alert in the deal card, a success its status line. Composes AppShell, PageHeader, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction, DescriptionList, ChartCard, BarChart, SegmentedControl, Timeline, TimelineItem, StatList, UserCell, StatusBadge, AsyncButton and Callout.
Preview
import { notFound } from "next/navigation"
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"
import { KIND_LABELS, isOpen } from "@/lib/dashboards/real-estate/vocabulary"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AgentCard } from "./components/agent-card"
import { DealCard } from "./components/deal-card"
import { FactsPanel } from "./components/facts-panel"
import { InterestChart } from "./components/interest-chart"
import { ListingMarketLine, ListingStateProvider, ListingStatusBadge } from "./components/listing-state"
import { DAY } from "./components/listing-vocabulary"
import { PriceHistory } from "./components/price-history"
import { ViewingCard } from "./components/viewing-card"
import { currentUser, fallbackId, listingIds, listingRecord, shellNotifications } from "./data"
/** One static page per listing, which is what makes this a real detail route. */
export function generateStaticParams() {
return listingIds().map((id) => ({ id }))
}
/**
* One listing. The route is `/real-estate/listings/[id]`, and `params` is
* optional so the docs preview — which renders the default export with no
* props at all — still has a listing to show: without one it falls back to
* the open listing with the most enquiries as the book stands. The page is a
* server component: it gathers the listing, its agent and its market in one
* read; everything a sale changes — the pill, the line under the title, the
* deal card, the city comparison, the price history, the agent's book and the
* viewing — reads one client state, so a listing marked sold reads Sold in
* all of them at once.
*/
export default async function ListingPage({ params }: { params?: Promise<{ id: string }> }) {
const { id } = (await params) ?? { id: fallbackId() }
const record = id ? await listingRecord(id) : undefined
if (!record) notFound()
const { listing, agent } = record
const closed = listing.status === "sold" || listing.status === "rented"
return (
<AppShell
nav={NAV}
activeHref={ROUTES.listing}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
breadcrumbs={[
{ title: NAV.brand.name, href: NAV.brand.href },
{ title: "Listings", href: ROUTES.listings },
{ title: listing.title, href: record.href },
]}
>
<ListingStateProvider
initial={{ id: listing.id, title: listing.title, status: listing.status, ...(closed && listing.closedAt ? { closedAt: listing.closedAt } : {}) }}
>
<PageHeader
backHref={ROUTES.listings}
title={listing.title}
badge={<ListingStatusBadge />}
description={`${KIND_LABELS[listing.kind]} · ${listing.city}, ${listing.country}`}
meta={<ListingMarketLine listedAt={listing.listedAt} now={REFERENCE_DATE} />}
/>
<div className="grid gap-4 lg:grid-cols-3">
<div className="flex min-w-0 flex-col gap-4 lg:col-span-2">
<FactsPanel listing={listing} market={record.market} />
<InterestChart weeks={record.weeks} listed={DAY.format(listing.listedAt)} />
<PriceHistory events={record.history} />
</div>
<div className="flex min-w-0 flex-col gap-4">
<DealCard />
<AgentCard agent={agent} year={REFERENCE_DATE.getUTCFullYear()} agentsHref={ROUTES.agents} />
<ViewingCard at={isOpen(listing) ? listing.nextViewingAt : undefined} agent={agent?.name} viewingsHref={ROUTES.viewings} />
</div>
</div>
</ListingStateProvider>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/real-estate-listingNeeds the @vibra registry in your components.json — set it up once.
Source
import { notFound } from "next/navigation"
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"
import { KIND_LABELS, isOpen } from "@/lib/dashboards/real-estate/vocabulary"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AgentCard } from "./components/agent-card"
import { DealCard } from "./components/deal-card"
import { FactsPanel } from "./components/facts-panel"
import { InterestChart } from "./components/interest-chart"
import { ListingMarketLine, ListingStateProvider, ListingStatusBadge } from "./components/listing-state"
import { DAY } from "./components/listing-vocabulary"
import { PriceHistory } from "./components/price-history"
import { ViewingCard } from "./components/viewing-card"
import { currentUser, fallbackId, listingIds, listingRecord, shellNotifications } from "./data"
/** One static page per listing, which is what makes this a real detail route. */
export function generateStaticParams() {
return listingIds().map((id) => ({ id }))
}
/**
* One listing. The route is `/real-estate/listings/[id]`, and `params` is
* optional so the docs preview — which renders the default export with no
* props at all — still has a listing to show: without one it falls back to
* the open listing with the most enquiries as the book stands. The page is a
* server component: it gathers the listing, its agent and its market in one
* read; everything a sale changes — the pill, the line under the title, the
* deal card, the city comparison, the price history, the agent's book and the
* viewing — reads one client state, so a listing marked sold reads Sold in
* all of them at once.
*/
export default async function ListingPage({ params }: { params?: Promise<{ id: string }> }) {
const { id } = (await params) ?? { id: fallbackId() }
const record = id ? await listingRecord(id) : undefined
if (!record) notFound()
const { listing, agent } = record
const closed = listing.status === "sold" || listing.status === "rented"
return (
<AppShell
nav={NAV}
activeHref={ROUTES.listing}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
breadcrumbs={[
{ title: NAV.brand.name, href: NAV.brand.href },
{ title: "Listings", href: ROUTES.listings },
{ title: listing.title, href: record.href },
]}
>
<ListingStateProvider
initial={{ id: listing.id, title: listing.title, status: listing.status, ...(closed && listing.closedAt ? { closedAt: listing.closedAt } : {}) }}
>
<PageHeader
backHref={ROUTES.listings}
title={listing.title}
badge={<ListingStatusBadge />}
description={`${KIND_LABELS[listing.kind]} · ${listing.city}, ${listing.country}`}
meta={<ListingMarketLine listedAt={listing.listedAt} now={REFERENCE_DATE} />}
/>
<div className="grid gap-4 lg:grid-cols-3">
<div className="flex min-w-0 flex-col gap-4 lg:col-span-2">
<FactsPanel listing={listing} market={record.market} />
<InterestChart weeks={record.weeks} listed={DAY.format(listing.listedAt)} />
<PriceHistory events={record.history} />
</div>
<div className="flex min-w-0 flex-col gap-4">
<DealCard />
<AgentCard agent={agent} year={REFERENCE_DATE.getUTCFullYear()} agentsHref={ROUTES.agents} />
<ViewingCard at={isOpen(listing) ? listing.nextViewingAt : undefined} agent={agent?.name} viewingsHref={ROUTES.viewings} />
</div>
</div>
</ListingStateProvider>
</AppShell>
)
}/**
* What /real-estate/listings/[id] reads. The record is a `db.listings` row;
* around it are the `db.members` row that holds it — the agent, with their
* whole book — and the other listings in its city. A listing records one
* asking price and its views and enquiries as totals, so the rest are rules
* over the row:
*
* - price history: a listing that stays on the market is cut 2.5% every 45
* days, at most three times, so it was listed at today's asking price over
* 0.975 per cut and reached it on schedule — each earlier price set the way
* an agent sets one, to the nearest $100 under $500,000 and the nearest
* $1,000 above, and each cut printed as the rule's 2.5%; a closing ends it
* at the asking price on `closedAt`;
* - interest by week and the agent's summary are the dashboard's own rules,
* in `@/lib/dashboards/real-estate/agents` — the Agents page
* reads the same weeks and the same summary, so the two cannot disagree;
* - the market: the asking price per m² against the median of the open
* listings in the same city — the page takes the median, from the others'
* prices here and this one's while it is on the market.
*
* "Now" is `REFERENCE_DATE`. The client islands import only the types below.
*/
import { agentSummary, mondayOf, weeklyInterest, type AgentSummary } from "@/lib/dashboards/real-estate/agents"
import { isOpen, listingHref } from "@/lib/dashboards/real-estate/vocabulary"
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"
const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS
const NOW = REFERENCE_DATE.getTime()
/** How long a listing waits between price cuts, how deep each cut is, and how many it takes at most. */
const CUT_EVERY_DAYS = 45
const CUT = 0.025
const MAX_CUTS = 3
export type PriceEvent = {
kind: "listed" | "reduced" | "sold" | "rented"
at: Date
priceCents: number
/** The cut the rule made, as a ratio (−0.025); only a cut has one. */
change?: number
}
export type WeekPoint = { week: string; views: number; enquiries: number }
export type Market = {
perSqmCents: number
/**
* The asking price per m² of every other open listing in the city. The
* page adds this one's while it is on the market and takes the median, so
* a sale made on the page takes it out of its own comparison.
*/
othersPerSqmCents: number[]
}
/** The listing and everything the page says about it, gathered in one read. */
export type ListingRecord = {
listing: Listing
href: string
agent: AgentSummary | undefined
history: PriceEvent[]
/** Up to twelve complete weeks of the listing's life, oldest first. */
weeks: WeekPoint[]
market: Market
}
/**
* The listing the page falls back to when it is rendered without a route
* param — which is what the docs preview does: the open listing with the most
* enquiries, the lowest id on a tie, so the preview has a price history, a
* viewing and both actions to offer. Read per render, as the book stands: a
* listing sold in the preview hands the fallback on to the next one.
*/
export function fallbackId(): string | undefined {
return db.listings
.all()
.filter(isOpen)
.sort((a, b) => b.enquiries - a.enquiries || a.id.localeCompare(b.id))[0]?.id
}
/** Every listing id, for `generateStaticParams`. */
export function listingIds(): string[] {
return db.listings.all().map((row) => row.id)
}
/** An asking price as an agent sets one: to the nearest $100 under $500,000, the nearest $1,000 above. */
function asking(cents: number): number {
const step = cents < 500_000_00 ? 100_00 : 1_000_00
return Math.round(cents / step) * step
}
/** Whole days between listing and closing, or now. */
export function daysOnMarket(row: Listing): number {
return Math.floor(((row.closedAt?.getTime() ?? NOW) - row.listedAt.getTime()) / DAY_MS)
}
/** The listing's asking prices, oldest first, and its closing. */
export function priceHistory(row: Listing): PriceEvent[] {
const cuts = Math.min(MAX_CUTS, Math.floor(daysOnMarket(row) / CUT_EVERY_DAYS))
const events: PriceEvent[] = []
for (let step = 0; step <= cuts; step++) {
const priceCents = step === cuts ? row.priceCents : asking(row.priceCents / (1 - CUT) ** (cuts - step))
events.push({
kind: step === 0 ? "listed" : "reduced",
at: new Date(row.listedAt.getTime() + step * CUT_EVERY_DAYS * DAY_MS),
priceCents,
// The rule's cut, not the ratio of two rounded prices, which wanders
// either side of it.
...(step > 0 ? { change: -CUT } : {}),
})
}
if (row.closedAt && (row.status === "sold" || row.status === "rented")) {
events.push({ kind: row.status, at: row.closedAt, priceCents: row.priceCents })
}
return events
}
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** The last twelve complete weeks of the listing's life: to this week for an open one, to the week it closed for the rest. */
function chartWeeks(row: Listing): WeekPoint[] {
const end = row.closedAt ? mondayOf(row.closedAt.getTime()) + WEEK_MS : mondayOf(NOW)
return weeklyInterest(row)
.filter((week) => week.start < end)
.slice(-12)
.map((week) => ({ week: WEEK_LABEL.format(new Date(week.start)), views: week.views, enquiries: week.enquiries }))
}
function marketOf(row: Listing): Market {
const perSqm = (listing: Listing) => Math.round(listing.priceCents / listing.sqm)
const others = db.listings.all().filter((other) => other.id !== row.id && other.city === row.city && isOpen(other))
return { perSqmCents: perSqm(row), othersPerSqmCents: others.map(perSqm) }
}
/** The whole record, or undefined when the id names no listing. */
export async function listingRecord(id: string): Promise<ListingRecord | undefined> {
const listing = await db.listings.get(id)
if (!listing) return undefined
return {
listing,
href: listingHref(listing.id),
agent: agentSummary(listing.agentId),
history: priceHistory(listing),
weeks: chartWeeks(listing),
market: marketOf(listing),
}
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { formatDate } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Result } from "@/lib/sample-data"
/**
* The three things this page changes. Each action looks the listing up by id
* and trusts only the stored row; a listing that has sold or let is off the
* market, and both verbs refuse it with the day it closed. What comes back is
* the listing trimmed to what the page prints, never the stored row.
*/
export type ListingChange = { id: string; title: string; status: Listing["status"]; closedAt?: Date }
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** Takes an offer on a listing that is on the market; refuses one already under offer, sold or let. */
export async function markUnderOffer(id: string): Promise<Result<ListingChange>> {
const row = await db.listings.get(id)
if (!row) return { ok: false, error: { code: "not_found", message: `No listing with id "${id}".` } }
if (row.status === "sold" || row.status === "rented") {
return { ok: false, error: { code: "closed", message: offTheMarket(row) } }
}
if (row.status === "under_offer") {
return { ok: false, error: { code: "already_under_offer", message: `${row.title} is already under offer.` } }
}
const updated = await db.listings.update(row.id, { status: "under_offer" })
if (!updated.ok) return updated
return { ok: true, data: { id: row.id, title: row.title, status: updated.data.status } }
}
/** Closes a listing on the market as sold, today; refuses one already sold or let. */
export async function markSold(id: string): Promise<Result<ListingChange>> {
const row = await db.listings.get(id)
if (!row) return { ok: false, error: { code: "not_found", message: `No listing with id "${id}".` } }
if (row.status === "sold" || row.status === "rented") {
return { ok: false, error: { code: "closed", message: offTheMarket(row) } }
}
const updated = await db.listings.update(row.id, { status: "sold", closedAt: REFERENCE_DATE })
if (!updated.ok) return updated
return { ok: true, data: { id: row.id, title: row.title, status: updated.data.status, closedAt: REFERENCE_DATE } }
}
/** "14 Linden Lane was sold on Aug 2, 2026 — it is off the market." */
function offTheMarket(row: Listing): string {
const verb = row.status === "sold" ? "sold" : "let"
const day = row.closedAt ? ` on ${formatDate(row.closedAt, "medium", { timeZone: "UTC" })}` : ""
return `${row.title} was ${verb}${day} — it is off the market.`
}"use client"
import Link from "next/link"
import { MailIcon, PhoneIcon } from "lucide-react"
import { type AgentSummary } from "@/lib/dashboards/real-estate/agents"
import { agentBookItems, isOpen, type AgentBook } from "@/lib/dashboards/real-estate/vocabulary"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { StatList } from "@/components/ui/stat-list"
import { UserCell } from "@/components/ui/user-cell"
import { type ListingChange } from "../actions"
import { useListingState } from "./listing-state"
export type AgentCardProps = {
agent: AgentSummary | undefined
/** The year the book's closings are counted over: REFERENCE_DATE's. */
year: number
agentsHref: string
}
/**
* The book as the server counted it, moved by a closing made on this page
* since: the listing leaves the market and joins the year's sales or lettings,
* as the Agents page will count it on its next read.
*/
function bookAfter(book: AgentBook, counted: ListingChange, now: ListingChange, year: number): AgentBook {
if (!isOpen(counted) || isOpen(now)) return book
const thisYear = now.closedAt?.getUTCFullYear() === year
return {
...book,
active: book.active - 1,
soldThisYear: book.soldThisYear + (now.status === "sold" && thisYear ? 1 : 0),
rentedThisYear: book.rentedThisYear + (now.status === "rented" && thisYear ? 1 : 0),
}
}
/**
* The agent holding the listing: their face and city, a number to call and an
* address to write to — plain `tel:` and `mailto:` links styled as icon
* buttons — and their book this year, printed with the Agents page's own
* labels from the dashboard's one agent summary, so the two pages agree.
*/
export function AgentCard({ agent, year, agentsHref }: AgentCardProps) {
const { listing, initial } = useListingState()
return (
<Card data-widget="widget-real-estate-listing-agent-card" role="region" aria-labelledby="listing-agent-title">
<CardHeader>
<CardTitle id="listing-agent-title">Agent</CardTitle>
{agent ? (
<CardAction className="flex items-center gap-0.5">
<a
href={`tel:${agent.phone.replace(/\s+/g, "")}`}
aria-label={`Call ${agent.name}`}
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
>
<PhoneIcon aria-hidden="true" />
</a>
<a
href={`mailto:${agent.email}`}
aria-label={`Email ${agent.name}`}
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
>
<MailIcon aria-hidden="true" />
</a>
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{agent ? (
<>
<UserCell name={agent.name} src={agent.avatarUrl} description={`${agent.city} · ${agent.phone}`} />
<StatList size="sm" divided items={agentBookItems(bookAfter(agent, initial, listing, year), year)} />
</>
) : (
<p className="text-sm text-muted-foreground">Nobody holds this listing yet.</p>
)}
</CardContent>
<CardFooter>
<Link href={agentsHref} className={buttonVariants({ variant: "link", size: "sm", className: "px-0" })}>
All agents
</Link>
</CardFooter>
</Card>
)
}"use client"
import * as React from "react"
import { BadgeCheckIcon, HandshakeIcon } from "lucide-react"
import { LISTING_STATUS_LABELS } from "@/lib/dashboards/real-estate/vocabulary"
import { type Result } from "@/lib/sample-data"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { markSold, markUnderOffer, type ListingChange } from "../actions"
import { useListingState } from "./listing-state"
import { DAY } from "./listing-vocabulary"
/** Why a verb will be refused, as the page already knows it; undefined when the server should take it. */
function reasonAgainst(listing: ListingChange, verb: "offer" | "sale"): string | undefined {
if (listing.status === "sold" || listing.status === "rented") return "It is off the market."
if (verb === "offer" && listing.status === "under_offer") return "It is under offer already."
return undefined
}
/**
* Where the deal stands, and the two verbs that move it: an offer taken, and a
* sale. The server decides — a verb the page expects to be refused is marked
* with its reason and still asks, so the reader hears the rule rather than
* nothing — and its answer is printed here: a refusal in an alert above the
* buttons, a success in the status line under them, never one slot. A success
* settles the page's one listing state, which everything a sale changes reads.
*/
export function DealCard() {
const { listing, settle } = useListingState()
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
async function run(action: (id: string) => Promise<Result<ListingChange>>, done: (title: string) => string) {
setNotice("")
setRefusal("")
const result = await action(listing.id)
if (!result.ok) return setRefusal(result.error.message)
settle(result.data)
setNotice(done(result.data.title))
}
const offer = reasonAgainst(listing, "offer")
const sale = reasonAgainst(listing, "sale")
return (
<Card data-widget="widget-real-estate-listing-deal-card" role="region" aria-labelledby="listing-deal-title">
<CardHeader>
<CardTitle id="listing-deal-title">The deal</CardTitle>
<CardDescription>
{listing.closedAt
? `${LISTING_STATUS_LABELS[listing.status]} on ${DAY.format(listing.closedAt)}.`
: `${LISTING_STATUS_LABELS[listing.status]}, on the market.`}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<div className="flex flex-wrap gap-2">
<AsyncButton
variant="outline"
size="sm"
aria-disabled={offer ? true : undefined}
aria-describedby={offer ? "listing-offer-reason" : undefined}
onClick={() => run(markUnderOffer, (title) => `Marked ${title} under offer.`)}
>
<HandshakeIcon data-icon="inline-start" aria-hidden="true" />
Mark under offer
</AsyncButton>
<AsyncButton
size="sm"
aria-disabled={sale ? true : undefined}
aria-describedby={sale ? "listing-sale-reason" : undefined}
onClick={() => run(markSold, (title) => `Marked ${title} sold.`)}
>
<BadgeCheckIcon data-icon="inline-start" aria-hidden="true" />
Mark sold
</AsyncButton>
</div>
{offer ? (
<p id="listing-offer-reason" className="sr-only">
{offer}
</p>
) : null}
{sale ? (
<p id="listing-sale-reason" className="sr-only">
{sale}
</p>
) : null}
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</CardContent>
</Card>
)
}"use client"
import {
KIND_ICONS,
KIND_LABELS,
isOpen,
median,
money,
} from "@/lib/dashboards/real-estate/vocabulary"
import { formatNumber, formatPercent } from "@/lib/format"
import { type Listing } from "@/lib/sample-data"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DescriptionList } from "@/components/ui/description-list"
import { type Market } from "../data"
import { useListingState } from "./listing-state"
import { DAY } from "./listing-vocabulary"
/** "12% above the Lisbon median", "At the Lisbon median", "The only open listing in Edinburgh". */
function against(city: string, open: boolean, perSqmCents: number, cityMedian: number, comparables: number): string {
if (comparables === 0) return `No open listing in ${city} to compare with`
if (open && comparables === 1) return `The only open listing in ${city}`
const change = perSqmCents / cityMedian - 1
if (Math.abs(change) < 0.005) return `At the ${city} median`
const size = formatPercent(Math.abs(change), { maximumFractionDigits: 0 })
return `${size} ${change > 0 ? "above" : "below"} the ${city} median`
}
/**
* The listing's facts, drawn with the kit rather than photographed: its
* kind's glyph in ink, the asking price as the figure, the price per square
* metre against the median of the open listings in its city, and the rest of
* what the row records. The comparison reads the page's listing state: the
* city's open listings are the others, and this one while it is on the
* market, so a sale made here takes it out of its own median.
*/
export function FactsPanel({ listing, market }: { listing: Listing; market: Market }) {
const { listing: now } = useListingState()
const Icon = KIND_ICONS[listing.kind]
const open = isOpen(now)
const comparables = open ? [...market.othersPerSqmCents, market.perSqmCents] : market.othersPerSqmCents
const cityMedian = median(comparables)
return (
<Card data-widget="widget-real-estate-listing-facts-panel" role="region" aria-labelledby="listing-facts-title">
<CardHeader>
<CardTitle id="listing-facts-title">Facts</CardTitle>
<CardDescription>{`${KIND_LABELS[listing.kind]} in ${listing.city}, ${listing.country}`}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex items-center gap-4">
<span
aria-hidden="true"
data-slot="listing-glyph"
className="flex size-14 shrink-0 items-center justify-center rounded-lg bg-foreground text-background"
>
<Icon className="size-7" />
</span>
<div className="flex min-w-0 flex-col gap-0.5">
<p data-slot="listing-price" className="type-numeral text-3xl">
{money(listing.priceCents)}
</p>
<p className="text-sm text-muted-foreground">
<span className="tabular-nums text-foreground">{`${money(market.perSqmCents)} per m²`}</span>
{` · ${against(listing.city, open, market.perSqmCents, cityMedian, comparables.length)}`}
</p>
</div>
</div>
<DescriptionList
columns={2}
size="sm"
items={[
{ term: "Bedrooms", description: <span className="tabular-nums">{listing.beds > 0 ? listing.beds : "None"}</span> },
{ term: "Bathrooms", description: <span className="tabular-nums">{listing.baths}</span> },
{ term: "Floor area", description: <span className="tabular-nums">{`${formatNumber(listing.sqm)} m²`}</span> },
{
term: `${listing.city} median`,
description:
comparables.length === 0 ? (
"No open listings"
) : (
<span className="tabular-nums">{`${money(cityMedian)} per m² · ${comparables.length} open`}</span>
),
},
{ term: "Views", description: <span className="tabular-nums">{formatNumber(listing.views)}</span> },
{ term: "Enquiries", description: <span className="tabular-nums">{formatNumber(listing.enquiries)}</span> },
{ term: "Listed", description: DAY.format(listing.listedAt) },
{ term: "Listing ID", description: <span className="font-mono text-xs">{listing.id}</span> },
]}
/>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { type WeekPoint } from "../data"
const MEASURES = [
{ value: "views", label: "Views" },
{ value: "enquiries", label: "Enquiries" },
] as const
type Measure = (typeof MEASURES)[number]["value"]
/**
* The listing's interest week by week, the last twelve complete weeks of its
* life, one grey series at a time: views, or the enquiries they turned into.
* A listing younger than a week has no complete week to show, and says so.
*/
export function InterestChart({ weeks, listed }: { weeks: WeekPoint[]; listed: string }) {
const [measure, setMeasure] = React.useState<Measure>("views")
const label = MEASURES.find((entry) => entry.value === measure)!.label
const total = weeks.reduce((sum, week) => sum + week[measure], 0)
return (
<ChartCard
data-widget="widget-real-estate-listing-interest-chart"
title="Interest by week"
description={
weeks.length === 0
? `Listed ${listed}.`
: `${formatNumber(total)} ${label.toLowerCase()} over the last ${weeks.length} complete week${weeks.length === 1 ? "" : "s"}.`
}
actions={
<SegmentedControl
size="sm"
aria-label="Measure plotted"
options={MEASURES.map((entry) => ({ value: entry.value, label: entry.label }))}
value={measure}
onValueChange={(value) => setMeasure(value as Measure)}
/>
}
height={220}
empty={weeks.length === 0}
emptyMessage="Its first week on the market is not over yet."
>
<BarChart
data={weeks}
index="week"
series={[{ key: measure, label }]}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
height={220}
legend="none"
/>
</ChartCard>
)
}"use client"
import * as React from "react"
import { LISTING_STATUS_LABELS, LISTING_STATUS_MAP } from "@/lib/dashboards/real-estate/vocabulary"
import { StatusBadge } from "@/components/ui/status-badge"
import { type ListingChange } from "../actions"
import { DAY } from "./listing-vocabulary"
const DAY_MS = 86_400_000
type ListingState = {
/** Where the listing stands now, a sale made on this page included. */
listing: ListingChange
/** Where it stood when the server read it: what the server-side figures were counted with. */
initial: ListingChange
/** Takes what the server handed back after an action. */
settle: (next: ListingChange) => void
}
const ListingStateContext = React.createContext<ListingState | null>(null)
/**
* Where the listing stands, held once for the page. Everything that changes
* when it sells reads this state — the header's pill and the line under the
* title, the deal card's verbs, the city comparison, the price history, the
* agent's book and the viewing — so a sale shows in all of them the moment
* the server says so, with nothing re-read and no refresh.
*/
export function ListingStateProvider({ initial, children }: { initial: ListingChange; children: React.ReactNode }) {
const [listing, setListing] = React.useState(initial)
const value = React.useMemo(() => ({ listing, initial, settle: setListing }), [listing, initial])
return <ListingStateContext.Provider value={value}>{children}</ListingStateContext.Provider>
}
export function useListingState(): ListingState {
const state = React.useContext(ListingStateContext)
if (!state) throw new Error("useListingState needs a ListingStateProvider above it")
return state
}
/** The line under the title: when it went on the market and for how long — or, once it has closed, after how long. */
export function ListingMarketLine({ listedAt, now }: { listedAt: Date; now: Date }) {
const { listing } = useListingState()
const days = Math.floor(((listing.closedAt ?? now).getTime() - listedAt.getTime()) / DAY_MS)
const span = `${days} day${days === 1 ? "" : "s"}`
const listed = DAY.format(listedAt)
return listing.closedAt ? `Listed ${listed} · closed after ${span}` : `Listed ${listed} · ${span} on the market`
}
/** The status pill beside the page title. */
export function ListingStatusBadge() {
const { listing } = useListingState()
return (
<StatusBadge status={listing.status} label={LISTING_STATUS_LABELS[listing.status]} map={LISTING_STATUS_MAP} />
)
}/**
* How this page writes a day and a viewing's slot. The words the whole
* dashboard shares — a listing's kind and status, its glyph, how a price is
* written — are in `@/lib/dashboards/real-estate/vocabulary`.
*/
export const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/** "Tue, Sep 8 · 10:30 AM", in UTC, the zone viewings are booked in. */
export const VIEWING_DAY = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
export const VIEWING_TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })"use client"
import * as React from "react"
import { money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatDelta } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Timeline, TimelineItem } from "@/components/ui/timeline"
import { type ListingChange } from "../actions"
import { type PriceEvent } from "../data"
import { useListingState } from "./listing-state"
import { DAY } from "./listing-vocabulary"
const TITLES: Record<PriceEvent["kind"], string> = {
listed: "Listed at",
reduced: "Reduced to",
sold: "Sold at",
rented: "Let at",
}
const TONES: Record<PriceEvent["kind"], "default" | "success" | "info"> = {
listed: "info",
reduced: "default",
sold: "success",
rented: "success",
}
/** The history as the server read it, ended by a closing made on this page since, at the price it was asking. */
function withClosing(events: PriceEvent[], listing: ListingChange): PriceEvent[] {
if (listing.status !== "sold" && listing.status !== "rented") return events
if (!listing.closedAt || events.some((event) => event.kind === "sold" || event.kind === "rented")) return events
const asking = events[events.length - 1]
return asking ? [...events, { kind: listing.status, at: listing.closedAt, priceCents: asking.priceCents }] : events
}
/**
* The asking price from the day it was listed: each cut with the rule's size,
* and the closing — a sale made on this page included, since it reads the
* page's listing state.
*/
export function PriceHistory({ events: recorded }: { events: PriceEvent[] }) {
const { listing } = useListingState()
const events = React.useMemo(() => withClosing(recorded, listing), [recorded, listing])
const cuts = events.filter((event) => event.kind === "reduced").length
return (
<Card data-widget="widget-real-estate-listing-price-history" role="region" aria-labelledby="listing-history-title">
<CardHeader>
<CardTitle id="listing-history-title">Price history</CardTitle>
<CardDescription>
{cuts === 0 ? "Still at the price it was listed at." : `${cuts} price cut${cuts === 1 ? "" : "s"} since it was listed.`}
</CardDescription>
</CardHeader>
<CardContent>
<Timeline aria-label="Asking price, oldest first">
{events.map((event) => (
<TimelineItem
key={`${event.kind}-${event.at.getTime()}`}
tone={TONES[event.kind]}
title={`${TITLES[event.kind]} ${money(event.priceCents)}`}
description={event.change === undefined ? undefined : `${formatDelta(event.change, { maximumFractionDigits: 1 })} on the price before`}
time={DAY.format(event.at)}
/>
))}
</Timeline>
</CardContent>
</Card>
)
}"use client"
import Link from "next/link"
import { CalendarClockIcon } from "lucide-react"
import { isOpen } from "@/lib/dashboards/real-estate/vocabulary"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { useListingState } from "./listing-state"
import { DAY, VIEWING_DAY, VIEWING_TIME } from "./listing-vocabulary"
export type ViewingCardProps = {
/** The viewing booked on the listing, if it has one. */
at?: Date
agent?: string
viewingsHref: string
}
/**
* The next viewing booked on the listing, with who is showing it. A listing
* that has closed has none to show, whatever its row still carries — and it
* reads the page's listing state, so a sale made here closes it at once.
*/
export function ViewingCard({ at, agent, viewingsHref }: ViewingCardProps) {
const { listing } = useListingState()
const closed = !isOpen(listing)
return (
<Card data-widget="widget-real-estate-listing-viewing-card" role="region" aria-labelledby="listing-viewing-title">
<CardHeader>
<CardTitle id="listing-viewing-title">Next viewing</CardTitle>
</CardHeader>
<CardContent>
{closed ? (
<p className="text-sm text-muted-foreground">
{listing.closedAt
? `Closed on ${DAY.format(listing.closedAt)} — there is nothing left to view.`
: "Off the market — there is nothing left to view."}
</p>
) : at ? (
<div className="flex items-center gap-3">
<span aria-hidden="true" className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-surface ring-1 ring-border">
<CalendarClockIcon className="size-5 text-muted-foreground" />
</span>
<div className="flex min-w-0 flex-col">
<p data-slot="viewing-at" className="text-sm font-medium tabular-nums">
{`${VIEWING_DAY.format(at)} · ${VIEWING_TIME.format(at)}`}
</p>
<p className="truncate text-xs text-muted-foreground">{agent ? `Shown by ${agent}` : "Nobody showing it yet"}</p>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No viewing booked.</p>
)}
</CardContent>
<CardFooter>
<Link href={viewingsHref} className={buttonVariants({ variant: "link", size: "sm", className: "px-0" })}>
All viewings
</Link>
</CardFooter>
</Card>
)
}