← back to Commercialrealestate
Make the grids actionable: MLS row → 100-yr property history popup; broker-grid row → full contact/listings/pitch modal
5a26b0387a5fbe0f7ee0bac7438ab2c8a2a60e66 · 2026-06-30 17:00:24 -0700 · Steve Abrams
mls.html: click any property (list row or grid card) → property-history popup (assessor parcel + sold
history + permits, address-only). broker-grid.html: click any broker → full modal (contact + current/
expired/sales listings with dates+MLS + ready Arcstone pitch). Header-sort clicks excluded. Verified
headless: both open correctly, sort still works, 0 console errors.
Files touched
M public/broker-grid.htmlM public/mls.html
Diff
commit 5a26b0387a5fbe0f7ee0bac7438ab2c8a2a60e66
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 30 17:00:24 2026 -0700
Make the grids actionable: MLS row → 100-yr property history popup; broker-grid row → full contact/listings/pitch modal
mls.html: click any property (list row or grid card) → property-history popup (assessor parcel + sold
history + permits, address-only). broker-grid.html: click any broker → full modal (contact + current/
expired/sales listings with dates+MLS + ready Arcstone pitch). Header-sort clicks excluded. Verified
headless: both open correctly, sort still works, 0 console errors.
---
public/broker-grid.html | 45 +++++++++++++++++++++++++++++++++++++++++++--
public/mls.html | 32 ++++++++++++++++++++++++++++++--
2 files changed, 73 insertions(+), 4 deletions(-)
diff --git a/public/broker-grid.html b/public/broker-grid.html
index 64e0c78..4259943 100644
--- a/public/broker-grid.html
+++ b/public/broker-grid.html
@@ -30,6 +30,16 @@
.atype{display:inline-block;font-size:10px;font-weight:700;text-transform:uppercase;padding:1px 6px;border-radius:8px;}
.atype.res{background:rgba(88,166,255,.16);color:var(--blue);} .atype.com{background:rgba(200,162,75,.16);color:var(--gold);}
.mut{color:var(--mut);} .miss{color:#5A6472;}
+ #ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
+ #ov.on{display:flex;}
+ #modal{width:min(700px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
+ #modal h3{margin:0 0 2px;font-size:20px;} #modal .mfirm{color:var(--gold);font-size:13px;}
+ #modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
+ #modal .ci{display:flex;gap:16px;flex-wrap:wrap;margin:10px 0;font-size:14px;} #modal .ci a{color:var(--blue);text-decoration:none;}
+ #modal .sec{margin-top:14px;} #modal .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
+ #modal .litem{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid #20262f;font-size:13px;} #modal .litem .m{color:var(--mut);}
+ .mbtn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 12px;font-size:13px;cursor:pointer;} .mbtn:hover{border-color:var(--blue);}
+ .pitch{background:#0a0d13;border:1px solid var(--line);border-radius:10px;padding:12px;font-size:13px;white-space:pre-wrap;margin-top:10px;} .pitch .subj{color:var(--gold);font-weight:700;margin-bottom:6px;}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;}
.gc{background:var(--card);border:1px solid var(--line);border-radius:11px;padding:12px 14px;}
.gc .nm{font-weight:700;} .gc .fm{color:var(--mut);font-size:12px;margin-bottom:6px;}
@@ -51,6 +61,7 @@
<div id="tableView" class="tblwrap"><table class="g"><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<div id="gridView" class="grid hide"></div>
</div>
+<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
@@ -106,10 +117,10 @@ function render(){
$('#thead').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}" class="${sortKey===c.k?(sortDir>0?'asc':'desc'):''}">${c.l}</th>`).join('')+'</tr>';
if(view==='table'){
$('#tableView').classList.remove('hide'); $('#gridView').classList.add('hide');
- $('#tbody').innerHTML=rows.map(r=>'<tr>'+COLS.map(c=>{const cls=(c.t==='n')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
+ $('#tbody').innerHTML=rows.map(r=>`<tr class="brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">`+COLS.map(c=>{const cls=(c.t==='n')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
} else {
$('#tableView').classList.add('hide'); $('#gridView').classList.remove('hide');
- $('#gridView').innerHTML=rows.map(r=>`<div class="gc">
+ $('#gridView').innerHTML=rows.map(r=>`<div class="gc brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">
<div class="nm">${esc(r.name)} ${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>
<div class="fm">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</div>
<div class="row"><span class="k">Listings</span><span>${num(r.listings)}</span></div>
@@ -120,6 +131,36 @@ function render(){
</div>`).join('');
}
}
+const money=n=>(n==null||n==='')?'—':'$'+Number(n).toLocaleString();
+const fdate=s=>{ if(!s) return ''; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return ''; } };
+const atb=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
+function renderPitch(p){ return '<div class="pitch">'+(p.subject?'<div class="subj">'+esc(p.subject)+'</div>':'')+esc(p.body)+'</div><div class="mut" style="margin-top:8px">⚑ DRAFT, ready to send — sending is George-gated + Steve-approved.</div>'; }
+async function openContact(name,id){
+ const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="mut">loading '+esc(name)+'…</div>';
+ const qs=id?'id='+encodeURIComponent(id):'name='+encodeURIComponent(name);
+ let d; try{ d=await (await fetch('/api/broker?'+qs)).json(); }catch(e){ mb.innerHTML='<div class="mut">error</div>'; return; }
+ if(d.error){ mb.innerHTML='<div class="mut">'+esc(d.error)+'</div>'; return; }
+ const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
+ const li=b.linkedin?(/^https?:/.test(b.linkedin)?b.linkedin:'https://'+b.linkedin):null;
+ mb.innerHTML='<h3>'+esc(b.name)+atb(b.agent_type)+'</h3><div class="mfirm">'+esc(b.firm||'—')+(b.title?' · '+esc(b.title):'')+(b.license?' · DRE/MLS '+esc(b.license):'')+'</div>'
+ +'<div class="ci">'
+ +(b.phone?'<a href="tel:'+esc(b.phone)+'">☎ '+esc(b.phone)+'</a>':'<span class="mut">☎ —</span>')
+ +(b.email?'<a href="mailto:'+esc(b.email)+'">✉ '+esc(b.email)+'</a>':'<span class="mut">✉ —</span>')
+ +(w?'<a href="'+esc(w)+'" target="_blank" rel="noopener noreferrer">🌐 website</a>':'')
+ +(li?'<a href="'+esc(li)+'" target="_blank" rel="noopener noreferrer">in↗</a>':'')+'</div>'
+ +'<div class="sec"><h4>Current listings ('+d.current.length+')</h4>'+(d.current.length?d.current.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+(l.zip?' '+esc(l.zip):'')+'</span><span class="m">'+esc(l.type)+' · '+money(l.price)+(l.units?' · '+l.units+'u':'')+(l.cap_rate?' · '+l.cap_rate+'% cap':'')+(l.listed_at?' · listed '+fdate(l.listed_at):'')+'</span></div>').join(''):'<div class="mut">none in our set</div>')+'</div>'
+ +'<div class="sec"><h4>Expired / off-market ('+((d.expired&&d.expired.length)||0)+')</h4>'+((d.expired&&d.expired.length)?d.expired.map(x=>'<div class="litem"><span>'+esc(x.address)+', '+esc(x.city)+'</span><span class="m">'+money(x.list_price)+' → sold '+money(x.sold_price)+' · '+fdate(x.sold_date)+(x.mls?' · MLS '+esc(x.mls):'')+(x.url?' · <a href="'+esc(x.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join(''):'<div class="mut">'+esc(d.expiredNote||'none')+'</div>')+'</div>'
+ +'<div class="sec"><h4>Sales — area sold ('+((d.comps&&d.comps.length)||0)+')</h4>'+((d.comps&&d.comps.length)?d.comps.map(c=>'<div class="litem"><span>'+esc(c.address)+', '+esc(c.city)+(c.beds?' · '+c.beds+'bd':'')+(c.baths?'/'+c.baths+'ba':'')+(c.sqft?' · '+Number(c.sqft).toLocaleString()+'sf':'')+'</span><span class="m">'+money(c.sold_price)+' · '+fdate(c.sold_date)+(c.mls?' · MLS '+esc(c.mls):'')+(c.url?' · <a href="'+esc(c.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join(''):'<div class="mut">no area sales</div>')+'</div>'
+ +'<div class="sec"><h4>Arcstone pitch <span class="mut">(ready to send)</span></h4><button class="mbtn" id="mgen">✉ Generate / refresh pitch</button><div id="mpitch">'+(d.pitches&&d.pitches.length?renderPitch(d.pitches[0]):'<div class="mut">no draft yet — click Generate</div>')+'</div></div>';
+ $('#mgen').onclick=()=>genPitch(b.name,b.id);
+}
+async function genPitch(name,id){
+ const out=$('#mpitch'); out.innerHTML='<div class="mut">drafting…</div>';
+ try{ const r=await (await fetch('/api/broker/pitch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,id,channel:'email'})})).json();
+ out.innerHTML=r.error?('<div class="mut">'+esc(r.error)+'</div>'):renderPitch(r.pitch);
+ }catch(e){ out.innerHTML='<div class="mut">'+esc(e.message)+'</div>'; }
+}
+document.addEventListener('click',e=>{ const row=e.target.closest('.brow'); if(row && e.target.tagName!=='A'){ openContact(row.dataset.name,row.dataset.id); } if(e.target.id==='ov') $('#ov').classList.remove('on'); });
$('#thead').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==='listings'||k==='total_assets'||k==='created_at')?-1:1;}render();});
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
diff --git a/public/mls.html b/public/mls.html
index c5ddfe5..dd52b28 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -38,6 +38,14 @@
.gc .row{display:flex;justify-content:space-between;font-size:12.5px;padding:2px 0;border-top:1px solid #20262f;}
.gc .row .k{color:var(--mut);} .gc .row .v{font-variant-numeric:tabular-nums;}
.hide{display:none;}
+ #ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
+ #ov.on{display:flex;}
+ #modal{width:min(680px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
+ #modal h3{margin:0 0 2px;font-size:19px;} .ph-sub{color:var(--warn);font-size:13px;}
+ .ph-x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
+ .ph-ci{display:flex;gap:14px;flex-wrap:wrap;margin:8px 0;font-size:13.5px;}
+ .ph-sec{margin-top:14px;} .ph-sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
+ .ph-item{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid #20262f;font-size:13px;} .ph-mut{color:var(--mut);font-size:12.5px;}
</style>
</head>
<body>
@@ -53,6 +61,7 @@
<div id="tableView" class="tblwrap"><table class="mls"><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<div id="gridView" class="grid hide"></div>
</div>
+<div id="ov"><div id="modal"><button class="ph-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,'"');
@@ -109,13 +118,13 @@ function render(){
$('#thead').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}" class="${sortKey===c.k?(sortDir>0?'asc':'desc'):''}">${c.l}</th>`).join('')+'</tr>';
if(view==='table'){
$('#tableView').classList.remove('hide'); $('#gridView').classList.add('hide');
- $('#tbody').innerHTML=rows.map(r=>'<tr>'+COLS.map(c=>{
+ $('#tbody').innerHTML=rows.map(r=>`<tr class="prow" data-addr="${esc(r.address||'')}" data-city="${esc(r.city||'')}" style="cursor:pointer">`+COLS.map(c=>{
const cls=(c.t==='money'||c.t==='n'||c.t==='pct')?' class="n"':'';
return `<td${cls}>${cellHTML(r,c)}</td>`;
}).join('')+'</tr>').join('');
} else {
$('#tableView').classList.add('hide'); $('#gridView').classList.remove('hide');
- $('#gridView').innerHTML=rows.map(r=>`<div class="gc">
+ $('#gridView').innerHTML=rows.map(r=>`<div class="gc prow" data-addr="${esc(r.address||'')}" data-city="${esc(r.city||'')}" style="cursor:pointer">
<div class="addr">${esc(r.address||'—')}</div><div class="sub">${esc(r.city||'')} ${esc(r.zip||'')} · ${esc(r.type||'')}</div>
<div class="row"><span class="k">Price</span><span class="v">${money(r.price)}</span></div>
<div class="row"><span class="k">Units / SqFt</span><span class="v">${num(r.units)} / ${num(r.sqft)}</span></div>
@@ -128,6 +137,25 @@ function render(){
}
$('#thead').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==='address'||k==='city'||k==='firm'||k==='type')?1:-1; } render(); });
+// click a property → 100-yr history popup (address-only, no names)
+async function propHistory(address,city){
+ const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="ph-mut">loading history for '+esc(address)+'…</div>';
+ let d; try{ d=await (await fetch('/api/property-history?address='+encodeURIComponent(address)+'&city='+encodeURIComponent(city||''))).json(); }catch(e){ mb.innerHTML='<div class="ph-mut">error</div>'; return; }
+ const a=d.assessor||d.assessorArchive;
+ let h='<h3>'+esc(address)+'</h3><div class="ph-sub">'+esc(city||'')+' · property history (address only — no names)</div>';
+ if(a){ h+='<div class="ph-sec"><h4>Assessor parcel'+(a.roll_year?' ('+esc(a.roll_year)+' roll)':'')+'</h4><div class="ph-ci">'
+ +(a.year_built?'<span>🏗 Built '+a.year_built+'</span>':'')
+ +(a.bedrooms!=null?'<span>🛏 '+a.bedrooms+' bd</span>':'')+(a.bathrooms!=null?'<span>🛁 '+a.bathrooms+' ba</span>':'')
+ +(a.sqft_main?'<span>📐 '+Number(a.sqft_main).toLocaleString()+' sqft</span>':'')+(a.units?'<span>🏠 '+a.units+' units</span>':'')
+ +((a.use_desc2||a.use_desc1||a.use_description)?'<span>🏷 '+esc(a.use_desc2||a.use_desc1||a.use_description)+'</span>':'')
+ +((a.roll_total_value||a.total_value)?'<span>💰 assessed '+money(a.roll_total_value||a.total_value)+'</span>':'')+'</div></div>'; }
+ h+='<div class="ph-sec"><h4>Sold history ('+d.sales.length+')</h4>'+(d.sales.length?d.sales.map(x=>'<div class="ph-item"><span>'+esc(x.address)+(x.city?', '+esc(x.city):'')+'</span><span class="ph-mut">'+money(x.sold_price)+' · '+String(x.sold_date||'').slice(0,10)+(x.mls?' · MLS '+esc(x.mls):'')+'</span></div>').join(''):'<div class="ph-mut">no sold records on file for this address</div>')+'</div>';
+ if(d.permits&&d.permits.length) h+='<div class="ph-sec"><h4>Permits ('+d.permits.length+')</h4>'+d.permits.map(p=>'<div class="ph-item"><span>'+esc(p.permit_type||p.use_description||'permit')+'</span><span class="ph-mut">'+esc(String(p.issue_date||'').slice(0,10))+(p.valuation?' · '+money(p.valuation):'')+'</span></div>').join('')+'</div>';
+ if(!a && !d.sales.length && !(d.permits||[]).length) h+='<div class="ph-mut">No history on file yet for this address. The 2.43M-parcel assessor roll is still ingesting — year built, beds/baths, and assessed value will appear here once it lands.</div>';
+ mb.innerHTML=h;
+}
+document.addEventListener('click',e=>{ const row=e.target.closest('.prow'); if(row && e.target.tagName!=='A'){ propHistory(row.dataset.addr,row.dataset.city); }
+ if(e.target.id==='ov'||e.target.classList.contains('ph-x')) $('#ov').classList.remove('on'); });
$('#q').addEventListener('input',()=>{ q=$('#q').value.trim(); render(); });
$('#view').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return;
document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x===b)); view=b.dataset.v; render(); });
← ddcd86b Broker grid+list view — all 2,537 brokers, every field sorta
·
back to Commercialrealestate
·
Add CSV export to MLS + broker grids (exports current filter c469c56 →