← back to Homesonspec
Amazon-style faceted search rail: State (multi-select, 50-state overflow) + Builder/firm + Min Sq Ft facets, live counts, clear-all, sticky scroll; add state facet to API + multi-state param
da530f2a3b3df455bdc13979691254247e95abc6 · 2026-07-22 18:17:00 -0700 · Steve
Files touched
M apps/web/src/app/search/SearchClient.tsx
Diff
commit da530f2a3b3df455bdc13979691254247e95abc6
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 22 18:17:00 2026 -0700
Amazon-style faceted search rail: State (multi-select, 50-state overflow) + Builder/firm + Min Sq Ft facets, live counts, clear-all, sticky scroll; add state facet to API + multi-state param
---
apps/web/src/app/search/SearchClient.tsx | 179 +++++++++++++++++++++++--------
1 file changed, 134 insertions(+), 45 deletions(-)
diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
index 6d03c9e..85ea5e4 100644
--- a/apps/web/src/app/search/SearchClient.tsx
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -189,67 +189,96 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
</button>
</form>
- <div className="mt-6 grid gap-6 lg:grid-cols-[260px_1fr]">
- {/* Filters */}
- <aside className="space-y-5 text-sm" data-testid="filters">
- <div>
- <h3 className="font-semibold text-brand-900">Price</h3>
- <div className="mt-2 flex gap-2">
- <input type="number" placeholder="Min" className="w-full rounded border border-neutral-300 px-2 py-1"
+ <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 border border-neutral-300 px-2 py-1"
+ <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>
- </div>
- <div>
- <h3 className="font-semibold text-brand-900">Beds (min)</h3>
- <div className="mt-2 flex gap-1">
+ </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-lg border px-2.5 py-1 transition ${filters.bedsMin === String(n) ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
+ 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>
- </div>
- <div>
- <h3 className="font-semibold text-brand-900">Construction status</h3>
- {facets?.constructionStatus.map((facet) => (
- <label key={facet.value} className="mt-1 flex items-center gap-2">
- <input type="checkbox"
+ </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)}
- onChange={() => toggleMulti("status", facet.value)} />
- {STATUS_LABELS[facet.value] ?? facet.value}
- <span className="text-neutral-400">({facet.count})</span>
- </label>
- ))}
- </div>
- <div>
- <h3 className="font-semibold text-brand-900">Builder</h3>
- {facets?.builder.map((facet) => (
- <label key={facet.value} className="mt-1 flex items-center gap-2">
- <input type="checkbox"
- checked={Array.isArray(filters.builder) && (filters.builder as string[]).includes(facet.value)}
- onChange={() => toggleMulti("builder", facet.value)} />
- {facet.label}
- <span className="text-neutral-400">({facet.count})</span>
- </label>
- ))}
- </div>
- <div>
- <h3 className="font-semibold text-brand-900">More</h3>
- <label className="mt-1 flex items-center gap-2">
- <input type="checkbox" checked={filters.incentives === "1"}
+ 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="mt-1 flex items-center gap-2">
- <input type="checkbox" checked={filters.ageRestricted === "1"}
+ <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 border border-neutral-300 px-2 py-1"
+ <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>
@@ -258,7 +287,7 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
<option value="6">Within 6 months</option>
<option value="12">Within 12 months</option>
</select>
- </div>
+ </FacetSection>
</aside>
{/* Results */}
@@ -334,3 +363,63 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
</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>
+ );
+}
← 008a9de auto-save: 2026-07-22T18:15:55 (4 files) — apps/web/src/app/
·
back to Homesonspec
·
Home detail: branded hero header + status badge, 'more homes 77b81eb →