← back to Nationalrealestate
usre: deals.html feed grid with full href-drill + /deal/:id clean route + fleet nav (TK-10482)
26fa1e5266f720572024fa89e10161160cc71ebb · 2026-08-12 08:32:37 -0700 · Steve Abrams
Files touched
A public/deals.htmlM public/nav-drawer.jsM src/server/index.ts
Diff
commit 26fa1e5266f720572024fa89e10161160cc71ebb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 08:32:37 2026 -0700
usre: deals.html feed grid with full href-drill + /deal/:id clean route + fleet nav (TK-10482)
---
public/deals.html | 364 +++++++++++++++++++++++++++++++++++++++++++++++++++
public/nav-drawer.js | 1 +
src/server/index.ts | 9 ++
3 files changed, 374 insertions(+)
diff --git a/public/deals.html b/public/deals.html
new file mode 100644
index 0000000..65ad9bb
--- /dev/null
+++ b/public/deals.html
@@ -0,0 +1,364 @@
+<!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;}
+ /* 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>
+</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="price_desc">Price ↓</option>
+ <option value="price_asc">Price ↑</option>
+ <option value="sqft_desc">SqFt ↓</option>
+ </select>
+ <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;
+
+// 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');
+ a.className='facet'+(sel2?' sel':''); a.href=drillUrl({[dim]:sel2?null: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);
+ }
+}
+
+// ── 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(state[f]) p.set(f,state[f]); }
+ 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; }
+ let h='<table><thead><tr>'+
+ '<th data-s="recent">Sale date</th><th>Address</th><th>City</th><th>Type</th>'+
+ '<th class="num">Price</th><th class="num">SqFt</th><th class="num">Built</th>'+
+ '<th>Seller</th><th>Buyer</th><th></th></tr></thead><tbody>';
+ for(const r of rows){
+ // price band drill key
+ const band = r.sale_price>=1e8?'100m+':r.sale_price>=25e6?'25m-100m':r.sale_price>=5e6?'5m-25m':r.sale_price>=1e6?'1m-5m':'250k-1m';
+ 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><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;
+ // wire the open-detail links (pushState instead of full nav) — but they remain real hrefs
+ $('#tablewrap').querySelectorAll('a.open').forEach(a=>a.onclick=e=>{ e.preventDefault(); openDeal(a.dataset.id); });
+ $('#tablewrap').querySelectorAll('a.drill').forEach(a=>a.onclick=e=>{
+ // let real hrefs work, but do a soft pushState nav for snappiness
+ 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').querySelectorAll('thead th[data-s]').forEach(th=>th.onclick=()=>go({sort:th.dataset.s==='recent'&&state.sort==='recent'?'oldest':th.dataset.s}));
+ 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});
+$('#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'; }
+
+// ── 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>
+</body>
+</html>
diff --git a/public/nav-drawer.js b/public/nav-drawer.js
index ec5f1b4..6cdef62 100644
--- a/public/nav-drawer.js
+++ b/public/nav-drawer.js
@@ -33,6 +33,7 @@
// current page active by URL) so the nav never drifts page-to-page.
var NAV = [
['/', 'Map'], ['/markets.html', 'Markets'], ['/commercial.html', 'Commercial'],
+ ['/deals.html', 'Deals'], ['/parcels.html', 'Parcels'],
['/compare.html', 'Compare'], ['/brokers.html', 'Brokers'], ['/sources.html', 'Sources'],
['/watchlist.html', 'Watchlist'], ['/admin.html', 'Admin']
];
diff --git a/src/server/index.ts b/src/server/index.ts
index 937c530..1268828 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -110,6 +110,15 @@ app.get('/api/ingest-health', async (_req, res) => {
app.get('/health', (_req, res) => res.sendFile(join(ROOT, 'public', 'health.html')));
+// Commercial deals feed + clean per-deal detail paths (TK-10482). The deals.html
+// SPA reads the deal id from ?id=; /deal/123 and /deals/123 redirect into it so
+// each deal is addressable by a clean URL (href-drill: no dead-end data points).
+app.get('/deals', (_req, res) => res.sendFile(join(ROOT, 'public', 'deals.html')));
+app.get(['/deal/:id', '/deals/:id'], (req, res) => {
+ const id = String(req.params.id || '').replace(/[^0-9]/g, '').slice(0, 18);
+ return res.redirect(302, id ? `/deals.html?id=${id}` : '/deals.html');
+});
+
app.get('/api/regions', async (req, res) => {
try {
const type = String(req.query.type || 'county');
← 41cb360 usre: add commercial deals feed + per-deal detail API over r
·
back to Nationalrealestate
·
usre deals: facet deselect uses undefined (not null) to matc 504ae66 →