Deal
Where the deal stands and the two verbs that move it — an offer taken, and a sale — each written through the server, a refusal marked with its reason; reads listingRecord(id).
Preview
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-real-estate-listing-deal-cardNeeds the @vibra registry in your components.json — set it up once.
Source
"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 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 * 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" })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