[object Object]

← back to Hollywood Wallcoverings

Server-side grid sort: honor ?sort param across all infinite-scroll pages

623f740848052c46cbd3217898f61bb8dbfb50ae · 2026-05-19 08:00:13 -0700 · Steve Abrams

Sort was client-side only — reordering only the ~48 cards already in the
DOM, so newly scrolled pages appended in newest order regardless. Now
filterAndPaginate sorts the full filtered list before pagination, the
select reloads with ?sort=, /api/page carries it forward, and saved sort
hydrates from localStorage (one-time redirect) before first render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 623f740848052c46cbd3217898f61bb8dbfb50ae
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 08:00:13 2026 -0700

    Server-side grid sort: honor ?sort param across all infinite-scroll pages
    
    Sort was client-side only — reordering only the ~48 cards already in the
    DOM, so newly scrolled pages appended in newest order regardless. Now
    filterAndPaginate sorts the full filtered list before pagination, the
    select reloads with ?sort=, /api/page carries it forward, and saved sort
    hydrates from localStorage (one-time redirect) before first render.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 server.js | 103 ++++++++++++++++++++++++++++++++++++++++++++------------------
 1 file changed, 73 insertions(+), 30 deletions(-)

diff --git a/server.js b/server.js
index 8bcaa94..c047a49 100644
--- a/server.js
+++ b/server.js
@@ -72,10 +72,44 @@ function paginate(arr, page, perPage = 60) {
   return { items: arr.slice((p-1)*perPage, p*perPage), page: p, pages, total };
 }
 
+// DW STANDARD: server-side sort so infinite-scroll respects the chosen order
+// across ALL pages, not just the first batch of cards loaded in the DOM.
+function colorRank(p) {
+  const t = `${p.title||''} ${p.color||''}`.toLowerCase();
+  if (/(black|noir|onyx|ink)/.test(t)) return 1;
+  if (/(white|ivory|cream|paper|alabaster)/.test(t)) return 2;
+  if (/(gold|brass|champagne|bronze|amber|tan|caramel|sand)/.test(t)) return 3;
+  if (/(silver|grey|gray|platinum|pewter|smoke)/.test(t)) return 4;
+  if (/(red|crimson|burgundy|wine|garnet)/.test(t)) return 5;
+  if (/(green|emerald|forest|sage|olive|moss)/.test(t)) return 6;
+  if (/(blue|navy|cobalt|sapphire|indigo)/.test(t)) return 7;
+  if (/(purple|plum|violet|aubergine|amethyst)/.test(t)) return 8;
+  return 9;
+}
+function styleKey(p) {
+  const t = `${p.title||''}`.toLowerCase();
+  if (/(damask|toile|baroque|rococo|ornate)/.test(t)) return '1';
+  if (/(stripe|trellis|geometric)/.test(t)) return '2';
+  if (/(floral|botanical|vine|leaf)/.test(t)) return '3';
+  if (/(grass|natural|jute|hemp|raffia|linen|cork)/.test(t)) return '4';
+  if (/(metallic|foil)/.test(t)) return '5';
+  return '9';
+}
+function sortProducts(list, mode) {
+  const titleOf = p => String(p.title || '');
+  const skuOf = p => String(p.dw_sku || p.handle || '');
+  if (mode === 'sku')   return [...list].sort((a,b) => skuOf(a).localeCompare(skuOf(b)));
+  if (mode === 'title') return [...list].sort((a,b) => titleOf(a).localeCompare(titleOf(b)));
+  if (mode === 'color') return [...list].sort((a,b) => colorRank(a) - colorRank(b) || titleOf(a).localeCompare(titleOf(b)));
+  if (mode === 'style') return [...list].sort((a,b) => styleKey(a).localeCompare(styleKey(b)) || titleOf(a).localeCompare(titleOf(b)));
+  return list; // 'newest' = natural order
+}
+
 // JSON endpoint for infinite scroll
 function filterAndPaginate(req, perPage = 48) {
   const q = (req.query.q || '').toString().toLowerCase().trim();
   const type = (req.query.type || '').toString();
+  const sort = (req.query.sort || 'newest').toString();
   const page = parseInt(req.query.page) || 1;
   let filtered = DATA;
   if (type) filtered = filtered.filter(p => NORM_TYPE(p.product_type) === type);
@@ -86,9 +120,10 @@ function filterAndPaginate(req, perPage = 48) {
       return tokens.every(t => hay.includes(t));
     });
   }
+  filtered = sortProducts(filtered, sort);
   const pages = Math.max(1, Math.ceil(filtered.length / perPage));
   const p = Math.min(Math.max(1, page), pages);
-  return { items: filtered.slice((p-1)*perPage, p*perPage), page: p, pages, total: filtered.length };
+  return { items: filtered.slice((p-1)*perPage, p*perPage), page: p, pages, total: filtered.length, sort };
 }
 
 function shopifyUrl(handle) {
@@ -513,7 +548,7 @@ app.get('/api/page', (req, res) => {
 app.get('/', (req, res) => {
   const q = (req.query.q || '').toString().toLowerCase().trim();
   const type = (req.query.type || '').toString();
-  const { items, page: cur, pages, total } = filterAndPaginate(req, 48);
+  const { items, page: cur, pages, total, sort } = filterAndPaginate(req, 48);
 
   const cards = items.map(cardHtml).join('');
 
@@ -526,20 +561,33 @@ app.get('/', (req, res) => {
       <div class="controls-bar">
         <label for="sort">Sort</label>
         <select id="sort">
-          <option value="newest">Newest</option>
-          <option value="color">Color</option>
-          <option value="style">Style</option>
-          <option value="sku">SKU A→Z</option>
-          <option value="title">Title A→Z</option>
+          <option value="newest"${sort==='newest'?' selected':''}>Newest</option>
+          <option value="color"${sort==='color'?' selected':''}>Color</option>
+          <option value="style"${sort==='style'?' selected':''}>Style</option>
+          <option value="sku"${sort==='sku'?' selected':''}>SKU A→Z</option>
+          <option value="title"${sort==='title'?' selected':''}>Title A→Z</option>
         </select>
         <label for="density" style="margin-left:14px">Density</label>
         <input id="density" type="range" min="180" max="380" step="10" value="240">
       </div>`}
       ${items.length === 0 ? '<div class="empty">No products match. Try a different search or category.</div>' : `<div class="grid" id="grid">${cards}</div>`}
-      <div id="loadmore" data-q="${esc(q)}" data-type="${esc(type)}" data-page="${cur}" data-pages="${pages}" style="text-align:center;padding:48px 0;color:var(--muted);font-size:11px;letter-spacing:.18em;text-transform:uppercase">${cur < pages ? 'Loading more…' : '— end —'}</div>
+      <div id="loadmore" data-q="${esc(q)}" data-type="${esc(type)}" data-sort="${esc(sort)}" data-page="${cur}" data-pages="${pages}" style="text-align:center;padding:48px 0;color:var(--muted);font-size:11px;letter-spacing:.18em;text-transform:uppercase">${cur < pages ? 'Loading more…' : '— end —'}</div>
     </main>
     <script>
     (function(){
+      const STORE='hw_grid';
+      const saved=(()=>{try{return JSON.parse(localStorage.getItem(STORE)||'{}')}catch(e){return {}}})();
+      const url=new URL(window.location.href);
+      const urlSort=url.searchParams.get('sort')||'newest';
+
+      // Hydrate saved sort BEFORE first grid render: if the URL has no explicit
+      // sort but a different one is saved, redirect once to apply it server-side.
+      if(saved.sort && saved.sort!=='newest' && !url.searchParams.get('sort')){
+        url.searchParams.set('sort',saved.sort);
+        window.location.replace(url.toString());
+        return;
+      }
+
       const grid=document.getElementById('grid');
       const lm=document.getElementById('loadmore');
       if(!grid||!lm)return;
@@ -548,6 +596,7 @@ app.get('/', (req, res) => {
       let loading=false;
       const q=lm.dataset.q;
       const type=lm.dataset.type;
+      const sort=lm.dataset.sort||'newest';
       async function loadNext(){
         if(loading||page>=pages)return;
         loading=true;
@@ -555,6 +604,7 @@ app.get('/', (req, res) => {
         const params=new URLSearchParams({page});
         if(q)params.set('q',q);
         if(type)params.set('type',type);
+        if(sort&&sort!=='newest')params.set('sort',sort);
         try{
           const r=await fetch('/api/page?'+params.toString());
           const d=await r.json();
@@ -566,34 +616,27 @@ app.get('/', (req, res) => {
       const io=new IntersectionObserver(es=>{es.forEach(e=>{if(e.isIntersecting)loadNext();});},{rootMargin:'400px'});
       io.observe(lm);
 
-      // Sort + density controls (client-side, persisted)
+      // Sort — server-side: persist + reload so ALL pages honor the order.
       const sortSel=document.getElementById('sort');
-      const dens=document.getElementById('density');
-      const STORE='hw_grid';
-      const saved=(()=>{try{return JSON.parse(localStorage.getItem(STORE)||'{}')}catch{return {}}})();
-      function applyDensity(v){grid.style.gridTemplateColumns='repeat(auto-fill,minmax('+v+'px,1fr))';}
-      function colorBucket(t){t=(t||'').toLowerCase();if(/(black|noir|onyx|ink)/.test(t))return 1;if(/(white|ivory|cream|paper|alabaster)/.test(t))return 2;if(/(gold|brass|champagne|bronze|amber|tan|caramel|sand)/.test(t))return 3;if(/(silver|grey|gray|platinum|pewter|smoke)/.test(t))return 4;if(/(red|crimson|burgundy|wine|garnet)/.test(t))return 5;if(/(green|emerald|forest|sage|olive|moss)/.test(t))return 6;if(/(blue|navy|cobalt|sapphire|indigo)/.test(t))return 7;if(/(purple|plum|violet|aubergine|amethyst)/.test(t))return 8;return 9;}
-      function styleBucket(t){t=(t||'').toLowerCase();if(/(damask|toile|baroque|rococo|ornate)/.test(t))return 1;if(/(stripe|trellis|geometric)/.test(t))return 2;if(/(floral|botanical|vine|leaf)/.test(t))return 3;if(/(grass|natural|jute|hemp|raffia|linen|cork)/.test(t))return 4;if(/(metallic|foil|leaf)/.test(t))return 5;return 9;}
-      function sortGrid(mode){
-        const cards=Array.from(grid.querySelectorAll('.card'));
-        const titleOf=c=>(c.querySelector('.t')?.textContent||'').trim();
-        const skuOf=c=>(c.querySelector('.sub')?.textContent||'').trim();
-        if(mode==='color')cards.sort((a,b)=>colorBucket(titleOf(a))-colorBucket(titleOf(b))||titleOf(a).localeCompare(titleOf(b)));
-        else if(mode==='style')cards.sort((a,b)=>styleBucket(titleOf(a))-styleBucket(titleOf(b))||titleOf(a).localeCompare(titleOf(b)));
-        else if(mode==='sku')cards.sort((a,b)=>skuOf(a).localeCompare(skuOf(b)));
-        else if(mode==='title')cards.sort((a,b)=>titleOf(a).localeCompare(titleOf(b)));
-        // newest = original order; clear+re-append in stored order
-        cards.forEach(c=>grid.appendChild(c));
-      }
       if(sortSel){
-        sortSel.value=saved.sort||'newest';
-        sortGrid(sortSel.value);
-        sortSel.addEventListener('change',()=>{sortGrid(sortSel.value);saved.sort=sortSel.value;localStorage.setItem(STORE,JSON.stringify(saved));});
+        sortSel.value=urlSort;
+        sortSel.addEventListener('change',()=>{
+          saved.sort=sortSel.value;
+          try{localStorage.setItem(STORE,JSON.stringify(saved));}catch(e){}
+          const u=new URL(window.location.href);
+          if(sortSel.value==='newest')u.searchParams.delete('sort');
+          else u.searchParams.set('sort',sortSel.value);
+          window.location.assign(u.toString());
+        });
       }
+
+      // Density — pure client-side, persisted.
+      const dens=document.getElementById('density');
+      function applyDensity(v){grid.style.gridTemplateColumns='repeat(auto-fill,minmax('+v+'px,1fr))';}
       if(dens){
         dens.value=saved.density||240;
         applyDensity(dens.value);
-        dens.addEventListener('input',()=>{applyDensity(dens.value);saved.density=dens.value;localStorage.setItem(STORE,JSON.stringify(saved));});
+        dens.addEventListener('input',()=>{applyDensity(dens.value);saved.density=dens.value;try{localStorage.setItem(STORE,JSON.stringify(saved));}catch(e){}});
       }
     })();
     </script>

← 75860b9 fix /contact: real phone+email (@hollywoodwallcoverings.com)  ·  back to Hollywood Wallcoverings  ·  Untrack backup snapshot, broaden .gitignore, add .bak/.pre- 2befa8b →