← back to Commercialrealestate
public/crcp.html
336 lines
<!doctype html>
<html lang="en">
<head>
<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>CRCP — Commercial Real Estate Control Panel</title>
<style>
/* Emulates CNCP (localhost:3333): dark panels, gold accent, status colors. */
:root{ --bg:#0c0f15; --panel:#141925; --panel2:#1d2230; --fg:#E6E8EC; --muted:#8A93A2; --gold:#C8A24B;
--active:#70AD47; --staged:#E0A33E; --draft:#5B9BD5; --archived:#5A6472; --del:#E74C3C; --line:#232a38; }
*{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}
header{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#10141d,#0c0f15);position:sticky;top:0;z-index:10;}
header h1{margin:0;font-size:18px;letter-spacing:.5px;} header h1 b{color:var(--gold);}
.live{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);}
.pulse{width:9px;height:9px;border-radius:50%;background:var(--active);box-shadow:0 0 0 0 rgba(112,173,71,.6);animation:p 1.6s infinite;}
@keyframes p{0%{box-shadow:0 0 0 0 rgba(112,173,71,.6)}70%{box-shadow:0 0 0 10px rgba(112,173,71,0)}100%{box-shadow:0 0 0 0 rgba(112,173,71,0)}}
header .nav{margin-left:auto;display:flex;gap:14px;} header .nav a{color:var(--draft);text-decoration:none;font-size:13px;} header .nav a:hover{color:var(--gold);}
.wrap{padding:18px 22px 40px;max-width:1320px;margin:0 auto;}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(var(--tilemin,150px),1fr));gap:14px;margin-bottom:18px;}
/* collapse-all-until-activated */
.panel h3{cursor:pointer;user-select:none;}
.panel h3 .caret{color:var(--gold);margin-right:6px;display:inline-block;transition:transform .15s;}
.panel.collapsed h3 .caret{transform:rotate(-90deg);}
.panel.collapsed > *:not(h3){display:none;}
.denslider{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);}
.denslider input{width:90px;accent-color:var(--gold);}
.expandall{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;}
.tile{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:15px 16px;position:relative;overflow:hidden;}
.tile .k{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);}
.tile .v{font-size:30px;font-weight:700;font-variant-numeric:tabular-nums;margin-top:3px;}
.tile .sub{font-size:11px;color:var(--muted);margin-top:2px;}
.tile .delta{position:absolute;top:13px;right:14px;font-size:12px;font-weight:700;color:var(--active);opacity:0;transition:opacity .3s;}
.tile .delta.show{opacity:1;}
.tile.gold .v{color:var(--gold);} .tile.active .v{color:var(--active);} .tile.draft .v{color:var(--draft);} .tile.staged .v{color:var(--staged);}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
@media(max-width:880px){.cols{grid-template-columns:1fr;}}
/* tablet + mobile: header + nav wrap instead of overflowing the viewport
(was 760px — missed iPad portrait at 768px; raised to 1024 to cover the whole tablet band) */
@media(max-width:1024px){
header{flex-wrap:wrap;}
header .nav{flex-wrap:wrap;gap:10px 12px;width:100%;}
.wrap{padding:14px;}
#modal{width:96vw;padding:16px;}
html,body{overflow-x:hidden;}
}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:16px;margin-bottom:16px;}
.panel h3{margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);display:flex;justify-content:space-between;cursor:pointer;user-select:none;}
.panel h3 .c{color:var(--muted);font-weight:400;}
table{width:100%;border-collapse:collapse;font-size:13px;} td,th{text-align:left;padding:6px 6px;border-top:1px solid var(--line);}
th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.4px;border-top:0;}
th.srt{cursor:pointer;user-select:none;} th.srt:hover{color:var(--fg);}
th.srt.asc::after{content:' ▲';color:var(--gold);} th.srt.desc::after{content:' ▼';color:var(--gold);}
td.n{text-align:right;font-variant-numeric:tabular-nums;color:var(--active);font-weight:600;}
.firm{color:var(--muted);font-size:11px;}
.atype{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:1px 6px;border-radius:9px;margin-left:6px;vertical-align:middle;}
.atype.res{background:rgba(91,155,213,.16);color:var(--draft);border:1px solid rgba(91,155,213,.4);}
.atype.com{background:rgba(200,162,75,.14);color:var(--gold);border:1px solid rgba(200,162,75,.4);}
.atype.arc{background:rgba(127,174,106,.18);color:#8fd06f;border:1px solid rgba(127,174,106,.55);}
.has{color:var(--active);} .miss{color:var(--archived);}
.bar{height:8px;background:#0a0d13;border-radius:5px;overflow:hidden;margin-top:4px;}
.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),var(--active));}
.prog{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-top:10px;}
.meta{font-size:11px;color:var(--muted);margin-top:6px;}
.flash{animation:fl .8s;} @keyframes fl{0%{background:rgba(200,162,75,.18)}100%{background:transparent}}
/* contact modal */
#ov{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;z-index:50;}
#ov.on{display:flex;align-items:center;justify-content:center;}
#modal{width:min(760px,94vw);max-height:90vh;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:20px 22px;}
#modal h2{margin:0 0 2px;font-size:20px;} #modal .mfirm{color:var(--gold);font-size:13px;}
#modal .x{float:right;background:none;border:0;color:var(--muted);font-size:20px;cursor:pointer;}
.cinfo{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0;}
.cinfo a,.cinfo span{font-size:13px;background:var(--panel2);border:1px solid var(--line);border-radius:18px;padding:5px 11px;color:var(--fg);text-decoration:none;}
.sec{margin-top:14px;} .sec h4{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:0 0 7px;border-top:1px solid var(--line);padding-top:10px;}
.litem{display:flex;justify-content:space-between;font-size:13px;padding:4px 0;} .litem .a{color:var(--fg);} .litem .m{color:var(--muted);}
.btn{background:var(--gold);color:#1a1206;border:0;border-radius:9px;padding:9px 14px;font-size:13px;font-weight:700;cursor:pointer;margin-right:8px;}
.btn.alt{background:transparent;color:var(--draft);border:1px solid var(--draft);}
.gate{font-size:11px;color:var(--staged);margin-top:6px;}
.findmail{background:transparent;border:1px solid var(--draft);color:var(--draft);border-radius:9px;padding:2px 8px;font-size:11px;cursor:pointer;white-space:nowrap;}
.findmail:hover{background:rgba(91,155,213,.12);}
.findmail:disabled{opacity:.5;cursor:default;}
.hunting{color:var(--staged);font-size:12px;}
</style>
</head>
<body>
<header>
<h1><b>CRCP</b> · Commercial Real Estate Control Panel</h1>
<span class="live"><span class="pulse"></span><span id="livetxt">live</span></span>
<nav class="nav">
<a href="/">Properties</a><a href="/mls.html">📋 MLS</a><a href="/broker-grid.html">👥 Brokers grid</a><a href="/map.html">Map</a><a href="/deals.html">Deals</a><a href="/rent-rolls.html">Rent Rolls</a><a href="/brokers.html">Mind-map</a><a href="/graphics.html">Graphics</a><a href="/condos.html">Condos</a><a href="/fha-loans.html">FHA Loans</a>
<span class="denslider" title="chip / tile density">⊞ <input type="range" id="dens" min="120" max="300" value="150" aria-label="Tile density"></span>
<button class="expandall" id="expandall">Expand all ▾</button>
</nav>
</header>
<div class="wrap">
<div class="tiles" id="tiles"></div>
<div class="panel"><h3>📞 Call Segments — prospects by property category <span class="c">click a segment for the call list</span></h3>
<table id="segTbl"><thead><tr><th>Segment</th><th>Category / note</th><th class="n">Properties</th><th class="n">Price range (med)</th><th class="n">Agents to call</th></tr></thead><tbody></tbody></table>
</div>
<div class="cols">
<div>
<div class="panel"><h3>Broker enrichment <span class="c" id="enrLbl"></span></h3>
<div id="enrBars"></div>
<div class="meta" id="enrMeta"></div>
</div>
<div class="panel"><h3>Top brokers <span class="c" id="brC"></span></h3>
<table id="brTbl"><thead><tr><th class="srt" data-col="name">Broker</th><th class="srt n" data-col="listings">Lists</th><th class="srt" data-col="phone">Phone</th><th class="srt" data-col="email">Email</th><th class="srt" data-col="website">Web</th></tr></thead><tbody></tbody></table>
</div>
</div>
<div>
<div class="panel"><h3>Warrantability (FHA proxy) <span class="c" id="waC"></span></h3>
<div id="wa"></div>
<div class="meta">FHA/VA-approval-based proxy — NOT lender-verified Fannie/Freddie warrantability.</div>
</div>
<div class="panel"><h3>Top firms by market share <span class="c" id="fmC"></span></h3>
<table id="fmTbl"><thead><tr><th class="srt" data-col="firm">Firm</th><th class="srt n" data-col="listings">Listings</th><th class="srt n" data-col="brokers">Brokers</th></tr></thead><tbody></tbody></table>
</div>
</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 money=n=>n?'$'+Number(n).toLocaleString():'—';
const atype=t=>t==='residential'?'<span class="atype res" title="Residential listing agent (Redfin)">Residential</span>':'<span class="atype com" title="Commercial broker (Crexi)">Commercial</span>';
async function openContact(name, id, seg){
window.__seg = seg || null;
const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading '+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='error'; return; }
if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
mb.innerHTML=`<h2>${b.name}${atype(b.agent_type)}</h2><div class="mfirm">${b.firm||'—'}${b.title?' · '+b.title:''}${b.license?' · DRE/MLS '+b.license:''}</div>
<div class="cinfo">
${b.phone?`<a href="tel:${b.phone}">☎ ${b.phone}</a>`:'<span class="m">☎ —</span>'}
${b.email?`<a href="mailto:${b.email}">✉ ${b.email}</a>`:(w?`<span class="m" id="memail">✉ — <button class="findmail" onclick="findEmailModal(${b.id})" title="Visit the site and hunt down an email address">find email on site</button></span>`:'<span class="m">✉ —</span>')}
${w?`<a href="${w}" target="_blank" rel="noopener noreferrer">🌐 ${w.replace(/^https?:\/\/(www\.)?/,'').replace(/\/$/,'')}</a>`:'<span class="m">🌐 —</span>'}
<span>📦 ${b.agent_type==='residential'?(d.current.length+' condo listing'+(d.current.length===1?'':'s')+' (Redfin)'):((b.total_assets||'?')+' total listings (Crexi)')}</span>
</div>
<div class="sec"><h4>Current listings (${d.current.length})</h4>${d.current.length?d.current.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}${l.zip?' '+l.zip:''}</span><span class="m">${l.type} · ${money(l.price)}${l.units?' · '+l.units+'u':''}${l.cap_rate?' · '+l.cap_rate+'% cap':''}${l.listed_at?' · listed '+(l.listed_at||'').toString().slice(0,10):''}</span></div>`).join(''):'<div class="meta">none in our set</div>'}</div>
<div class="sec"><h4>Expired / off-market (${(d.expired&&d.expired.length)||0})</h4>${(d.expired&&d.expired.length)?d.expired.map(x=>`<div class="litem"><span class="a">${x.address}, ${x.city}</span><span class="m">${money(x.list_price)} → sold ${money(x.sold_price)} · ${(x.sold_date||'').toString().slice(0,10)}${x.mls?' · MLS '+x.mls:''}${x.url?` · <a href="${x.url}" target="_blank" rel="noopener noreferrer">↗</a>`:''}</span></div>`).join(''):'<div class="meta">'+(d.expiredNote||'none')+'</div>'}</div>
<div class="sec"><h4>Closed listings — past 10 yrs (${d.closed.length})</h4>${d.closed.length?d.closed.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}</span><span class="m">${money(l.sold_price)} · ${(l.sold_date||'').toString().slice(0,10)}</span></div>`).join(''):'<div class="meta">'+(d.closedNote||'none')+'</div>'}</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 class="a">${c.address}, ${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)} · ${(c.sold_date||'').toString().slice(0,10)}${c.mls?' · MLS '+c.mls:''}${c.url?` · <a href="${c.url}" target="_blank" rel="noopener noreferrer">↗</a>`:''}</span></div>`).join('')}<div class="meta">${d.compsNote||''}</div></div>`:''}`;
}
// Modal variant of the email hunt — same endpoint, updates the ✉ chip in place.
async function findEmailModal(id){
const el=document.getElementById('memail'); if(el) el.innerHTML='✉ <span class="hunting">⏳ hunting on the site…</span>';
try{
const r=await (await fetch('/api/broker/find-email',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:+id})})).json();
if(r.email){ hunt[id]={email:r.email,basis:r.basis}; if(el) el.innerHTML=`✉ <a href="mailto:${r.email}">${r.email}</a> <span style="color:var(--muted);font-size:11px">(${r.basis||'found'}${r.site?' · '+r.site:''})</span>`; }
else { hunt[id]={miss:r.reason||r.error||'not found'}; if(el) el.innerHTML=`✉ not found <span style="color:var(--muted);font-size:11px" title="${((r.reason||r.error||'')+'').replace(/"/g,'"')}">(${r.reason||r.error||'no email on the site'})</span>`; }
renderBrokers(lastStats);
}catch(e){ if(el) el.innerHTML='✉ error: '+e.message; }
}
async function openFirm(name){
const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading '+name+'…</div>';
let d; try{ d=await (await fetch('/api/firm?name='+encodeURIComponent(name))).json(); }catch(e){ mb.innerHTML='error'; return; }
if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
const a=d.agg||{};
mb.innerHTML=`<h2>${d.firm.name}</h2><div class="mfirm">brokerage firm</div>
<div class="cinfo"><span>👥 ${a.brokers||0} brokers/agents</span><span>🏠 ${a.residential||0} residential</span><span>☎ ${a.phone||0}</span><span>✉ ${a.email||0}</span></div>
<div class="sec"><h4>Roster (${d.roster.length}) — click anyone for full contact</h4>
${d.roster.map(b=>`<div class="litem brow2" data-id="${b.id}" data-name="${(b.name||'').replace(/"/g,'"')}" style="cursor:pointer">
<span class="a">${b.name} ${atype(b.agent_type)}</span>
<span class="m">${b.listings} lists · ${b.phone?'☎':'·'} ${b.email?'✉':'·'} ${b.website?'🌐':'·'}</span></div>`).join('')}</div>`;
document.querySelectorAll('.brow2').forEach(r=>r.onclick=()=>openContact(r.dataset.name, r.dataset.id));
}
// ---- Call Segments (by property category) ----
// Compliant lead-source playbook for self-employed / bank-statement buyers (NOT a scraped list —
// referral partners + compliant marketing channels). Shown on the SFR / non-QM segments.
const LEAD_SOURCES=[
['🤝 CPAs & bookkeepers','Highest-intent referral partners — they serve self-employed clients who need bank-statement/P&L loans. Build relationships; they send pre-qualified borrowers.'],
['🏠 Real-estate agents (in this DB)','The residential agents here list to self-employed buyers — when a buyer can’t show W-2 income, they refer to you. Use the segments to reach SFR-listing agents.'],
['📣 Meta ads — Special Ad Category','Mortgage ads are a HUD/Meta Special Ad Category: NO age/gender/ZIP/protected-class targeting. Allowed: broad interest ("small business owner") + Special Ad Audience. Equal Housing + NMLS ID required.'],
['💼 LinkedIn — organic + content','Educational posts, business-owner groups, inbound. Value-first; no scraping/mass-DM. Sales Navigator for TOS-compliant outreach only.'],
['🌐 Local business communities','Chamber, BNI, "small business owners of [city]" groups. Relationship + referral, not list-harvesting.'],
['⚖ Compliance','ECOA/fair-lending (no protected-class targeting), TCPA (no cold calls/texts w/o consent), CAN-SPAM, NMLS disclosure. Inbound/referral only — never scrape/buy a consumer list.']
];
function leadSourcesHtml(){ return `<div class="sec"><h4>Lead sources — finding self-employed buyers (compliant)</h4>${LEAD_SOURCES.map(([t,d])=>`<div style="margin-bottom:8px"><b style="font-size:13px">${t}</b><div class="meta" style="margin-top:2px">${d}</div></div>`).join('')}</div>`; }
async function loadSegments(){
let d; try{ d=await (await fetch('/api/segments')).json(); }catch(e){ return; }
const kM=n=>n==null?null:(n>=1e6?'$'+(n/1e6).toFixed(n>=1e7?0:1)+'M':'$'+Math.round(n/1e3)+'k');
const priceCell=g=>{ if(g.priceMed==null) return '<span style="color:var(--muted)">—</span>';
const range=(g.priceMin!=null&&g.priceMax!=null&&g.priceMax>g.priceMin)?`<div class="meta" style="margin:0">${kM(g.priceMin)}–${kM(g.priceMax)}</div>`:'';
return `<b style="color:var(--gold)">${kM(g.priceMed)}</b>${range}`; };
$('#segTbl tbody').innerHTML=(d.segments||[]).map(g=>`<tr class="srow" data-key="${g.key}" style="cursor:${g.props?'pointer':'default'}${g.props?'':';opacity:.5'}">
<td><b>${g.label}</b></td><td style="color:var(--muted)">${g.product}</td>
<td class="n">${(g.props||0).toLocaleString()}</td><td class="n">${priceCell(g)}</td><td class="n" style="color:var(--draft)">${g.agents!=null?g.agents.toLocaleString():'—'}</td></tr>`).join('');
document.querySelectorAll('.srow').forEach(r=>r.onclick=()=>openSegment(r.dataset.key));
}
async function openSegment(key){
const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading…</div>';
let d; try{ d=await (await fetch('/api/segment?key='+encodeURIComponent(key))).json(); }catch(e){ mb.innerHTML='error'; return; }
if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
mb.innerHTML=`<h2>📞 ${d.label}</h2><div class="mfirm">${d.product}</div>
<div class="meta" style="margin:8px 0;border-left:2px solid var(--gold);padding-left:8px">${d.hook}</div>
<div class="sec"><h4>Agents to call (${d.callList.length}) — click one for full contact</h4>
${d.callList.length?d.callList.map(c=>`<div class="litem brow3" data-id="${c.id}" data-name="${(c.name||'').replace(/"/g,'"')}" style="cursor:pointer">
<span class="a">${c.name} ${atype(c.agent_type)}${c.avg_price?`<span class="meta" style="margin:0;display:block">🏢 ${money(c.avg_price)} avg${c.top_price&&c.top_price>c.avg_price?` · up to ${money(c.top_price)}`:''}</span>`:''}</span>
<span class="m">${c.n} props · ${c.phone?`<a href="tel:${c.phone}" class="has" style="color:var(--active)">${c.phone}</a>`:'no phone'} ${c.email?'✉':''}</span></div>`).join(''):'<div class="meta">'+(d.note||'no matches')+'</div>'}</div>
${(key==='sfr-bankstmt'||key==='nonqm')?leadSourcesHtml():''}`;
document.querySelectorAll('.brow3').forEach(r=>r.onclick=e=>{ if(e.target.tagName==='A') return; openContact(r.dataset.name, r.dataset.id, key); });
}
const fmt=n=>n==null?'—':Number(n).toLocaleString();
let prev={};
function tile(k,key,v,sub,cls){ const d=(prev[key]!=null&&v>prev[key])?('+'+(v-prev[key])):'';
return `<div class="tile ${cls||''}"><div class="k">${k}</div><div class="v">${fmt(v)}</div><div class="sub">${sub||''}</div><div class="delta ${d?'show':''}">${d}</div></div>`; }
function bar(label,have,total,color){ const pct=total?Math.round(100*have/total):0;
return `<div style="margin-bottom:10px"><div class="prog"><span>${label}</span><span>${fmt(have)} / ${fmt(total)} · ${pct}%</span></div><div class="bar"><i style="width:${pct}%;background:${color||'linear-gradient(90deg,var(--gold),var(--active))'}"></i></div></div>`; }
// ---- sortable columns ----
let brSort={col:'listings',dir:-1}, fmSort={col:'listings',dir:-1}, lastStats=null;
function srt(arr,col,dir){ return arr.slice().sort((a,b)=>{ let x=a[col],y=b[col];
if(col==='phone'||col==='email'||col==='website'){ x=x?1:0; y=y?1:0; }
if(typeof x==='string'||typeof y==='string') return dir*String(x||'').localeCompare(String(y||''));
return dir*((+x||0)-(+y||0)); }); }
function markHdr(tbl,sort){ document.querySelectorAll('#'+tbl+' th.srt').forEach(th=>{ th.classList.remove('asc','desc'); if(th.dataset.col===sort.col) th.classList.add(sort.dir>0?'asc':'desc'); }); }
// Web column shows 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 esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
const webLink=u=>{ if(!u) return '<span class="miss">—</span>'; const h=/^https?:/.test(u)?u:'https://'+u; return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer" style="color:var(--gold)" title="${esc(h)}">${esc(siteName(u))}↗</a>`; };
// On-demand email hunt (the "✉ find email" button). hunt[] survives the 3s poll re-renders;
// a found email also lands in broker.email server-side, so the next poll carries it natively.
const hunt={};
async function findEmail(id,name,website){
hunt[id]={state:'hunting'}; renderBrokers(lastStats);
try{
const r=await (await fetch('/api/broker/find-email',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:+id,name,website})})).json();
hunt[id]=r.email?{email:r.email,basis:r.basis}:{miss:r.reason||r.error||'not found'};
}catch(e){ hunt[id]={miss:e.message}; }
renderBrokers(lastStats);
}
function emailCell(b){
if(b.email) return `<a href="mailto:${b.email}" class="has" style="color:var(--draft)">${b.email}</a>`;
const h=hunt[b.id];
if(h){
if(h.state==='hunting') return '<span class="hunting">⏳ hunting…</span>';
if(h.email) return `<a href="mailto:${h.email}" class="has" style="color:var(--draft)" title="found on site (${h.basis||''})">${h.email}</a>`;
return `<span class="miss" title="${(h.miss||'').replace(/"/g,'"')}">not found</span>`;
}
if(b.website) return `<button class="findmail" data-id="${b.id}" data-name="${(b.name||'').replace(/"/g,'"')}" data-web="${(b.website||'').replace(/"/g,'"')}" title="Visit ${siteName(b.website)} and hunt down an email address">✉ find email</button>`;
return '<span class="miss">—</span>';
}
function renderBrokers(s){ if(!s) return; const rows=srt(s.topBrokers||[], brSort.col, brSort.dir);
$('#brC').textContent=rows.length+' shown · click a broker · click a header to sort';
$('#brTbl tbody').innerHTML=rows.map(b=>`<tr class="brow" data-id="${b.id||''}" data-name="${(b.name||'').replace(/"/g,'"')}" style="cursor:pointer">
<td>${b.name}${atype(b.agent_type)}<div class="firm">${b.firm||'—'}</div></td>
<td class="n">${b.listings}</td>
<td>${b.phone?`<a href="tel:${b.phone}" class="has" style="color:var(--active)">${b.phone}</a>`:'<span class="miss">—</span>'}</td>
<td>${emailCell(b)}</td>
<td>${webLink(b.website)}</td></tr>`).join('');
document.querySelectorAll('.brow').forEach(r=>r.onclick=e=>{ if(e.target.tagName==='A'||e.target.tagName==='BUTTON') return; openContact(r.dataset.name, r.dataset.id); });
document.querySelectorAll('#brTbl .findmail').forEach(x=>x.onclick=e=>{ e.stopPropagation(); findEmail(x.dataset.id, x.dataset.name, x.dataset.web); });
markHdr('brTbl',brSort); }
function renderFirms(s){ if(!s) return; const rows=srt(s.topFirms||[], fmSort.col, fmSort.dir);
$('#fmC').textContent='click a firm · click a header to sort';
$('#fmTbl tbody').innerHTML=rows.map(f=>`<tr class="frow" data-firm="${(f.firm||'').replace(/"/g,'"')}" style="cursor:pointer"><td>${f.firm}</td><td class="n">${f.listings}</td><td class="n" style="color:var(--draft)">${f.brokers}</td></tr>`).join('');
document.querySelectorAll('.frow').forEach(r=>r.onclick=()=>openFirm(r.dataset.firm));
markHdr('fmTbl',fmSort); }
// wire header clicks once
document.querySelectorAll('#brTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(brSort.col===c) brSort.dir*=-1; else {brSort.col=c; brSort.dir=-1;} renderBrokers(lastStats); });
document.querySelectorAll('#fmTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(fmSort.col===c) fmSort.dir*=-1; else {fmSort.col=c; fmSort.dir=-1;} renderFirms(lastStats); });
async function poll(){
let s; try{ s=await (await fetch('/api/crcp/stats')).json(); }catch(e){ $('#livetxt').textContent='reconnecting…'; return; }
lastStats=s;
$('#livetxt').textContent='live · '+new Date(s.ts).toLocaleTimeString();
// tiles
$('#tiles').innerHTML = [
tile('Listings','listings',s.listings,s.market||'LA County','gold'),
tile('Brokers','brokers',s.brokers,(s.colist!=null?s.colist+' co-list links':''),'active'),
tile('Firms','firms',s.firms,'brokerages','draft'),
tile('Condos scraped','condos',s.condos,(s.condosByStatus?Object.entries(s.condosByStatus).map(([k,v])=>v+' '+k.replace('_',' ')).join(' · '):'awaiting Job A'),'staged'),
tile('Residential agents','agents_residential',s.agents_residential||0,((s.condos_with_agent||0)+' condos w/ agent · '+(s.agents_res_phone||0)+'☎ '+(s.agents_res_email||0)+'✉'),'draft'),
tile('FHA-approved','fhaApproved',s.fhaApproved,'warrantable (proxy)','active'),
tile('Broker edges','edges',s.edges,'broker↔listing','gold')
].join('');
// enrichment bars (the LIVE populating)
$('#enrLbl').textContent = (s.brokers_phone||0)+' phone · '+(s.brokers_email||0)+' email';
$('#enrBars').innerHTML = bar('Phone captured', s.brokers_phone||0, s.brokers, 'var(--active)')
+ bar('Email captured', s.brokers_email||0, s.brokers, 'var(--draft)')
+ (s.brokers_web!=null?bar('Website found', s.brokers_web, s.brokers, 'var(--gold)'):'');
const enr=(s.brokers_phone||0)+(s.brokers_email||0);
const penr=(prev.brokers_phone||0)+(prev.brokers_email||0);
$('#enrMeta').innerHTML = enr>penr ? `<span class="has">▲ +${enr-penr} contacts since last refresh — Job B populating…</span>` : (enr===0?'Job B (broker enrichment) queued — contacts will populate here live.':'enrichment idle / complete');
renderBrokers(s);
// warrantability
$('#wa').innerHTML = bar('FHA-approved', s.fhaApproved||0, (s.fhaApproved||0)+(s.fhaExpired||0), 'var(--active)')
+ bar('FHA-expired (flag/review)', s.fhaExpired||0, (s.fhaApproved||0)+(s.fhaExpired||0), 'var(--staged)');
renderFirms(s);
prev=s;
// flash changed tiles
document.querySelectorAll('.delta.show').forEach(d=>d.closest('.tile').classList.add('flash'));
setTimeout(()=>document.querySelectorAll('.flash').forEach(e=>e.classList.remove('flash')),800);
}
// ---- collapse-all-until-activated + density slider (persisted) ----
(function initPanels(){
const LS_OPEN='crcp_open_panels', LS_DENS='crcp_dens';
// restore density
const dens=$('#dens'), saved=+localStorage.getItem(LS_DENS)||150;
dens.value=saved; document.documentElement.style.setProperty('--tilemin', saved+'px');
dens.addEventListener('input',()=>{ document.documentElement.style.setProperty('--tilemin', dens.value+'px'); localStorage.setItem(LS_DENS, dens.value); });
// which panels were left open last time (default: ALL collapsed)
let open; try{ open=new Set(JSON.parse(localStorage.getItem(LS_OPEN)||'[]')); }catch(e){ open=new Set(); }
const save=()=>localStorage.setItem(LS_OPEN, JSON.stringify([...open]));
const panels=[...document.querySelectorAll('.panel')];
panels.forEach((p,i)=>{
const h=p.querySelector('h3'); if(!h) return;
const key=h.textContent.trim().slice(0,40)||('p'+i); p.dataset.pk=key;
if(!h.querySelector('.caret')){ const c=document.createElement('span'); c.className='caret'; c.textContent='▸'; h.prepend(c); }
if(!open.has(key)) p.classList.add('collapsed'); // start collapsed until activated
h.addEventListener('click',()=>{
p.classList.toggle('collapsed');
if(p.classList.contains('collapsed')) open.delete(key); else open.add(key);
save(); syncExpandBtn();
});
});
const btn=$('#expandall');
function syncExpandBtn(){ const allOpen=panels.every(p=>!p.classList.contains('collapsed'));
btn.textContent = allOpen?'Collapse all ▴':'Expand all ▾'; }
btn.addEventListener('click',()=>{
const allOpen=panels.every(p=>!p.classList.contains('collapsed'));
panels.forEach(p=>{ const key=p.dataset.pk;
if(allOpen){ p.classList.add('collapsed'); open.delete(key); }
else { p.classList.remove('collapsed'); open.add(key); } });
save(); syncExpandBtn();
});
syncExpandBtn();
})();
poll(); setInterval(poll, 3000);
loadSegments(); setInterval(loadSegments, 20000);
</script>
<script src="/col-resize.js" defer></script>
</body>
</html>