← back to Homesonspec
apps/web/src/app/search/SearchClient.tsx
473 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";
// 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;
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 Facets {
constructionStatus: { value: string; count: number }[];
builder: { value: string; label: string; count: number }[];
beds: { value: number; count: number }[];
homeType: { value: string; count: number }[];
state: { value: string; count: number }[];
}
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",
};
const SORTS = [
{ value: "newest", label: "Newest" },
{ value: "price_asc", label: "Price ↑" },
{ value: "price_desc", label: "Price ↓" },
{ value: "sqft_desc", label: "Sq Ft ↓" },
{ value: "closest", label: "Closest" },
] as const;
const STATUS_LABELS: Record<string, string> = {
MOVE_IN_READY: "Move-in ready",
UNDER_CONSTRUCTION: "Under construction",
PLANNED: "Planned",
};
// 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);
}
export default function SearchClient({ initialQuery }: { initialQuery: string }) {
const [q, setQ] = useState(initialQuery);
const [filters, setFilters] = useState<Record<string, string | string[]>>({});
// Sort + density persist across reloads (standing grid rule).
const [sort, setSort] = useState<string>("newest");
const [cols, setCols] = useState<number>(3);
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);
// 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");
if (savedSort) setSort(savedSort);
if (savedCols) setCols(Number(savedCols));
}, []);
const setSortPersist = (value: string) => {
setSort(value);
localStorage.setItem("homesonspec.sort", value);
};
const setColsPersist = (value: number) => {
setCols(value);
localStorage.setItem("homesonspec.cols", String(value));
};
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]);
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 }));
};
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;
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 dimension a checkable filter 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-3 flex items-center justify-between">
<h2 className="font-display text-base font-semibold text-brand-900">Filter homes</h2>
{Object.keys(filters).length > 0 && (
<button type="button" className="text-xs font-semibold text-brand-700 hover:text-brand-800"
onClick={() => { setPage(1); setFilters({}); }}>
Clear all
</button>
)}
</div>
{/* State — multi-select, top 12 + overflow. */}
<FacetSection title="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"
/>
</FacetSection>
{/* Builder / firm — multi-select. */}
<FacetSection title="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"
/>
</FacetSection>
{/* Min Sq Ft — single-select chips. */}
<FacetSection title="Min sq ft">
<div className="flex flex-wrap gap-1.5" data-testid="facet-sqft">
{[1000, 1500, 2000, 2500, 3000].map((n) => (
<button key={n} type="button"
onClick={() => setSingle("sqftMin", filters.sqftMin === String(n) ? "" : String(n))}
className={`rounded-full border px-3 py-1 text-xs transition ${filters.sqftMin === String(n) ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
{n.toLocaleString()}+
</button>
))}
</div>
</FacetSection>
<FacetSection title="Price">
<div className="flex gap-2">
<input type="number" placeholder="Min $" 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 $" 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>
</FacetSection>
<FacetSection title="Beds (min)">
<div className="flex gap-1.5">
{[1, 2, 3, 4, 5].map((n) => (
<button key={n} type="button" data-testid={`beds-${n}`}
onClick={() => setSingle("bedsMin", filters.bedsMin === String(n) ? "" : String(n))}
className={`rounded-full border px-3 py-1 text-xs transition ${filters.bedsMin === String(n) ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
{n}+
</button>
))}
</div>
</FacetSection>
<FacetSection title="Construction status">
<div className="space-y-0.5">
{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>
</FacetSection>
<FacetSection title="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>
</FacetSection>
</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: sort select + density slider, localStorage-persisted */}
<div className="flex items-center gap-4">
<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>
<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>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_380px]">
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} data-testid="results-grid">
{(data?.homes ?? []).map((home) => (
<Link key={home.id} href={`/homes/${home.id}`}
className="card-interactive relative overflow-hidden"
data-testid="home-card">
{/* 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-10 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[home.constructionStatus] && (
<span className={`absolute left-3 top-3 inline-block rounded-full px-2 py-0.5 text-[11px] font-medium shadow-sm ${STATUS_BADGE[home.constructionStatus]!.cls}`}>
{STATUS_BADGE[home.constructionStatus]!.label}
</span>
)}
</div>
<div className="p-4">
<div className="flex items-baseline gap-2">
<span className="font-display text-xl font-semibold text-brand-900">{fmtPrice(home.price)}</span>
{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">
{home.street ?? "Address on request"} · {home.city}, {home.state}
</div>
<div className="mt-1 text-sm text-neutral-500">
{home.beds ?? "—"} bd · {home.bathsTotal ?? "—"} ba · {home.sqft?.toLocaleString() ?? "—"} sqft
{home.estCompletionDate ? ` · ready ${new Date(home.estCompletionDate).toLocaleDateString(undefined, { month: "short", year: "numeric" })}` : ""}
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-neutral-500">{home.builder.name}</span>
<VerificationBadge label={home.verificationLabel} />
</div>
</div>
</Link>
))}
{!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 ────────────────────────────────────────────────
function FacetSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border-t border-neutral-100 py-3 first:border-t-0">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-brand-600">{title}</h3>
{children}
</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).
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>
);
}