← back to Commercialrealestate
TK-10703 cycle7: direct-listings → CRCP standard (grid-controls + always-on phone/email via listing-brokers address-join)
cc4510aa658b478e4d333499d7ecd717f2e21f09 · 2026-08-21 06:54:08 -0700 · Steve Abrams
Files touched
M public/direct-listings.html
Diff
commit cc4510aa658b478e4d333499d7ecd717f2e21f09
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 21 06:54:08 2026 -0700
TK-10703 cycle7: direct-listings → CRCP standard (grid-controls + always-on phone/email via listing-brokers address-join)
---
public/direct-listings.html | 413 +++++++++++++++++++++++++++++++++++---------
1 file changed, 335 insertions(+), 78 deletions(-)
diff --git a/public/direct-listings.html b/public/direct-listings.html
index 303db50..9d9384f 100644
--- a/public/direct-listings.html
+++ b/public/direct-listings.html
@@ -1,131 +1,388 @@
<!doctype html><html lang="en"><head>
-<script>/* credential-safe fetch */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
+<script>/* 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 saved theme BEFORE first paint (single key 'crcp-theme'). 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: clear any nav-agent "removed" state 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>Direct Listings · our-own dbase · CRCP</title>
<style>
-:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--onacc:#0e1116;--rowpad:7px}
+/* 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;--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);text-decoration:none}a:hover{text-decoration:underline}
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{font-size:12px;margin-left:12px}
-.wrap{padding:14px 18px}
-.stats{margin-bottom:12px}
+.shell{display:flex;align-items:flex-start}
+/* ── left rail: 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 - 56px);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}
+.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}
.stat{display:inline-block;background:var(--card);border:1px solid var(--line);border-radius:9px;padding:7px 12px;margin:0 8px 8px 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}
.prov{font-size:11.5px;color:var(--mut);margin:0 0 12px;line-height:1.5}
.toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
-.dens-ctrl{display:inline-flex;align-items:center;gap:6px;color:var(--mut);font-size:13px}
-.dens-ctrl input[type=range]{width:90px;accent-color:var(--blue);cursor:pointer}
-.dens-ic{font-size:15px;line-height:1}
.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}
-.chips{display:flex;flex-wrap:wrap;gap:6px}
-.chip{border:1px solid var(--line);border-radius:20px;padding:3px 10px;font-size:11.5px;cursor:pointer;background:var(--card);color:var(--ink);user-select:none}
-.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)}
.count{color:var(--mut);font-size:12px}
-.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 250px)}
+.src{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 6px;border-radius:6px;display:inline-block}
+.src.crexi{background:rgba(88,166,255,.16);color:var(--blue)}
+.src.brokersiteoc{background:rgba(63,185,80,.16);color:var(--acc)}
+.src.brokersite{background:rgba(255,166,0,.16);color:var(--gold)}
+.role{font-size:10px;color:var(--mut)}
+/* ── 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}
+.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}
+/* 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 - 300px)}
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)}
-tbody td{padding:var(--rowpad) 11px;border-bottom:1px solid var(--line);vertical-align:top}
+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:hover{background:var(--hover,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}
-.src{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 6px;border-radius:6px}
-.src.crexi{background:rgba(88,166,255,.16);color:var(--blue)}
-.src.brokersiteoc{background:rgba(63,185,80,.16);color:var(--acc)}
-.src.brokersite{background:rgba(255,166,0,.16);color:var(--gold)}
-.role{font-size:10px;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(620px,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}
</style>
-<link rel="stylesheet" href="/crcp-theme.css">
-<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
+ <link rel="stylesheet" href="/three-view.css">
+ <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so it wins -->
+ <style>/* theme-aware "text on accent" so chips/pills pass contrast in BOTH themes (Cody gate) */:root{--onacc:#0e1116}html[data-theme="light"]{--onacc:#ffffff}</style>
</head><body>
<header>
<h1><b>Direct Listings</b> · our-own dbase</h1>
<span class="badge" id="cnt">…</span>
<div class="nav"><a href="/brokers.html">Brokers</a><a href="/just-listed.html">Just Listed</a><a href="/">Explorer</a></div>
</header>
-<div class="wrap">
+<div class="shell">
+<aside class="rail" id="rail">
+ <div class="panel collapsed" data-p="source"><h4>Source <span><span class="n" id="nSrc"></span> <span class="caret">▼</span></span></h4><div class="body"><div class="chips" id="fSrc"></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="firm"><h4>Firm <span><span class="n" id="nFirm"></span> <span class="caret">▼</span></span></h4><div class="body"><div class="chips" id="fFirm"></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="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 class="stats" id="stats"></div>
- <div class="prov">Listings scraped <b>directly</b> from broker & firm websites (openclaw real-Chrome) and Crexi detail pages (agent-attributed) — kept in a dedicated store (<code>broker_direct_listing</code>), separate from the aggregator-originated graph. No listing here links out to Crexi/LoopNet.</div>
+ <div class="prov">Listings scraped <b>directly</b> from broker & firm websites (openclaw real-Chrome) and Crexi detail pages (agent-attributed) — kept in a dedicated store (<code>broker_direct_listing</code>), separate from the aggregator-originated graph. No listing here links out to Crexi/LoopNet. Broker <b>phone & email</b> are joined by property address from the CRCP listing-broker graph (<code>/api/listing-brokers</code>); un-joined rows show the firm + a note (never a fabricated contact).</div>
<div class="toolbar">
- <input class="search" id="q" placeholder="search agent, firm, address, city…">
- <div class="chips" id="srcChips"></div>
- <select id="sort"></select>
- <label class="dens-ctrl" title="Row density"><span class="dens-ic" aria-hidden="true">≡</span><input type="range" id="dens" min="4" max="16" step="1" value="7" aria-label="Row density"></label>
+ <input class="search" id="q" placeholder="search agent, firm, address, city, broker… (space = AND)">
+ <span data-tvseg id="viewseg"></span>
+ <select id="tvSort"></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>
- <div class="tablewrap"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
- <div class="empty" id="empty" hidden>No direct listings yet.</div>
-</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 direct 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,'"');
-const money=n=>n==null||isNaN(+n)?'—':'$'+Math.round(+n).toLocaleString();
-let DATA=[],q='',srcF=new Set(),sortKey='scraped_at',sortDir=-1;
+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=[],q='',sortKey='scraped_at',sortDir=-1,TV=null,dens=(function(){const v=parseInt(localStorage.getItem('dl:dens'),10);return(v>=1&&v<=8)?v:4;})();
+let ADDRC={}; // normalized-street-address -> {name,phone,email,website} (REAL per-listing agent, primary)
+const F={source:new Set(),type:new Set(),firm:new Set(),pMin:null,pMax:null};
+const tog=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
+const clean=v=>{const s=(v==null?'':String(v)).trim();return(!s||/^null/i.test(s))?null:s;};
+const weburl=w=>{w=clean(w);return w?(/^https?:\/\//i.test(w)?w:'http://'+w):null;};
+// address normalizer — street-only (drop the ", City, CA ZIP" tail) then strip street-type + directional words
+const na=s=>String(s||'').split(',')[0].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 srcClass=s=>String(s||'').replace(/[^a-z]/g,'');
+
+// ── broker contact join (Steve HARD RULE: always show broker phone + email) ──
+// The authoritative, NON-FABRICATED source is /api/listing-brokers joined by property address.
+function brokerFor(r){ if(!r) return null; return ADDRC[na(r.address)]||null; }
+
+// ── COLUMN MODEL — header, cells, sort comparator, picker, + ThreeView fields ──
const COLS=[
- {k:'agent',l:'Agent',r:x=>esc(x.agent||'—')},
- {k:'firm',l:'Firm',r:x=>esc(x.firm||'—')},
- {k:'address',l:'Address',r:x=>esc(x.address||'—')},
- {k:'city',l:'City',r:x=>esc(x.city||'—')},
- {k:'price',l:'Price',num:1,cls:'price',r:x=>money(x.price)},
- {k:'type',l:'Type',r:x=>esc(x.type||'—')},
- {k:'cap_rate',l:'Cap %',num:1,r:x=>x.cap_rate!=null?(+x.cap_rate).toFixed(2)+'%':'—'},
- {k:'role',l:'Role',r:x=>`<span class="role">${esc(x.role||'—')}</span>`},
- {k:'source',l:'Source',r:x=>`<span class="src ${(x.source||'').replace(/[^a-z]/g,'')}">${esc(x.source||'—')}</span>`},
- {k:'scraped_at',l:'Scraped',r:x=>x.scraped_at?new Date(x.scraped_at).toLocaleDateString(undefined,{month:'short',day:'numeric'}):'—'},
+ {k:'agent',label:'Agent',on:true,render:r=>esc(r.agent||'—')},
+ {k:'firm',label:'Firm',on:true,render:r=>esc(String(r.firm||'—').slice(0,42))},
+ {k:'address',label:'Address',on:true,render:r=>esc(r.address||'—')},
+ {k:'city',label:'City',on:false,render:r=>esc(r.city||'—')},
+ {k:'price',label:'Price',on:true,num:true,cls:'price',render:r=>money(r.price)||'—'},
+ {k:'type',label:'Type',on:true,render:r=>esc(r.type||'—')},
+ {k:'units',label:'Units',on:false,num:true,render:r=>r.units!=null?esc(r.units):'—'},
+ {k:'cap_rate',label:'Cap %',on:true,num:true,render:r=>(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—'},
+ {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:'role',label:'Role',on:false,render:r=>`<span class="role">${esc(r.role||'—')}</span>`},
+ {k:'source',label:'Source',on:true,render:r=>`<span class="src ${srcClass(r.source)}">${esc(r.source||'—')}</span>`},
+ {k:'scraped_at',label:'Scraped',on:true,val:r=>r.scraped_at||'',render:r=>r.scraped_at?new Date(r.scraped_at).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—'},
];
-function pass(x){
- if(srcF.size&&!srcF.has(x.source))return false;
+try{
+ const savedOrder=JSON.parse(localStorage.getItem('dlColOrder')||'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('dlCols')||'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('dlCols',JSON.stringify(COLS.filter(c=>c.on).map(c=>c.k)));localStorage.setItem('dlColOrder',JSON.stringify(COLS.map(c=>c.k)));};
+
+function passSearch(r){
if(!q)return true;
- const hay=[x.agent,x.firm,x.address,x.city,x.type,x.source].map(v=>v==null?'':String(v)).join(' ').toLowerCase();
+ const b=brokerFor(r);
+ const hay=[r.agent,r.firm,r.address,r.city,r.type,r.source,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(t=>hay.includes(t));
}
+function pass(r){
+ if(F.source.size&&!F.source.has(r.source))return false;
+ if(F.type.size&&!F.type.has(r.type||'—'))return false;
+ if(F.firm.size&&!F.firm.has(r.firm||'—'))return false;
+ if(F.pMin!=null&&!(+r.price>=F.pMin))return false;
+ if(F.pMax!=null&&!(+r.price<=F.pMax))return false;
+ return passSearch(r);
+}
function filtered(){
- return DATA.filter(pass).slice().sort((a,b)=>{let x=a[sortKey],y=b[sortKey];const xm=x==null||x==='',ym=y==null||y==='';
+ 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 scc={},tc={},fc={};
+ DATA.forEach(r=>{const s=r.source||'—';scc[s]=(scc[s]||0)+1;const t=r.type||'—';tc[t]=(tc[t]||0)+1;const f=r.firm||'—';fc[f]=(fc[f]||0)+1;});
+ $('#fSrc').innerHTML=chip(scc,'s');$('#fType').innerHTML=chip(tc,'t',14);$('#fFirm').innerHTML=chip(fc,'f',25);
+ $('#nSrc').textContent=Object.keys(scc).length;$('#nType').textContent=Object.keys(tc).length;$('#nFirm').textContent=Object.keys(fc).length;
+}
+
+// ── broker cell shared between grid card + modal ──
+function brokerBlock(r){
+ const b=brokerFor(r);
+ 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>';
+ const web=b.website?`<a href="${esc(b.website)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">🌐 site</a>`:'';
+ return `<div class="broker"><div class="bf">${esc(b.name||r.agent||r.firm||'—')}${b.name&&r.firm?` · ${esc(r.firm)}`:''}</div><div class="bc">${ph}${em}${web}</div></div>`;
+ }
+ return `<div class="broker"><div class="bf">${esc(r.agent||r.firm||'Listing brokerage')}${r.agent&&r.firm?` · ${esc(r.firm)}`:''}</div><div class="bc"><span class="none">📞 phone: via source</span><span class="none">✉️ email: via source</span></div></div>`;
+}
+
+// ── GRID cards ──
+function renderGrid(rows){
+ const host=$('#gridView');
+ host.innerHTML=rows.map(r=>{
+ return `<div class="gcard" data-id="${esc(r.id)}">
+ <div class="addr">${esc(r.address||'—')}</div>
+ <div class="sub">${esc(r.city||'')}${r.type?' · '+esc(r.type):''} · <span class="src ${srcClass(r.source)}">${esc(r.source||'—')}</span></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">Cap</span>${(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—'}</span>
+ <span><span class="k">Role</span>${esc(r.role||'—')}</span>
+ </div>
+ ${brokerBlock(r)}
+ </div>`;
+ }).join('');
+}
+
+// ── table header (click to sort · drag to reorder) ──
+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(''); }
+
+// ── 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 withContact=rows.filter(r=>{const b=brokerFor(r);return b&&(b.phone||b.email);}).length;
+ $('#stats').innerHTML=
+ `<div class="stat"><b>${rows.length.toLocaleString()}</b><span>direct listings</span></div>`+
+ `<div class="stat"><b>${new Set(rows.map(r=>r.firm).filter(Boolean)).size}</b><span>firms</span></div>`+
+ `<div class="stat"><b>${new Set(rows.filter(r=>r.agent).map(r=>r.agent)).size}</b><span>agents</span></div>`+
+ `<div class="stat"><b style="color:var(--acc)">${withContact.toLocaleString()}</b><span>with contact</span></div>`+
+ `<div class="stat"><b style="color:var(--gold)">$${(tot/1e9).toFixed(2)}B</b><span>total</span></div>`;
+ $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
+ const emp=$('#empty');emp.hidden=rows.length>0;
+}
+
+// ── 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:''}`}),
+ badgeHtml:r=>`<span class="src ${srcClass(r.source)}">${esc(r.source||'—')}</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:'dl', defaultView:'table', views:['grid','list','table'],
+ });
}
-let _dlInf=null,_dlRows=[];
-function _dlRow(x){ return '<tr>'+COLS.map(c=>`<td class="${c.num?'num ':''}${c.cls||''}">${c.r(x)}</td>`).join('')+'</tr>'; }
function render(){
const rows=filtered();
- $('#thead').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}" class="${c.num?'num':''}${c.k===sortKey?' sorted':''}">${c.l}${c.k===sortKey?(sortDir===1?' ▲':' ▼'):''}</th>`).join('')+'</tr>';
- // Windowed infinite scroll (was rows.slice(0,2000)): reveal a batch, load more on scroll.
- _dlRows=rows;
- if(!_dlInf&&window.InfScroll) _dlInf=InfScroll.attach({batch:150,getRows:()=>_dlRows,getMount:()=>{const b=$('#tbody');return (b&&b.closest('table'))||b;},render:win=>{$('#tbody').innerHTML=win.map(_dlRow).join('');}});
- if(_dlInf) _dlInf.reset(); else $('#tbody').innerHTML=rows.map(_dlRow).join('');
- $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
- $('#empty').hidden=rows.length>0;
+ renderStats(rows);
+ if(TV){TV.render();}else{renderGrid(rows);renderTable(rows);}
}
-$('#thead').addEventListener('click',e=>{const th=e.target.closest('th');if(!th)return;const k=th.dataset.k;if(k===sortKey)sortDir=-sortDir;else{sortKey=k;sortDir=COLS.find(c=>c.k===k)?.num?-1:1;}render();});
+function setSort(key,dir){ sortKey=key;sortDir=dir; render(); }
+
+// ── full-spec modal ──
+function openSpec(r){
+ const b=brokerFor(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)">Agent contact not in the CRCP listing-broker graph for this address — reach the listing agent via the firm below.</span>`;
+ $('#mbody').innerHTML=
+ `<h2>${esc(r.address||'—')}</h2><div class="msub">${esc(r.city||'')}${r.type?' · '+esc(r.type):''} · <span class="src ${srcClass(r.source)}">${esc(r.source||'—')}</span></div>`+
+ `<div class="mbroker"><h3>Listing agent${b&&b.name?` — ${esc(b.name)}`:(r.agent?` — ${esc(r.agent)}`:'')}</h3><div><b>${esc(r.firm||'—')}</b></div><div style="margin-top:6px">${bc}</div></div>`+
+ `<div class="specs">`+
+ spec('Price',money(r.price)?`<b style="color:var(--gold)">${money(r.price)}</b>`:'—')+
+ spec('Type',esc(r.type||'—'))+spec('Units',r.units)+
+ spec('Cap rate',(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—')+
+ spec('City',esc(r.city||'—'))+spec('ZIP',esc(r.zip||'—'))+
+ spec('Role',esc(r.role||'—'))+spec('Source',esc(r.source||'—'))+
+ spec('Scraped',r.scraped_at?new Date(r.scraped_at).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—')+
+ `</div>`;
+ $('#ov').classList.add('on');
+}
+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!=null){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!=null){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 + filters ──
+$('#rail').addEventListener('click',e=>{const h=e.target.closest('.panel > h4');if(!h)return;h.parentElement.classList.toggle('collapsed');});
+$('#fSrc').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.source,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();});
+$('#fFirm').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.firm,c.dataset.f);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();});
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
-// Density slider — controls row padding (compact ↔ roomy), persisted (Steve's grid standard).
-const _setDens=v=>document.documentElement.style.setProperty('--rowpad',v+'px');
-(()=>{const saved=+localStorage.getItem('dl:dens')||7;$('#dens').value=saved;_setDens(saved);})();
-$('#dens').addEventListener('input',()=>{const v=$('#dens').value;_setDens(v);localStorage.setItem('dl:dens',v);});
-$('#sort').innerHTML=[['scraped_at:desc','Newest scraped'],['price:desc','Price ↓'],['price:asc','Price ↑'],['agent:asc','Agent A→Z'],['firm:asc','Firm A→Z'],['city:asc','City A→Z']].map(([k,l])=>`<option value="${k}">Sort: ${l}</option>`).join('');
-$('#sort').addEventListener('change',()=>{const v=$('#sort').value.split(':');sortKey=v[0];sortDir=v[1]==='asc'?1:-1;render();});
-fetch('/api/direct-listings').then(r=>r.json()).then(d=>{
- DATA=d.listings||[];
- $('#cnt').textContent=`${DATA.length.toLocaleString()} direct`;
- const bs=d.bySource||{};
- $('#srcChips').innerHTML=Object.entries(bs).sort((a,b)=>b[1]-a[1]).map(([s,n])=>`<span class="chip" data-s="${esc(s)}">${esc(s)}<span class="ct">${n}</span></span>`).join('');
- $('#stats').innerHTML=
- `<div class="stat"><b>${DATA.length.toLocaleString()}</b><span>direct listings</span></div>`+
- `<div class="stat"><b>${new Set(DATA.map(x=>x.firm).filter(Boolean)).size}</b><span>firms</span></div>`+
- `<div class="stat"><b>${new Set(DATA.filter(x=>x.agent).map(x=>x.agent)).size}</b><span>agents</span></div>`+
- `<div class="stat"><b style="color:var(--gold)">$${(DATA.reduce((s,x)=>s+(+x.price||0),0)/1e9).toFixed(2)}B</b><span>total</span></div>`;
- render();
-}).catch(()=>{$('#count').textContent='failed to load /api/direct-listings';});
-$('#srcChips').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;const s=c.dataset.s;srcF.has(s)?srcF.delete(s):srcF.add(s);c.classList.toggle('active');render();});
+$('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('dl:dens',dens);render();});
+$('#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();
+});
+
+// ── boot: load listing-broker contacts (by address) THEN direct listings ──
+let _tvTries=0;
+function startViews(){
+ if(typeof ThreeView==='undefined'){ if(++_tvTries>100){renderGrid(filtered());renderTable(filtered());return;} return setTimeout(startViews,40); }
+ mountThreeView();render();
+}
+async function boot(){
+ // REAL per-listing agent contact by address (authoritative, non-fabricated source)
+ try{
+ const lb=await fetch('/api/listing-brokers').then(r=>r.json());
+ ((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),phone:ph,email:em,website:weburl(row.website)};
+ });
+ }catch(e){}
+ fetch('/api/direct-listings').then(r=>r.json()).then(d=>{
+ DATA=(d.listings||[]).map((x,i)=>Object.assign({id:i},x));
+ $('#cnt').textContent=`${DATA.length.toLocaleString()} direct`;
+ buildRail();renderColGrid();startViews();
+ }).catch(()=>{$('#count').textContent='failed to load /api/direct-listings';});
+}
+boot();
</script>
-<script src="/corner-nav.js" defer></script>
-<script src="/crcp-theme.js" defer></script>
+ <script src="/three-view.js" defer></script>
+ <script src="/corner-nav.js" defer></script>
+ <script src="/crcp-theme.js" defer></script><!-- day/night toggle (auto-injects into header) -->
</body></html>
← 1390e7a TK-10703 cycle7: property → always-on listing-agent contact
·
back to Commercialrealestate
·
TK-10703 cycle7: linkedin → CRCP standard (List/Grid view to 05883e8 →