← back to Commercialrealestate
public/residential-brokers.html
248 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>
<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='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
<title>Residential Brokers — Lending Leads</title>
<style>
:root{--cols:4;--bg:#0a0d13;--panel:#121722;--ink:#e9edf5;--muted:#8b93a3;--line:#232a38;--gold:#c8a24b;--accent:#4a90d9}
*{box-sizing:border-box}
body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
header{padding:20px 28px 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,var(--bg2,#0d1119),var(--bg))}
h1{margin:0 0 4px;font-size:20px;font-weight:650;letter-spacing:.2px}
h1 span{color:var(--gold)}
.sub{color:var(--muted);font-size:12.5px;max-width:760px}
a.back{color:var(--accent);text-decoration:none;font-size:12.5px}
.bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:14px 28px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:5}
.bar input,.bar select{background:var(--card,#0c1017);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px}
.bar label{color:var(--muted);font-size:11.5px;display:flex;align-items:center;gap:6px}
.count{margin-left:auto;color:var(--muted);font-size:12px}
.toggles{display:flex;flex-wrap:wrap;gap:6px;padding:10px 28px;border-bottom:1px solid var(--line);background:var(--bg2,#0d1119)}
.toggles button{background:var(--card,#0c1017);color:var(--muted);border:1px solid var(--line);border-radius:20px;padding:4px 11px;font-size:11.5px;cursor:pointer}
.toggles button.on{color:var(--onacc,#0a0d13);background:var(--gold);border-color:var(--gold);font-weight:600}
main{padding:20px 28px 60px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:12px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 15px;min-width:0}
.nm{font-weight:600;font-size:14px;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fm{color:var(--gold);font-size:12px;margin-bottom:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.row{color:var(--muted);font-size:11.5px;margin:1px 0}
.row b{color:var(--ink);font-weight:500}
.pill{display:inline-block;font-size:10px;padding:1px 7px;border-radius:10px;background:var(--card,#1a2130);color:var(--muted);border:1px solid var(--line);margin-top:6px}
/* always-visible broker contact (Steve HARD RULE: every card shows phone + email) */
.contact{display:flex;flex-wrap:wrap;gap:4px 10px;margin-top:7px;padding-top:7px;border-top:1px solid var(--line);font-size:11.5px}
.contact a{color:var(--accent);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}
.contact a:hover{text-decoration:underline}
.contact .tier{font-size:9.5px;color:var(--muted);opacity:.8}
.contact .none{color:var(--muted)}
.empty{color:var(--muted);padding:40px;text-align:center}
/* ── 3-view segmented [Grid|List|Table] + all-field sort (mirrors just-listed.html) ── */
#viewseg{display:inline-flex}
#viewseg button{background:var(--card,#0c1017);color:var(--muted);border:1px solid var(--line);padding:6px 11px;font-size:12px;cursor:pointer}
#viewseg button:first-child{border-radius:8px 0 0 8px}
#viewseg button:last-child{border-radius:0 8px 8px 0}
#viewseg button:not(:last-child){border-right:0}
#viewseg button.active{color:var(--onacc,#0a0d13);background:var(--gold);border-color:var(--gold);font-weight:600}
/* ── table view ── */
#tableView{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);max-height:calc(100vh - 260px)}
#tableView table{border-collapse:collapse;width:100%;font-size:12.5px}
#tableView thead th{position:sticky;top:0;background:var(--headbg,#11161d);z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;color:var(--muted);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
#tableView tbody td{padding:7px 11px;border-bottom:1px solid var(--line);vertical-align:top;line-height:1.35}
#tableView tbody tr:hover{background:rgba(74,144,217,.06)}
#tableView td a{color:var(--accent);text-decoration:none}
#tableView td a:hover{text-decoration:underline}
</style>
<link rel="stylesheet" href="/three-view.css">
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
<style>/* theme this page's custom vars (crcp-theme.css doesn't define --panel/--accent) + text-on-accent, so light mode fully flips */
html[data-theme="dark"]{--panel:#121722;--accent:#4a90d9;--onacc:#0a0d13}
html[data-theme="light"]{--panel:#ffffff;--accent:#0969da;--onacc:#ffffff}</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
<a class="back" href="/">← back to properties</a>
<h1>Residential <span>Brokers</span> — Lending Leads</h1>
<p class="sub">The residential half of the shared licensee registry, surfaced here as a purchase-loan / refi lead source. Commercial brokers live in the <a class="back" href="/brokers.html">CRE Broker registry</a>. Public DRE license-board data — always re-verify a license before doing business.</p>
</header>
<div class="bar">
<input id="q" type="search" placeholder="Search name, firm, license…" style="min-width:220px">
<select id="state">
<option value="CA">California</option>
<option value="">All states</option>
<option value="FL">Florida</option><option value="TX">Texas</option>
<option value="NY">New York</option><option value="AZ">Arizona</option>
</select>
<span data-tvseg id="viewseg"></span>
<select id="tvSort"></select>
<label>Density <input id="dens" type="range" min="2" max="7" value="4"></label>
<span class="count" id="count">loading…</span>
</div>
<div class="toggles" id="toggles"></div>
<main>
<div class="grid" id="grid"></div>
<div id="tvList"></div>
<div id="tableView"><table><thead></thead><tbody></tbody></table></div>
<div class="empty" id="empty" hidden></div>
</main>
<script src="/three-view.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script>
// FIELDS drive the card body rows, the left-rail-style toggles, the table columns,
// and the all-field sort menu. `get` = sortable/plain value; `render` = optional HTML.
// `lock:true` = contact columns that can never be hidden (Steve hard rule).
const FIELDS = [
{k:'name', label:'Name', get:r=>r.name,
// Link to the CRCP broker profile ONLY for CA licenses (CRCP's commercial index is CA-focused,
// so an out-of-state license would almost always resolve to "not in index"). broker.html?dre=
// degrades gracefully when the DRE isn't in the commercial set.
render:r=>{const nm=r.name||'';const lic=r.license_no;const ca=(r.license_state==='CA'||r.state_code==='CA');
return (lic&&ca)?`<a href="/broker.html?dre=${encodeURIComponent(String(lic).replace(/[^0-9]/g,''))}&name=${encodeURIComponent(nm)}" onclick="event.stopPropagation()" title="CRCP broker profile (if in our commercial index)">${esc(nm)}</a>`:esc(nm);}},
{k:'firm', label:'Firm', get:r=>r.firm_name},
{k:'city', label:'City', get:r=>[r.city,r.state_code].filter(Boolean).join(', ')},
{k:'license', label:'License', get:r=>r.license_no ? (r.license_no+(r.license_state?' · '+r.license_state:'')) : ''},
{k:'type', label:'Type', get:r=>r.license_type},
{k:'status', label:'Status', get:r=>r.license_status},
{k:'class', label:'Class', get:r=>r.asset_class},
{k:'phone', label:'Phone', lock:true, get:r=>brokerPhone(r),
render:r=>{const p=brokerPhone(r);return p?`<a href="tel:${esc(p.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">${esc(p)}</a>`:'<span class="none">—</span>';}},
{k:'email', label:'Email', lock:true, get:r=>brokerEmail(r),
render:r=>{const e=brokerEmail(r);return e?`<a href="mailto:${esc(e)}" onclick="event.stopPropagation()">${esc(e)}</a>`:'<span class="none">—</span>';}},
];
const brokerPhone=r=>r.phone||r.broker_phone||r.firm_phone||'';
const brokerEmail=r=>r.email||r.broker_email||r.firm_email||'';
const LS='crcp-resbrokers';
const state = Object.assign({state:'CA',sort:'name',q:'',cols:4,off:{}}, JSON.parse(localStorage.getItem(LS)||'{}'));
let ROWS=[], TV=null;
const $=id=>document.getElementById(id);
function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
// contact columns can never be toggled off — Steve hard rule
FIELDS.forEach(f=>{ if(f.lock) state.off[f.k]=false; });
function renderToggles(){
$('toggles').innerHTML='';
// 'name' is always the card title; locked contact fields (phone/email) can't be hidden
for(const f of FIELDS){
if(f.k==='name'||f.lock) continue;
const b=document.createElement('button');
b.textContent=f.label; b.className = state.off[f.k] ? '' : 'on';
b.onclick=()=>{ state.off[f.k]=!state.off[f.k]; save(); b.className=state.off[f.k]?'':'on'; draw(); };
$('toggles').appendChild(b);
}
}
const fvis=k=>{const f=FIELDS.find(x=>x.k===k);return f?(f.lock||!state.off[k]):false;};
// filtered+sorted rows (ThreeView owns the all-field sort via applySort; this keeps
// the page's own default order for grid/table). getRows() = these rows.
function sortedRows(){ return [...ROWS]; }
// ── GRID cards (the page's EXISTING renderer — unchanged look) ──
function renderGrid(rows){
const grid=$('grid');
if(!rows.length){ grid.innerHTML=''; return; }
grid.innerHTML = rows.map(r=>{
// body rows: everything visible except name (title), firm (own line), class (pill), and locked contacts
const lines = FIELDS.filter(f=>!state.off[f.k] && !f.lock && f.k!=='name' && f.k!=='firm' && f.k!=='class').map(f=>{
const v=f.get(r); return v ? `<div class="row">${f.label}: <b>${esc(v)}</b></div>` : '';
}).join('');
const firm = !state.off.firm && r.firm_name ? `<div class="fm">${esc(r.firm_name)}</div>` : '';
const cls = !state.off.class && r.asset_class ? `<span class="pill">${esc(r.asset_class)}</span>` : '';
return `<div class="card"><div class="nm">${esc(r.name||'—')}</div>${firm}${lines}${cls}${contact(r)}</div>`;
}).join('');
}
// ── TABLE view (simple table of the same rows/fields; phone+email always present) ──
function tableCols(){ return FIELDS.filter(f=>f.lock || (!state.off[f.k] && f.k!=='class')); }
function renderTable(rows){
const cols=tableCols(), t=$('tableView').querySelector('table');
t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
t.querySelector('tbody').innerHTML=rows.map(r=>
'<tr>'+cols.map(c=>{
const cell=c.render?c.render(r):(c.get(r)||'—');
return `<td>${c.render?cell:esc(cell)}</td>`;
}).join('')+'</tr>').join('');
}
function draw(){
document.documentElement.style.setProperty('--cols', state.cols);
const rows=sortedRows();
const has=rows.length>0;
$('empty').hidden=has;
if(!has){ $('empty').textContent='No residential brokers match.'; }
if(TV){ TV.render(); } else { renderGrid(rows); renderTable(rows); }
}
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
// ── broker contact (Steve HARD RULE: ALWAYS show broker phone + email, always visible) ──
// The /api/residential-brokers row already resolves phone/email (broker→firm) with a phone_tier,
// so no client-side join is needed. Fall back to a directory/profile link, NEVER fabricate.
function contact(r){
const phone = r.phone || r.broker_phone || r.firm_phone || '';
const email = r.email || r.broker_email || r.firm_email || '';
const tier = r.phone_tier==='direct' ? 'direct' : (phone ? 'firm' : '');
const site = r.broker_website || r.firm_website || '';
let ph = phone
? `<a href="tel:${esc(phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">📞 ${esc(phone)}</a>${tier?`<span class="tier">${esc(tier)}</span>`:''}`
: '<span class="none">📞 —</span>';
let em = email
? `<a href="mailto:${esc(email)}" onclick="event.stopPropagation()">✉️ ${esc(email)}</a>`
: '<span class="none">✉️ —</span>';
let dir = (!phone && !email && site && /^https?:/i.test(site))
? `<a href="${esc(site)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">🌐 contact via directory ↗</a>`
: '';
return `<div class="contact">${ph}${em}${dir}</div>`;
}
async function load(){
$('count').textContent='loading…';
const p=new URLSearchParams({state:state.state,limit:'500'});
if(state.q) p.set('q',state.q);
try{
const r=await fetch('/api/residential-brokers?'+p);
const j=await r.json();
ROWS=j.rows||[];
$('count').textContent = j.upstream_down ? 'registry offline' : `${j.total?.toLocaleString?.()||ROWS.length} residential brokers`;
draw();
}catch(e){ $('count').textContent='error'; $('empty').hidden=false; $('empty').textContent='Could not reach the registry.'; }
}
// ── ThreeView: Grid / List / Table + all-field sort (guarded — three-view.js is deferred) ──
function mountThreeView(){
const fields=FIELDS.map(f=>({k:f.k,l:f.label,html:!!f.render,
calc:r=>f.render?f.render(r):(f.get(r)||'')}));
TV=ThreeView.mount({
seg:'#viewseg', sortSel:'#tvSort',
listMount:'#tvList', gridMount:'#grid', tableMount:'#tableView',
fields,
getRows:sortedRows,
title:r=>({name:r.name||'—',sub:[r.firm_name,[r.city,r.state_code].filter(Boolean).join(', ')].filter(Boolean).join(' · '),href:null}),
badgeHtml:r=>r.asset_class?`<span class="pill">${esc(r.asset_class)}</span>`:'',
fvis,
renderGrid, renderTable,
onSort:()=>{ /* helper owns the sort; just re-render */ },
rowClick:()=>{},
storageKey:'resb', defaultView:'grid', views:['grid','list','table'],
});
}
let _tvTries=0;
function startViews(){
if(typeof ThreeView==='undefined'){ if(++_tvTries>100){renderGrid(sortedRows());renderTable(sortedRows());return;} return setTimeout(startViews,40); }
mountThreeView(); draw();
}
// wire controls
$('q').value=state.q; $('state').value=state.state; $('dens').value=state.cols;
let t; $('q').oninput=e=>{ state.q=e.target.value.trim(); save(); clearTimeout(t); t=setTimeout(load,300); };
$('state').onchange=e=>{ state.state=e.target.value; save(); load(); };
$('dens').oninput=e=>{ state.cols=+e.target.value; save(); draw(); };
renderToggles(); startViews(); load();
</script>
<script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>