← back to Commercialrealestate
just-listed: rebuild to canonical CRCP UI (grid/list/table, day/night, collapsed rail, chat, new-drip, always-on broker contact by address)
30278bcc6e8daa787dacbd2a837dd1fbb7373590 · 2026-08-18 17:22:55 -0700 · Steve Abrams
Files touched
M public/fha-leads.htmlA public/filter-modal.jsM public/just-listed.htmlM scripts/serve.js
Diff
commit 30278bcc6e8daa787dacbd2a837dd1fbb7373590
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 18 17:22:55 2026 -0700
just-listed: rebuild to canonical CRCP UI (grid/list/table, day/night, collapsed rail, chat, new-drip, always-on broker contact by address)
---
public/fha-leads.html | 70 ++++++-
public/filter-modal.js | 183 +++++++++++++++++
public/just-listed.html | 512 +++++++++++++++++++++++++++++++++++++-----------
scripts/serve.js | 15 ++
4 files changed, 658 insertions(+), 122 deletions(-)
diff --git a/public/fha-leads.html b/public/fha-leads.html
index 9a26bcf..dc16d63 100644
--- a/public/fha-leads.html
+++ b/public/fha-leads.html
@@ -36,7 +36,22 @@ tr:hover td{background:#131922}
.prov{font-size:11px;color:var(--mut);margin:2px 0 12px;line-height:1.5}
.stat{display:inline-block;background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 12px;margin:0 8px 10px 0}
.stat b{font-size:19px;color:#fff}.stat span{display:block;font-size:10px;text-transform:uppercase;color:var(--mut);letter-spacing:.4px}
-</style> <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+/* grid/list/table view toggle + FHA lead cards (three-view) */
+.viewseg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+.viewseg button{background:var(--card);color:var(--mut);border:0;border-right:1px solid var(--line);padding:7px 12px;font-size:12px;cursor:pointer}
+.viewseg button:last-child{border-right:0}.viewseg button.active{background:var(--blue);color:#0e1116;font-weight:600}
+.fha-grid{display:grid;grid-template-columns:repeat(var(--cols,4),minmax(0,1fr));gap:12px;margin-top:4px}
+.fcard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 15px;display:flex;flex-direction:column;gap:8px}
+.fcard:hover{border-color:var(--blue)}
+.fcard-h{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
+.fcard-t{font-weight:700;color:#fff;font-size:13px;line-height:1.25}
+.fcard-sub{color:var(--mut);font-size:11px;margin-top:-4px}
+.fcard-spread{color:var(--gold);font-weight:700;font-size:13px}
+.fcard-spread span{color:var(--mut);font-weight:400;font-size:10px;text-transform:uppercase;letter-spacing:.4px;margin-left:5px}
+.fcard-kv{display:grid;grid-template-columns:1fr 1fr;gap:5px 12px;font-size:12px}
+.fcard-kv div{display:flex;justify-content:space-between;gap:8px;border-bottom:1px solid #20262f;padding-bottom:3px}
+.fcard-kv em{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.3px;font-style:normal}
+</style> <link rel="stylesheet" href="/three-view.css"><link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head><body>
<header>
<h1><b>Assumable-Loan Leads</b> · FHA Multifamily</h1>
@@ -59,12 +74,16 @@ tr:hover td{background:#131922}
<div class="prov">Source: <b>HUD/FHA Multifamily Insured Mortgages (Active)</b> — U.S. government public data ($0, no license, resellable). Rate spread = market average (Freddie PMMS) at the loan's origination month − the loan's actual rate. A large spread = a below-market note a qualified buyer may be able to assume. Single-family FHA per-loan rates are NOT public (borrower privacy), so this is the commercial multifamily universe.</div>
<div class="toolbar">
<input class="search" id="q" placeholder="search property, city, lender, zip…">
+ <span class="viewseg tv-seg" id="viewseg"></span>
<select id="sort"></select>
<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>
- <table><thead id="head"></thead><tbody id="body"></tbody></table>
+ <div id="tableView"><table><thead id="head"></thead><tbody id="body"></tbody></table></div>
+ <div id="gridView" class="fha-grid"></div>
+ <div id="listView"></div>
</div></div>
+<script src="/three-view.js"></script>
<script>
const $=s=>document.querySelector(s);
const money=n=>n==null||n===''||isNaN(+n)?'—':'$'+Math.round(+n).toLocaleString();
@@ -75,7 +94,7 @@ const COLS=[
{k:'pmmsBenchmark',l:'Mkt @ orig',t:'pct'},{k:'spread',l:'Spread',t:'spread',calc:r=>(r.pmmsBenchmark!=null&&r.rate!=null)?+(r.pmmsBenchmark-r.rate).toFixed(2):null},
{k:'holder',l:'Lender',t:'s'},{k:'originationDate',l:'Originated',t:'s'},{k:'belowMarket',l:'Assumable?',t:'flag',calc:r=>r.belowMarket}
];
-let DATA=[],sortKey='spread',sortDir=-1,q='';
+let DATA=[],sortKey='spread',sortDir=-1,q='',TV=null,CURRENT=[];
const F={state:new Set(),city:new Set(),holder:new Set(),qual:new Set(),uMin:null,uMax:null,rMax:null};
let dens=(function(){const v=parseInt(localStorage.getItem('fhaDens'),10);return(v>=1&&v<=8)?v:4;})();
const cval=(r,c)=>c.calc?c.calc(r):r[c.k];
@@ -115,8 +134,34 @@ function buildRail(){
DATA.forEach(r=>{if(r.state)sc[r.state]=(sc[r.state]||0)+1;if(r.city)cc[r.city]=(cc[r.city]||0)+1;if(r.holder)hc[r.holder]=(hc[r.holder]||0)+1;});
$('#fState').innerHTML=chip(sc,'st',12);$('#fCity').innerHTML=chip(cc,'cy',18);$('#fHolder').innerHTML=chip(hc,'ho',14);
}
+// ── the three view renderers (delegated to by ThreeView; each receives the current filtered+sorted rows) ──
+function renderTable(rows){
+ $('#head').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
+ const fs=Math.max(10,15-dens);
+ $('#body').innerHTML=rows.slice(0,600).map(r=>`<tr style="font-size:${fs}px">`+COLS.map(c=>`<td class="${c.t==='money'||c.t==='n'||c.t==='pct'?'mono':''}">${cell(r,c)}</td>`).join('')+'</tr>').join('');
+}
+function renderGrid(rows){
+ const gv=$('#gridView'); gv.style.setProperty('--cols',dens);
+ const kv=(l,v)=>`<div><em>${l}</em><span>${v}</span></div>`;
+ gv.innerHTML=rows.slice(0,400).map(r=>{
+ const spread=(r.pmmsBenchmark!=null&&r.rate!=null)?+(r.pmmsBenchmark-r.rate).toFixed(2):null;
+ return `<div class="fcard">`+
+ `<div class="fcard-h"><span class="fcard-t">${esc(r.property||'—')}</span>${r.belowMarket?'<span class="pill gold">ASSUMABLE ◆</span>':''}</div>`+
+ `<div class="fcard-sub">${esc(r.city||'')}${r.state?', '+esc(r.state):''}${r.zip?' · '+esc(r.zip):''}</div>`+
+ (spread!=null&&spread>0?`<div class="fcard-spread">▼ ${spread} pts<span>below market</span></div>`:'')+
+ `<div class="fcard-kv">`+
+ kv('Units',r.units!=null?(+r.units).toLocaleString():'—')+
+ kv('Orig $',money(r.originalAmount))+
+ kv('Rate',r.rate!=null?(+r.rate).toFixed(2)+'%':'—')+
+ kv('Mkt @ orig',r.pmmsBenchmark!=null?(+r.pmmsBenchmark).toFixed(2)+'%':'—')+
+ kv('Lender',esc(r.holder||'—'))+
+ kv('Originated',esc(r.originationDate||'—'))+
+ `</div></div>`;
+ }).join('')||'<div style="color:var(--mut);padding:30px">No matches for these filters.</div>';
+}
function render(){
- const rows=filtered();
+ CURRENT=filtered();
+ const rows=CURRENT;
const below=rows.filter(r=>r.belowMarket).length;
const avgSpread=rows.filter(r=>r.belowMarket&&r.pmmsBenchmark!=null&&r.rate!=null);
const avg=avgSpread.length?(avgSpread.reduce((s,r)=>s+(r.pmmsBenchmark-r.rate),0)/avgSpread.length).toFixed(1):'—';
@@ -125,9 +170,7 @@ function render(){
`<div class="stat"><b>${avg}${avg==='—'?'':' pts'}</b><span>avg assumable spread</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()}`;
- $('#head').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
- const fs=Math.max(10,15-dens);
- $('#body').innerHTML=rows.slice(0,600).map(r=>`<tr style="font-size:${fs}px">`+COLS.map(c=>`<td class="${c.t==='money'||c.t==='n'||c.t==='pct'?'mono':''}">${cell(r,c)}</td>`).join('')+'</tr>').join('');
+ if(TV) TV.render(); else renderTable(rows);
}
$('#head').addEventListener('click',e=>{const th=e.target.closest('th');if(!th)return;const k=th.dataset.k;if(sortKey===k)sortDir*=-1;else{sortKey=k;sortDir=(k==='property'||k==='city'||k==='holder'||k==='state')?1:-1;}render();});
$('#fState').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.state,c.dataset.st);c.classList.toggle('active');render();});
@@ -142,8 +185,21 @@ $('#sort').addEventListener('change',()=>{sortKey=$('#sort').value;sortDir=(sort
$('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('fhaDens',dens);render();});
$('#sort').innerHTML=COLS.map(c=>`<option value="${c.k}"${c.k==='spread'?' selected':''}>Sort: ${esc(c.l)}</option>`).join('');
$('#dens').value=dens;
+// grid / list / table toggle + compact-list view (three-view). Sort stays owned by the
+// page's own #sort select + header clicks (getRows returns already-sorted rows), so we
+// don't pass a sortSel — ThreeView just adds the view modes + the compact list.
+TV=ThreeView.mount({
+ seg:'#viewseg', gridMount:'#gridView', listMount:'#listView', tableMount:'#tableView',
+ getRows:()=>CURRENT,
+ fields:COLS.filter(c=>!['property','city','state'].includes(c.k)).map(c=>({k:c.k,l:c.l,html:true,calc:r=>cell(r,c)})),
+ title:r=>({name:r.property||'—', sub:(r.city||'')+(r.state?', '+r.state:'')}),
+ badgeHtml:r=>r.belowMarket?'<span class="pill gold">ASSUMABLE ◆</span>':'',
+ renderGrid:renderGrid, renderTable:renderTable,
+ storageKey:'fha', defaultView:'table', views:['grid','list','table']
+});
fetch('/data/fha-loans.json').then(r=>r.json()).then(j=>{DATA=(j.rows||[]).filter(r=>r.rate!=null);buildRail();render();})
.catch(()=>{$('#count').textContent='failed to load /data/fha-loans.json';});
</script><script src="/corner-nav.js" defer></script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
+ <script src="/filter-modal.js" defer></script><!-- filters → collapsed modal panels -->
</body></html>
diff --git a/public/filter-modal.js b/public/filter-modal.js
new file mode 100644
index 0000000..39cdea2
--- /dev/null
+++ b/public/filter-modal.js
@@ -0,0 +1,183 @@
+/* filter-modal.js — CRCP standard (2026-08-18): collapse the always-open left filter
+ * rail (aside.rail full of .rsec sections) into ONE "⚙ Filters" modal made of
+ * collapsible panels. Single-line drop-in — <script src="/filter-modal.js" defer></script>.
+ *
+ * What it does, with ZERO changes to the host page's own filter JS:
+ * 1. Finds `aside.rail` (or `.rail`) and the page toolbar (`.toolbar` or `.topbar`).
+ * 2. Injects a "⚙ Filters (N)" trigger button into the toolbar; N = live count of
+ * active filters (active chips + non-empty number/text inputs inside the rail).
+ * 3. Moves every `.rsec` filter section into an overlay modal — element IDs and their
+ * already-bound event listeners travel with the nodes, so #fState/#fCity/etc. and
+ * all the page's filtering keep working exactly as before.
+ * 4. Turns each `.rsec > h4` into an accordion header (collapsed by default; per-page,
+ * per-section state persisted in localStorage).
+ * 5. "Clear all" un-sets every active filter by driving the page's OWN controls
+ * (click active chips off, blank inputs + dispatch input) so the page re-renders.
+ *
+ * Fully reversible: it only relocates DOM + injects a modal shell; remove the <script>
+ * and the rail renders inline again (nothing about the page's data/logic is touched).
+ * Idempotent: guarded so double-inclusion is a no-op.
+ */
+(function () {
+ if (window.__crcpFilterModal) return; window.__crcpFilterModal = true;
+
+ function boot() {
+ var rail = document.querySelector('aside.rail') || document.querySelector('.rail');
+ if (!rail) return; // page has no filter rail — nothing to do
+ var secs = Array.prototype.slice.call(rail.querySelectorAll('.rsec'));
+ if (!secs.length) return;
+ var toolbar = document.querySelector('.toolbar') || document.querySelector('.topbar') ||
+ document.querySelector('main .toolbar, main .topbar') || document.querySelector('.main');
+ if (!toolbar) return;
+
+ var PAGE = (location.pathname.split('/').pop() || 'index').replace(/\.html?$/, '') || 'index';
+ var LSK = 'crcpFilterPanels:' + PAGE; // {sectionIndex: open?} persisted collapse state
+
+ injectStyle();
+
+ // ---- build the modal shell ----
+ var ov = document.createElement('div'); ov.className = 'fm-ov'; ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'Filters');
+ var modal = document.createElement('div'); modal.className = 'fm-modal';
+ var head = document.createElement('div'); head.className = 'fm-head';
+ head.innerHTML = '<h3>Filters</h3><div class="fm-hactions"><button type="button" class="fm-clear">Clear all</button><button type="button" class="fm-x" aria-label="Close">✕</button></div>';
+ var bodyWrap = document.createElement('div'); bodyWrap.className = 'fm-body';
+ modal.appendChild(head); modal.appendChild(bodyWrap); ov.appendChild(modal);
+ document.body.appendChild(ov);
+
+ // ---- move each .rsec into the modal as a collapsible panel ----
+ var openState = {}; try { openState = JSON.parse(localStorage.getItem(LSK) || '{}') || {}; } catch (e) {}
+ secs.forEach(function (sec, i) {
+ var h = sec.querySelector('h4');
+ var label = h ? (h.textContent || '').trim() : ('Filter ' + (i + 1));
+ sec.classList.add('fm-panel');
+ var startOpen = openState[i] === true; // collapsed by default
+ if (startOpen) sec.classList.add('fm-open');
+ // rebuild the h4 as an accordion toggle (keep its text; add a chevron + per-panel count)
+ if (h) {
+ h.classList.add('fm-ph');
+ h.innerHTML = '<span class="fm-ptitle">' + escapeHtml(label) + '</span><span class="fm-pcount"></span><span class="fm-chev">▸</span>';
+ h.setAttribute('role', 'button'); h.setAttribute('tabindex', '0');
+ h.addEventListener('click', function () { togglePanel(sec, i); });
+ h.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(sec, i); } });
+ }
+ bodyWrap.appendChild(sec);
+ });
+
+ // the rail is now empty — hide it so the main content goes full-width
+ rail.classList.add('fm-railhidden');
+
+ // ---- trigger button in the toolbar ----
+ var trig = document.createElement('button'); trig.type = 'button'; trig.className = 'fm-trigger';
+ trig.innerHTML = '<span class="fm-tico">⚙</span> Filters<span class="fm-badge" hidden>0</span>';
+ // put it first so it reads left-to-right before search on most toolbars
+ if (toolbar.firstChild) toolbar.insertBefore(trig, toolbar.firstChild); else toolbar.appendChild(trig);
+
+ function togglePanel(sec, i) {
+ var open = sec.classList.toggle('fm-open');
+ openState[i] = open;
+ try { localStorage.setItem(LSK, JSON.stringify(openState)); } catch (e) {}
+ }
+ function openModal() { ov.classList.add('on'); recount(); document.addEventListener('keydown', onEsc); }
+ function closeModal() { ov.classList.remove('on'); document.removeEventListener('keydown', onEsc); }
+ function onEsc(e) { if (e.key === 'Escape') closeModal(); }
+
+ trig.addEventListener('click', openModal);
+ head.querySelector('.fm-x').addEventListener('click', closeModal);
+ ov.addEventListener('click', function (e) { if (e.target === ov) closeModal(); });
+ head.querySelector('.fm-clear').addEventListener('click', clearAll);
+
+ // recount active filters whenever the user interacts inside the modal
+ bodyWrap.addEventListener('click', function () { setTimeout(recount, 0); });
+ bodyWrap.addEventListener('input', function () { setTimeout(recount, 0); });
+ bodyWrap.addEventListener('change', function () { setTimeout(recount, 0); });
+
+ function countActive(scope) {
+ var n = 0;
+ n += scope.querySelectorAll('.chip.active').length;
+ Array.prototype.forEach.call(scope.querySelectorAll('input'), function (inp) {
+ var t = (inp.type || 'text').toLowerCase();
+ if (t === 'range') return; // density etc. isn't a "filter"
+ if (t === 'checkbox' || t === 'radio') { if (inp.checked) n++; return; }
+ if ((inp.value || '').trim() !== '') n++;
+ });
+ Array.prototype.forEach.call(scope.querySelectorAll('select'), function (s) {
+ if (s.value && s.value !== '' && s.selectedIndex > 0) n++;
+ });
+ return n;
+ }
+ function recount() {
+ var total = countActive(bodyWrap);
+ var badge = trig.querySelector('.fm-badge');
+ badge.textContent = total; badge.hidden = total === 0;
+ trig.classList.toggle('fm-has', total > 0);
+ // per-panel counts on the accordion headers
+ secs.forEach(function (sec) {
+ var pc = sec.querySelector('.fm-pcount'); if (!pc) return;
+ var c = countActive(sec); pc.textContent = c ? c : ''; pc.classList.toggle('on', c > 0);
+ });
+ }
+ function clearAll() {
+ // click every active chip off (drives the page's own toggle handlers)
+ Array.prototype.forEach.call(bodyWrap.querySelectorAll('.chip.active'), function (ch) { ch.click(); });
+ // blank inputs + notify the page
+ Array.prototype.forEach.call(bodyWrap.querySelectorAll('input'), function (inp) {
+ var t = (inp.type || 'text').toLowerCase();
+ if (t === 'range') return;
+ if (t === 'checkbox' || t === 'radio') { if (inp.checked) { inp.checked = false; inp.dispatchEvent(new Event('change', { bubbles: true })); inp.dispatchEvent(new Event('input', { bubbles: true })); } return; }
+ if ((inp.value || '').trim() !== '') { inp.value = ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); }
+ });
+ Array.prototype.forEach.call(bodyWrap.querySelectorAll('select'), function (s) {
+ if (s.selectedIndex > 0) { s.selectedIndex = 0; s.dispatchEvent(new Event('change', { bubbles: true })); }
+ });
+ setTimeout(recount, 0);
+ }
+
+ // keep the badge live even when filters change from outside the modal (e.g. chip
+ // datum-drills elsewhere on the page): observe the modal subtree for class flips.
+ try {
+ var mo = new MutationObserver(function () { recount(); });
+ mo.observe(bodyWrap, { subtree: true, attributes: true, attributeFilter: ['class'], childList: true });
+ } catch (e) {}
+
+ recount();
+ }
+
+ function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
+
+ function injectStyle() {
+ if (document.getElementById('fm-style')) return;
+ var css =
+ '.fm-railhidden{display:none!important}' +
+ '.fm-trigger{display:inline-flex;align-items:center;gap:6px;background:var(--card,#161b22);color:var(--ink,#e6edf3);border:1px solid var(--line,#2a313c);border-radius:8px;padding:8px 12px;font-size:12px;font-weight:600;cursor:pointer;line-height:1;white-space:nowrap}' +
+ '.fm-trigger:hover{border-color:var(--blue,#58a6ff)}' +
+ '.fm-trigger.fm-has{border-color:var(--blue,#58a6ff);color:var(--blue,#58a6ff)}' +
+ '.fm-tico{font-size:13px}' +
+ '.fm-badge{background:var(--blue,#58a6ff);color:#0e1116;border-radius:20px;padding:1px 7px;font-size:10px;font-weight:700;min-width:16px;text-align:center}' +
+ '.fm-ov{position:fixed;inset:0;background:rgba(2,6,12,.62);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);display:none;align-items:flex-start;justify-content:center;z-index:9999;padding:5vh 16px 16px}' +
+ '.fm-ov.on{display:flex}' +
+ '.fm-modal{width:min(560px,96vw);max-height:88vh;display:flex;flex-direction:column;background:var(--card,#161b22);border:1px solid var(--line,#2a313c);border-radius:16px;box-shadow:0 24px 70px rgba(0,0,0,.55);overflow:hidden}' +
+ '.fm-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:15px 18px;border-bottom:1px solid var(--line,#2a313c)}' +
+ '.fm-head h3{margin:0;font-size:16px;color:var(--ink,#e6edf3)}' +
+ '.fm-hactions{display:flex;align-items:center;gap:8px}' +
+ '.fm-clear{background:none;border:1px solid var(--line,#2a313c);color:var(--mut,#8b949e);border-radius:7px;padding:6px 11px;font-size:11.5px;cursor:pointer}' +
+ '.fm-clear:hover{color:var(--ink,#e6edf3);border-color:var(--blue,#58a6ff)}' +
+ '.fm-x{background:none;border:0;color:var(--mut,#8b949e);font-size:20px;line-height:1;cursor:pointer;padding:2px 4px}' +
+ '.fm-x:hover{color:var(--ink,#e6edf3)}' +
+ '.fm-body{overflow:auto;padding:6px 6px 12px}' +
+ '.fm-panel.rsec{border-bottom:1px solid var(--line,#2a313c);padding:0 12px}' +
+ '.fm-panel.rsec:last-child{border-bottom:0}' +
+ '.fm-ph{display:flex;align-items:center;gap:8px;margin:0!important;padding:13px 2px;cursor:pointer;user-select:none;color:var(--mut,#8b949e)!important;font-size:11px!important;text-transform:uppercase;letter-spacing:.5px}' +
+ '.fm-ph .fm-ptitle{flex:1}' +
+ '.fm-ph .fm-chev{transition:transform .16s ease;font-size:11px;color:var(--mut,#8b949e)}' +
+ '.fm-panel.fm-open .fm-ph .fm-chev{transform:rotate(90deg)}' +
+ '.fm-ph .fm-pcount{background:var(--blue,#58a6ff);color:#0e1116;border-radius:20px;padding:0 6px;font-size:9.5px;font-weight:700;display:none}' +
+ '.fm-ph .fm-pcount.on{display:inline-block}' +
+ '.fm-panel:not(.fm-open) > *:not(.fm-ph){display:none!important}' + /* collapsed: hide everything but the header; open state falls through to the page\'s own CSS so chip/flex layouts are preserved */
+ '.fm-panel .chips{padding-bottom:12px}' +
+ '@media(max-width:640px){.fm-modal{width:100%;max-height:92vh}.fm-ov{padding:2vh 8px 8px}}';
+ var st = document.createElement('style'); st.id = 'fm-style'; st.textContent = css; document.head.appendChild(st);
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+ else boot();
+})();
diff --git a/public/just-listed.html b/public/just-listed.html
index c7c013e..e7a5023 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -1,66 +1,113 @@
<!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>
-:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--rowpad:6px}
+/* 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:#fff}
+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:#0e1116;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 (shared look with deals-flow) */
-.ticker{display:flex;align-items:stretch;background:#0b0e13;border-bottom:1px solid var(--line);overflow:hidden;height:32px}
+/* 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:#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 #20262f}
+.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}
-.rail{width:230px;flex:0 0 230px;border-right:1px solid var(--line);padding:12px;height:calc(100vh - 84px);overflow:auto;position:sticky;top:0}
-.rsec{border-bottom:1px solid var(--line);padding:9px 0}
-.rsec h4{margin:0 0 7px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut)}
+/* ── 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(--card);user-select:none}
+.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:#0e1116;border-color:var(--blue);font-weight:600}
.chip .ct{color:var(--mut);margin-left:4px;font-size:10px}.chip.active .ct{color:#0e1116}
-.rail input[type=number]{width:100%;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
+.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:#0e1116;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:#fff}.stat span{display:block;font-size:10px;text-transform:uppercase;color:var(--mut);letter-spacing:.4px}
+.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)}
-/* ── column-picker dropdown ── */
-.colmenu{position:relative}
-.colpanel{position:absolute;right:0;top:calc(100% + 4px);background:var(--card);border:1px solid var(--line);border-radius:10px;padding:9px 10px;z-index:30;display:grid;grid-template-columns:1fr 1fr;gap:1px 16px;box-shadow:0 10px 30px rgba(0,0,0,.5);min-width:300px}
-.colpanel .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;align-items:center}
-.colpanel .ph a{color:var(--blue);cursor:pointer;text-transform:none;letter-spacing:0}
-.colpanel label{display:flex;align-items:center;gap:7px;font-size:12px;padding:3px 3px;cursor:pointer;white-space:nowrap;color:var(--ink);border-radius:6px}
-.colpanel label:hover{background:rgba(88,166,255,.08)}
-.colpanel input{accent-color:var(--blue)}
-/* ── the list / table ── */
-.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 300px)}
+.newpill{font-size:9px;font-weight:800;letter-spacing:.4px;color:#0e1116;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:#0e1116}
+.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:#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{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}
@@ -69,15 +116,32 @@ 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 #20262f;vertical-align:top;line-height:1.35}
+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:hover{background:rgba(88,166,255,.06)}
+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:220px;max-width:380px;color:var(--mut)}
+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}
-</style> <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+/* 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 -->
</head><body>
<header>
<h1><b>Just Listed</b> · Active Inventory</h1>
@@ -87,52 +151,93 @@ td a.f:hover{text-decoration:underline}
</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">
- <div class="rsec"><h4>Status</h4><div class="chips" id="fStatus"></div></div>
- <div class="rsec"><h4>Property type</h4><div class="chips" id="fType"></div></div>
- <div class="rsec"><h4>City</h4><div class="chips" id="fCity"></div></div>
- <div class="rsec"><h4>Min price $</h4><input type="number" id="pMin" placeholder="e.g. 1000000" step="250000"></div>
- <div class="rsec"><h4>Max price $</h4><input type="number" id="pMax" placeholder="e.g. 5000000" step="250000"></div>
- <div class="rsec"><h4>Min units</h4><input type="number" id="uMin" placeholder="e.g. 5"></div>
- <div class="rsec"><h4>Max units</h4><input type="number" id="uMax" placeholder="any"></div>
+<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>
- <div class="toolbar">
- <input class="search" id="q" placeholder="search address, city, type, firm, upside… (space = AND)">
- <select id="sort"></select>
- <div class="colmenu">
- <button class="btn" id="colBtn" aria-expanded="false">⚙ Columns ▾</button>
- <div class="colpanel" id="colPanel" hidden></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>
- <button class="btn" id="csvBtn" title="Download the current filtered/sorted view (visible columns) as CSV">⭳ CSV</button>
- <label style="font-size:11px;color:var(--mut)">row height <input type="range" id="dens" min="2" max="10" step="1"></label>
- <span class="count" id="count"></span>
</div>
- <div class="tablewrap">
- <table>
- <thead id="thead"></thead>
- <tbody id="tbody"></tbody>
- </table>
+
+ <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;
-let dens=(function(){const v=parseInt(localStorage.getItem('jlDens'),10);return(v>=2&&v<=10)?v:6;})();
+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 (id -> iso created_at)
+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,'');
+
+// ── 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[r.id] || 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 — one source of truth for header, cells, sort comparator + picker ──
-// num:true → right-aligned + numeric sort. val() → custom sort/derived value. render() → cell HTML. on → visible by default.
+// ── 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">${esc(r.address||'—')} ↗</a>`:esc(r.address||'—')},
+ {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},
@@ -142,14 +247,16 @@ const COLS=[
{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>`},
+ {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,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,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:'firm',label:'Firm',on:false,render:r=>esc(String(r.firm||'—').slice(0,42))},
{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))},
];
-// restore saved column ORDER, then visibility
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);});
@@ -160,13 +267,14 @@ 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)));};
-// space-separated search = AND across every field (Steve standing rule)
function passSearch(r){
if(!q)return true;
- 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].map(v=>v==null?'':String(v)).join(' ').toLowerCase();
+ 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;
@@ -188,59 +296,177 @@ function chip(obj,attr,lim){return Object.entries(obj).sort((a,b)=>b[1]-a[1]).sl
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',10);$('#fCity').innerHTML=chip(cc,'c',18);
+ $('#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.slice(0,300);
+ 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) ──
+
+// ── 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>';
}
-// ── body ──
function rowHtml(r){
- return '<tr>'+visCols().map(c=>{
+ 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 render(){
- document.documentElement.style.setProperty('--rowpad',dens+'px');
- const rows=filtered();
+function renderTable(rows){
+ renderHead();
+ $('#tbody').innerHTML=rows.slice(0,600).map(rowHtml).join('');
+}
+
+// ── 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>`;
- const shown=rows.slice(0,600);
- $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`+(rows.length>600?' (showing first 600)':'');
- renderHead();
- $('#tbody').innerHTML=shown.map(rowHtml).join('');
+ $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
$('#empty').hidden=rows.length>0;
}
-// ── set sort + keep the preset <select> in sync ──
-function setSort(key,dir){
- sortKey=key;sortDir=dir;
- const want=key+':'+(dir===1?'asc':'desc');
- if([...$('#sort').options].some(o=>o.value===want))$('#sort').value=want;
- render();
+
+// ── 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(); },
+ rowClick:(row)=>openSpec(row),
+ storageKey:'jl', defaultView:'grid', views:['grid','list','table'],
+ });
}
-// header click: same col flips direction; new col defaults desc for numbers, asc for text
+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);
});
-// header drag-to-reorder (a real drag suppresses the trailing click, so click-sort still works)
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();renderColPanel();render();}dragK=null;});
+$('#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();});
@@ -250,11 +476,28 @@ $('#pMax').addEventListener('input',()=>{F.pMax=$('#pMax').value?+$('#pMax').val
$('#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();});
-$('#sort').addEventListener('change',()=>{const v=$('#sort').value.split(':');setSort(v[0],v[1]==='asc'?1:-1);});
+$('#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();});
-$('#sort').innerHTML=[['price:desc','Price ↓'],['price:asc','Price ↑'],['units:desc','Units ↓'],['ppu:asc','$/Unit ↑'],['cap_rate:desc','Cap rate ↓'],['year_built:desc','Newest built'],['city:asc','City A→Z'],['address:asc','Address A→Z']].map(([k,l])=>`<option value="${k}"${k==='price:desc'?' selected':''}>Sort: ${l}</option>`).join('');
-$('#dens').value=dens;
-// ── CSV export: current visible columns (in order) × full filtered+sorted set (no 600 cap) ──
+$('#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}/${COLS.length} shown</span><a id="colAll">all</a></div>`+
+ COLS.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();
@@ -266,43 +509,82 @@ function exportCSV(){
document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1500);
}
$('#csvBtn').addEventListener('click',exportCSV);
-// ── column picker (remove / restore any column) ──
-function renderColPanel(){
- $('#colPanel').innerHTML=
- `<div class="ph"><span>Columns shown</span><a id="colAll">all</a></div>`+
- COLS.map(c=>`<label><input type="checkbox" data-k="${c.k}"${c.on?' checked':''}>${esc(c.label)}</label>`).join('');
+
+// ── 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);
}
-renderColPanel();
-$('#colBtn').addEventListener('click',e=>{e.stopPropagation();const p=$('#colPanel');p.hidden=!p.hidden;$('#colBtn').setAttribute('aria-expanded',String(!p.hidden));});
-$('#colPanel').addEventListener('click',e=>{
- e.stopPropagation();
- if(e.target.id==='colAll'){COLS.forEach(c=>c.on=true);renderColPanel();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;} // never allow zero columns
- if(sortKey===col.k&&!col.on){setSort(visCols()[0].k,-1);} // if we hid the sorted col, re-anchor sort
- saveCols();render();
-});
-document.addEventListener('click',()=>{$('#colPanel').hidden=true;$('#colBtn').setAttribute('aria-expanded','false');});
-document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!$('#colPanel').hidden){$('#colPanel').hidden=true;$('#colBtn').setAttribute('aria-expanded','false');}});
-// ── auth pill (same account layer as deals-flow) ──
+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:#fff">${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';}}
+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(()=>{});
-// ── breaking-news ticker ──
+// 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();
-// ── load active inventory ──
-fetch('/data/listings.json').then(r=>r.json()).then(d=>{
- META=d.meta||{};
- DATA=(d.listings||[]).filter(l=>String(l.status||'').toLowerCase().startsWith('active'));
- $('#srcBadge').textContent=`${DATA.length.toLocaleString()} active`;
- $('#prov').innerHTML=`Active listings sourced from broker/portal feeds${META.sourced?` · batch sourced <b>${esc(String(META.sourced).slice(0,10))}</b>`:''}. Click any column header to sort, <b>drag headers</b> to reorder, and use <b>⚙ Columns</b> to show/hide fields. This feed carries no per-listing "date listed", so there's no true recency sort — use <b>Cap rate</b>, <b>Price</b>, or <b>Newest built</b>. Verify status against the source before acting.`;
- buildRail();render();
-}).catch(()=>{$('#count').textContent='failed to load /data/listings.json';});
-</script> <script src="/global-search.js" defer></script>
- <script src="/sort-bar.js" defer></script>
-<script src="/corner-nav.js" defer></script>
+
+// ── boot: load brokers (contacts) + first-seen dates, then listings ──
+function startViews(){
+ if(typeof ThreeView==='undefined'){return setTimeout(startViews,40);} // three-view.js is deferred
+ 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;
+ if(!BROKERS[key] || ((b.phone||b.email) && !(BROKERS[key].phone||BROKERS[key].email)))
+ BROKERS[key]={name:b.name,firm:b.firm,phone:b.phone,email:b.email,website: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;
+ if(!ADDRC[k] || ((row.phone||row.email) && !(ADDRC[k].phone||ADDRC[k].email)))
+ ADDRC[k]={name:row.name,firm:null,phone:row.phone,email:row.email,website:row.website};
+ });
+ }catch(e){}
+ try{ const d=await api('/api/listing-dates'); DATES=(d&&d.dates)||{}; }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'));
+ const nowIso=new Date().toISOString();let changed=false;
+ DATA.forEach(r=>{ if(!SEEN[r.id] && !DATES[r.id]){ SEEN[r.id]=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>
diff --git a/scripts/serve.js b/scripts/serve.js
index e5455ec..d1bd253 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -176,6 +176,21 @@ app.get('/api/listing-dates', async (req, res) => {
res.json({ dates: m });
} catch (e) { res.json({ dates: {} }); }
});
+// TK-10703: per-listing REAL broker contact by ADDRESS. listings.json only knows the portal
+// ("Crexi") as firm, so a firm-name join yields ~0 contacts; the honest source is the broker
+// graph's listing→broker_listing→broker chain, matched on address. Returns the rows; the client
+// normalizes the address the same way it does locally so the join keys line up. Empty when no DB.
+// (Honors Steve's hard rule that every page always surfaces broker phone/email where it's known.)
+app.get('/api/listing-brokers', async (req, res) => {
+ try {
+ if (!brokerdb) return res.json({ rows: [] });
+ const rows = await brokerdb.pool.query(
+ `SELECT l.address, l.city, b.name, b.phone, b.email, b.website
+ FROM listing l JOIN broker_listing bl ON bl.listing_id = l.id JOIN broker b ON b.id = bl.broker_id
+ WHERE b.phone IS NOT NULL OR b.email IS NOT NULL`).then(r => r.rows);
+ res.json({ rows });
+ } catch (e) { res.json({ rows: [] }); }
+});
// On-demand: the $0 lazy path a deal's broker cell fires when it has no agent site yet.
app.post('/api/agent/discover', async (req, res) => {
try {
← 0914826 snapshot before CRCP UI standardization
·
back to Commercialrealestate
·
just-listed: Cody-gate fixes — recency join by address (new- 8cdf838 →