[object Object]

← 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

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 →