← back to Commercialrealestate
public/contractors.html
406 lines
<!doctype html><html lang="en"><head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
<script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Contractors for this deal — CSLB licensed · CRCP</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%F0%9F%94%A8%3C/text%3E%3C/svg%3E">
<style>
:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--red:#f85149;--cols:4}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,Helvetica,Arial,sans-serif;font-size:13px}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
h1{font-size:17px;margin:0}h1 b{color:#fff}
.badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#0e1116;background:var(--gold);padding:3px 8px;border-radius:6px}
.nav a{color:var(--blue);text-decoration:none;font-size:12px;margin-left:12px}.nav a:hover{color:var(--gold)}
.shell{display:flex;align-items:flex-start}
.rail{width:236px;flex:0 0 236px;border-right:1px solid var(--line);padding:12px;height:calc(100vh - 52px);overflow:auto;position:sticky;top:0}
.rsec{border-bottom:1px solid var(--line);padding:9px 0}
.rsec h4{margin:0 0 7px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut)}
.chips{display:flex;flex-wrap:wrap;gap:5px}
.chip{border:1px solid var(--line);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer;background:var(--card);user-select:none}
.chip.active{background:var(--blue);color:#0e1116;border-color:var(--blue);font-weight:600}
.chip .ct{color:var(--mut);margin-left:4px;font-size:10px}.chip.active .ct{color:#0e1116}
.rail input[type=text]{width:100%;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
.main{flex:1;padding:12px 16px;min-width:0}
/* deal framing strip (Frank's angle) */
.persona{background:linear-gradient(120deg,rgba(255,166,0,.10),rgba(88,166,255,.06));border:1px solid var(--line);border-radius:12px;padding:12px 15px;margin-bottom:13px;display:flex;flex-wrap:wrap;gap:11px 14px;align-items:center}
.persona h2{font-size:15px;margin:0;color:#fff;font-weight:600}
.persona .role{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--gold);border:1px solid rgba(255,166,0,.35);border-radius:20px;padding:2px 9px;white-space:nowrap}
.persona .tip{flex-basis:100%;font-size:11.5px;color:var(--mut);margin:-2px 0 0;line-height:1.5}
/* mode toggle: browse registry vs match-this-deal */
.modebar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
.modebtn{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 14px;font-size:12.5px;color:var(--mut);cursor:pointer}
.modebtn.on{border-color:var(--gold);color:#fff;background:rgba(255,166,0,.10);font-weight:600}
.toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
.search{flex:1;min-width:200px;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 11px;font-size:13px}
select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
input.dealf{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
.count{color:var(--mut);font-size:12px}
.prov{font-size:11px;color:var(--mut);margin:0 0 12px;line-height:1.5;border-left:2px solid var(--gold);padding-left:9px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
.ccard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 14px;display:flex;flex-direction:column;gap:7px}
.ccard .top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
.ccard .name{font-weight:600;font-size:13.5px;line-height:1.25}
.pill{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 6px;border-radius:6px;white-space:nowrap;flex-shrink:0}
.pill.active{background:rgba(63,185,80,.16);color:var(--acc)}
.pill.inactive{background:rgba(248,81,73,.16);color:var(--red)}
.pill.other{background:rgba(139,148,158,.16);color:var(--mut)}
.dl{display:flex;flex-wrap:wrap;gap:5px 14px;font-size:12px;color:var(--ink)}
.dl .i{color:var(--mut);display:inline-flex;align-items:center;gap:4px}
.dl .i b{color:var(--ink);font-weight:600}
.dl a.ddrill{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(88,166,255,.35)}
.dl a.ddrill:hover{color:var(--blue);border-bottom-color:var(--blue)}
.classrow{display:flex;flex-wrap:wrap;gap:5px}
.classtag{font-size:10px;background:rgba(188,140,255,.14);color:var(--violet);border:1px solid rgba(188,140,255,.3);border-radius:6px;padding:1px 6px;cursor:pointer;text-decoration:none}
.classtag:hover{border-color:var(--violet)}
.classtag.prim{background:rgba(255,166,0,.16);color:var(--gold);border-color:rgba(255,166,0,.4)}
.datesrow{gap:5px 12px;font-size:11.5px;margin-top:-1px}
.datesrow .i b{font-weight:600}
.datesrow .expwarn{color:var(--red)}.datesrow .expwarn b{color:var(--red)}
.fresh{font-size:10px;color:var(--mut);line-height:1.5;border-top:1px solid #20262f;padding-top:6px;margin-top:1px}
.fresh b{color:var(--ink)}
.fresh a{color:var(--blue);text-decoration:none}.fresh a:hover{text-decoration:underline}
.provline{font-size:10px;color:var(--mut);border-top:1px solid #20262f;padding-top:6px;margin-top:1px;display:flex;align-items:center;gap:5px}
.ccard a.f{color:var(--blue);text-decoration:none;font-size:12px}.ccard a.f:hover{text-decoration:underline}
/* match-mode: class buckets */
.bucket{margin-bottom:20px}
.bucket h3{font-size:13px;margin:0 0 3px;color:#fff;display:flex;align-items:center;gap:8px}
.bucket h3 .cls{font-size:10px;font-weight:800;letter-spacing:.5px;color:var(--gold);background:rgba(255,166,0,.14);border:1px solid rgba(255,166,0,.35);border-radius:6px;padding:2px 7px}
.bucket .btitle{font-size:11.5px;color:var(--mut);margin:0 0 9px}
.empty{color:var(--mut);padding:34px 12px;text-align:center;border:1px dashed var(--line);border-radius:12px;line-height:1.6}
.empty b{color:var(--gold)}
.finance{background:rgba(88,166,255,.08);border:1px solid rgba(88,166,255,.25);border-radius:8px;padding:2px 7px;font-size:10.5px;color:var(--blue);cursor:pointer;text-decoration:none}
.finance:hover{border-color:var(--blue)}
@media(max-width:760px){.rail{display:none}}
.viewseg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.viewseg button{background:var(--card);color:var(--mut);border:0;border-right:1px solid var(--line);padding:7px 11px;font-size:12px;cursor:pointer}
.viewseg button:last-child{border-right:0}.viewseg button.on{background:var(--blue,#58a6ff);color:#0e1116;font-weight:600}
.cttable{overflow:auto}
.cttable table{width:100%;border-collapse:collapse;font-size:12.5px}
.cttable th{text-align:left;color:var(--mut);text-transform:uppercase;font-size:10px;letter-spacing:.4px;padding:7px 9px;border-bottom:1px solid var(--line);white-space:nowrap;position:sticky;top:0;background:var(--card)}
.cttable td{padding:7px 9px;border-bottom:1px solid var(--line)}
.cttable tr:hover td{background:rgba(120,160,255,.06)}
.lst{display:flex;flex-direction:column}
.lrow{display:flex;align-items:baseline;gap:16px;flex-wrap:wrap;padding:10px 6px;border-bottom:1px solid var(--line);cursor:pointer}
.lrow:hover{background:rgba(120,160,255,.06)}
.lnm{font-weight:700;min-width:210px;flex:0 0 auto;color:var(--ink)}
.lmeta{display:flex;flex-wrap:wrap;gap:5px 16px;flex:1 1 320px;min-width:0}
.lm{font-size:12.5px;white-space:nowrap;color:var(--ink)}
.lk{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px}
</style> <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
</head><body>
<header>
<h1><b>Contractors for this deal</b> · CSLB licensed</h1>
<span class="badge">🔨 vendor & borrower graph</span>
<div class="nav"><a href="/crcp.html">🎛️ Control Panel</a><a href="/deals-flow.html">📊 Deal Flow</a><a href="/loan-officers.html">🏦 Loan Officers</a><a href="/">Explorer</a></div>
</header>
<div class="shell">
<aside class="rail">
<div class="rsec"><h4>License status</h4><div class="chips" id="fStatus"></div></div>
<div class="rsec"><h4>County</h4><div class="chips" id="fCounty"></div></div>
<div class="rsec"><h4>City</h4><div class="chips" id="fCity"></div></div>
<div class="rsec"><h4>CSLB class</h4><div class="chips" id="fClass"></div></div>
<div class="rsec" style="border-bottom:0"><h4>Registry note</h4>
<div style="font-size:11px;color:var(--mut);line-height:1.5">CSLB = California Contractors State License Board. <b>A</b> = General Engineering, <b>B</b> = General Building, <b>C-##</b> = specialty trades. License status is the CSLB record; verify at <a href="https://www.cslb.ca.gov/" target="_blank" rel="noopener noreferrer" style="color:var(--blue)">cslb.ca.gov ↗</a> before contracting.</div>
</div>
</aside>
<div class="main">
<div class="persona">
<h2>Who builds — and who might need to borrow</h2>
<span class="role">Frank · Loan Officer</span>
<p class="tip">Every closed CRE asset spins up work: TI build-outs for new tenants, value-add renovation, deferred-maintenance catch-up. The licensed <b>general contractors</b> and <b>trades</b> in that market are the vendors around the asset — and prime candidates for <b>construction / bridge financing</b>. Filter by the deal's county, city, or asset type to build a call list.</p>
</div>
<div class="modebar">
<button class="modebtn on" id="mBrowse" type="button">🔍 Browse the registry</button>
<button class="modebtn" id="mMatch" type="button">🎯 Match a deal</button>
</div>
<!-- Deal-match inputs (shown in match mode) -->
<div class="toolbar" id="matchInputs" style="display:none;border:1px solid var(--line);border-radius:10px;padding:11px 12px;background:var(--card)">
<span style="font-size:10px;font-weight:700;letter-spacing:.5px;color:var(--gold);text-transform:uppercase;white-space:nowrap">🎯 Deal criteria</span>
<input class="dealf" id="dCounty" placeholder="County (e.g. Los Angeles)" style="min-width:170px">
<input class="dealf" id="dCity" placeholder="City (e.g. Van Nuys)" style="min-width:150px">
<select id="dType">
<option value="">Asset type…</option>
<option value="office">Office / TI build-out</option>
<option value="retail">Retail / restaurant TI</option>
<option value="multifamily">Multifamily value-add</option>
<option value="industrial">Industrial / warehouse</option>
<option value="hospitality">Hotel / hospitality</option>
<option value="medical">Medical / dental</option>
<option value="ground-up">Ground-up construction</option>
</select>
<button class="modebtn" id="dGo" type="button" style="border-color:var(--gold);color:#fff">Find contractors →</button>
</div>
<div class="prov" id="prov">CSLB licensed-contractor registry, surfaced from the shared usre contractor API. Public license-board data. This is a lead / market-coverage aid — always re-verify a license on the CSLB site before doing business.</div>
<!-- Browse-mode toolbar (search + sort + density) -->
<div class="toolbar" id="browseTools">
<div class="viewseg" id="viewseg"><button data-v="grid" class="on">▦ Grid</button><button data-v="list">☰ List</button><button data-v="table">▤ Table</button></div>
<input class="search" id="q" placeholder="search business name, city, license #…">
<select id="sort"></select>
<label style="font-size:11px;color:var(--mut)">density <input type="range" id="dens" min="2" max="7" step="1"></label>
<span class="count" id="count"></span>
</div>
<div id="body"><div class="empty" style="opacity:.6">Loading contractors…</div></div>
</div></div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
const api=(u)=>fetch(u,{headers:{'Content-Type':'application/json'}}).then(r=>r.json());
let MODE='browse';
let DATA=[], q='', sortKey='business_name', sortDir=1, view=(localStorage.getItem('conView')||'grid');
// columns for the Table + List views (browse mode); the Grid keeps its rich cards
const CCOLS=[{k:'business_name',l:'Contractor'},{k:'primary_class',l:'Class'},{k:'license_status',l:'Status'},{k:'city',l:'City'},{k:'county',l:'County'},{k:'license_no',l:'License #'},{k:'phone',l:'Phone'}];
const cfmt=(c,col)=>{let v=c[col.k];if(col.k==='primary_class')v=c.primary_class||(Array.isArray(c.classifications)?c.classifications.join(', '):'');return v==null||v===''?'—':esc(String(v));};
let dens=(function(){const v=parseInt(localStorage.getItem('cxDens'),10);return(v>=2&&v<=7)?v:4;})();
const SORTS=[['business_name','Name A→Z'],['city','City A→Z'],['county','County A→Z'],['primary_class','Class'],['license_status','Status'],['license_no','License #'],['issue_date','Issued (oldest)'],['expire_date','Expires (soonest)']];
const DATE_KEYS=new Set(['issue_date','expire_date']);
$('#sort').innerHTML=SORTS.map(([k,l],i)=>`<option value="${k}"${i===0?' selected':''}>Sort: ${l}</option>`).join('');
$('#dens').value=dens;
// ── rail filter state (in-memory Sets) — each is a real, URL-addressable filter ──
const F={status:new Set(),county:new Set(),city:new Set(),class:new Set()};
const tog=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
// upstream-down banner + graceful empty-state: the whole point of the wiring is that a not-yet-running
// contractor API renders a clean, honest empty-state — never a broken page.
function emptyState(reason,extra){
return `<div class="empty">🔨 <b>No contractors to show yet.</b><br>
${reason||'The contractor registry API is not reachable from CRCP right now.'}<br>
<span style="font-size:11px;opacity:.8">${extra||'This panel is wired to the shared usre contractor API (CONTRACTORS_API_BASE). Once it is running + populated, licensed contractors for the selected market appear here automatically — no change needed.'}</span></div>`;
}
// ── card renderers ──
function statusPill(s){const v=(s||'').toLowerCase();
// CSLB good-standing status is 'CLEAR' (the shared usre contractor API uses status=CLEAR, not 'Active').
if(v.includes('clear')||v.includes('active'))return `<span class="pill active">${esc(s)}</span>`;
if(v)return `<span class="pill inactive">${esc(s)}</span>`;
return `<span class="pill other">unknown</span>`;}
// ── DATE EVERYTHING (Steve's hard rule + CSLB compliance): render license issue + expire dates on
// every card, plus a "CSLB data as of {source_as_of}" freshness line that tells Frank to re-verify.
function fmtDate(v){if(!v)return '';const d=new Date(v);if(isNaN(d))return esc(String(v));
return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});}
function fmtDateTime(v){if(!v)return '';const d=new Date(v);if(isNaN(d))return esc(String(v));
return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
function dateRow(c){
const iso=v=>{if(!v)return '';const d=new Date(v);return isNaN(d)?String(v):d.toISOString();};
const issue=c.issue_date?`<span class="i" title="License issued ${esc(iso(c.issue_date))}">🗓️ issued <b>${fmtDate(c.issue_date)}</b></span>`:'';
const exp=(()=>{if(!c.expire_date)return '';const d=new Date(c.expire_date);const past=(!isNaN(d)&&d.getTime()<Date.now());
return `<span class="i${past?' expwarn':''}" title="License expires ${esc(iso(c.expire_date))}">⏳ ${past?'expired':'expires'} <b>${fmtDate(c.expire_date)}</b></span>`;})();
return (issue||exp)?`<div class="dl datesrow">${issue}${exp}</div>`:'';
}
// Freshness / compliance line — the CSLB snapshot date this row came from, with the standing "verify" nudge.
function freshLine(c){
const asof=c.source_as_of||c.cslb_last_update;
const upd=c.updated_at?` · row updated ${fmtDateTime(c.updated_at)}`:'';
return `<div class="fresh" title="Contractor license data is a periodic CSLB snapshot — always re-verify current status before contracting.">🕓 CSLB data as of <b>${asof?fmtDate(asof):'—'}</b> — <a href="https://www.cslb.ca.gov/" target="_blank" rel="noopener noreferrer">verify current status at cslb.ca.gov ↗</a>${upd}</div>`;
}
function classTags(c){
const prim=c.primary_class||''; const all=Array.isArray(c.classifications)?c.classifications:(c.classifications?String(c.classifications).split(/[,\s]+/):[]);
const titles=c.classification_titles||{};
const seen=new Set();
const tags=[];
const push=(cl)=>{cl=String(cl||'').trim();if(!cl||seen.has(cl))return;seen.add(cl);
const t=titles&&titles[cl]?` — ${titles[cl]}`:'';
tags.push(`<a class="classtag${cl===prim?' prim':''}" href="?class=${encodeURIComponent(cl)}" title="See all Class ${esc(cl)}${esc(t)}" data-drillf="class" data-drillv="${esc(cl)}">${esc(cl)}${cl===prim?' ★':''}</a>`);};
if(prim)push(prim); all.forEach(push);
return tags.length?`<div class="classrow">${tags.join('')}</div>`:'';
}
// Steve's hard rule: ALWAYS show phone AND email, both clickable, on every card.
// CSLB public license data carries phone but NO email address, so email is future-proofed:
// render a mailto: whenever the row (or a joined broker record) supplies one, else an honest
// "not on the CSLB record" note — styled like phone, never fabricated.
function emailOf(c){
const cand=[c.email,c.contact_email,c.business_email,c.owner_email,c.e_mail,c.mail];
for(const v of cand){if(v&&/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(v).trim()))return String(v).trim();}
return '';
}
function card(c){
const county=c.county||''; const city=c.city||'';
const phone=c.phone?`<span class="i">☎ <a class="ddrill" href="tel:${esc(c.phone)}"><b>${esc(c.phone)}</b></a></span>`
:`<span class="i" style="color:var(--mut)" title="No phone on the CSLB record">☎ <span>—</span></span>`;
const emailV=emailOf(c);
const email=emailV?`<span class="i">✉️ <a class="ddrill" href="mailto:${esc(emailV)}"><b>${esc(emailV)}</b></a></span>`
:`<span class="i" style="color:var(--mut)" title="Email is not part of the public CSLB license record — verify contact at cslb.ca.gov">✉️ <span>not on CSLB record</span></span>`;
const cityD=city?`<span class="i" data-drillf="city" data-drillv="${esc(city)}">📍 <a class="ddrill" href="?city=${encodeURIComponent(city)}" title="All contractors in ${esc(city)}">${esc(city)}</a></span>`:'';
const countyD=county?`<span class="i" data-drillf="county" data-drillv="${esc(county)}">🗺️ <a class="ddrill" href="?county=${encodeURIComponent(county)}" title="All contractors in ${esc(county)} County">${esc(county)}</a></span>`:'';
const lic=c.license_no?`<span class="i">🪪 <a class="ddrill" href="https://www.cslb.ca.gov/OnlineServices/CheckLicenseII/LicenseDetail.aspx?LicNum=${encodeURIComponent(String(c.license_no).replace(/\D/g,''))}" target="_blank" rel="noopener noreferrer" title="Verify on CSLB ↗"><b>#${esc(c.license_no)}</b></a></span>`:'';
// Frank's angle: a construction-financing prospect chip that drills to Loan Officers / FHA products.
const fin=`<a class="finance" href="/loan-officers.html" title="Construction / bridge financing prospect — route to lending">💵 financing prospect</a>`;
return `<div class="ccard">
<div class="top"><div class="name">${esc(c.business_name||'—')}</div>${statusPill(c.license_status)}</div>
${classTags(c)}
<div class="dl">${lic}${phone}${email}${cityD}${countyD}</div>
${dateRow(c)}
<div style="margin-top:2px">${fin}</div>
${freshLine(c)}
<div class="provline">📋 CSLB registry · usre contractor API</div>
</div>`;
}
// ── BROWSE MODE — server-side filter via the proxy, client-side sort/search/density ──
function passLocal(c){
if(F.status.size&&!F.status.has(c.license_status||'—'))return false;
if(F.county.size&&!F.county.has(c.county||'—'))return false;
if(F.city.size&&!F.city.has(c.city||'—'))return false;
if(F.class.size){const all=new Set([c.primary_class,...(Array.isArray(c.classifications)?c.classifications:[])].filter(Boolean).map(String));
let hit=false;F.class.forEach(v=>{if(all.has(v))hit=true;});if(!hit)return false;}
if(q){const t=q.toLowerCase();return [c.business_name,c.city,c.county,c.license_no,c.primary_class].some(v=>v!=null&&String(v).toLowerCase().includes(t));}
return true;
}
function filteredSorted(){
// #2 data hygiene — drop junk/placeholder CSLB rows (a business name with no alphanumeric char, e.g. ".").
let rows=DATA.filter(passLocal).filter(c=>c.business_name && /[A-Za-z0-9]/.test(c.business_name));
rows.sort((a,b)=>{let x=a[sortKey],y=b[sortKey];const xm=(x==null||x===''),ym=(y==null||y==='');
if(xm&&ym)return 0;if(xm)return 1;if(ym)return -1;
if(DATE_KEYS.has(sortKey)){const dx=new Date(x).getTime(),dy=new Date(y).getTime();
x=isNaN(dx)?Infinity:dx;y=isNaN(dy)?Infinity:dy;}
else if(!isNaN(+x)&&!isNaN(+y)&&sortKey==='license_no'){x=+x;y=+y;}
else{x=String(x).toLowerCase();y=String(y).toLowerCase();}
return x<y?-1*sortDir:x>y?sortDir:0;});
return rows;
}
function chip(counts,attr,lim){return Object.entries(counts).sort((a,b)=>b[1]-a[1]).slice(0,lim||99)
.map(([k,c])=>`<span class="chip${F[attr].has(k)?' active':''}" data-${attr}="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');}
function buildRail(){
const sc={},cc={},ci={},cl={};
DATA.forEach(c=>{const s=c.license_status||'—';sc[s]=(sc[s]||0)+1;
const co=c.county||'—';cc[co]=(cc[co]||0)+1; const cy=c.city||'—';ci[cy]=(ci[cy]||0)+1;
[c.primary_class,...(Array.isArray(c.classifications)?c.classifications:[])].filter(Boolean).forEach(x=>{cl[x]=(cl[x]||0)+1;});});
$('#fStatus').innerHTML=chip(sc,'status',8)||'<span style="font-size:11px;color:var(--mut)">—</span>';
$('#fCounty').innerHTML=chip(cc,'county',12)||'<span style="font-size:11px;color:var(--mut)">—</span>';
$('#fCity').innerHTML=chip(ci,'city',14)||'<span style="font-size:11px;color:var(--mut)">—</span>';
$('#fClass').innerHTML=chip(cl,'class',20)||'<span style="font-size:11px;color:var(--mut)">—</span>';
}
let _ctrInf=null,_ctrRows=[];
// Render one window (slice) of contractors into #body for the current view.
function _ctrPaint(win){
if(view==='list'){
$('#body').innerHTML=`<div class="lst">`+win.map(c=>`<div class="lrow crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer"><div class="lnm">${esc(c.business_name||'—')}</div><div class="lmeta">`+CCOLS.slice(1).map(col=>`<span class="lm"><span class="lk">${esc(col.l)}</span>${cfmt(c,col)}</span>`).join('')+`</div></div>`).join('')+`</div>`;
} else if(view==='table'){
$('#body').innerHTML=`<div class="cttable"><table><thead><tr>`+CCOLS.map(col=>`<th data-k="${col.k}" style="cursor:pointer" title="Click to sort">${esc(col.l)}${sortKey===col.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+`</tr></thead><tbody>`+win.map(c=>`<tr class="crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer">`+CCOLS.map(col=>`<td>${cfmt(c,col)}</td>`).join('')+`</tr>`).join('')+`</tbody></table></div>`;
} else {
$('#body').innerHTML=`<div class="grid">${win.map(card).join('')}</div>`;
}
}
function renderBrowse(down,reason){
document.documentElement.style.setProperty('--cols',dens);
if(down){$('#body').innerHTML=emptyState(reason);$('#count').textContent='upstream not reachable';return;}
const rows=filteredSorted();
$('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()} shown`;
if(!rows.length){$('#body').innerHTML=emptyState('No licensed contractors match these filters.','Loosen the county / city / class filters, or clear the search.');return;}
// Windowed infinite scroll (was rows.slice(0,600)): reveal a batch, load more on scroll.
_ctrRows=rows;
if(!_ctrInf&&window.InfScroll) _ctrInf=InfScroll.attach({batch:120,getRows:()=>_ctrRows,getMount:()=>$('#body'),render:_ctrPaint});
if(_ctrInf) _ctrInf.reset(); else _ctrPaint(rows);
}
// Pull the registry through our own proxy (server injects CONTRACTORS_API_BASE). Server-side county/city
// let the upstream do the heavy filter; we sort/search/paginate client-side.
async function loadBrowse(){
// CSLB good-standing status value is 'CLEAR' (not 'Active') per the shared usre contractor API contract.
const u=new URLSearchParams();u.set('status','CLEAR');u.set('limit','1000');
if(F.county.size===1)u.set('county',[...F.county][0]);
if(F.city.size===1)u.set('city',[...F.city][0]);
if(F.class.size===1)u.set('class',[...F.class][0]);
let d;try{d=await api('/api/contractors?'+u.toString());}catch(e){renderBrowse(true,'CRCP could not reach the contractor API (network error).');return;}
if(d&&d.upstream_down){DATA=[];buildRail();renderBrowse(true,`The contractor API (<code>${esc(d.upstream||'')}</code>) is not up yet${d.reason?` — ${esc(d.reason)}`:''}.`);return;}
DATA=Array.isArray(d.results)?d.results:[];
buildRail();renderBrowse(false);
}
// ── MATCH MODE — deal criteria → class-bucketed matches for Frank's call list ──
const CTYPE_LABEL={office:'Office / tenant-improvement build-out',retail:'Retail / restaurant tenant improvement',multifamily:'Multifamily value-add renovation',industrial:'Industrial / warehouse',hospitality:'Hotel / hospitality',medical:'Medical / dental office','ground-up':'Ground-up construction'};
async function loadMatch(){
const county=$('#dCounty').value.trim(), city=$('#dCity').value.trim(), ctype=$('#dType').value;
const u=new URLSearchParams();u.set('mode','deal');
if(county)u.set('county',county);if(city)u.set('city',city);if(ctype)u.set('ctype',ctype);
$('#body').innerHTML='<div class="empty" style="opacity:.6">Matching contractors to the deal…</div>';
let d;try{d=await api('/api/contractors/match?'+u.toString());}catch(e){$('#body').innerHTML=emptyState('CRCP could not reach the contractor API (network error).');return;}
if(d&&d.upstream_down){$('#body').innerHTML=emptyState(`The contractor API (<code>${esc(d.upstream||'')}</code>) is not up yet${d.reason?` — ${esc(d.reason)}`:''}.`,'When it is running, the class-bucketed match list for this deal renders here — General Engineering (A), General Building (B), and the specialty trades (C-##) that fit the asset type.');return;}
const matches=(d&&d.matches&&typeof d.matches==='object')?d.matches:{};
const keys=Object.keys(matches);
document.documentElement.style.setProperty('--cols',dens);
const crit=[county&&`${esc(county)} County`,city&&esc(city),ctype&&CTYPE_LABEL[ctype]].filter(Boolean).join(' · ')||'all markets';
if(!keys.length){$('#body').innerHTML=emptyState(`No contractor matches for <b>${crit}</b>.`,'Try a broader county / drop the asset type, or the registry may not yet cover this market.');return;}
const CLASS_TITLE={A:'General Engineering — site, structural, heavy civil',B:'General Building — the prime for most TI + reno'};
let html=`<div style="font-size:12px;color:var(--mut);margin-bottom:14px">Contractors matched to <b style="color:#fff">${crit}</b> — bucketed by CSLB class. Start with <b style="color:var(--gold)">B (General Building)</b> for most build-outs; the C-## trades round out the job. Each is a construction-financing prospect.</div>`;
keys.forEach(cls=>{
const list=Array.isArray(matches[cls])?matches[cls]:[];
const title=CLASS_TITLE[cls]||(list[0]&&list[0].classification_titles&&list[0].classification_titles[cls])||'Specialty trade';
html+=`<div class="bucket"><h3><span class="cls">${esc(cls)}</span> ${esc(title)} <span style="font-size:11px;color:var(--mut);font-weight:400">(${list.length})</span></h3>
<div class="grid">${list.length?list.map(card).join(''):'<div class="empty" style="grid-column:1/-1">none in this class for this market</div>'}</div></div>`;
});
$('#body').innerHTML=html;
}
// ── mode switching ──
function setMode(m){
MODE=m;
$('#mBrowse').classList.toggle('on',m==='browse');
$('#mMatch').classList.toggle('on',m==='match');
$('#matchInputs').style.display=m==='match'?'flex':'none';
$('#browseTools').style.display=m==='browse'?'flex':'none';
$('.rail')&&(document.querySelector('.rail').style.display=(m==='browse')?'':'');
if(m==='browse'){renderBrowse(DATA.length?false:undefined);if(!DATA.length)loadBrowse();}
else{$('#body').innerHTML='<div class="empty">🎯 Enter the deal’s county / city / asset type above, then <b>Find contractors →</b>.</div>';}
}
$('#mBrowse').onclick=()=>setMode('browse');
$('#mMatch').onclick=()=>setMode('match');
$('#dGo').onclick=loadMatch;
['dCounty','dCity'].forEach(id=>$('#'+id).addEventListener('keydown',e=>{if(e.key==='Enter')loadMatch();}));
// ── browse toolbar wiring ──
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();renderBrowse(false);});
$('#sort').addEventListener('change',()=>{sortKey=$('#sort').value;sortDir=1;renderBrowse(false);});
// grid/list/table view toggle (browse mode)
document.querySelectorAll('#viewseg button').forEach(x=>x.classList.toggle('on',x.dataset.v===view));
$('#viewseg').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('#viewseg button').forEach(x=>x.classList.toggle('on',x===b));view=b.dataset.v;localStorage.setItem('conView',view);renderBrowse(false);});
// table header → sort any column; a table/list row → open the CSLB license record
document.addEventListener('click',e=>{
const th=e.target.closest('.cttable th'); if(th){const k=th.dataset.k;if(sortKey===k)sortDir*=-1;else{sortKey=k;sortDir=(k==='license_no')?-1:1;}const so=$('#sort');if(so&&Array.from(so.options).some(o=>o.value===k))so.value=k;renderBrowse(false);return;}
const row=e.target.closest('.crow'); if(row && !e.target.closest('a,button')){const lic=row.dataset.lic;if(lic)window.open('https://www.cslb.ca.gov/OnlineServices/CheckLicenseII/LicenseDetail.aspx?LicNum='+encodeURIComponent(lic),'_blank','noopener');}
});
$('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('cxDens',dens);renderBrowse(false);});
// rail chips: multi-select. County/city/class with exactly one selection re-query the upstream
// (server-side filter); otherwise filter the already-loaded set client-side.
function railClick(attr,el){el.addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;
tog(F[attr],c.dataset[attr]);c.classList.toggle('active');
// a single county/city/class selection is worth a fresh, upstream-scoped pull; else local filter.
if((attr==='county'||attr==='city'||attr==='class')&&F[attr].size<=1){loadBrowse();}
else renderBrowse(false);});}
railClick('status',$('#fStatus'));railClick('county',$('#fCounty'));railClick('city',$('#fCity'));railClick('class',$('#fClass'));
// card drill-links (city/county/class) — filter in place; also real shareable hrefs.
$('#body').addEventListener('click',e=>{
if(e.metaKey||e.ctrlKey||e.shiftKey||e.button===1)return;
const a=e.target.closest('a.ddrill[href^="tel"],a.ddrill[target="_blank"],a.f,a.finance,a.classtag[target]');
if(a&&a.getAttribute('target')==='_blank')return; // external verify links navigate
const d=e.target.closest('[data-drillf]');
if(d){const f=d.dataset.drillf,v=d.dataset.drillv;if(F[f]!==undefined){e.preventDefault();
if(MODE!=='browse'){setMode('browse');}
if(!F[f].has(v)){tog(F[f],v);}
if((f==='county'||f==='city'||f==='class')&&F[f].size<=1)loadBrowse();else renderBrowse(false);}}
});
// URL deep-links: /contractors?county=&city=&class=&q= apply on load (shareable card drills).
(function initFromUrl(){const u=new URLSearchParams(location.search);let any=false;
['county','city','class','status'].forEach(k=>{u.getAll(k).forEach(v=>{F[k].add(v);any=true;});});
if(u.get('q')){q=u.get('q');$('#q').value=q;any=true;}
if(u.get('mode')==='match'){setMode('match');return;}
setMode('browse');
})();
</script>
<script src="/corner-nav.js" defer></script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
<script src="/filter-modal.js" defer></script><!-- responsive filters: collapsed rail (desktop) / modal (mobile) -->
<script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body></html>