← back to Nationalrealestate
usre: deals.html gets grid/list/table view toggle + every table header click-to-sort via ?sort=&order= + full Sort select (view+sort persisted, drill hrefs preserved)
1227b5cc6912bd540592efeff95130cb04042b82 · 2026-08-13 08:08:41 -0700 · steve
Files touched
Diff
commit 1227b5cc6912bd540592efeff95130cb04042b82
Author: steve <steve@designerwallcoverings.com>
Date: Thu Aug 13 08:08:41 2026 -0700
usre: deals.html gets grid/list/table view toggle + every table header click-to-sort via ?sort=&order= + full Sort select (view+sort persisted, drill hrefs preserved)
---
public/deals.html | 150 +++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 116 insertions(+), 34 deletions(-)
diff --git a/public/deals.html b/public/deals.html
index 1d3190b..c2981d2 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -50,6 +50,28 @@
#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;}
@@ -83,10 +105,26 @@
<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>
+ <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>
</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>
@@ -135,6 +173,14 @@ function readState(){
}
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){
@@ -211,51 +257,84 @@ function renderActive(){
}
}
-// ── 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; }
+// 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']];
+function renderTable(rows){
+ const c=curSort(); const arr=k=> k===c.k?(c.d==='desc'?' ▾':' ▴'):'';
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>';
+ 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){
- // 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 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><a class="open" href="${drillUrl({id:r.id})}" data-id="${r.id}" title="Open deal detail">detail ›</a></td>`+
- `</tr>`;
+ 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('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=>{
- // 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}));
+ $('#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;
@@ -347,13 +426,16 @@ function closeDetail(){
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'; }
+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(){
← cf1e3b2 usre: contractors.html gets grid/list/table views + all-colu
·
back to Nationalrealestate
·
usre: commercial-feed.html Recorded Activity gets grid/list/ 7b50153 →