Skip to contentVibraUI

Listing facts

The listing drawn with the kit rather than photographed — its kind's glyph, the asking price, the price per square metre against its city's median and the rest of what the row records; reads listingRecord(id).

Preview

Install

npx shadcn@latest add @vibra/widget-real-estate-listing-facts-panel

Needs the @vibra registry in your components.json — set it up once.

Source

app/real-estate/listings/[id]/components/facts-panel.tsx
"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>
  )
}
app/real-estate/listings/[id]/data.ts
/**
 * 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 }))
}
app/real-estate/listings/[id]/actions.ts
"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.`
}
app/real-estate/listings/[id]/components/listing-state.tsx
"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} />
  )
}
app/real-estate/listings/[id]/components/listing-vocabulary.ts
/**
 * 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" })

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Listing page page