← back to Homesonspec
HomesOnSpec: 'Find subs near this home' — nearby licensed CA trades on the home detail page
86f64f40b2c9f2f7593b860f203013039d28eb5b · 2026-08-12 10:14:44 -0700 · Steve Abrams
Adds a 'Licensed trades in this area' surface to the home detail page so a
spec-home builder can staff the job straight from the listing. Consumes the
shared usre/CSLB contractor API.
- lib/trades.ts: CSLB class-code -> trade metadata (label/icon/build-order) +
DEFAULT_TRADES_PARAM (B,C-8,C-5,C-6,C-10,C-36,C-20,C-39,C-35,C-33,C-15,C-54).
- lib/contractors.ts: server-side graceful fetch to
{CONTRACTORS_API_BASE}/api/contractors/match (default http://localhost:9913),
3.5s abort, 15m revalidate, defensive normalize; returns empty result on any
failure (never throws).
- homes/[id]/NearbySubs.tsx: grid of trade blocks grouped by CSLB class, sort
(build order / name / city) + density slider both localStorage-persisted
(standing grid rule), per-sub CSLB license deep-link + tel link, and a graceful
empty-state with a CSLB public-lookup fallback when the API is unreachable /
the home is out-of-state.
- Wired into the detail page Promise.all (CA-only fetch; non-CA short-circuits).
- .env.example: documents CONTRACTORS_API_BASE.
Verified: typecheck clean; detail page 200 on real CA + non-CA homes; empty-state
renders when API auth-walled; populated grid (2 trade blocks / 3 rows / correct
null-phone handling / license links) renders against a mock API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .env.exampleA apps/web/src/app/homes/[id]/NearbySubs.tsxM apps/web/src/app/homes/[id]/page.tsxA apps/web/src/lib/contractors.tsA apps/web/src/lib/trades.ts
Diff
commit 86f64f40b2c9f2f7593b860f203013039d28eb5b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 10:14:44 2026 -0700
HomesOnSpec: 'Find subs near this home' — nearby licensed CA trades on the home detail page
Adds a 'Licensed trades in this area' surface to the home detail page so a
spec-home builder can staff the job straight from the listing. Consumes the
shared usre/CSLB contractor API.
- lib/trades.ts: CSLB class-code -> trade metadata (label/icon/build-order) +
DEFAULT_TRADES_PARAM (B,C-8,C-5,C-6,C-10,C-36,C-20,C-39,C-35,C-33,C-15,C-54).
- lib/contractors.ts: server-side graceful fetch to
{CONTRACTORS_API_BASE}/api/contractors/match (default http://localhost:9913),
3.5s abort, 15m revalidate, defensive normalize; returns empty result on any
failure (never throws).
- homes/[id]/NearbySubs.tsx: grid of trade blocks grouped by CSLB class, sort
(build order / name / city) + density slider both localStorage-persisted
(standing grid rule), per-sub CSLB license deep-link + tel link, and a graceful
empty-state with a CSLB public-lookup fallback when the API is unreachable /
the home is out-of-state.
- Wired into the detail page Promise.all (CA-only fetch; non-CA short-circuits).
- .env.example: documents CONTRACTORS_API_BASE.
Verified: typecheck clean; detail page 200 on real CA + non-CA homes; empty-state
renders when API auth-walled; populated grid (2 trade blocks / 3 rows / correct
null-phone handling / license links) renders against a mock API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.env.example | 5 +
apps/web/src/app/homes/[id]/NearbySubs.tsx | 221 +++++++++++++++++++++++++++++
apps/web/src/app/homes/[id]/page.tsx | 20 ++-
apps/web/src/lib/contractors.ts | 131 +++++++++++++++++
apps/web/src/lib/trades.ts | 62 ++++++++
5 files changed, 438 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index ebddfb20..dbd78465 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,11 @@ BASIC_AUTH="admin:CHANGE_ME_BEFORE_DEPLOY"
# Snapshot storage root (raw fetched bodies live on disk, not in PG)
SNAPSHOT_DIR="./var/snapshots"
+# Shared usre/CSLB contractor API base (powers "Find subs near this home" on the
+# home detail page). Defaults to http://localhost:9913 when unset. If the API is
+# unreachable the detail page renders a graceful empty-state (never errors).
+CONTRACTORS_API_BASE="http://localhost:9913"
+
# --- Google AdSense banners (GATED — ads OFF until these are set) ---
# Supply the ca-pub-… publisher id ONLY after the AdSense account is approved.
# Empty = no ad script, no ad markup, no network calls (site ships ad-free).
diff --git a/apps/web/src/app/homes/[id]/NearbySubs.tsx b/apps/web/src/app/homes/[id]/NearbySubs.tsx
new file mode 100644
index 00000000..f934aa47
--- /dev/null
+++ b/apps/web/src/app/homes/[id]/NearbySubs.tsx
@@ -0,0 +1,221 @@
+"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;
+ /** Home location, echoed in the section subhead + the browse deep-link. */
+ city: string | null;
+ county: string | null;
+ state: string;
+}
+
+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, 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>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <p className="mt-2 text-xs text-neutral-400">
+ Licensed-contractor facts come from the public California Contractors State License Board (CSLB)
+ record. HomesOnSpec does not endorse any contractor — always verify a license on CSLB before hiring.
+ {error && total === 0 ? ` (${error})` : ""}
+ </p>
+ </section>
+ );
+}
diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
index c171083b..e7fb6755 100644
--- a/apps/web/src/app/homes/[id]/page.tsx
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -4,7 +4,9 @@ import { prisma } from "@homesonspec/database";
import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";
import CorrectionForm from "./CorrectionForm";
import LeadForm from "./LeadForm";
+import NearbySubs from "./NearbySubs";
import { parcelLink } from "../../../lib/parcel";
+import { matchSubsForHome } from "../../../lib/contractors";
export const dynamic = "force-dynamic";
@@ -33,7 +35,7 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
});
if (!home || home.status !== "PUBLISHED" || home.isDemo) notFound();
- const [communityIncentives, evidence, otherHomes] = await Promise.all([
+ const [communityIncentives, evidence, otherHomes, subs] = await Promise.all([
prisma.incentive.findMany({ where: { communityId: home.communityId, scope: "COMMUNITY" } }),
prisma.sourceEvidence.findMany({
where: { entityType: "INVENTORY_HOME", entityId: home.id },
@@ -55,6 +57,13 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
orderBy: { publishedAt: "desc" },
take: 12,
}),
+ // "Find subs near this home" — nearby licensed CA subcontractors grouped by
+ // trade, from the shared usre/CSLB contractor API. CA-only (the CSLB source
+ // covers California); non-CA homes short-circuit to the empty-state. This
+ // never throws — matchSubsForHome returns a graceful empty result on failure.
+ home.state?.toUpperCase() === "CA"
+ ? matchSubsForHome({ city: home.city, county: home.community.county, zip: home.zip })
+ : Promise.resolve({ matches: {}, criteria: null, ok: false, error: null }),
]);
const incentives = [...home.incentives, ...communityIncentives];
// Media-rights safe default: builder photos are OFF unless BUILDER_IMAGES_ENABLED=1
@@ -274,6 +283,15 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
</p>
</section>
+ {/* Find subs near this home — nearby licensed CA subcontractors by trade. */}
+ <NearbySubs
+ matches={subs.matches}
+ error={subs.error}
+ city={home.city}
+ county={home.community.county}
+ state={home.state}
+ />
+
{(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/lib/contractors.ts b/apps/web/src/lib/contractors.ts
new file mode 100644
index 00000000..a4cf2ed8
--- /dev/null
+++ b/apps/web/src/lib/contractors.ts
@@ -0,0 +1,131 @@
+// Fetch layer for the shared usre contractor API (built in parallel by another
+// agent). Consumed SERVER-SIDE from the home detail page so the base URL / any
+// credentials never reach the browser.
+//
+// Contract (per TK-10488):
+// GET {base}/api/contractors/match?mode=home&city=&county=&zip=&trades=C-8,C-10,...
+// -> { criteria, matches: { "C-10": [ {license_no, business_name, city,
+// county, phone, license_status, classifications, primary_class} ], ... } }
+// active subs grouped by trade class, nearest-first.
+// GET {base}/api/contractors?county=&class=&status=Active&limit= (plain browse)
+//
+// Base URL from process.env.CONTRACTORS_API_BASE (default http://localhost:9913).
+// GRACEFUL by design: if the API is unreachable / errors / returns junk, this
+// returns a null-ish result so the UI renders an empty-state, never throws.
+
+import { DEFAULT_TRADES_PARAM } from "./trades";
+
+export const CONTRACTORS_API_BASE =
+ process.env.CONTRACTORS_API_BASE?.replace(/\/+$/, "") || "http://localhost:9913";
+
+export interface ContractorMatch {
+ license_no: string;
+ business_name: string;
+ city: string | null;
+ county: string | null;
+ phone: string | null;
+ license_status: string | null;
+ classifications: string | null;
+ primary_class: string | null;
+}
+
+export interface MatchResult {
+ /** Active subs grouped by CSLB class code, nearest-first (as returned). */
+ matches: Record<string, ContractorMatch[]>;
+ /** The criteria the API echoed back (city/county/zip/trades), when present. */
+ criteria: Record<string, unknown> | null;
+ /** True when the API answered with a well-formed body. */
+ ok: boolean;
+ /** Set when the API was unreachable or errored — drives the empty-state copy. */
+ error: string | null;
+}
+
+const EMPTY: MatchResult = { matches: {}, criteria: null, ok: false, error: null };
+
+interface MatchArgs {
+ city?: string | null;
+ county?: string | null;
+ zip?: string | null;
+ /** Comma-joined CSLB class codes; defaults to the spec-home build set. */
+ trades?: string;
+ /** Abort budget (ms). Short so a slow/absent API never blocks the page. */
+ timeoutMs?: number;
+}
+
+/**
+ * Fetch nearby licensed subs grouped by trade for a spec home's location.
+ * Returns a graceful empty result on any failure — the caller renders an
+ * empty-state rather than crashing the page.
+ */
+export async function matchSubsForHome(args: MatchArgs): Promise<MatchResult> {
+ const trades = args.trades ?? DEFAULT_TRADES_PARAM;
+ const qs = new URLSearchParams({ mode: "home", trades });
+ if (args.city) qs.set("city", args.city);
+ if (args.county) qs.set("county", args.county);
+ if (args.zip) qs.set("zip", args.zip);
+
+ const url = `${CONTRACTORS_API_BASE}/api/contractors/match?${qs.toString()}`;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 3500);
+ try {
+ const res = await fetch(url, {
+ signal: controller.signal,
+ headers: { accept: "application/json" },
+ // Location facts change slowly; cache briefly to avoid hammering the API
+ // on every detail-page view.
+ next: { revalidate: 900 },
+ });
+ if (!res.ok) {
+ return { ...EMPTY, error: `contractor API ${res.status}` };
+ }
+ const body = (await res.json()) as unknown;
+ return normalize(body);
+ } catch (err) {
+ const error = err instanceof Error ? err.message : "contractor API unreachable";
+ return { ...EMPTY, error };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** Defensively coerce the API body into a MatchResult; tolerate shape drift. */
+function normalize(body: unknown): MatchResult {
+ if (!body || typeof body !== "object") return { ...EMPTY, error: "empty response" };
+ const obj = body as Record<string, unknown>;
+ const rawMatches = obj.matches;
+ const matches: Record<string, ContractorMatch[]> = {};
+ if (rawMatches && typeof rawMatches === "object") {
+ for (const [cls, list] of Object.entries(rawMatches as Record<string, unknown>)) {
+ if (!Array.isArray(list)) continue;
+ matches[cls] = list
+ .filter((r): r is Record<string, unknown> => !!r && typeof r === "object")
+ .map((r) => ({
+ license_no: str(r.license_no),
+ business_name: str(r.business_name),
+ city: strOrNull(r.city),
+ county: strOrNull(r.county),
+ phone: strOrNull(r.phone),
+ license_status: strOrNull(r.license_status),
+ classifications: strOrNull(r.classifications),
+ primary_class: strOrNull(r.primary_class) ?? cls,
+ }))
+ // Drop rows with no business name — never surface a blank sub.
+ .filter((r) => r.business_name.length > 0);
+ }
+ }
+ const criteria =
+ obj.criteria && typeof obj.criteria === "object"
+ ? (obj.criteria as Record<string, unknown>)
+ : null;
+ const total = Object.values(matches).reduce((n, a) => n + a.length, 0);
+ return { matches, criteria, ok: total > 0, error: total > 0 ? null : "no matches" };
+}
+
+function str(v: unknown): string {
+ return typeof v === "string" ? v : v == null ? "" : String(v);
+}
+function strOrNull(v: unknown): string | null {
+ if (v == null) return null;
+ const s = typeof v === "string" ? v : String(v);
+ return s.trim() === "" ? null : s;
+}
diff --git a/apps/web/src/lib/trades.ts b/apps/web/src/lib/trades.ts
new file mode 100644
index 00000000..2b788734
--- /dev/null
+++ b/apps/web/src/lib/trades.ts
@@ -0,0 +1,62 @@
+// CSLB (California Contractors State License Board) classification → trade metadata.
+//
+// A spec-home builder staffing a job thinks in TRADES ("who does my framing /
+// electrical / roofing"), not in raw CSLB class codes. This map turns each class
+// code the contractor API groups results under into a human trade label so the
+// "Find subs near this home" surface reads like a build schedule, not a code list.
+//
+// SCOPE: labels only — this is display metadata, no data is fabricated. The
+// contractor facts themselves come from the shared usre contractor API (which is
+// sourced from the public CSLB licensed-contractor list).
+
+export interface TradeMeta {
+ /** CSLB class code, e.g. "C-10". */
+ code: string;
+ /** Human trade label, e.g. "Electrical". */
+ label: string;
+ /** Emoji used as a lightweight per-trade icon on the card header. */
+ icon: string;
+ /** Rough order a spec home is staffed in (used as a sensible default sort). */
+ order: number;
+}
+
+// The default trade set a spec-home builder wants surfaced, in build order.
+// Mirrors the brief's default set: framing, concrete, electrical, plumbing,
+// HVAC, roofing, paint, plaster, flooring, tile, carpentry (+ general "B").
+export const DEFAULT_TRADES: TradeMeta[] = [
+ { code: "B", label: "General building", icon: "🏗️", order: 0 },
+ { code: "C-8", label: "Concrete", icon: "🧱", order: 1 },
+ { code: "C-5", label: "Framing & rough carpentry", icon: "🪵", order: 2 },
+ { code: "C-6", label: "Cabinet & finish carpentry", icon: "🪚", order: 3 },
+ { code: "C-10", label: "Electrical", icon: "⚡", order: 4 },
+ { code: "C-36", label: "Plumbing", icon: "🚰", order: 5 },
+ { code: "C-20", label: "HVAC", icon: "❄️", order: 6 },
+ { code: "C-39", label: "Roofing", icon: "🏠", order: 7 },
+ { code: "C-35", label: "Lathing & plaster", icon: "🪵", order: 8 },
+ { code: "C-33", label: "Painting & decorating", icon: "🎨", order: 9 },
+ { code: "C-15", label: "Flooring", icon: "🪟", order: 10 },
+ { code: "C-54", label: "Tile (ceramic & mosaic)", icon: "🔲", order: 11 },
+];
+
+/** Comma-joined class codes for the contractor API `trades=` param, in build order. */
+export const DEFAULT_TRADES_PARAM: string = DEFAULT_TRADES.map((t) => t.code).join(",");
+
+const BY_CODE: Record<string, TradeMeta> = Object.fromEntries(
+ DEFAULT_TRADES.map((t) => [t.code, t]),
+);
+
+/**
+ * Resolve display metadata for a CSLB class code. Unknown codes (the API may
+ * return a class outside the default set) get an honest generic label rather
+ * than being dropped — never fabricate a trade name, but never dead-end either.
+ */
+export function tradeMeta(code: string): TradeMeta {
+ return (
+ BY_CODE[code] ?? {
+ code,
+ label: `Class ${code}`,
+ icon: "🔧",
+ order: 999,
+ }
+ );
+}
← d89b21a7 chore: sync pnpm-lock.yaml for new collector workspace deps
·
back to Homesonspec
·
HomesOnSpec: date-stamp nearby-subs — per-row CSLB issue/exp 552d6aa2 →