← back to Hollywood Wallcoverings

server.js

1062 lines

/**
 * 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).
 */
try { require('dotenv').config({ path: require('path').join(__dirname, '.env') }); } catch (e) {}
const express = require('express');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
const { createImgProxy } = require('../_shared/image-proxy');
const IMGP = createImgProxy();

/* Vendor-name leak scrub (audit 2026-05-29) — strip any hyphen-delimited segment
 * carrying a 3rd-party vendor token from a slug/handle shown in this storefront's
 * UI (the /p/<slug> hrefs + visible-sku handle fallback). "Hollywood" is a DW
 * HOUSE BRAND and is NOT in the denylist, so it passes through untouched. The
 * REAL p.handle is kept for the main-store memo-sample / Shop-on-DW links (they
 * legitimately point at designerwallcoverings.com/products/<real-handle>). The
 * /p/ route resolver is dual-keyed so both the real handle and the redacted slug
 * resolve. Longer vendor names precede their prefixes ("lee jofa modern" before
 * "lee jofa") so the longer match strips first. Verbatim from
 * dw-domain-fleet/shared/render.js @ ee30c6a. */
const VENDORS_DENYLIST = [
  'wolf gordon', 'nina campbell', 'jeffrey stevens', 'versace',
  'arte international', 'lee jofa modern', 'lee jofa', 'innovations usa',
  'innovations', 'koroseal', 'schumacher', 'candice olson', 'thibaut',
  'maya romanoff', 'scalamandre', 'dedar', 'carnegie', 'cole and son',
  'kravet', 'clarke and clarke', 'brunschwig', 'gp j baker', 'sandberg',
  'designers guild', 'graham and brown', 'harlequin', 'andrew martin',
  'ronald redding', 'blithfield', 'coordonne', 'fentucci', 'sister parish',
  'phillip jeffries', 'fromental', 'westport', 'breegan', 'les ensembliers',
  'roger thomas', 'stacy garcia', 'de gournay', 'romo', 'farrow and ball',
  'colefax and fowler', 'sanderson', 'morris and co', 'osborne and little',
];
const VENDOR_SLUG_RES = VENDORS_DENYLIST.map(v => {
  const s = String(v).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
  return new RegExp('(^|-)' + s + '(?=-|$)', 'gi');
});
function redactVendorSlug(slug) {
  let out = String(slug == null ? '' : slug);
  for (let i = 0; i < VENDOR_SLUG_RES.length; i++) out = out.replace(VENDOR_SLUG_RES[i], '$1');
  out = out.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
  return out || String(slug == null ? '' : slug);   // never empty (route key must be non-empty)
}

const PORT = process.env.PORT || 9832;
const __SITE = path.basename(__dirname);
// Admin catalog (PG-backed) is opt-in via MICROSITE_ADMIN_ENABLED=true. Prod
// stays static-only (data/products.json) so Kamatera doesn't need `pg` or the
// _shared/ tree. Dev (Mac2) flips the flag on to use dw_unified + /admin/catalog.
const ADMIN_ENABLED = process.env.MICROSITE_ADMIN_ENABLED === 'true';
let catalog = null;
if (ADMIN_ENABLED) {
  try { catalog = require('../_shared/admin-catalog'); }
  catch (e) { console.error(`[${__SITE}] admin-catalog unavailable (${e.message}) — falling back to static JSON`); }
}
const siteCfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
const SITE_SLUG = siteCfg.slug || __SITE;            // slug ≠ dir name (hollywoodwallcoverings)
const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];

// Catalog now lives in dw_unified.microsite_products. The shared module returns
// rows shaped { sku, handle, title, vendor, product_type, image_url, tags, ... };
// this site's render code expects { dw_sku, mfr_sku, color, ... } — normalize so
// the existing junk filter, sort, search, and templates keep working untouched.
function normalizeRow(r) {
  if (!r || typeof r !== 'object') return r;
  return {
    ...r,
    dw_sku: r.dw_sku || r.sku || r.handle || '',
    mfr_sku: r.mfr_sku || '',
    color: r.color || (Array.isArray(r.tags) ? r.tags.join(' ') : ''),
  };
}

// Synchronous JSON read kept as a fallback only — used if the PG load fails.
function loadFromJson() {
  try {
    const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
    if (!Array.isArray(raw)) throw new Error('products.json must be an array');
    return raw;
  } catch (e) {
    console.error(`[${__SITE}] products.json fallback failed — ${e.message}`);
    return [];
  }
}

let DATA_RAW = [];
const DW_SHOPIFY = 'https://designerwallcoverings.com';

const app = express();


app.get(['/favicon.ico','/favicon.svg'], (req, res) => res.sendFile(require('path').join(__dirname, 'public', req.path.slice(1))));
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));

// Custom Creator — made-to-measure murals at /CustomCreator (Hollywood-branded,
// uses the SHOPIFY_* tokens already in this process env; no new secrets).
try { require('./customcreator').mount(app, express); }
catch (e) { console.error(`[${__SITE}] CustomCreator mount failed (${e.message})`); }

// 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();
});
// 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();
});

// Vendor-neutral image proxy — streams real CDN bytes via /img/:token so the
// 3rd-party-vendor-named CDN filenames never reach customer-facing HTML/JSON.
IMGP.mount(app);

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;
}

// Populated by the async startup path at the bottom of this file. Kept as `let`
// (was `const`) so the PG load can fill them before app.listen().
let DATA = [];
let DROPPED = 0;
let TYPES = [];

function rebuildCatalog(rawRows) {
  DATA_RAW = rawRows.map(normalizeRow);
  DATA = DATA_RAW.filter(p => !isJunk(p));
  DROPPED = DATA_RAW.length - DATA.length;
  TYPES = [...new Set(DATA.map(p => NORM_TYPE(p.product_type)))].sort();
  IMGP.registerAll(DATA);
  console.log(`[${__SITE}] catalog: ${DATA_RAW.length} rows, kept ${DATA.length}, dropped ${DROPPED} as junk`);
  console.log(`[${__SITE}] image-proxy map: ${IMGP.size()} urls`);
}

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 };
}

// DW STANDARD: server-side sort so infinite-scroll respects the chosen order
// across ALL pages, not just the first batch of cards loaded in the DOM.
function colorRank(p) {
  const t = `${p.title||''} ${p.color||''}`.toLowerCase();
  if (/(black|noir|onyx|ink)/.test(t)) return 1;
  if (/(white|ivory|cream|paper|alabaster)/.test(t)) return 2;
  if (/(gold|brass|champagne|bronze|amber|tan|caramel|sand)/.test(t)) return 3;
  if (/(silver|grey|gray|platinum|pewter|smoke)/.test(t)) return 4;
  if (/(red|crimson|burgundy|wine|garnet)/.test(t)) return 5;
  if (/(green|emerald|forest|sage|olive|moss)/.test(t)) return 6;
  if (/(blue|navy|cobalt|sapphire|indigo)/.test(t)) return 7;
  if (/(purple|plum|violet|aubergine|amethyst)/.test(t)) return 8;
  return 9;
}
function styleKey(p) {
  const t = `${p.title||''}`.toLowerCase();
  if (/(damask|toile|baroque|rococo|ornate)/.test(t)) return '1';
  if (/(stripe|trellis|geometric)/.test(t)) return '2';
  if (/(floral|botanical|vine|leaf)/.test(t)) return '3';
  if (/(grass|natural|jute|hemp|raffia|linen|cork)/.test(t)) return '4';
  if (/(metallic|foil)/.test(t)) return '5';
  return '9';
}
function sortProducts(list, mode) {
  const titleOf = p => String(p.title || '');
  const skuOf = p => String(p.dw_sku || redactVendorSlug(p.handle) || '');
  if (mode === 'sku')   return [...list].sort((a,b) => skuOf(a).localeCompare(skuOf(b)));
  if (mode === 'title') return [...list].sort((a,b) => titleOf(a).localeCompare(titleOf(b)));
  if (mode === 'color') return [...list].sort((a,b) => colorRank(a) - colorRank(b) || titleOf(a).localeCompare(titleOf(b)));
  if (mode === 'style') return [...list].sort((a,b) => styleKey(a).localeCompare(styleKey(b)) || titleOf(a).localeCompare(titleOf(b)));
  return list; // 'newest' = natural order
}

// JSON endpoint for infinite scroll
function filterAndPaginate(req, perPage = 48) {
  const q = (req.query.q || '').toString().toLowerCase().trim();
  const type = (req.query.type || '').toString();
  const aesthetic = (req.query.aesthetic || '').toString().toLowerCase();
  const sort = (req.query.sort || 'newest').toString();
  const page = parseInt(req.query.page) || 1;
  let filtered = DATA;
  if (type) filtered = filtered.filter(p => NORM_TYPE(p.product_type) === type);
  if (aesthetic) filtered = filtered.filter(p => String(p.aesthetic||'').toLowerCase() === aesthetic);
  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));
    });
  }
  filtered = sortProducts(filtered, sort);
  const pages = Math.max(1, Math.ceil(filtered.length / perPage));
  const p = Math.min(Math.max(1, page), pages);
  return { items: filtered.slice((p-1)*perPage, p*perPage), page: p, pages, total: filtered.length, sort, aesthetic };
}

// Per-rail counts for the pills strip — derived once per request from DATA.
function railCounts() {
  const counts = {};
  for (const p of DATA) {
    const a = String(p.aesthetic||'').toLowerCase();
    if (!a) continue;
    counts[a] = (counts[a] || 0) + 1;
  }
  return counts;
}

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}
  .rails-strip{display:flex;gap:10px;flex-wrap:wrap;padding:18px 0 24px;border-bottom:1px solid var(--rule);margin-bottom:28px}
  .rail-pill{display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border:1px solid var(--rule);background:transparent;color:var(--muted);font-family:var(--sans);font-size:11px;letter-spacing:.16em;text-transform:uppercase;font-weight:500;cursor:pointer;transition:border-color .15s,color .15s,background .15s;border-radius:0}
  .rail-pill:hover{border-color:var(--accent-dim);color:var(--accent-soft)}
  .rail-pill.active{border-color:var(--accent);color:var(--accent);background:rgba(201,168,101,.08)}
  .rail-pill span{font-size:10px;color:var(--accent-dim);font-weight:600}
  .rail-pill.active span{color:var(--accent-soft)}
  .rail-section{margin-bottom:56px}
  .rail-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:18px;padding-bottom:10px;border-bottom:1px solid var(--rule)}
  .rail-title{font-family:var(--display);font-size:22px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);font-weight:600;margin:0}
  .rail-all{font-family:var(--sans);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500;transition:color .15s}
  .rail-all:hover{color:var(--accent-soft)}
  .rail-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px}
  .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.15;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}
    /* ≥44px touch targets on phones */
    .card .actions a{padding:14px 12px}
    .controls-bar select{padding:11px 12px}
  }
</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 -->
  <script src="/corner-nav.js" defer></script>
</head>
<body>
<!-- Theme toggle (theme1 / theme2) — query-param-gated preview surface
     (Steve 2026-05-29). Mirror of dw-domain-fleet shared/render.js productPage()
     block (commit 34fbe0e). No admin auth → security-by-obscurity: ?theme=2
     flips to theme2 and persists in localStorage 'hw-page-theme'. Toggle UI
     hidden until ?theme=N or localStorage opt-in. Shares the hw-page-theme
     localStorage key for cross-site continuity. -->
<script>
(function(){
  var qs = new URLSearchParams(location.search);
  var fromUrl = qs.get('theme');
  var saved = null; try { saved = localStorage.getItem('hw-page-theme'); } catch(e){}
  var theme = null;
  if (fromUrl === '2' || fromUrl === 'theme2') theme = 'theme2';
  else if (fromUrl === '1' || fromUrl === 'theme1') theme = 'theme1';
  else if (saved === 'theme2' || saved === 'theme1') theme = saved;
  if (theme) {
    document.documentElement.setAttribute('data-page-theme', theme);
    if (fromUrl) { try { localStorage.setItem('hw-page-theme', theme); } catch(e){} }
  }
  /* No opt-in → no attribute set, toggle stays display:none (gate works). */
})();
</script>
<style id="page-theme-css-fleet">
  [data-page-theme="theme2"] body{
    --bg:#ffffff !important; --ink:#0a0a0a !important;
    --muted:#5a5a5a !important; --line:#e5e5e5 !important;
    --card-bg:#fafafa !important; --accent:#c14a2e !important;
    background:#fff !important; color:#0a0a0a !important;
  }
  [data-page-theme="theme2"] h1,
  [data-page-theme="theme2"] h2,
  [data-page-theme="theme2"] h3{ letter-spacing:-0.01em; font-weight:500; }
  [data-page-theme="theme2"] .card{
    background:#fafafa !important; border:1px solid #e5e5e5 !important; box-shadow:none !important;
  }
  #page-theme-toggle{ display:none; }
  [data-page-theme] #page-theme-toggle{ display:inline-flex; }
  #page-theme-toggle{
    position:fixed; top:96px; right:14px; z-index:90;
    gap:0; align-items:center;
    background:rgba(255,255,255,.92); border:1px solid #d8d2c5;
    border-radius:999px; padding:3px; backdrop-filter:blur(6px);
    box-shadow:0 2px 10px rgba(0,0,0,.08);
    font:600 11px ui-sans-serif,system-ui; letter-spacing:.06em; text-transform:uppercase;
  }
  #page-theme-toggle button{
    border:0; background:transparent; color:#5a5048; cursor:pointer;
    padding:6px 14px; border-radius:999px; font:inherit; transition:all .15s;
  }
  #page-theme-toggle button[aria-pressed="true"]{ background:#1a1714; color:#faf7f2; }
  #page-theme-toggle button:hover:not([aria-pressed="true"]){ color:#1a1714; }
</style>
<div id="page-theme-toggle" role="group" aria-label="Page theme">
  <button type="button" data-theme="theme1" aria-pressed="false">Theme 1</button>
  <button type="button" data-theme="theme2" aria-pressed="false">Theme 2</button>
</div>
<script>
(function(){
  var cur = document.documentElement.getAttribute('data-page-theme') || 'theme1';
  var btns = document.querySelectorAll('#page-theme-toggle button');
  function sync(t){ btns.forEach(function(b){ b.setAttribute('aria-pressed', b.dataset.theme === t ? 'true' : 'false'); }); }
  sync(cur);
  btns.forEach(function(b){
    b.addEventListener('click', function(){
      var t = b.dataset.theme;
      document.documentElement.setAttribute('data-page-theme', t);
      try { localStorage.setItem('hw-page-theme', t); } catch(e){}
      sync(t);
    });
  });
})();
</script>
<style id="ns-header-modal-styles">
        .ns-header { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; padding: 18px 36px; background: rgba(10,10,10,0.92) !important; backdrop-filter: blur(8px); border-bottom: 1px solid rgba(245,241,232,0.06); }
        .ns-brand { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; }
        .ns-dot { width: 28px; height: 28px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #c9a865 0%, #6f5c38 60%, #3c321e 100%); box-shadow: 0 0 0 1px rgba(245,241,232,0.10), 0 2px 12px rgba(180,90,60,0.30); flex-shrink: 0; }
        .ns-name { font-family: -apple-system, "SF Pro Display", "Helvetica Neue", system-ui, sans-serif; font-size: 18px !important; font-weight: 300 !important; letter-spacing: 0.02em !important; color: #f5f1e8 !important; text-transform: none !important; margin: 0 !important; }
        .ns-name sup { font-size: 0.55em; font-weight: 300; margin-left: 1px; }
        .ns-nav { display: flex; gap: 32px; align-items: center; list-style: none; padding: 0; margin: 0; }
        .ns-nav a, .ns-nav button { font-family: -apple-system, "SF Pro Text", system-ui, sans-serif; font-size: 15px !important; font-weight: 400 !important; color: #f5f1e8 !important; text-decoration: none !important; background: none !important; border: 0 !important; cursor: pointer; padding: 0 !important; transition: color 200ms; letter-spacing: 0 !important; text-transform: none !important; }
        .ns-nav a:hover, .ns-nav button:hover { color: #ffffff !important; }
        @media (max-width: 820px) { .ns-header { padding: 14px 18px; } .ns-nav { gap: 16px; } .ns-nav a, .ns-nav button { font-size: 13px !important; } }
        @media (max-width: 760px) {
          .ns-header { flex-wrap: wrap; }
          .ns-nav { display: none !important; flex-basis: 100%; flex-direction: column; align-items: flex-start; gap: 6px; padding: 14px 0 4px; }
          .ns-nav.open { display: flex !important; }
          .ns-nav a, .ns-nav button { padding: 10px 0 !important; border-bottom: 1px solid rgba(245,241,232,0.08); width: 100%; text-align: left; }
          .ns-nav a:nth-child(n+5), .ns-nav button:nth-child(n+5) { display: block !important; }
          .ns-header .mobile-toggle { display: flex !important; }
        }
        @media (min-width: 761px) {
          .ns-header .mobile-toggle { display: none !important; }
        }
        .ns-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.78); z-index: 200; align-items: center; justify-content: center; padding: 24px; backdrop-filter: blur(6px); }
        .ns-modal.show { display: flex; }
        .ns-modal-card { max-width: 460px; width: 100%; background: #141414 !important; border: 1px solid rgba(245,241,232,0.14); border-radius: 6px; padding: 28px 30px 30px; position: relative; }
        .ns-modal-card .close-x { position: absolute; top: 12px; right: 14px; width: 30px; height: 30px; background: none; border: 1px solid rgba(245,241,232,0.18); color: rgba(245,241,232,0.6) !important; border-radius: 50%; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; }
        .ns-modal-card .close-x:hover { color: #ffffff !important; border-color: #ffffff; }
        .ns-modal-card h3 { font-family: -apple-system, "SF Pro Display", system-ui; font-weight: 300 !important; font-size: 22px !important; margin: 0 0 6px !important; color: #f5f1e8 !important; }
        .ns-modal-card .sub { color: rgba(245,241,232,0.65) !important; font-size: 13px; margin: 0 0 22px; }
        .ns-actions { display: flex; flex-direction: column; gap: 10px; }
        .ns-action { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: rgba(245,241,232,0.04) !important; border: 1px solid rgba(245,241,232,0.12) !important; color: #f5f1e8 !important; text-decoration: none !important; cursor: pointer; transition: all 200ms; font: inherit; text-align: left; }
        .ns-action:hover { background: rgba(245,241,232,0.08) !important; border-color: #d4b683 !important; }
        .ns-action .ico { width: 32px; height: 32px; border-radius: 50%; background: rgba(212,182,131,0.12); display: flex; align-items: center; justify-content: center; font-size: 15px; flex-shrink: 0; }
        .ns-action .txt { flex: 1; }
        .ns-action .txt b { display: block; font-weight: 500; font-size: 14px; color: #f5f1e8 !important; }
        .ns-action .txt span { font-size: 12px; color: rgba(245,241,232,0.55) !important; }

        /* ─── FINAL CONTRAST PASS — kill remaining gold-solid + white-island defects ─── */
        /* Replace all gold-fill accents with off-white (Gucci-aesthetic, no DW gold solids) */
        .new-badge, .new-arrivals-pill,
        .color-filter-btn.active,
        .drapery-btn, .sample-btn-chip, .cb-cta {
          background: #f5f1e8 !important;
          color: #0a0a0a !important;
          border-color: #f5f1e8 !important;
        }
        .new-badge:hover, .new-arrivals-pill:hover,
        .color-filter-btn.active:hover,
        .drapery-btn:hover, .sample-btn-chip:hover, .cb-cta:hover {
          background: #fff !important;
          color: #0a0a0a !important;
        }
        /* Gold accents stay only as edge color (links, glyphs) — not fills */
        a { color: #d4b683; }
        a:hover { color: #f5d97a; }
        /* Authorized-dealer footer line — small caps tracked, muted ink, no gold pill */
        .authorized, .authorized-dealer p {
          background: transparent !important;
          color: rgba(245,241,232,0.5) !important;
          font-size: 10px !important;
          letter-spacing: 0.22em !important;
          text-transform: uppercase !important;
          padding: 0 !important;
          border: none !important;
          margin-top: 14px !important;
        }
        /* Kill any straggler white bg in info-card / sample-related cards */
        .info-card, .info-card-link, .care-highlight, .feature-card, .spec-item {
          background-color: rgba(245,241,232,0.04) !important;
          border-color: rgba(245,241,232,0.12) !important;
        }
        /* Ensure modal content stays readable — Steve's earlier sweep made it #141414 with white text */
        .modal-content { background: #141414 !important; color: #f5f1e8 !important; }
        .modal-content * { color: #f5f1e8 !important; }
        .modal-content input, .modal-content textarea, .modal-content select {
          background: rgba(245,241,232,0.05) !important;
          color: #f5f1e8 !important;
          border-color: rgba(245,241,232,0.2) !important;
        }
        .modal-content label { color: rgba(245,241,232,0.6) !important; }
        /* Force the wide-tracked NOVASUEDE wordmark to stay white */
        .ns-wordmark, .ns-wordmark sup { color: #f5f1e8 !important; }

        /* ─── Modal-internal contrast (post-QA defects) ─── */
        /* Backdrop covers full viewport */
        .modal { width: 100vw !important; height: 100vh !important; left: 0 !important; top: 0 !important; background: rgba(0, 0, 0, 0.78) !important; backdrop-filter: blur(6px); }
        /* Chip — flip from white-on-white island to dark card */
        .request-chip {
          background: rgba(245, 241, 232, 0.06) !important;
          color: #f5f1e8 !important;
          border: 1px solid rgba(245, 241, 232, 0.18) !important;
          border-radius: 4px !important;
        }
        .request-chip * { color: #f5f1e8 !important; }
        .request-chip .chip-swatch {
          /* swatch dot ALWAYS displays the inline-style hex from JS — fight any sweep */
          border: 1px solid rgba(0, 0, 0, 0.4) !important;
        }
        /* + Add to request — hairline white-border on transparent (no gold) */
        .add-to-request-btn {
          background: transparent !important;
          color: #f5f1e8 !important;
          border: 1px solid rgba(245, 241, 232, 0.6) !important;
          border-radius: 0 !important;
          padding: 12px 22px !important;
          font-size: 11px !important;
          letter-spacing: 0.32em !important;
          text-transform: uppercase !important;
          font-weight: 400 !important;
        }
        .add-to-request-btn:hover {
          background: #f5f1e8 !important;
          color: #0a0a0a !important;
          border-color: #f5f1e8 !important;
        }
        /* Hex caption visible */
        .preview-hex, #colorPreviewHex {
          display: block !important;
          color: rgba(245, 241, 232, 0.55) !important;
          font-family: ui-monospace, "SF Mono", Menlo, monospace !important;
          font-size: 11px !important;
          margin-top: 4px !important;
          letter-spacing: 0.04em !important;
          text-transform: lowercase !important;
        }
        /* Color preview name — stays single, prominent */
        #colorPreviewName {
          color: #f5f1e8 !important;
          font-size: 11px !important;
          letter-spacing: 0.32em !important;
          text-transform: uppercase !important;
          font-weight: 500 !important;
          display: block !important;
          margin-top: 12px !important;
        }

        /* ─── Mobile hamburger nav (replaces the overflow at <760px) ─── */
        @media (max-width: 760px) {
          header .nav-container { flex-wrap: wrap; }
          header nav { display: none; flex-basis: 100%; flex-direction: column; gap: 6px; padding: 16px 0 6px; }
          header nav.open { display: flex; }
          header nav a { padding: 10px 0; border-bottom: 1px solid rgba(245,241,232,0.1); }
          header .mobile-toggle {
            display: flex !important;
            flex-direction: column;
            gap: 5px;
            background: transparent;
            border: 0;
            cursor: pointer;
            padding: 8px;
          }
          header .mobile-toggle span {
            width: 22px; height: 1.5px;
            background: #f5f1e8;
            display: block;
          }
        }
        @media (min-width: 761px) {
          header .mobile-toggle { display: none !important; }
        }

    </style>
<header class="ns-header">
        <a href="/" class="ns-brand" aria-label="Home">
            <span class="ns-dot"></span>
            <span class="ns-name">Hollywood Wallcoverings</span>
        </a>
        <nav class="ns-nav" id="nsPrimaryNav" aria-label="Primary">
            <a href="/">Home</a>
            <a href="/CustomCreator">Custom Creator</a>
            <a href="/about">About</a>
            <button type="button" onclick="nsContactOpen()">Contact</button>
        </nav>
        <button type="button" class="mobile-toggle" aria-label="Toggle menu" aria-controls="nsPrimaryNav" aria-expanded="false" onclick="nsToggleNav(this)">
            <span></span><span></span><span></span>
        </button>
    </header>

<div class="ns-modal" id="nsContactModal" role="dialog" aria-modal="true" aria-labelledby="nsContactTitle" onclick="if(event.target===this)nsContactClose()">
        <div class="ns-modal-card">
            <button type="button" class="close-x" onclick="nsContactClose()" aria-label="Close">×</button>
            <h3 id="nsContactTitle">How can we help?</h3>
            <p class="sub">Trade-account replies within one business day.</p>
            <div class="ns-actions">
                <a class="ns-action" href="#contact" onclick="nsContactClose()"><span class="ico">📋</span><div class="txt"><b>Get a Quote</b><span>Project specs → priced estimate</span></div></a>
                <a class="ns-action" href="#contact" onclick="nsContactClose();openModal();"><span class="ico">📦</span><div class="txt"><b>Order a Sample</b><span>Free swatch to your shop</span></div></a>
                <a class="ns-action" href="mailto:info@hollywoodwallcoverings.com?subject=Hollywood%20Wallcoverings%20inquiry"><span class="ico">✉️</span><div class="txt"><b>Email Us</b><span>info@hollywoodwallcoverings.com</span></div></a>
                <a class="ns-action" href="tel:+18883734564"><span class="ico">📞</span><div class="txt"><b>Call Us</b><span>(888) 373-4564 · M–F, 9–5 PT</span></div></a>
            </div>
        </div>
    </div>
<script>
  function nsContactOpen() { var m = document.getElementById('nsContactModal'); if (m) m.classList.add('show'); }
  function nsContactClose() { var m = document.getElementById('nsContactModal'); if (m) m.classList.remove('show'); }
  function nsToggleNav(btn) {
    var nav = document.getElementById('nsPrimaryNav');
    if (!nav) return;
    var open = nav.classList.toggle('open');
    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
  }
  document.addEventListener('keydown', function(e){ if (e.key === 'Escape') nsContactClose(); });
</script>

<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 noreferrer">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(encodeURIComponent(redactVendorSlug(p.handle||'')))}">
      ${p.image_url ? `<img loading="lazy" src="${esc(IMGP.proxyImg(p.image_url))}" alt="${esc(cleanTitle)}">` : ''}
    </a>
    <div class="info">
      <a href="/p/${esc(encodeURIComponent(redactVendorSlug(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(encodeURIComponent(redactVendorSlug(p.handle||'')))}">View</a>
      <a class="cta" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">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 aesthetic = (req.query.aesthetic || '').toString().toLowerCase();
  const { items, page: cur, pages, total, sort } = filterAndPaginate(req, 48);

  const cards = items.map(cardHtml).join('');
  const counts = railCounts();

  // Rails pills strip — every site that defines rails gets a horizontal
  // filter row. Hidden if the site has no rails configured.
  const pillsHtml = SITE_RAILS.length === 0 ? '' : `
    <nav class="rails-strip" aria-label="Browse by aesthetic">
      <a class="rail-pill${!aesthetic ? ' active' : ''}" href="/${type ? `?type=${encodeURIComponent(type)}` : ''}">All <span>${DATA.length.toLocaleString()}</span></a>
      ${SITE_RAILS.filter(r => counts[r]).map(r => {
        const c = counts[r];
        const params = new URLSearchParams();
        params.set('aesthetic', r);
        if (type) params.set('type', type);
        return `<a class="rail-pill${aesthetic === r ? ' active' : ''}" href="/?${params.toString()}">${esc(r.charAt(0).toUpperCase() + r.slice(1))} <span>${c.toLocaleString()}</span></a>`;
      }).join('')}
    </nav>`;

  // Unfiltered homepage: render rail SECTIONS (one row per rail, top N
  // products, "View all →" link). Falls through to the flat grid when any
  // filter is active (aesthetic/type/search).
  const isUnfiltered = !q && !type && !aesthetic;
  const railsPerSection = 8;
  let mainContent = '';

  if (isUnfiltered && SITE_RAILS.length > 0) {
    const sections = SITE_RAILS.filter(r => counts[r]).map(rail => {
      const railItems = DATA.filter(p => String(p.aesthetic||'').toLowerCase() === rail).slice(0, railsPerSection);
      const railCards = railItems.map(cardHtml).join('');
      const allUrl = `/?aesthetic=${encodeURIComponent(rail)}`;
      return `
        <section class="rail-section">
          <div class="rail-head">
            <h2 class="rail-title">${esc(rail.charAt(0).toUpperCase() + rail.slice(1))}</h2>
            <a class="rail-all" href="${allUrl}">View all ${counts[rail].toLocaleString()} →</a>
          </div>
          <div class="grid rail-grid">${railCards}</div>
        </section>`;
    }).join('');
    mainContent = `
      <div class="meta-line"><strong>${DATA.length.toLocaleString()}</strong> products available · free memo samples · browse by aesthetic</div>
      ${pillsHtml}
      ${sections}`;
  } else {
    mainContent = `
      <div class="meta-line">
        ${q || type || aesthetic ? `Showing <strong>${total.toLocaleString()}</strong> ${aesthetic ? `<strong>${esc(aesthetic)}</strong>` : ''} ${type ? `<strong>${esc(type)}</strong>` : ''} ${q ? `matching "<strong>${esc(q)}</strong>"` : ''}` : `<strong>${total.toLocaleString()}</strong> products available · free memo samples`}
      </div>
      ${pillsHtml}
      ${items.length === 0 ? '' : `
      <div class="controls-bar">
        <label for="sort">Sort</label>
        <select id="sort">
          <option value="newest"${sort==='newest'?' selected':''}>Newest</option>
          <option value="color"${sort==='color'?' selected':''}>Color</option>
          <option value="style"${sort==='style'?' selected':''}>Style</option>
          <option value="sku"${sort==='sku'?' selected':''}>SKU A→Z</option>
          <option value="title"${sort==='title'?' selected':''}>Title A→Z</option>
        </select>
        <label for="density" style="margin-left:14px">Density</label>
        <input id="density" type="range" min="180" max="380" step="10" value="240">
      </div>`}
      ${items.length === 0 ? '<div class="empty">No products match. Try a different search or category.</div>' : `<div class="grid" id="grid">${cards}</div>`}
      <div id="loadmore" data-q="${esc(q)}" data-type="${esc(type)}" data-aesthetic="${esc(aesthetic)}" data-sort="${esc(sort)}" data-page="${cur}" data-pages="${pages}" style="text-align:center;padding:48px 0;color:var(--muted);font-size:11px;letter-spacing:.18em;text-transform:uppercase">${cur < pages ? 'Loading more…' : '— end —'}</div>`;
  }

  const body = `
    <main>
      ${mainContent}
    </main>
    <script>
    (function(){
      const STORE='hw_grid';
      const saved=(()=>{try{return JSON.parse(localStorage.getItem(STORE)||'{}')}catch(e){return {}}})();
      const url=new URL(window.location.href);
      const urlSort=url.searchParams.get('sort')||'newest';

      // Hydrate saved sort BEFORE first grid render: if the URL has no explicit
      // sort but a different one is saved, redirect once to apply it server-side.
      if(saved.sort && saved.sort!=='newest' && !url.searchParams.get('sort')){
        url.searchParams.set('sort',saved.sort);
        window.location.replace(url.toString());
        return;
      }

      const grid=document.getElementById('grid');
      const lm=document.getElementById('loadmore');
      if(!grid||!lm)return;
      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;
      const aesthetic=lm.dataset.aesthetic||'';
      const sort=lm.dataset.sort||'newest';
      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);
        if(aesthetic)params.set('aesthetic',aesthetic);
        if(sort&&sort!=='newest')params.set('sort',sort);
        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 — server-side: persist + reload so ALL pages honor the order.
      const sortSel=document.getElementById('sort');
      if(sortSel){
        sortSel.value=urlSort;
        sortSel.addEventListener('change',()=>{
          saved.sort=sortSel.value;
          try{localStorage.setItem(STORE,JSON.stringify(saved));}catch(e){}
          const u=new URL(window.location.href);
          if(sortSel.value==='newest')u.searchParams.delete('sort');
          else u.searchParams.set('sort',sortSel.value);
          window.location.assign(u.toString());
        });
      }

      // Density — pure client-side, persisted.
      const dens=document.getElementById('density');
      function applyDensity(v){grid.style.gridTemplateColumns='repeat(auto-fill,minmax('+v+'px,1fr))';}
      if(dens){
        dens.value=saved.density||240;
        applyDensity(dens.value);
        dens.addEventListener('input',()=>{applyDensity(dens.value);saved.density=dens.value;try{localStorage.setItem(STORE,JSON.stringify(saved));}catch(e){}});
      }
    })();
    </script>
  `;
  res.set('Content-Type','text/html').send(layout(`${type || 'All Products'}${q ? ` · ${q}` : ''}`, body, { q, type }));
});

app.get('/p/:handle', (req, res) => {
  // Dual-key: match the REAL handle first (old links + main-store parity), then
  // fall back to the vendor-redacted slug that the /p/ hrefs now emit.
  const key = req.params.handle;
  const p = DATA.find(x => x.handle === key)
         || DATA.find(x => redactVendorSlug(x.handle) === key);
  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(IMGP.proxyImg(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 noreferrer">Order Free Memo</a>
          <a class="btn-secondary" href="${esc(shopifyUrl(p.handle))}" target="_blank" rel="noopener noreferrer">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 an exclusive collection 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@hollywoodwallcoverings.com" style="color:var(--accent-soft)">trade@hollywoodwallcoverings.com</a></div>
      </div>
      <div>
        <div class="label">General Inquiries</div>
        <div class="val"><a href="mailto:info@hollywoodwallcoverings.com" style="color:var(--accent-soft)">info@hollywoodwallcoverings.com</a></div>
      </div>
      <div>
        <div class="label">Phone</div>
        <div class="val"><a href="tel:+18883734564" style="color:var(--accent-soft)">(888) 373-4564</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 }));

// Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* REST.
if (catalog) catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });

// Load the catalog. When admin is enabled, prefer dw_unified; else static JSON.
const BIND = process.env.BIND || '127.0.0.1';
(async () => {
  let loaded = false;
  if (catalog) {
    try {
      const rows = await catalog.getProducts(SITE_SLUG);
      if (!rows.length) throw new Error('0 rows from microsite_products');
      rebuildCatalog(rows);
      console.log(`[${__SITE}] loaded ${rows.length} from dw_unified (slug=${SITE_SLUG})`);
      loaded = true;
    } catch (e) {
      console.error(`[${__SITE}] PG catalog load failed (${e.message}) — falling back to data/products.json`);
    }
  }
  if (!loaded) {
    rebuildCatalog(loadFromJson());
    console.log(`[${__SITE}] loaded ${DATA.length} from data/products.json (static mode)`);
  }
  app.listen(PORT, BIND, () => console.log(`Hollywood Wallcoverings on http://${BIND}:${PORT} · ${DATA.length} products`));
})();