← back to Homesonspec
HomesOnSpec: property detail parcel/public-records block + href-drill atoms on listings grid (TK-10482)
4d9cd041570f9db3d62cb05989173296ebae97d1 · 2026-08-12 08:28:56 -0700 · Steve
- Detail page (/homes/[id]): new Parcel & public records section — lot number,
county, and a public-records/assessor parcel-search link (curated county
assessor map + generic county-scoped fallback). No APN fabricated. Core facts
(status/beds/baths/sqft/stories/garage/city/state/community) now drill to their
URL-addressable filtered views.
- Listings grid (SearchClient): every card data point is now a drill href — price
(band), city, state, beds, baths, sqft (band), status, builder→/builders,
community→/communities. Card uses a stretched overlay link (no nested anchors).
- Filters are URL-addressable: SearchClient hydrates from the URL query and syncs
filter/sort changes back via replaceState, so drill links deep-link into a
pre-filtered grid.
- lib/parcel.ts: county→assessor public-records resolver (compliant: link OUT to
the county's own public search; source facts from public records).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M apps/web/src/app/homes/[id]/page.tsxM apps/web/src/app/search/SearchClient.tsxM apps/web/src/app/search/page.tsxA apps/web/src/lib/parcel.ts
Diff
commit 4d9cd041570f9db3d62cb05989173296ebae97d1
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Aug 12 08:28:56 2026 -0700
HomesOnSpec: property detail parcel/public-records block + href-drill atoms on listings grid (TK-10482)
- Detail page (/homes/[id]): new Parcel & public records section — lot number,
county, and a public-records/assessor parcel-search link (curated county
assessor map + generic county-scoped fallback). No APN fabricated. Core facts
(status/beds/baths/sqft/stories/garage/city/state/community) now drill to their
URL-addressable filtered views.
- Listings grid (SearchClient): every card data point is now a drill href — price
(band), city, state, beds, baths, sqft (band), status, builder→/builders,
community→/communities. Card uses a stretched overlay link (no nested anchors).
- Filters are URL-addressable: SearchClient hydrates from the URL query and syncs
filter/sort changes back via replaceState, so drill links deep-link into a
pre-filtered grid.
- lib/parcel.ts: county→assessor public-records resolver (compliant: link OUT to
the county's own public search; source facts from public records).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
apps/web/src/app/homes/[id]/page.tsx | 84 ++++++++++++++---
apps/web/src/app/search/SearchClient.tsx | 149 +++++++++++++++++++++++++++----
apps/web/src/app/search/page.tsx | 15 +++-
apps/web/src/lib/parcel.ts | 95 ++++++++++++++++++++
4 files changed, 315 insertions(+), 28 deletions(-)
diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
index d26b1950..c171083b 100644
--- a/apps/web/src/app/homes/[id]/page.tsx
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -4,6 +4,7 @@ import { prisma } from "@homesonspec/database";
import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";
import CorrectionForm from "./CorrectionForm";
import LeadForm from "./LeadForm";
+import { parcelLink } from "../../../lib/parcel";
export const dynamic = "force-dynamic";
@@ -71,14 +72,24 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
}, {}),
).sort((a, b) => a.field.localeCompare(b.field));
- const facts: [string, string][] = [
+ // href-drill: builds drill URLs into the URL-addressable search grid, so every
+ // fact on this detail page routes to its deeper, filtered view.
+ const drill = (kv: Record<string, string>): string => {
+ const params = new URLSearchParams();
+ for (const [k, v] of Object.entries(kv)) if (v) params.set(k, v);
+ return `/search?${params.toString()}`;
+ };
+
+ // Each fact is [label, value, href?] — href makes the fact a drill link into
+ // the URL-addressable grid (href-drill rule: no dead-end data points).
+ const facts: [string, string, string?][] = [
["Price", fmtPrice(home.price === null ? null : Number(home.price))],
- ["Status", STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus],
- ["Beds", home.beds?.toString() ?? "Not published"],
- ["Baths", home.bathsTotal?.toString() ?? "Not published"],
- ["Square feet", home.sqft?.toLocaleString() ?? "Not published"],
- ["Stories", home.stories?.toString() ?? "Not published"],
- ["Garage", home.garageSpaces ? `${home.garageSpaces}-car` : "Not published"],
+ ["Status", STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus, drill({ status: home.constructionStatus })],
+ ["Beds", home.beds?.toString() ?? "Not published", home.beds != null ? drill({ bedsMin: String(home.beds) }) : undefined],
+ ["Baths", home.bathsTotal?.toString() ?? "Not published", home.bathsTotal != null ? drill({ bathsMin: String(home.bathsTotal) }) : undefined],
+ ["Square feet", home.sqft?.toLocaleString() ?? "Not published", home.sqft != null ? drill({ sqftMin: String(Math.max(0, Math.floor((home.sqft * 0.8) / 100) * 100)), sqftMax: String(Math.ceil((home.sqft * 1.2) / 100) * 100) }) : undefined],
+ ["Stories", home.stories?.toString() ?? "Not published", home.stories != null ? drill({ stories: String(home.stories) }) : undefined],
+ ["Garage", home.garageSpaces ? `${home.garageSpaces}-car` : "Not published", home.garageSpaces ? drill({ garageMin: String(home.garageSpaces) }) : undefined],
["Lot", home.lotNumber ? `Lot ${home.lotNumber}` : "Not published"],
[
"Est. completion",
@@ -117,6 +128,11 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
});
const purchaseUrl = typeof rawSpecs.purchaseUrl === "string" ? rawSpecs.purchaseUrl : null;
+ // Parcel / public-records drill. We have no APN column (never fabricated) — we
+ // surface the honest identifiers we DO have (lot number + county) and link OUT
+ // to the county's public parcel-search surface (public-records backbone).
+ const parcel = parcelLink(home.community.county, home.state, home.city);
+
return (
<div className="mx-auto max-w-5xl px-4 py-8">
<nav className="text-sm text-neutral-500">
@@ -147,7 +163,9 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
{home.street ?? "Address available from builder"}
</h1>
<p className="mt-1 text-sm text-brand-100">
- {home.city}, {home.state} {home.zip} · {home.community.name}
+ <Link href={drill({ city: home.city, st: home.state })} className="hover:text-white hover:underline">{home.city}</Link>,{" "}
+ <Link href={drill({ st: home.state })} className="hover:text-white hover:underline">{home.state}</Link> {home.zip} ·{" "}
+ <Link href={`/communities/${home.community.slug}`} className="hover:text-white hover:underline">{home.community.name}</Link>
</p>
</div>
<div className="text-right">
@@ -199,10 +217,19 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
<div>
<h2 className="font-display text-xl font-semibold text-brand-900">Home facts</h2>
<dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
- {facts.map(([label, value]) => (
+ {facts.map(([label, value, href]) => (
<div key={label} className="border-b border-neutral-100 py-1.5">
<dt className="text-neutral-500">{label}</dt>
- <dd className="font-medium">{value}</dd>
+ <dd className="font-medium">
+ {href ? (
+ <Link href={href} className="text-brand-800 hover:text-brand-600 hover:underline decoration-brand-300 underline-offset-2"
+ data-testid={`fact-drill-${label.toLowerCase().replace(/\s+/g, "-")}`}>
+ {value}
+ </Link>
+ ) : (
+ value
+ )}
+ </dd>
</div>
))}
</dl>
@@ -210,6 +237,43 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
“Not published” means the source did not state this fact. HomesOnSpec never fills in missing values.
</p>
+ {/* Parcel & public records — the lot/parcel identifier plus a link OUT to the
+ county's public assessor / property-records search. We surface only the
+ identifiers we honestly hold (lot number + county); we never fabricate an
+ APN. The assessor link is public-records backbone (compliant). */}
+ <section className="mt-8" data-testid="parcel-section">
+ <h2 className="font-display text-xl font-semibold text-brand-900">Parcel & public records</h2>
+ <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
+ <div className="border-b border-neutral-100 py-1.5">
+ <dt className="text-neutral-500">Lot number</dt>
+ <dd className="font-medium">{home.lotNumber ? `Lot ${home.lotNumber}` : "Not published"}</dd>
+ </div>
+ <div className="border-b border-neutral-100 py-1.5">
+ <dt className="text-neutral-500">County</dt>
+ <dd className="font-medium">{home.community.county ?? "Not published"}</dd>
+ </div>
+ <div className="border-b border-neutral-100 py-1.5">
+ <dt className="text-neutral-500">City / State</dt>
+ <dd className="font-medium">
+ <Link href={drill({ city: home.city, st: home.state })} className="text-brand-800 hover:text-brand-600 hover:underline">
+ {home.city}, {home.state}
+ </Link>
+ </dd>
+ </div>
+ </dl>
+ {parcel ? (
+ <a href={parcel.url} target="_blank" rel="nofollow noopener noreferrer"
+ className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-accent-600 hover:text-accent-700"
+ data-testid="assessor-link">
+ 🔎 Look up this parcel on {parcel.authority} ↗
+ </a>
+ ) : null}
+ <p className="mt-2 text-xs text-neutral-400">
+ HomesOnSpec does not publish an APN. Use the county’s public property-records
+ search above to look up the official parcel record by address or lot.
+ </p>
+ </section>
+
{(specFacts.length > 0 || purchaseUrl) && (
<section className="mt-8">
<h2 className="font-display text-xl font-semibold text-brand-900">Additional details</h2>
diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
index 560c3427..528918ce 100644
--- a/apps/web/src/app/search/SearchClient.tsx
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -5,6 +5,30 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";
+// Which URL params seed the filter state on load (href-drill: a link like
+// /search?city=Austin&status=MOVE_IN_READY deep-links into a pre-filtered grid).
+// Multi-value params (repeatable) vs single-value params are split so hydration
+// rebuilds the exact filter shape the rail expects.
+const MULTI_FILTER_KEYS = ["st", "builder", "status", "homeType"] as const;
+const SINGLE_FILTER_KEYS = [
+ "priceMin", "priceMax", "bedsMin", "bathsMin", "sqftMin", "sqftMax",
+ "stories", "garageMin", "city", "incentives", "ageRestricted", "moveInByMonths",
+] as const;
+
+function hydrateFilters(qs: string): Record<string, string | string[]> {
+ const sp = new URLSearchParams(qs);
+ const out: Record<string, string | string[]> = {};
+ for (const k of MULTI_FILTER_KEYS) {
+ const vals = sp.getAll(k);
+ if (vals.length) out[k] = vals;
+ }
+ for (const k of SINGLE_FILTER_KEYS) {
+ const v = sp.get(k);
+ if (v) out[k] = v;
+ }
+ return out;
+}
+
// 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 });
@@ -110,6 +134,23 @@ function estMonthly(price: string | number | null): number | null {
return Math.round(pi + (p * 0.0125) / 12);
}
+// Price/sqft are continuous, so their card atoms drill to a sensible BAND around
+// the home's value (±15% price, ±20% sqft) rather than an exact match that would
+// usually return just the one home. Rounds to clean numbers for a shareable URL.
+function priceBandDrill(price: string | number | null, drill: (kv: Record<string, string>) => string): string {
+ const p = Number(price);
+ if (!Number.isFinite(p) || p <= 0) return drill({});
+ const lo = Math.max(0, Math.floor((p * 0.85) / 10000) * 10000);
+ const hi = Math.ceil((p * 1.15) / 10000) * 10000;
+ return drill({ priceMin: String(lo), priceMax: String(hi) });
+}
+function sqftBandDrill(sqft: number, drill: (kv: Record<string, string>) => string): string {
+ if (!Number.isFinite(sqft) || sqft <= 0) return drill({});
+ const lo = Math.max(0, Math.floor((sqft * 0.8) / 100) * 100);
+ const hi = Math.ceil((sqft * 1.2) / 100) * 100;
+ return drill({ sqftMin: String(lo), sqftMax: String(hi) });
+}
+
// 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 = [
@@ -117,11 +158,24 @@ const FACET_KEYS = [
"status", "stories", "garage", "city", "builder", "more",
] as const;
-export default function SearchClient({ initialQuery }: { initialQuery: string }) {
+export default function SearchClient({
+ initialQuery,
+ initialParams = "",
+}: {
+ initialQuery: string;
+ initialParams?: 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");
+ // Seed filters from the URL so drill links (?city=…&status=…) land pre-filtered.
+ const [filters, setFilters] = useState<Record<string, string | string[]>>(() =>
+ hydrateFilters(initialParams),
+ );
+ // Sort + density persist across reloads (standing grid rule). Sort can also be
+ // seeded from the URL (a drill link may pin a sort).
+ const [sort, setSort] = useState<string>(() => {
+ const s = new URLSearchParams(initialParams).get("sort");
+ return s ?? "newest";
+ });
const [cols, setCols] = useState<number>(3);
const [data, setData] = useState<SearchResponse | null>(null);
const [facets, setFacets] = useState<Facets | null>(null);
@@ -178,6 +232,22 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
return params.toString();
}, [q, filters, sort, page]);
+ // Keep the browser URL in sync with the active filters + sort + query so the
+ // filtered grid is URL-addressable and shareable (href-drill rule). bbox/page
+ // are intentionally excluded to keep the shareable URL stable and clean.
+ useEffect(() => {
+ const params = new URLSearchParams();
+ if (q) params.set("q", q);
+ 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);
+ }
+ if (sort !== "newest") params.set("sort", sort);
+ const qs = params.toString();
+ const url = qs ? `/search?${qs}` : "/search";
+ window.history.replaceState(null, "", url);
+ }, [q, filters, sort]);
+
const fetchResults = useCallback(async () => {
setLoading(true);
try {
@@ -266,6 +336,14 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
+ // Drill-href builder: every displayed data point on a card links to its deeper,
+ // URL-addressable filtered view (href-drill rule — no dead-end data points).
+ const drill = (kv: Record<string, string>): string => {
+ const params = new URLSearchParams();
+ for (const [k, v] of Object.entries(kv)) if (v) params.set(k, v);
+ return `/search?${params.toString()}`;
+ };
+
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Search bar */}
@@ -469,9 +547,15 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
<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"
+ // href-drill: the card is a container with a STRETCHED overlay link to the
+ // detail page, and every data point below is its own real drill link sitting
+ // ABOVE the overlay (z-10). No nested anchors — clicking blank card area opens
+ // the detail; clicking a data atom drills to that atom's deeper view.
+ <div key={home.id}
+ className="card-interactive group relative overflow-hidden"
data-testid="home-card">
+ <Link href={`/homes/${home.id}`} aria-label={`View ${home.street ?? "home"} in ${home.city}, ${home.state}`}
+ className="absolute inset-0 z-0" data-testid="home-card-overlay" tabIndex={-1} />
{/* 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. */}
@@ -483,35 +567,70 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
) : 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">
+ className="absolute right-3 top-3 z-20 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 drills to the same construction status. */}
{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}`}>
+ <Link href={drill({ status: home.constructionStatus })} data-testid="drill-status"
+ onClick={(e) => e.stopPropagation()}
+ className={`absolute left-3 top-3 z-20 inline-block rounded-full px-2 py-0.5 text-[11px] font-medium shadow-sm transition hover:brightness-95 ${STATUS_BADGE[home.constructionStatus]!.cls}`}>
{STATUS_BADGE[home.constructionStatus]!.label}
- </span>
+ </Link>
)}
</div>
- <div className="p-4">
+ <div className="relative z-10 p-4">
<div className="flex items-baseline gap-2">
- <span className="font-display text-xl font-semibold text-brand-900">{fmtPrice(home.price)}</span>
+ {/* Price drills to a price band around this home's price (±15%). */}
+ <Link href={priceBandDrill(home.price, drill)} data-testid="drill-price"
+ className="font-display text-xl font-semibold text-brand-900 hover:text-brand-700 hover:underline decoration-brand-300 underline-offset-2">
+ {fmtPrice(home.price)}
+ </Link>
{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}
+ {/* Detail link for the street, then city + state each drill to their scope. */}
+ <Link href={`/homes/${home.id}`} className="hover:underline">
+ {home.street ?? "Address on request"}
+ </Link>{" · "}
+ <Link href={drill({ city: home.city, st: home.state })} data-testid="drill-city"
+ className="hover:text-brand-700 hover:underline">{home.city}</Link>{", "}
+ <Link href={drill({ st: home.state })} data-testid="drill-state"
+ className="hover:text-brand-700 hover:underline">{home.state}</Link>
</div>
<div className="mt-1 text-sm text-neutral-500">
- {home.beds ?? "—"} bd · {home.bathsTotal ?? "—"} ba · {home.sqft?.toLocaleString() ?? "—"} sqft
+ {/* Beds / baths / sqft each drill to the matching min filter. */}
+ {home.beds != null ? (
+ <Link href={drill({ bedsMin: String(home.beds) })} data-testid="drill-beds"
+ className="hover:text-brand-700 hover:underline">{home.beds} bd</Link>
+ ) : "— bd"}{" · "}
+ {home.bathsTotal != null ? (
+ <Link href={drill({ bathsMin: String(home.bathsTotal) })} data-testid="drill-baths"
+ className="hover:text-brand-700 hover:underline">{home.bathsTotal} ba</Link>
+ ) : "— ba"}{" · "}
+ {home.sqft != null ? (
+ <Link href={sqftBandDrill(home.sqft, drill)} data-testid="drill-sqft"
+ className="hover:text-brand-700 hover:underline">{home.sqft.toLocaleString()} sqft</Link>
+ ) : "— 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>
+ {/* Builder drills to the builder's own page (existing route). */}
+ <Link href={`/builders/${home.builder.slug}`} data-testid="drill-builder"
+ className="truncate text-xs text-neutral-500 hover:text-brand-700 hover:underline">
+ {home.builder.name}
+ </Link>
<VerificationBadge label={home.verificationLabel} />
</div>
+ {/* Community drill — the "project" this home belongs to. */}
+ <Link href={`/communities/${home.community.slug}`} data-testid="drill-community"
+ className="mt-1 block truncate text-xs text-neutral-400 hover:text-brand-700 hover:underline">
+ {home.community.name}
+ </Link>
</div>
- </Link>
+ </div>
))}
{!loading && (data?.homes.length ?? 0) === 0 && (
<p className="col-span-full py-10 text-center text-neutral-500">
diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx
index e66b2960..98a0836f 100644
--- a/apps/web/src/app/search/page.tsx
+++ b/apps/web/src/app/search/page.tsx
@@ -2,11 +2,20 @@ import SearchClient from "./SearchClient";
export const dynamic = "force-dynamic";
+// Pass the FULL query string through so the grid's drill atoms are URL-addressable:
+// /search?city=Austin&status=MOVE_IN_READY&st=TX deep-links straight into a
+// pre-filtered grid (href-drill rule: URL-addressable filters).
export default async function SearchPage({
searchParams,
}: {
- searchParams: Promise<{ q?: string }>;
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
- const { q } = await searchParams;
- return <SearchClient initialQuery={q ?? ""} />;
+ const sp = await searchParams;
+ const params = new URLSearchParams();
+ for (const [k, v] of Object.entries(sp)) {
+ if (Array.isArray(v)) v.forEach((x) => params.append(k, x));
+ else if (v !== undefined) params.set(k, v);
+ }
+ const q = typeof sp.q === "string" ? sp.q : "";
+ return <SearchClient initialQuery={q} initialParams={params.toString()} />;
}
diff --git a/apps/web/src/lib/parcel.ts b/apps/web/src/lib/parcel.ts
new file mode 100644
index 00000000..ae53346e
--- /dev/null
+++ b/apps/web/src/lib/parcel.ts
@@ -0,0 +1,95 @@
+// Parcel / public-records drill helpers.
+//
+// SOURCING-COMPLIANCE: we source parcel facts (lot, county) that the ingest
+// pipeline already legitimately captured, and we link OUT to the county's own
+// public-records / assessor parcel-search surface. We NEVER fabricate an APN —
+// the data model has no APN column, so we surface what we honestly have (lot
+// number + county) and hand the user a public-records search scoped to the
+// county so they can look the parcel up on the authoritative source themselves.
+//
+// A small curated map of county → assessor parcel-search URL covers the highest
+// -volume counties in our data with a known, stable public search page. For any
+// county not in the map we fall back to a generic public-records search that is
+// still scoped to the county + state, so there is never a dead-end data point.
+
+interface CountyAssessor {
+ /** Display name of the authority the link points to. */
+ authority: string;
+ /** Landing/search URL on the county's own public parcel-search surface. */
+ url: (city: string, state: string) => string;
+}
+
+// Normalize "Solano County" / "solano" → "solano" for keying.
+export function normCounty(county: string | null | undefined): string {
+ if (!county) return "";
+ return county
+ .toLowerCase()
+ .replace(/\s+county$/i, "")
+ .trim();
+}
+
+// state-scoped county key so identical county names in different states don't collide.
+function key(county: string | null | undefined, state: string): string {
+ return `${state.toUpperCase()}:${normCounty(county)}`;
+}
+
+// Curated public parcel-search surfaces. URLs point at the county's OWN public
+// search landing page (public-records backbone) — the user runs the lookup.
+const ASSESSOR_MAP: Record<string, CountyAssessor> = {
+ // California
+ "CA:solano": { authority: "Solano County Assessor", url: () => "https://services.solanocounty.com/apps/PropertyInformation/" },
+ "CA:san diego": { authority: "San Diego County Assessor", url: () => "https://arcc.sdcounty.ca.gov/Pages/PropertySearch.aspx" },
+ "CA:kern": { authority: "Kern County Assessor", url: () => "https://recorderonline.co.kern.ca.us/" },
+ "CA:riverside": { authority: "Riverside County Assessor", url: () => "https://ca-riverside-acr.publicaccessnow.com/" },
+ "CA:sacramento": { authority: "Sacramento County Assessor", url: () => "https://assessorparcelviewer.saccounty.gov/" },
+ // Texas
+ "TX:harris": { authority: "Harris County Appraisal District", url: () => "https://hcad.org/property-search/" },
+ "TX:fort bend": { authority: "Fort Bend Central Appraisal District", url: () => "https://www.fbcad.org/property-search/" },
+ "TX:comal": { authority: "Comal Appraisal District", url: () => "https://esearch.comalad.org/" },
+ "TX:kendall": { authority: "Kendall Appraisal District", url: () => "https://esearch.kendallad.org/" },
+ "TX:travis": { authority: "Travis Central Appraisal District", url: () => "https://traviscad.org/property-search/" },
+ // North Carolina
+ "NC:wake": { authority: "Wake County Real Estate", url: () => "https://services.wake.gov/realestate/" },
+ "NC:forsyth": { authority: "Forsyth County Tax Assessor", url: () => "https://www.forsyth.cc/tax/property_records.aspx" },
+ "NC:cherokee": { authority: "Cherokee County Tax Office", url: () => "https://cherokeecounty-nc.gov/213/Tax-Assessor" },
+ // Delaware
+ "DE:sussex": { authority: "Sussex County Assessment", url: () => "https://sussexcountyde.gov/property-information" },
+ // Virginia
+ "VA:albemarle": { authority: "Albemarle County Real Estate", url: () => "https://www.albemarle.org/government/finance-budget/real-estate-tax-assessment" },
+ // Georgia
+ "GA:forsyth": { authority: "Forsyth County GA Assessor", url: () => "https://qpublic.schneidercorp.com/Application.aspx?App=ForsythCountyGA" },
+};
+
+export interface ParcelLink {
+ /** Human label for the authority the link points at. */
+ authority: string;
+ /** URL to the county's public parcel/records search. */
+ url: string;
+ /** True when we matched a curated county assessor; false = generic fallback. */
+ curated: boolean;
+}
+
+// Resolve a public-records drill target for a home's county+state. Always returns
+// something — a curated assessor when known, else a generic county-scoped
+// public-records web search — so a county/parcel data point is never a dead end.
+export function parcelLink(
+ county: string | null | undefined,
+ state: string | null | undefined,
+ city: string | null | undefined,
+): ParcelLink | null {
+ if (!state) return null;
+ const st = state.toUpperCase();
+ const hit = county ? ASSESSOR_MAP[key(county, st)] : undefined;
+ if (hit) {
+ return { authority: hit.authority, url: hit.url(city ?? "", st), curated: true };
+ }
+ // Generic, honest fallback: a web search scoped to the county assessor. No
+ // fabricated parcel id — just routes the user to the authoritative source.
+ const label = county ? `${normCounty(county).replace(/\b\w/g, (c) => c.toUpperCase())} County` : st;
+ const q = encodeURIComponent(`${label} ${st} county assessor parcel property records search`);
+ return {
+ authority: county ? `${label} public records` : `${st} public records`,
+ url: `https://www.google.com/search?q=${q}`,
+ curated: false,
+ };
+}
← be9e4af2 TK-10001: deploy-kamatera.sh frees :9975/:9976 from orphaned
·
back to Homesonspec
·
homesonspec search: drill() merges active rail filters (unio 62b2c3cb →