← back to Homesonspec
feat(search): Amazon-style collapsed facet rail — 11 data points with live counts
fdacc4265127d594f1e29c6733b5b4a2ae87f8cc · 2026-07-30 08:34:52 -0700 · Steve
Backend (packages/search runFacets): expand faceting from 5 → 11 groups —
add baths, stories, garage, city (top 25), and price/sqft range bands
(one indexed count() per band, each computed with its own band excluded so
counts reflect the other active filters).
Frontend (SearchClient): every data-point section is now a collapsed
accordion panel (per-panel open state persisted to localStorage), with a
teal active-selection badge visible while collapsed, an option-count in the
header, and Expand all / Collapse all. New panels: Home type, Baths, Sq ft
bands, Stories, Garage, City. Price gains quick range-band quick-picks
above the custom min/max inputs.
Author: steve@designerwallcoverings.com
Files touched
M apps/web/src/app/search/SearchClient.tsxM packages/search/src/index.ts
Diff
commit fdacc4265127d594f1e29c6733b5b4a2ae87f8cc
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 30 08:34:52 2026 -0700
feat(search): Amazon-style collapsed facet rail — 11 data points with live counts
Backend (packages/search runFacets): expand faceting from 5 → 11 groups —
add baths, stories, garage, city (top 25), and price/sqft range bands
(one indexed count() per band, each computed with its own band excluded so
counts reflect the other active filters).
Frontend (SearchClient): every data-point section is now a collapsed
accordion panel (per-panel open state persisted to localStorage), with a
teal active-selection badge visible while collapsed, an option-count in the
header, and Expand all / Collapse all. New panels: Home type, Baths, Sq ft
bands, Stories, Garage, City. Price gains quick range-band quick-picks
above the custom min/max inputs.
Author: steve@designerwallcoverings.com
---
apps/web/src/app/search/SearchClient.tsx | 334 ++++++++++++++++++++++++-------
packages/search/src/index.ts | 112 +++++++----
2 files changed, 335 insertions(+), 111 deletions(-)
diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
index 46ba66dc..560c3427 100644
--- a/apps/web/src/app/search/SearchClient.tsx
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -40,12 +40,25 @@ interface SearchResponse {
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> = {
@@ -74,6 +87,14 @@ const STATUS_LABELS: Record<string, string> = {
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" },
@@ -89,6 +110,13 @@ function estMonthly(price: string | number | null): number | null {
return Math.round(pi + (p * 0.0125) / 12);
}
+// 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 }: { initialQuery: string }) {
const [q, setQ] = useState(initialQuery);
const [filters, setFilters] = useState<Record<string, string | string[]>>({});
@@ -100,6 +128,8 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
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 {} }, []);
@@ -113,6 +143,11 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
const savedCols = localStorage.getItem("homesonspec.cols");
if (savedSort) setSort(savedSort);
if (savedCols) setCols(Number(savedCols));
+ 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) => {
@@ -123,6 +158,12 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
setCols(value);
localStorage.setItem("homesonspec.cols", String(value));
};
+ 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();
@@ -167,6 +208,43 @@ export default function SearchClient({ initialQuery }: { initialQuery: 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)
@@ -213,104 +291,154 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
</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. */}
+ {/* 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-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 && (
+ <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({}); }}>
+ 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. */}
- <FacetSection title="State">
+ <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"
/>
- </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>
+ </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>
- </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"
+ <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 $" 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"
+ <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>
- </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>
+ </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>
- </FacetSection>
+ </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>
- <FacetSection title="Construction status">
- <div className="space-y-0.5">
- {facets?.constructionStatus.map((facet) => (
+ {/* 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>
- </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>
+ </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 */}
@@ -412,11 +540,67 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
}
// ── Faceted-rail primitives ────────────────────────────────────────────────
-function FacetSection({ title, children }: { title: string; children: React.ReactNode }) {
+
+// 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="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 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>
);
}
@@ -436,7 +620,7 @@ function FacetCheck({
}
// 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).
+// rail stays short with long lists (all 50 states, many builders, top cities).
function ChecklistOverflow({
items, selected, onToggle, cap = 12, testid,
}: {
diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts
index c14fea19..5d858ace 100644
--- a/packages/search/src/index.ts
+++ b/packages/search/src/index.ts
@@ -252,34 +252,59 @@ export async function runFacets(params: SearchParams) {
const without = (key: keyof SearchParams): Prisma.InventoryHomeWhereInput =>
buildWhere({ ...params, [key]: undefined }, bbox);
+ // Range facets drop BOTH ends of their own band so each band's count reflects the
+ // OTHER active filters (Amazon behavior — a facet never zeroes out its own options).
+ const whereNoPrice = buildWhere({ ...params, priceMin: undefined, priceMax: undefined }, bbox);
+ const whereNoSqft = buildWhere({ ...params, sqftMin: undefined, sqftMax: undefined }, bbox);
- const [byStatus, byBuilder, byBeds, byType, byState] = await Promise.all([
- prisma.inventoryHome.groupBy({
- by: ["constructionStatus"],
- where: without("statuses"),
- _count: { _all: true },
- }),
- prisma.inventoryHome.groupBy({
- by: ["builderId"],
- where: without("builderSlugs"),
- _count: { _all: true },
- }),
- prisma.inventoryHome.groupBy({
- by: ["beds"],
- where: without("bedsMin"),
- _count: { _all: true },
- }),
- prisma.inventoryHome.groupBy({
- by: ["homeType"],
- where: without("homeTypes"),
- _count: { _all: true },
- }),
- prisma.inventoryHome.groupBy({
- by: ["state"],
- where: without("states"),
- _count: { _all: true },
- }),
- ]);
+ // [label, min-inclusive, max-exclusive]; null = open-ended.
+ const PRICE_BANDS: [string, number | null, number | null][] = [
+ ["Under $300k", null, 300000],
+ ["$300k–$400k", 300000, 400000],
+ ["$400k–$500k", 400000, 500000],
+ ["$500k–$600k", 500000, 600000],
+ ["$600k–$750k", 600000, 750000],
+ ["$750k–$1M", 750000, 1000000],
+ ["$1M+", 1000000, null],
+ ];
+ const SQFT_BANDS: [string, number | null, number | null][] = [
+ ["Under 1,500", null, 1500],
+ ["1,500–2,000", 1500, 2000],
+ ["2,000–2,500", 2000, 2500],
+ ["2,500–3,000", 2500, 3000],
+ ["3,000–4,000", 3000, 4000],
+ ["4,000+", 4000, null],
+ ];
+ const bandCount = (
+ base: Prisma.InventoryHomeWhereInput,
+ field: "price" | "sqft",
+ min: number | null,
+ max: number | null,
+ ) =>
+ prisma.inventoryHome.count({
+ where: { ...base, [field]: { ...(min !== null ? { gte: min } : {}), ...(max !== null ? { lt: max } : {}) } },
+ });
+
+ const [byStatus, byBuilder, byBeds, byType, byState, byBaths, byStories, byGarage, byCity, priceBands, sqftBands] =
+ await Promise.all([
+ prisma.inventoryHome.groupBy({ by: ["constructionStatus"], where: without("statuses"), _count: { _all: true } }),
+ prisma.inventoryHome.groupBy({ by: ["builderId"], where: without("builderSlugs"), _count: { _all: true } }),
+ prisma.inventoryHome.groupBy({ by: ["beds"], where: without("bedsMin"), _count: { _all: true } }),
+ prisma.inventoryHome.groupBy({ by: ["homeType"], where: without("homeTypes"), _count: { _all: true } }),
+ prisma.inventoryHome.groupBy({ by: ["state"], where: without("states"), _count: { _all: true } }),
+ prisma.inventoryHome.groupBy({ by: ["bathsTotal"], where: without("bathsMin"), _count: { _all: true } }),
+ prisma.inventoryHome.groupBy({ by: ["stories"], where: without("storiesEq"), _count: { _all: true } }),
+ prisma.inventoryHome.groupBy({ by: ["garageSpaces"], where: without("garageMin"), _count: { _all: true } }),
+ prisma.inventoryHome.groupBy({
+ by: ["city"],
+ where: without("city"),
+ _count: { _all: true },
+ orderBy: { _count: { city: "desc" } },
+ take: 25,
+ }),
+ Promise.all(PRICE_BANDS.map(async ([label, min, max]) => ({ label, min, max, count: await bandCount(whereNoPrice, "price", min, max) }))),
+ Promise.all(SQFT_BANDS.map(async ([label, min, max]) => ({ label, min, max, count: await bandCount(whereNoSqft, "sqft", min, max) }))),
+ ]);
const builders = await prisma.builder.findMany({
where: { id: { in: byBuilder.map((b) => b.builderId) } },
@@ -289,18 +314,33 @@ export async function runFacets(params: SearchParams) {
return {
constructionStatus: byStatus.map((s) => ({ value: s.constructionStatus, count: s._count._all })),
- builder: byBuilder.map((b) => ({
- value: builderById.get(b.builderId)?.slug ?? b.builderId,
- label: builderById.get(b.builderId)?.name ?? b.builderId,
- count: b._count._all,
- })),
+ builder: byBuilder
+ .map((b) => ({
+ value: builderById.get(b.builderId)?.slug ?? b.builderId,
+ label: builderById.get(b.builderId)?.name ?? b.builderId,
+ count: b._count._all,
+ }))
+ .sort((a, b) => b.count - a.count),
beds: byBeds
.filter((b) => b.beds !== null)
.sort((a, b) => (a.beds ?? 0) - (b.beds ?? 0))
.map((b) => ({ value: b.beds, count: b._count._all })),
- homeType: byType.map((t) => ({ value: t.homeType, count: t._count._all })),
- state: byState
- .map((s) => ({ value: s.state, count: s._count._all }))
- .sort((a, b) => b.count - a.count),
+ baths: byBaths
+ .filter((b) => b.bathsTotal !== null)
+ .map((b) => ({ value: Number(b.bathsTotal), count: b._count._all }))
+ .sort((a, b) => a.value - b.value),
+ homeType: byType.map((t) => ({ value: t.homeType, count: t._count._all })).sort((a, b) => b.count - a.count),
+ state: byState.map((s) => ({ value: s.state, count: s._count._all })).sort((a, b) => b.count - a.count),
+ stories: byStories
+ .filter((s) => s.stories !== null)
+ .map((s) => ({ value: s.stories as number, count: s._count._all }))
+ .sort((a, b) => a.value - b.value),
+ garage: byGarage
+ .filter((g) => g.garageSpaces !== null)
+ .map((g) => ({ value: g.garageSpaces as number, count: g._count._all }))
+ .sort((a, b) => a.value - b.value),
+ city: byCity.map((c) => ({ value: c.city, count: c._count._all })),
+ priceBands: priceBands.filter((b) => b.count > 0),
+ sqftBands: sqftBands.filter((b) => b.count > 0),
};
}
← 41e2509b workers: register K. Hovnanian adapter (khovnanian-site)
·
back to Homesonspec
·
auto-save: 2026-07-30T08:46:20 (1 files) — pnpm-lock.yaml 891f7980 →