[object Object]

← 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 →