[object Object]

← back to Screenprintedwallpaper

stage2: wire microsite catalog + admin CRUD + grid/list view; repair dead facets + niche keyword

e6bc93a7753658be2705d8a2b449b62b4f825972 · 2026-05-19 10:07:49 -0700 · Steve Abrams

Files touched

Diff

commit e6bc93a7753658be2705d8a2b449b62b4f825972
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 10:07:49 2026 -0700

    stage2: wire microsite catalog + admin CRUD + grid/list view; repair dead facets + niche keyword
---
 public/index.html | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 server.js         |  77 +++++++++++++++++++++++++-------------
 site.config.json  |  12 +++---
 3 files changed, 166 insertions(+), 33 deletions(-)

diff --git a/public/index.html b/public/index.html
index 50f7590..641025a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -123,6 +123,33 @@ header { position:fixed; top:0; left:0; right:0; display:flex; justify-content:s
 .sentinel { height:1px }
 .loading { text-align:center; color:var(--muted); padding:32px; font-size:10px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700 }
 
+/* ===== Grid / List view toggle + sortable list columns ===== */
+.viewtoggle { display:inline-flex; border:1px solid var(--line); margin-right:14px }
+.viewtoggle button { background:transparent; border:0; padding:5px 9px; cursor:pointer; color:var(--muted); display:flex; align-items:center }
+.viewtoggle button.active { background:var(--accent); color:#fff }
+.viewtoggle svg { width:13px; height:13px }
+.list-head { display:none }
+[data-view=list] .list-head { display:grid; grid-template-columns:56px 2.6fr 1.2fr 1fr .8fr; gap:14px; align-items:center; padding:9px 12px; border-bottom:2px solid var(--accent) }
+.list-head span, .list-head button { font-size:9px; letter-spacing:0.2em; text-transform:uppercase; font-weight:700; color:var(--muted) }
+.list-head button { background:transparent; border:0; cursor:pointer; padding:0; display:inline-flex; gap:3px; align-items:center; font-family:inherit }
+.list-head button:hover { color:var(--paper) }
+.list-head button.sorted { color:var(--accent) }
+.list-head .arr { font-size:8px }
+.list-head button[data-sort-col=price] { justify-self:end }
+[data-view=list] .grid { display:block }
+[data-view=list] .card { display:grid; grid-template-columns:56px 2.6fr 1.2fr 1fr .8fr; gap:14px; align-items:center; aspect-ratio:auto; padding:8px 12px; border:0; border-bottom:1px solid var(--line); background:transparent; overflow:visible; text-decoration:none }
+[data-view=list] .card:hover { transform:none; background:var(--bg-soft) }
+[data-view=list] .card img { width:56px; height:56px; object-fit:cover }
+[data-view=list] .r-pat { font-size:13px; font-weight:600; color:var(--paper); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
+[data-view=list] .r-col { font-size:11px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
+[data-view=list] .r-sku { font-variant-numeric:tabular-nums; opacity:.8 }
+[data-view=list] .r-aes { text-transform:capitalize }
+[data-view=list] .r-price { text-align:right; color:var(--paper); font-weight:600 }
+@media (max-width:720px) {
+  [data-view=list] .list-head, [data-view=list] .card { grid-template-columns:44px 2fr .9fr }
+  .list-head .col-sku, .list-head .col-aes, [data-view=list] .r-sku, [data-view=list] .r-aes { display:none }
+}
+
 /* ===== Footer ===== */
 footer { padding:64px 32px 32px; border-top:1px solid var(--line); margin-top:32px }
 .footer-grid { max-width:1400px; margin:0 auto; display:grid; grid-template-columns:2fr 1fr 1fr 1fr; gap:48px; margin-bottom:48px }
@@ -308,6 +335,10 @@ textarea:focus-visible,
   </div>
 
   <div class="density">
+    <div class="viewtoggle" id="viewToggle">
+      <button data-view="grid" class="active" aria-label="Grid view" title="Grid view"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="8" height="8"/><rect x="13" y="3" width="8" height="8"/><rect x="3" y="13" width="8" height="8"/><rect x="13" y="13" width="8" height="8"/></svg></button>
+      <button data-view="list" aria-label="List view" title="List view"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="4" width="18" height="3"/><rect x="3" y="10" width="18" height="3"/><rect x="3" y="16" width="18" height="3"/></svg></button>
+    </div>
     <label>Sort</label>
     <select id="sortSelect" aria-label="Sort products" style="background:transparent;color:inherit;border:1px solid var(--line,rgba(255,255,255,.2));font:inherit;font-size:11px;letter-spacing:.10em;text-transform:uppercase;padding:4px 8px;cursor:pointer;outline:none;margin-right:14px">
       <option value="newest">Newest</option>
@@ -326,6 +357,13 @@ textarea:focus-visible,
   </div>
 
   <div class="stat-line" id="statLine">Loading…</div>
+  <div class="list-head" id="listHead">
+    <span></span>
+    <button data-sort-col="title">Pattern <span class="arr"></span></button>
+    <button data-sort-col="sku" class="col-sku">SKU <span class="arr"></span></button>
+    <button data-sort-col="aesthetic" class="col-aes">Aesthetic <span class="arr"></span></button>
+    <button data-sort-col="price">Price <span class="arr"></span></button>
+  </div>
   <div class="grid" id="grid"></div>
   <div class="loading" id="loading">Loading more…</div>
   <div class="sentinel" id="sentinel"></div>
@@ -425,7 +463,16 @@ textarea:focus-visible,
 </script>
 
 <script>
-const state = { q:'', facet:'all', sort:'newest', page:1, pages:1, total:0, loading:false, exhausted:false };
+const state = { q:'', facet:'all', sort:'newest', view:'grid', page:1, pages:1, total:0, loading:false, exhausted:false };
+const HOSTKEY = location.hostname.replace(/\./g, '_');
+// DW STANDARD: hydrate saved view BEFORE the first grid load (sort hydrates in the sort-select wire-up below).
+(function(){
+  try {
+    const savedView = localStorage.getItem(HOSTKEY + '_view');
+    if (savedView === 'list' || savedView === 'grid') state.view = savedView;
+    document.documentElement.dataset.view = state.view;
+  } catch(e){}
+})();
 // Hydrate saved sort before the first grid render (wire-up IIFE runs in a later
 // <script> block, so without this the initial load ignores the persisted choice).
 try {
@@ -452,6 +499,19 @@ function cardHTML(p) {
     + '</div></div>';
 }
 
+// List-view row — same product, table-style columns. Vendor is NEVER shown
+// on the customer-facing storefront (always "Designer Wallcoverings").
+function rowHTML(p) {
+  const mp = Number(p.max_price);
+  const price = (p.max_price != null && !isNaN(mp) && mp > 0) ? '$' + mp.toFixed(2) : '—';
+  const aes = LABELS[p.aesthetic] || p.aesthetic || '—';
+  return '<img loading="lazy" src="' + escAttr(safeImg(p.image_url)) + '" alt="' + escAttr(p.title) + '">'
+    + '<div class="r-pat">' + escAttr(p.pattern_name || p.title) + '</div>'
+    + '<div class="r-col r-sku">' + escAttr(p.sku || p.handle || '—') + '</div>'
+    + '<div class="r-col r-aes">' + escAttr(String(aes).replace(/-/g, ' ')) + '</div>'
+    + '<div class="r-col r-price">' + price + '</div>';
+}
+
 // Click-to-open-full-details — every card opens a rich detail modal showing
 // the full image, pattern name, SKU, retail price (when known), aesthetic
 // chips, and CTAs for sample + Big Red chat. The modal NEVER reveals vendor.
@@ -569,7 +629,7 @@ async function loadGridPage() {
   for (const p of data.items) {
     const a = document.createElement('a');
     a.className = 'card'; a.href = '#'; a.onclick = (e) => { e.preventDefault(); openDetails(p); };
-    a.innerHTML = cardHTML(p);
+    a.innerHTML = state.view === 'list' ? rowHTML(p) : cardHTML(p);
     grid.appendChild(a);
   }
   if (state.page >= state.pages || data.items.length === 0) {
@@ -600,6 +660,51 @@ slider.addEventListener('input', e => setDensity(parseInt(e.target.value)));
 const savedDensity = parseInt(localStorage.getItem('sp_theme_density') || '6');
 if (savedDensity >= 4 && savedDensity <= 12) { slider.value = savedDensity; setDensity(savedDensity); }
 
+// ===== Grid / List view toggle =====
+const viewToggle = document.getElementById('viewToggle');
+function setView(v) {
+  state.view = (v === 'list') ? 'list' : 'grid';
+  document.documentElement.dataset.view = state.view;
+  for (const b of viewToggle.querySelectorAll('button')) b.classList.toggle('active', b.dataset.view === state.view);
+  try { localStorage.setItem(HOSTKEY + '_view', state.view); } catch(e){}
+  resetGrid();
+}
+viewToggle.addEventListener('click', e => {
+  const btn = e.target.closest('button[data-view]');
+  if (btn) setView(btn.dataset.view);
+});
+for (const b of viewToggle.querySelectorAll('button')) b.classList.toggle('active', b.dataset.view === state.view);
+
+// ===== Sortable list-view columns ===== (column -> [ascSort, descSort])
+const COL_SORTS = {
+  title:     ['title', 'title-desc'],
+  sku:       ['sku', 'sku-desc'],
+  aesthetic: ['aesthetic', 'aesthetic-desc'],
+  price:     ['price-asc', 'price-desc'],
+};
+function syncColHeaders() {
+  for (const b of document.querySelectorAll('#listHead button[data-sort-col]')) {
+    const pair = COL_SORTS[b.dataset.sortCol] || [];
+    const arr = b.querySelector('.arr');
+    if (state.sort === pair[0]) { b.classList.add('sorted'); arr.textContent = '▲'; }
+    else if (state.sort === pair[1]) { b.classList.add('sorted'); arr.textContent = '▼'; }
+    else { b.classList.remove('sorted'); arr.textContent = ''; }
+  }
+}
+document.getElementById('listHead').addEventListener('click', e => {
+  const btn = e.target.closest('button[data-sort-col]');
+  if (!btn) return;
+  const pair = COL_SORTS[btn.dataset.sortCol];
+  if (!pair) return;
+  state.sort = (state.sort === pair[0]) ? pair[1] : pair[0];
+  const sortSel = document.getElementById('sortSelect');
+  if (sortSel && [...sortSel.options].some(o => o.value === state.sort)) sortSel.value = state.sort;
+  try { localStorage.setItem(HOSTKEY + '_sort', state.sort); } catch(e){}
+  syncColHeaders();
+  resetGrid();
+});
+syncColHeaders();
+
 // Theme toggle
 const tb = document.getElementById('theme-toggle');
 function setTheme(t){ document.documentElement.dataset.theme = t; try { localStorage.setItem('sp_theme', t); } catch(e){} tb.textContent = t === 'dark' ? '☾' : '☀'; }
@@ -880,6 +985,7 @@ loadGridPage();
     const v = e.target.value;
     try { localStorage.setItem(KEY, v); } catch(e){}
     if (typeof state !== "undefined") state.sort = v;
+    if (typeof syncColHeaders === 'function') syncColHeaders();
     if (typeof resetGrid === "function") resetGrid();
     else location.reload();
   });
diff --git a/server.js b/server.js
index a56a6d7..cec26e7 100644
--- a/server.js
+++ b/server.js
@@ -10,16 +10,10 @@ const fs = require('fs');
 const PORT = process.env.PORT || 9876;
 const DW_SHOPIFY = 'https://designerwallcoverings.com';
 const __SITE = path.basename(__dirname);
-let DATA_RAW;
-try {
-  const raw = fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8');
-  DATA_RAW = JSON.parse(raw);
-  if (!Array.isArray(DATA_RAW)) throw new Error('products.json must be an array');
-} catch (e) {
-  console.error(`[${__SITE}] FATAL: could not load products.json — ${e.message}`);
-  console.error(`[${__SITE}] Starting with empty catalog. Run pull script to populate data/products.json.`);
-  DATA_RAW = [];
-}
+const catalog = require('../_shared/admin-catalog');
+const siteCfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
+const SITE_SLUG = siteCfg.slug || __SITE;
+const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];
 
 function isJunk(p) {
   if (!p.image_url || !p.image_url.trim()) return true;
@@ -32,24 +26,21 @@ function isJunk(p) {
   return false;
 }
 
-const PRODUCTS = DATA_RAW.filter(p => !isJunk(p));
-const DROPPED = DATA_RAW.length - PRODUCTS.length;
-console.log(`Loaded ${DATA_RAW.length}, kept ${PRODUCTS.length}, dropped ${DROPPED}`);
+// Catalog now lives in dw_unified.microsite_products — populated async at
+// startup (see bottom of file). Falls back to data/products.json if PG is down.
+let PRODUCTS = [];
+let DROPPED = 0;
 
 // graphics-loop pass 12: niche keyword filter — only show products that fit this site's niche
-const NICHE_POS = ["silkscreen","screen-printed","block-print","hand-pulled","artisan"];
+const NICHE_POS = ["silkscreen","screen print","screen-printed","screen-print","block print","block-print","hand-pulled","artisan"];
 const NICHE_NEG = ["solid","blank","plain"];
 function nicheFit(p) {
   const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
   if (NICHE_NEG.some(n => blob.includes(n.toLowerCase()))) return false;
   return NICHE_POS.length === 0 || NICHE_POS.some(k => blob.includes(k.toLowerCase()));
 }
-const PRODUCTS_NICHE = PRODUCTS.filter(nicheFit);
-const NICHE_DROPPED = PRODUCTS.length - PRODUCTS_NICHE.length;
-console.log(`Niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}, dropped ${NICHE_DROPPED}`);
-// Replace PRODUCTS reference with the niche-filtered list for all downstream handlers.
-const PRODUCTS_ORIG = PRODUCTS;
-Object.defineProperty(global, '__products_niche_applied', { value: true });
+// Niche-filtered view of PRODUCTS — recomputed after the async catalog load.
+let PRODUCTS_NICHE = [];
 
 
 const app = express();
@@ -89,6 +80,23 @@ function sortProducts(list, sort) {
       return arr.sort((a, b) => colorRank(a) - colorRank(b) || String(a.title || '').localeCompare(String(b.title || '')));
     case 'style':
       return arr.sort((a, b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+    // List-view sortable columns — every column sortable in both directions.
+    case 'title-desc':
+      return arr.sort((a, b) => String(b.title || '').localeCompare(String(a.title || '')));
+    case 'sku-desc':
+      return arr.sort((a, b) => String(b.sku || b.handle || '').localeCompare(String(a.sku || a.handle || '')));
+    case 'vendor':
+      return arr.sort((a, b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+    case 'vendor-desc':
+      return arr.sort((a, b) => String(b.vendor || '').localeCompare(String(a.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+    case 'aesthetic':
+      return arr.sort((a, b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+    case 'aesthetic-desc':
+      return arr.sort((a, b) => String(b.aesthetic || '').localeCompare(String(a.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+    case 'price-asc':
+      return arr.sort((a, b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
+    case 'price-desc':
+      return arr.sort((a, b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
     default:
       return list;
   }
@@ -112,9 +120,8 @@ app.get('/api/products', (req, res) => {
 });
 
 app.get('/api/sliders', (req, res) => {
-  const SLIDER_AESTHETICS = ["screen","block","hand","botanical","chinoiserie","abstract"];
   const out = [];
-  for (const a of SLIDER_AESTHETICS) {
+  for (const a of SITE_RAILS) {
     const items = PRODUCTS_NICHE.filter(p => p.aesthetic === a).slice(0, 12);
     if (items.length >= 4) out.push({ aesthetic: a, items });
   }
@@ -164,6 +171,26 @@ ${urls.map(u => `  <url><loc>https://screenprintedwallpaper.com${u}</loc><change
   res.type('application/xml').send(xml);
 });
 
-app.listen(PORT, '127.0.0.1', () => {
-  console.log(`screenprintedwallpaper listening on http://127.0.0.1:${PORT}`);
-});
+// Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* REST.
+catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
+
+// Load the catalog from dw_unified, then start listening. PG down → JSON fallback.
+(async () => {
+  try {
+    const rows = await catalog.getProducts(SITE_SLUG);
+    PRODUCTS = rows.filter(p => !isJunk(p));
+    DROPPED = rows.length - PRODUCTS.length;
+    console.log(`[${__SITE}] loaded ${rows.length} from dw_unified, kept ${PRODUCTS.length}, dropped ${DROPPED}`);
+  } catch (e) {
+    console.error(`[${__SITE}] PG catalog load failed (${e.message}) — falling back to data/products.json`);
+    try {
+      const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
+      PRODUCTS = (Array.isArray(raw) ? raw : []).filter(p => !isJunk(p));
+    } catch (_) { PRODUCTS = []; }
+  }
+  PRODUCTS_NICHE = PRODUCTS.filter(nicheFit);
+  console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}`);
+  app.listen(PORT, '127.0.0.1', () => {
+    console.log(`screenprintedwallpaper listening on http://127.0.0.1:${PORT}`);
+  });
+})();
diff --git a/site.config.json b/site.config.json
index 6c412ad..4e96bd3 100644
--- a/site.config.json
+++ b/site.config.json
@@ -10,12 +10,12 @@
     "accent": "#3a5a8a"
   },
   "rails": [
-    "multi-color",
-    "silkscreen",
-    "flat-ink",
-    "graphic",
-    "poster",
-    "editorial"
+    "contemporary",
+    "white",
+    "whimsical",
+    "screen",
+    "commercial",
+    "architectural"
   ],
   "port": 9904
 }

← 16c275d untrack 13 .bak snapshot files, ignore *.bak.*/*.pre-*, add  ·  back to Screenprintedwallpaper  ·  Phase 2: mobile-ready + tap-to-reveal c884b7a →