Skip to contentVibraUI

Listing agent

The agent holding the listing — a face, a number to call and an address to write to — and their book this year; reads listingRecord(id).

Preview

Install

npx shadcn@latest add @vibra/widget-real-estate-listing-agent-card

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

Source

app/real-estate/listings/[id]/components/agent-card.tsx
"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>
  )
}
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