← back to Homesonspec
HomesOnSpec search: add Grid/List/Table view modes + broadened server-side sort
1ee4dcd7e1cb5afe83bd6db046b268370a65dbe8 · 2026-08-13 08:29:15 -0700 · steve
- View-mode toggle (Grid | List | Table), persisted to localStorage (homesonspec.viewMode)
- ListView: compact one-row-per-home; TableView: dense all-columns spreadsheet with click-to-sort headers driving the same server-side sort param
- Broaden sort set (sqft asc, beds/baths desc, completion asc/desc, city/state A-Z, recently verified) wired through a validated whitelist (SORT_KEYS) + Prisma orderBy with nulls-last
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M apps/web/src/app/search/SearchClient.tsxM apps/web/src/lib/parse-search.tsM packages/search/src/index.ts
Diff
commit 1ee4dcd7e1cb5afe83bd6db046b268370a65dbe8
Author: steve <steve@designerwallcoverings.com>
Date: Thu Aug 13 08:29:15 2026 -0700
HomesOnSpec search: add Grid/List/Table view modes + broadened server-side sort
- View-mode toggle (Grid | List | Table), persisted to localStorage (homesonspec.viewMode)
- ListView: compact one-row-per-home; TableView: dense all-columns spreadsheet with click-to-sort headers driving the same server-side sort param
- Broaden sort set (sqft asc, beds/baths desc, completion asc/desc, city/state A-Z, recently verified) wired through a validated whitelist (SORT_KEYS) + Prisma orderBy with nulls-last
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
apps/web/src/app/search/SearchClient.tsx | 301 ++++++++++++++++++++++++++++++-
apps/web/src/lib/parse-search.ts | 4 +-
packages/search/src/index.ts | 71 +++++++-
3 files changed, 360 insertions(+), 16 deletions(-)
diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
index 56c477f3..1b7686be 100644
--- a/apps/web/src/app/search/SearchClient.tsx
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -45,6 +45,7 @@ interface Home {
sqft: number | null;
stories: number | null;
garageSpaces: number | null;
+ homeType: string | null;
lat: string | number | null;
lon: string | number | null;
constructionStatus: string;
@@ -97,14 +98,60 @@ const STATE_NAMES: Record<string, string> = {
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_asc" },
+ { key: "state", label: "State", asc: "state_asc", desc: "state_asc" },
+ { 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_desc", desc: "stories_desc", align: "right" },
+ { key: "garage", label: "Garage", asc: "garage_desc", 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", desc: "last_verified" },
+];
+
const STATUS_LABELS: Record<string, string> = {
MOVE_IN_READY: "Move-in ready",
UNDER_CONSTRUCTION: "Under construction",
@@ -177,6 +224,8 @@ export default function SearchClient({
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);
@@ -195,8 +244,10 @@ export default function SearchClient({
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).
@@ -212,6 +263,18 @@ export default function SearchClient({
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));
@@ -539,8 +602,20 @@ export default function SearchClient({
<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 */}
+ {/* 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)}
@@ -550,15 +625,40 @@ export default function SearchClient({
))}
</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>
+ {/* 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
@@ -652,10 +752,12 @@ export default function SearchClient({
</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">
@@ -787,3 +889,190 @@ function ChecklistOverflow({
</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>
+ );
+}
diff --git a/apps/web/src/lib/parse-search.ts b/apps/web/src/lib/parse-search.ts
index c13c2c99..6c3cd615 100644
--- a/apps/web/src/lib/parse-search.ts
+++ b/apps/web/src/lib/parse-search.ts
@@ -1,4 +1,5 @@
import type { SearchParams } from "@homesonspec/search";
+import { isSortKey } from "@homesonspec/search";
function num(v: string | null): number | undefined {
if (v === null || v === "") return undefined;
@@ -39,7 +40,8 @@ export function parseSearchParams(searchParams: URLSearchParams): SearchParams {
states: searchParams.getAll("st"),
city: searchParams.get("city") ?? undefined,
moveInByMonths: num(searchParams.get("moveInByMonths")),
- sort: (searchParams.get("sort") as SearchParams["sort"]) ?? "newest",
+ // Validate against the whitelist so an arbitrary ?sort= can't reach the orderBy.
+ sort: (() => { const s = searchParams.get("sort"); return isSortKey(s) ? s : "newest"; })(),
page: num(searchParams.get("page")),
pageSize: num(searchParams.get("pageSize")),
};
diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts
index 5d858ace..1345ad60 100644
--- a/packages/search/src/index.ts
+++ b/packages/search/src/index.ts
@@ -35,11 +35,39 @@ export interface SearchParams {
states?: string[]; // multi-select state facet (Amazon-style rail)
city?: string;
moveInByMonths?: number; // move-in timeframe: ready or completing within N months
- sort?: "newest" | "price_asc" | "price_desc" | "sqft_desc" | "closest";
+ sort?: SortKey;
page?: number;
pageSize?: number;
}
+// The whitelist of server-side sort keys. Table column-header clicks and the Sort
+// <select> both drive this single param, so sorting is over the FULL result set
+// (server-side), not just the current page. parse-search validates against this.
+export const SORT_KEYS = [
+ "newest",
+ "price_asc",
+ "price_desc",
+ "sqft_asc",
+ "sqft_desc",
+ "beds_desc",
+ "beds_asc",
+ "baths_desc",
+ "baths_asc",
+ "stories_desc",
+ "garage_desc",
+ "completion_asc",
+ "completion_desc",
+ "city_asc",
+ "state_asc",
+ "last_verified",
+ "closest",
+] as const;
+export type SortKey = (typeof SORT_KEYS)[number];
+
+export function isSortKey(v: string | null | undefined): v is SortKey {
+ return v != null && (SORT_KEYS as readonly string[]).includes(v);
+}
+
export interface ResolvedLocation {
label: string;
lat: number;
@@ -173,6 +201,38 @@ export interface SearchResult {
location: ResolvedLocation | null;
}
+/**
+ * Map a whitelisted sort key → a Prisma orderBy. Every column-sort from the Table
+ * view and every Sort <select> option resolves here, so sorting is server-side over
+ * the full filtered result set. All nullable columns sort nulls-last both directions
+ * so empty values never lead. A secondary publishedAt key gives a stable tiebreak.
+ * `closest` is handled separately (raw-SQL haversine) — it falls through to newest here.
+ */
+function orderByFor(sort?: SortKey): Prisma.InventoryHomeOrderByWithRelationInput[] {
+ const NEWEST: Prisma.InventoryHomeOrderByWithRelationInput[] = [{ publishedAt: "desc" }];
+ const tie: Prisma.InventoryHomeOrderByWithRelationInput = { publishedAt: "desc" };
+ switch (sort) {
+ case "price_asc": return [{ price: { sort: "asc", nulls: "last" } }, tie];
+ case "price_desc": return [{ price: { sort: "desc", nulls: "last" } }, tie];
+ case "sqft_asc": return [{ sqft: { sort: "asc", nulls: "last" } }, tie];
+ case "sqft_desc": return [{ sqft: { sort: "desc", nulls: "last" } }, tie];
+ case "beds_desc": return [{ beds: { sort: "desc", nulls: "last" } }, tie];
+ case "beds_asc": return [{ beds: { sort: "asc", nulls: "last" } }, tie];
+ case "baths_desc": return [{ bathsTotal: { sort: "desc", nulls: "last" } }, tie];
+ case "baths_asc": return [{ bathsTotal: { sort: "asc", nulls: "last" } }, tie];
+ case "stories_desc": return [{ stories: { sort: "desc", nulls: "last" } }, tie];
+ case "garage_desc": return [{ garageSpaces: { sort: "desc", nulls: "last" } }, tie];
+ case "completion_asc": return [{ estCompletionDate: { sort: "asc", nulls: "last" } }, tie];
+ case "completion_desc": return [{ estCompletionDate: { sort: "desc", nulls: "last" } }, tie];
+ case "city_asc": return [{ city: "asc" }, { state: "asc" }, tie];
+ case "state_asc": return [{ state: "asc" }, { city: "asc" }, tie];
+ case "last_verified": return [{ lastVerifiedAt: { sort: "desc", nulls: "last" } }, tie];
+ case "newest":
+ default:
+ return NEWEST; // organic default
+ }
+}
+
export async function runSearch(params: SearchParams): Promise<SearchResult> {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(60, Math.max(1, params.pageSize ?? 24));
@@ -189,14 +249,7 @@ export async function runSearch(params: SearchParams): Promise<SearchResult> {
const where = buildWhere(params, bbox);
- const orderBy: Prisma.InventoryHomeOrderByWithRelationInput[] =
- params.sort === "price_asc"
- ? [{ price: { sort: "asc", nulls: "last" } }]
- : params.sort === "price_desc"
- ? [{ price: { sort: "desc", nulls: "last" } }]
- : params.sort === "sqft_desc"
- ? [{ sqft: { sort: "desc", nulls: "last" } }]
- : [{ publishedAt: "desc" }]; // newest — the organic default
+ const orderBy = orderByFor(params.sort);
// sort=closest is the one raw-SQL path (haversine); fall back to newest
// when there is no center point to measure from.
← db6c10ce chore: HomesOnSpec safe full-web deploy runbook (build-gated
·
back to Homesonspec
·
HomesOnSpec search: make Table columns bidirectionally sorta d5513153 →