← back to Commercialrealestate
public/just-listed.html
603 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>
<!-- CRCP day/night: apply the saved theme BEFORE first paint (single key 'crcp-theme', matching crcp-theme.js). Default = dark. -->
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<!-- 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">
<title>Just Listed — Active Inventory · CRCP</title>
<style>
/* Local dark fallbacks; /crcp-theme.css (loaded LAST) overrides these per html[data-theme]. */
:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--rowpad:6px;--cols:4}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,Helvetica,Arial,sans-serif;font-size:13px}
a{color:var(--blue)}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
h1{font-size:17px;margin:0}h1 b{color:var(--ink)}
.badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--onacc);background:var(--acc);padding:3px 8px;border-radius:6px}
.nav{display:flex;align-items:center;gap:2px;flex-wrap:wrap}
.nav a{color:var(--blue);text-decoration:none;font-size:12px;margin-left:12px}
/* ticker */
.ticker{display:flex;align-items:stretch;background:var(--bg2,#0b0e13);border-bottom:1px solid var(--line);overflow:hidden;height:32px}
.ticker .lbl{flex:0 0 auto;display:flex;align-items:center;gap:6px;background:var(--acc);color:#0b0e13;font-weight:800;font-size:10px;letter-spacing:.6px;text-transform:uppercase;padding:0 13px;white-space:nowrap}
.ticker .lbl::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--bg2,#0b0e13);animation:blip 1.1s steps(1) infinite}
@keyframes blip{50%{opacity:.25}}
.ticker .vp{flex:1;overflow:hidden;position:relative}
.ticker .track{display:inline-flex;align-items:center;height:100%;white-space:nowrap;animation:tick 90s linear infinite;will-change:transform}
.ticker .vp:hover .track{animation-play-state:paused}
.ticker .it{display:inline-flex;align-items:center;gap:8px;padding:0 20px;font-size:12px;color:var(--ink);border-right:1px solid var(--line)}
.ticker .it .t{font-weight:800;font-size:9px;letter-spacing:.4px;padding:2px 6px;border-radius:5px}
.ticker .it .t.listed{background:rgba(63,185,80,.18);color:var(--acc)}
.ticker .it .t.closed{background:rgba(255,166,0,.16);color:var(--gold)}
@keyframes tick{from{transform:translateX(0)}to{transform:translateX(-50%)}}
@media (prefers-reduced-motion:reduce){.ticker .track{animation:none}}
.shell{display:flex;align-items:flex-start}
/* ── left rail: ALL sections start collapsed (CRCP .panel.collapsed pattern) ── */
.rail{width:238px;flex:0 0 238px;border-right:1px solid var(--line);padding:10px;height:calc(100vh - 84px);overflow:auto;position:sticky;top:0}
.panel{border:1px solid var(--line);border-radius:10px;margin-bottom:8px;background:var(--card);overflow:hidden}
.panel h4{margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);padding:9px 11px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none}
.panel h4 .caret{transition:transform .15s;font-size:9px;opacity:.7}
.panel.collapsed h4 .caret{transform:rotate(-90deg)}
.panel .body{padding:9px 11px;border-top:1px solid var(--line)}
.panel.collapsed .body{display:none}
.panel h4 .n{color:var(--mut);font-weight:600;font-size:10px}
.chips{display:flex;flex-wrap:wrap;gap:5px}
.chip{border:1px solid var(--line);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer;background:var(--bg2,var(--card));user-select:none;color:var(--ink)}
.chip.active{background:var(--blue);color:var(--onacc);border-color:var(--blue);font-weight:600}
.chip .ct{color:var(--mut);margin-left:4px;font-size:10px}.chip.active .ct{color:var(--onacc)}
.rail input[type=number]{width:100%;background:var(--bg2,var(--card));border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
/* column-picker inside the rail */
.colgrid{display:grid;grid-template-columns:1fr 1fr;gap:1px 12px}
.colgrid .ph{grid-column:1/-1;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin:0 0 4px;display:flex;justify-content:space-between}
.colgrid .ph a{color:var(--blue);cursor:pointer}
.colgrid label{display:flex;align-items:center;gap:6px;font-size:12px;padding:2px 2px;cursor:pointer;white-space:nowrap;color:var(--ink);border-radius:6px}
.colgrid label:hover{background:var(--hover,rgba(88,166,255,.08))}
.colgrid input{accent-color:var(--blue)}
.main{flex:1;padding:12px 16px;min-width:0}
.toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
.search{flex:1;min-width:200px;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 11px;font-size:13px}
select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
.btn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 11px;font-size:12px;cursor:pointer;white-space:nowrap}
.btn:hover{border-color:var(--blue)}
.btn.on{background:var(--blue);color:var(--onacc);border-color:var(--blue);font-weight:600}
.count{color:var(--mut);font-size:12px}
.prov{font-size:11px;color:var(--mut);margin:0 0 12px;line-height:1.5}
.stat{display:inline-block;background:var(--card);border:1px solid var(--line);border-radius:9px;padding:7px 12px;margin:0 8px 10px 0}
.stat b{font-size:18px;color:var(--ink)}.stat span{display:block;font-size:10px;text-transform:uppercase;color:var(--mut);letter-spacing:.4px}
.st{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 6px;border-radius:6px;white-space:nowrap;display:inline-block}
.st.active{background:rgba(63,185,80,.16);color:var(--acc)}
.st.pending{background:rgba(255,166,0,.16);color:var(--gold)}
.st.other{background:rgba(139,148,158,.16);color:var(--mut)}
.newpill{font-size:9px;font-weight:800;letter-spacing:.4px;color:var(--onacc);background:var(--acc);padding:2px 6px;border-radius:5px;text-transform:uppercase}
/* ── inline chat bar (in the middle of the page) ── */
.chatbar{border:1px solid var(--line);border-radius:12px;background:var(--card);margin:0 0 12px;overflow:hidden}
.chatbar .ch-hd{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;user-select:none;border-bottom:1px solid transparent}
.chatbar.open .ch-hd{border-bottom-color:var(--line)}
.chatbar .ch-hd .dot{width:8px;height:8px;border-radius:50%;background:var(--acc)}
.chatbar .ch-hd b{font-size:12.5px}
.chatbar .ch-hd .hint{color:var(--mut);font-size:11px;margin-left:auto}
.chatbar .ch-body{display:none;padding:10px 12px}
.chatbar.open .ch-body{display:block}
.chatlog{max-height:230px;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:9px}
.msg{padding:8px 11px;border-radius:10px;font-size:12.5px;line-height:1.45;max-width:88%;white-space:pre-wrap}
.msg.u{align-self:flex-end;background:var(--blue);color:var(--onacc)}
.msg.a{align-self:flex-start;background:var(--bg2,rgba(255,255,255,.05));border:1px solid var(--line)}
.msg.a.think{color:var(--mut);font-style:italic}
.chatform{display:flex;gap:8px}
.chatform input{flex:1;background:var(--bg2,var(--card));border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:9px 11px;font-size:13px}
/* ── view containers (Grid / List / Table via ThreeView) ── */
#gridView{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:12px}
.gcard{border:1px solid var(--line);border-radius:12px;background:var(--card);padding:12px;cursor:pointer;position:relative;opacity:0;transform:translateY(8px);animation:cardin .4s ease forwards}
@keyframes cardin{to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.gcard{animation:none;opacity:1;transform:none}}
.gcard:hover{border-color:var(--blue)}
.gcard .addr{font-weight:700;font-size:13.5px;margin:0 0 2px;color:var(--ink)}
.gcard .sub{color:var(--mut);font-size:11.5px;margin-bottom:8px}
.gcard .price{color:var(--gold);font-weight:800;font-size:16px}
.gcard .meta{display:flex;flex-wrap:wrap;gap:5px 14px;margin:7px 0;font-size:12px}
.gcard .meta .k{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.4px;margin-right:4px}
.gcard .up{color:var(--mut);font-size:11.5px;line-height:1.4;margin-top:6px;border-top:1px solid var(--line);padding-top:7px}
.gcard .newflag{position:absolute;top:10px;right:10px}
/* broker contact block — ALWAYS visible (Steve hard rule) */
.broker{margin-top:8px;border-top:1px solid var(--line);padding-top:7px;font-size:12px}
.broker .bf{font-weight:600;color:var(--ink)}
.broker .bc{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:3px}
.broker .bc a{color:var(--blue);text-decoration:none}
.broker .bc a:hover{text-decoration:underline}
.broker .bc .none{color:var(--mut)}
.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 320px)}
table{border-collapse:collapse;width:100%;font-size:12.5px}
thead th{position:sticky;top:0;background:var(--headbg,#11161d);z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;cursor:pointer;user-select:none;color:var(--mut);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
thead th:hover{color:var(--ink)}
thead th.sorted{color:var(--blue)}
thead th[draggable=true]{cursor:grab}
thead th.dragging{opacity:.45}
thead th.dropzone{box-shadow:inset 3px 0 0 var(--blue);color:var(--ink)}
thead th .ar{opacity:.35;font-size:9px;margin-left:4px}
thead th.sorted .ar{opacity:1}
thead th.num{text-align:right}
tbody td{padding:var(--rowpad) 11px;border-bottom:1px solid var(--line);vertical-align:top;line-height:1.35}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
tbody tr{cursor:pointer}
tbody tr:hover{background:var(--hover,rgba(88,166,255,.06))}
td.price{color:var(--gold);font-weight:600}
td.wrap{white-space:normal;min-width:200px;max-width:360px;color:var(--mut)}
td a.f{color:var(--blue);text-decoration:none}
td a.f:hover{text-decoration:underline}
.empty{color:var(--mut);padding:40px;text-align:center}
/* full-spec modal (CRCP #ov/#modal) */
#ov{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9990;display:none;align-items:flex-start;justify-content:center;padding:5vh 12px;overflow:auto}
#ov.on{display:flex}
#modal{width:min(720px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative}
#modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;line-height:1}
#modal h2{margin:0 4px 2px 0;font-size:20px;color:var(--ink)}
#modal .msub{color:var(--gold);font-size:13px;margin-bottom:12px}
#modal .specs{display:grid;grid-template-columns:1fr 1fr;gap:8px 18px;margin:0 0 12px}
#modal .specs div{font-size:13px;border-bottom:1px solid var(--line);padding:5px 0}
#modal .specs .k{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.4px;display:block}
#modal .mbroker{border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:var(--bg2,rgba(255,255,255,.03));margin-bottom:10px}
#modal .mbroker h3{margin:0 0 5px;font-size:12px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut)}
#modal .mbroker a{color:var(--blue);text-decoration:none;margin-right:16px}
#modal .note{color:var(--mut);font-size:12.5px;line-height:1.5}
</style> <link rel="stylesheet" href="/three-view.css">
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so it wins -->
<style>/* theme-aware "text on accent" so chips/buttons/pills/chat bubbles pass contrast in BOTH themes (Cody gate): accents are LIGHT in dark mode (black text), DARK in light mode (white text) */:root{--onacc:#0e1116}html[data-theme="light"]{--onacc:#ffffff}</style>
</head><body>
<header>
<h1><b>Just Listed</b> · Active Inventory</h1>
<span class="badge" id="srcBadge">active listings</span>
<div class="nav"><a href="/deals-flow.html">Deal Flow</a><a href="/mls.html">Ranked Deals</a><a href="/">Explorer</a></div>
<span id="auth" style="margin-left:auto;font-size:12px;color:var(--blue);cursor:pointer">Sign in</span>
</header>
<div class="ticker" id="ticker" style="display:none" aria-label="Breaking real estate headlines"><div class="lbl" aria-hidden="true">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
<div class="shell">
<aside class="rail" id="rail">
<div class="panel collapsed" data-p="status"><h4>Status <span><span class="n" id="nStatus"></span> <span class="caret">▼</span></span></h4><div class="body"><div class="chips" id="fStatus"></div></div></div>
<div class="panel collapsed" data-p="type"><h4>Property type <span><span class="n" id="nType"></span> <span class="caret">▼</span></span></h4><div class="body"><div class="chips" id="fType"></div></div></div>
<div class="panel collapsed" data-p="city"><h4>City <span><span class="n" id="nCity"></span> <span class="caret">▼</span></span></h4><div class="body"><div class="chips" id="fCity"></div></div></div>
<div class="panel collapsed" data-p="price"><h4>Price range $ <span class="caret">▼</span></h4><div class="body">
<label style="font-size:10px;color:var(--mut)">min</label><input type="number" id="pMin" placeholder="e.g. 1000000" step="250000">
<label style="font-size:10px;color:var(--mut);margin-top:6px;display:block">max</label><input type="number" id="pMax" placeholder="e.g. 5000000" step="250000"></div></div>
<div class="panel collapsed" data-p="units"><h4>Units <span class="caret">▼</span></h4><div class="body">
<label style="font-size:10px;color:var(--mut)">min</label><input type="number" id="uMin" placeholder="e.g. 5">
<label style="font-size:10px;color:var(--mut);margin-top:6px;display:block">max</label><input type="number" id="uMax" placeholder="any"></div></div>
<div class="panel collapsed" data-p="cols"><h4>Columns shown <span class="caret">▼</span></h4><div class="body"><div class="colgrid" id="colGrid"></div></div></div>
</aside>
<div class="main">
<div id="stats"></div>
<div class="prov" id="prov"></div>
<!-- CHAT BAR — in the middle of the page, grounded in the current filtered listings -->
<div class="chatbar" id="chatbar">
<div class="ch-hd" id="chHd"><span class="dot"></span><b>Ask the CRE analyst</b><span class="hint" id="chHint">grounded in the listings shown ▾</span></div>
<div class="ch-body">
<div class="chatlog" id="chatlog"></div>
<form class="chatform" id="chatform"><input id="chatin" placeholder="e.g. which of these has the best cap rate under $2M?" autocomplete="off"><button class="btn" type="submit">Send</button></form>
</div>
</div>
<div class="toolbar">
<input class="search" id="q" placeholder="search address, city, type, firm, broker, upside… (space = AND)">
<span data-tvseg id="viewseg"></span>
<select id="tvSort"></select>
<button class="btn" id="newBtn" title="Show only listings first seen recently">🆕 New only</button>
<label style="font-size:11px;color:var(--mut)">crawl <input type="range" id="drip" min="0" max="10" step="1" title="0 = instant · 10 = slow crawl reveal"></label>
<button class="btn" id="csvBtn" title="Download the current filtered/sorted view as CSV">⭳ CSV</button>
<label style="font-size:11px;color:var(--mut)">density <input type="range" id="dens" min="1" max="8" step="1"></label>
<span class="count" id="count"></span>
</div>
<div id="gridView"></div>
<div id="tvList"></div>
<div class="tablewrap" id="tableView"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<div class="empty" id="empty" hidden>No listings match.</div>
</div></div>
<!-- full-spec modal -->
<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 esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
const money=n=>n==null||isNaN(+n)?null:'$'+Math.round(+n).toLocaleString();
let DATA=[],META={},q='',sortKey='price',sortDir=-1,ME=null,TV=null;
let BROKERS={}; // firm_key/lc-firm -> {name,firm,phone,email,website,linkedin} (weak fallback)
let ADDRC={}; // normalized-address -> {name,phone,email,website} (REAL per-listing agent, primary)
let SEEN={}; // localStorage first-seen ledger (id -> iso)
let DATES={}; // /api/listing-dates rows -> normalized-address -> iso created_at (ids don't overlap; join on address)
let onlyNew=false;
const NEW_DAYS=14;
let dens=(function(){const v=parseInt(localStorage.getItem('jlDens'),10);return(v>=1&&v<=8)?v:4;})();
let dripLvl=(function(){const v=parseInt(localStorage.getItem('jlDrip'),10);return(v>=0&&v<=10)?v:3;})();
const F={status:new Set(),type:new Set(),city:new Set(),pMin:null,pMax:null,uMin:null,uMax:null};
const tog=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
const statusClass=s=>{const t=String(s||'').toLowerCase();return t.startsWith('active')?'active':(t.includes('pending')||t.includes('contract'))?'pending':'other';};
const statusShort=s=>String(s||'—').replace(/\s*\(.*\)$/,'').trim();
const norm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'').replace(/(inc|llc|corp|corporation|company|co|realty|realestate|group|properties|partners|associates)$/,'');
// address normalizer — drops street-type + directional words so listings.json ⇄ CRCP DB addresses join
const na=s=>String(s||'').toLowerCase().replace(/\b(street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|place|pl|court|ct|suite|ste|unit|#|north|south|east|west|n|s|e|w)\b/g,'').replace(/[^a-z0-9]+/g,'');
const clean=v=>{const s=(v==null?'':String(v)).trim();return(!s||/^null/i.test(s))?null:s;}; // drop DB "Null00709"-style stringified nulls
const weburl=w=>{w=clean(w);return w?(/^https?:\/\//i.test(w)?w:'http://'+w):null;}; // ensure a real scheme so href isn't a broken relative path
// ── broker contact join (Steve HARD RULE: always show broker phone + email) ──
// Priority: (1) REAL agent matched by property address from CRCP's broker graph, then
// (2) a weak firm-name fallback. listings.json's own firm is just "Crexi", so (1) is what delivers real numbers.
function brokerFor(r){
if(!r) return null;
const a=ADDRC[na(r.address)]; if(a) return a;
const keys=[r.firm_key, r.firm].filter(Boolean).map(norm);
for(const k of keys){ if(BROKERS[k]) return BROKERS[k]; }
return null;
}
// first-seen recency: DB created_at if present, else this-viewer's first-seen ledger
function firstSeen(r){ return DATES[na(r.address)] || SEEN[r.id] || null; }
function isNew(r){
const fs=firstSeen(r); if(!fs) return false;
const age=(Date.now()-new Date(fs).getTime())/86400000;
return age>=0 && age<=NEW_DAYS;
}
// ── COLUMN MODEL — header, cells, sort comparator, picker, + ThreeView fields ──
const COLS=[
{k:'address',label:'Address',on:true,render:r=>r.source&&/^https?:/.test(r.source)?`<a class="f" href="${esc(r.source)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${esc(r.address||'—')} ↗</a>`:esc(r.address||'—')},
{k:'city',label:'City',on:true},
{k:'zip',label:'ZIP',on:false},
{k:'type',label:'Type',on:true},
{k:'price',label:'Price',on:true,num:true,cls:'price',render:r=>money(r.price)||'—'},
{k:'units',label:'Units',on:true,num:true},
{k:'ppu',label:'$/Unit',on:true,num:true,val:r=>(r.price&&r.units)?Math.round(r.price/r.units):null,render:r=>{const v=(r.price&&r.units)?Math.round(r.price/r.units):null;return v?money(v):'—';}},
{k:'cap_rate',label:'Cap %',on:true,num:true,render:r=>(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—'},
{k:'sqft',label:'Sq Ft',on:false,num:true,render:r=>r.sqft?(+r.sqft).toLocaleString():'—'},
{k:'year_built',label:'Built',on:false,num:true,render:r=>r.year_built||'—'},
{k:'status',label:'Status',on:true,render:r=>`<span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>${isNew(r)?' <span class="newpill">new</span>':''}`},
{k:'firm',label:'Firm',on:true,render:r=>esc(String(r.firm||'—').slice(0,42))},
{k:'broker_phone',label:'Broker phone',on:true,lock:true,val:r=>{const b=brokerFor(r);return b&&b.phone||'';},render:r=>{const b=brokerFor(r);return b&&b.phone?`<a class="f" href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">${esc(b.phone)}</a>`:'<span style="color:var(--mut)">via source ↗</span>';}},
{k:'broker_email',label:'Broker email',on:true,lock:true,val:r=>{const b=brokerFor(r);return b&&b.email||'';},render:r=>{const b=brokerFor(r);return b&&b.email?`<a class="f" href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">${esc(b.email)}</a>`:'<span style="color:var(--mut)">via source ↗</span>';}},
{k:'rent_control',label:'Rent control',on:false,cls:'wrap',render:r=>esc(r.rent_control||'—')},
{k:'metro',label:'Metro',on:false,render:r=>esc(r.metro||'—')},
{k:'verified',label:'Verified',on:false,render:r=>r.verified?'✓':'—'},
{k:'first_seen',label:'First seen',on:false,val:r=>firstSeen(r)||'',render:r=>{const fs=firstSeen(r);return fs?new Date(fs).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—';}},
{k:'upside_note',label:'Upside',on:true,cls:'wrap',render:r=>esc(String(r.upside_note||'—').slice(0,200))},
];
try{
const savedOrder=JSON.parse(localStorage.getItem('jlColOrder')||'null');
if(Array.isArray(savedOrder)&&savedOrder.length)COLS.sort((a,b)=>{const ia=savedOrder.indexOf(a.k),ib=savedOrder.indexOf(b.k);return(ia<0?999:ia)-(ib<0?999:ib);});
const savedVis=JSON.parse(localStorage.getItem('jlCols')||'null');
if(Array.isArray(savedVis)&&savedVis.length)COLS.forEach(c=>c.on=savedVis.includes(c.k));
}catch(e){}
COLS.forEach(c=>{if(c.lock)c.on=true;}); // locked columns (broker phone/email) can't be hidden — Steve hard rule, all views
const visCols=()=>COLS.filter(c=>c.on);
const sortVal=(r,key)=>{const c=COLS.find(x=>x.k===key);return c&&c.val?c.val(r):r[key];};
const saveCols=()=>{localStorage.setItem('jlCols',JSON.stringify(COLS.filter(c=>c.on).map(c=>c.k)));localStorage.setItem('jlColOrder',JSON.stringify(COLS.map(c=>c.k)));};
function passSearch(r){
if(!q)return true;
const b=brokerFor(r);
const hay=[r.address,r.city,r.zip,r.type,r.status,r.firm,r.upside_note,r.rent_control,r.year_built,r.units,r.price,b&&b.name,b&&b.phone,b&&b.email].map(v=>v==null?'':String(v)).join(' ').toLowerCase();
return q.toLowerCase().split(/\s+/).filter(Boolean).every(tok=>hay.includes(tok));
}
function pass(r){
if(onlyNew&&!isNew(r))return false;
if(F.status.size&&!F.status.has(statusShort(r.status)))return false;
if(F.type.size&&!F.type.has(r.type||'—'))return false;
if(F.city.size&&!F.city.has(r.city||'—'))return false;
if(F.pMin!=null&&!(+r.price>=F.pMin))return false;
if(F.pMax!=null&&!(+r.price<=F.pMax))return false;
if(F.uMin!=null&&!(+r.units>=F.uMin))return false;
if(F.uMax!=null&&!(r.units!=null&&+r.units<=F.uMax))return false;
return passSearch(r);
}
function filtered(){
let rows=DATA.filter(pass);
rows=rows.slice().sort((a,b)=>{let x=sortVal(a,sortKey),y=sortVal(b,sortKey);const xm=(x==null||x===''),ym=(y==null||y==='');
if(xm&&ym)return 0;if(xm)return 1;if(ym)return -1;
if(!isNaN(+x)&&!isNaN(+y)){x=+x;y=+y;}else{x=String(x).toLowerCase();y=String(y).toLowerCase();}
return x<y?-1*sortDir:x>y?sortDir:0;});
return rows;
}
function chip(obj,attr,lim){return Object.entries(obj).sort((a,b)=>b[1]-a[1]).slice(0,lim||99).map(([k,c])=>`<span class="chip" data-${attr}="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');}
function buildRail(){
const sc={},tc={},cc={};
DATA.forEach(r=>{const s=statusShort(r.status);sc[s]=(sc[s]||0)+1;const t=r.type||'—';tc[t]=(tc[t]||0)+1;const c=r.city||'—';cc[c]=(cc[c]||0)+1;});
$('#fStatus').innerHTML=chip(sc,'s');$('#fType').innerHTML=chip(tc,'t',12);$('#fCity').innerHTML=chip(cc,'c',20);
$('#nStatus').textContent=Object.keys(sc).length;$('#nType').textContent=Object.keys(tc).length;$('#nCity').textContent=Object.keys(cc).length;
}
// ── broker cell shared between grid card + modal ──
function brokerBlock(r){
const b=brokerFor(r);
const src=r.source&&/^https?:/.test(r.source)?`<a href="${esc(r.source)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">listing source ↗</a>`:'<span class="none">no source link</span>';
if(b){
const ph=b.phone?`<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">📞 ${esc(b.phone)}</a>`:'<span class="none">phone: via source ↗</span>';
const em=b.email?`<a href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">✉️ ${esc(b.email)}</a>`:'<span class="none">email: via source ↗</span>';
return `<div class="broker"><div class="bf">${esc(b.name||r.firm||'—')}${b.name&&r.firm&&norm(b.name)!==norm(r.firm)?` · ${esc(r.firm)}`:''}</div><div class="bc">${ph}${em}${src}</div></div>`;
}
return `<div class="broker"><div class="bf">${esc(r.firm||'Listing brokerage')}</div><div class="bc"><span class="none">📞 phone: via source</span><span class="none">✉️ email: via source</span>${src}</div></div>`;
}
// ── GRID cards (with slow drip/crawl reveal) ──
let dripTimer=null;
function renderGrid(rows){
const host=$('#gridView');
if(dripTimer){clearInterval(dripTimer);dripTimer=null;}
if(!rows.length){host.innerHTML='';return;}
const cap=rows; // three-view.js windows the set via infinite scroll; render all it hands us
const cardHtml=r=>{
const ppu=(r.price&&r.units)?Math.round(r.price/r.units):null;
return `<div class="gcard" data-id="${esc(r.id)}">
${isNew(r)?'<span class="newpill newflag">new</span>':''}
<div class="addr">${esc(r.address||'—')}</div>
<div class="sub">${esc(r.city||'')}${r.zip?' · '+esc(r.zip):''} · ${esc(r.type||'—')}</div>
<div class="price">${money(r.price)||'—'}</div>
<div class="meta">
<span><span class="k">Units</span>${r.units!=null?esc(r.units):'—'}</span>
<span><span class="k">$/Unit</span>${ppu?money(ppu):'—'}</span>
<span><span class="k">Cap</span>${(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—'}</span>
<span><span class="k">Status</span><span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span></span>
</div>
${r.upside_note?`<div class="up">${esc(String(r.upside_note).slice(0,160))}</div>`:''}
${brokerBlock(r)}
</div>`;
};
// drip level -> ms between reveals (0 = instant dump, 10 = slow crawl)
const dripMs=dripLvl===0?0:Math.round(30+dripLvl*dripLvl*7);
if(dripMs===0){ host.innerHTML=cap.map(cardHtml).join(''); return; }
host.innerHTML='';
let i=0;const batch=Math.max(1,Math.round(6-dripLvl/2));
dripTimer=setInterval(()=>{
if(i>=cap.length){clearInterval(dripTimer);dripTimer=null;return;}
const frag=document.createElement('div');
frag.innerHTML=cap.slice(i,i+batch).map(cardHtml).join('');
while(frag.firstChild)host.appendChild(frag.firstChild);
i+=batch;
},dripMs);
}
// ── header (click to sort) — table view ──
function renderHead(){
$('#thead').innerHTML='<tr>'+visCols().map(c=>{
const sorted=c.k===sortKey,ar=sorted?(sortDir===1?'▲':'▼'):'↕';
return `<th draggable="true" data-k="${c.k}" class="${c.num?'num':''}${sorted?' sorted':''}" title="Click to sort · drag to reorder">${esc(c.label)}<span class="ar">${ar}</span></th>`;
}).join('')+'</tr>';
}
function rowHtml(r){
return `<tr data-id="${esc(r.id)}">`+visCols().map(c=>{
const cls=((c.num?'num ':'')+(c.cls||'')).trim();
const content=c.render?c.render(r):(r[c.k]==null||r[c.k]===''?'—':esc(r[c.k]));
return `<td${cls?` class="${cls}"`:''}>${content}</td>`;
}).join('')+'</tr>';
}
function renderTable(rows){
renderHead();
$('#tbody').innerHTML=rows.map(rowHtml).join(''); // three-view.js windows via infinite scroll
}
// ── stats + count (shared across views) ──
function renderStats(rows){
document.documentElement.style.setProperty('--rowpad',(dens+2)+'px');
document.documentElement.style.setProperty('--cols',Math.max(2,7-dens));
const tot=rows.reduce((s,r)=>s+(+r.price||0),0);
const units=rows.reduce((s,r)=>s+(+r.units||0),0);
const newN=rows.filter(isNew).length;
$('#stats').innerHTML=
`<div class="stat"><b>${rows.length.toLocaleString()}</b><span>listings shown</span></div>`+
`<div class="stat"><b style="color:var(--acc)">${newN.toLocaleString()}</b><span>new (≤${NEW_DAYS}d)</span></div>`+
`<div class="stat"><b style="color:var(--gold)">$${(tot/1e9).toFixed(2)}B</b><span>total ask</span></div>`+
`<div class="stat"><b>${units.toLocaleString()}</b><span>units</span></div>`+
`<div class="stat"><b>${new Set(rows.map(r=>r.city)).size}</b><span>cities</span></div>`;
$('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
const emp=$('#empty');emp.hidden=rows.length>0;
if(rows.length===0)emp.textContent=onlyNew?`No listings first seen in the last ${NEW_DAYS} days. Recency = CRCP capture date where known, else this browser's first-seen history — so new arrivals appear here on the visit after they enter the feed.`:'No listings match.';
}
// ── ThreeView: Grid / List / Table + all-field sort ──
function mountThreeView(){
const fields=COLS.map(c=>({k:c.k,l:c.label,num:!!c.num,html:true,
calc:r=>c.render?c.render(r):(r[c.k]==null?'':r[c.k])}));
TV=ThreeView.mount({
seg:'#viewseg', sortSel:'#tvSort',
listMount:'#tvList', gridMount:'#gridView', tableMount:'#tableView',
fields,
getRows:()=>filtered(),
title:r=>({name:r.address||'—',sub:`${r.city||''}${r.type?' · '+r.type:''}`,href:(r.source&&/^https?:/.test(r.source))?r.source:null}),
badgeHtml:r=>`<span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>${isNew(r)?' <span class="newpill">new</span>':''}`,
fvis:k=>{const c=COLS.find(x=>x.k===k);return c?c.on:false;},
renderGrid, renderTable,
onSort:(k,dir)=>{ if(k){sortKey=k;sortDir=dir;} render(); },
onView:v=>{ if(v!=='grid'&&dripTimer){clearInterval(dripTimer);dripTimer=null;} }, // kill the grid drip when leaving grid
rowClick:(row)=>openSpec(row),
storageKey:'jl', defaultView:'grid', views:['grid','list','table'],
});
}
function render(){
const rows=filtered();
renderStats(rows);
if(TV){TV.render();}else{renderGrid(rows);renderTable(rows);}
}
function setSort(key,dir){ sortKey=key;sortDir=dir; render(); }
// ── full-spec modal (reuse CRCP #ov/#modal). Pulls the fullest spec CRCP has. ──
async function openSpec(r){
const b=brokerFor(r),ppu=(r.price&&r.units)?Math.round(r.price/r.units):null,fs=firstSeen(r);
const spec=(k,v)=>`<div><span class="k">${esc(k)}</span>${v==null||v===''?'—':v}</div>`;
const bc=b?`${b.phone?`<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}">📞 ${esc(b.phone)}</a>`:'<span style="color:var(--mut)">phone via source</span>'} ${b.email?`<a href="mailto:${esc(b.email)}">✉️ ${esc(b.email)}</a>`:'<span style="color:var(--mut)">email via source</span>'}${b.website?` <a href="${esc(b.website)}" target="_blank" rel="noopener noreferrer">🌐 site</a>`:''}`
:`<span style="color:var(--mut)">Firm contact not in the broker graph — reach the listing agent via the source page.</span>`;
$('#mbody').innerHTML=
`<h2>${esc(r.address||'—')}</h2><div class="msub">${esc(r.city||'')}${r.zip?' '+esc(r.zip):''} · ${esc(r.type||'—')} · <span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>${isNew(r)?' <span class="newpill">new</span>':''}</div>`+
`<div class="mbroker"><h3>Listing brokerage${b&&b.name?` — ${esc(b.name)}`:''}</h3><div><b>${esc((b&&b.firm)||r.firm||'—')}</b></div><div style="margin-top:6px">${bc}</div>${r.source&&/^https?:/.test(r.source)?`<div style="margin-top:6px"><a href="${esc(r.source)}" target="_blank" rel="noopener noreferrer">Open the full listing ↗</a></div>`:''}</div>`+
`<div class="specs">`+
spec('Price',money(r.price)?`<b style="color:var(--gold)">${money(r.price)}</b>`:'—')+
spec('Units',r.units)+spec('$ / Unit',ppu?money(ppu):'—')+
spec('Cap rate',(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—')+
spec('Cap (projected)',(r.cap_rate_projected!=null&&r.cap_rate_projected!=='')?(+r.cap_rate_projected).toFixed(2)+'%':'—')+
spec('NOI (actual)',money(r.noi_actual))+spec('NOI (seller)',money(r.noi_seller))+
spec('Gross (actual)',money(r.gross_actual))+spec('Gross (proforma)',money(r.gross_proforma))+
spec('Expenses',money(r.expenses))+
spec('Sq Ft',r.sqft?(+r.sqft).toLocaleString():'—')+spec('Year built',r.year_built)+
spec('Rent control',esc(r.rent_control||'—'))+spec('Metro',esc(r.metro||'—'))+
spec('ZIP',esc(r.zip||'—'))+spec('First seen',fs?new Date(fs).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—')+
spec('Verified',r.verified?'✓ yes':'—')+
`</div>`+
(r.upside_note?`<div class="note"><b style="color:var(--ink)">Upside:</b> ${esc(r.upside_note)}</div>`:'')+
`<div class="note" id="mhist" style="margin-top:10px;color:var(--mut)">Loading property history…</div>`;
$('#ov').classList.add('on');
try{
const h=await fetch('/api/property-history?address='+encodeURIComponent(r.address||'')+'&city='+encodeURIComponent(r.city||'')).then(x=>x.json()).catch(()=>null);
const el=$('#mhist'); if(!el)return;
if(h&&Array.isArray(h.history)&&h.history.length){
el.innerHTML='<b style="color:var(--ink)">Property history (CRCP):</b><br>'+h.history.slice(0,8).map(e=>`• ${esc(e.date||e.event_date||'')} — ${esc(e.event||e.type||'')} ${e.price?money(e.price):''}`).join('<br>');
}else if(h&&h.assessor){
const a=h.assessor;el.innerHTML=`<b style="color:var(--ink)">Assessor (CRCP):</b> ${esc(a.use_desc1||'')} · built ${esc(a.year_built||'—')} · ${a.sqft_main?(+a.sqft_main).toLocaleString()+' sf':''} · roll ${a.roll_total_value?money(a.roll_total_value):'—'}`;
}else{ el.textContent='No additional CRCP history matched this address.'; }
}catch(e){ const el=$('#mhist'); if(el)el.textContent='No additional CRCP history available.'; }
}
function idFromEvent(e){const el=e.target.closest('[data-id]');return el?el.getAttribute('data-id'):null;}
$('#gridView').addEventListener('click',e=>{if(e.target.closest('a'))return;const id=idFromEvent(e);if(id){const r=DATA.find(x=>String(x.id)===id);if(r)openSpec(r);}});
$('#tbody').addEventListener('click',e=>{if(e.target.closest('a'))return;const id=idFromEvent(e);if(id){const r=DATA.find(x=>String(x.id)===id);if(r)openSpec(r);}});
document.addEventListener('keydown',e=>{if(e.key==='Escape')$('#ov').classList.remove('on');});
$('#ov').addEventListener('click',e=>{if(e.target.id==='ov')$('#ov').classList.remove('on');});
// ── table header click-sort + drag-reorder ──
$('#thead').addEventListener('click',e=>{
const th=e.target.closest('th');if(!th)return;
const key=th.dataset.k,col=COLS.find(c=>c.k===key);
if(key===sortKey)setSort(key,-sortDir);
else setSort(key,col&&col.num?-1:1);
});
let dragK=null;
$('#thead').addEventListener('dragstart',e=>{const th=e.target.closest('th');if(!th)return;dragK=th.dataset.k;th.classList.add('dragging');e.dataTransfer.effectAllowed='move';try{e.dataTransfer.setData('text/plain',dragK);}catch(_){}});
$('#thead').addEventListener('dragover',e=>{if(dragK==null)return;e.preventDefault();e.dataTransfer.dropEffect='move';const th=e.target.closest('th');$('#thead').querySelectorAll('th').forEach(t=>t.classList.remove('dropzone'));if(th&&th.dataset.k!==dragK)th.classList.add('dropzone');});
$('#thead').addEventListener('drop',e=>{e.preventDefault();const th=e.target.closest('th');if(!th||dragK==null)return;const destK=th.dataset.k;if(destK!==dragK){const from=COLS.findIndex(c=>c.k===dragK);const[m]=COLS.splice(from,1);const to=COLS.findIndex(c=>c.k===destK);COLS.splice(to,0,m);saveCols();renderColGrid();render();}dragK=null;});
$('#thead').addEventListener('dragend',()=>{dragK=null;$('#thead').querySelectorAll('th').forEach(t=>t.classList.remove('dropzone','dragging'));});
// ── left rail: collapse/expand panels (start collapsed) ──
$('#rail').addEventListener('click',e=>{const h=e.target.closest('.panel > h4');if(!h)return;h.parentElement.classList.toggle('collapsed');});
// rail filters
$('#fStatus').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.status,c.dataset.s);c.classList.toggle('active');render();});
$('#fType').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.type,c.dataset.t);c.classList.toggle('active');render();});
$('#fCity').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.city,c.dataset.c);c.classList.toggle('active');render();});
$('#pMin').addEventListener('input',()=>{F.pMin=$('#pMin').value?+$('#pMin').value:null;render();});
$('#pMax').addEventListener('input',()=>{F.pMax=$('#pMax').value?+$('#pMax').value:null;render();});
$('#uMin').addEventListener('input',()=>{F.uMin=$('#uMin').value?+$('#uMin').value:null;render();});
$('#uMax').addEventListener('input',()=>{F.uMax=$('#uMax').value?+$('#uMax').value:null;render();});
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
$('#newBtn').addEventListener('click',()=>{onlyNew=!onlyNew;$('#newBtn').classList.toggle('on',onlyNew);render();});
$('#drip').addEventListener('input',()=>{dripLvl=+$('#drip').value;localStorage.setItem('jlDrip',dripLvl);if(TV&&TV.getView()==='grid')render();});
$('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('jlDens',dens);render();});
$('#drip').value=dripLvl;$('#dens').value=dens;
// ── column picker in the rail (remove / restore any column) ──
function renderColGrid(){
$('#colGrid').innerHTML=
`<div class="ph"><span>${visCols().length} shown · broker contact locked on</span><a id="colAll">all</a></div>`+
COLS.filter(c=>!c.lock).map(c=>`<label><input type="checkbox" data-k="${c.k}"${c.on?' checked':''}>${esc(c.label)}</label>`).join('');
}
$('#colGrid').addEventListener('click',e=>{
if(e.target.id==='colAll'){e.preventDefault();COLS.forEach(c=>c.on=true);renderColGrid();saveCols();render();return;}
const cb=e.target.closest('input');if(!cb)return;
const col=COLS.find(c=>c.k===cb.dataset.k);if(!col)return;
col.on=cb.checked;
if(!visCols().length){col.on=true;cb.checked=true;return;}
if(sortKey===col.k&&!col.on){setSort(visCols()[0].k,-1);}
saveCols();renderColGrid();render();
});
// ── CSV export ──
function csvVal(r,c){let v=c.k==='status'?statusShort(r.status):(c.val?c.val(r):r[c.k]);return v==null?'':String(v);}
function exportCSV(){
const cols=visCols(),rows=filtered();
const qt=s=>/[",\r\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;
const lines=[cols.map(c=>qt(c.label)).join(',')].concat(rows.map(r=>cols.map(c=>qt(csvVal(r,c))).join(',')));
const blob=new Blob([''+lines.join('\r\n')],{type:'text/csv;charset=utf-8'});
const url=URL.createObjectURL(blob),a=document.createElement('a'),d=new Date(),p=n=>String(n).padStart(2,'0');
a.href=url;a.download=`just-listed-${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}.csv`;
document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1500);
}
$('#csvBtn').addEventListener('click',exportCSV);
// ── inline chat bar (POST /api/claude-chat, grounded in the current filtered listings) ──
const CHAT=[];
$('#chHd').addEventListener('click',()=>{$('#chatbar').classList.toggle('open');if($('#chatbar').classList.contains('open'))$('#chatin').focus();});
function chatContext(){
const all=filtered();
const rows=all.slice(0,60).map(r=>{const b=brokerFor(r);return{address:r.address,city:r.city,type:r.type,price:r.price,units:r.units,cap_rate:r.cap_rate,status:statusShort(r.status),firm:r.firm,broker_phone:b&&b.phone||null,broker_email:b&&b.email||null,upside:r.upside_note};});
return 'LIVE VIEW — the '+rows.length+' listings currently shown (of '+all.length+' filtered):\n'+JSON.stringify(rows);
}
async function sendChat(text){
CHAT.push({role:'user',content:text});
const log=$('#chatlog');
log.insertAdjacentHTML('beforeend',`<div class="msg u">${esc(text)}</div>`);
const think=document.createElement('div');think.className='msg a think';think.textContent='thinking…';log.appendChild(think);log.scrollTop=log.scrollHeight;
try{
const r=await fetch('/api/claude-chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:CHAT.slice(-8),context:chatContext(),system:'You are a commercial-real-estate analyst embedded in the CRCP "Just Listed" active-inventory viewer. Answer ONLY from the listings in the context. Be concise, give numbers, and when useful cite the broker phone/email shown. Never invent listings or contacts.'})}).then(x=>x.json());
think.remove();
const reply=(r&&r.reply)||'(no reply)';CHAT.push({role:'assistant',content:reply});
log.insertAdjacentHTML('beforeend',`<div class="msg a">${esc(reply)}</div>`);
}catch(e){ think.remove(); log.insertAdjacentHTML('beforeend',`<div class="msg a">⚠️ chat unavailable</div>`); }
log.scrollTop=log.scrollHeight;
}
$('#chatform').addEventListener('submit',e=>{e.preventDefault();const v=$('#chatin').value.trim();if(!v)return;$('#chatin').value='';sendChat(v);});
// ── auth pill ──
const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/json'}},o||{})).then(r=>r.json());
function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);el.innerHTML=`<b style="color:var(--ink)">${who}</b> · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=()=>location.href='/deals-flow.html';}}
api('/api/me').then(m=>{ME=m;renderAuth();}).catch(()=>{});
// ticker
async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');const dup=html.replace(/<span class="it">/g,'<span class="it" aria-hidden="true">');$('#tickTrack').innerHTML=html+dup;$('#ticker').style.display='';}catch(e){}}
loadTicker();
// ── boot: load brokers (contacts) + first-seen dates, then listings ──
let _tvTries=0;
function startViews(){
if(typeof ThreeView==='undefined'){ if(++_tvTries>100){renderGrid(filtered());renderTable(filtered());return;} return setTimeout(startViews,40); } // three-view.js is deferred; cap the retry so a 404 can't spin forever
mountThreeView();render();
}
async function boot(){
try{
const r=await api('/api/brokers/all');
const arr=(r&&(r.brokers||r.results||r))||[];
(Array.isArray(arr)?arr:[]).forEach(b=>{
const key=norm(b.firm||b.name); if(!key)return;
const ph=clean(b.phone),em=clean(b.email);
if(!BROKERS[key] || ((ph||em) && !(BROKERS[key].phone||BROKERS[key].email)))
BROKERS[key]={name:clean(b.name),firm:b.firm,phone:ph,email:em,website:weburl(b.website),linkedin:b.linkedin};
});
}catch(e){}
// REAL per-listing agent contact by address (primary source — see brokerFor)
try{
const lb=await api('/api/listing-brokers');
((lb&&lb.rows)||[]).forEach(row=>{
const k=na(row.address); if(!k)return;
const ph=clean(row.phone),em=clean(row.email);
if(!ADDRC[k] || ((ph||em) && !(ADDRC[k].phone||ADDRC[k].email)))
ADDRC[k]={name:clean(row.name),firm:null,phone:ph,email:em,website:weburl(row.website)};
});
}catch(e){}
try{ const d=await api('/api/listing-dates'); ((d&&d.rows)||[]).forEach(row=>{const k=na(row.address); if(k&&row.created_at) DATES[k]=row.created_at;}); }catch(e){ DATES={}; }
try{ SEEN=JSON.parse(localStorage.getItem('jlSeen')||'{}'); }catch(e){ SEEN={}; }
fetch('/data/listings.json').then(r=>r.json()).then(d=>{
META=d.meta||{};
DATA=(d.listings||[]).filter(l=>String(l.status||'').toLowerCase().startsWith('active'));
// Honest recency: DB capture date wins. For date-less listings, the FIRST visit establishes a
// baseline (stamped far in the past = NOT "new") so we don't flood "New only" with the whole
// catalog; only ids that first appear on a LATER visit get stamped "now" and count as new.
const nowIso=new Date().toISOString(),baseIso='2000-01-01T00:00:00.000Z',firstRun=!localStorage.getItem('jlSeen');let changed=false;
DATA.forEach(r=>{ if(!SEEN[r.id] && !DATES[na(r.address)]){ SEEN[r.id]=firstRun?baseIso:nowIso; changed=true; } });
if(changed){ try{ localStorage.setItem('jlSeen',JSON.stringify(SEEN)); }catch(e){} }
$('#srcBadge').textContent=`${DATA.length.toLocaleString()} active`;
const withContact=DATA.filter(r=>{const b=brokerFor(r);return b&&(b.phone||b.email);}).length;
$('#prov').innerHTML=`Active listings from broker/portal feeds${META.sourced?` · batch sourced <b>${esc(String(META.sourced).slice(0,10))}</b>`:''}. Broker phone/email shown from the CRCP broker graph where the firm matches (<b>${withContact.toLocaleString()}</b> of ${DATA.length.toLocaleString()} joined); the rest link through to the listing source. <b>New</b> = first seen ≤${NEW_DAYS} days (DB capture date, else first seen in this browser). Switch <b>Grid / List / Table</b>, drag the <b>crawl</b> slider to slow the reveal, click any listing for full specs.`;
buildRail();renderColGrid();startViews();
}).catch(()=>{$('#count').textContent='failed to load /data/listings.json';});
}
boot();
</script>
<script src="/three-view.js" defer></script>
<script src="/global-search.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script><!-- day/night toggle (auto-injects into header .nav) -->
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body></html>