Skip to contentVibraUI

Book figures

What is on the market and under offer, what it asks and the median ask, what has closed — sold and let — and the enquiry rate its views turned into; reads bookTotals().

Preview

Install

npx shadcn@latest add @vibra/widget-real-estate-listings-book-figures

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

Source

app/real-estate/listings/components/book-figures.tsx
import { BuildingIcon, HandshakeIcon, MessageSquareIcon, TagIcon } from "lucide-react"

import { money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatCompact, formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type BookTotals } from "../data"

/**
 * The book in four numbers: what is on the market and what it asks, what has
 * closed, and how many views became an enquiry. No state, so it renders on the
 * server.
 */
export function BookFigures({ totals }: { totals: BookTotals }) {
  const rate = totals.views === 0 ? 0 : totals.enquiries / totals.views

  return (
    <StatCardGroup data-widget="widget-real-estate-listings-book-figures" columns={4} role="region" aria-label="The book in figures">
      <StatCard
        label="On the market"
        value={formatNumber(totals.open)}
        description={`${formatNumber(totals.underOffer)} under offer`}
        icon={<BuildingIcon />}
      />
      <StatCard
        label="Asking"
        value={formatCurrency(totals.askingCents / 100, "USD", { compact: true })}
        description={`Median ${money(totals.medianCents)}`}
        icon={<TagIcon />}
      />
      <StatCard
        label="Closed"
        value={formatNumber(totals.sold + totals.rented)}
        description={`${formatNumber(totals.sold)} sold · ${formatNumber(totals.rented)} let`}
        icon={<HandshakeIcon />}
      />
      <StatCard
        label="Enquiry rate"
        value={formatPercent(rate, { maximumFractionDigits: 1 })}
        description={`${formatNumber(totals.enquiries)} enquiries from ${formatCompact(totals.views)} views`}
        icon={<MessageSquareIcon />}
      />
    </StatCardGroup>
  )
}
app/real-estate/listings/data.ts
/**
 * What /real-estate/listings reads. The book is `db.listings`; each listing's
 * agent is the `db.members` row it names. The four figures are rules over the
 * rows: what is on the market (listed or under offer) and what it asks, what
 * has closed, and how many of the views turned into an enquiry. "Now" is
 * `REFERENCE_DATE`.
 *
 * The client islands import only the types below; `db` never reaches the
 * browser. The words and the money formatter the islands print with are the
 * dashboard's own, in `@/lib/dashboards/real-estate/vocabulary`.
 */
import { formatDate, getInitials } from "@/lib/format"
import { isOpen, listingHref, median } from "@/lib/dashboards/real-estate/vocabulary"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"

/** One listing as the table and the tiles print it. */
export type ListingRow = {
  id: string
  title: string
  kind: Listing["kind"]
  city: string
  country: string
  priceCents: number
  beds: number
  baths: number
  sqm: number
  status: Listing["status"]
  agent: string
  agentAvatar?: string
  views: number
  enquiries: number
  listedAt: Date
  /** The listing's own page. */
  href: string
}

/** Every listing on the book; the island sorts, filters and pages them itself. */
export function listingRows(): ListingRow[] {
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  return db.listings.all().map((row) => ({
    id: row.id,
    title: row.title,
    kind: row.kind,
    city: row.city,
    country: row.country,
    priceCents: row.priceCents,
    beds: row.beds,
    baths: row.baths,
    sqm: row.sqm,
    status: row.status,
    agent: members.get(row.agentId)?.name ?? "Unassigned",
    agentAvatar: members.get(row.agentId)?.avatarUrl,
    views: row.views,
    enquiries: row.enquiries,
    listedAt: row.listedAt,
    href: listingHref(row.id),
  }))
}

/** Every city the book holds a listing in, A to Z, for the facet. */
export function cities(): string[] {
  return [...new Set(db.listings.all().map((row) => row.city))].sort((a, b) => a.localeCompare(b))
}

export type BookTotals = {
  /** Listed or under offer. */
  open: number
  underOffer: number
  /** What the open listings ask between them, and the middle one of them, in cents. */
  askingCents: number
  medianCents: number
  sold: number
  rented: number
  views: number
  enquiries: number
}

/** The four figures over the book. */
export function bookTotals(): BookTotals {
  const rows = db.listings.all()
  const open = rows.filter(isOpen)
  return {
    open: open.length,
    underOffer: rows.filter((row) => row.status === "under_offer").length,
    askingCents: open.reduce((sum, row) => sum + row.priceCents, 0),
    medianCents: median(open.map((row) => row.priceCents)),
    sold: rows.filter((row) => row.status === "sold").length,
    rented: rows.filter((row) => row.status === "rented").length,
    views: rows.reduce((sum, row) => sum + row.views, 0),
    enquiries: rows.reduce((sum, row) => sum + row.enquiries, 0),
  }
}

/** The line under the title: how many listings, and when they were read. */
export function lastUpdated(): string {
  return `${db.listings.all().length} listings · synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

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 }))
}

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 book page