[object Object]

← back to Naturaltextilewallpaper

Hydrate saved sort from localStorage before first grid load

f812ade524e565267b60f74cf6e184f1ff678f51 · 2026-05-19 07:52:12 -0700 · Steve Abrams

The sort <select> persisted its value but never re-applied it on a
fresh visit, so the catalog always loaded in 'newest' order regardless
of the user's last choice. hydrateSort() now checks localStorage and
issues a window.location.replace with the saved sort BEFORE the grid's
initial loadPage() runs, gating the IntersectionObserver setup on it.

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

Files touched

Diff

commit f812ade524e565267b60f74cf6e184f1ff678f51
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 07:52:12 2026 -0700

    Hydrate saved sort from localStorage before first grid load
    
    The sort <select> persisted its value but never re-applied it on a
    fresh visit, so the catalog always loaded in 'newest' order regardless
    of the user's last choice. hydrateSort() now checks localStorage and
    issues a window.location.replace with the saved sort BEFORE the grid's
    initial loadPage() runs, gating the IntersectionObserver setup on it.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 server.js                |  63 ++++---
 server.js.pre-sort-skill | 480 -----------------------------------------------
 2 files changed, 42 insertions(+), 501 deletions(-)

diff --git a/server.js b/server.js
index fa159d2..93bdf16 100644
--- a/server.js
+++ b/server.js
@@ -138,6 +138,13 @@ const app = express();
 app.use(helmet({
   contentSecurityPolicy: false  // allow inline styles/scripts for the toggle
 }));
+// Never serve editor/snapshot files from the static root.
+app.use((req, res, next) => {
+  if (/\.(bak|pre-[\w.-]*|orig|swp|tmp)$|\.bak\.|~$/i.test(req.path)) {
+    return res.status(404).send(page404());
+  }
+  next();
+});
 app.use(express.static(path.join(__dirname, 'public')));
 
 // --- API: products (JSON for infinite scroll) ---------------------------
@@ -257,7 +264,7 @@ function headerHTML(q='', material='all', sort='newest') {
         <a href="/?material=linen" class="nav-link">Linen</a>
         <a href="/?material=grasscloth" class="nav-link">Grasscloth</a>
         <a href="/?material=jute" class="nav-link">Jute</a>
-        <a href="${DW}" class="nav-link" target="_blank" rel="noopener">Free Samples</a>
+        <a href="${DW}" class="nav-link" target="_blank" rel="noopener noreferrer">Free Samples</a>
         <button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">
           <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
           <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
@@ -341,11 +348,11 @@ function renderIndex(q, material, sort) {
       </div>
       <div class="footer-links">
         <a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
-        <a href="${DW}" target="_blank" rel="noopener">Designer Wallcoverings</a>
-        <a href="${DW}/pages/trade-program" target="_blank" rel="noopener">Trade Program</a>
-        <a href="${DW}/pages/contact" target="_blank" rel="noopener">Contact</a>
+        <a href="${DW}" target="_blank" rel="noopener noreferrer">Designer Wallcoverings</a>
+        <a href="${DW}/pages/trade-program" target="_blank" rel="noopener noreferrer">Trade Program</a>
+        <a href="${DW}/pages/contact" target="_blank" rel="noopener noreferrer">Contact</a>
       </div>
-      <p class="footer-legal">All patterns and images courtesy of <a href="${DW}" target="_blank" rel="noopener">designerwallcoverings.com</a>. Free memo samples available on every product.</p>
+      <p class="footer-legal">All patterns and images courtesy of <a href="${DW}" target="_blank" rel="noopener noreferrer">designerwallcoverings.com</a>. Free memo samples available on every product.</p>
     </div>
   </footer>
 
@@ -398,7 +405,7 @@ function renderIndex(q, material, sort) {
         + '<span class="card-title">'+escHtml(p.title)+'</span>'
         + priceStr
         + '</div>'
-        + '<a href="https://designerwallcoverings.com/products/'+p.handle+'#sample" class="sample-cta" target="_blank" rel="noopener" onclick="event.stopPropagation()">Order Memo Sample (free)</a>'
+        + '<a href="https://designerwallcoverings.com/products/'+p.handle+'#sample" class="sample-cta" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Order Memo Sample (free)</a>'
         + '</a>';
     }
 
@@ -436,20 +443,34 @@ function renderIndex(q, material, sort) {
         .catch(function(){NTW.loading=false;document.getElementById('gridLoader').style.display='none';});
     }
 
-    // IntersectionObserver sentinel
-    var obs = new IntersectionObserver(function(entries){
-      if (entries[0].isIntersecting) loadPage();
-    }, { rootMargin: '600px 0px' });
-    obs.observe(document.getElementById('sentinel'));
-    loadPage(); // initial load
-
-    // Restore sort from localStorage
-    (function(){
-      var sel=document.getElementById('sortSelect');if(!sel)return;
+    // Restore sort from localStorage — sort is server-driven, so if the
+    // saved value differs from what the server rendered, reload with it
+    // BEFORE the first grid paints. Returns true if a redirect was issued.
+    function hydrateSort(){
+      var sel=document.getElementById('sortSelect');if(!sel)return false;
       var saved=localStorage.getItem('ntw_sort');
-      // Sort is server-driven, just persist selection
       sel.addEventListener('change',function(){localStorage.setItem('ntw_sort',this.value);});
-    })();
+      if(saved && saved!==NTW.sort){
+        var has=Array.prototype.some.call(sel.options,function(o){return o.value===saved;});
+        if(has){
+          var u=new URL(window.location.href);
+          u.searchParams.set('sort',saved);
+          window.location.replace(u.toString());
+          return true;
+        }
+      }
+      return false;
+    }
+
+    // IntersectionObserver sentinel — only wire up + initial load if we are
+    // NOT about to redirect to the saved sort.
+    if(!hydrateSort()){
+      var obs = new IntersectionObserver(function(entries){
+        if (entries[0].isIntersecting) loadPage();
+      }, { rootMargin: '600px 0px' });
+      obs.observe(document.getElementById('sentinel'));
+      loadPage(); // initial load
+    }
   </script>
 </body>
 </html>`;
@@ -478,8 +499,8 @@ function renderDetail(p, matLabel) {
         ${p.vendor ? `<p class="detail-vendor">by ${escHtml(p.vendor)}</p>` : ''}
         ${p.max_price > 0 ? `<p class="detail-price">From <strong>$${Number(p.min_price).toFixed(2)}</strong></p>` : ''}
         <div class="detail-ctas">
-          <a href="${sample_url}" class="btn-primary" target="_blank" rel="noopener">Order Memo Sample (free)</a>
-          <a href="${dw_url}" class="btn-secondary" target="_blank" rel="noopener">View on Designer Wallcoverings</a>
+          <a href="${sample_url}" class="btn-primary" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</a>
+          <a href="${dw_url}" class="btn-secondary" target="_blank" rel="noopener noreferrer">View on Designer Wallcoverings</a>
         </div>
         <div class="detail-spec-panel">
           <p class="spec-label">MATERIAL</p><p class="spec-value">${matLabel}</p>
@@ -500,7 +521,7 @@ function renderDetail(p, matLabel) {
       </div>
       <div class="footer-links">
         <a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
-        <a href="${DW}" target="_blank" rel="noopener">Designer Wallcoverings</a>
+        <a href="${DW}" target="_blank" rel="noopener noreferrer">Designer Wallcoverings</a>
       </div>
     </div>
   </footer>
diff --git a/server.js.pre-sort-skill b/server.js.pre-sort-skill
deleted file mode 100644
index cc18cc2..0000000
--- a/server.js.pre-sort-skill
+++ /dev/null
@@ -1,480 +0,0 @@
-/**
- * NATURAL TEXTILE WALLPAPER — DW family category aggregator
- * Curated from live designerwallcoverings.com Shopify catalog.
- * Covers: linen, jute, raffia, sisal, hemp, grasscloth, woven, seagrass, abaca, bamboo.
- * Design: Loro Piana heirloom mill aesthetic — warm cream/oatmeal/charcoal, Futura-feel sans.
- * Port: 9835
- */
-const express = require('express');
-const helmet = require('helmet');
-const path = require('path');
-const fs = require('fs');
-
-const PORT = process.env.PORT || 9835;
-const DW = 'https://designerwallcoverings.com';
-const SITE_NAME = 'Natural Textile Wallpaper';
-const SITE_DOMAIN = 'naturaltextilewallpaper.com';
-const SITE_EMAIL = 'info@naturaltextilewallpaper.com';
-const GA_ID = process.env.GA_ID || '';
-
-// --- Data load ----------------------------------------------------------
-let DATA_RAW;
-try {
-  DATA_RAW = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
-  if (!Array.isArray(DATA_RAW)) throw new Error('products.json must be an array');
-} catch(e) {
-  console.error(`[NTW] FATAL: products.json load failed — ${e.message}`);
-  DATA_RAW = [];
-}
-
-// --- Junk filter --------------------------------------------------------
-function isJunk(p) {
-  if (!p.image_url || !p.image_url.trim()) return true;
-  if (!p.handle) return true;
-  const t = p.title || '';
-  if (/lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine/i.test(t)) return true;
-  if (/visual.{0,3}merchandiser|bh.?90210|beverly.?hills.?90210/i.test(t)) return true;
-  if (/(?:^|\W)image[ _-]?4(?:\W|$)/i.test(t)) return true;
-  const sku = p.sku || p.handle || '';
-  if (/^\d{13,}$/.test(sku)) return true;  // variant_id leak
-  return false;
-}
-
-const PRODUCTS = DATA_RAW.filter(p => !isJunk(p));
-console.log(`[NTW] Loaded ${DATA_RAW.length} → kept ${PRODUCTS.length} after junk filter`);
-
-// --- Material labels (the 7 filter pills) --------------------------------
-const MATERIALS = [
-  { key: 'linen',      label: 'Linen' },
-  { key: 'jute',       label: 'Jute' },
-  { key: 'raffia',     label: 'Raffia' },
-  { key: 'sisal',      label: 'Sisal' },
-  { key: 'hemp',       label: 'Hemp' },
-  { key: 'grasscloth', label: 'Grasscloth' },
-  { key: 'bamboo',     label: 'Bamboo' },
-  { key: 'woven',      label: 'Woven' },
-  { key: 'natural',    label: 'Natural' },
-];
-
-// --- Sort ---------------------------------------------------------------
-const COLOR_RANK = {
-  black:0,gray:1,grey:1,silver:2,white:3,ivory:4,cream:5,beige:6,brown:7,tan:8,khaki:9,copper:10,
-  red:20,pink:21,coral:22,orange:23,peach:24,salmon:25,
-  yellow:30,gold:31,mustard:32,olive:33,
-  green:40,lime:41,mint:42,teal:50,aqua:51,turquoise:52,cyan:53,
-  blue:60,navy:61,indigo:62,periwinkle:63,
-  purple:70,violet:71,lavender:72,plum:73,magenta:74,fuchsia:75,
-};
-function colorRank(p) {
-  const tags = (p.tags||[]).map(t=>String(t).toLowerCase());
-  for (const t of tags) for (const k of Object.keys(COLOR_RANK)) if (t.includes(k)) return COLOR_RANK[k];
-  return 999;
-}
-const STYLE_TAGS = ['traditional','transitional','modern','contemporary','minimalist','art deco','victorian','mid-century','rustic','industrial','farmhouse','bohemian','geometric','floral','botanical','damask','toile','grasscloth','linen','natural'];
-function styleKey(p) {
-  const tags = (p.tags||[]).map(t=>String(t).toLowerCase());
-  for (const s of STYLE_TAGS) if (tags.some(t=>t.includes(s))) return s;
-  return 'zzz';
-}
-function sortProducts(list, mode) {
-  if (mode==='sku')        return [...list].sort((a,b)=>String(a.sku||a.handle||'').localeCompare(String(b.sku||b.handle||'')));
-  if (mode==='title')      return [...list].sort((a,b)=>String(a.title||'').localeCompare(String(b.title||'')));
-  if (mode==='color')      return [...list].sort((a,b)=>colorRank(a)-colorRank(b)||String(a.title||'').localeCompare(String(b.title||'')));
-  if (mode==='style')      return [...list].sort((a,b)=>styleKey(a).localeCompare(styleKey(b))||String(a.title||'').localeCompare(String(b.title||'')));
-  if (mode==='price-asc')  return [...list].sort((a,b)=>(Number(a.max_price)||0)-(Number(b.max_price)||0));
-  if (mode==='price-desc') return [...list].sort((a,b)=>(Number(b.max_price)||0)-(Number(a.max_price)||0));
-  return list; // newest = loaded order
-}
-
-// --- Express app --------------------------------------------------------
-const app = express();
-app.use(helmet({
-  contentSecurityPolicy: false  // allow inline styles/scripts for the toggle
-}));
-app.use(express.static(path.join(__dirname, 'public')));
-
-// --- API: products (JSON for infinite scroll) ---------------------------
-app.get('/api/products', (req, res) => {
-  const { q, material, sort='newest', page=1, limit=24 } = req.query;
-  let list = PRODUCTS;
-  if (q) {
-    const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
-    list = list.filter(p => {
-      const blob = ((p.title||'')+' '+(p.sku||'')+' '+(p.tags||[]).join(' ')).toLowerCase();
-      return tokens.every(tok => blob.includes(tok));
-    });
-  }
-  if (material && material !== 'all') {
-    list = list.filter(p => p.material === material);
-  }
-  list = sortProducts(list, sort);
-  const total = list.length;
-  const pg = Math.max(1, parseInt(page)||1);
-  const lim = Math.min(60, parseInt(limit)||24);
-  const start = (pg-1)*lim;
-  res.json({ total, page:pg, limit:lim, pages: Math.ceil(total/lim), sort, items: list.slice(start, start+lim) });
-});
-
-// --- API: sliders (rails per material) ----------------------------------
-app.get('/api/sliders', (req, res) => {
-  const rails = [];
-  for (const m of MATERIALS) {
-    const items = PRODUCTS.filter(p => p.material === m.key).slice(0, 12);
-    if (items.length >= 4) rails.push({ material: m.key, label: m.label, items });
-  }
-  res.json({ rails });
-});
-
-// --- API: material counts for filter pills ------------------------------
-app.get('/api/facets', (req, res) => {
-  const counts = {};
-  for (const p of PRODUCTS) counts[p.material] = (counts[p.material]||0) + 1;
-  res.json({ counts, total: PRODUCTS.length });
-});
-
-// --- Product detail page ------------------------------------------------
-app.get('/p/:handle', (req, res) => {
-  const p = PRODUCTS.find(x => x.handle === req.params.handle);
-  if (!p) return res.status(404).send(page404());
-  const matLabel = MATERIALS.find(m=>m.key===p.material)?.label || 'Natural Textile';
-  res.send(renderDetail(p, matLabel));
-});
-
-// --- Health check -------------------------------------------------------
-app.get('/health', (req, res) => {
-  const counts = {};
-  for (const p of PRODUCTS) counts[p.material] = (counts[p.material]||0) + 1;
-  res.json({ ok: true, products: PRODUCTS.length, materials: counts, domain: SITE_DOMAIN });
-});
-
-// --- Main catalog page --------------------------------------------------
-app.get('/', (req, res) => {
-  const { q='', material='all', sort='newest' } = req.query;
-  res.send(renderIndex(q, material, sort));
-});
-
-// --- HTML templates -----------------------------------------------------
-const GTAG = GA_ID ? `
-  <script async src="https://www.googletagmanager.com/gtag/js?id=${GA_ID}"></script>
-  <script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${GA_ID}');</script>
-` : '';
-
-function headHTML(title, description) {
-  return `<!DOCTYPE html>
-<html lang="en">
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <script>
-    (function(){try{var t=localStorage.getItem('ntw_theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
-  </script>
-  <title>${title}</title>
-  <meta name="description" content="${description}">
-  <meta name="theme-color" content="#f5ede0" media="(prefers-color-scheme: light)">
-  <meta name="theme-color" content="#1a1410" media="(prefers-color-scheme: dark)">
-  ${GTAG}
-  <link rel="preconnect" href="https://fonts.googleapis.com">
-  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-  <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
-  <link rel="stylesheet" href="/css/site.css">
-  <script type="application/ld+json">
-  {"@context":"https://schema.org","@type":"Organization","name":"Natural Textile Wallpaper","url":"https://${SITE_DOMAIN}","email":"${SITE_EMAIL}","description":"${description}","sameAs":["https://designerwallcoverings.com"]}
-  </script>
-</head>
-<body>`;
-}
-
-function headerHTML(q='', material='all', sort='newest') {
-  const matOptions = [
-    {key:'all', label:'All Materials'},
-    ...MATERIALS
-  ];
-  const sortOptions = [
-    {key:'newest', label:'Newest'},
-    {key:'color',  label:'Color'},
-    {key:'style',  label:'Style'},
-    {key:'sku',    label:'SKU A→Z'},
-    {key:'title',  label:'Title A→Z'},
-    {key:'price-asc', label:'Price ↑'},
-    {key:'price-desc', label:'Price ↓'},
-  ];
-  return `
-  <header class="site-header">
-    <div class="header-inner">
-      <a href="/" class="brand">
-        <span class="brand-mark">Natural Textile<br>Wallpaper</span>
-        <small class="brand-sub">Curated by Designer Wallcoverings</small>
-      </a>
-      <nav class="header-nav">
-        <a href="/" class="nav-link">Collection</a>
-        <a href="/?material=linen" class="nav-link">Linen</a>
-        <a href="/?material=grasscloth" class="nav-link">Grasscloth</a>
-        <a href="/?material=jute" class="nav-link">Jute</a>
-        <a href="${DW}" class="nav-link" target="_blank" rel="noopener">Free Samples</a>
-        <button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">
-          <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
-          <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
-        </button>
-      </nav>
-    </div>
-  </header>
-
-  <div class="search-bar-wrap">
-    <form class="search-form" method="GET" action="/" id="searchForm">
-      <div class="search-row">
-        <div class="search-input-wrap">
-          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" class="search-icon"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
-          <input type="text" name="q" id="searchInput" value="${escHtml(q)}" placeholder="Search 4,000+ natural fiber wallcoverings…" class="search-input" autocomplete="off">
-          ${q ? `<button type="button" class="search-clear" onclick="document.getElementById('searchInput').value='';document.getElementById('searchForm').submit();">✕</button>` : ''}
-        </div>
-        <div class="controls-row">
-          <select name="sort" id="sortSelect" class="ctrl-select" onchange="this.form.submit()">
-            ${sortOptions.map(o=>`<option value="${o.key}"${sort===o.key?' selected':''}>${o.label}</option>`).join('')}
-          </select>
-          <div class="density-wrap">
-            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
-            <input type="range" id="densitySlider" min="120" max="300" value="220" class="density-slider" title="Card size">
-          </div>
-        </div>
-      </div>
-      <input type="hidden" name="material" value="${escHtml(material)}">
-    </form>
-  </div>
-
-  <div class="material-pills">
-    ${matOptions.map(m=>`
-      <a href="/?material=${m.key}${q?'&q='+encodeURIComponent(q):''}${sort!=='newest'?'&sort='+sort:''}"
-         class="pill${material===m.key?' pill-active':''}" data-mat="${m.key}">${m.label}</a>
-    `).join('')}
-  </div>`;
-}
-
-function renderIndex(q, material, sort) {
-  const matLabel = material==='all' ? 'All Natural Fibers' : (MATERIALS.find(m=>m.key===material)?.label || material);
-  const heroMaterial = material!=='all' ? material : 'linen';
-  const heroProduct = PRODUCTS.find(p => p.material === heroMaterial) || PRODUCTS[0];
-
-  return headHTML(
-    `Natural Textile Wallpaper — Free Memo Samples from Designer Wallcoverings | ${matLabel}`,
-    `Shop linen, jute, raffia, sisal, hemp, grasscloth and woven natural-fiber wallcoverings. Free memo samples, trade service, and expert hand-holding from Designer Wallcoverings.`
-  ) + headerHTML(q, material, sort) + `
-
-  ${q==='' && material==='all' ? `
-  <section class="hero">
-    <div class="hero-image-wrap">
-      ${heroProduct ? `<img src="${escHtml(heroProduct.image_url)}" alt="Natural fiber weave detail — ${escHtml(heroProduct.title)}" class="hero-img" loading="eager">` : ''}
-      <div class="hero-overlay"></div>
-    </div>
-    <div class="hero-content">
-      <p class="hero-eyebrow">Heirloom Natural Materials</p>
-      <h1 class="hero-headline">Where Earth<br>Meets Wall</h1>
-      <p class="hero-sub">Linen. Jute. Raffia. Sisal. Hemp. Grasscloth.<br>The complete collection of natural-fiber wallcoverings, curated for biophilic interiors.</p>
-      <a href="#catalog" class="hero-cta">Explore the Collection</a>
-    </div>
-  </section>
-  ` : ''}
-
-  <div id="catalog" class="catalog-wrap">
-    <div class="catalog-meta" id="catalogMeta">
-      <span class="loading-text">Loading collection…</span>
-    </div>
-    <div class="product-grid" id="productGrid"></div>
-    <div class="grid-sentinel" id="sentinel"></div>
-    <div class="grid-end" id="gridEnd" style="display:none">— end of archive · <span id="totalCount">0</span> patterns —</div>
-    <div class="grid-loader" id="gridLoader">
-      <div class="loader-dots"><span></span><span></span><span></span></div>
-    </div>
-  </div>
-
-  <footer class="site-footer">
-    <div class="footer-inner">
-      <div class="footer-brand">
-        <span class="footer-name">Natural Textile Wallpaper</span>
-        <span class="footer-tagline">Curated natural-fiber wallcoverings for biophilic interiors</span>
-      </div>
-      <div class="footer-links">
-        <a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
-        <a href="${DW}" target="_blank" rel="noopener">Designer Wallcoverings</a>
-        <a href="${DW}/pages/trade-program" target="_blank" rel="noopener">Trade Program</a>
-        <a href="${DW}/pages/contact" target="_blank" rel="noopener">Contact</a>
-      </div>
-      <p class="footer-legal">All patterns and images courtesy of <a href="${DW}" target="_blank" rel="noopener">designerwallcoverings.com</a>. Free memo samples available on every product.</p>
-    </div>
-  </footer>
-
-  <script>
-    // ---- Dark/Light toggle ----
-    (function(){
-      var btn=document.getElementById('theme-toggle');if(!btn)return;
-      function sync(){var t=document.documentElement.getAttribute('data-theme');btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}
-      sync();
-      btn.addEventListener('click',function(){
-        var cur=document.documentElement.getAttribute('data-theme')||'light';
-        var nxt=cur==='dark'?'light':'dark';
-        document.documentElement.setAttribute('data-theme',nxt);
-        try{localStorage.setItem('ntw_theme',nxt);}catch(e){}
-        sync();
-      });
-    })();
-
-    // ---- Density slider ----
-    (function(){
-      var slider=document.getElementById('densitySlider');if(!slider)return;
-      var saved=localStorage.getItem('ntw_density');
-      if(saved)slider.value=saved;
-      function applyDensity(v){document.documentElement.style.setProperty('--card-min',''+v+'px');}
-      applyDensity(slider.value);
-      slider.addEventListener('input',function(){applyDensity(this.value);localStorage.setItem('ntw_density',this.value);});
-    })();
-
-    // ---- Infinite scroll catalog ----
-    var NTW = {
-      page: 1,
-      pages: 999,
-      total: 0,
-      loading: false,
-      exhausted: false,
-      q: ${JSON.stringify(q)},
-      material: ${JSON.stringify(material)},
-      sort: ${JSON.stringify(sort)}
-    };
-
-    function productCard(p) {
-      var priceStr = '';
-      if (p.max_price > 0) priceStr = '<span class="card-price">From $' + p.min_price.toFixed(2) + '</span>';
-      var matLabel = ${JSON.stringify(MATERIALS.reduce((a,m)=>({...a,[m.key]:m.label}),{}))};
-      var mat = matLabel[p.material] || p.material || '';
-      return '<a href="/p/'+p.handle+'" class="product-card">'
-        + '<div class="card-img-wrap"><img src="'+p.image_url+'" alt="'+escHtml(p.title)+'" loading="lazy" class="card-img"></div>'
-        + '<div class="card-info">'
-        + '<span class="card-material">'+mat+'</span>'
-        + '<span class="card-title">'+escHtml(p.title)+'</span>'
-        + priceStr
-        + '</div>'
-        + '<a href="https://designerwallcoverings.com/products/'+p.handle+'#sample" class="sample-cta" target="_blank" rel="noopener" onclick="event.stopPropagation()">Order Memo Sample (free)</a>'
-        + '</a>';
-    }
-
-    function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
-
-    function loadPage() {
-      if (NTW.loading || NTW.exhausted) return;
-      NTW.loading = true;
-      document.getElementById('gridLoader').style.display = 'flex';
-      var params = new URLSearchParams({
-        q: NTW.q,
-        material: NTW.material,
-        sort: NTW.sort,
-        page: NTW.page,
-        limit: 24
-      });
-      fetch('/api/products?' + params.toString())
-        .then(function(r){return r.json();})
-        .then(function(data){
-          NTW.pages = data.pages;
-          NTW.total = data.total;
-          document.getElementById('totalCount').textContent = data.total;
-          document.getElementById('catalogMeta').textContent = data.total + ' patterns' + (NTW.q ? ' for "' + NTW.q + '"' : '') + (NTW.material!=='all' ? ' in ' + NTW.material : '');
-          var grid = document.getElementById('productGrid');
-          data.items.forEach(function(p){ grid.insertAdjacentHTML('beforeend', productCard(p)); });
-          NTW.page++;
-          NTW.loading = false;
-          document.getElementById('gridLoader').style.display = 'none';
-          if (NTW.page > NTW.pages) {
-            NTW.exhausted = true;
-            document.getElementById('sentinel').style.display = 'none';
-            document.getElementById('gridEnd').style.display = 'block';
-          }
-        })
-        .catch(function(){NTW.loading=false;document.getElementById('gridLoader').style.display='none';});
-    }
-
-    // IntersectionObserver sentinel
-    var obs = new IntersectionObserver(function(entries){
-      if (entries[0].isIntersecting) loadPage();
-    }, { rootMargin: '600px 0px' });
-    obs.observe(document.getElementById('sentinel'));
-    loadPage(); // initial load
-
-    // Restore sort from localStorage
-    (function(){
-      var sel=document.getElementById('sortSelect');if(!sel)return;
-      var saved=localStorage.getItem('ntw_sort');
-      // Sort is server-driven, just persist selection
-      sel.addEventListener('change',function(){localStorage.setItem('ntw_sort',this.value);});
-    })();
-  </script>
-</body>
-</html>`;
-}
-
-function renderDetail(p, matLabel) {
-  const dw_url = `${DW}/products/${p.handle}`;
-  const sample_url = `${dw_url}#sample`;
-  const images = p.images && p.images.length > 1 ? p.images : [p.image_url];
-  return headHTML(
-    `${p.title} — Free Memo Sample | Natural Textile Wallpaper`,
-    `Explore ${p.title} — a ${matLabel} wallcovering available with free memo samples and trade service from Designer Wallcoverings. Order yours today.`
-  ) + headerHTML() + `
-  <div class="detail-wrap">
-    <nav class="breadcrumb">
-      <a href="/">Collection</a> / <a href="/?material=${p.material}">${matLabel}</a> / <span>${escHtml(p.title)}</span>
-    </nav>
-    <div class="detail-grid">
-      <div class="detail-images">
-        <img src="${escHtml(images[0])}" alt="${escHtml(p.title)}" class="detail-hero-img" id="detailMainImg">
-        ${images.length > 1 ? `<div class="detail-thumbs">${images.map((img,i)=>`<img src="${escHtml(img)}" alt="View ${i+1}" class="detail-thumb" onclick="document.getElementById('detailMainImg').src='${escHtml(img)}';">`).join('')}</div>` : ''}
-      </div>
-      <div class="detail-info">
-        <span class="detail-material-pill">${matLabel}</span>
-        <h1 class="detail-title">${escHtml(p.title)}</h1>
-        ${p.vendor ? `<p class="detail-vendor">by ${escHtml(p.vendor)}</p>` : ''}
-        ${p.max_price > 0 ? `<p class="detail-price">From <strong>$${Number(p.min_price).toFixed(2)}</strong></p>` : ''}
-        <div class="detail-ctas">
-          <a href="${sample_url}" class="btn-primary" target="_blank" rel="noopener">Order Memo Sample (free)</a>
-          <a href="${dw_url}" class="btn-secondary" target="_blank" rel="noopener">View on Designer Wallcoverings</a>
-        </div>
-        <div class="detail-spec-panel">
-          <p class="spec-label">MATERIAL</p><p class="spec-value">${matLabel}</p>
-          ${p.product_type ? `<p class="spec-label">TYPE</p><p class="spec-value">${escHtml(p.product_type)}</p>` : ''}
-          ${p.tags && p.tags.length ? `<p class="spec-label">TAGS</p><p class="spec-value spec-tags">${p.tags.slice(0,12).map(t=>`<span class="spec-tag">${escHtml(t)}</span>`).join('')}</p>` : ''}
-          <p class="spec-label">SKU</p><p class="spec-value">${escHtml(p.sku||p.handle)}</p>
-        </div>
-        <p class="detail-sample-note">Free memo samples ship within 2–3 business days. No commitment required. Designer Wallcoverings offers white-glove trade service for all natural-fiber installations.</p>
-      </div>
-    </div>
-    <div class="detail-back"><a href="/?material=${p.material}" class="back-link">← Back to ${matLabel}</a></div>
-  </div>
-  <footer class="site-footer">
-    <div class="footer-inner">
-      <div class="footer-brand">
-        <span class="footer-name">Natural Textile Wallpaper</span>
-        <span class="footer-tagline">Curated by Designer Wallcoverings</span>
-      </div>
-      <div class="footer-links">
-        <a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
-        <a href="${DW}" target="_blank" rel="noopener">Designer Wallcoverings</a>
-      </div>
-    </div>
-  </footer>
-  <script>
-    (function(){var btn=document.getElementById('theme-toggle');if(!btn)return;function sync(){var t=document.documentElement.getAttribute('data-theme');btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}sync();btn.addEventListener('click',function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var nxt=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',nxt);try{localStorage.setItem('ntw_theme',nxt);}catch(e){}sync();});})();
-  </script>
-</body>
-</html>`;
-}
-
-function page404() {
-  return headHTML('Not Found | Natural Textile Wallpaper', 'Page not found') + `
-  <div style="text-align:center;padding:80px 20px">
-    <h1>Pattern not found</h1>
-    <p><a href="/">Browse all natural textile wallcoverings →</a></p>
-  </div>
-  </body></html>`;
-}
-
-function escHtml(s) {
-  return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
-}
-
-app.listen(PORT, () => console.log(`[NTW] Running on port ${PORT} — ${PRODUCTS.length} products`));

← 10d0c18 snapshot: 1 file(s) changed, +1 new  ·  back to Naturaltextilewallpaper  ·  Untrack server.js.pre-sort-skill snapshot; broaden .gitignor d25f742 →