← back to Commercialrealestate
public/crcp.html
944 lines
<!doctype html>
<html lang="en">
<head>
<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>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='dark'?'dark':'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();</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='%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. */
/* Dark fallbacks (shared crcp-theme.css overrides these per html[data-theme] after load) */
:root{ --bg:#0d1117; --panel:#1c232e; --panel2:#171d26; --fg:#f2f6fb; --muted:#8A93A2; --gold:#C8A24B;
--active:#70AD47; --staged:#E0A33E; --draft:#5B9BD5; --archived:#5A6472; --del:#E74C3C; --line:#33404f; }
*{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:var(--bg);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);}
.tile.clickable{cursor:pointer;transition:border-color .12s,transform .12s;} .tile.clickable:hover{border-color:var(--gold);transform:translateY(-1px);} .tile.clickable::after{content:'↗';position:absolute;bottom:8px;right:11px;font-size:11px;color:var(--muted);opacity:0;transition:opacity .12s;} .tile.clickable:hover::after{opacity:1;}
.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:var(--bg);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);}
/* warrantability drill-down (TK-10092): clickable bars + modal list */
.wclk{cursor:pointer;border-radius:8px;padding:2px 8px;margin:0 -8px;transition:background .12s;} .wclk:hover{background:var(--panel2);} .wclk:hover .prog span:first-child{color:var(--gold);}
.wtabs{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0 4px;}
.wtab{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:5px 11px;font-size:12px;color:var(--muted);cursor:pointer;} .wtab.on{border-color:var(--gold);color:var(--fg);} .wtab .c{color:var(--gold);font-weight:700;}
.wsearch{width:100%;box-sizing:border-box;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px;margin:8px 0 2px;}
a.wrow{text-decoration:none;border-radius:6px;padding:5px 6px;margin:0 -6px;} a.wrow:hover{background:var(--panel2);} a.wrow:hover .a{color:var(--gold);}
.clink{cursor:pointer;} .clink:hover{color:var(--gold);}
#xpTbl tbody tr.xprow:hover{background:rgba(200,162,75,.08);}
/* unwarrantable-loans CTA */
.loanbtn{display:inline-flex;align-items:center;gap:7px;margin-top:12px;text-decoration:none;}
.loanbtn .loanct{background:#1a1206;color:var(--gold);border-radius:10px;padding:1px 8px;font-size:12px;font-weight:700;}
/* Solicitation Studio (RENTV-style outreach content) */
.solbar{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start;margin-bottom:12px;}
.soltabs,.soltypes{display:flex;gap:6px;flex-wrap:wrap;}
.soltab,.soltype{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:6px 13px;font-size:13px;color:var(--muted);cursor:pointer;}
.soltab.on{border-color:var(--gold);color:var(--fg);background:rgba(200,162,75,.10);}
.soltype:hover{color:var(--fg);border-color:var(--gold);} .soltype.on{border-color:var(--gold);color:var(--fg);background:rgba(200,162,75,.10);}
.solaud{display:flex;gap:6px;flex-wrap:wrap;}
.solaud .achip{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:6px 11px;font-size:12px;color:var(--muted);cursor:pointer;}
.solaud .achip.on{border-color:var(--draft);color:var(--fg);background:rgba(91,155,213,.12);}
.solaud .achip .n{color:var(--draft);font-weight:700;margin-left:4px;}
.solout{background:var(--panel2);border:1px solid var(--line);border-radius:11px;padding:16px;min-height:80px;}
.solout h4{margin:0 0 4px;font-size:15px;color:var(--fg);} .solout .subj{color:var(--gold);font-size:12px;margin-bottom:8px;}
.solout pre{white-space:pre-wrap;font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--fg);margin:0;}
.solactions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;}
.solactions button{background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 12px;font-size:12px;cursor:pointer;}
.solactions button.prime{background:var(--gold);color:#1a1206;border:0;font-weight:700;}
.solgate{font-size:11px;color:var(--staged);margin-top:8px;}
.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;}
/* DSCR Qualifier */
.dscr-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;} @media(max-width:880px){.dscr-grid{grid-template-columns:1fr;}}
.dscr .dfield{margin-bottom:10px;} .dscr .dfield>label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:4px;}
.dscr input[type=number],.dscr input[type=text]{width:100%;box-sizing:border-box;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:14px;font-variant-numeric:tabular-nums;}
.dscr input:focus{outline:0;border-color:var(--gold);}
.dscr .row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.dscr .unitrow{display:grid;grid-template-columns:1fr 130px 32px;gap:8px;align-items:center;margin-bottom:6px;}
.dscr .unitrow button{background:transparent;border:1px solid var(--line);color:var(--muted);border-radius:7px;cursor:pointer;font-size:15px;line-height:1;padding:7px 0;}
.dscr .unitrow button:hover{border-color:var(--del);color:var(--del);}
.dscr .addunit{background:transparent;border:1px dashed var(--line);color:var(--draft);border-radius:8px;padding:6px 11px;font-size:12px;cursor:pointer;margin-top:2px;}
.dscr .addunit:hover{border-color:var(--draft);}
.dscr .rentsum{font-size:12px;color:var(--muted);margin-top:7px;} .dscr .rentsum b{color:var(--active);font-variant-numeric:tabular-nums;}
.dscr .dtog{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;}
.dscr .dtog button{background:var(--panel2);border:0;color:var(--muted);padding:6px 12px;font-size:12px;cursor:pointer;} .dscr .dtog button.on{background:rgba(200,162,75,.14);color:var(--gold);}
.dscr .dresult{background:var(--bg);border:1px solid var(--line);border-radius:12px;padding:18px 16px;text-align:center;}
.dscr .dresult .ratio{font-size:48px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1;margin-top:4px;}
.dscr .dbadge{display:inline-block;margin-top:12px;font-size:13px;font-weight:800;letter-spacing:.4px;text-transform:uppercase;padding:6px 14px;border-radius:20px;}
.dscr .dbadge.pass{background:rgba(112,173,71,.15);color:var(--active);border:1px solid rgba(112,173,71,.5);}
.dscr .dbadge.marg{background:rgba(224,163,62,.15);color:var(--staged);border:1px solid rgba(224,163,62,.5);}
.dscr .dbadge.fail{background:rgba(231,76,60,.14);color:var(--del);border:1px solid rgba(231,76,60,.5);}
.dscr .brk{font-size:12.5px;color:var(--muted);margin-top:14px;line-height:1.8;text-align:left;}
.dscr .brk .r{display:flex;justify-content:space-between;gap:10px;} .dscr .brk b{color:var(--fg);font-variant-numeric:tabular-nums;}
.dscr .brk .r.hi b{color:var(--gold);}
.dscr pre{white-space:pre-wrap;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px;font:12.5px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--fg);margin:6px 0 0;}
.dscr .dactions{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;} .dscr .dactions button{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 12px;font-size:12px;cursor:pointer;} .dscr .dactions .prime{background:var(--gold);color:#1a1206;border-color:var(--gold);font-weight:700;}
</style>
<link rel="stylesheet" href="/crcp-theme.css">
<style>
/* Bridge: map page-local aliases to shared tokens so JS inline styles using --fg/--panel still work */
html[data-theme="dark"] { --panel:var(--card); --panel2:var(--bg2); --fg:var(--ink); }
html[data-theme="light"] { --panel:var(--card); --panel2:var(--bg2); --fg:var(--ink); }
</style>
<style>
/* ── TK-10330: industry-grade layout redesign (override layer) ──────────────
Loads LAST, so these rules win by cascade order. Additive + fully reversible;
preserves every JS-bound ID/DOM hook. Builds on the shared day/night tokens.
Design consensus: graphic-designer + ui-ux-designer panels — move from
"consumer card app" toward a CoStar/Crexi/Bloomberg-tier deal terminal. */
/* — Header: compact single-line toolbar, differentiated zones — */
header{padding:0 22px;min-height:46px;gap:12px;border-bottom:2px solid var(--line);background:var(--headbg,var(--bg));backdrop-filter:saturate(1.1) blur(6px);}
header h1{font-size:15px;font-weight:700;letter-spacing:.2px;white-space:nowrap;}
header h1 b{color:var(--gold);}
header h1::after{content:'';display:inline-block;width:1px;height:15px;background:var(--line);margin:0 4px 0 12px;vertical-align:-2px;}
header .nav a{color:var(--muted);font-size:12.5px;font-weight:500;}
header .nav a:hover{color:var(--ink,var(--fg));}
header .nav a.active{color:var(--gold);font-weight:600;}
.live{font-size:11.5px;}
.expandall{border-radius:5px;padding:5px 10px;font-size:11.5px;}
/* — Panels: terminal radius + gold category rail + muted section headers — */
.panel{border-radius:5px;border-left:2px solid var(--gold);margin-bottom:12px;padding:14px 16px;}
.panel h3{font-size:11px;font-weight:600;letter-spacing:.5px;color:var(--muted);
border-bottom:1px solid var(--line);padding-bottom:9px;margin-bottom:11px;align-items:center;}
.panel h3 .caret{color:var(--gold);}
.panel h3 .c{font-size:10.5px;}
/* — KPI tiles: color-coded top rail, tighter terminal density — */
.tiles{gap:11px;margin-bottom:16px;}
.tile{border-radius:6px;padding:12px 14px;border-top:2px solid var(--line);}
.tile.gold{border-top-color:var(--gold);}
.tile.active{border-top-color:var(--active);}
.tile.draft{border-top-color:var(--draft);}
.tile.staged{border-top-color:var(--staged);}
.tile .k{font-size:10px;letter-spacing:.7px;font-weight:600;}
.tile .v{font-size:26px;line-height:1.08;margin-top:2px;}
.tile .sub{font-size:10px;margin-top:1px;}
/* persistent (not hover-only) drill affordance so it's discoverable */
.tile.clickable::after{opacity:.3;}
.tile.clickable:hover::after{opacity:1;}
/* — Tables: header rail, zebra, hover, discoverable sort affordance — */
td,th{padding:7px 8px;}
th{font-size:10px;letter-spacing:.5px;}
#segTbl thead th,#brTbl thead th,#fmTbl thead th,#xpTbl thead th{
background:var(--bg2,transparent);border-bottom:1px solid var(--line);}
/* sort affordance is already owned by the page's native th.srt.asc/.desc glyphs
+ col-resize.js's .cr-sort span — do NOT add an idle ::after here or it double-renders */
#segTbl tbody tr:nth-child(even) td,#brTbl tbody tr:nth-child(even) td,
#fmTbl tbody tr:nth-child(even) td{background:var(--stripe,transparent);}
#segTbl tbody tr:hover td,#brTbl tbody tr:hover td,
#fmTbl tbody tr:hover td,#xpTbl tbody tr:hover td{background:var(--hover,rgba(121,184,255,.10));}
/* — Loading / connection state: skeleton shimmer + honest pulse — */
@keyframes crcpShimmer{0%{opacity:.45}50%{opacity:.9}100%{opacity:.45}}
.tile.skel .v{color:transparent;background:var(--line);border-radius:4px;height:24px;width:62%;margin-top:5px;animation:crcpShimmer 1.3s ease-in-out infinite;}
.tile.skel .sub{color:transparent;background:var(--line);border-radius:3px;height:8px;width:75%;margin-top:6px;opacity:.5;}
.pulse.reconnecting{background:var(--gold);animation:pWarn 1.6s infinite;}
@keyframes pWarn{0%{box-shadow:0 0 0 0 rgba(200,162,75,.55)}70%{box-shadow:0 0 0 9px rgba(200,162,75,0)}100%{box-shadow:0 0 0 0 rgba(200,162,75,0)}}
#crcpConnBanner{display:none;margin:0 0 14px;background:rgba(231,76,60,.10);border:1px solid var(--del);
border-radius:6px;padding:9px 13px;font-size:12.5px;color:var(--del);}
#crcpConnBanner.show{display:block;}
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</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">📋 Listings</a><a href="/broker-grid.html">👥 Brokers grid</a><a href="/firms.html">🏢 Firms</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><a href="/lending.html">💰 Lending</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 class="srt" data-col="label">Segment</th><th class="srt" data-col="product">Category / note</th><th class="srt n" data-col="props">Properties</th><th class="srt n" data-col="priceMed">Price range (med)</th><th class="srt n" data-col="agents">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>
<a class="btn loanbtn" href="/condos.html?status=unwarrantable" title="Every non-FHA-approved condo — the deals conventional lenders decline">💰 Unwarrantable Loans — non-QM opportunities <span id="unwarrC" class="loanct"></span></a>
<div class="meta">FHA/VA-approval-based proxy — NOT lender-verified Fannie/Freddie warrantability. “Unwarrantable” = every condo not FHA-approved (non-QM / portfolio lending target).</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 class="panel"><h3>⚠️ FHA approval expiring within <span id="xpWin">12</span> months <span class="c" id="xpC"></span></h3>
<div class="wtabs" data-xp-win>
<button type="button" class="wtab" data-months="6" data-days="183">6 months</button>
<button type="button" class="wtab on" data-months="12" data-days="365">12 months</button>
<button type="button" class="wtab" data-months="24" data-days="730">24 months</button>
</div>
<table id="xpTbl"><thead><tr>
<th class="srt" data-col="project">Project</th>
<th class="srt" data-col="city">City</th>
<th class="srt" data-col="zip">ZIP</th>
<th class="srt" data-col="expires">Expires</th>
<th class="srt n" data-col="days">Days</th>
</tr></thead><tbody></tbody></table>
<div class="meta">Approved projects whose FHA certification lapses within a year — a buyer using FHA financing should confirm re-certification. FHA-approval PROXY signal, not lender-verified Fannie/Freddie warrantability.</div>
</div>
<div class="panel dscr" id="dscrPanel"><h3>🧮 DSCR Qualifier — does the property qualify itself? <span class="c">no tax returns · rents ÷ PITI</span></h3>
<div class="meta">A <b>DSCR</b> (debt-service-coverage-ratio) loan qualifies on the <b>property's own cash flow</b>, not the borrower's tax returns — the alternative to conventional commercial financing on small multifamily. Enter the rents and the payment: if the current or projected fair-market rents cover the PITI at the lender's ratio, it's approvable.</div>
<div class="dscr-grid" style="margin-top:14px">
<div>
<div class="dfield"><label>Property (used in Frank's note)</label><input type="text" id="dAddr" placeholder="e.g. the 95th St 6-unit"></div>
<div class="dfield"><label>Rent basis</label>
<div class="dtog" id="dRentBasis"><button type="button" class="on" data-v="current">Current rents</button><button type="button" data-v="projected">Projected fair-market</button></div>
</div>
<div class="dfield"><label>Rent roll — monthly rent per unit</label>
<div id="dUnits"></div>
<button type="button" class="addunit" id="dAddUnit">+ add unit</button>
<div class="rentsum">Gross monthly rents: <b id="dRentSum">—</b> · annual <b id="dRentAnn">—</b></div>
</div>
<div class="dfield"><label>Vacancy % (0 = fully occupied)</label><input type="number" id="dVac" value="0" min="0" max="30" step="1"></div>
<div class="dfield"><label>PITI — the monthly payment</label>
<div class="dtog" id="dPitiMode"><button type="button" class="on" data-v="build">Build it</button><button type="button" data-v="direct">Enter directly</button></div>
</div>
<div id="dBuild">
<div class="dfield"><label>Payment type</label>
<div class="dtog" id="dPayType"><button type="button" class="on" data-v="pi">P&I (amortizing)</button><button type="button" data-v="io">Interest-only</button></div>
</div>
<div class="row2">
<div class="dfield"><label>Loan amount</label><input type="number" id="dLoan" value="960000" min="0" step="1000"></div>
<div class="dfield"><label>Rate %</label><input type="number" id="dRate" value="7.25" min="0" step="0.05"></div>
</div>
<div class="row2">
<div class="dfield"><label>Amortization (yrs)</label><input type="number" id="dAmort" value="30" min="1" step="1"></div>
<div class="dfield"><label>Taxes / mo</label><input type="number" id="dTax" value="1250" min="0" step="10"></div>
</div>
<div class="row2">
<div class="dfield"><label>Insurance / mo</label><input type="number" id="dIns" value="450" min="0" step="10"></div>
<div class="dfield"><label>HOA / other / mo</label><input type="number" id="dHoa" value="0" min="0" step="10"></div>
</div>
</div>
<div id="dDirect" style="display:none">
<div class="dfield"><label>PITI / mo (all-in payment)</label><input type="number" id="dPiti" value="8200" min="0" step="10"></div>
</div>
<div class="dfield"><label>Approval DSCR (lender's line)</label><input type="number" id="dThresh" value="1.20" min="0.5" max="3" step="0.05"></div>
</div>
<div>
<div class="dresult">
<div class="k" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">DSCR = rents ÷ PITI</div>
<div class="ratio" id="dRatio">—</div>
<div id="dBadge" class="dbadge">—</div>
<div class="brk" id="dBrk"></div>
</div>
<div style="margin-top:14px">
<div class="k" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Frank's note to the referring agent</div>
<pre id="dNote"></pre>
<div class="dactions"><button type="button" class="prime" id="dCopy">📋 Copy note</button><button type="button" id="dQueue">🎬 Queue to producer</button></div>
<div class="gate">Draft only — sending / producing is gated (queued for Steve, never auto-sent).</div>
</div>
</div>
</div>
</div>
<div class="panel" id="solPanel"><h3>📣 Solicitation Studio — cold & warm outreach content <span class="c" id="solC">ads · video · music · social</span></h3>
<div class="solbar">
<div class="soltabs" id="solTone" title="Cold = never contacted · Warm = prior relationship">
<button type="button" class="soltab on" data-tone="cold">❄️ Cold</button>
<button type="button" class="soltab" data-tone="warm">🔥 Warm</button>
</div>
<div class="solaud" id="solAud"></div>
</div>
<div class="soltypes" id="solTypes" style="margin-bottom:6px">
<button type="button" class="soltype on" data-type="ad">📝 Ad copy</button>
<button type="button" class="soltype" data-type="video">🎬 Video script</button>
<button type="button" class="soltype" data-type="music">🎵 Music / jingle</button>
<button type="button" class="soltype" data-type="social">📣 Social post</button>
</div>
<div class="meta">Pick a tone + audience + content type — the Studio drafts a ready-to-produce solicitation grounded in that segment's live pipeline. Production & publishing stay gated (queued for Steve).</div>
<div id="solOut" class="solout"><div class="meta" style="margin:0">Choose an audience above to draft the outreach.</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 src="/finance.js"></script>
<script>
const $=s=>document.querySelector(s);
const money=n=>n?'$'+Number(n).toLocaleString():'—';
const fd10=s=>(s||'').toString().slice(0,10); // public-record dates are date-only
// date+TIME — only for genuine capture timestamps (listed_at); public sale/deed dates stay date-only
const fdt=s=>{ if(!s)return''; try{ const d=new Date(s),S=String(s); const t=/T\d\d:\d\d/.test(S)&&!/T0[0-7]:00:00/.test(S); return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})+(t?', '+d.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}):''); }catch(e){ return fd10(s); } };
// Shared property-bucket renderer for broker + firm modals (DTD-C rider 2026-07-31): show ONLY
// the buckets that have real data — never a hollow "Closed (0)" note-section — and collapse the
// gaps into ONE honest coverage line, so the modal reads "active listings only" cleanly instead
// of looking broken. Active is always shown (it's the covered dimension).
function propBuckets(d){
const active=d.current||[], closed=d.closed||[], expired=d.expired||[];
let html=`<div class="sec"><h4>Active / listed (${active.length})</h4>${active.length?active.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 '+fdt(l.listed_at):''}</span></div>`).join(''):'<div class="meta">none in our set</div>'}</div>`;
if(closed.length) html+=`<div class="sec"><h4>Closed (${closed.length})</h4>${closed.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}</span><span class="m">${money(l.sold_price)}${l.sold_date?' · '+fd10(l.sold_date):''}${l.type?' · '+l.type:''}</span></div>`).join('')}</div>`;
if(expired.length) html+=`<div class="sec"><h4>Withdrawn / expired / off-market (${expired.length})</h4>${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)} · ${fd10(x.sold_date)}${x.mls?' · MLS '+x.mls:''}${x.url?` · <a href="${x.url}" target="_blank" rel="noopener noreferrer">↗</a>`:''}</span></div>`).join('')}</div>`;
const gaps=[]; if(!closed.length) gaps.push('closed'); if(!expired.length) gaps.push('withdrawn/expired');
if(gaps.length) html+=`<div class="meta" style="margin-top:6px;opacity:.85">📊 Coverage: active listings only${active.length?'':' — none attributed here yet'}. ${gaps.join(' & ')} history isn’t in free public records (needs a paid records source).</div>`;
return html;
}
// Fire-and-forget modal-open telemetry (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){} }
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;
track('broker_modal',{kind:'broker',id:b&&b.id,name:b&&b.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
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>
${propBuckets(d)}
${(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||{};
track('firm_modal',{kind:'firm',name:d.firm&&d.firm.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
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>
${propBuckets(d)}
<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>`; }
// Segments are a fixed summary/lookup set (a handful of rows), NOT a browsable catalog —
// so no column machinery, just page-owned click-to-sort on the multi-row result table
// (Steve list-build rule: any multi-row result table must be click-sortable). Segment
// rows stay click-to-drill; empty/null values always sink to the bottom.
let SEGMENTS=[], segSort={col:null,dir:-1};
function renderSegments(){
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}`; };
let rows=SEGMENTS.slice();
if(segSort.col){ const c=segSort.col, dir=segSort.dir;
rows.sort((a,b)=>{ let x=a[c], y=b[c];
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)&&x!=='', yn=!isNaN(+y)&&y!=='';
if(xn&&yn) return dir*((+x)-(+y));
return dir*String(x).toLowerCase().localeCompare(String(y).toLowerCase()); }); }
$('#segTbl tbody').innerHTML=rows.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));
markHdr('segTbl',segSort);
}
async function loadSegments(){
let d; try{ d=await (await fetch('/api/segments')).json(); }catch(e){ return; }
SEGMENTS=d.segments||[]; renderSegments();
}
// wire segment header sort once (page-owned, same pattern as brTbl/fmTbl)
document.querySelectorAll('#segTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col;
if(segSort.col===c) segSort.dir*=-1; else { segSort.col=c; segSort.dir=(th.classList.contains('n'))?-1:1; }
renderSegments(); });
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); });
}
// Warrantability (FHA proxy) drill-down — count -> filtered condo list -> each row hrefs into the
// full Condos page (?status=&q=). Data from /api/fha-condos (data/fha-approved-condos.json). TK-10092.
async function openWarrant(status){
status = status || 'fha_approved';
const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading…</div>';
const q = window._waQ || '';
let d; try{ d=await (await fetch('/api/fha-condos?status='+encodeURIComponent(status)+'&q='+encodeURIComponent(q))).json(); }
catch(e){ mb.innerHTML='<div class="meta">error loading warrantability list</div>'; return; }
if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
const titles={ fha_approved:'✅ FHA-approved condos', fha_expired:'⚑ FHA-expired condos (flag / review)', all:'All condos · FHA proxy' };
const rows=d.condos||[];
const nApp=(lastStats&&lastStats.fhaApproved)||0, nExp=(lastStats&&lastStats.fhaExpired)||0;
const chip=(v,l,n)=>`<button class="wtab${v===status?' on':''}" data-st="${v}">${l} <span class="c">${fmt(n)}</span></button>`;
mb.innerHTML=`<h2>${titles[status]||status}</h2>
<div class="mfirm">${esc(d.label||'')}</div>
<div class="wtabs">${chip('fha_approved','FHA-approved',nApp)}${chip('fha_expired','FHA-expired',nExp)}${chip('all','All',nApp+nExp)}</div>
<input id="waSearch" class="wsearch" type="text" placeholder="filter these condos — project, city, zip, address… (server-side)" value="${esc(q)}">
<div class="sec"><h4>${fmt(d.count)} condo${d.count===1?'':'s'}${d.count>rows.length?` · showing first ${fmt(rows.length)}`:''} — click one to open it in the full Condos page</h4>
${rows.length?rows.map(c=>{
const proj=esc(c.project_name||c.condo_id||'—');
const loc=[c.city,c.zip].filter(Boolean).join(' ');
const conc=c.fha_concentration?` · ${esc(c.fha_concentration)} FHA`:'';
const meth=c.approval_method?` · ${esc(c.approval_method)}`:'';
const dates=c.expiration_date?` · exp ${esc(c.expiration_date)}`:(c.status_date?` · ${esc(c.status_date)}`:'');
const href=`/condos.html?status=${encodeURIComponent(status==='all'?'all':status)}&q=${encodeURIComponent(c.project_name||c.city||'')}`;
return `<a class="litem wrow" href="${href}" title="Open ${proj} in the Condos page">
<span class="a">${proj}</span>
<span class="m">${esc(loc)}${conc}${meth}${dates}</span></a>`;
}).join(''):'<div class="meta">none in this bucket</div>'}
</div>
<div class="meta" style="margin-top:10px"><a href="/condos.html?status=${encodeURIComponent(status)}" style="color:var(--gold)">Open the full Condos page for this filter ↗</a></div>`;
mb.querySelectorAll('.wtab').forEach(b=>b.onclick=()=>{ const bx=mb.querySelector('#waSearch'); window._waQ = bx?bx.value:''; openWarrant(b.dataset.st); });
const box=mb.querySelector('#waSearch');
if(box){ let t; box.oninput=()=>{ clearTimeout(t); t=setTimeout(()=>{ window._waQ=box.value; openWarrant(status); }, 300); };
box.focus(); box.setSelectionRange(box.value.length, box.value.length); }
}
const fmt=n=>n==null?'—':Number(n).toLocaleString();
let prev={};
function tile(k,key,v,sub,cls,href){ const d=(prev[key]!=null&&v>prev[key])?('+'+(v-prev[key])):'';
return `<div class="tile ${cls||''}${href?' clickable':''}" data-key="${key}" data-href="${href||''}"><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,'>').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); });
// Panel 7 — FHA approval expiring within 12 months. Own endpoint (daily-churn data → slow refresh,
// not the 3s poll). Soonest-to-lapse first; the Days cell reddens under 30d, ambers under 90d.
// Reuses the same srt()/markHdr() sort engine as the broker/firm tables.
let xpSort={col:'days',dir:1}, xpData=[], xpDays=+localStorage.getItem('crcp_xp_days')||365;
const xesc=s=>String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m]));
function renderExpiring(){
const rows=srt(xpData, xpSort.col, xpSort.dir);
$('#xpC').textContent=rows.length+' projects · soonest first · click a header to sort';
$('#xpTbl tbody').innerHTML=rows.map(r=>{
const urg=r.days<=30?'color:#c0392b;font-weight:600':r.days<=90?'color:#b8860b':'';
return `<tr class="xprow" data-q="${xesc(r.project)}" style="cursor:pointer" title="Open ${xesc(r.project)} in the Condos page">`+
`<td>${xesc(r.project)}</td><td>${xesc(r.city)}</td><td>${xesc(r.zip)}</td><td>${xesc(r.expires)}</td><td class="n" style="${urg}">${r.days<=30?'⚠️ ':''}${r.days}d</td></tr>`;
}).join('');
// each expiring project drills to its record ("all data points href", per contrarian gate)
document.querySelectorAll('#xpTbl .xprow').forEach(tr=>tr.onclick=()=>{ location.href='/condos.html?status=fha_approved&q='+encodeURIComponent(tr.dataset.q); });
markHdr('xpTbl',xpSort);
}
document.querySelectorAll('#xpTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(xpSort.col===c) xpSort.dir*=-1; else {xpSort.col=c; xpSort.dir=1;} renderExpiring(); });
async function loadExpiring(){ try{ const d=await (await fetch('/api/fha-expiring?days='+xpDays)).json(); xpData=d.condos||[]; renderExpiring(); }catch(e){ $('#xpC').textContent='reconnecting…'; } }
// window toggle (6/12/24 mo): re-query + persist; reflects the persisted choice on load.
(function(){
const wrap=document.querySelector('[data-xp-win]'), winLbl=document.getElementById('xpWin'); if(!wrap) return;
const sync=b=>{ wrap.querySelectorAll('.wtab').forEach(x=>x.classList.toggle('on',x===b)); if(winLbl) winLbl.textContent=b.dataset.months; };
const init=[...wrap.querySelectorAll('.wtab')].find(b=>+b.dataset.days===xpDays); if(init) sync(init);
wrap.querySelectorAll('.wtab').forEach(b=>b.addEventListener('click',()=>{ xpDays=+b.dataset.days; localStorage.setItem('crcp_xp_days',xpDays); sync(b); loadExpiring(); }));
})();
loadExpiring(); setInterval(loadExpiring, 300000);
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','/mls.html'),
tile('Brokers','brokers',s.brokers,(s.colist!=null?s.colist+' co-list links':''),'active','/broker-grid.html'),
tile('Firms','firms',s.firms,'brokerages','active','/firms.html'),
tile('Condos scraped','condos',s.condos,(s.condosByStatus?Object.entries(s.condosByStatus).map(([k,v])=>v+' '+k.replace('_',' ')).join(' · '):'awaiting Job A'),'staged','/condos.html'),
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','/broker-grid.html?type=residential'),
tile('FHA-approved','fhaApproved',s.fhaApproved,'warrantable (proxy)','active','/condos.html?status=fha_approved'),
tile('Broker edges','edges',s.edges,'broker↔listing','gold','/brokers.html')
].join('');
// Every stat tile drills to its filtered-list page ("all data points href", TK-10092).
document.querySelectorAll('#tiles .tile[data-href]').forEach(t=>{ const h=t.dataset.href;
if(h){ t.classList.add('clickable'); t.title='Open '+h.replace(/^\//,'').replace('.html',''); t.onclick=()=>{ location.href=h; }; } });
// FHA-approved tile: left-click opens the warrantability modal (richer than a page);
// data-href=/condos.html?status=fha_approved stays as the right-click/keyboard/scraper fallback.
const fhaTile=document.querySelector('#tiles .tile[data-key="fhaApproved"]');
if(fhaTile){ fhaTile.classList.add('clickable'); fhaTile.title='Click to list the FHA-approved condos';
fhaTile.onclick=()=>{ window._waQ=''; openWarrant('fha_approved'); }; }
// enrichment bars (the LIVE populating)
// header count is itself a drill into the broker grid (stopPropagation so it doesn't toggle panel collapse)
$('#enrLbl').innerHTML = `<span class="clink" title="Open the broker grid">${(s.brokers_phone||0)} phone · ${(s.brokers_email||0)} email ↗</span>`;
const enrLink=$('#enrLbl .clink'); if(enrLink) enrLink.onclick=e=>{ e.stopPropagation(); location.href='/broker-grid.html'; };
// each enrichment count drills to the broker grid (where phone/email/website are columns) — "all data points href"
$('#enrBars').innerHTML =
`<div class="wclk" title="Open the broker grid — phone column">`+bar('Phone captured', s.brokers_phone||0, s.brokers, 'var(--active)')+`</div>`
+ `<div class="wclk" title="Open the broker grid — email column">`+bar('Email captured', s.brokers_email||0, s.brokers, 'var(--draft)')+`</div>`
+ (s.brokers_web!=null?`<div class="wclk" title="Open the broker grid — website column">`+bar('Website found', s.brokers_web, s.brokers, 'var(--gold)')+`</div>`:'');
document.querySelectorAll('#enrBars .wclk').forEach(el=>el.onclick=()=>{ location.href='/broker-grid.html'; });
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
const waTot=(s.fhaApproved||0)+(s.fhaExpired||0);
$('#wa').innerHTML =
`<div class="wclk" data-st="fha_approved" title="Click to list the ${fmt(s.fhaApproved||0)} FHA-approved condos">`
+ bar('FHA-approved', s.fhaApproved||0, waTot, 'var(--active)') + `</div>`
+ `<div class="wclk" data-st="fha_expired" title="Click to list the ${fmt(s.fhaExpired||0)} FHA-expired condos">`
+ bar('FHA-expired (flag/review)', s.fhaExpired||0, waTot, 'var(--staged)') + `</div>`;
// Panel 4 — FHA reference-list breakdown: caption the full HUD registry size + approval-method split.
$('#waC').textContent = fmt(s.fhaTotal||waTot)+' HUD reference projects'
+ (s.fhaMethods ? ' · HRAP '+fmt(s.fhaMethods.HRAP||0)+' / DELRAP '+fmt(s.fhaMethods.DELRAP||0) : '');
document.querySelectorAll('#wa .wclk').forEach(el=>el.onclick=()=>{ window._waQ=''; openWarrant(el.dataset.st); });
// unwarrantable = scraped condos NOT FHA-approved (the non-QM lending opportunity)
const _unwarr=(s.condos||0)-((s.condosByStatus&&s.condosByStatus.fha_approved)||0);
const _uc=$('#unwarrC'); if(_uc) _uc.textContent = _unwarr>0 ? fmt(_unwarr) : '';
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 fresh = localStorage.getItem(LS_OPEN)===null; // no saved layout yet (TK-10330)
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); }
// TK-10330: land useful, not a wall of closed drawers. On a FRESH session open the
// primary deal-sourcing workflow panels (segments -> call list -> firms -> urgency);
// returning users keep whatever layout they saved.
if(fresh){
const KT=key.toLowerCase();
const seedOpen = KT.includes('call segments')||KT.includes('top brokers')||KT.includes('top firms')||KT.includes('fha approval expiring');
if(seedOpen) open.add(key); else p.classList.add('collapsed');
}
else 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);
// deep-link from the graphics dashboard: ?broker=<id>[&name=…] · ?firm=<name>
(function(){ try{
const P=new URLSearchParams(location.search);
const bid=P.get('broker'), fm=P.get('firm'), bn=P.get('brokerName');
if(bid){ openContact(P.get('name')||'', bid); }
else if(bn){ openContact(bn, null); } // name-only path (e.g. from a condo detail modal)
else if(fm){ openFirm(fm); }
}catch(e){} })();
// ── Solicitation Studio — RENTV-style outreach content for cold & warm solicitations ──
(function(){
const AUD=[
{key:'unwarrantable',label:'Unwarrantable-condo owners',cnt:s=>((s&&s.condos)||0)-((s&&s.condosByStatus&&s.condosByStatus.fha_approved)||0),
who:'owners in condo buildings that aren’t FHA-approved',pain:'conventional and FHA lenders keep declining their building',
offer:'a non-QM / portfolio condo loan',proof:'we close the condos other lenders won’t touch',drill:'/condos.html?status=unwarrantable'},
{key:'fha_expired',label:'FHA-expired HOAs',cnt:s=>(s&&s.fhaExpired)||0,
who:'HOA boards whose FHA approval has lapsed',pain:'nobody in the building can buy with FHA financing until it’s re-certified',
offer:'FHA re-certification help + bridge financing',proof:'we get buildings re-approved and buyers to the closing table',drill:'/condos.html?status=fha_expired'},
{key:'res_agents',label:'Residential agents',cnt:s=>(s&&s.agents_residential)||0,
who:'residential agents with buyers stuck on tricky condos',pain:'their buyers keep getting denied on non-warrantable or lapsed-FHA condos',
offer:'a lender partner who says yes on the hard condo deals',proof:'send me the deal your last lender killed',drill:'/broker-grid.html?type=residential'},
{key:'brokers',label:'Commercial brokers',cnt:s=>(s&&s.brokers)||0,
who:'commercial brokers with multifamily / mixed-use listings',pain:'their buyers need creative financing to close',
offer:'CRE / bridge financing',proof:'we fund the deals banks slow-walk',drill:'/broker-grid.html'},
{key:'realtor_dscr',label:'Referring agents (DSCR)',cnt:s=>(s&&s.agents_residential)||0,
who:'agents whose buyer got turned down for a tax-return-heavy commercial loan',pain:'the deal died in underwriting over docs, not the property',
offer:'a DSCR loan that qualifies on the property’s own rents — no tax returns',proof:'send me the rent roll and I’ll tell you same-day if it pencils',drill:'/broker-grid.html?type=residential'},
];
let tone='cold', type='ad', audKey='unwarrantable';
const SIGN='— Frank · your commercial-lending desk';
function gen(a){
const cold=tone==='cold', open=cold?`I work with ${a.who} and noticed ${a.pain}.`:`Great connecting — following up on ${a.who} where ${a.pain}.`;
if(type==='ad') return {subject:`Ad · ${a.label} · ${cold?'cold':'warm'}`,
body:`HEADLINE: ${cold?'Turned down on the condo?':'Still working that condo deal?'}\n\n${open}\nWe offer ${a.offer} — ${a.proof}.\n\nCTA: Reply "RATE" for a same-day scenario review.\n${SIGN}`};
if(type==='video') return {subject:`Video script (~25s) · ${a.label} · ${cold?'cold':'warm'}`,
body:`[0-4s] ON-SCREEN: "${cold?'Condo financing falling through?':'Let’s close that condo.'}"\n[4-12s] VO: ${open}\n[12-20s] VO: We offer ${a.offer}. ${a.proof}.\n[20-25s] ON-SCREEN + VO: "Send me the deal." ${SIGN}\nB-ROLL: condo exteriors, a signed closing, a handshake.`};
if(type==='music') return {subject:`Music / jingle brief · ${a.label} · ${cold?'cold':'warm'}`,
body:`MOOD: confident, warm, trustworthy (not salesy).\nTEMPO: 90-100 BPM, light piano + soft percussion.\nLENGTH: 15s bed + 5s tag.\nHOOK LINE (sung/spoken): "When the bank says no — we find the yes."\nVOICE TAG: "${a.offer}. ${a.proof}."\nUSE: bed under the ${a.label.toLowerCase()} ${type} + social reels.\n${SIGN}`};
return {subject:`Social post · ${a.label} · ${cold?'cold':'warm'}`,
body:`${cold?'🏢 ':'👋 '}${open}\n\nWe offer ${a.offer}. ${a.proof}.\n\nDM "RATE" for a same-day scenario review.\n#CREfinance #nonQM #condoloans #realestate #${a.key}`};
}
function render(){
const a=AUD.find(x=>x.key===audKey)||AUD[0];
const out=gen(a), q=(lastStats||{}), n=a.cnt(q);
const el=document.getElementById('solOut'); if(!el) return;
el.innerHTML=`<h4>${out.subject}</h4>`+
`<div class="subj">Audience: ${a.label}${n?` · ~${fmt(n)} in pipeline`:''} · <a href="${a.drill}" style="color:var(--gold)">see the list ↗</a></div>`+
`<pre id="solText"></pre>`+
`<div class="solactions"><button class="prime" id="solCopy">📋 Copy</button>`+
`<button id="solQueue">🎬 Queue to producer</button>`+
`<button id="solRe">↻ Re-draft</button></div>`+
`<div class="solgate">Draft only — video/music/ad rendering & any publish are gated (queued for Steve, never auto-sent).</div>`;
el.querySelector('#solText').textContent=out.body;
el.querySelector('#solCopy').onclick=()=>{ navigator.clipboard&&navigator.clipboard.writeText(out.body); const b=el.querySelector('#solCopy'); b.textContent='✓ Copied'; setTimeout(()=>b.textContent='📋 Copy',1200); };
el.querySelector('#solRe').onclick=render;
el.querySelector('#solQueue').onclick=()=>{
const b=el.querySelector('#solQueue'); b.disabled=true; b.textContent='queuing…';
fetch('/api/solicitation/queue',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({tone,type,audience:a.key,subject:out.subject,body:out.body})})
.then(r=>r.json()).then(d=>{ b.textContent=d&&d.ok?('✓ Queued #'+(d.count||'')+' — gated'):'✓ Queued (draft)'; })
.catch(()=>{ b.textContent='✓ Queued (local draft)'; });
};
}
function buildAud(){
const wrap=document.getElementById('solAud'); if(!wrap) return;
const q=lastStats||{};
wrap.innerHTML=AUD.map(a=>`<button type="button" class="achip${a.key===audKey?' on':''}" data-k="${a.key}">${a.label}<span class="n">${fmt(a.cnt(q))}</span></button>`).join('');
wrap.querySelectorAll('.achip').forEach(c=>c.onclick=()=>{ audKey=c.dataset.k; wrap.querySelectorAll('.achip').forEach(x=>x.classList.toggle('on',x===c)); render(); });
}
const tw=document.getElementById('solTone'); if(tw) tw.querySelectorAll('.soltab').forEach(b=>b.onclick=()=>{ tone=b.dataset.tone; tw.querySelectorAll('.soltab').forEach(x=>x.classList.toggle('on',x===b)); render(); });
const ty=document.getElementById('solTypes'); if(ty) ty.querySelectorAll('.soltype').forEach(b=>b.onclick=()=>{ type=b.dataset.type; ty.querySelectorAll('.soltype').forEach(x=>x.classList.toggle('on',x===b)); render(); });
buildAud(); render();
// refresh the audience counts once live stats arrive
let _tries=0; const _t=setInterval(()=>{ if(lastStats||_tries++>20){ buildAud(); render(); clearInterval(_t); } }, 1500);
})();
// ── DSCR Qualifier — rents ÷ PITI, no-tax-returns qualification + Frank's referral note ──
(function(){
const panel=document.getElementById('dscrPanel'); if(!panel) return;
let basis='current', pitiMode='build', payType='pi';
// Monthly P&I from loan/rate/amort — reuse the ranker's amortization (finance.js) so the desk
// and the property ranker never disagree; fall back to an inline standard mortgage formula if
// finance.js didn't load for any reason.
function monthlyPI(loan, ratePct, amortYears){
if(!(loan>0)) return 0;
if(window.CREFin && CREFin.debtService) return CREFin.debtService(loan, ratePct, amortYears)/12;
const r=ratePct/100/12, n=amortYears*12;
return r===0 ? loan/n : loan*(r*Math.pow(1+r,n))/(Math.pow(1+r,n)-1);
}
const num=id=>{ const el=document.getElementById(id); const v=el?parseFloat(el.value):NaN; return isFinite(v)?v:0; };
const usd=n=>'$'+Math.round(n).toLocaleString();
// ── rent roll ──
const DEF=[1850,1850,1850,1850,1850,1850]; // seeds the six-unit story
function unitRows(){ return Array.from(document.querySelectorAll('#dUnits .unitrow')); }
function grossMonthly(){ return unitRows().reduce((s,r)=>{ const v=parseFloat(r.querySelector('.urent').value); return s+(isFinite(v)?v:0); },0); }
function addUnit(rent){
const wrap=document.getElementById('dUnits'); const i=unitRows().length+1;
const row=document.createElement('div'); row.className='unitrow';
row.innerHTML=`<input type="text" class="ulabel" value="Unit ${i}" aria-label="Unit label">`+
`<input type="number" class="urent" value="${rent!=null?rent:1800}" min="0" step="25" aria-label="Monthly rent">`+
`<button type="button" class="udel" title="Remove unit">×</button>`;
row.querySelector('.urent').addEventListener('input',recompute);
row.querySelector('.udel').addEventListener('click',()=>{ if(unitRows().length>1){ row.remove(); recompute(); } });
wrap.appendChild(row);
}
function verdict(dscr, thresh){
if(dscr==null||!isFinite(dscr)) return {cls:'',txt:'—'};
if(dscr>=thresh) return {cls:'pass',txt:'✓ Approves — no tax returns'};
if(dscr>=1.0) return {cls:'marg',txt:'⚠ Marginal — covers, below the line'};
return {cls:'fail',txt:'✕ Short — rents don’t cover PITI'};
}
function buildNote(g){
const addr=(document.getElementById('dAddr').value||'').trim()||'that property';
const units=unitRows().length, basisWord=basis==='projected'?'projected fair-market':'current';
const cap=basisWord[0].toUpperCase()+basisWord.slice(1);
const rentsLbl = g.vac>0 ? `${cap} rents at ${g.vac}% vacancy` : `${cap} rents`;
const rentsVal = g.vac>0 ? `${usd(g.eff)}/mo effective (${usd(g.gross)} gross)` : `${usd(g.gross)}/mo (${usd(g.gross*12)}/yr)`;
const payLbl = (pitiMode==='build'&&g.payType==='io') ? 'Interest-only payment' : 'Full payment (PITI)';
let line, close;
if(g.dscr==null){ line='the payment nets to zero'; close='Send me the rent roll and I’ll pencil it.'; }
else if(g.dscr>=g.thresh){ line=`a ${g.dscr.toFixed(2)} DSCR — clears the ${g.thresh.toFixed(2)} line with ${usd(g.surplus)}/mo to spare`; close='It qualifies as-is — happy to send terms and get an app started whenever your seller’s ready.'; }
else if(g.dscr>=1.0){ line=`a ${g.dscr.toFixed(2)} DSCR — the rents cover the payment but it’s under a typical ${g.thresh.toFixed(2)} approval line`; close=`We can still get there: about ${usd(g.rentsNeeded-g.eff)}/mo more in rent, a bit more down, or a lender at ${g.dscr.toFixed(2)} min gets it done. Want me to run those?`; }
else { line=`a ${g.dscr.toFixed(2)} DSCR — the rents fall about ${usd(g.shortfall)}/mo short of the payment`; close=`It doesn’t pencil at asking on rents alone — projected fair-market rents or more down would move it. Want me to model it?`; }
return `Hey — thanks for the list. I called the agent on ${addr} and ran DSCR numbers on it as an alternative to commercial financing on that ${units}-unit.\n\n`+
`The nice part: it’s a DSCR loan, so no tax returns — it qualifies on the building’s own rents.\n\n`+
` • ${rentsLbl}: ${rentsVal}\n`+
` • ${payLbl}: ${usd(g.piti)}/mo\n`+
` • DSCR: ${g.dscr==null?'—':g.dscr.toFixed(2)} → ${line}\n\n`+
`${close}\n\n`+
`Happy to run a same-day scenario on any of the others on your list.\n— Frank · your commercial-lending desk`;
}
function recompute(){
const gross=grossMonthly();
const vac=Math.max(0,Math.min(100,num('dVac'))); // vacancy %; 0 = fully occupied
const eff=gross*(1-vac/100); // effective rents used for DSCR
document.getElementById('dRentSum').textContent=usd(gross)+(vac>0?` → ${usd(eff)} @ ${vac}% vac`:'');
document.getElementById('dRentAnn').textContent=usd((vac>0?eff:gross)*12);
let piti, pi=null;
if(pitiMode==='build'){
// interest-only = loan × monthly rate; else amortizing P&I (CREFin, single source of truth)
pi = payType==='io' ? num('dLoan')*(num('dRate')/100/12) : monthlyPI(num('dLoan'),num('dRate'),num('dAmort')||30);
piti = pi + num('dTax') + num('dIns') + num('dHoa');
} else { piti = num('dPiti'); }
const thresh=num('dThresh')||1.20;
const dscr = piti>0 ? eff/piti : null; // DSCR on EFFECTIVE rents
const g={ gross, eff, vac, payType, piti, thresh, dscr,
surplus: eff-piti, shortfall: piti-eff,
maxPiti: thresh>0 ? eff/thresh : 0, // most PITI these rents support at the approval line
rentsNeeded: piti*thresh }; // effective rents needed to hit the approval line
// headline
document.getElementById('dRatio').textContent = dscr==null?'—':dscr.toFixed(2);
const v=verdict(dscr,thresh), badge=document.getElementById('dBadge');
badge.className='dbadge '+v.cls; badge.textContent=v.txt;
document.getElementById('dRatio').style.color = v.cls==='pass'?'var(--active)':v.cls==='marg'?'var(--staged)':v.cls==='fail'?'var(--del)':'var(--fg)';
// breakdown
const gap = dscr==null?'':(dscr>=thresh
? `<div class="r"><span>Monthly surplus over payment</span><b style="color:var(--active)">${usd(g.surplus)}</b></div>`
: `<div class="r"><span>Rents needed to hit ${thresh.toFixed(2)}</span><b>${usd(g.rentsNeeded)}/mo</b></div>`+
`<div class="r"><span>${eff>=piti?'Cushion above payment':'Monthly shortfall'}</span><b style="color:${eff>=piti?'var(--active)':'var(--del)'}">${usd(Math.abs(g.surplus))}</b></div>`);
document.getElementById('dBrk').innerHTML =
`<div class="r"><span>Gross monthly rents</span><b>${usd(gross)}</b></div>`+
(vac>0?`<div class="r"><span>Effective rents (${vac}% vacancy)</span><b>${usd(eff)}</b></div>`:'')+
`<div class="r"><span>PITI (${pitiMode==='build'&&payType==='io'?'interest-only':'payment'})</span><b>${usd(piti)}</b></div>`+
`<div class="r hi"><span>Max payment these rents support @ ${thresh.toFixed(2)}</span><b>${usd(g.maxPiti)}/mo</b></div>`+
gap;
// Frank's note
document.getElementById('dNote').textContent = buildNote(g);
return g;
}
// ── wire toggles ──
document.querySelectorAll('#dRentBasis button').forEach(b=>b.onclick=()=>{ basis=b.dataset.v; b.parentNode.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b)); recompute(); });
document.querySelectorAll('#dPitiMode button').forEach(b=>b.onclick=()=>{ pitiMode=b.dataset.v; b.parentNode.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b)); document.getElementById('dBuild').style.display=pitiMode==='build'?'':'none'; document.getElementById('dDirect').style.display=pitiMode==='direct'?'':'none'; recompute(); });
document.querySelectorAll('#dPayType button').forEach(b=>b.onclick=()=>{ payType=b.dataset.v; b.parentNode.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b)); recompute(); });
['dLoan','dRate','dAmort','dTax','dIns','dHoa','dPiti','dThresh','dAddr','dVac'].forEach(id=>{ const el=document.getElementById(id); if(el) el.addEventListener('input',recompute); });
document.getElementById('dAddUnit').onclick=()=>{ addUnit(1800); recompute(); };
document.getElementById('dCopy').onclick=()=>{ const t=document.getElementById('dNote').textContent; navigator.clipboard&&navigator.clipboard.writeText(t); const b=document.getElementById('dCopy'); b.textContent='✓ Copied'; setTimeout(()=>b.textContent='📋 Copy note',1200); };
document.getElementById('dQueue').onclick=()=>{
const b=document.getElementById('dQueue'); b.disabled=true; b.textContent='queuing…';
const g=recompute(), addr=(document.getElementById('dAddr').value||'').trim()||'DSCR scenario';
fetch('/api/solicitation/queue',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({tone:'warm',type:'dscr-note',audience:'realtor_dscr',
subject:`DSCR note · ${addr} · ${g.dscr==null?'—':g.dscr.toFixed(2)}`,body:document.getElementById('dNote').textContent})})
.then(r=>r.json()).then(d=>{ b.textContent=d&&d.ok?('✓ Queued #'+(d.count||'')+' — gated'):'✓ Queued (draft)'; })
.catch(()=>{ b.textContent='✓ Queued (local draft)'; })
.finally(()=>{ setTimeout(()=>{ b.disabled=false; b.textContent='🎬 Queue to producer'; },2500); });
};
// seed the six-unit rent roll and compute once
DEF.forEach(r=>addUnit(r));
recompute();
})();
</script>
<script src="/col-resize.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script>
<script>
// Day/Night theme — pre-applied before first paint (see head snippet).
// apply() only PAINTS — does NOT persist (passive loads must not lock in a theme;
// only an explicit toggle click below saves a pref, so everyone gets the Day default).
(function(){
var root=document.documentElement;
function applyTheme(t){ root.setAttribute('data-theme',t); }
var cur; try{ cur=localStorage.getItem('crcp-theme'); }catch(e){}
applyTheme(cur==='dark'?'dark':'light');
// Wire up the toggle button injected by crcp-theme.js (defer — may not exist yet;
// observe the header for it and intercept its click to persist crcp-theme).
function wireToggle(){
var btn=document.getElementById('crcpThemeToggle'); if(!btn||btn.__wired2) return;
btn.__wired2=true;
btn.addEventListener('click',function(e){
e.stopImmediatePropagation(); // suppress crcp-theme.js's own handler so we don't double-toggle
var nt=root.getAttribute('data-theme')==='light'?'dark':'light';
applyTheme(nt); try{ localStorage.setItem('crcp-theme',nt); }catch(e){}
},true); // capture phase — fires before crcp-theme.js's own click handler
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',wireToggle); else wireToggle();
// also wire after defer scripts finish (crcp-theme.js injects the button on DOMContentLoaded)
document.addEventListener('DOMContentLoaded',wireToggle);
})();
</script>
<script>
// ── TK-10330: honest loading / connection state ────────────────────────────
// The cold-load used to read as "broken": empty KPI strip + empty panels while a
// green "live" pulse sat next to "reconnecting…". This adds (a) skeleton KPI tiles
// so the strip is never blank, (b) a real error banner, and (c) an honest amber
// pulse while reconnecting. Purely additive — poll() still owns the real render.
(function(){
function seedSkeletonTiles(){
var t=document.getElementById('tiles');
if(!t || t.children.length) return; // poll() already filled it
var labels=['Listings','Brokers','Firms','Condos','Res. agents','FHA-approved','Broker edges'];
var cls=['gold','active','draft','staged','draft','active','gold'];
t.innerHTML=labels.map(function(k,i){
return '<div class="tile skel '+cls[i]+'"><div class="k">'+k+'</div><div class="v">—</div><div class="sub"> </div></div>';
}).join('');
}
function ensureBanner(){
var w=document.querySelector('.wrap'); if(!w) return null;
var b=document.getElementById('crcpConnBanner');
if(!b){ b=document.createElement('div'); b.id='crcpConnBanner';
b.textContent='⚠ Can’t reach the server — showing the last known data. Retrying…';
w.insertBefore(b, w.firstChild); }
return b;
}
function tick(){
var lt=document.getElementById('livetxt'); var pulse=document.querySelector('.pulse'); var b=ensureBanner();
var reconnecting = lt && /reconnect/i.test(lt.textContent||'');
if(pulse) pulse.classList.toggle('reconnecting', !!reconnecting);
if(b) b.classList.toggle('show', !!reconnecting);
// once real tiles arrive, the skeleton class is naturally gone (poll replaces innerHTML)
}
function start(){ seedSkeletonTiles(); tick(); setInterval(tick, 1500); }
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',start); else start();
})();
</script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>