← back to Nationalrealestate
public/deals.html
453 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Commercial Deals · USRealEstate</title>
<style>
:root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;--blue:#5a9be0;--rowh:32px;--fs:13px;}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;overflow:hidden;}
a{color:var(--gold);text-decoration:none;} a:hover{text-decoration:underline;}
#topbar{position:fixed;inset:0 0 auto 0;z-index:1200;display:flex;align-items:center;gap:16px;
padding:9px 16px;background:rgba(17,21,29,.96);border-bottom:1px solid var(--line);backdrop-filter:blur(6px);}
.brand{font-size:15px;font-weight:700;white-space:nowrap;} .brand b{color:var(--gold);}
nav{display:flex;gap:13px;font-size:13px;} nav a{color:var(--muted);} nav a:hover,nav a.active{color:var(--gold);text-decoration:none;}
#controls{display:flex;gap:8px;align-items:center;margin-left:auto;flex-wrap:wrap;}
#controls input,#controls select{background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:6px 10px;font:12px inherit;}
#controls input:focus,#controls select:focus{outline:none;border-color:var(--gold);}
#q{width:200px;}
.lbl{font-size:11px;color:var(--muted);}
#density{width:90px;accent-color:var(--gold);}
#wrap{position:fixed;inset:49px 0 0 0;display:flex;}
#side{width:245px;flex:none;border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:10px;}
#side h3{margin:12px 0 6px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--gold);}
#side h3:first-child{margin-top:2px;}
#totals{font-size:12px;color:var(--muted);margin-bottom:6px;font-variant-numeric:tabular-nums;line-height:1.7;}
#totals b{color:var(--fg);} #totals .vol{color:var(--gold);}
.facet{display:flex;justify-content:space-between;gap:6px;padding:4px 7px;border-radius:7px;cursor:pointer;font-size:12.5px;border:1px solid transparent;}
.facet:hover{background:var(--bg);border-color:var(--line);}
.facet.sel{background:var(--bg);border-color:var(--gold);color:var(--gold);}
.facet .ct{color:var(--gold);font-variant-numeric:tabular-nums;flex:none;}
.facet .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#active{display:flex;flex-wrap:wrap;gap:5px;padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);min-height:0;}
#active:empty{display:none;}
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;padding:3px 9px;border-radius:12px;border:1px solid var(--gold);color:var(--gold);background:rgba(200,162,75,.08);}
.chip b{color:var(--fg);font-weight:600;} .chip .rm{cursor:pointer;opacity:.7;} .chip .rm:hover{opacity:1;}
#main{flex:1;display:flex;flex-direction:column;min-width:0;}
#tablewrap{flex:1;overflow:auto;position:relative;}
table{border-collapse:collapse;width:100%;font-size:var(--fs);font-variant-numeric:tabular-nums;}
thead th{position:sticky;top:0;z-index:2;background:#0d1119;border-bottom:1px solid var(--line);color:var(--muted);
text-align:left;padding:7px 10px;font-weight:600;white-space:nowrap;cursor:pointer;}
thead th:hover{color:var(--gold);} thead th.num{text-align:right;}
tbody td{border-bottom:1px solid var(--line);padding:0 10px;height:var(--rowh);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px;}
tbody td.num{text-align:right;}
tbody tr:hover{background:#141a24;}
.num{text-align:right;} .gold{color:var(--gold);}
td a.drill{color:var(--blue);} td a.drill:hover{color:var(--gold);}
td a.open{color:var(--gold);font-weight:600;}
#status{padding:6px 14px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);background:var(--panel);display:flex;gap:14px;align-items:center;}
#status button{background:var(--bg);border:1px solid var(--line);color:var(--muted);border-radius:6px;padding:3px 10px;cursor:pointer;font:11px inherit;}
#status button:hover:not(:disabled){color:var(--fg);border-color:var(--muted);} #status button:disabled{opacity:.4;cursor:default;}
.empty{color:var(--muted);font-size:13px;padding:40px;text-align:center;}
/* view segment toggle */
.cg-seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
.cg-seg button{background:var(--bg);color:var(--muted);border:0;border-right:1px solid var(--line);padding:6px 9px;font:12px inherit;cursor:pointer;}
.cg-seg button:last-child{border-right:0;}
.cg-seg button.active{background:var(--gold);color:var(--bg);font-weight:700;}
thead th.sorted{color:var(--gold);}
/* grid view: image/data cards */
#cards{padding:14px 16px;display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;}
.dcard{border:1px solid var(--line);border-radius:10px;padding:12px 13px;background:var(--panel);font-size:13px;}
.dcard:hover{border-color:var(--gold);}
.dcard .h{display:flex;justify-content:space-between;gap:8px;align-items:baseline;}
.dcard .price{color:var(--gold);font-weight:700;font-size:16px;font-variant-numeric:tabular-nums;}
.dcard .addr{font-weight:600;margin:4px 0 2px;line-height:1.3;}
.dcard .addr a{color:var(--fg);} .dcard .addr a:hover{color:var(--gold);}
.dcard .meta{color:var(--muted);font-size:12px;display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;}
.dcard .meta a{color:var(--blue);}
/* list view: one compact scannable row per record */
#cards.list{display:block;padding:0;}
#cards.list .dcard{display:flex;align-items:baseline;gap:14px;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:8px 14px;background:none;}
#cards.list .dcard:hover{background:#141a24;}
#cards.list .dcard .h{display:contents;} #cards.list .dcard .price{min-width:110px;text-align:right;}
#cards.list .dcard .addr{margin:0;min-width:220px;flex:1;} #cards.list .dcard .meta{margin:0;}
/* detail drawer */
#detail{position:fixed;top:49px;right:0;bottom:0;width:440px;max-width:94vw;background:var(--panel);border-left:1px solid var(--gold);
z-index:1100;transform:translateX(100%);transition:transform .18s ease;overflow-y:auto;padding:18px 20px;}
#detail.open{transform:translateX(0);}
#detail .x{position:absolute;top:12px;right:16px;color:var(--muted);cursor:pointer;font-size:20px;}
#detail h2{margin:0 20px 2px 0;font-size:18px;color:var(--gold);line-height:1.3;}
#detail .sub{color:var(--muted);font-size:12px;margin-bottom:16px;}
#detail .price{font-size:26px;color:var(--gold);font-weight:700;font-variant-numeric:tabular-nums;margin:2px 0 2px;}
.kv{display:grid;grid-template-columns:120px 1fr;gap:4px 12px;font-size:13px;margin:14px 0;}
.kv .k{color:var(--muted);} .kv .v{color:var(--fg);word-break:break-word;} .kv .v.gold{color:var(--gold);}
.kv .v a{color:var(--blue);}
.sec{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--gold);margin:16px 0 8px;border-top:1px solid var(--line);padding-top:12px;}
.deed{border:1px solid var(--line);border-radius:8px;padding:9px 11px;margin-bottom:8px;font-size:12.5px;}
.deed.cur{border-color:var(--gold);}
.deed .amt{color:var(--gold);font-weight:700;font-size:14px;} .deed .dt{color:var(--muted);}
.deed .row{display:flex;justify-content:space-between;gap:8px;margin-top:3px;} .deed .row span{color:var(--muted);}
.deed a{color:var(--blue);}
.note{font-size:11px;color:var(--muted);border-top:1px solid var(--line);margin-top:16px;padding-top:10px;line-height:1.5;}
.btnrow{display:flex;gap:8px;margin-top:14px;flex-wrap:wrap;}
.btnrow a{font-size:12px;border:1px solid var(--line);border-radius:8px;padding:6px 11px;color:var(--fg);}
.btnrow a:hover{border-color:var(--gold);text-decoration:none;color:var(--gold);}
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<div id="topbar">
<div class="brand">US<b>RealEstate</b> · Commercial Deals</div>
<nav><a href="/index.html">Map</a><a href="/markets.html">Markets</a><a href="/parcels.html">Parcels</a><a href="/deals.html" class="active">Deals</a><a href="/brokers.html">Brokers</a></nav>
<div id="controls">
<input id="q" type="search" placeholder="Search address / city / APN…" autocomplete="off">
<select id="sort" title="Sort">
<option value="recent">Most recent</option>
<option value="oldest">Oldest</option>
<option value="sale_price:desc">Price ↓</option>
<option value="sale_price:asc">Price ↑</option>
<option value="address:asc">Address A→Z</option>
<option value="address:desc">Address Z→A</option>
<option value="city:asc">City A→Z</option>
<option value="city:desc">City Z→A</option>
<option value="ctype:asc">Type A→Z</option>
<option value="county_name:asc">County A→Z</option>
<option value="sqft:desc">SqFt ↓</option>
<option value="sqft:asc">SqFt ↑</option>
<option value="year_built:desc">Built newest</option>
<option value="year_built:asc">Built oldest</option>
<option value="grantor:asc">Seller A→Z</option>
<option value="grantee:asc">Buyer A→Z</option>
<option value="doc_number:asc">Doc # A→Z</option>
<option value="doc_number:desc">Doc # Z→A</option>
</select>
<span class="cg-seg" id="viewseg">
<button data-view="grid" title="Grid">▦ Grid</button>
<button data-view="list" title="List">☰ List</button>
<button data-view="table" class="active" title="Table">▤ Table</button>
</span>
<span class="lbl">density</span><input id="density" type="range" min="24" max="46" value="32">
</div>
</div>
<div id="wrap">
<aside id="side">
<div id="totals">Loading…</div>
<h3>County</h3><div id="fCounty"></div>
<h3>Type</h3><div id="fType"></div>
<h3>Price band</h3><div id="fBand"></div>
<h3>City <span class="lbl">(top)</span></h3><div id="fCity"></div>
</aside>
<div id="main">
<div id="active"></div>
<div id="tablewrap"><div class="empty">Loading deals…</div></div>
<div id="status">
<span id="count">—</span>
<button id="prev" disabled>‹ Prev</button><button id="next" disabled>Next ›</button>
<span style="margin-left:auto">$250k+ commercial sales · last 18 mo · public records</span>
</div>
</div>
</div>
<div id="detail"><span class="x" onclick="closeDetail()">✕</span><div id="detailBody"></div></div>
<script>
const $ = s => document.querySelector(s);
const fmt = n => n==null?'—':Number(n).toLocaleString();
const money = n => n==null?'—':'$'+Number(n).toLocaleString(undefined,{maximumFractionDigits:0});
const moneyShort = n => { if(n==null) return '—'; n=Number(n);
if(n>=1e9) return '$'+(n/1e9).toFixed(2)+'B'; if(n>=1e6) return '$'+(n/1e6).toFixed(1)+'M';
if(n>=1e3) return '$'+(n/1e3).toFixed(0)+'K'; return '$'+n; };
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const safeHref = u => { try{ const p=new URL(String(u||'')); return (p.protocol==='http:'||p.protocol==='https:')?esc(p.href):'#'; }catch(e){ return '#'; } };
const TYPE_LABEL = {industrial:'Industrial',retail:'Retail',office:'Office',hospitality:'Hospitality',parking:'Parking',other:'Other'};
const BAND_LABEL = {'250k-1m':'$250K–1M','1m-5m':'$1M–5M','5m-25m':'$5M–25M','25m-100m':'$25M–100M','100m+':'$100M+'};
// filter state lives in the URL — every drill is a real, shareable address (href-drill rule)
const FILTERS = ['county','type','city','band','year','q','sort'];
function readState(){
const p=new URLSearchParams(location.search); const s={offset:0};
for(const f of FILTERS){ const v=p.get(f); if(v) s[f]=v; }
const off=parseInt(p.get('offset')||'0',10); if(off>0) s.offset=off;
if(!s.sort) s.sort='recent';
return s;
}
let state = readState();
const limit = 80;
let VIEW = localStorage.getItem('deals:view') || 'table'; // grid | list | table
// state.sort may be a legacy preset ('recent','oldest','sqft_desc') OR a 'col:dir' pair.
// Split it into {sort,order} for the API (backend accepts both forms).
function sortParams(){
const s = state.sort || 'recent';
if(s.includes(':')){ const [k,d]=s.split(':'); return {sort:k, order:(d==='desc'?'desc':'asc')}; }
return {sort:s};
}
// build a URL that sets/overrides some params (used for every drill link + navigation)
function drillUrl(over){
const p=new URLSearchParams();
const merged={...state,...over};
for(const f of FILTERS){ if(merged[f]) p.set(f,merged[f]); }
if(merged.offset>0) p.set('offset',merged.offset);
// `id` isn't a feed filter but must ride the URL so a deal detail is shareable /
// right-clickable / works with JS off (href-drill: no dead-end data points).
if(over && 'id' in over){ if(over.id) p.set('id',over.id); }
else if(merged.id){ p.set('id',merged.id); }
return '/deals.html?'+p.toString();
}
// navigate (pushState) then re-render, without a full reload
function go(over,replace){
state={...state,...over}; if(over && !('offset' in over)) state.offset=0;
const url=drillUrl({});
history[replace?'replaceState':'pushState']({},'',url);
renderActive(); loadFacets(); loadFeed();
}
// ── sidebar facets ──
async function loadFacets(){
// facets reflect the CURRENT filters EXCEPT the one they control (so a facet list
// stays useful for switching within its dimension). We fetch full-context facets.
const p=new URLSearchParams();
for(const f of ['county','type','city','band','year','q']){ if(state[f]) p.set(f,state[f]); }
let d; try{ d=await (await fetch('/api/deals/facets?'+p.toString())).json(); }catch(e){ return; }
if(d.error) return;
$('#totals').innerHTML=`<b>${fmt(d.total)}</b> deals · <span class="vol">${moneyShort(d.volume)}</span> volume`;
facetList('#fCounty','county',(d.counties||[]).map(c=>({key:c.fips,label:c.name,n:c.n})));
facetList('#fType','type',(d.types||[]).map(t=>({key:t.type,label:TYPE_LABEL[t.type]||t.type,n:t.n})));
const bandOrder=['250k-1m','1m-5m','5m-25m','25m-100m','100m+'];
const bmap=Object.fromEntries((d.bands||[]).map(b=>[b.band,b.n]));
facetList('#fBand','band',bandOrder.filter(b=>bmap[b]).map(b=>({key:b,label:BAND_LABEL[b],n:bmap[b]})));
facetList('#fCity','city',(d.cities||[]).slice(0,25).map(c=>({key:c.city,label:c.city,n:c.n})));
}
function facetList(sel,dim,items){
const box=$(sel); box.innerHTML='';
for(const it of items){
const sel2=String(state[dim]||'')===String(it.key);
const a=document.createElement('a');
// deselect uses `undefined` (not null) to match go()/rm/clear everywhere else — one unset sentinel so a future `in`-check refactor can't diverge the href vs onclick paths (TK-10482 SHOULD-FIX).
a.className='facet'+(sel2?' sel':''); a.href=drillUrl({[dim]:sel2?undefined:it.key,offset:0});
a.innerHTML=`<span class="nm">${esc(it.label)}</span><span class="ct">${fmt(it.n)}</span>`;
a.onclick=e=>{ e.preventDefault(); go({[dim]:sel2?undefined:String(it.key)}); };
box.appendChild(a);
}
if(!items.length) box.innerHTML='<div class="lbl" style="padding:4px 7px">—</div>';
}
// ── active-filter chips ──
function renderActive(){
const box=$('#active'); box.innerHTML='';
const chips=[];
if(state.county) chips.push(['county','County',state.county]);
if(state.type) chips.push(['type','Type',TYPE_LABEL[state.type]||state.type]);
if(state.band) chips.push(['band','Price',BAND_LABEL[state.band]||state.band]);
if(state.city) chips.push(['city','City',state.city]);
if(state.year) chips.push(['year','Built',state.year]);
if(state.q) chips.push(['q','Search','“'+state.q+'”']);
for(const [dim,lbl,val] of chips){
const c=document.createElement('span'); c.className='chip';
c.innerHTML=`${esc(lbl)}: <b>${esc(val)}</b> <span class="rm" title="Remove">✕</span>`;
c.querySelector('.rm').onclick=()=>go({[dim]:undefined});
box.appendChild(c);
}
if(chips.length>1){
const clr=document.createElement('a'); clr.className='chip'; clr.href='/deals.html';
clr.style.borderColor='var(--muted)'; clr.style.color='var(--muted)';
clr.textContent='clear all';
clr.onclick=e=>{ e.preventDefault(); go({county:undefined,type:undefined,band:undefined,city:undefined,year:undefined,q:undefined}); };
box.appendChild(clr);
}
}
// sale_price → price-band drill key
function bandKey(p){ return p>=1e8?'100m+':p>=25e6?'25m-100m':p>=5e6?'5m-25m':p>=1e6?'1m-5m':'250k-1m'; }
// current sort col + dir (state.sort may be a legacy preset)
function curSort(){ const s=state.sort||'recent';
if(s.includes(':')){ const [k,d]=s.split(':'); return {k,d}; }
if(s==='recent') return {k:'sale_date',d:'desc'}; if(s==='oldest') return {k:'sale_date',d:'asc'};
if(s==='price_desc') return {k:'sale_price',d:'desc'}; if(s==='price_asc') return {k:'sale_price',d:'asc'};
if(s==='sqft_desc') return {k:'sqft',d:'desc'}; return {k:'',d:''}; }
// click a header → toggle dir if same col, else asc; drives ?sort=col&order=dir
function headSort(k){ const c=curSort(); const nd=(c.k===k)?(c.d==='asc'?'desc':'asc'):'asc'; go({sort:k+':'+nd}); }
// ── TABLE view: dense all-columns spreadsheet, EVERY header click-to-sort ──
const TBL=[['sale_date','Sale date'],['address','Address'],['city','City'],['ctype','Type'],
['sale_price','Price','num'],['sqft','SqFt','num'],['year_built','Built','num'],
['grantor','Seller'],['grantee','Buyer'],['doc_number','Doc #']];
function renderTable(rows){
const c=curSort(); const arr=k=> k===c.k?(c.d==='desc'?' ▾':' ▴'):'';
let h='<table><thead><tr>'+
TBL.map(([k,l,n])=>`<th data-k="${k}" class="${n?'num':''}${k===c.k?' sorted':''}">${esc(l)}${arr(k)}</th>`).join('')+
'<th></th></tr></thead><tbody>';
for(const r of rows){
const band=bandKey(r.sale_price);
const cityDrill = r.city?`<a class="drill" href="${drillUrl({city:r.city,offset:0})}" title="All deals in ${esc(r.city)}">${esc(r.city)}</a>`:'<span class="lbl">—</span>';
const typeDrill = r.ctype?`<a class="drill" href="${drillUrl({type:r.ctype,offset:0})}" title="All ${esc(TYPE_LABEL[r.ctype]||r.ctype)} deals">${esc(TYPE_LABEL[r.ctype]||r.ctype)}</a>`:'';
const priceDrill = `<a class="drill" href="${drillUrl({band,offset:0})}" title="All ${esc(BAND_LABEL[band])} deals">${esc(money(r.sale_price))}</a>`;
const yearDrill = r.year_built?`<a class="drill" href="${drillUrl({year:r.year_built,offset:0})}" title="All deals built ${r.year_built}">${r.year_built}</a>`:'';
const addrOpen = `<a class="open" href="${drillUrl({id:r.id})}" data-id="${r.id}" title="Open deal detail">${esc(r.address||r.ain)}</a>`;
h+=`<tr><td>${esc(r.sale_date)}</td><td>${addrOpen}</td><td>${cityDrill}</td><td>${typeDrill}</td>`+
`<td class="num">${priceDrill}</td><td class="num">${r.sqft!=null?fmt(r.sqft):''}</td><td class="num">${yearDrill}</td>`+
`<td>${esc(r.grantor)||'<span class="lbl">—</span>'}</td><td>${esc(r.grantee)||'<span class="lbl">—</span>'}</td>`+
`<td>${esc(r.doc_number)||'<span class="lbl">—</span>'}</td>`+
`<td><a class="open" href="${drillUrl({id:r.id})}" data-id="${r.id}" title="Open deal detail">detail ›</a></td></tr>`;
}
h+='</tbody></table>';
$('#tablewrap').innerHTML=h;
$('#tablewrap').querySelectorAll('thead th[data-k]').forEach(th=>th.onclick=()=>headSort(th.dataset.k));
}
// ── GRID / LIST view: cards (list is a CSS-compact variant) ──
function renderCards(rows){
const h=rows.map(r=>{
const band=bandKey(r.sale_price);
const addrOpen=`<a class="open" href="${drillUrl({id:r.id})}" data-id="${r.id}" title="Open deal detail">${esc(r.address||r.ain)}</a>`;
const cityDrill=r.city?`<a class="drill" href="${drillUrl({city:r.city,offset:0})}">${esc(r.city)}</a>`:'';
const typeDrill=r.ctype?`<a class="drill" href="${drillUrl({type:r.ctype,offset:0})}">${esc(TYPE_LABEL[r.ctype]||r.ctype)}</a>`:'';
const priceDrill=`<a class="drill" href="${drillUrl({band,offset:0})}">${esc(money(r.sale_price))}</a>`;
const yearDrill=r.year_built?`<a class="drill" href="${drillUrl({year:r.year_built,offset:0})}">${r.year_built}</a>`:'';
return `<div class="dcard">`+
`<div class="h"><span class="price">${priceDrill}</span><span class="lbl">${esc(r.sale_date)}</span></div>`+
`<div class="addr">${addrOpen}</div>`+
`<div class="meta">${typeDrill?`<span>${typeDrill}</span>`:''}${cityDrill?`<span>${cityDrill}</span>`:''}`+
`${r.sqft!=null?`<span>${fmt(r.sqft)} sqft</span>`:''}${yearDrill?`<span>built ${yearDrill}</span>`:''}`+
`${r.grantee?`<span>buyer: ${esc(r.grantee)}</span>`:''}</div></div>`;
}).join('');
$('#tablewrap').innerHTML=`<div id="cards" class="${VIEW==='list'?'list':''}">${h}</div>`;
}
function wireRowLinks(){
$('#tablewrap').querySelectorAll('a.open').forEach(a=>a.onclick=e=>{ e.preventDefault(); openDeal(a.dataset.id); });
$('#tablewrap').querySelectorAll('a.drill').forEach(a=>a.onclick=e=>{
e.preventDefault(); const u=new URL(a.href,location.origin); const over={};
for(const f of FILTERS){ over[f]=u.searchParams.get(f)||undefined; } go(over);
});
$('#tablewrap').scrollTop=0;
}
// ── deals grid: EVERY data cell is an href to its deeper data ──
async function loadFeed(){
const p=new URLSearchParams();
for(const f of FILTERS){ if(f!=='sort' && state[f]) p.set(f,state[f]); }
const sp=sortParams(); p.set('sort',sp.sort); if(sp.order) p.set('order',sp.order);
p.set('limit',limit); p.set('offset',state.offset||0);
$('#count').textContent='loading…';
let d; try{ d=await (await fetch('/api/deals?'+p.toString())).json(); }catch(e){ $('#tablewrap').innerHTML='<div class="empty">Load error.</div>'; return; }
if(d.error){ $('#tablewrap').innerHTML='<div class="empty">'+esc(d.error)+'</div>'; return; }
const rows=d.rows||[];
if(!rows.length){ $('#tablewrap').innerHTML='<div class="empty">No deals match these filters.</div>'; $('#count').textContent='0 deals'; $('#prev').disabled=state.offset<=0; $('#next').disabled=true; return; }
if(VIEW==='table') renderTable(rows); else renderCards(rows);
wireRowLinks();
const from=(state.offset||0)+1, to=(state.offset||0)+rows.length;
$('#count').textContent=`deals ${fmt(from)}–${fmt(to)}${d.hasMore?'+':''}`;
$('#prev').disabled=(state.offset||0)<=0; $('#next').disabled=!d.hasMore;
$('#tablewrap').scrollTop=0;
}
// ── deal detail drawer (drills the whole record for one property + its deal chain) ──
async function openDeal(id){
history.pushState({},'', drillUrl({id}));
$('#detail').classList.add('open'); $('#detailBody').innerHTML='<div class="empty">Loading…</div>';
let d; try{ d=await (await fetch('/api/deals/'+encodeURIComponent(id))).json(); }catch(e){ $('#detailBody').innerHTML='<div class="empty">Load error.</div>'; return; }
if(d.error){ $('#detailBody').innerHTML='<div class="empty">'+esc(d.error)+'</div>'; return; }
const s=d.sale, pc=d.parcel;
const krow=(k,v,gold)=>v==null||v===''?'':`<div class="k">${k}</div><div class="v${gold?' gold':''}">${v}</div>`;
// in-drawer filter links so a name/type/city here also drills the feed
const typeLink = pc.ctype?`<a href="${drillUrl({type:pc.ctype,id:null,offset:0})}">${esc(TYPE_LABEL[pc.ctype]||pc.ctype)}</a>`:'';
const cityLink = pc.city?`<a href="${drillUrl({city:pc.city,id:null,offset:0})}">${esc(pc.city)}</a>`:'';
const countyLink = `<a href="${drillUrl({county:d.county_fips,id:null,offset:0})}">${esc(d.county_name)}${d.state_code?' '+esc(d.state_code):''}</a>`;
const yearLink = pc.year_built?`<a href="${drillUrl({year:pc.year_built,id:null,offset:0})}">${pc.year_built}</a>`:'';
let kv='<div class="kv">';
kv+=krow('Sale date',esc(s.date),1);
kv+=krow('Doc type',esc(s.doc_type));
kv+=krow('Doc number',esc(s.doc_number));
kv+=krow('Seller',esc(s.grantor));
kv+=krow('Buyer',esc(s.grantee));
kv+='</div>';
let pkv='<div class="kv">';
pkv+=krow('Address',esc(pc.address));
pkv+=krow('City',cityLink);
pkv+=krow('ZIP',esc(pc.zip));
pkv+=krow('County',countyLink);
pkv+=krow('APN / folio',esc(d.ain));
pkv+=krow('Use type',typeLink);
pkv+=krow('Use detail',esc(pc.use_desc));
pkv+=krow('Year built',yearLink);
pkv+=krow('SqFt',pc.sqft!=null?fmt(pc.sqft):'');
pkv+=krow('Units',pc.units);
pkv+=krow('Assessed value',pc.assessed_total!=null?money(pc.assessed_total):'',1);
pkv+=krow('Assessed land',pc.assessed_land!=null?money(pc.assessed_land):'');
pkv+=krow('Assessed impr.',pc.assessed_improvement!=null?money(pc.assessed_improvement):'');
pkv+=krow('Roll year',esc(pc.roll_year));
pkv+='</div>';
// deal chain — each prior sale is its own deal id you can open
let hist='';
if(d.history&&d.history.length){
hist='<div class="sec">Deal history ('+d.history.length+')</div>';
for(const h of d.history){
const cur=h.id===d.id;
hist+='<div class="deed'+(cur?' cur':'')+'">'+
'<div>'+(cur?'<a href="'+drillUrl({id:h.id})+'" onclick="return false" style="pointer-events:none">':'<a href="'+drillUrl({id:h.id})+'" data-open="'+h.id+'">')+
'<span class="amt">'+money(h.price)+'</span></a> <span class="dt">'+esc(h.date||'')+'</span> '+
(h.doc_type?'<span class="lbl">'+esc(h.doc_type)+'</span>':'')+(cur?' <span class="lbl">· this deal</span>':'')+'</div>'+
(h.grantor?'<div class="row"><span>Seller</span>'+esc(h.grantor)+'</div>':'')+
(h.grantee?'<div class="row"><span>Buyer</span>'+esc(h.grantee)+'</div>':'')+
(h.doc_number?'<div class="row"><span>Doc #</span>'+esc(h.doc_number)+'</div>':'')+
(h.record_url?'<div class="row"><span>Record</span><a href="'+safeHref(h.record_url)+'" target="_blank" rel="noopener noreferrer">county record ↗</a></div>':'')+
'</div>';
}
}
// public-record source buttons
let btns='<div class="btnrow">';
if(s.record_url) btns+='<a href="'+safeHref(s.record_url)+'" target="_blank" rel="noopener noreferrer">🏛️ County record for this deed ↗</a>';
for(const l of (d.links||[])) if(l.url) btns+='<a href="'+safeHref(l.url)+'" target="_blank" rel="noopener noreferrer">'+esc(l.label||l.kind)+' ↗</a>';
btns+='<a href="/parcels.html?county='+esc(d.county_fips)+'&sid='+encodeURIComponent(d.ain)+'">🗺️ Parcel explorer</a>';
btns+='</div>';
$('#detailBody').innerHTML=
`<h2>${esc(pc.address||d.ain)}</h2>`+
`<div class="sub">${esc(d.county_name)}${d.state_code?' '+esc(d.state_code):''} · ${esc(TYPE_LABEL[pc.ctype]||pc.ctype||'')}</div>`+
`<div class="price">${money(s.price)}</div>`+
`<div class="sub">sale price · ${esc(s.date||'')}</div>`+
`<div class="sec">This deal</div>${kv}`+
`<div class="sec">Property</div>${pkv}`+
hist+btns+
`<div class="note">${esc(d.pricing_note)}</div>`;
// wire prior-sale open links
$('#detailBody').querySelectorAll('a[data-open]').forEach(a=>a.onclick=e=>{ e.preventDefault(); openDeal(a.dataset.open); });
}
function closeDetail(){
$('#detail').classList.remove('open');
// drop ?id= from the URL, keep filters
history.replaceState({},'', drillUrl({id:null}));
}
// ── controls ──
let qT;
$('#q').oninput=e=>{ clearTimeout(qT); qT=setTimeout(()=>go({q:e.target.value.trim()||undefined}),320); };
$('#sort').onchange=e=>go({sort:e.target.value});
$('#viewseg').querySelectorAll('button').forEach(b=>b.onclick=()=>{ VIEW=b.dataset.view; localStorage.setItem('deals:view',VIEW);
$('#viewseg').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x.dataset.view===VIEW)); loadFeed(); });
$('#density').oninput=e=>{ const v=+e.target.value; document.documentElement.style.setProperty('--rowh',v+'px'); document.documentElement.style.setProperty('--fs',(10+v*0.09).toFixed(1)+'px'); localStorage.setItem('deals:density',v); };
$('#prev').onclick=()=>{ state.offset=Math.max(0,(state.offset||0)-limit); history.replaceState({},'',drillUrl({})); loadFeed(); };
$('#next').onclick=()=>{ state.offset=(state.offset||0)+limit; history.replaceState({},'',drillUrl({})); loadFeed(); };
document.addEventListener('keydown',e=>{ if(e.key==='Escape'&&$('#detail').classList.contains('open')) closeDetail(); });
window.addEventListener('popstate',()=>{ state=readState(); syncControls(); renderActive(); loadFacets(); loadFeed();
const id=new URLSearchParams(location.search).get('id'); if(id) openDeal(id); else $('#detail').classList.remove('open'); });
function syncControls(){ $('#q').value=state.q||''; $('#sort').value=state.sort||'recent';
$('#viewseg').querySelectorAll('button').forEach(b=>b.classList.toggle('active',b.dataset.view===VIEW)); }
// ── boot ──
(function(){
const dz=localStorage.getItem('deals:density'); if(dz){ $('#density').value=dz; $('#density').oninput({target:{value:dz}}); }
syncControls(); renderActive(); loadFacets(); loadFeed();
const id=new URLSearchParams(location.search).get('id'); if(id) setTimeout(()=>openDeal(id),200);
})();
</script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>