← back to Commercialrealestate
public/broker-grid.html
520 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>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</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">
<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='%230c0f15'/%3E%3Ctext x='8' y='12' font-size='9' text-anchor='middle' fill='%23C8A24B'%3E%F0%9F%91%A5%3C/text%3E%3C/svg%3E">
<title>Brokers & Agents — Grid / List</title>
<style>
:root{ --bg:#0c0f15; --card:#141925; --line:#232a38; --ink:#E6E8EC; --mut:#8A93A2; --gold:#C8A24B; --acc:#70AD47; --blue:#58a6ff; --warn:#d29922; }
*{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}
header{display:flex;align-items:center;gap:12px;padding:11px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:40;background:rgba(12,15,21,.98);backdrop-filter:blur(8px);flex-wrap:wrap;}
header h1{font-size:16px;margin:0;} header h1 b{color:var(--gold);}
header .nav{display:flex;gap:12px;margin-left:6px;} header .nav a{color:var(--blue);text-decoration:none;font-size:13px;} header .nav a:hover{color:var(--gold);}
.spacer{flex:1;}
.search{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:8px 12px;font-size:14px;min-width:220px;}
.toggle{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;}
.toggle button{background:var(--card);border:0;color:var(--mut);padding:8px 13px;font-size:13px;cursor:pointer;}
.toggle button.on{background:var(--gold);color:#1a1205;font-weight:700;}
.csvbtn{background:var(--card);border:1px solid var(--line);color:var(--acc);border-radius:9px;padding:8px 12px;font-size:13px;cursor:pointer;} .csvbtn:hover{border-color:var(--acc);}
.count{color:var(--mut);font-size:12px;}
.wrap{padding:24px 28px 60px;}
.tblwrap{overflow:auto;max-height:calc(100vh - 130px);border:1px solid var(--line);border-radius:10px;}
table.g{border-collapse:separate;border-spacing:0;width:100%;font-size:12.5px;white-space:nowrap;}
table.g thead th{position:sticky;top:0;background:#1b2230;color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;padding:8px 10px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none;z-index:2;}
table.g thead th:hover{color:var(--ink);}
table.g thead th.asc::after{content:' ▲';color:var(--gold);} table.g thead th.desc::after{content:' ▼';color:var(--gold);}
/* draggable column reorder (Steve rule 2026-07-31: move columns around) */
table.g thead th.dragcol{cursor:grab;}
table.g thead th.dragging{opacity:.45;}
table.g thead th.dropL{box-shadow:inset 3px 0 0 var(--gold);}
table.g thead th.dropR{box-shadow:inset -3px 0 0 var(--gold);}
table.g tbody td{padding:7px 10px;border-bottom:1px solid #20262f;}
table.g tbody tr:hover td{background:rgba(200,162,75,.07);}
table.g td.n{text-align:right;font-variant-numeric:tabular-nums;}
table.g a{color:var(--blue);text-decoration:none;} table.g a:hover{text-decoration:underline;}
.atype{display:inline-block;font-size:10px;font-weight:700;text-transform:uppercase;padding:1px 6px;border-radius:8px;}
.atype.res{background:rgba(88,166,255,.16);color:var(--blue);} .atype.com{background:rgba(200,162,75,.16);color:var(--gold);}
.mut{color:var(--mut);} .miss{color:#5A6472;}
.findmail{background:transparent;border:1px solid #5B9BD5;color:#5B9BD5;border-radius:9px;padding:2px 8px;font-size:11px;cursor:pointer;white-space:nowrap;}
.findmail:hover{background:rgba(91,155,213,.12);}
.hunting{color:#E0A33E;font-size:12px;}
.geo{display:inline-block;font-size:10px;font-weight:700;padding:1px 7px;border-radius:8px;white-space:nowrap;}
.geo.ca{background:rgba(127,174,106,.18);color:#8fd06f;border:1px solid rgba(127,174,106,.5);}
.geo.non{background:rgba(248,81,73,.16);color:#f77;border:1px solid rgba(248,81,73,.5);}
.geo.unk{background:rgba(138,147,162,.14);color:var(--mut);border:1px solid var(--line);}
#ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
#ov.on{display:flex;}
#modal{width:min(700px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
#modal h3{margin:0 0 2px;font-size:20px;} #modal .mfirm{color:var(--gold);font-size:13px;}
#modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
#modal .ci{display:flex;gap:16px;flex-wrap:wrap;margin:10px 0;font-size:14px;} #modal .ci a{color:var(--blue);text-decoration:none;}
#modal .sec{margin-top:14px;} #modal .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
#modal .litem{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid #20262f;font-size:13px;} #modal .litem .m{color:var(--mut);}
.mbtn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 12px;font-size:13px;cursor:pointer;} .mbtn:hover{border-color:var(--blue);}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,280px),1fr));gap:20px;}
#sortsel{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px}
#denswrap{display:none;align-items:center;gap:6px;font-size:13px;color:var(--mut)}
#denswrap input[type=range]{width:92px;accent-color:var(--acc)}
.gc{background:var(--card);border:2px solid #fff;border-radius:11px;padding:18px 20px;}
.gc .nm{font-weight:700;} .gc .fm{color:var(--mut);font-size:12px;margin-bottom:6px;}
.gc .row{display:flex;justify-content:space-between;gap:8px;font-size:12.5px;padding:2px 0;border-top:1px solid #20262f;}
.gc .row .k{color:var(--mut);} .gc .ci{display:flex;gap:12px;margin-top:8px;flex-wrap:wrap;font-size:12.5px;}
.hide{display:none;}
.copybtn { background:none; border:0; color:var(--mut); font-size:14px; line-height:1; cursor:pointer; padding:0; }
.copybtn:hover { color:var(--blue); }
table.g td.actd, table.g th.acth { width:26px; text-align:center; padding:5px 4px; }
.statsbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:9px 22px; border-bottom:1px solid var(--line); }
.statsbar:empty { display:none; }
.statpill { font-size:12px; color:var(--ink); background:var(--card); border:1px solid var(--line); border-radius:8px; padding:4px 10px; }
.statpill .sl { color:var(--mut); }
/* ---- Amazon-style left rail ---- */
.shell{display:flex;align-items:flex-start;}
.rail{flex:0 0 260px;background:#0a0d13;border-right:1px solid var(--line);align-self:stretch;position:sticky;top:53px;height:calc(100vh - 53px);overflow-y:auto;padding:0 0 40px;}
.rail .rsec{padding:13px 16px;border-bottom:1px solid var(--line);}
.rail h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);}
.chips{display:flex;flex-wrap:wrap;gap:6px;}
.chip{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:16px;padding:4px 10px;font-size:12px;cursor:pointer;user-select:none;}
.chip:hover{color:var(--ink);} .chip.active{border-color:var(--gold);color:var(--gold);background:rgba(200,162,75,.10);}
.chip .ct{color:var(--mut);font-size:10px;margin-left:4px;}
.fieldtoggles{display:flex;flex-direction:column;gap:1px;max-height:320px;overflow-y:auto;}
.ftog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink);cursor:pointer;padding:3px 4px;border-radius:6px;} .ftog:hover{background:rgba(200,162,75,.08);} .ftog input{accent-color:var(--gold);}
.ftog .flbl{flex:1;}
.ftog .fdrag{cursor:grab;color:var(--mut);font-size:12px;opacity:.5;user-select:none;line-height:1;} .ftog:hover .fdrag{opacity:1;}
.ftog.fdragging{opacity:.45;}
.ftog.fdropT{box-shadow:inset 0 3px 0 var(--gold);} .ftog.fdropB{box-shadow:inset 0 -3px 0 var(--gold);}
.fgrp{font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);margin:9px 0 3px;font-weight:700;opacity:.85;} .fgrp:first-child{margin-top:0;}
.minibtn{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;margin-right:5px;} .minibtn:hover{border-color:var(--gold);color:var(--ink);}
.wrap{flex:1 1 auto;min-width:0;}
@media(max-width:820px){ .rail{display:none;} }
.rail .rsec > h4 { cursor:pointer; display:flex; align-items:center; justify-content:space-between; user-select:none; }
.rail .rsec > h4::after { content:'▾'; color:var(--mut); font-size:11px; margin-left:8px; transition:transform .15s; }
.rail .rsec.collapsed > h4::after { transform:rotate(-90deg); }
.rail .rsec.collapsed > *:not(h4) { display:none !important; }
</style>
<link rel="stylesheet" href="/three-view.css">
<script src="/three-view.js"></script>
<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
<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 -->
</head>
<body>
<header>
<h1><b>👥 Brokers</b> & Agents</h1>
<div class="nav"><a href="/crcp.html">← CRCP</a><a href="/">Explorer</a><a href="/mls.html">📋 Listings</a><a href="/gov-agents.html">🏛️ Gov-Licensed</a><a href="/linkedin.html">🔗 LinkedIn</a><a href="/brokers.html">🕸️ Mindmap</a></div>
<span class="spacer"></span>
<input class="search" id="q" type="text" placeholder="search all fields — name, firm, phone, email, license, source… (space = AND)">
<div class="toggle tv-seg" id="view"><button data-v="grid">▦ Grid</button><button data-v="list">☰ List</button><button data-v="table" class="on">▤ Table</button></div>
<select id="sortsel" title="Sort brokers"></select>
<label id="denswrap" title="Card density — grid view">⤢<input type="range" id="density" min="1" max="10" step="1"></label>
<button class="csvbtn" id="csv" title="Export current rows to CSV">⬇ CSV</button>
<span class="count" id="count">…</span>
</header>
<div class="shell">
<aside class="rail">
<div class="rsec" id="fieldsSec"><h4>Fields / columns — show / hide · reorder</h4>
<div class="fieldtoggles" id="fFields"></div>
<div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fReset" title="Reset columns to the tight default — order, widths & visibility">↺ Reset</button></div>
<div class="mut" style="font-size:10.5px;margin-top:7px;line-height:1.35">Drag a row by its ⠿ handle to reorder columns. In List view you can also drag a column header to reorder · its right edge to resize · click to sort.</div>
</div>
<div class="rsec"><h4>Agent type</h4><div class="chips" id="fType"></div></div>
<div class="rsec"><h4>Company / firm</h4><div class="chips" id="fFirm"></div></div>
</aside>
<div class="wrap">
<div class="statsbar" id="statsbar"></div>
<div id="tableView" class="tblwrap"><table class="g" data-no-sort><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<div id="gridView" class="grid hide"></div>
<div id="listView" class="hide"></div>
</div>
</div>
<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
const num=n=>(n==null||n==='')?'—':Number(n).toLocaleString();
const tel=v=>v?`<a href="tel:${esc(v)}">${esc(v)}</a>`:'<span class="miss">—</span>';
const mail=v=>v?`<a href="mailto:${esc(v)}">${esc(v)}</a>`:'<span class="miss">—</span>';
const url=(v,lbl)=>{ if(!v) return '<span class="miss">—</span>'; const h=/^https?:/.test(v)?v:'https://'+v; return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer">${lbl}</a>`; };
// Website links show the SITE NAME (domain), not a bare "site↗" — Steve 2026-07-13.
const siteName=u=>{ try{ return new URL(/^https?:/.test(u)?u:'https://'+u).hostname.replace(/^www\./,''); }catch(e){ return u; } };
const web=v=>{ if(!v) return '<span class="miss">—</span>'; const h=/^https?:/.test(v)?v:'https://'+v; return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer" title="${esc(h)}">${esc(siteName(v))}↗</a>`; };
// On-demand email hunt (the "✉ find email" button) — POSTs /api/broker/find-email, which visits
// the broker's site and extracts an email with the batch finder's guards. hunt[] keeps per-row
// state across re-renders; a success also writes the row in DATA so every view picks it up.
const hunt={};
function mailCell(r){
if(r.email) return mail(r.email);
const h=hunt[r.id];
if(h){
if(h.state==='hunting') return '<span class="hunting">⏳ hunting…</span>';
if(h.email) return mail(h.email);
return `<span class="miss" title="${esc(h.miss||'')}">not found</span>`;
}
if(r.website) return `<button class="findmail" data-id="${r.id}" title="Visit ${esc(siteName(r.website))} and hunt down an email address">✉ find email</button>`;
return '<span class="miss">—</span>';
}
async function findEmail(id){
const r=DATA.find(x=>+x.id===+id); if(!r) return;
hunt[id]={state:'hunting'}; render();
try{
const d=await (await fetch('/api/broker/find-email',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:+id,name:r.name,website:r.website})})).json();
if(d.email){ hunt[id]={email:d.email}; r.email=d.email; }
else hunt[id]={miss:d.reason||d.error||'not found'};
}catch(e){ hunt[id]={miss:e.message}; }
render();
}
// 'Added' (created_at) is an admin-card timestamp → date AND time, full ISO in title=
// (Steve HARD rule). created_at is the only t:'date' column, so this formatter is safe to
// upgrade wholesale; pure DATE fields (sold_date) use fdate() and stay date-only.
const dt=s=>{ if(!s) return '—'; try{ const d=new Date(s); if(isNaN(d)) return '—'; const t=d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); return '<span title="'+esc(d.toISOString())+'">'+esc(t)+'</span>'; }catch(e){ return '—'; } };
// Every field carries a group `g` (for the sort menu optgroups + organized rail toggles).
// Order below IS the column order. Every field is sortable (raw-value sort, any type).
const COLS=[
// ── Identity ──
{k:'name',l:'Name',t:'s',g:'Identity'},
{k:'firm',l:'Company / Firm',t:'s',g:'Identity'},
{k:'agent_type',l:'Type',t:'atype',g:'Identity'},
{k:'title',l:'Title',t:'s',g:'Identity'},
// ── Geography ── (added by the CA-DRE cross-ref work)
{k:'state',l:'State',t:'geo',g:'Geography'},
{k:'dre_match',l:'CA Confidence',t:'geo',g:'Geography'},
{k:'license',l:'DRE / MLS #',t:'s',g:'Geography'},
{k:'dre_license',l:'DRE License #',t:'s',g:'Geography',def:0},
// ── Activity ──
{k:'listings',l:'Listings',t:'n',g:'Activity'},
{k:'total_assets',l:'Total Assets',t:'n',g:'Activity'},
// ── Contact ──
{k:'phone',l:'Phone',t:'tel',g:'Contact'},
{k:'email',l:'Email',t:'mail',g:'Contact'},
{k:'website',l:'Website',t:'web',g:'Contact'},
{k:'linkedin',l:'LinkedIn',t:'li',g:'Contact'},
// ── Details ──
{k:'specialties',l:'Specialties',t:'s',g:'Details'},
{k:'office_addr',l:'Office',t:'s',g:'Details'},
{k:'source',l:'Source',t:'s',g:'Details'},
{k:'created_at',l:'Added',t:'date',g:'Details'},
];
let DATA=[], view='table', sortKey='listings', sortDir=-1, q='', lastColSig='';
const F={types:new Set(),firms:new Set()};
// START TIGHT (Steve rule 2026-07-31): only the essentials show on first load; the
// user toggles the rest on from the rail. A saved pref in VISCOL always wins.
const DEFAULT_ON=new Set(['name','firm','agent_type','title','state','listings']);
let VISCOL=(function(){let s={};try{s=JSON.parse(localStorage.getItem('brokerCols')||'{}');}catch(e){}return s;})();
function colVis(k){ if(k in VISCOL) return !!VISCOL[k]; return DEFAULT_ON.has(k); }
// Draggable column ORDER (Steve rule: move columns around). Keys in display order;
// auto-discovered/new keys are appended, stale keys pruned. Persisted per browser.
let COLORDER=(function(){let a=[];try{a=JSON.parse(localStorage.getItem('brokerColOrder')||'[]');}catch(e){}return Array.isArray(a)?a:[];})();
function syncColOrder(){
const keys=COLS.map(c=>c.k);
COLORDER=COLORDER.filter(k=>keys.includes(k)); // prune removed
keys.forEach(k=>{ if(!COLORDER.includes(k)) COLORDER.push(k); }); // append new
}
function orderedCols(){ syncColOrder(); return COLORDER.map(k=>COLS.find(c=>c.k===k)).filter(Boolean); }
function visCols(){ return orderedCols().filter(c=>colVis(c.k)); }
function saveCols(){ try{localStorage.setItem('brokerCols',JSON.stringify(VISCOL));}catch(e){} }
function saveColOrder(){ try{localStorage.setItem('brokerColOrder',JSON.stringify(COLORDER));}catch(e){} }
// When the visible column SET or ORDER changes, hand the table back to col-resize.js
// so it rebuilds its <colgroup> + drag handles for the current columns.
function reinitResize(){
const tbl=document.querySelector('#tableView table.g'); if(!tbl) return;
const cg=tbl.querySelector('colgroup[data-cr]'); if(cg) cg.remove();
delete tbl.dataset.crDone; tbl.style.tableLayout=''; tbl.style.width='';
if(window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
}
function tog(set,v){ set.has(v)?set.delete(v):set.add(v); }
function autoCols(){
const known=new Set(COLS.map(c=>c.k)); const skip=new Set(['id']);
const seen=new Set();
DATA.slice(0,60).forEach(r=>Object.keys(r||{}).forEach(k=>{
if(known.has(k)||skip.has(k)||seen.has(k)) return; const v=r[k]; if(v==null) return;
if(typeof v==='object') return; seen.add(k);
const isNum = typeof v==='number' || (v!==''&&!isNaN(+v));
COLS.push({k, l:k.replace(/_/g,' ').replace(/\b\w/g,ch=>ch.toUpperCase()).slice(0,20), t:isNum?'n':'s', g:'Other', def:0});
}));
}
function buildRail(){
const tc={},fc={};
DATA.forEach(r=>{ if(r.agent_type)tc[r.agent_type]=(tc[r.agent_type]||0)+1; if(r.firm)fc[r.firm]=(fc[r.firm]||0)+1; });
const lbl={residential:'Residential',commercial:'Commercial'};
$('#fType').innerHTML=Object.entries(tc).sort((a,b)=>b[1]-a[1]).map(([k,c])=>`<span class="chip" data-ty="${esc(k)}">${esc(lbl[k]||k)}<span class="ct">${c}</span></span>`).join('');
$('#fFirm').innerHTML=Object.entries(fc).sort((a,b)=>b[1]-a[1]).slice(0,16).map(([k,c])=>`<span class="chip" data-fm="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');
buildFieldToggles();
}
function buildFieldToggles(){
// Emit rows in current COLORDER, grouped by g for the section headers.
// Each row is draggable via the ⠿ handle (FIX B); data-fk on the label drives
// the panel drag listeners; data-ck on the checkbox is kept for the change listener.
const ordered=orderedCols();
// build group → ordered cols mapping (preserving COLORDER within each group)
const groupsSeen=[], groupMap={};
ordered.forEach(c=>{ const g=c.g||'Other'; if(!groupMap[g]){ groupMap[g]=[]; groupsSeen.push(g); } groupMap[g].push(c); });
$('#fFields').innerHTML=groupsSeen.map(g=>
`<div class="fgrp">${esc(g)}</div>`+groupMap[g].map(c=>`<label class="ftog" draggable="true" data-fk="${c.k}"><span class="fdrag" title="Drag to reorder column">⠿</span><input type="checkbox" data-ck="${c.k}" ${colVis(c.k)?'checked':''}><span class="flbl">${esc(c.l)}</span></label>`).join('')
).join('');
}
function passFacets(r){
if(F.types.size && !F.types.has(r.agent_type)) return false;
if(F.firms.size && !F.firms.has(r.firm)) return false;
return true;
}
function cell(r,c){
const v=r[c.k];
if(c.k==='name') return esc(v==null?'':v);
if(c.t==='n') return num(v);
if(c.t==='tel') return tel(v);
if(c.t==='mail') return mailCell(r);
if(c.t==='web') return web(v);
if(c.t==='li') return url(v,'in↗');
if(c.t==='date') return dt(v);
if(c.t==='atype') return v==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
if(c.t==='geo'){
if(c.k==='state'){
if(v==='CA') return '<span class="geo ca">CA ✓</span>';
if(v==null||v==='') return '<span class="geo unk">unknown</span>';
return '<span class="geo non">'+esc(v)+'</span>';
}
// dre_match / CA-confidence provenance
if(v==null||v==='') return '<span class="miss">—</span>';
const map={verified:'ca',probable:'ca','probable-fuzzy':'ca','ca-by-city':'ca','ca-by-listing-activity':'ca','address-derived':'ca',unmatched:'unk',error:'non'};
return '<span class="geo '+(map[v]||'unk')+'">'+esc(v)+'</span>';
}
return esc(v==null||v===''?'—':v);
}
function filtered(){
let rows=DATA.filter(passFacets);
if(q){
// Search ALL data (Steve rule 2026-07-31): every field the API returned on a row —
// not just the defined/visible columns — incl. nested objects. Space = AND.
const terms=q.toLowerCase().split(/\s+/).filter(Boolean);
rows=rows.filter(r=>{
const hay=Object.keys(r).filter(k=>k!=='__hay').map(k=>{const v=r[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join(' ').toLowerCase();
return terms.every(t=>hay.includes(t));
});
}
const c=COLS.find(x=>x.k===sortKey)||COLS[0];
rows=rows.slice().sort((a,b)=>{
let x=a[c.k], y=b[c.k];
// empty/null values always sink to the bottom, regardless of sort direction, so
// choosing a sparse column (e.g. Company / Firm) never front-loads a wall of "—"
const xE=(x==null||x===''), yE=(y==null||y==='');
if(xE&&yE) return 0; if(xE) return 1; if(yE) return -1;
const xn=!isNaN(+x), yn=!isNaN(+y);
if(xn&&yn){ x=+x; y=+y; } else { x=String(x).toLowerCase(); y=String(y).toLowerCase(); }
if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
});
return rows;
}
let _bgInf=null,_bgRows=[],_bgCols=[];
// Render one window (slice) of brokers into the active view container.
function _bgPaint(win){ const cols=_bgCols;
if(view==='table'){
$('#tbody').innerHTML=win.map(r=>`<tr class="brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">`+cols.map(c=>{const cls=(c.t==='n')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
const csig=cols.map(c=>c.k).join(',');
// Column SET/ORDER changed → full colgroup rebuild. Same columns (a sort / search / grow
// re-render) → just re-wire the resize handles the innerHTML rebuild wiped (TK-10088).
if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
else if(window.ColResize && window.ColResize.reattach){ window.ColResize.reattach(document.querySelector('#tableView table.g')); }
} else if(view==='list'){
const metaCols=cols.filter(c=>c.k!=='name');
$('#listView').innerHTML='<div class="tv-rows">'+win.map(r=>`<div class="tv-lrow brow" data-id="${r.id}" data-name="${esc(r.name)}">`
+`<div class="tv-lmain"><span class="tv-la">${esc(r.name)}</span><span class="tv-lsub">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</span>${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>`
+`<div class="tv-lmeta">`+metaCols.map(c=>`<span class="tv-lm"><span class="tv-lmk">${esc(c.l)}</span>${cell(r,c)}</span>`).join('')+`</div></div>`).join('')+'</div>';
} else {
const skipGrid=new Set(['name','firm','agent_type','phone','email','website','linkedin','title']);
const bodyCols=cols.filter(c=>!skipGrid.has(c.k));
$('#gridView').innerHTML=win.map(r=>`<div class="gc brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">
<div class="nm">${esc(r.name)} ${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>
<div class="fm">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</div>`
+bodyCols.map(c=>`<div class="row"><span class="k">${esc(c.l)}</span><span>${cell(r,c)}</span></div>`).join('')
+`<div class="ci">${tel(r.phone)} ${mailCell(r)} ${web(r.website)} ${r.linkedin?url(r.linkedin,'in↗'):''}</div>
</div>`).join('');
}
}
function render(){
const rows=filtered(); const cols=visCols();
$('#count').textContent=`${rows.length} of ${DATA.length} brokers/agents`;
$('#thead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc':' desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
const activeSel=view==='table'?'#tableView':view==='list'?'#listView':'#gridView';
['#tableView','#gridView','#listView'].forEach(s=>$(s).classList.toggle('hide',s!==activeSel));
// Windowed infinite scroll (was rendering ALL ~2,500 brokers at once): reveal a batch,
// load more on scroll. reset() repaints the active view from the top on filter/sort/view change.
_bgRows=rows; _bgCols=cols;
if(!_bgInf&&window.InfScroll) _bgInf=InfScroll.attach({batch:120,getRows:()=>_bgRows,getMount:()=>$(view==='table'?'#tableView':view==='list'?'#listView':'#gridView'),render:_bgPaint});
if(_bgInf) _bgInf.reset(); else _bgPaint(rows);
}
const money=n=>(n==null||n==='')?'—':'$'+Number(n).toLocaleString();
const fdate=s=>{ if(!s) return ''; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return ''; } };
// Date+TIME — only meaningful on our genuine capture timestamps (listed_at). Public-record
// sale/deed dates are date-only (no time-of-day exists in the source), so those keep fdate.
const fdt=s=>{ if(!s) return ''; try{ const d=new Date(s); const hasTime=/T\d\d:\d\d/.test(String(s))&&!/T0[0-7]:00:00/.test(String(s)); return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})+(hasTime?', '+d.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}):''); }catch(e){ return ''; } };
const atb=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
// Fire-and-forget modal-open telemetry (DTD-C rider — demand signal for the parked paid-data track).
function track(ev,o){ try{ fetch('/api/track',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({ev},o)),keepalive:true}).catch(()=>{}); }catch(e){} }
async function openContact(name,id){
const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="mut">loading '+esc(name)+'…</div>';
const qs=id?'id='+encodeURIComponent(id):'name='+encodeURIComponent(name);
let d; try{ d=await (await fetch('/api/broker?'+qs)).json(); }catch(e){ mb.innerHTML='<div class="mut">error</div>'; return; }
if(d.error){ mb.innerHTML='<div class="mut">'+esc(d.error)+'</div>'; return; }
track('broker_modal',{kind:'broker',id:d.broker&&d.broker.id,name:d.broker&&d.broker.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
const li=b.linkedin?(/^https?:/.test(b.linkedin)?b.linkedin:'https://'+b.linkedin):null;
mb.innerHTML='<h3>'+esc(b.name)+atb(b.agent_type)+'</h3><div class="mfirm">'+esc(b.firm||'—')+(b.title?' · '+esc(b.title):'')+(function(){
// Prefer the VERIFIED dre_license column (clean, cross-referenced) over regex-matching the
// raw scraped license (Cody gate) — so the DRE# always hooks the correct full CA DRE record.
const dl=b.dre_license?String(b.dre_license).trim():'', L=b.license?String(b.license).trim():'';
const dre=(dl&&/^\d{7,8}$/.test(dl))?dl:(/^\d{8}$/.test(L)?L:'');
if(dre) return ' · DRE# <a href="https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id='+encodeURIComponent(dre)+'" target="_blank" rel="noopener noreferrer" title="Full CA DRE license record ↗">'+esc(dre)+' ↗</a>';
return L?' · DRE/MLS '+esc(L):'';
})()+'</div>'
+'<div class="ci">'
+(b.phone?'<a href="tel:'+esc(b.phone)+'">☎ '+esc(b.phone)+'</a>':'<span class="mut">☎ —</span>')
+(b.email?'<a href="mailto:'+esc(b.email)+'">✉ '+esc(b.email)+'</a>':'<span class="mut">✉ —</span>')
+(w?'<a href="'+esc(w)+'" target="_blank" rel="noopener noreferrer">🌐 '+esc(w.replace(/^https?:\/\/(www\.)?/,'').replace(/\/$/,''))+'</a>':'<span class="mut">🌐 —</span>')
+(li?'<a href="'+esc(li)+'" target="_blank" rel="noopener noreferrer">in↗</a>':'')+'</div>'
+(b.id!=null?'<div style="margin:8px 0"><a href="/broker.html?id='+encodeURIComponent(b.id)+'" style="color:var(--gold,#d29922);font-weight:600">View full profile →</a></div>':'')
+'<div class="sec"><h4>Active / listed ('+d.current.length+')</h4>'+(d.current.length?d.current.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+(l.zip?' '+esc(l.zip):'')+'</span><span class="m">'+esc(l.type)+' · '+money(l.price)+(l.units?' · '+l.units+'u':'')+(l.cap_rate?' · '+l.cap_rate+'% cap':'')+(l.listed_at?' · listed '+fdt(l.listed_at):'')+'</span></div>').join(''):'<div class="mut">none in our set</div>')+'</div>'
// DTD-C rider: render Closed/Withdrawn/area-sold ONLY when populated; collapse the gaps into one coverage line.
+((d.closed&&d.closed.length)?'<div class="sec"><h4>Closed ('+d.closed.length+')</h4>'+d.closed.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+'</span><span class="m">'+money(l.sold_price)+(l.sold_date?' · '+fdate(l.sold_date):'')+(l.type?' · '+esc(l.type):'')+'</span></div>').join('')+'</div>':'')
+((d.expired&&d.expired.length)?'<div class="sec"><h4>Withdrawn / expired / off-market ('+d.expired.length+')</h4>'+d.expired.map(x=>'<div class="litem"><span>'+esc(x.address)+', '+esc(x.city)+'</span><span class="m">'+money(x.list_price)+' → sold '+money(x.sold_price)+' · '+fdate(x.sold_date)+(x.mls?' · MLS '+esc(x.mls):'')+(x.url?' · <a href="'+esc(x.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join('')+'</div>':'')
+((d.comps&&d.comps.length)?'<div class="sec"><h4>Sales — area sold ('+d.comps.length+')</h4>'+d.comps.map(c=>'<div class="litem"><span>'+esc(c.address)+', '+esc(c.city)+(c.beds?' · '+c.beds+'bd':'')+(c.baths?'/'+c.baths+'ba':'')+(c.sqft?' · '+Number(c.sqft).toLocaleString()+'sf':'')+'</span><span class="m">'+money(c.sold_price)+' · '+fdate(c.sold_date)+(c.mls?' · MLS '+esc(c.mls):'')+(c.url?' · <a href="'+esc(c.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join('')+'</div>':'')
+((!(d.closed&&d.closed.length)||!(d.expired&&d.expired.length))?'<div class="mut" style="margin-top:6px;opacity:.85">📊 Coverage: active listings only'+(d.current.length?'':' — none attributed here yet')+'. '+[!(d.closed&&d.closed.length)?'closed':'',!(d.expired&&d.expired.length)?'withdrawn/expired':''].filter(Boolean).join(' & ')+' history isn’t in free public records (needs a paid records source).</div>':'');
}
document.addEventListener('click',e=>{ const fm=e.target.closest('.findmail'); if(fm){ findEmail(fm.dataset.id); return; }
const nm=e.target.closest('.nm,.tv-la'); const nrow=nm&&nm.closest('.brow'); if(nrow){ location.href='/agent.html?name='+encodeURIComponent(nrow.dataset.name); return; } // agent NAME -> our own profile page (Steve 2026-08-19: never crexi)
const row=e.target.closest('.brow'); if(row && e.target.tagName!=='A' && e.target.tagName!=='BUTTON'){ openContact(row.dataset.name,row.dataset.id); } if(e.target.id==='ov') $('#ov').classList.remove('on'); });
/* make agent names look clickable */
(function(){var s=document.createElement('style');s.textContent='.nm,.tv-la{cursor:pointer}.nm:hover,.tv-la:hover{text-decoration:underline;color:var(--blue)}';document.head.appendChild(s);})();
$('#thead').addEventListener('click',e=>{
// A click on the resize handle (or the tail of a resize drag) must NOT sort —
// otherwise grabbing/double-clicking the handle re-renders the thead mid-gesture,
// detaching the cell col-resize is measuring (collapsed autofit) and firing a
// spurious sort on every resize. col-resize owns the handle; the app owns sort.
if(e.target.closest('.cr-handle'))return;
if(document.body.classList.contains('cr-dragging'))return;
const th=e.target.closest('th');if(!th)return;const k=th.dataset.k;
if(sortKey===k)sortDir*=-1;else{sortKey=k;sortDir=(k==='listings'||k==='total_assets'||k==='created_at')?-1:1;}
localStorage.setItem('brokSortKey',sortKey);localStorage.setItem('brokSortDir',sortDir);if(window.__syncSortSel)window.__syncSortSel();render();});
// ── drag-to-reorder columns (Steve rule 2026-07-31: move columns around) ──
let dragKey=null;
$('#thead').addEventListener('dragstart',e=>{ const th=e.target.closest('th'); if(!th) return;
// A grab on the resize handle must NOT start a column-move. On dragstart the target is the
// draggable <th>, never the handle span, so closest('.cr-handle') can't catch it — instead
// detect the resize gesture by the body.cr-dragging class col-resize adds on pointerdown
// (which fires before dragstart). Without this, grabbing the edge starts a native reorder
// drag that suppresses pointermove and the column silently won't resize (TK-10089).
if(document.body.classList.contains('cr-dragging') || e.target.closest('.cr-handle')){ e.preventDefault(); return; }
dragKey=th.dataset.k; e.dataTransfer.effectAllowed='move'; try{e.dataTransfer.setData('text/plain',dragKey);}catch(_){}
th.classList.add('dragging'); });
$('#thead').addEventListener('dragover',e=>{ if(!dragKey) return; const th=e.target.closest('th'); if(!th||th.dataset.k===dragKey) return;
e.preventDefault(); e.dataTransfer.dropEffect='move';
const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
th.classList.toggle('dropR',after); th.classList.toggle('dropL',!after); });
$('#thead').addEventListener('dragleave',e=>{ const th=e.target.closest('th'); if(th) th.classList.remove('dropL','dropR'); });
$('#thead').addEventListener('drop',e=>{ if(!dragKey) return; const th=e.target.closest('th'); if(!th) return; e.preventDefault();
const tgt=th.dataset.k; if(tgt!==dragKey){
const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
syncColOrder(); const from=COLORDER.indexOf(dragKey); if(from>=0){
COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,dragKey);
saveColOrder(); lastColSig=''; render();
}
}
dragKey=null; });
$('#thead').addEventListener('dragend',()=>{ document.querySelectorAll('#thead th.dragging,#thead th.dropL,#thead th.dropR').forEach(x=>x.classList.remove('dragging','dropL','dropR')); dragKey=null; });
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
// deep-link ?q= (from the global nav search) — pre-fill + filter on load
(function(){ const _q=new URLSearchParams(location.search).get('q'); if(_q){ const b=$('#q'); if(b){ b.value=_q; q=_q.trim(); } } })();
function exportCSV(){ const rows=filtered(); const cols=visCols(); const cv=(r,c)=>{let v=r[c.k]; v=(v==null?'':String(v)); return '"'+v.replace(/"/g,'""')+'"';};
const csv=cols.map(c=>'"'+c.l+'"').join(',')+'\n'+rows.map(r=>cols.map(c=>cv(r,c)).join(',')).join('\n');
const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download='cre-brokers-'+rows.length+'.csv'; a.click(); URL.revokeObjectURL(a.href); }
$('#csv').addEventListener('click',exportCSV);
$('#view').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x===b));view=b.dataset.v;localStorage.setItem('brokView',view);$('#denswrap').style.display=(view==='grid')?'inline-flex':'none';render();});
// v2 3-view migration (TK-10526): the old 'table' button was labeled "List". Model is
// now grid/list/table — no key remap needed (stored value 'table' still valid).
// ── sort <select> + density slider (Steve rule: every grid gets sort + density) ──
(function(){
const sel=$('#sortsel'),dz=$('#density');
// Grouped sort menu — EVERY field is sortable, organized under its category optgroup.
// Rebuildable so auto-discovered columns join the menu once data loads.
window.__buildSortSel=()=>{
const groups={};
COLS.forEach(c=>{ (groups[c.g||'Other']=groups[c.g||'Other']||[]).push(c); });
sel.innerHTML=Object.entries(groups).map(([g,cs])=>
`<optgroup label="${esc(g)}">`+cs.map(c=>`<option value="${c.k}">${esc(c.l)}</option>`).join('')+`</optgroup>`).join('');
if(Array.from(sel.options).some(o=>o.value===sortKey)) sel.value=sortKey;
};
window.__buildSortSel();
const sk=localStorage.getItem('brokSortKey'),sd=localStorage.getItem('brokSortDir');
if(sk&&COLS.some(c=>c.k===sk))sortKey=sk; if(sd==='1'||sd==='-1')sortDir=+sd;
if(!Array.from(sel.options).some(o=>o.value===sortKey))sortKey='listings';
sel.value=sortKey;
sel.addEventListener('change',()=>{sortKey=sel.value;const c=COLS.find(x=>x.k===sortKey);
sortDir=(c&&(c.t==='n'||c.t==='date'))?-1:1;
localStorage.setItem('brokSortKey',sortKey);localStorage.setItem('brokSortDir',sortDir);render();});
const widthFor=v=>Math.round(400-(Math.max(1,Math.min(10,v))-1)*(400-150)/9);
const applyDensity=v=>document.documentElement.style.setProperty('--card-min',widthFor(v)+'px');
const dv=parseInt(localStorage.getItem('brokDensity'),10),dval=(dv>=1&&dv<=10)?dv:5;
dz.value=dval;applyDensity(dval);
dz.addEventListener('input',()=>{const v=+dz.value;applyDensity(v);localStorage.setItem('brokDensity',v);});
const pv=localStorage.getItem('brokView'); if(pv==='grid'||pv==='table'||pv==='list'){view=pv;document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x.dataset.v===view));}
$('#denswrap').style.display=(view==='grid')?'inline-flex':'none';
window.__syncSortSel=()=>{if(Array.from(sel.options).some(o=>o.value===sortKey)&&sel.value!==sortKey)sel.value=sortKey;};
})();
// left-rail facets + column toggles
$('#fType').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.types,c.dataset.ty);c.classList.toggle('active');render();});
$('#fFirm').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.firms,c.dataset.fm);c.classList.toggle('active');render();});
$('#fFields').addEventListener('change',e=>{const cb=e.target.closest('input[data-ck]');if(!cb)return;VISCOL[cb.dataset.ck]=cb.checked;saveCols();render();});
// ── panel drag-to-reorder columns (FIX B: Steve rule — resort order via ⠿ handle) ──
let pDragKey=null;
$('#fFields').addEventListener('dragstart',e=>{ const row=e.target.closest('.ftog'); if(!row) return;
if(!e.target.closest('.fdrag')){ e.preventDefault(); return; } // only ⠿ handle starts a move
pDragKey=row.dataset.fk; e.dataTransfer.effectAllowed='move'; try{e.dataTransfer.setData('text/plain',pDragKey);}catch(_){}
row.classList.add('fdragging'); });
$('#fFields').addEventListener('dragover',e=>{ if(!pDragKey) return; const row=e.target.closest('.ftog'); if(!row||row.dataset.fk===pDragKey) return;
e.preventDefault(); e.dataTransfer.dropEffect='move';
const r=row.getBoundingClientRect(), after=(e.clientY-r.top)>r.height/2;
$('#fFields').querySelectorAll('.ftog').forEach(x=>x.classList.remove('fdropT','fdropB'));
row.classList.toggle('fdropB',after); row.classList.toggle('fdropT',!after); });
$('#fFields').addEventListener('dragleave',e=>{ const row=e.target.closest('.ftog'); if(row) row.classList.remove('fdropT','fdropB'); });
$('#fFields').addEventListener('drop',e=>{ if(!pDragKey) return; const row=e.target.closest('.ftog'); if(!row){ pDragKey=null; return; } e.preventDefault();
const tgt=row.dataset.fk;
if(tgt!==pDragKey){
const r=row.getBoundingClientRect(), after=(e.clientY-r.top)>r.height/2;
syncColOrder(); const from=COLORDER.indexOf(pDragKey);
if(from>=0){ COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,pDragKey);
saveColOrder(); lastColSig=''; buildFieldToggles(); render(); }
}
pDragKey=null; });
$('#fFields').addEventListener('dragend',()=>{ $('#fFields').querySelectorAll('.fdragging,.fdropT,.fdropB').forEach(x=>x.classList.remove('fdragging','fdropT','fdropB')); pDragKey=null; });
$('#fAll').addEventListener('click',()=>{COLS.forEach(c=>VISCOL[c.k]=true);saveCols();buildFieldToggles();render();});
$('#fNone').addEventListener('click',()=>{COLS.forEach(c=>VISCOL[c.k]=false);saveCols();buildFieldToggles();render();});
// Reset the whole table layout back to the tight default: visibility, order, widths.
$('#fReset').addEventListener('click',()=>{ VISCOL={}; saveCols(); COLORDER=[]; saveColOrder();
try{Object.keys(localStorage).forEach(k=>{ if(k.indexOf('cr:'+location.pathname+':')===0) localStorage.removeItem(k); });}catch(e){}
lastColSig=''; buildFieldToggles(); render(); });
// collapsible rail tabs — all collapsed on load except #fieldsSec (FIX A: fields panel stays expanded)
const railEl=document.querySelector('.rail');
if(railEl){ railEl.addEventListener('click',e=>{ const h=e.target.closest('h4'); if(h && h.parentElement.classList.contains('rsec')) h.parentElement.classList.toggle('collapsed'); });
document.querySelectorAll('.rail .rsec').forEach(s=>{ if(s.id!=='fieldsSec') s.classList.add('collapsed'); }); }
fetch('/api/brokers/all').then(r=>r.json()).then(d=>{DATA=d.brokers||[];autoCols();if(window.__buildSortSel)window.__buildSortSel();buildRail();
// deep-link: ?type=residential|commercial pre-selects the Agent-type facet (from the crcp Residential-agents tile)
const _tp=new URLSearchParams(location.search).get('type');
if(_tp){ F.types.add(_tp); document.querySelectorAll('#fType .chip').forEach(c=>{ if(c.dataset.ty===_tp) c.classList.add('active'); }); }
render();})
.catch(()=>{$('#count').textContent='failed to load /api/brokers/all';});
</script>
<script src="/col-resize.js" defer></script>
<script src="/global-search.js" defer></script>
<script src="/sort-bar.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
<script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body>
</html>