← back to Nationalrealestate
public/brokers.html
251 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>USRealEstate — Broker Registry</title>
<link rel="stylesheet" href="/crcp-grid.css">
<style>
:root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;
--cg-card:#11151d;--cg-line:#222936;--cg-ink:#e8ecf2;--cg-mut:#8a93a3;
--cg-acc:#c8a24b;--cg-accbg:rgba(200,162,75,.12);--cg-railbg:#0a0d13;--cg-topbg:rgba(17,21,29,.94);}
*{box-sizing:border-box;}
html,body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;}
#topbar{position:sticky;top:0;z-index:1000;display:flex;align-items:center;gap:18px;flex-wrap:wrap;
padding:10px 18px;background:rgba(17,21,29,.94);border-bottom:1px solid var(--line);backdrop-filter:blur(6px);}
#topbar .brand{font-size:16px;font-weight:700;white-space:nowrap;}
#topbar .brand b{color:var(--gold);}
nav{display:flex;gap:14px;font-size:13px;}
nav a{color:var(--muted);text-decoration:none;} nav a:hover,nav a.active{color:var(--gold);}
#coverage{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-left:auto;font-size:12px;}
#coverage .cov-lbl{color:var(--muted);}
.st-chip{border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--muted);
padding:4px 10px;cursor:pointer;white-space:nowrap;font-size:12px;}
.st-chip b{color:var(--fg);} .st-chip .n{color:var(--gold);font-weight:700;margin-left:5px;}
.st-chip.active{background:var(--gold);color:var(--bg);} .st-chip.active b,.st-chip.active .n{color:var(--bg);}
#srvbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:10px 18px;border-bottom:1px solid var(--line);
background:var(--panel);font-size:13px;}
#srvbar input{background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;
padding:7px 10px;font-size:13px;min-width:220px;}
#srvbar input:focus{outline:none;border-color:var(--gold);}
#pager{margin-left:auto;display:flex;gap:8px;align-items:center;color:var(--muted);}
#pager button{background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:8px;
padding:6px 12px;cursor:pointer;font-size:12px;}
#pager button:disabled{opacity:.35;cursor:default;}
#pager button:not(:disabled):hover{border-color:var(--gold);color:var(--gold);}
#loading{padding:60px;text-align:center;color:var(--muted);}
.lic{font-family:ui-monospace,Menlo,monospace;font-size:12px;}
/* M-B2 inline firm detail (opens under the clicked row — Steve's standing pattern) */
.cg-tbl tr[data-ri], .cg-card[data-ri]{cursor:pointer;}
.usre-fd{background:#0d1119;border:1px solid var(--line);border-left:3px solid var(--gold);
border-radius:8px;margin:6px 0;padding:12px 14px;font-size:13px;}
td > .usre-fd{margin:2px 0;}
.usre-fd h3{margin:0 0 6px;font-size:14px;color:var(--gold);}
.usre-fd .fd-muted{color:var(--muted);}
.usre-fd .fd-sec{margin-top:8px;}
.usre-fd .fd-sec b{color:var(--fg);font-size:12px;text-transform:uppercase;letter-spacing:.04em;}
.usre-fd a{color:var(--gold);text-decoration:none;} .usre-fd a:hover{text-decoration:underline;}
.usre-fd .fd-pill{display:inline-block;border:1px solid var(--line);border-radius:999px;
padding:1px 9px;font-size:11px;margin-left:6px;color:var(--muted);}
.usre-fd .fd-pill.yes{color:#5fd38a;border-color:#2b4d38;}
.usre-fd .fd-pill.no{color:#d97d7d;border-color:#4d2b2b;}
.usre-fd .fd-brokers{display:flex;flex-wrap:wrap;gap:4px 12px;margin-top:4px;max-height:120px;overflow:auto;}
.usre-fd .fd-brokers span{color:var(--muted);font-size:12px;white-space:nowrap;}
</style>
<link rel="stylesheet" href="/nav-drawer.css">
<link rel="stylesheet" href="/geo-search.css">
</head>
<body>
<div id="topbar">
<div class="brand"><b>USRealEstate</b> — Broker Registry</div>
<nav>
<a href="/">Map</a>
<a href="/markets.html">Markets</a><a href="/commercial.html">Commercial</a>
<a href="/brokers.html" class="active">Brokers</a>
<a href="/sources.html">Sources</a>
<a href="/watchlist.html">Watchlist</a>
<a href="/admin.html">Admin</a>
</nav>
<span id="coverage"><span class="cov-lbl">coverage:</span><span style="color:var(--muted)">loading…</span></span>
</div>
<div id="srvbar">
<input type="text" id="srvQ" placeholder="search name / firm / license # (server-side)">
<input type="text" id="srvFirm" placeholder="firm filter" style="min-width:160px">
<select id="srvStatus" title="License status filter">
<option value="">All statuses</option>
<option value="active">Active only</option>
<option value="inactive">Inactive</option>
<option value="expired application">Expired application</option>
</select>
<span id="pager">
<button id="pgPrev">‹ Prev</button>
<span id="pgInfo">—</span>
<button id="pgNext">Next ›</button>
</span>
</div>
<div class="cg-shell">
<div class="cg-rail" id="cgRail"></div>
<div class="cg-main" id="cgMain"><div id="loading">loading brokers…</div></div>
</div>
<script src="/crcp-grid.js"></script>
<script src="/col-resize.js"></script>
<script>
const PAGE = 500;
const LS = { get(k){ try { return localStorage.getItem(k); } catch { return null; } },
set(k,v){ try { localStorage.setItem(k,v); } catch {} } };
const S = { state: LS.get('usreBrk.state') || '', q: '', firm: '', status: LS.get('usreBrk.status') || '', offset: 0, total: 0 };
const FIELDS = [
{ k: 'license_no', l: 'License #', def: 1, col: 1 },
{ k: 'license_type', l: 'Type', def: 1, col: 1 },
{ k: 'license_status', l: 'Status', def: 1, col: 1 },
{ k: 'firm_name', l: 'Firm', def: 1, col: 1 },
{ k: 'city', l: 'City', def: 1, col: 1 },
{ k: 'license_state', l: 'State', def: 1, col: 1 },
{ k: 'source', l: 'Source', def: 0, col: 1 },
{ k: 'name', l: 'Name', def: 0, col: 0 }, // title-column sort target only
];
let grid = null;
async function loadStats() {
const d = await fetch('/api/broker-stats').then(r => r.json());
const box = document.getElementById('coverage');
const sc = d.site_coverage || {};
const covChip = sc.firms_with_url
? `<span class="st-chip" title="${(sc.firms_crawled||0).toLocaleString()} crawled · ${(sc.firms_with_idx||0).toLocaleString()} with IDX · ${(sc.contact_rows||0).toLocaleString()} contacts"><b>sites</b><span class="n">${sc.firms_with_url.toLocaleString()}</span></span>`
: '';
box.innerHTML = '<span class="cov-lbl">coverage:</span>' +
`<span class="st-chip${S.state === '' ? ' active' : ''}" data-st=""><b>ALL</b><span class="n">${(d.total_brokers || 0).toLocaleString()}</span></span>` +
(d.states || []).map(s =>
`<span class="st-chip${S.state === s.state ? ' active' : ''}" data-st="${s.state}" title="${s.firms.toLocaleString()} firms"><b>${s.state}</b><span class="n">${s.brokers.toLocaleString()}</span></span>`
).join('') + covChip;
box.addEventListener('click', e => {
const c = e.target.closest('.st-chip'); if (!c || c.dataset.st === undefined) return;
S.state = c.dataset.st; S.offset = 0;
LS.set('usreBrk.state', S.state);
box.querySelectorAll('.st-chip').forEach(x => x.classList.toggle('active', x.dataset.st === S.state));
loadPage();
}, { once: false });
}
async function loadPage() {
const p = new URLSearchParams();
if (S.state) p.set('state', S.state);
if (S.q) p.set('q', S.q);
if (S.firm) p.set('firm', S.firm);
if (S.status) p.set('status', S.status);
p.set('limit', PAGE); p.set('offset', S.offset);
const d = await fetch('/api/brokers?' + p).then(r => r.json());
S.total = d.total || 0;
const rows = d.rows || [];
const loading = document.getElementById('loading');
if (loading) loading.remove();
const pg = Math.floor(S.offset / PAGE) + 1, pgs = Math.max(Math.ceil(S.total / PAGE), 1);
document.getElementById('pgInfo').textContent = `${S.total.toLocaleString()} brokers · page ${pg} / ${pgs.toLocaleString()}`;
document.getElementById('pgPrev').disabled = S.offset <= 0;
document.getElementById('pgNext').disabled = S.offset + PAGE >= S.total;
if (grid) { grid.setData(rows); return; }
grid = CrcpGrid.mount({
onRowClick: toggleFirmDetail,
data: rows,
fields: FIELDS,
mount: '#cgMain',
rail: '#cgRail',
storageKey: 'usreBrk',
cols: 4,
view: 'list',
title: r => ({ name: r.name, sub: (r.firm_name ? r.firm_name + ' · ' : '') + r.license_state }),
titleCol: 'Broker',
titleSortK: 'name',
search: r => `${r.name} ${r.firm_name || ''} ${r.license_no || ''} ${r.city || ''}`,
sort: { k: 'name', dir: 1 },
facets: [
{ k: 'license_type', l: 'License Type', type: 'chip', max: 20 },
{ k: 'license_status', l: 'Status', type: 'chip', max: 12 },
{ k: 'license_state', l: 'State', type: 'chip', max: 10 },
],
});
}
// ---- M-B2 inline firm detail (site + IDX flag + contacts + top brokers) ----
const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
const firmCache = new Map();
function firmDetailHtml(d) {
const f = d.firm, s = d.site;
const idxPill = s && s.has_idx_listings != null
? `<span class="fd-pill ${s.has_idx_listings ? 'yes' : 'no'}">${s.has_idx_listings ? 'IDX / listing search' : 'no IDX detected'}</span>` : '';
const siteLine = s && s.url
? `<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${esc(s.url)}</a>` +
(s.http_status ? ` <span class="fd-muted">(${esc(s.http_status)})</span>` : '') + idxPill +
(s.title ? `<div class="fd-muted">“${esc(s.title)}”</div>` : '')
: `<span class="fd-muted">${s ? 'no website found (' + esc(s.discovery_method) + ')' : 'not yet searched'}</span>`;
const contacts = (d.contacts || []).map(c =>
c.kind === 'email' ? `<a href="mailto:${esc(c.value)}">${esc(c.value)}</a>` :
c.kind === 'phone' ? `<a href="tel:${esc(c.value)}">${esc(c.value)}</a>` :
`<a href="${esc(c.value)}" target="_blank" rel="noopener noreferrer">contact page</a>`
).join(' · ') || '<span class="fd-muted">none extracted</span>';
const brokers = (d.brokers || []).map(b =>
`<span>${esc(b.name)}${b.city ? ' · ' + esc(b.city) : ''}</span>`).join('') || '<span class="fd-muted">none</span>';
return `<div class="usre-fd">
<h3>${esc(f.name)} <span class="fd-pill">${esc(f.license_state)}</span>
${f.agent_count != null ? `<span class="fd-pill">${Number(f.agent_count).toLocaleString()} agents</span>` : ''}</h3>
<div>${siteLine}</div>
<div class="fd-sec"><b>Contacts</b><div>${contacts}</div></div>
<div class="fd-sec"><b>Brokers</b> <span class="fd-muted">(first ${(d.brokers || []).length})</span>
<div class="fd-brokers">${brokers}</div></div>
</div>`;
}
async function toggleFirmDetail(row, el) {
if (!row || !row.firm_id || !el) return;
// Already open under this element? Close it.
const next = el.nextElementSibling;
if (next && next.dataset && next.dataset.fdFor === String(row.firm_id)) { next.remove(); return; }
document.querySelectorAll('[data-fd-for]').forEach(x => x.remove());
let holder;
if (el.tagName === 'TR') {
holder = document.createElement('tr');
holder.dataset.fdFor = row.firm_id;
const td = document.createElement('td');
td.colSpan = el.children.length;
td.innerHTML = '<div class="usre-fd fd-muted">loading firm…</div>';
holder.appendChild(td);
} else {
holder = document.createElement('div');
holder.dataset.fdFor = row.firm_id;
holder.style.gridColumn = '1 / -1';
holder.innerHTML = '<div class="usre-fd fd-muted">loading firm…</div>';
}
el.after(holder);
try {
let d = firmCache.get(row.firm_id);
if (!d) { d = await fetch('/api/firm/' + row.firm_id).then(r => r.json()); firmCache.set(row.firm_id, d); }
const target = holder.tagName === 'TR' ? holder.firstElementChild : holder;
if (d.error) target.innerHTML = `<div class="usre-fd fd-muted">${esc(d.error)}</div>`;
else target.innerHTML = firmDetailHtml(d);
} catch (e) {
const target = holder.tagName === 'TR' ? holder.firstElementChild : holder;
target.innerHTML = '<div class="usre-fd fd-muted">failed to load firm detail</div>';
}
}
const deb = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
document.getElementById('srvQ').addEventListener('input', deb(e => { S.q = e.target.value.trim(); S.offset = 0; loadPage(); }, 350));
document.getElementById('srvFirm').addEventListener('input', deb(e => { S.firm = e.target.value.trim(); S.offset = 0; loadPage(); }, 350));
{ const el = document.getElementById('srvStatus'); el.value = S.status;
el.addEventListener('change', e => { S.status = e.target.value; LS.set('usreBrk.status', S.status); S.offset = 0; loadPage(); }); }
document.getElementById('pgPrev').addEventListener('click', () => { S.offset = Math.max(S.offset - PAGE, 0); loadPage(); });
document.getElementById('pgNext').addEventListener('click', () => { S.offset += PAGE; loadPage(); });
loadStats();
loadPage();
</script>
<script src="/nav-drawer.js"></script>
<script src="/geo-search.js"></script>
</body>
</html>