/real-estate/listingsListing book
Every listing on the book — kind, city, price, rooms and area, status, the agent with a face, views and enquiries — with the book added up in four figures, facets on kind, status and city, and a switch that draws the same page as tiles the kit draws in place of photographs.
Installs at /real-estate/listings. The page is a server component inside AppShell: listingRows reads every db.listings row with the db.members row holding it, and bookTotals adds the book up — what is on the market (listed or under offer) and what it asks, with the median; what has sold and let; and the enquiries over the views. The island builds one table with useTable: the search matches a title or a city, the facets filter kind, status and city, and the sort and the page hold whichever way the book is drawn — as rows, or as tiles. A listing has no photograph anywhere: the table shows its kind's glyph in an ink square with the kind and city under the title — their own columns and the day it was listed open hidden, still filtered by the facets and a click away in the View menu, so the table fits a 1,280px screen without scrolling sideways; on a phone the table keeps the listing and its price, with the status under the title beside the kind and the city, so it fits a 390px screen too — and a tile is a framed card with that glyph, the status pill, the asking price as the figure, the rooms and the floor area, and the agent with the interest the listing has drawn. Each title is a next/link to the listing's own page at /real-estate/listings/<id>, stretched over its tile, and the tiles drop the View menu, which has no columns to show or hide there. Composes AppShell, PageHeader, StatCardGroup, StatCard, DataTableToolbar, DataTableRows, DataTablePagination, DataTableColumnHeader, SegmentedControl, Card, CardHeader, CardAction, CardContent, CardFooter, StatusBadge, UserCell, NumberCell and EmptyState.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"
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 { BookFigures } from "./components/book-figures"
import { ListingsView } from "./components/listings-view"
import { bookTotals, cities, currentUser, lastUpdated, listingRows, shellNotifications } from "./data"
/**
* The book. The page is a server component: it reads every listing and the
* agent holding it through `db`, adds the book up for the four figures, and
* hands the rows to the island that searches, filters, sorts and pages them —
* as a table, or as tiles. Each listing's title opens its own page.
*/
export default function ListingsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.listings}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="real-estate-listings-title"
title="Listings"
description="Every property on the book: what it is, where, what it asks, who holds it and how the market has answered."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<BookFigures totals={bookTotals()} />
<ListingsView titleId="real-estate-listings-title" rows={listingRows()} cities={cities()} />
</div>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/real-estate-listingsNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"
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 { BookFigures } from "./components/book-figures"
import { ListingsView } from "./components/listings-view"
import { bookTotals, cities, currentUser, lastUpdated, listingRows, shellNotifications } from "./data"
/**
* The book. The page is a server component: it reads every listing and the
* agent holding it through `db`, adds the book up for the four figures, and
* hands the rows to the island that searches, filters, sorts and pages them —
* as a table, or as tiles. Each listing's title opens its own page.
*/
export default function ListingsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.listings}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="real-estate-listings-title"
title="Listings"
description="Every property on the book: what it is, where, what it asks, who holds it and how the market has answered."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<BookFigures totals={bookTotals()} />
<ListingsView titleId="real-estate-listings-title" rows={listingRows()} cities={cities()} />
</div>
</AppShell>
)
}/**
* 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 }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes: the book is read here, and a listing is
* marked under offer or sold from its own page. A server action so the page
* can stay a server component and still hand the shell something to call.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}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>
)
}"use client"
import Link from "next/link"
import { KIND_ICONS, KIND_LABELS, LISTING_STATUS_LABELS, LISTING_STATUS_MAP, money } from "@/lib/dashboards/real-estate/vocabulary"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { NumberCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type ListingRow } from "../data"
import { LISTED_DAY, layout } from "./listing-vocabulary"
/**
* The book's columns. The table is built with `useTable` here rather than the
* `DataTable` wrapper — the tiles read the same filtered, sorted page — so the
* facet columns name their filter (`arrHas`: the row's value is one of the
* picked) and the search column its own (`includesString`). The title is the
* link to the listing's page, with its kind and city under it; those two keep
* columns of their own for the facets and the View menu, which the view opens
* hidden.
*
* On a phone the table keeps the listing and its price; the status rides
* under the title with the kind and the city, and the layout, the agent and
* the interest wait for a wider screen, instead of three screens of columns
* scrolling sideways.
*/
export const LISTING_COLUMNS: DataTableColumnDef<ListingRow>[] = [
{
id: "listing",
// Title and city in one accessor, so the search box matches either.
accessorFn: (row) => `${row.title} ${row.city}`,
filterFn: "includesString",
header: ({ column }) => <DataTableColumnHeader column={column} title="Listing" />,
cell: ({ row }) => {
const Icon = KIND_ICONS[row.original.kind]
return (
<span className="flex max-w-52 min-w-40 items-center gap-3 md:max-w-none md:min-w-52">
<span
aria-hidden="true"
data-slot="listing-glyph"
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-foreground text-background"
>
<Icon className="size-4" />
</span>
<span className="flex min-w-0 flex-col">
<Link href={row.original.href} className="truncate rounded-sm font-medium underline-offset-4 hover:underline focus-ring">
{row.original.title}
</Link>
<span className="truncate text-xs text-muted-foreground">
<span className="md:hidden">{`${LISTING_STATUS_LABELS[row.original.status]} · `}</span>
{`${KIND_LABELS[row.original.kind]} · ${row.original.city}`}
</span>
</span>
</span>
)
},
meta: { label: "Listing" },
},
{
accessorKey: "kind",
filterFn: "arrHas",
header: ({ column }) => <DataTableColumnHeader column={column} title="Kind" />,
cell: ({ row }) => <span className="text-muted-foreground">{KIND_LABELS[row.original.kind]}</span>,
meta: { label: "Kind" },
},
{
accessorKey: "city",
filterFn: "arrHas",
header: ({ column }) => <DataTableColumnHeader column={column} title="City" />,
cell: ({ row }) => <span className="whitespace-nowrap">{row.original.city}</span>,
meta: { label: "City" },
},
{
accessorKey: "priceCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="Price" />,
cell: ({ row }) => <span className="whitespace-nowrap tabular-nums">{money(row.original.priceCents)}</span>,
meta: { align: "right", label: "Price" },
},
{
accessorKey: "sqm",
header: ({ column }) => <DataTableColumnHeader column={column} title="Layout" />,
cell: ({ row }) => <span className="whitespace-nowrap tabular-nums text-muted-foreground">{layout(row.original)}</span>,
meta: { label: "Layout", className: "hidden md:table-cell" },
},
{
accessorKey: "status",
filterFn: "arrHas",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge status={row.original.status} label={LISTING_STATUS_LABELS[row.original.status]} map={LISTING_STATUS_MAP} />
),
meta: { label: "Status", className: "hidden md:table-cell" },
},
{
accessorKey: "agent",
header: ({ column }) => <DataTableColumnHeader column={column} title="Agent" />,
cell: ({ row }) => (
<UserCell size="sm" name={row.original.agent} src={row.original.agentAvatar} className="whitespace-nowrap" />
),
meta: { label: "Agent", className: "hidden md:table-cell" },
},
{
accessorKey: "views",
header: ({ column }) => <DataTableColumnHeader column={column} title="Views" />,
cell: ({ row }) => <NumberCell value={row.original.views} />,
meta: { align: "right", label: "Views", className: "hidden md:table-cell" },
},
{
accessorKey: "enquiries",
header: ({ column }) => <DataTableColumnHeader column={column} title="Enquiries" />,
cell: ({ row }) => <NumberCell value={row.original.enquiries} />,
meta: { align: "right", label: "Enquiries", className: "hidden md:table-cell" },
},
{
id: "listedAt",
accessorFn: (row) => row.listedAt.getTime(),
header: ({ column }) => <DataTableColumnHeader column={column} title="Listed" />,
cell: ({ row }) => (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">{LISTED_DAY.format(row.original.listedAt)}</span>
),
meta: { align: "right", label: "Listed" },
},
]import Link from "next/link"
import { BathIcon, BedDoubleIcon, RulerDimensionLineIcon } from "lucide-react"
import { KIND_ICONS, KIND_LABELS, LISTING_STATUS_LABELS, LISTING_STATUS_MAP, money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatNumber } from "@/lib/format"
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { type ListingRow } from "../data"
/**
* One listing as a tile, drawn with the kit rather than a photograph: its
* kind's glyph in ink on the strip with the status pill, the asking price as
* the figure, the rooms and the floor area, and the agent with the interest
* the listing has drawn. The whole tile opens the listing — the title is the
* link, stretched over the card — so there is one tab stop per tile.
*/
export function ListingTile({ listing }: { listing: ListingRow }) {
const Icon = KIND_ICONS[listing.kind]
return (
<Card data-slot="listing-tile" variant="interactive" className="relative h-full">
<CardHeader>
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
data-slot="listing-glyph"
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-foreground text-background"
>
<Icon className="size-4" />
</span>
<span className="truncate text-xs text-muted-foreground">{`${KIND_LABELS[listing.kind]} · ${listing.city}`}</span>
</div>
<CardAction>
<StatusBadge status={listing.status} label={LISTING_STATUS_LABELS[listing.status]} map={LISTING_STATUS_MAP} />
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Link
href={listing.href}
className="truncate rounded-sm font-medium focus-ring after:absolute after:inset-0 after:rounded-xl"
>
{listing.title}
</Link>
<p data-slot="listing-price" className="type-numeral text-2xl">
{money(listing.priceCents)}
</p>
<ul aria-label="Rooms and area" className="flex flex-wrap gap-x-4 gap-y-1 text-xs tabular-nums text-muted-foreground">
{listing.beds > 0 ? (
<li className="flex items-center gap-1.5">
<BedDoubleIcon aria-hidden="true" className="size-3.5" />
{`${listing.beds} bed${listing.beds === 1 ? "" : "s"}`}
</li>
) : null}
<li className="flex items-center gap-1.5">
<BathIcon aria-hidden="true" className="size-3.5" />
{`${listing.baths} bath${listing.baths === 1 ? "" : "s"}`}
</li>
<li className="flex items-center gap-1.5">
<RulerDimensionLineIcon aria-hidden="true" className="size-3.5" />
{`${formatNumber(listing.sqm)} m²`}
</li>
</ul>
</CardContent>
<CardFooter className="justify-between gap-3">
<UserCell size="sm" name={listing.agent} src={listing.agentAvatar} className="min-w-0" />
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{`${formatNumber(listing.views)} views · ${formatNumber(listing.enquiries)} enquiries`}
</span>
</CardFooter>
</Card>
)
}import { formatNumber } from "@/lib/format"
/**
* How this page writes a listing's rooms and the day it was listed. 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`.
*/
/** "3 bd · 2 ba · 140 m²" — an office has no bedrooms to count. */
export function layout(row: { beds: number; baths: number; sqm: number }): string {
return [row.beds > 0 ? `${row.beds} bd` : null, `${row.baths} ba`, `${formatNumber(row.sqm)} m²`]
.filter(Boolean)
.join(" · ")
}
export const LISTED_DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })"use client"
import * as React from "react"
import { useTable } from "@tanstack/react-table"
import { LayoutGridIcon, TableIcon } from "lucide-react"
import { KIND_LABELS, LISTING_STATUS_LABELS } from "@/lib/dashboards/real-estate/vocabulary"
import {
DataTablePagination,
DataTableRows,
DataTableToolbar,
dataTableDefaultColumn,
dataTableFeatures,
type DataTableFacet,
} from "@/components/ui/data-table"
import { EmptyState } from "@/components/ui/empty-state"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { type ListingRow } from "../data"
import { LISTING_COLUMNS } from "./listing-columns"
import { ListingTile } from "./listing-tile"
const VIEWS = [
{ value: "table", label: "Table", icon: <TableIcon /> },
{ value: "tiles", label: "Tiles", icon: <LayoutGridIcon /> },
]
export type ListingsViewProps = {
/** The page's h1 id, which names the table. */
titleId: string
rows: ListingRow[]
cities: string[]
}
/**
* The book, two ways over one query. One table instance holds the search, the
* facets, the sort and the page; the switch only changes how that page is
* drawn — as rows, or as tiles the kit draws in place of photographs — so
* narrowing the book to villas in Lisbon holds whichever way it is read.
*/
export function ListingsView({ titleId, rows, cities }: ListingsViewProps) {
const [view, setView] = React.useState("table")
const facets = React.useMemo<DataTableFacet<ListingRow>[]>(
() => [
{ columnId: "kind", title: "Kind", options: Object.entries(KIND_LABELS).map(([value, label]) => ({ value, label })) },
{
columnId: "status",
title: "Status",
options: Object.entries(LISTING_STATUS_LABELS).map(([value, label]) => ({ value, label })),
},
{ columnId: "city", title: "City", options: cities.map((city) => ({ value: city, label: city })) },
],
[cities]
)
const table = useTable({
features: dataTableFeatures,
columns: LISTING_COLUMNS,
data: rows,
defaultColumn: dataTableDefaultColumn,
enableRowSelection: false,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 12 },
sorting: [{ id: "listedAt", desc: true }],
// The kind and the city ride under each title, and the book reads
// newest first; their own columns — and the day each was listed — are
// one click away in the View menu, so the table fits a 1,280px screen
// without scrolling sideways. A hidden column still takes its facet.
columnVisibility: { kind: false, city: false, listedAt: false },
},
})
const pagination = <DataTablePagination table={table} showSelectedCount={false} pageSizeOptions={[12, 24, 48]} />
const page = table.getRowModel().rows.map((row) => row.original)
return (
<div data-slot="listings-view" className="flex flex-col gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<DataTableToolbar
table={table}
searchKey="listing"
searchPlaceholder="Search listings"
facets={facets}
// Tiles have no columns to show or hide, so no View menu: false,
// not undefined, which the toolbar fills with its own.
viewOptionsSlot={view === "tiles" ? false : undefined}
className="min-w-0 flex-1"
/>
<SegmentedControl size="sm" aria-label="Show the book as" options={VIEWS} value={view} onValueChange={setView} />
</div>
{view === "table" ? (
<DataTableRows table={table} aria-labelledby={titleId} emptyMessage="No listing matches these filters." footer={pagination} />
) : (
<div className="flex flex-col gap-3">
{page.length === 0 ? (
<EmptyState variant="dashed" title="No listing matches these filters." />
) : (
<ul aria-label="Listings" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{page.map((listing) => (
<li key={listing.id} className="min-w-0">
<ListingTile listing={listing} />
</li>
))}
</ul>
)}
{pagination}
</div>
)}
</div>
)
}