← back to Homesonspec
apps/web/src/app/search/SearchClient.tsx
1079 lines
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";
// Which URL params seed the filter state on load (href-drill: a link like
// /search?city=Austin&status=MOVE_IN_READY deep-links into a pre-filtered grid).
// Multi-value params (repeatable) vs single-value params are split so hydration
// rebuilds the exact filter shape the rail expects.
const MULTI_FILTER_KEYS = ["st", "builder", "status", "homeType"] as const;
const SINGLE_FILTER_KEYS = [
"priceMin", "priceMax", "bedsMin", "bathsMin", "sqftMin", "sqftMax",
"stories", "garageMin", "city", "incentives", "ageRestricted", "moveInByMonths",
] as const;
function hydrateFilters(qs: string): Record<string, string | string[]> {
const sp = new URLSearchParams(qs);
const out: Record<string, string | string[]> = {};
for (const k of MULTI_FILTER_KEYS) {
const vals = sp.getAll(k);
if (vals.length) out[k] = vals;
}
for (const k of SINGLE_FILTER_KEYS) {
const v = sp.get(k);
if (v) out[k] = v;
}
return out;
}
// Leaflet needs window — client-only load, direct file import (not the barrel).
const MapView = dynamic(() => import("@homesonspec/shared-ui/src/MapView").then((m) => m.MapView), { ssr: false });
interface Home {
id: string;
street: string | null;
city: string;
state: string;
zip: string;
price: string | number | null;
images: string[];
beds: number | null;
bathsTotal: string | number | null;
sqft: number | null;
stories: number | null;
garageSpaces: number | null;
homeType: string | null;
lat: string | number | null;
lon: string | number | null;
constructionStatus: string;
estCompletionDate: string | null;
verificationLabel: string;
lastVerifiedAt: string | null;
publishedAt: string;
community: { slug: string; name: string; schoolDistrict: string | null };
builder: { slug: string; name: string };
}
interface SearchResponse {
homes: Home[];
total: number;
page: number;
pageSize: number;
location: { label: string; lat: number; lon: number } | null;
}
interface Band {
label: string;
min: number | null;
max: number | null;
count: number;
}
interface Facets {
constructionStatus: { value: string; count: number }[];
builder: { value: string; label: string; count: number }[];
beds: { value: number; count: number }[];
baths: { value: number; count: number }[];
homeType: { value: string; count: number }[];
state: { value: string; count: number }[];
stories: { value: number; count: number }[];
garage: { value: number; count: number }[];
city: { value: string; count: number }[];
priceBands: Band[];
sqftBands: Band[];
}
const STATE_NAMES: Record<string, string> = {
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas", CA: "California", CO: "Colorado",
CT: "Connecticut", DE: "Delaware", FL: "Florida", GA: "Georgia", HI: "Hawaii", ID: "Idaho",
IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas", KY: "Kentucky", LA: "Louisiana",
ME: "Maine", MD: "Maryland", MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada", NH: "New Hampshire", NJ: "New Jersey",
NM: "New Mexico", NY: "New York", NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island", SC: "South Carolina", SD: "South Dakota",
TN: "Tennessee", TX: "Texas", UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming", DC: "Washington DC",
};
// Broadened sort set. Every option maps 1:1 to a whitelisted key in packages/search
// (SORT_KEYS) and drives the server-side orderBy over the full result set. The Table
// view's click-to-sort column headers set the SAME `sort` param.
const SORTS = [
{ value: "newest", label: "Newest" },
{ value: "price_asc", label: "Price ↑" },
{ value: "price_desc", label: "Price ↓" },
{ value: "sqft_asc", label: "Sq Ft ↑" },
{ value: "sqft_desc", label: "Sq Ft ↓" },
{ value: "beds_desc", label: "Beds (most)" },
{ value: "baths_desc", label: "Baths (most)" },
{ value: "completion_asc", label: "Completion (soonest)" },
{ value: "completion_desc", label: "Completion (latest)" },
{ value: "city_asc", label: "City A→Z" },
{ value: "state_asc", label: "State A→Z" },
{ value: "last_verified", label: "Recently verified" },
{ value: "closest", label: "Closest" },
] as const;
// View modes — mirrors the vanilla RE builds' shared grid engine: card Grid, compact
// List, and dense all-columns Table. Persisted to localStorage (homesonspec.viewMode).
type ViewMode = "grid" | "list" | "table";
const VIEW_MODES: { value: ViewMode; label: string }[] = [
{ value: "grid", label: "Grid" },
{ value: "list", label: "List" },
{ value: "table", label: "Table" },
];
// Table columns: [sort key asc, sort key desc] so a header click toggles direction.
// null means the column isn't server-sortable (e.g. street address).
type TableCol = {
key: string;
label: string;
asc: string | null;
desc: string | null;
align?: "right";
};
const TABLE_COLS: TableCol[] = [
{ key: "address", label: "Address", asc: null, desc: null },
{ key: "city", label: "City", asc: "city_asc", desc: "city_desc" },
{ key: "state", label: "State", asc: "state_asc", desc: "state_desc" },
{ key: "price", label: "Price", asc: "price_asc", desc: "price_desc", align: "right" },
{ key: "beds", label: "Beds", asc: "beds_asc", desc: "beds_desc", align: "right" },
{ key: "baths", label: "Baths", asc: "baths_asc", desc: "baths_desc", align: "right" },
{ key: "sqft", label: "Sq Ft", asc: "sqft_asc", desc: "sqft_desc", align: "right" },
{ key: "stories", label: "Stories", asc: "stories_asc", desc: "stories_desc", align: "right" },
{ key: "garage", label: "Garage", asc: "garage_asc", desc: "garage_desc", align: "right" },
{ key: "homeType", label: "Type", asc: null, desc: null },
{ key: "status", label: "Status", asc: null, desc: null },
{ key: "completion", label: "Completion", asc: "completion_asc", desc: "completion_desc" },
{ key: "builder", label: "Builder", asc: null, desc: null },
{ key: "verified", label: "Verified", asc: "last_verified_asc", desc: "last_verified" },
];
const STATUS_LABELS: Record<string, string> = {
MOVE_IN_READY: "Move-in ready",
UNDER_CONSTRUCTION: "Under construction",
PLANNED: "Planned",
};
const HOME_TYPE_LABELS: Record<string, string> = {
SINGLE_FAMILY: "Single-family",
TOWNHOME: "Townhome",
CONDO: "Condo",
DUPLEX: "Duplex",
OTHER: "Other",
};
// national-site card upgrades: colored urgency badges + an estimated monthly payment.
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
MOVE_IN_READY: { label: "Move-in ready", cls: "bg-amber-100 text-amber-800 ring-1 ring-amber-200" },
UNDER_CONSTRUCTION: { label: "Under construction", cls: "bg-sky-100 text-sky-800 ring-1 ring-sky-200" },
PLANNED: { label: "Planned", cls: "bg-neutral-100 text-neutral-600 ring-1 ring-neutral-200" },
};
// est. all-in monthly: 20% down, 7% APR, 30yr P&I + ~1.25%/yr taxes+insurance.
function estMonthly(price: string | number | null): number | null {
const p = Number(price);
if (!Number.isFinite(p) || p <= 0) return null;
const loan = p * 0.8, r = 0.07 / 12, n = 360;
const pi = (loan * (r * Math.pow(1 + r, n))) / (Math.pow(1 + r, n) - 1);
return Math.round(pi + (p * 0.0125) / 12);
}
// Price/sqft are continuous, so their card atoms drill to a sensible BAND around
// the home's value (±15% price, ±20% sqft) rather than an exact match that would
// usually return just the one home. Rounds to clean numbers for a shareable URL.
function priceBandDrill(price: string | number | null, drill: (kv: Record<string, string>) => string): string {
const p = Number(price);
if (!Number.isFinite(p) || p <= 0) return drill({});
const lo = Math.max(0, Math.floor((p * 0.85) / 10000) * 10000);
const hi = Math.ceil((p * 1.15) / 10000) * 10000;
return drill({ priceMin: String(lo), priceMax: String(hi) });
}
function sqftBandDrill(sqft: number, drill: (kv: Record<string, string>) => string): string {
if (!Number.isFinite(sqft) || sqft <= 0) return drill({});
const lo = Math.max(0, Math.floor((sqft * 0.8) / 100) * 100);
const hi = Math.ceil((sqft * 1.2) / 100) * 100;
return drill({ sqftMin: String(lo), sqftMax: String(hi) });
}
// The rail's data-point panels, in display order. Each is a collapsed accordion whose
// open/closed state persists to localStorage (standing "one collapsed tab per data field" rule).
const FACET_KEYS = [
"state", "price", "beds", "baths", "sqft", "homeType",
"status", "stories", "garage", "city", "builder", "more",
] as const;
export default function SearchClient({
initialQuery,
initialParams = "",
}: {
initialQuery: string;
initialParams?: string;
}) {
const [q, setQ] = useState(initialQuery);
// Seed filters from the URL so drill links (?city=…&status=…) land pre-filtered.
const [filters, setFilters] = useState<Record<string, string | string[]>>(() =>
hydrateFilters(initialParams),
);
// Sort + density persist across reloads (standing grid rule). Sort can also be
// seeded from the URL (a drill link may pin a sort).
const [sort, setSort] = useState<string>(() => {
const s = new URLSearchParams(initialParams).get("sort");
return s ?? "newest";
});
const [cols, setCols] = useState<number>(3);
// View mode (grid | list | table), persisted across reloads.
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [data, setData] = useState<SearchResponse | null>(null);
const [facets, setFacets] = useState<Facets | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const bboxRef = useRef<string | null>(null);
// Per-panel collapsed/expanded state — persisted so the rail remembers what you opened.
const [open, setOpen] = useState<Record<string, boolean>>({});
// Favorites (national-site staple) — persisted client-side.
const [favs, setFavs] = useState<Set<string>>(new Set());
useEffect(() => { try { setFavs(new Set(JSON.parse(localStorage.getItem("homesonspec.favs") || "[]"))); } catch {} }, []);
const toggleFav = (id: string) => setFavs((prev) => {
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id);
localStorage.setItem("homesonspec.favs", JSON.stringify([...n])); return n;
});
useEffect(() => {
const savedSort = localStorage.getItem("homesonspec.sort");
const savedCols = localStorage.getItem("homesonspec.cols");
const savedView = localStorage.getItem("homesonspec.viewMode");
if (savedSort) setSort(savedSort);
if (savedCols) setCols(Number(savedCols));
if (savedView === "grid" || savedView === "list" || savedView === "table") setViewMode(savedView);
try {
const savedOpen = JSON.parse(localStorage.getItem("homesonspec.facetOpen") || "null");
// First visit → everything collapsed except State (so the rail isn't a blank wall).
setOpen(savedOpen ?? { state: true });
} catch { setOpen({ state: true }); }
}, []);
const setSortPersist = (value: string) => {
setSort(value);
localStorage.setItem("homesonspec.sort", value);
};
const setColsPersist = (value: number) => {
setCols(value);
localStorage.setItem("homesonspec.cols", String(value));
};
const setViewModePersist = (value: ViewMode) => {
setViewMode(value);
localStorage.setItem("homesonspec.viewMode", value);
};
// A Table header click sets the sort param (server-side) and toggles direction when
// the same column is clicked again. Resets to page 1 so the new order starts at top.
const onColumnSort = (col: TableCol) => {
if (!col.asc && !col.desc) return; // non-sortable column
setPage(1);
const next = sort === col.asc ? (col.desc ?? col.asc) : (col.asc ?? col.desc);
if (next) setSortPersist(next);
};
const persistOpen = (next: Record<string, boolean>) => {
setOpen(next);
localStorage.setItem("homesonspec.facetOpen", JSON.stringify(next));
};
const toggleOpen = (key: string) => persistOpen({ ...open, [key]: !open[key] });
const setAllOpen = (v: boolean) => persistOpen(Object.fromEntries(FACET_KEYS.map((k) => [k, v])));
const queryString = useMemo(() => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (bboxRef.current) params.set("bbox", bboxRef.current);
for (const [key, value] of Object.entries(filters)) {
if (Array.isArray(value)) value.forEach((v) => params.append(key, v));
else if (value !== "") params.set(key, value);
}
params.set("sort", sort);
params.set("page", String(page));
return params.toString();
}, [q, filters, sort, page]);
// Keep the browser URL in sync with the active filters + sort + query so the
// filtered grid is URL-addressable and shareable (href-drill rule). bbox/page
// are intentionally excluded to keep the shareable URL stable and clean.
useEffect(() => {
const params = new URLSearchParams();
if (q) params.set("q", q);
for (const [key, value] of Object.entries(filters)) {
if (Array.isArray(value)) value.forEach((v) => params.append(key, v));
else if (value !== "") params.set(key, value);
}
if (sort !== "newest") params.set("sort", sort);
const qs = params.toString();
const url = qs ? `/search?${qs}` : "/search";
window.history.replaceState(null, "", url);
}, [q, filters, sort]);
const fetchResults = useCallback(async () => {
setLoading(true);
try {
const [searchRes, facetsRes] = await Promise.all([
fetch(`/api/search?${queryString}`),
fetch(`/api/facets?${queryString}`),
]);
setData(await searchRes.json());
setFacets(await facetsRes.json());
} finally {
setLoading(false);
}
}, [queryString]);
useEffect(() => {
void fetchResults();
}, [fetchResults]);
const toggleMulti = (key: string, value: string) => {
setPage(1);
setFilters((prev) => {
const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : [];
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
return { ...prev, [key]: next };
});
};
const setSingle = (key: string, value: string) => {
setPage(1);
setFilters((prev) => ({ ...prev, [key]: value }));
};
// Range bands set a min+max pair together; clicking the active band clears both.
const bandActive = (minKey: string, maxKey: string, b: Band) =>
(filters[minKey] ?? "") === (b.min === null ? "" : String(b.min)) &&
(filters[maxKey] ?? "") === (b.max === null ? "" : String(b.max));
const toggleBand = (minKey: string, maxKey: string, b: Band) => {
setPage(1);
setFilters((prev) => {
const next = { ...prev };
if (bandActive(minKey, maxKey, b)) { delete next[minKey]; delete next[maxKey]; return next; }
if (b.min === null) delete next[minKey]; else next[minKey] = String(b.min);
if (b.max === null) delete next[maxKey]; else next[maxKey] = String(b.max);
return next;
});
};
// How many active selections live inside each panel — drives the teal header badge
// so you can see where your filters are even while a panel is collapsed.
const activeCount = (key: string): number => {
const arr = (k: string) => (Array.isArray(filters[k]) ? (filters[k] as string[]).length : 0);
const one = (k: string) => (filters[k] && filters[k] !== "" ? 1 : 0);
switch (key) {
case "state": return arr("st");
case "builder": return arr("builder");
case "status": return arr("status");
case "homeType": return arr("homeType");
case "beds": return one("bedsMin");
case "baths": return one("bathsMin");
case "stories": return one("stories");
case "garage": return one("garageMin");
case "city": return one("city");
case "price": return one("priceMin") + one("priceMax");
case "sqft": return one("sqftMin") + one("sqftMax");
case "more": return one("incentives") + one("ageRestricted") + one("moveInByMonths");
default: return 0;
}
};
const totalActive = FACET_KEYS.reduce((n, k) => n + activeCount(k), 0);
const markers = (data?.homes ?? [])
.filter((h) => h.lat !== null && h.lon !== null)
.map((h) => ({
id: h.id,
lat: Number(h.lat),
lon: Number(h.lon),
label: h.street ?? `${h.community.name} home`,
sublabel: fmtPrice(h.price),
href: `/homes/${h.id}`,
}));
const onMapMove = useCallback(
(bbox: { minLat: number; maxLat: number; minLon: number; maxLon: number }) => {
bboxRef.current = `${bbox.minLat},${bbox.minLon},${bbox.maxLat},${bbox.maxLon}`;
},
[],
);
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
// Drill-href builder: every displayed data point on a card links to its deeper,
// URL-addressable filtered view (href-drill rule — no dead-end data points).
const drill = (kv: Record<string, string>): string => {
// Preserve the CURRENT active rail filters so a card drill NARROWS context instead of
// clobbering it (TK-10482 contrarian SHOULD-FIX: a TX+AZ user clicking a city kept only TX).
// Multi-value keys (st/builder/status/homeType) UNION with the drilled value; single keys replace.
const MULTI = new Set<string>(MULTI_FILTER_KEYS as readonly string[]);
const params = new URLSearchParams();
const kvKeys = new Set(Object.keys(kv));
for (const [k, val] of Object.entries(filters)) {
if (kvKeys.has(k) && !MULTI.has(k)) continue; // single key: the drill value replaces it below
if (Array.isArray(val)) { for (const v of val) if (v) params.append(k, v); }
else if (val) params.set(k, String(val));
}
for (const [k, v] of Object.entries(kv)) {
if (!v) continue;
if (MULTI.has(k)) { if (!params.getAll(k).includes(v)) params.append(k, v); }
else params.set(k, v);
}
return `/search?${params.toString()}`;
};
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Search bar */}
<form
onSubmit={(e) => {
e.preventDefault();
bboxRef.current = null;
setPage(1);
void fetchResults();
}}
className="flex gap-2"
>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="City, ZIP, or community"
className="field max-w-md flex-1 py-2"
data-testid="search-input"
/>
<button type="submit" className="btn-primary">
Search
</button>
</form>
<div className="mt-6 grid gap-6 lg:grid-cols-[280px_1fr]">
{/* Amazon-style faceted rail — every data point a collapsed panel with live counts. */}
<aside className="text-sm lg:sticky lg:top-[68px] lg:max-h-[calc(100vh-84px)] lg:overflow-y-auto lg:pr-1" data-testid="filters">
<div className="mb-2 flex items-center justify-between">
<h2 className="font-display text-base font-semibold text-brand-900">
Filter homes
{totalActive > 0 && (
<span className="ml-2 rounded-full bg-teal-100 px-1.5 py-0.5 align-middle text-[11px] font-bold text-teal-700 ring-1 ring-teal-200">
{totalActive}
</span>
)}
</h2>
{totalActive > 0 && (
<button type="button" className="text-xs font-semibold text-brand-700 hover:text-brand-800"
onClick={() => { setPage(1); setFilters({}); }} data-testid="clear-all">
Clear all
</button>
)}
</div>
{/* Expand / collapse every panel at once. */}
<div className="mb-1 flex gap-3 text-[11px] font-semibold text-neutral-500">
<button type="button" className="hover:text-brand-700" onClick={() => setAllOpen(true)} data-testid="expand-all">Expand all</button>
<span className="text-neutral-300">·</span>
<button type="button" className="hover:text-brand-700" onClick={() => setAllOpen(false)} data-testid="collapse-all">Collapse all</button>
</div>
{/* State — multi-select, top 12 + overflow. */}
<FacetPanel id="state" title="State" optionCount={facets?.state.length} active={activeCount("state")} open={!!open.state} onToggle={() => toggleOpen("state")}>
<ChecklistOverflow
items={(facets?.state ?? []).map((s) => ({ value: s.value, label: STATE_NAMES[s.value] ?? s.value, count: s.count }))}
selected={Array.isArray(filters.st) ? (filters.st as string[]) : []}
onToggle={(v) => toggleMulti("st", v)}
testid="facet-state"
/>
</FacetPanel>
{/* Price — quick range bands (counts) + custom min/max. */}
<FacetPanel id="price" title="Price" optionCount={facets?.priceBands.length} active={activeCount("price")} open={!!open.price} onToggle={() => toggleOpen("price")}>
<div className="space-y-0.5" data-testid="facet-price">
{(facets?.priceBands ?? []).map((b) => (
<FacetCheck key={b.label} label={b.label} count={b.count}
checked={bandActive("priceMin", "priceMax", b)} onToggle={() => toggleBand("priceMin", "priceMax", b)} />
))}
</div>
<div className="mt-2 flex gap-2">
<input type="number" placeholder="Min $" value={(filters.priceMin as string) ?? ""} className="w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
onChange={(e) => setSingle("priceMin", e.target.value)} data-testid="price-min" />
<input type="number" placeholder="Max $" value={(filters.priceMax as string) ?? ""} className="w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
onChange={(e) => setSingle("priceMax", e.target.value)} data-testid="price-max" />
</div>
</FacetPanel>
{/* Beds — single-select "N+" min, with live counts. */}
<FacetPanel id="beds" title="Beds (min)" optionCount={facets?.beds.length} active={activeCount("beds")} open={!!open.beds} onToggle={() => toggleOpen("beds")}>
<MinChips values={(facets?.beds ?? []).map((b) => b.value)} suffix="+" field="bedsMin" filters={filters} onPick={setSingle} testid="facet-beds" />
</FacetPanel>
{/* Baths — single-select "N+" min, with live counts. */}
<FacetPanel id="baths" title="Baths (min)" optionCount={facets?.baths.length} active={activeCount("baths")} open={!!open.baths} onToggle={() => toggleOpen("baths")}>
<MinChips values={(facets?.baths ?? []).map((b) => b.value)} suffix="+" field="bathsMin" filters={filters} onPick={setSingle} testid="facet-baths" />
</FacetPanel>
{/* Sq ft — quick range bands (counts). */}
<FacetPanel id="sqft" title="Sq ft" optionCount={facets?.sqftBands.length} active={activeCount("sqft")} open={!!open.sqft} onToggle={() => toggleOpen("sqft")}>
<div className="space-y-0.5" data-testid="facet-sqft">
{(facets?.sqftBands ?? []).map((b) => (
<FacetCheck key={b.label} label={b.label} count={b.count}
checked={bandActive("sqftMin", "sqftMax", b)} onToggle={() => toggleBand("sqftMin", "sqftMax", b)} />
))}
</div>
</FacetPanel>
{/* Home type — multi-select. */}
<FacetPanel id="homeType" title="Home type" optionCount={facets?.homeType.length} active={activeCount("homeType")} open={!!open.homeType} onToggle={() => toggleOpen("homeType")}>
<div className="space-y-0.5" data-testid="facet-hometype">
{(facets?.homeType ?? []).map((t) => (
<FacetCheck key={t.value} label={HOME_TYPE_LABELS[t.value] ?? t.value} count={t.count}
checked={Array.isArray(filters.homeType) && (filters.homeType as string[]).includes(t.value)}
onToggle={() => toggleMulti("homeType", t.value)} />
))}
</div>
</FacetPanel>
{/* Construction status — multi-select. */}
<FacetPanel id="status" title="Construction status" optionCount={facets?.constructionStatus.length} active={activeCount("status")} open={!!open.status} onToggle={() => toggleOpen("status")}>
<div className="space-y-0.5" data-testid="facet-status">
{(facets?.constructionStatus ?? []).map((facet) => (
<FacetCheck key={facet.value}
label={STATUS_LABELS[facet.value] ?? facet.value} count={facet.count}
checked={Array.isArray(filters.status) && (filters.status as string[]).includes(facet.value)}
onToggle={() => toggleMulti("status", facet.value)} />
))}
</div>
</FacetPanel>
{/* Stories — single-select exact, with live counts. */}
<FacetPanel id="stories" title="Stories" optionCount={facets?.stories.length} active={activeCount("stories")} open={!!open.stories} onToggle={() => toggleOpen("stories")}>
<MinChips values={(facets?.stories ?? []).map((s) => s.value)} suffix="" field="stories" filters={filters} onPick={setSingle} testid="facet-stories" />
</FacetPanel>
{/* Garage — single-select "N+" min, with live counts. */}
<FacetPanel id="garage" title="Garage (min)" optionCount={facets?.garage.length} active={activeCount("garage")} open={!!open.garage} onToggle={() => toggleOpen("garage")}>
<MinChips values={(facets?.garage ?? []).map((g) => g.value)} suffix="+" field="garageMin" filters={filters} onPick={setSingle} testid="facet-garage" />
</FacetPanel>
{/* City — single-select, top 25 + overflow. */}
<FacetPanel id="city" title="City" optionCount={facets?.city.length} active={activeCount("city")} open={!!open.city} onToggle={() => toggleOpen("city")}>
<ChecklistOverflow
items={(facets?.city ?? []).map((c) => ({ value: c.value, label: c.value, count: c.count }))}
selected={filters.city ? [filters.city as string] : []}
onToggle={(v) => setSingle("city", filters.city === v ? "" : v)}
testid="facet-city"
/>
</FacetPanel>
{/* Builder / firm — multi-select. */}
<FacetPanel id="builder" title="Builder" optionCount={facets?.builder.length} active={activeCount("builder")} open={!!open.builder} onToggle={() => toggleOpen("builder")}>
<ChecklistOverflow
items={(facets?.builder ?? []).map((b) => ({ value: b.value, label: b.label, count: b.count }))}
selected={Array.isArray(filters.builder) ? (filters.builder as string[]) : []}
onToggle={(v) => toggleMulti("builder", v)}
testid="facet-builder"
/>
</FacetPanel>
{/* More — incentives / age-restricted / move-in timeframe. */}
<FacetPanel id="more" title="More" active={activeCount("more")} open={!!open.more} onToggle={() => toggleOpen("more")}>
<div data-testid="facet-more">
<label className="flex cursor-pointer items-center gap-2 py-0.5">
<input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={filters.incentives === "1"}
onChange={() => setSingle("incentives", filters.incentives === "1" ? "" : "1")} />
Has incentives
</label>
<label className="flex cursor-pointer items-center gap-2 py-0.5">
<input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={filters.ageRestricted === "1"}
onChange={() => setSingle("ageRestricted", filters.ageRestricted === "1" ? "" : "1")} />
Age-restricted community
</label>
<select className="mt-2 w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
value={(filters.moveInByMonths as string) ?? ""}
onChange={(e) => setSingle("moveInByMonths", e.target.value)}>
<option value="">Any move-in timeframe</option>
<option value="0">Ready now</option>
<option value="3">Within 3 months</option>
<option value="6">Within 6 months</option>
<option value="12">Within 12 months</option>
</select>
</div>
</FacetPanel>
</aside>
{/* Results */}
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-neutral-600" data-testid="result-count">
{loading ? "Searching…" : `${data?.total ?? 0} homes${data?.location ? ` near ${data.location.label}` : ""}`}
</div>
{/* Mandatory grid controls: view-mode toggle + sort select + density slider, localStorage-persisted */}
<div className="flex items-center gap-4">
{/* View mode: Grid | List | Table (matches the vanilla RE builds' shared grid engine). */}
<div className="inline-flex overflow-hidden rounded-lg text-sm shadow-sm ring-1 ring-inset ring-neutral-200" role="group" aria-label="View mode" data-testid="view-toggle">
{VIEW_MODES.map((v) => (
<button key={v.value} type="button"
onClick={() => setViewModePersist(v.value)}
aria-pressed={viewMode === v.value}
data-testid={`view-${v.value}`}
className={`px-3 py-1 transition ${viewMode === v.value ? "bg-brand-700 text-white" : "bg-white text-neutral-600 hover:bg-brand-50"}`}>
{v.label}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm">
Sort
<select value={sort} onChange={(e) => setSortPersist(e.target.value)}
className="rounded-lg border-0 px-2 py-1 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400" data-testid="sort-select">
{SORTS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</label>
{/* Density controls the card grid columns — only meaningful in Grid view. */}
{viewMode === "grid" && (
<label className="flex items-center gap-2 text-sm">
Density
<input type="range" min={2} max={4} step={1} value={cols} className="accent-brand-700"
onChange={(e) => setColsPersist(Number(e.target.value))} data-testid="density-slider" />
</label>
)}
</div>
</div>
{viewMode === "table" ? (
<TableView
homes={data?.homes ?? []}
loading={loading}
sort={sort}
onColumnSort={onColumnSort}
drill={drill}
priceBandDrill={priceBandDrill}
sqftBandDrill={sqftBandDrill}
/>
) : (
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_380px]">
{viewMode === "list" ? (
<ListView
homes={data?.homes ?? []}
loading={loading}
favs={favs}
toggleFav={toggleFav}
drill={drill}
priceBandDrill={priceBandDrill}
sqftBandDrill={sqftBandDrill}
/>
) : (
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} data-testid="results-grid">
{(data?.homes ?? []).map((home) => (
// href-drill: the card is a container with a STRETCHED overlay link to the
// detail page, and every data point below is its own real drill link sitting
// ABOVE the overlay (z-10). No nested anchors — clicking blank card area opens
// the detail; clicking a data atom drills to that atom's deeper view.
<div key={home.id}
className="card-interactive group relative overflow-hidden"
data-testid="home-card">
<Link href={`/homes/${home.id}`} aria-label={`View ${home.street ?? "home"} in ${home.city}, ${home.state}`}
className="absolute inset-0 z-0" data-testid="home-card-overlay" tabIndex={-1} />
{/* Photo header — the primary scan signal for a real-estate grid. Falls back
to a branded gradient when a home has no photo (image-less builders) or when
the media-rights gate blanks images upstream, so the grid stays uniform. */}
<div className="relative h-44 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500">
{home.images?.[0]?.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={home.images[0]} alt={`${home.community.name} home in ${home.city}, ${home.state}`}
loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
) : null}
<button type="button" aria-label="Save home" data-testid="fav-btn"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(home.id); }}
className="absolute right-3 top-3 z-20 text-lg leading-none drop-shadow transition hover:scale-110">
<span className={favs.has(home.id) ? "text-rose-500" : "text-white/90"}>{favs.has(home.id) ? "♥" : "♡"}</span>
</button>
{/* Status badge drills to the same construction status. */}
{STATUS_BADGE[home.constructionStatus] && (
<Link href={drill({ status: home.constructionStatus })} data-testid="drill-status"
onClick={(e) => e.stopPropagation()}
className={`absolute left-3 top-3 z-20 inline-block rounded-full px-2 py-0.5 text-[11px] font-medium shadow-sm transition hover:brightness-95 ${STATUS_BADGE[home.constructionStatus]!.cls}`}>
{STATUS_BADGE[home.constructionStatus]!.label}
</Link>
)}
</div>
<div className="relative z-10 p-4">
<div className="flex items-baseline gap-2">
{/* Price drills to a price band around this home's price (±15%). */}
<Link href={priceBandDrill(home.price, drill)} data-testid="drill-price"
className="font-display text-xl font-semibold text-brand-900 hover:text-brand-700 hover:underline decoration-brand-300 underline-offset-2">
{fmtPrice(home.price)}
</Link>
{estMonthly(home.price) !== null && (
<span className="text-xs text-neutral-500">~${estMonthly(home.price)!.toLocaleString()}/mo</span>
)}
</div>
<div className="mt-0.5 truncate text-sm text-neutral-700">
{/* Detail link for the street, then city + state each drill to their scope. */}
<Link href={`/homes/${home.id}`} className="hover:underline">
{home.street ?? "Address on request"}
</Link>{" · "}
<Link href={drill({ city: home.city, st: home.state })} data-testid="drill-city"
className="hover:text-brand-700 hover:underline">{home.city}</Link>{", "}
<Link href={drill({ st: home.state })} data-testid="drill-state"
className="hover:text-brand-700 hover:underline">{home.state}</Link>
</div>
<div className="mt-1 text-sm text-neutral-500">
{/* Beds / baths / sqft each drill to the matching min filter. */}
{home.beds != null ? (
<Link href={drill({ bedsMin: String(home.beds) })} data-testid="drill-beds"
className="hover:text-brand-700 hover:underline">{home.beds} bd</Link>
) : "— bd"}{" · "}
{home.bathsTotal != null ? (
<Link href={drill({ bathsMin: String(home.bathsTotal) })} data-testid="drill-baths"
className="hover:text-brand-700 hover:underline">{home.bathsTotal} ba</Link>
) : "— ba"}{" · "}
{home.sqft != null ? (
<Link href={sqftBandDrill(home.sqft, drill)} data-testid="drill-sqft"
className="hover:text-brand-700 hover:underline">{home.sqft.toLocaleString()} sqft</Link>
) : "— sqft"}
{home.estCompletionDate ? ` · ready ${new Date(home.estCompletionDate).toLocaleDateString(undefined, { month: "short", year: "numeric" })}` : ""}
</div>
<div className="mt-2 flex items-center justify-between">
{/* Builder drills to the builder's own page (existing route). */}
<Link href={`/builders/${home.builder.slug}`} data-testid="drill-builder"
className="truncate text-xs text-neutral-500 hover:text-brand-700 hover:underline">
{home.builder.name}
</Link>
<VerificationBadge label={home.verificationLabel} />
</div>
{/* Community drill — the "project" this home belongs to. */}
<Link href={`/communities/${home.community.slug}`} data-testid="drill-community"
className="mt-1 block truncate text-xs text-neutral-400 hover:text-brand-700 hover:underline">
{home.community.name}
</Link>
</div>
</div>
))}
{!loading && (data?.homes.length ?? 0) === 0 && (
<p className="col-span-full py-10 text-center text-neutral-500">
No homes match these filters.
</p>
)}
</div>
)}
<div className="h-[540px] overflow-hidden rounded-2xl border border-neutral-200 shadow-[var(--shadow-card)]" data-testid="map">
<MapView markers={markers} onMoveEnd={onMapMove} />
</div>
</div>
)}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-3 text-sm">
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}
className="rounded-full border border-neutral-300 px-4 py-1.5 hover:border-brand-300 disabled:opacity-40">Prev</button>
<span className="text-neutral-600">Page {page} of {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}
className="rounded-full border border-neutral-300 px-4 py-1.5 hover:border-brand-300 disabled:opacity-40">Next</button>
</div>
)}
</div>
</div>
</div>
);
}
// ── Faceted-rail primitives ────────────────────────────────────────────────
// A collapsed accordion panel for one data point. Header shows the option count and a
// teal badge with the # of active selections inside (visible even while collapsed).
function FacetPanel({
id, title, optionCount, active, open, onToggle, children,
}: {
id: string;
title: string;
optionCount?: number;
active: number;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="border-t border-neutral-100 first:border-t-0">
<button type="button" onClick={onToggle} aria-expanded={open}
className="flex w-full items-center justify-between gap-2 py-2.5 text-left"
data-testid={`facet-head-${id}`}>
<span className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-brand-600">{title}</span>
{active > 0 && (
<span className="rounded-full bg-teal-100 px-1.5 py-0.5 text-[10px] font-bold text-teal-700 ring-1 ring-teal-200">{active}</span>
)}
</span>
<span className="flex items-center gap-2 text-neutral-400">
{optionCount != null && optionCount > 0 && <span className="text-[11px]">{optionCount}</span>}
<svg className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 4.5L6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
{open && <div className="pb-3">{children}</div>}
</div>
);
}
// Single-select "N+" (or exact) numeric chips, backed by live facet counts.
function MinChips({
values, suffix, field, filters, onPick, testid,
}: {
values: number[];
suffix: string;
field: string;
filters: Record<string, string | string[]>;
onPick: (key: string, value: string) => void;
testid?: string;
}) {
if (values.length === 0) return <p className="px-1 text-xs text-neutral-400">No data</p>;
return (
<div className="flex flex-wrap gap-1.5" data-testid={testid}>
{values.map((n) => {
const active = filters[field] === String(n);
return (
<button key={n} type="button"
onClick={() => onPick(field, active ? "" : String(n))}
className={`rounded-full border px-3 py-1 text-xs transition ${active ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
{n % 1 === 0 ? n : n.toFixed(1)}{suffix}
</button>
);
})}
</div>
);
}
function FacetCheck({
label, count, checked, onToggle,
}: { label: string; count: number; checked: boolean; onToggle: () => void }) {
return (
<label className="flex cursor-pointer items-center justify-between gap-2 rounded-lg px-1 py-1 hover:bg-brand-50">
<span className="flex items-center gap-2">
<input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={checked} onChange={onToggle} />
<span className="truncate">{label}</span>
</span>
<span className="shrink-0 text-xs text-neutral-400">{count.toLocaleString()}</span>
</label>
);
}
// Shows the first `cap` options; the rest live inside a native <details> so the
// rail stays short with long lists (all 50 states, many builders, top cities).
function ChecklistOverflow({
items, selected, onToggle, cap = 12, testid,
}: {
items: { value: string; label: string; count: number }[];
selected: string[];
onToggle: (v: string) => void;
cap?: number;
testid?: string;
}) {
const head = items.slice(0, cap);
const rest = items.slice(cap);
return (
<div className="space-y-0.5" data-testid={testid}>
{head.map((it) => (
<FacetCheck key={it.value} label={it.label} count={it.count}
checked={selected.includes(it.value)} onToggle={() => onToggle(it.value)} />
))}
{rest.length > 0 && (
<details className="group">
<summary className="cursor-pointer list-none px-1 py-1 text-xs font-semibold text-brand-700 hover:text-brand-800">
+ {rest.length} more<span className="group-open:hidden"> ▾</span><span className="hidden group-open:inline"> ▴</span>
</summary>
<div className="space-y-0.5">
{rest.map((it) => (
<FacetCheck key={it.value} label={it.label} count={it.count}
checked={selected.includes(it.value)} onToggle={() => onToggle(it.value)} />
))}
</div>
</details>
)}
</div>
);
}
// ── View modes ─────────────────────────────────────────────────────────────
// Shared drill helper types so the view components can build the same href-drill
// links the Grid uses (no dead-end data points).
type Drill = (kv: Record<string, string>) => string;
type BandDrill = (v: string | number | null, drill: Drill) => string;
const fmtCompletion = (d: string | null) =>
d ? new Date(d).toLocaleDateString(undefined, { month: "short", year: "numeric" }) : "—";
const fmtVerified = (d: string | null) =>
d ? new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "—";
// ListView — one compact row per home: address/city + price + key stats inline.
// Keeps every data point a drill link (href-drill rule), like the card grid.
function ListView({
homes, loading, favs, toggleFav, drill, priceBandDrill, sqftBandDrill,
}: {
homes: Home[];
loading: boolean;
favs: Set<string>;
toggleFav: (id: string) => void;
drill: Drill;
priceBandDrill: BandDrill;
sqftBandDrill: (sqft: number, drill: Drill) => string;
}) {
if (!loading && homes.length === 0) {
return <p className="py-10 text-center text-neutral-500">No homes match these filters.</p>;
}
return (
<div className="divide-y divide-neutral-100 rounded-2xl border border-neutral-200 shadow-[var(--shadow-card)]" data-testid="results-list">
{homes.map((home) => (
<div key={home.id} className="group relative flex items-center gap-4 p-3 transition hover:bg-brand-50/40" data-testid="home-row">
<Link href={`/homes/${home.id}`} aria-label={`View ${home.street ?? "home"} in ${home.city}, ${home.state}`}
className="absolute inset-0 z-0" tabIndex={-1} />
{/* Thumbnail (or branded gradient fallback). */}
<div className="relative z-10 h-16 w-24 shrink-0 overflow-hidden rounded-lg bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500">
{home.images?.[0]?.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={home.images[0]} alt="" loading="lazy" className="h-full w-full object-cover" />
) : null}
</div>
<div className="relative z-10 min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<Link href={priceBandDrill(home.price, drill)} data-testid="row-price"
className="font-display text-lg font-semibold text-brand-900 hover:text-brand-700 hover:underline">
{fmtPrice(home.price)}
</Link>
{STATUS_BADGE[home.constructionStatus] && (
<Link href={drill({ status: home.constructionStatus })} onClick={(e) => e.stopPropagation()}
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[home.constructionStatus]!.cls}`}>
{STATUS_BADGE[home.constructionStatus]!.label}
</Link>
)}
</div>
<div className="mt-0.5 truncate text-sm text-neutral-700">
<Link href={`/homes/${home.id}`} className="hover:underline">{home.street ?? "Address on request"}</Link>{" · "}
<Link href={drill({ city: home.city, st: home.state })} className="hover:text-brand-700 hover:underline">{home.city}</Link>{", "}
<Link href={drill({ st: home.state })} className="hover:text-brand-700 hover:underline">{home.state}</Link>
</div>
<div className="mt-0.5 truncate text-sm text-neutral-500">
{home.beds != null ? (
<Link href={drill({ bedsMin: String(home.beds) })} className="hover:text-brand-700 hover:underline">{home.beds} bd</Link>
) : "— bd"}{" · "}
{home.bathsTotal != null ? (
<Link href={drill({ bathsMin: String(home.bathsTotal) })} className="hover:text-brand-700 hover:underline">{home.bathsTotal} ba</Link>
) : "— ba"}{" · "}
{home.sqft != null ? (
<Link href={sqftBandDrill(home.sqft, drill)} className="hover:text-brand-700 hover:underline">{home.sqft.toLocaleString()} sqft</Link>
) : "— sqft"}
{" · "}
<Link href={`/builders/${home.builder.slug}`} className="hover:text-brand-700 hover:underline">{home.builder.name}</Link>
{home.estCompletionDate ? ` · ready ${fmtCompletion(home.estCompletionDate)}` : ""}
</div>
</div>
<div className="relative z-10 flex shrink-0 items-center gap-3">
<VerificationBadge label={home.verificationLabel} />
<button type="button" aria-label="Save home"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(home.id); }}
className="text-lg leading-none transition hover:scale-110">
<span className={favs.has(home.id) ? "text-rose-500" : "text-neutral-300"}>{favs.has(home.id) ? "♥" : "♡"}</span>
</button>
</div>
</div>
))}
</div>
);
}
// TableView — dense spreadsheet, a column per meaningful field, click-to-sort headers.
// A header click drives the SAME server-side `sort` param (over the full result set),
// toggling asc/desc. Sortable cells stay drill links (href-drill rule).
function TableView({
homes, loading, sort, onColumnSort, drill, priceBandDrill, sqftBandDrill,
}: {
homes: Home[];
loading: boolean;
sort: string;
onColumnSort: (col: TableCol) => void;
drill: Drill;
priceBandDrill: BandDrill;
sqftBandDrill: (sqft: number, drill: Drill) => string;
}) {
// Direction arrow shown on the active column's header.
const arrow = (col: TableCol): string => {
if (sort === col.asc && sort === col.desc) return "↕";
if (sort === col.asc) return "↑";
if (sort === col.desc) return "↓";
return "";
};
const isActive = (col: TableCol) => sort === col.asc || sort === col.desc;
return (
<div className="mt-4 overflow-x-auto rounded-2xl border border-neutral-200 shadow-[var(--shadow-card)]" data-testid="results-table">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left">
{TABLE_COLS.map((col) => {
const sortable = !!(col.asc || col.desc);
return (
<th key={col.key} scope="col"
className={`whitespace-nowrap px-3 py-2 text-xs font-semibold uppercase tracking-wide ${col.align === "right" ? "text-right" : ""} ${isActive(col) ? "text-brand-800" : "text-neutral-500"}`}
data-testid={`th-${col.key}`}
aria-sort={isActive(col) ? (sort === col.desc ? "descending" : "ascending") : "none"}>
{sortable ? (
<button type="button" onClick={() => onColumnSort(col)}
className="inline-flex items-center gap-1 hover:text-brand-700"
data-testid={`sort-col-${col.key}`}>
{col.label}<span className="text-[10px]">{arrow(col)}</span>
</button>
) : col.label}
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{homes.map((home) => (
<tr key={home.id} className="transition hover:bg-brand-50/40" data-testid="table-row">
<td className="max-w-[220px] truncate px-3 py-2">
<Link href={`/homes/${home.id}`} className="text-brand-800 hover:underline">{home.street ?? "Address on request"}</Link>
</td>
<td className="whitespace-nowrap px-3 py-2">
<Link href={drill({ city: home.city, st: home.state })} className="hover:text-brand-700 hover:underline">{home.city}</Link>
</td>
<td className="whitespace-nowrap px-3 py-2">
<Link href={drill({ st: home.state })} className="hover:text-brand-700 hover:underline">{home.state}</Link>
</td>
<td className="whitespace-nowrap px-3 py-2 text-right font-medium">
<Link href={priceBandDrill(home.price, drill)} className="text-brand-900 hover:text-brand-700 hover:underline">{fmtPrice(home.price)}</Link>
</td>
<td className="px-3 py-2 text-right">
{home.beds != null ? (
<Link href={drill({ bedsMin: String(home.beds) })} className="hover:text-brand-700 hover:underline">{home.beds}</Link>
) : "—"}
</td>
<td className="px-3 py-2 text-right">
{home.bathsTotal != null ? (
<Link href={drill({ bathsMin: String(home.bathsTotal) })} className="hover:text-brand-700 hover:underline">{home.bathsTotal}</Link>
) : "—"}
</td>
<td className="whitespace-nowrap px-3 py-2 text-right">
{home.sqft != null ? (
<Link href={sqftBandDrill(home.sqft, drill)} className="hover:text-brand-700 hover:underline">{home.sqft.toLocaleString()}</Link>
) : "—"}
</td>
<td className="px-3 py-2 text-right">{home.stories ?? "—"}</td>
<td className="px-3 py-2 text-right">{home.garageSpaces ?? "—"}</td>
<td className="whitespace-nowrap px-3 py-2 text-neutral-600">{home.homeType ? (HOME_TYPE_LABELS[home.homeType] ?? home.homeType) : "—"}</td>
<td className="whitespace-nowrap px-3 py-2">
<Link href={drill({ status: home.constructionStatus })} className="hover:text-brand-700 hover:underline">
{STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus}
</Link>
</td>
<td className="whitespace-nowrap px-3 py-2 text-neutral-600">{fmtCompletion(home.estCompletionDate)}</td>
<td className="max-w-[160px] truncate px-3 py-2">
<Link href={`/builders/${home.builder.slug}`} className="hover:text-brand-700 hover:underline">{home.builder.name}</Link>
</td>
<td className="whitespace-nowrap px-3 py-2 text-neutral-500" title={home.lastVerifiedAt ?? undefined}>{fmtVerified(home.lastVerifiedAt)}</td>
</tr>
))}
</tbody>
</table>
{!loading && homes.length === 0 && (
<p className="py-10 text-center text-neutral-500">No homes match these filters.</p>
)}
</div>
);
}