[object Object]

← back to Philipperomano

add sort select + density slider to product grid (DW standing rule), with localStorage persistence

597bd720982ef633bad8ab3eb93c00a7d0a62168 · 2026-05-18 20:30:23 -0700 · Steve Abrams

Files touched

Diff

commit 597bd720982ef633bad8ab3eb93c00a7d0a62168
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 18 20:30:23 2026 -0700

    add sort select + density slider to product grid (DW standing rule), with localStorage persistence
---
 server.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 79 insertions(+), 3 deletions(-)

diff --git a/server.js b/server.js
index 129ba0f..0cea4d1 100644
--- a/server.js
+++ b/server.js
@@ -8,7 +8,15 @@ const path = require('path');
 const fs = require('fs');
 
 const PORT = process.env.PORT || 9831;
-const DATA = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
+const DATA_FILE = path.join(__dirname, 'data', 'products.json');
+let DATA;
+try {
+  DATA = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
+  if (!Array.isArray(DATA)) throw new Error('products.json is not a JSON array');
+} catch (err) {
+  console.error(`FATAL: could not load product catalog from ${DATA_FILE}\n  ${err.message}`);
+  process.exit(1);
+}
 const DW_SHOPIFY = 'https://designerwallcoverings.com';
 
 const app = express();
@@ -48,6 +56,17 @@ function paginate(arr, page, perPage = 60) {
   return { items: arr.slice((p-1)*perPage, p*perPage), page: p, pages, total };
 }
 
+// Server-side product sort. Only modes the snapshot data can actually
+// support — products.json carries no tag/color data and price fields are
+// near-empty, so Color/Style/Price sorts are intentionally omitted here.
+function sortProducts(list, mode) {
+  const cmpStr = k => (a, b) => String(a[k] || '').localeCompare(String(b[k] || ''));
+  if (mode === 'sku')   return [...list].sort((a,b) => String(a.dw_sku || a.handle || '').localeCompare(String(b.dw_sku || b.handle || '')));
+  if (mode === 'title') return [...list].sort(cmpStr('title'));
+  if (mode === 'type')  return [...list].sort((a,b) => NORM_TYPE(a.product_type).localeCompare(NORM_TYPE(b.product_type)) || cmpStr('title')(a,b));
+  return list; // 'newest' / default — snapshot's natural order
+}
+
 function shopifyUrl(handle) {
   return handle ? `${DW_SHOPIFY}/products/${handle}` : `${DW_SHOPIFY}`;
 }
@@ -89,7 +108,12 @@ const layout = (title, body, { q='', type='' } = {}) => `<!doctype html>
   form.search button{border:1px solid var(--accent);background:var(--accent);color:#fff;padding:8px 16px;font:inherit;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:500;cursor:pointer}
   main{max-width:1400px;margin:0 auto;padding:32px}
   .meta-line{font-size:12px;color:var(--muted);margin-bottom:18px;letter-spacing:.06em;text-transform:uppercase}
-  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:24px}
+  .controls{display:flex;gap:24px;align-items:center;flex-wrap:wrap;margin-bottom:24px}
+  .controls label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:500;display:flex;gap:8px;align-items:center}
+  .controls select{border:1px solid var(--rule);background:#fff;padding:6px 10px;font:inherit;font-size:13px;color:var(--fg);outline:none;cursor:pointer}
+  .controls select:focus{border-color:var(--accent)}
+  .controls input[type=range]{cursor:pointer;accent-color:var(--accent)}
+  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cardmin,220px),1fr));gap:24px}
   .card{background:var(--card);border:1px solid var(--rule);transition:box-shadow .15s, transform .12s}
   .card:hover{box-shadow:0 8px 24px rgba(0,0,0,.06);transform:translateY(-2px)}
   .card a.thumb{display:block;aspect-ratio:1/1;background:#f0ece4;overflow:hidden}
@@ -151,7 +175,8 @@ ${body}
 app.get('/', (req, res) => {
   const q = (req.query.q || '').toString().toLowerCase().trim();
   const type = (req.query.type || '').toString();
-  const page = parseInt(req.query.page) || 1;
+  const sort = (req.query.sort || '').toString();
+  const page = parseInt(req.query.page, 10) || 1;
 
   let filtered = DATA;
   if (type) filtered = filtered.filter(p => NORM_TYPE(p.product_type) === type);
@@ -162,6 +187,7 @@ app.get('/', (req, res) => {
       return tokens.every(t => hay.includes(t));
     });
   }
+  filtered = sortProducts(filtered, sort);
 
   const { items, page: cur, pages, total } = paginate(filtered, page);
 
@@ -183,11 +209,25 @@ app.get('/', (req, res) => {
     </article>
   `).join('');
 
+  const SORTS = [
+    ['', 'Newest'], ['sku', 'SKU A→Z'], ['title', 'Title A→Z'], ['type', 'Type'],
+  ];
+  const sortOptions = SORTS.map(([v, lbl]) =>
+    `<option value="${v}"${sort === v ? ' selected' : ''}>${lbl}</option>`).join('');
+
   const body = `
     <main>
       <div class="meta-line">
         ${q || type ? `Showing ${total.toLocaleString()} ${type ? `<strong style="color:var(--fg)">${esc(type)}</strong>` : ''} ${q ? `matching "<strong style="color:var(--fg)">${esc(q)}</strong>"` : ''}` : `${total.toLocaleString()} products available`}
       </div>
+      <div class="controls">
+        <label>Sort
+          <select id="sortSelect">${sortOptions}</select>
+        </label>
+        <label>Density
+          <input type="range" id="densityRange" min="160" max="340" step="20" value="220">
+        </label>
+      </div>
       ${items.length === 0 ? '<div class="empty">No products match. Try a different search or category.</div>' : `<div class="grid">${cards}</div>`}
       ${pages > 1 ? `<nav class="pager">
         ${cur > 1 ? `<a href="?${new URLSearchParams({...req.query, page: cur-1}).toString()}">‹ Prev</a>` : ''}
@@ -202,6 +242,42 @@ app.get('/', (req, res) => {
         ${cur < pages ? `<a href="?${new URLSearchParams({...req.query, page: cur+1}).toString()}">Next ›</a>` : ''}
       </nav>` : ''}
     </main>
+    <script>
+      (function () {
+        var sortSel = document.getElementById('sortSelect');
+        var dens = document.getElementById('densityRange');
+        // Sort: server-side. Persist choice, then navigate with ?sort=.
+        if (sortSel) {
+          var savedSort = localStorage.getItem('pr.sort');
+          var params = new URLSearchParams(location.search);
+          if (savedSort !== null && !params.has('sort') && savedSort !== '') {
+            params.set('sort', savedSort);
+            location.replace(location.pathname + '?' + params.toString());
+            return;
+          }
+          sortSel.addEventListener('change', function () {
+            localStorage.setItem('pr.sort', sortSel.value);
+            var p = new URLSearchParams(location.search);
+            if (sortSel.value) p.set('sort', sortSel.value); else p.delete('sort');
+            p.delete('page');
+            location.search = p.toString();
+          });
+        }
+        // Density: client-side CSS var, persisted.
+        if (dens) {
+          var savedDens = localStorage.getItem('pr.density');
+          if (savedDens) { dens.value = savedDens; }
+          var apply = function () {
+            document.documentElement.style.setProperty('--cardmin', dens.value + 'px');
+          };
+          apply();
+          dens.addEventListener('input', function () {
+            localStorage.setItem('pr.density', dens.value);
+            apply();
+          });
+        }
+      })();
+    </script>
   `;
   res.set('Content-Type','text/html').send(layout(`${type || 'All Products'}${q ? ` · ${q}` : ''}`, body, { q, type }));
 });

← 4cd23c3 copy: replace 'private label' with 'exclusive' (brand-voice  ·  back to Philipperomano  ·  contacts: import DW_PHONE/PR_EMAIL constants from kamatera ( 03b56e8 →