[object Object]

← 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

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 →