← back to Homesonspec
apps/web/src/app/homes/[id]/NearbySubs.tsx
262 lines
"use client";
import { useEffect, useMemo, useState } from "react";
import { tradeMeta } from "../../../lib/trades";
import type { ContractorMatch } from "../../../lib/contractors";
// "Find subs near this home" — surfaces nearby licensed CALIFORNIA subcontractors
// (from the shared usre / CSLB contractor API) grouped by trade, so a spec-home
// builder can staff the job straight from the listing. Sort + density controls
// are mandatory on any grid (standing rule) and persist to localStorage.
interface Props {
/** Active subs grouped by CSLB class code, nearest-first (from the API). */
matches: Record<string, ContractorMatch[]>;
/** Reason the API returned nothing (unreachable / no matches) — for the empty-state. */
error: string | null;
/** CSLB data-as-of date — stamped on the section (DATE EVERYTHING). */
sourceAsOf: string | null;
/** Home location, echoed in the section subhead + the browse deep-link. */
city: string | null;
county: string | null;
state: string;
}
// Date formatting (DATE EVERYTHING). Renders the license issue/expire dates and
// the CSLB data-as-of stamp in the viewer's local timezone; a bad/empty value
// degrades to a dash rather than "Invalid Date".
function fmtDate(v: string | null | undefined): string {
if (!v) return "—";
const t = Date.parse(v);
if (Number.isNaN(t)) return String(v);
return new Date(t).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
type SortMode = "trade" | "name" | "city";
const SORTS: { value: SortMode; label: string }[] = [
{ value: "trade", label: "Build order (trade)" },
{ value: "name", label: "Business name A→Z" },
{ value: "city", label: "City A→Z" },
];
const SORT_KEY = "homesonspec.subs.sort";
const COLS_KEY = "homesonspec.subs.cols";
export default function NearbySubs({ matches, error, sourceAsOf, city, county, state }: Props) {
const [sort, setSort] = useState<SortMode>("trade");
const [cols, setCols] = useState<number>(2);
// Restore persisted controls (standing grid rule).
useEffect(() => {
const s = localStorage.getItem(SORT_KEY);
if (s === "trade" || s === "name" || s === "city") setSort(s);
const c = Number(localStorage.getItem(COLS_KEY));
if (c >= 1 && c <= 3) setCols(c);
}, []);
const setSortPersist = (v: SortMode) => {
setSort(v);
localStorage.setItem(SORT_KEY, v);
};
const setColsPersist = (v: number) => {
setCols(v);
localStorage.setItem(COLS_KEY, String(v));
};
// Flatten the grouped matches into a sortable list of trade blocks. Each block
// is one CSLB class with its subs; the sort mode reorders both the blocks and
// the subs inside them so the view reads the way the builder is thinking.
const blocks = useMemo(() => {
const entries = Object.entries(matches).filter(([, list]) => list.length > 0);
const withMeta = entries.map(([code, list]) => ({ code, meta: tradeMeta(code), subs: [...list] }));
// Sort the subs within each block.
for (const b of withMeta) {
if (sort === "name") {
b.subs.sort((a, c) => a.business_name.localeCompare(c.business_name));
} else if (sort === "city") {
b.subs.sort((a, c) => (a.city ?? "").localeCompare(c.city ?? ""));
}
// "trade" leaves the API's nearest-first order untouched.
}
// Sort the blocks: build order by default, else alphabetical trade label.
withMeta.sort((a, c) =>
sort === "trade" ? a.meta.order - c.meta.order : a.meta.label.localeCompare(c.meta.label),
);
return withMeta;
}, [matches, sort]);
const total = useMemo(() => blocks.reduce((n, b) => n + b.subs.length, 0), [blocks]);
const locLabel = [city, county ? `${county.replace(/\s+county$/i, "")} County` : null, state]
.filter(Boolean)
.join(", ");
return (
<section className="mt-8" data-testid="nearby-subs">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-display text-xl font-semibold text-brand-900">Licensed trades in this area</h2>
<p className="mt-1 text-sm text-neutral-500">
Active California-licensed subcontractors near {locLabel || "this home"}, grouped by trade —
staff this build straight from the listing.
</p>
</div>
{/* Mandatory grid controls: sort select + density slider, localStorage-persisted. */}
{total > 0 && (
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
Sort
<select
value={sort}
onChange={(e) => setSortPersist(e.target.value as SortMode)}
className="rounded-lg border-0 px-2 py-1 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
data-testid="subs-sort-select"
>
{SORTS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
Density
<input
type="range" min={1} max={3} step={1} value={cols} className="accent-brand-700"
onChange={(e) => setColsPersist(Number(e.target.value))}
data-testid="subs-density-slider"
/>
</label>
</div>
)}
</div>
{total === 0 ? (
// Graceful empty-state — the contractor API was unreachable or returned no
// matches (e.g. an out-of-state home, or the API isn't up yet). Never a
// broken grid; offer the honest CSLB public-lookup fallback.
<div
className="mt-4 rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-5 text-sm text-neutral-600"
data-testid="subs-empty"
>
<p className="font-medium text-neutral-700">No licensed subs to show here yet.</p>
<p className="mt-1">
{state?.toUpperCase() !== "CA"
? "Licensed-trade matching currently covers California homes."
: "We couldn’t load nearby licensed trades for this location right now."}{" "}
You can search the state license board directly:
</p>
<a
href="https://www.cslb.ca.gov/onlineservices/checklicenseII/checklicense.aspx"
target="_blank"
rel="nofollow noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 font-medium text-accent-600 hover:text-accent-700"
data-testid="subs-cslb-link"
>
🔎 Look up a licensed contractor on CSLB ↗
</a>
</div>
) : (
<div
className="mt-4 grid gap-4"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
data-testid="subs-grid"
>
{blocks.map((b) => (
<div key={b.code} className="card p-4" data-testid="subs-trade-block">
<div className="flex items-center justify-between">
<h3 className="font-display text-base font-semibold text-brand-900">
<span className="mr-1.5">{b.meta.icon}</span>
{b.meta.label}
</h3>
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-800">
{b.subs.length} · {b.code}
</span>
</div>
<ul className="mt-3 divide-y divide-neutral-100">
{b.subs.map((s) => {
const tel = s.phone ? s.phone.replace(/[^0-9+]/g, "") : null;
return (
<li key={`${s.license_no}-${s.business_name}`} className="py-2" data-testid="subs-row">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate font-medium text-neutral-800">{s.business_name}</div>
<div className="mt-0.5 text-xs text-neutral-500">
{[s.city, s.county ? s.county.replace(/\s+county$/i, "") : null]
.filter(Boolean)
.join(" · ") || "California"}
</div>
</div>
{s.license_status ? (
<span
className="shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
title={`CSLB status: ${s.license_status}`}
>
{s.license_status}
</span>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{/* Deep-link to the authoritative CSLB record by license number. */}
{s.license_no ? (
<a
href={`https://www.cslb.ca.gov/OnlineServices/CheckLicenseII/LicenseDetail.aspx?LicNum=${encodeURIComponent(s.license_no)}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="text-brand-700 hover:underline"
data-testid="subs-license-link"
>
Lic. #{s.license_no} ↗
</a>
) : null}
{tel ? (
<a href={`tel:${tel}`} className="font-medium text-accent-600 hover:text-accent-700" data-testid="subs-phone">
📞 {s.phone}
</a>
) : null}
</div>
{/* DATE EVERYTHING: license issue + expire dates on every row. */}
<div
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-neutral-400"
data-testid="subs-dates"
>
<span title={s.issue_date ? `License issued ${s.issue_date}` : undefined}>
Issued {fmtDate(s.issue_date)}
</span>
<span title={s.expire_date ? `License expires ${s.expire_date}` : undefined}>
Expires {fmtDate(s.expire_date)}
</span>
</div>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
{/* DATE EVERYTHING: CSLB data-as-of provenance stamp + verify pointer. */}
<p className="mt-2 text-xs text-neutral-400" data-testid="subs-source-stamp">
{sourceAsOf
? `CSLB data as of ${fmtDate(sourceAsOf)} — verify at `
: "CSLB data — verify at "}
<a
href="https://www.cslb.ca.gov"
target="_blank"
rel="nofollow noopener noreferrer"
className="underline hover:text-neutral-600"
>
cslb.ca.gov
</a>
. Licensed-contractor facts come from the public California Contractors State License Board (CSLB)
record. HomesOnSpec does not endorse any contractor — always verify a license before hiring.
{error && total === 0 ? ` (${error})` : ""}
</p>
</section>
);
}