← 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>