[object Object]

← back to Hollywood Wallcoverings

Untrack backup snapshot, broaden .gitignore, add .bak/.pre- 404-guard

2befa8b8bc7609bed97123361bb3cf185dca8fb0 · 2026-05-19 08:00:45 -0700 · Steve Abrams

server.js.bak.nshdr.1778214775073 was committed despite *.bak in
.gitignore (ignore doesn't untrack). Removed from index, kept on disk.
.gitignore now covers *.bak.* and *.pre-* too. Added an Express
middleware that 404s any path containing .bak or .pre- so a stray
snapshot can never be served from a static root.

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

Files touched

Diff

commit 2befa8b8bc7609bed97123361bb3cf185dca8fb0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 08:00:45 2026 -0700

    Untrack backup snapshot, broaden .gitignore, add .bak/.pre- 404-guard
    
    server.js.bak.nshdr.1778214775073 was committed despite *.bak in
    .gitignore (ignore doesn't untrack). Removed from index, kept on disk.
    .gitignore now covers *.bak.* and *.pre-* too. Added an Express
    middleware that 404s any path containing .bak or .pre- so a stray
    snapshot can never be served from a static root.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .gitignore                        |   2 +
 server.js                         |  10 +
 server.js.bak.nshdr.1778214775073 | 543 --------------------------------------
 3 files changed, 12 insertions(+), 543 deletions(-)

diff --git a/.gitignore b/.gitignore
index 7e6a9c3..1140724 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ dist/
 build/
 .next/
 *.bak
+*.bak.*
+*.pre-*
diff --git a/server.js b/server.js
index c047a49..c126fb1 100644
--- a/server.js
+++ b/server.js
@@ -36,6 +36,16 @@ app.use((req, res, next) => {
   }
   next();
 });
+// 404-guard — snapshot/backup files must never serve, even if dropped into a
+// static root. Blocks any path containing .bak or .pre- (e.g. server.js.bak,
+// foo.bak.123, index.html.pre-deploy).
+app.use((req, res, next) => {
+  if (/\.bak(\.|$|\/)|\.pre-/i.test(req.path)) {
+    return res.status(404).type('text/plain').send('Not found');
+  }
+  next();
+});
+
 const NORM_TYPE = (t) => {
   if (!t) return 'Other';
   const x = t.replace(/s$/i, '').trim();
diff --git a/server.js.bak.nshdr.1778214775073 b/server.js.bak.nshdr.1778214775073
deleted file mode 100644
index 93b88a0..0000000
--- a/server.js.bak.nshdr.1778214775073
+++ /dev/null
@@ -1,543 +0,0 @@
-/**
- * hollywoodwallcoverings.com — Hollywood Wallcoverings storefront.
- * Reads products.json, exposes search/filter/pagination, links memo-sample CTA
- * back to the DW Shopify product page (#sample anchor).
- */
-const express = require('express');
-const helmet = require('helmet');
-const path = require('path');
-const fs = require('fs');
-
-const PORT = process.env.PORT || 9832;
-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 DW_SHOPIFY = 'https://designerwallcoverings.com';
-
-const app = express();
-
-// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
-app.use(helmet({ contentSecurityPolicy: false }));
-
-// Disable Cloudflare HTML caching — origin must say no-store so edits propagate
-// immediately. CF respects no-store; max-age=0 alone is still cacheable.
-// (added 2026-05-07 fleet cache-header sweep)
-app.use((req, res, next) => {
-  if (!req.path.startsWith('/api/')) {
-    res.set('Cache-Control', 'no-store, must-revalidate');
-  }
-  next();
-});
-const NORM_TYPE = (t) => {
-  if (!t) return 'Other';
-  const x = t.replace(/s$/i, '').trim();
-  if (/wallcovering/i.test(x)) return 'Wallcovering';
-  if (/natural/i.test(x)) return 'Natural';
-  if (/drapery/i.test(x)) return 'Drapery';
-  if (/fabric/i.test(x)) return 'Fabric';
-  return x;
-};
-
-// Junk filter — drop products that don't belong on this storefront
-function isJunk(p) {
-  if (!p.image_url || !p.image_url.trim()) return true;
-  if (!p.dw_sku || !p.dw_sku.trim()) return true;
-  if (/^\d{13,}$/.test(p.dw_sku)) return true; // variant_id leak
-  if (/lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine/i.test(p.title || '')) return true;
-  const t = p.product_type || '';
-  if (!/wallcovering|fabric/i.test(t)) return true;
-  return false;
-}
-
-const DATA = DATA_RAW.filter(p => !isJunk(p));
-const DROPPED = DATA_RAW.length - DATA.length;
-console.log(`Loaded ${DATA_RAW.length} products, kept ${DATA.length}, dropped ${DROPPED} as junk`);
-
-const TYPES = [...new Set(DATA.map(p => NORM_TYPE(p.product_type)))].sort();
-
-const esc = s => String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
-
-function paginate(arr, page, perPage = 60) {
-  const total = arr.length;
-  const pages = Math.max(1, Math.ceil(total / perPage));
-  const p = Math.min(Math.max(1, page), pages);
-  return { items: arr.slice((p-1)*perPage, p*perPage), page: p, pages, total };
-}
-
-// 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 page = parseInt(req.query.page) || 1;
-  let filtered = DATA;
-  if (type) filtered = filtered.filter(p => NORM_TYPE(p.product_type) === type);
-  if (q) {
-    const tokens = q.split(/\s+/);
-    filtered = filtered.filter(p => {
-      const hay = `${p.title} ${p.dw_sku||''} ${p.mfr_sku||''}`.toLowerCase();
-      return tokens.every(t => hay.includes(t));
-    });
-  }
-  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 };
-}
-
-function shopifyUrl(handle) {
-  return handle ? `${DW_SHOPIFY}/products/${handle}` : DW_SHOPIFY;
-}
-
-function memoSampleUrl(handle) {
-  return handle ? `${DW_SHOPIFY}/products/${handle}#sample` : DW_SHOPIFY;
-}
-
-// Hollywood = cinematic, brass + ivory + deep noir. Old-Hollywood glamour tone.
-const layout = (title, body, { q='', type='' } = {}) => `<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<title>${esc(title)} — Hollywood Wallcoverings</title>
-<meta name="description" content="Hollywood Wallcoverings — commercial-grade Type II vinyls, naturals, and fabrics for interior designers. Free memo samples and trade-only pricing.">
-<meta property="og:title" content="${esc(title)} · Hollywood Wallcoverings">
-<meta property="og:description" content="Old-Hollywood glamour for the modern interior. Free memo samples · trade pricing · hand-selected for designers.">
-<style>
-  :root {
-    --bg:#0e0a06;
-    --bg-card:#1a130c;
-    --bg-soft:#241a10;
-    --fg:#f6efe1;
-    --muted:#a89880;
-    --rule:#3a2c1d;
-    --accent:#c9a865;       /* brass */
-    --accent-soft:#e0c896;  /* champagne */
-    --accent-dim:#7d6231;
-    --serif:'Cormorant Garamond', 'Playfair Display', Georgia, serif;
-    --display:'Cinzel', 'Cormorant Garamond', Georgia, serif;
-    --sans: -apple-system, 'Inter', 'Segoe UI', system-ui, sans-serif;
-  }
-  *{box-sizing:border-box;margin:0;padding:0}
-  html,body{font-family:var(--sans);background:var(--bg);color:var(--fg);line-height:1.5}
-  a{color:inherit;text-decoration:none}
-  header.top{padding:32px 32px 24px;border-bottom:1px solid var(--rule);background:linear-gradient(180deg, #1a130c 0%, #0e0a06 100%);text-align:center;position:relative}
-  .hamburger{position:absolute;top:32px;right:32px;background:none;border:1px solid var(--accent-dim);color:var(--accent);width:40px;height:40px;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;transition:border-color .15s, background .15s}
-  .hamburger:hover{border-color:var(--accent);background:rgba(201,168,101,.08)}
-  .hamburger span{display:block;width:18px;height:1.5px;background:var(--accent);transition:transform .25s, opacity .25s}
-  .hamburger.open span:nth-child(1){transform:translateY(5.5px) rotate(45deg)}
-  .hamburger.open span:nth-child(2){opacity:0}
-  .hamburger.open span:nth-child(3){transform:translateY(-5.5px) rotate(-45deg)}
-  .menu-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:90;opacity:0;pointer-events:none;transition:opacity .25s}
-  .menu-overlay.open{opacity:1;pointer-events:auto}
-  .menu-panel{position:fixed;top:0;right:0;height:100vh;width:360px;max-width:90vw;background:var(--bg-card);border-left:1px solid var(--rule);z-index:100;transform:translateX(100%);transition:transform .3s ease;padding:80px 36px 36px;overflow-y:auto}
-  .menu-panel.open{transform:translateX(0)}
-  .menu-panel h3{font-family:var(--display);font-size:14px;letter-spacing:.24em;color:var(--accent);text-transform:uppercase;margin-bottom:28px;font-weight:600}
-  .menu-panel ul{list-style:none;padding:0;margin:0}
-  .menu-panel li{margin-bottom:0;border-bottom:1px solid var(--rule)}
-  .menu-panel li:last-child{border-bottom:none}
-  .menu-panel a{display:block;padding:16px 0;font-family:var(--serif);font-size:20px;color:var(--fg);transition:color .15s,padding-left .15s}
-  .menu-panel a:hover{color:var(--accent);padding-left:8px}
-  .menu-panel .menu-foot{margin-top:48px;padding-top:24px;border-top:1px solid var(--rule);font-size:11px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase;line-height:1.8}
-  .menu-panel .menu-foot a{font-family:var(--sans);font-size:11px;display:inline;color:var(--accent-soft);padding:0}
-  .brand{font-family:var(--display);font-weight:600;font-size:38px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}
-  .brand small{display:block;font-family:var(--sans);font-size:11px;letter-spacing:.32em;font-weight:500;color:var(--muted);margin-top:6px;text-transform:uppercase}
-  nav.types{display:flex;gap:22px;padding:14px 32px;background:var(--bg-card);border-bottom:1px solid var(--rule);flex-wrap:wrap;align-items:center}
-  nav.types a{font-size:12px;font-weight:500;color:var(--muted);padding:6px 0;border-bottom:2px solid transparent;letter-spacing:.14em;text-transform:uppercase;transition:color .15s, border-color .15s}
-  nav.types a:hover{color:var(--accent-soft)}
-  nav.types a.active{color:var(--accent);border-bottom-color:var(--accent)}
-  form.search{margin-left:auto;display:flex;gap:8px;align-items:center}
-  form.search input{border:1px solid var(--rule);background:var(--bg);padding:9px 14px;border-radius:0;min-width:260px;font:inherit;font-size:13px;outline:none;color:var(--fg);font-family:var(--sans)}
-  form.search input:focus{border-color:var(--accent)}
-  form.search input::placeholder{color:var(--muted);font-style:italic}
-  form.search button{border:1px solid var(--accent);background:transparent;color:var(--muted, var(--accent));padding:9px 18px;font:inherit;font-size:11px;text-transform:uppercase;letter-spacing:.16em;font-weight:600;cursor:pointer;transition:background .15s, color .15s}
-  form.search button:hover{background:var(--accent);color:var(--bg)}
-  main{max-width:1400px;margin:0 auto;padding:36px 32px}
-  .controls-bar{display:flex;align-items:center;gap:18px;margin-bottom:18px;flex-wrap:wrap;justify-content:flex-end}
-  .controls-bar label{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500}
-  .controls-bar select{background:transparent;border:1px solid var(--rule);color:var(--fg);padding:7px 12px;font:inherit;font-size:12px;letter-spacing:.10em;text-transform:uppercase;cursor:pointer;outline:none}
-  .controls-bar select:focus{border-color:var(--accent)}
-  .controls-bar input[type=range]{accent-color:var(--accent);width:140px;cursor:pointer}
-  .meta-line{font-size:11px;color:var(--muted);margin-bottom:24px;letter-spacing:.16em;text-transform:uppercase;text-align:center;font-weight:500}
-  .meta-line strong{color:var(--accent-soft);font-weight:600}
-  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:28px}
-  .card{background:var(--bg-card);border:1px solid var(--rule);transition:box-shadow .2s, transform .15s, border-color .2s;overflow:hidden}
-  .card:hover{box-shadow:0 12px 40px rgba(0,0,0,.5), 0 0 0 1px var(--accent-dim);transform:translateY(-3px);border-color:var(--accent-dim)}
-  .card a.thumb{display:block;aspect-ratio:1/1;background:#0a0703;overflow:hidden;position:relative}
-  .card a.thumb img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .4s ease}
-  .card:hover a.thumb img{transform:scale(1.04)}
-  .card .info{padding:16px 16px 14px}
-  .card .info .t{font-family:var(--serif);font-size:18px;font-weight:500;line-height:1.25;margin-bottom:6px;color:var(--fg)}
-  .card .info .sub{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em}
-  .card .actions{display:flex;border-top:1px solid var(--rule)}
-  .card .actions a{flex:1;padding:11px 12px;text-align:center;font-size:10px;text-transform:uppercase;letter-spacing:.14em;font-weight:500;color:var(--muted);transition:all .15s}
-  .card .actions a + a{border-left:1px solid var(--rule)}
-  .card .actions a:hover{background:var(--bg-soft);color:var(--accent-soft)}
-  .card .actions a.cta{color:var(--accent);font-weight:600}
-  .card .actions a.cta:hover{background:var(--accent);color:var(--bg)}
-  .pager{display:flex;justify-content:center;gap:8px;margin-top:56px}
-  .pager a, .pager span{padding:9px 16px;border:1px solid var(--rule);background:var(--bg-card);font-size:12px;min-width:44px;text-align:center;letter-spacing:.06em}
-  .pager a:hover{border-color:var(--accent);color:var(--accent)}
-  .pager .cur{background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:600}
-  .empty{padding:80px 20px;text-align:center;color:var(--muted);font-style:italic}
-  footer{padding:48px 32px;border-top:1px solid var(--rule);background:var(--bg-card);color:var(--muted);font-size:11px;letter-spacing:.14em;text-align:center;margin-top:96px;text-transform:uppercase}
-  footer a{color:var(--accent)}
-  footer a:hover{color:var(--accent-soft)}
-  .footer-tag{font-family:var(--serif);font-style:italic;text-transform:none;letter-spacing:.02em;font-size:14px;color:var(--accent-soft);margin-bottom:14px;display:block}
-  /* Detail page */
-  .detail{display:grid;grid-template-columns:1.2fr 1fr;gap:56px;padding:56px 32px;max-width:1400px;margin:0 auto}
-  .detail .hero{background:#0a0703;aspect-ratio:1/1;border:1px solid var(--rule)}
-  .detail .hero img{width:100%;height:100%;object-fit:cover;display:block}
-  .detail h1{font-family:var(--serif);font-size:52px;font-weight:500;line-height:1.05;margin-bottom:12px;color:var(--fg)}
-  .detail .crumb{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:18px}
-  .detail .crumb a:hover{color:var(--accent)}
-  .detail .pt{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);font-weight:600;margin-bottom:36px}
-  .detail .row{display:flex;gap:24px;padding:16px 0;border-bottom:1px solid var(--rule);font-size:13px}
-  .detail .row .k{flex:0 0 140px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-size:11px;font-weight:500}
-  .detail .actions{display:flex;gap:12px;margin-top:36px;flex-wrap:wrap}
-  .detail .actions a{padding:14px 28px;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:600}
-  .detail .btn-primary{background:var(--accent);color:var(--bg);border:1px solid var(--accent)}
-  .detail .btn-primary:hover{background:var(--accent-soft);border-color:var(--accent-soft)}
-  .detail .btn-secondary{border:1px solid var(--accent);color:var(--accent)}
-  .detail .btn-secondary:hover{background:var(--accent);color:var(--bg)}
-  @media (max-width:780px){
-    .detail{grid-template-columns:1fr;padding:32px 20px;gap:32px}
-    .detail h1{font-size:36px}
-    .brand{font-size:28px;letter-spacing:.14em}
-    nav.types{padding:12px 20px;gap:14px}
-    form.search{margin-left:0;width:100%;margin-top:8px}
-    form.search input{min-width:0;flex:1}
-  }
-</style>
-<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=Cinzel:wght@500;600;700&family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
-<!-- Google Analytics 4 (GA4) -->
-<script async src="https://www.googletagmanager.com/gtag/js?id=G-JBB1K6YNJ3"></script>
-<script>
-  window.dataLayer = window.dataLayer || [];
-  function gtag(){dataLayer.push(arguments);}
-  gtag('js', new Date());
-  gtag('config', 'G-JBB1K6YNJ3');
-</script>
-<!-- /Google Analytics 4 -->
-</head>
-<body>
-<header class="top">
-  <a href="/" class="brand">Hollywood Wallcoverings<small>Old-Hollywood Glamour for the Modern Interior</small></a>
-  <button class="hamburger" id="hamburger" aria-label="Open menu" onclick="toggleMenu()"><span></span><span></span><span></span></button>
-</header>
-<div class="menu-overlay" id="menu-overlay" onclick="toggleMenu()"></div>
-<aside class="menu-panel" id="menu-panel" aria-hidden="true">
-  <h3>Menu</h3>
-  <ul>
-    <li><a href="/">All Products</a></li>
-    <li><a href="/?type=Wallcovering">Wallcoverings</a></li>
-    <li><a href="/?type=Fabric">Fabrics</a></li>
-    <li><a href="/about">About Us</a></li>
-    <li><a href="/samples">How Memo Samples Work</a></li>
-    <li><a href="/trade">Trade Pricing</a></li>
-    <li><a href="/contact">Contact Us</a></li>
-    <li><a href="${DW_SHOPIFY}" target="_blank" rel="noopener">Shop the Full DW Catalog ↗</a></li>
-  </ul>
-  <div class="menu-foot">
-    A private collection by<br>
-    <a href="${DW_SHOPIFY}">designerwallcoverings.com</a><br><br>
-    Free memo samples · Trade pricing
-  </div>
-</aside>
-<script>
-function toggleMenu(){
-  const h=document.getElementById('hamburger');
-  const o=document.getElementById('menu-overlay');
-  const p=document.getElementById('menu-panel');
-  const open=p.classList.toggle('open');
-  o.classList.toggle('open',open);
-  h.classList.toggle('open',open);
-  p.setAttribute('aria-hidden',open?'false':'true');
-  document.body.style.overflow=open?'hidden':'';
-}
-document.addEventListener('keydown',e=>{if(e.key==='Escape'&&document.getElementById('menu-panel').classList.contains('open'))toggleMenu();});
-</script>
-<nav class="types">
-  <a href="/" ${!type ? 'class="active"' : ''}>All</a>
-  ${TYPES.map(t => `<a href="/?type=${encodeURIComponent(t)}" ${type===t ? 'class="active"' : ''}>${esc(t)}</a>`).join('')}
-  <form class="search" method="GET" action="/">
-    <input name="q" value="${esc(q)}" placeholder="Search ${DATA.length.toLocaleString()} SKUs…">
-    ${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}
-    <button>Search</button>
-  </form>
-</nav>
-${body}
-<footer>
-  <span class="footer-tag">A private collection · Old-Hollywood specialists</span>
-  Free memo samples · Trade pricing · Hand-selected
-  <div style="margin-top:10px;font-size:11px;letter-spacing:.14em"><a href="mailto:info@hollywoodwallcoverings.com">info@hollywoodwallcoverings.com</a></div>
-  <div style="margin-top:14px;font-size:10px;letter-spacing:.18em">© ${new Date().getFullYear()} Hollywood Wallcoverings · All rights reserved</div>
-</footer>
-</body>
-</html>`;
-
-function cardHtml(p) {
-  const cleanTitle = (p.title || '').replace(/\s*\|\s*Hollywood Wallcoverings\s*$/i,'');
-  return `<article class="card">
-    <a class="thumb" href="/p/${esc(p.handle||'')}">
-      ${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(cleanTitle)}">` : ''}
-    </a>
-    <div class="info">
-      <a href="/p/${esc(p.handle||'')}">
-        <div class="t">${esc(cleanTitle)}</div>
-        <div class="sub">${esc(p.dw_sku || '')} · ${esc(NORM_TYPE(p.product_type))}</div>
-      </a>
-    </div>
-    <div class="actions">
-      <a href="/p/${esc(p.handle||'')}">View</a>
-      <a class="cta" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener">Free Sample</a>
-    </div>
-  </article>`;
-}
-
-// JSON page endpoint for infinite scroll
-app.get('/api/page', (req, res) => {
-  const { items, page, pages, total } = filterAndPaginate(req, 48);
-  res.json({ html: items.map(cardHtml).join(''), page, pages, total, hasMore: page < pages });
-});
-
-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 cards = items.map(cardHtml).join('');
-
-  const body = `
-    <main>
-      <div class="meta-line">
-        ${q || type ? `Showing <strong>${total.toLocaleString()}</strong> ${type ? `<strong>${esc(type)}</strong>` : ''} ${q ? `matching "<strong>${esc(q)}</strong>"` : ''}` : `<strong>${total.toLocaleString()}</strong> products available · free memo samples`}
-      </div>
-      ${items.length === 0 ? '' : `
-      <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>
-        </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>
-    </main>
-    <script>
-    (function(){
-      const grid=document.getElementById('grid');
-      const lm=document.getElementById('loadmore');
-      if(!grid||!lm)return;
-      let page=parseInt(lm.dataset.page);
-      const pages=parseInt(lm.dataset.pages);
-      let loading=false;
-      const q=lm.dataset.q;
-      const type=lm.dataset.type;
-      async function loadNext(){
-        if(loading||page>=pages)return;
-        loading=true;
-        page++;
-        const params=new URLSearchParams({page});
-        if(q)params.set('q',q);
-        if(type)params.set('type',type);
-        try{
-          const r=await fetch('/api/page?'+params.toString());
-          const d=await r.json();
-          grid.insertAdjacentHTML('beforeend',d.html);
-          if(!d.hasMore){lm.textContent='— end —';lm.style.display='block';}
-        }catch(e){lm.textContent='Failed to load. Scroll up and refresh.';}
-        loading=false;
-      }
-      const io=new IntersectionObserver(es=>{es.forEach(e=>{if(e.isIntersecting)loadNext();});},{rootMargin:'400px'});
-      io.observe(lm);
-
-      // Sort + density controls (client-side, persisted)
-      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));});
-      }
-      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));});
-      }
-    })();
-    </script>
-  `;
-  res.set('Content-Type','text/html').send(layout(`${type || 'All Products'}${q ? ` · ${q}` : ''}`, body, { q, type }));
-});
-
-app.get('/p/:handle', (req, res) => {
-  const p = DATA.find(x => x.handle === req.params.handle);
-  if (!p) return res.status(404).set('Content-Type','text/html').send(layout('Not found', '<main><div class="empty">Product not found.</div></main>'));
-
-  const cleanTitle = p.title.replace(/\s*\|\s*Hollywood Wallcoverings\s*$/i,'');
-  const body = `
-    <div class="detail">
-      <div class="hero">${p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(cleanTitle)}">` : ''}</div>
-      <div>
-        <div class="crumb"><a href="/">Hollywood Wallcoverings</a> / ${esc(NORM_TYPE(p.product_type))}</div>
-        <div class="pt">${esc(NORM_TYPE(p.product_type))}</div>
-        <h1>${esc(cleanTitle)}</h1>
-        <div class="row"><div class="k">SKU</div><div>${esc(p.dw_sku || '—')}</div></div>
-        <div class="row"><div class="k">Type</div><div>${esc(p.product_type)}</div></div>
-        <div class="actions">
-          <a class="btn-primary" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener">Order Free Memo</a>
-          <a class="btn-secondary" href="${esc(shopifyUrl(p.handle))}" target="_blank" rel="noopener">Shop on DW</a>
-        </div>
-      </div>
-    </div>
-  `;
-  res.set('Content-Type','text/html').send(layout(cleanTitle, body));
-});
-
-// Content pages
-const PAGE_STYLE = `<style>
-  .page-wrap{max-width:760px;margin:0 auto;padding:64px 32px 96px}
-  .page-wrap .eyebrow{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--accent);margin-bottom:14px;font-weight:600}
-  .page-wrap h1{font-family:var(--display);font-size:42px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);margin-bottom:8px;line-height:1.1}
-  .page-wrap .lede{font-family:var(--serif);font-style:italic;font-size:20px;color:var(--accent-soft);margin-bottom:36px;line-height:1.5}
-  .page-wrap p, .page-wrap li{font-size:15px;line-height:1.75;color:var(--fg);margin-bottom:18px}
-  .page-wrap h2{font-family:var(--serif);font-size:28px;color:var(--accent-soft);margin:40px 0 14px;font-weight:500}
-  .page-wrap ul{padding-left:20px;margin-bottom:24px}
-  .page-wrap a.cta-btn{display:inline-block;padding:14px 32px;background:var(--accent);color:var(--bg);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:600;margin-top:16px}
-  .page-wrap a.cta-btn:hover{background:var(--accent-soft)}
-  .page-wrap .contact-grid{display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-top:32px}
-  .page-wrap .contact-grid .label{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
-  .page-wrap .contact-grid .val{font-family:var(--serif);font-size:20px;color:var(--accent-soft)}
-  @media (max-width:640px){.page-wrap{padding:36px 20px}.page-wrap h1{font-size:32px}.page-wrap .contact-grid{grid-template-columns:1fr}}
-</style>`;
-
-app.get('/about', (_req, res) => {
-  const body = `${PAGE_STYLE}<div class="page-wrap">
-    <div class="eyebrow">Our Story</div>
-    <h1>About Hollywood Wallcoverings</h1>
-    <p class="lede">A curated collection of Type II commercial vinyls, naturals, and fabrics — assembled with the eye of a Hollywood costume director and the rigor of a hospitality designer.</p>
-    <p>Hollywood Wallcoverings is a private label of <a href="${DW_SHOPIFY}" style="color:var(--accent)">Designer Wallcoverings</a>, the trade-first source serving interior designers, hospitality firms, and contract specifiers across North America since 2008.</p>
-    <p>Every pattern in this collection has been hand-selected for one of three rooms: a quiet hotel corridor, a glittering powder room, or a backstage greenroom that needs to look the part on camera. The aesthetic vocabulary is borrowed from the great studios — RKO, Warner, Paramount — and translated into surfaces that actually live well: scrubbable, fire-rated, and engineered for fifteen-year installations.</p>
-    <h2>What we make easy</h2>
-    <ul>
-      <li><strong>Free memo samples</strong> on every product, hand-cut and shipped same-day from our Los Angeles warehouse.</li>
-      <li><strong>Trade pricing</strong> for any verified design firm, hospitality buyer, or commercial specifier.</li>
-      <li><strong>Type II compliance</strong> — every wallcovering meets ASTM F793 Class A fire-rated standards for commercial install.</li>
-      <li><strong>Hand-holding</strong> — a real person walks you from sample request through final yardage, including custom roll-cutting and field consultation.</li>
-    </ul>
-    <a class="cta-btn" href="/contact">Speak with a Specialist</a>
-  </div>`;
-  res.set('Content-Type','text/html').send(layout('About Us', body));
-});
-
-app.get('/contact', (_req, res) => {
-  const body = `${PAGE_STYLE}<div class="page-wrap">
-    <div class="eyebrow">Speak With a Specialist</div>
-    <h1>Contact Us</h1>
-    <p class="lede">A real person handles every inquiry. No form-fill purgatory, no auto-responder loops.</p>
-    <div class="contact-grid">
-      <div>
-        <div class="label">Trade Sales</div>
-        <div class="val"><a href="mailto:trade@designerwallcoverings.com" style="color:var(--accent-soft)">trade@designerwallcoverings.com</a></div>
-      </div>
-      <div>
-        <div class="label">General Inquiries</div>
-        <div class="val"><a href="mailto:hello@designerwallcoverings.com" style="color:var(--accent-soft)">hello@designerwallcoverings.com</a></div>
-      </div>
-      <div>
-        <div class="label">Phone</div>
-        <div class="val"><a href="tel:+18889991234" style="color:var(--accent-soft)">(888) 999-1234</a></div>
-      </div>
-      <div>
-        <div class="label">Hours</div>
-        <div class="val">Mon–Fri · 9–6 PT</div>
-      </div>
-    </div>
-    <h2>For trade verification</h2>
-    <p>If you're a designer, architect, or commercial specifier, send us your business license, DBA, or design-firm credentials and we'll set up trade pricing within one business day.</p>
-    <h2>For sample requests</h2>
-    <p>Click <strong>Free Sample</strong> on any product and we ship same-day. No account required, no quantity limits, never charged.</p>
-  </div>`;
-  res.set('Content-Type','text/html').send(layout('Contact Us', body));
-});
-
-app.get('/samples', (_req, res) => {
-  const body = `${PAGE_STYLE}<div class="page-wrap">
-    <div class="eyebrow">Memo Samples · Free, Always</div>
-    <h1>How Memo Samples Work</h1>
-    <p class="lede">A 9″×12″ swatch in your hands — the only honest way to specify a wallcovering for a real space.</p>
-    <h2>Step one — request</h2>
-    <p>Click <strong>Free Sample</strong> on any product card. You'll be handed off to the Designer Wallcoverings checkout where the sample variant is pre-selected at $0.</p>
-    <h2>Step two — receive</h2>
-    <p>Samples ship same-day from our Los Angeles warehouse via USPS first-class. Most designers receive within 2–4 business days; expedited options available at checkout for next-day delivery.</p>
-    <h2>Step three — specify</h2>
-    <p>Tape the swatch to your client's wall. Watch it through morning, afternoon, and evening light. Then come back and order yardage — we'll have the exact roll batch on file from your sample so the production order matches what you saw.</p>
-    <h2>Trade designers, contract specifiers</h2>
-    <p>Bulk sample-binders, custom labeling, and dye-lot reservations are available for trade accounts. <a href="/contact" style="color:var(--accent)">Talk to us</a> if you need a curated set for a board.</p>
-    <a class="cta-btn" href="/">Browse Patterns</a>
-  </div>`;
-  res.set('Content-Type','text/html').send(layout('How Memo Samples Work', body));
-});
-
-app.get('/trade', (_req, res) => {
-  const body = `${PAGE_STYLE}<div class="page-wrap">
-    <div class="eyebrow">For Designers, Architects, Commercial Specifiers</div>
-    <h1>Trade Pricing</h1>
-    <p class="lede">Verified trade accounts receive net pricing on the entire Hollywood Wallcoverings collection — typically 30–50% off retail, with volume tiers above 50 yards.</p>
-    <h2>Who qualifies</h2>
-    <ul>
-      <li>Licensed interior designers (resale or business license)</li>
-      <li>Architecture firms and design–build studios</li>
-      <li>Hospitality groups, hotel operators, restaurant owner-operators</li>
-      <li>Commercial specifiers, FF&E firms, project management groups</li>
-      <li>Set decorators, production designers, scenic artists</li>
-    </ul>
-    <h2>What you get</h2>
-    <ul>
-      <li>Net pricing on every product, applied at checkout when logged in</li>
-      <li>Volume breaks at 50 / 100 / 250 yard tiers</li>
-      <li>Custom roll-cutting and dye-lot reservations on request</li>
-      <li>A direct line to a trade-experienced sales rep — no chat-bot front door</li>
-      <li>Quick-turn quotes for RFP-stage specifications</li>
-    </ul>
-    <h2>How to apply</h2>
-    <p>Email a copy of your business license, DBA, or active project list to <a href="mailto:trade@designerwallcoverings.com" style="color:var(--accent)">trade@designerwallcoverings.com</a>. Most accounts are approved within one business day.</p>
-    <a class="cta-btn" href="/contact">Apply for Trade Account</a>
-  </div>`;
-  res.set('Content-Type','text/html').send(layout('Trade Pricing', body));
-});
-
-app.get('/health', (_req, res) => res.json({ ok: true, products: DATA.length, dropped: DROPPED, types: TYPES }));
-
-const BIND = process.env.BIND || '127.0.0.1';
-app.listen(PORT, BIND, () => console.log(`Hollywood Wallcoverings on http://${BIND}:${PORT} · ${DATA.length} products`));

← 623f740 Server-side grid sort: honor ?sort param across all infinite  ·  back to Hollywood Wallcoverings  ·  Add noreferrer to all target=_blank external links 26ab45f →