← back to Philipperomano

server.js

827 lines

/**
 * philipperomano.com — Phillipe Romano exclusive storefront.
 * Reads products.json (8,227 active SKUs), exposes search/filter, links
 * memo-sample CTA back to the DW Shopify product page (?variant=sample).
 */
const express = require('express');
const path = require('path');
const fs = require('fs');
const https = require('https');

const PORT = process.env.PORT || 9831;
const DATA_FILE = path.join(__dirname, 'data', 'products.json');
let DATA;
try {
  DATA = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
  if (!Array.isArray(DATA)) throw new Error('products.json is not a JSON array');
} catch (err) {
  console.error(`FATAL: could not load product catalog from ${DATA_FILE}\n  ${err.message}`);
  process.exit(1);
}
const DW_SHOPIFY = 'https://designerwallcoverings.com';
const DW_PHONE = '888-373-4564';
const DW_PHONE_TEL = '+18883734564';
const PR_EMAIL = 'info@philipperomano.com';

const app = express();

// Minimal security headers (no helmet dep — keep this storefront tiny).
// Customer-facing storefront → at minimum: clickjacking guard, MIME-sniff
// guard, and a tight referrer policy. CSP would need 'unsafe-inline' for
// the inline <style> block, so deferred until styles are extracted.
app.use((_req, res, next) => {
  res.set('X-Content-Type-Options', 'nosniff');
  res.set('X-Frame-Options', 'SAMEORIGIN');
  res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  // Snapshot-driven catalog → 1h edge cache (Cloudflare in front). Refresh
  // only when products.json is regenerated from dw_unified.
  res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
  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;
};

const TYPES = [...new Set(DATA.map(p => NORM_TYPE(p.product_type)))].sort();

// ─── Color buckets ────────────────────────────────────────────────────────
// Extract the colorway from titles like "Pattern - Colorway Wallcovering | Phillipe Romano"
// then map keywords to one of the buckets below.
const COLOR_BUCKETS = [
  { id: 'black',  label: 'Black',  hex: '#1a1a1a' },
  { id: 'white',  label: 'White',  hex: '#f5f1ea' },
  { id: 'cream',  label: 'Cream',  hex: '#e8d9c0' },
  { id: 'beige',  label: 'Beige',  hex: '#cdb792' },
  { id: 'brown',  label: 'Brown',  hex: '#7a4a23' },
  { id: 'gray',   label: 'Gray',   hex: '#7c7c7c' },
  { id: 'silver', label: 'Silver', hex: '#b8b8b8' },
  { id: 'gold',   label: 'Gold',   hex: '#c9a14a' },
  { id: 'red',    label: 'Red',    hex: '#a8242b' },
  { id: 'pink',   label: 'Pink',   hex: '#d98ca7' },
  { id: 'orange', label: 'Orange', hex: '#cc6b3a' },
  { id: 'yellow', label: 'Yellow', hex: '#d8b94a' },
  { id: 'green',  label: 'Green',  hex: '#5a7a4a' },
  { id: 'blue',   label: 'Blue',   hex: '#3e5d80' },
  { id: 'purple', label: 'Purple', hex: '#6e4a7a' },
  { id: 'multi',  label: 'Multi',  hex: 'linear-gradient(135deg,#d98ca7,#c9a14a,#5a7a4a,#3e5d80)' },
];

const COLOR_KEYWORDS = {
  black:  ['black','onyx','ebony','jet','noir','ink','coal','obsidian','raven'],
  white:  ['white','ivory','snow','chalk','alabaster','porcelain','linen','milk','bone','swan','frost','vanilla'],
  cream:  ['cream','ecru','natural','parchment','oat','oatmeal','wheat','flax','straw','buff','champagne','butter'],
  beige:  ['beige','sand','tan','khaki','camel','taupe','stone','shell','desert','almond','sahara','dune','clay'],
  brown:  ['brown','chocolate','espresso','mocha','java','walnut','cocoa','bronze','umber','sepia','chestnut','rust','cinnamon','copper','caramel','toast','hazel'],
  gray:   ['gray','grey','slate','smoke','charcoal','ash','pewter','graphite','dove','fog','mist','steel','flint'],
  silver:  ['silver','platinum','sterling','chrome','metallic','mercury','aluminum'],
  gold:   ['gold','golden','brass','honey','amber','ochre','mustard','saffron','antique gold'],
  red:    ['red','crimson','cherry','scarlet','burgundy','wine','garnet','ruby','brick','cardinal','poppy','vermillion','blood','sangria'],
  pink:   ['pink','rose','blush','coral','salmon','peach','mauve','dusty rose','fuchsia','magenta','flamingo'],
  orange: ['orange','tangerine','apricot','marigold','sunset','persimmon','spice','paprika','terracotta'],
  yellow: ['yellow','citron','canary','dijon','primrose','lemon','sun','daffodil','goldenrod'],
  green:  ['green','sage','olive','mint','seafoam','jade','emerald','forest','moss','celery','pistachio','fern','kelly','hunter','celadon','laurel','basil','sea','aqua green','grass','leaf','spring','meadow','willow','vert'],
  blue:   ['blue','navy','indigo','sapphire','azure','cobalt','denim','peacock','turquoise','teal','aqua','sky','cerulean','royal','ocean','marine','cornflower','periwinkle','baltic','arctic','glacier','lagoon'],
  purple: ['purple','violet','lavender','plum','aubergine','eggplant','orchid','lilac','iris','heather','wisteria','grape','amethyst','byzantine'],
};
const KW_TO_BUCKET = {};
for (const [bucket, kws] of Object.entries(COLOR_KEYWORDS)) {
  for (const kw of kws) KW_TO_BUCKET[kw] = bucket;
}
// Sort keys longest-first so multi-word matches win ("dusty rose" before "rose")
const KW_ORDERED = Object.keys(KW_TO_BUCKET).sort((a, b) => b.length - a.length);

function extractColorway(title) {
  if (!title) return '';
  // Strip trailing brand suffix
  let t = title.replace(/\s*\|\s*Phillipe Romano.*$/i, '').trim();
  // Pattern: "Pattern - Colorway Type"
  const m = t.match(/-\s*(.+?)\s+(Wallcovering|Wallpaper|Fabric|Drapery|Natural|Velvet|Linen|Silk|Cotton|Sheer)/i);
  if (m) return m[1].trim();
  // Fallback: anything after the last hyphen
  const idx = t.lastIndexOf(' - ');
  if (idx > -1) return t.slice(idx + 3).trim();
  return '';
}

function colorBucket(title) {
  const cw = extractColorway(title).toLowerCase();
  if (!cw) return null;
  for (const kw of KW_ORDERED) {
    if (cw.includes(kw)) return KW_TO_BUCKET[kw];
  }
  return null;
}

// Granular colorway-name → real hex lookup. Each entry maps a colorway keyword
// (extracted from the title) to its actual hex code so the per-product dot
// shows the product's own color, not the bucket's average.
const COLORWAY_HEX = {
  // Black / dark
  black:'#1a1a1a', onyx:'#0f0f0f', ebony:'#181210', jet:'#0a0a0a', noir:'#161616',
  ink:'#1a1d24', coal:'#2b2b2b', obsidian:'#0d0d0d', raven:'#1d1d1d',
  // White / cream
  white:'#f5f1ea', ivory:'#f1ead8', snow:'#fafafa', chalk:'#ece7d7',
  alabaster:'#efe9d9', porcelain:'#f0e9d8', linen:'#e9dfc8', milk:'#efeae0',
  bone:'#dccfb4', swan:'#f0ebde', frost:'#eaece9', vanilla:'#f3e7c4',
  // Cream / oat
  cream:'#e8d9c0', ecru:'#d9c8a3', natural:'#d6c39a', parchment:'#e1cd9c',
  oat:'#d5c19a', oatmeal:'#cfbb96', wheat:'#d6b87b', flax:'#cdb789',
  straw:'#d6b87b', buff:'#cdb287', champagne:'#e6cfa5', butter:'#e7d496',
  // Beige / tan
  beige:'#cdb792', sand:'#c8b48a', tan:'#b89460', khaki:'#a78d63',
  camel:'#a87d4f', taupe:'#9c8470', stone:'#a89682', shell:'#dcc7a7',
  desert:'#cdaa7d', almond:'#d2b48c', sahara:'#c8a877', dune:'#c5b18a',
  clay:'#a96a4a',
  // Brown
  brown:'#7a4a23', chocolate:'#3d220e', espresso:'#3a2418', mocha:'#6f4e2a',
  java:'#3b2618', walnut:'#5b3a1f', cocoa:'#4a3122', bronze:'#7a522d',
  umber:'#5e3a1f', sepia:'#704d23', chestnut:'#714028', rust:'#9b4a1a',
  cinnamon:'#8a4a22', copper:'#b35a23', caramel:'#a06a35', toast:'#9a6a44',
  hazel:'#8c6238',
  // Gray
  gray:'#7c7c7c', grey:'#7c7c7c', slate:'#535e69', smoke:'#7a797a',
  charcoal:'#36383b', ash:'#92918f', pewter:'#7d7e80', graphite:'#3b3d3f',
  dove:'#a09e98', fog:'#8b8b8a', mist:'#c2c1bd', steel:'#5b6770', flint:'#5d6065',
  // Silver
  silver:'#b8b8b8', platinum:'#c8c8c5', sterling:'#bcbab5', chrome:'#d4d4d4',
  metallic:'#a9a9aa', mercury:'#bfbebb', aluminum:'#c5c4c0',
  // Gold / yellow-warm
  gold:'#c9a14a', golden:'#c9a14a', brass:'#a78436', honey:'#cf9f3a',
  amber:'#c5862e', ochre:'#a87523', mustard:'#c69a36', saffron:'#d09b25',
  // Red
  red:'#a8242b', crimson:'#7e1923', cherry:'#a8212e', scarlet:'#a01828',
  burgundy:'#5c1a22', wine:'#5a1e23', garnet:'#621a22', ruby:'#841a26',
  brick:'#8a4035', cardinal:'#94232b', poppy:'#bd2c30', vermillion:'#c0331a',
  blood:'#5a1216', sangria:'#6e1d24',
  // Pink
  pink:'#d98ca7', rose:'#c97189', blush:'#e0bbb3', coral:'#cf6f5e',
  salmon:'#dc8b76', peach:'#e3a987', mauve:'#a98091', fuchsia:'#bf2c6e',
  magenta:'#a02963', flamingo:'#df6e83',
  // Orange
  orange:'#cc6b3a', tangerine:'#d8722a', apricot:'#daa177', marigold:'#d2952c',
  sunset:'#cc6f3a', persimmon:'#c25827', spice:'#a35430', paprika:'#a8431f',
  terracotta:'#a35a3b',
  // Yellow
  yellow:'#d8b94a', citron:'#c7c247', canary:'#dfc83a', dijon:'#9a7d23',
  primrose:'#dac86b', lemon:'#d6c63b', sun:'#d4b22c', daffodil:'#d8b820',
  goldenrod:'#b88a25',
  // Green
  green:'#5a7a4a', sage:'#9aa687', olive:'#7a7035', mint:'#9bc8a6',
  seafoam:'#82bda1', jade:'#3f7361', emerald:'#235a3c', forest:'#264a30',
  moss:'#5c6c3a', celery:'#a8b274', pistachio:'#9bbf72', fern:'#4a6a3b',
  kelly:'#3b6a31', hunter:'#28482e', celadon:'#a4b89a', laurel:'#4f6b3e',
  basil:'#566c34', sea:'#5e8e7a', grass:'#5a7e2c', leaf:'#598a3a',
  spring:'#7ab83a', meadow:'#618d44', willow:'#869c6e', vert:'#3e6a3a',
  // Blue
  blue:'#3e5d80', navy:'#1c2a44', indigo:'#202852', sapphire:'#1f3a78',
  azure:'#2c5e90', cobalt:'#1c4f8e', denim:'#3d6391', peacock:'#1c5d6c',
  turquoise:'#2db1b8', teal:'#226e72', aqua:'#5fb4b6', sky:'#7ea8c8',
  cerulean:'#2c5e8a', royal:'#1c358a', ocean:'#1f4d6d', marine:'#1f3d68',
  cornflower:'#6a82bb', periwinkle:'#9aa4d2', baltic:'#365a78',
  arctic:'#a8c5d2', glacier:'#a4c4cf', lagoon:'#357a86',
  // Purple
  purple:'#6e4a7a', violet:'#5b3a78', lavender:'#aa9bcb', plum:'#5b2a4a',
  aubergine:'#3a1f33', eggplant:'#3d2436', orchid:'#a3679a', lilac:'#b69dca',
  iris:'#6b4f99', heather:'#9a8aa8', wisteria:'#a48ac4', grape:'#552864',
  amethyst:'#7e4d9c', byzantine:'#7a3784',
};
const COLORWAY_HEX_KEYS = Object.keys(COLORWAY_HEX).sort((a, b) => b.length - a.length);

function colorHex(title) {
  const cw = extractColorway(title).toLowerCase();
  if (!cw) return null;
  for (const kw of COLORWAY_HEX_KEYS) {
    if (cw.includes(kw)) return COLORWAY_HEX[kw];
  }
  return null;
}

// Annotate every product with bucket + dwsku numeric (for sort)
const skuNum = sku => {
  const m = String(sku || '').match(/(\d+)/);
  return m ? parseInt(m[1], 10) : 0;
};
// Fallback for products where dw_sku is null in JSON: extract DWxx-NNNNNN from the handle.
function effectiveSku(p) {
  if (p.dw_sku) return p.dw_sku;
  const m = (p.handle || '').match(/(dw[a-z]{2,4}-\d+)$/i);
  return m ? m[1].toUpperCase() : '';
}
// Inclusive naturals matcher — catches BOTH real natural-fiber wallcoverings
// (grasscloth, cork, linen, silk, jute, sisal, abaca, raffia, seagrass, bamboo,
// hemp, flax, wool, leather, suede, mica, capiz) AND Type II commercial vinyls
// with natural-look textures (linen-look, grasscloth-look, cork-look, etc.).
// Per Steve 2026-05-25 — "most Type II vinyl textures work".
const NATURALS_RE = /\b(grasscloth|seagrass|sea[- ]grass|jute|sisal|abaca|raffia|cork|linen|silk|silk[- ]?look|bamboo|hemp|flax|wool|leather|suede|mica|capiz|slate|natural|naturals|fiber|woven|sandstone|reed|cane|rattan|paperweave|paper[- ]weave|sandgrass)\b/i;
function isNatural(p) {
  // product_type is the strongest signal when present
  const pt = (p.product_type || '').toLowerCase();
  if (pt.includes('natural')) return true;
  // Title-keyword pattern (catches Type II naturals-look vinyls)
  const blob = (p.title || '') + ' ' + (p.handle || '');
  return NATURALS_RE.test(blob);
}

const ENRICHED = DATA.map(p => {
  const sku = effectiveSku(p);
  return {
    ...p,
    dw_sku: sku || p.dw_sku,
    _color: colorBucket(p.title),
    _hex: colorHex(p.title),
    _skuNum: skuNum(sku),
    _isNatural: isNatural(p),
  };
});
// Default order: newest first (highest DWxx sku numeric desc)
ENRICHED.sort((a, b) => b._skuNum - a._skuNum);


// --- Junk-product defense (codified in /dw-site-build) -------------------
// Drop rows that don't belong on a textile-brand storefront even though
// the dw_unified replica returned them under vendor='Phillipe Romano'.
// Causes: variant_id leaked into dw_sku column, mis-tagged product_type,
// cross-vendor catalog corruption.
const JUNK_TITLE_RE = /\b(lamp|tripod|rug|pillow|throw|frame|mirror|vase|candle|sculpture|figurine)\b/i;
const ALLOWED_TYPES = new Set([
  'Wallcovering','Wallcoverings','Commercial Wallcovering','Commercial Wallcoverings',
  'Natural Wallcovering','Naturals','Fabric','Fabrics','Drapery Fabric','Drapery'
]);
function isJunk(p) {
  if (!p.image_url || !String(p.image_url).trim()) return true;
  const sku = String(p.dw_sku || '').trim();
  if (!sku) return true;
  if (/^\d{13,}$/.test(sku)) return true;            // variant_id leaked into sku
  if (!ALLOWED_TYPES.has(p.product_type || '')) return true;
  if (JUNK_TITLE_RE.test(p.title || '')) return true;
  return false;
}
const _kept = ENRICHED.filter(p => !isJunk(p));
const _dropped = ENRICHED.length - _kept.length;
console.log(`[junk-filter] kept=${_kept.length} dropped=${_dropped}`);
ENRICHED.length = 0;
ENRICHED.push(..._kept);
// --- end junk-product defense -------------------------------------------

const COLOR_COUNTS = {};
for (const p of ENRICHED) if (p._color) COLOR_COUNTS[p._color] = (COLOR_COUNTS[p._color] || 0) + 1;
const NATURALS_COUNT = ENRICHED.filter(p => p._isNatural).length;
console.log(`[naturals] enriched count: ${NATURALS_COUNT} of ${ENRICHED.length}`);

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

// Server-side product sort. Only modes the snapshot data can actually
// support — products.json carries no tag/color data and price fields are
// near-empty, so Color/Style/Price sorts are intentionally omitted here.
function sortProducts(list, mode) {
  const cmpStr = k => (a, b) => String(a[k] || '').localeCompare(String(b[k] || ''));
  if (mode === 'sku')   return [...list].sort((a,b) => String(a.dw_sku || a.handle || '').localeCompare(String(b.dw_sku || b.handle || '')));
  if (mode === 'title') return [...list].sort(cmpStr('title'));
  if (mode === 'type')  return [...list].sort((a,b) => NORM_TYPE(a.product_type).localeCompare(NORM_TYPE(b.product_type)) || cmpStr('title')(a,b));
  return list; // 'newest' / default — snapshot's natural order
}

function shopifyUrl(handle) {
  return handle ? `${DW_SHOPIFY}/products/${handle}` : `${DW_SHOPIFY}`;
}

function memoSampleUrl(handle) {
  // DW Shopify products have a sample variant via ?variant=sample query/option-passthrough.
  // The actual sample-variant ID lives on the product page; this URL hands off to DW
  // where the customer finishes adding to cart.
  return handle ? `${DW_SHOPIFY}/products/${handle}#sample` : DW_SHOPIFY;
}

// Lazy-fetch + cache the full Shopify product per handle, so the detail page
// can mirror everything that's on the canonical DW Shopify product page
// (description, price, variants, images, tags) without re-fetching on each request.
const productCache = new Map();
const PRODUCT_TTL_MS = 60 * 60 * 1000; // 1h
function fetchShopifyProduct(handle) {
  const cached = productCache.get(handle);
  if (cached && Date.now() - cached.at < PRODUCT_TTL_MS) return Promise.resolve(cached.data);
  return new Promise((resolve) => {
    const opts = {
      hostname: 'designerwallcoverings.com',
      path: `/products/${encodeURIComponent(handle)}.json`,
      headers: {
        'User-Agent': 'Mozilla/5.0 (compatible; PhillipeRomanoSite/1.0; +https://philipperomano.com)',
        'Accept': 'application/json'
      }
    };
    https.get(opts, (r) => {
      let body = '';
      r.on('data', c => body += c);
      r.on('end', () => {
        try {
          const data = JSON.parse(body)?.product || null;
          if (data) productCache.set(handle, { data, at: Date.now() });
          resolve(data);
        } catch { resolve(null); }
      });
    }).on('error', () => resolve(null));
  });
}
function fetchVariantId(handle) {
  return fetchShopifyProduct(handle).then(p => p?.variants?.[0]?.id || null);
}

// Group product tags into meaningful sections for the detail page.
// Hides internal/utility tags (lowercase mfr codes, AI-* flags) and groups the rest.
function categorizeTags(tagsStr) {
  if (!tagsStr) return {};
  const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
  const ROOMS = /^(Bedroom|Living Room|Dining Room|Hallway|Bathroom|Kitchen|Office|Foyer|Entryway|Powder Room|Nursery|Family Room|Hospitality|Restaurant|Hotel|Lobby|Healthcare|Retail|Spa)$/i;
  const STYLES = /^(Contemporary|Traditional|Transitional|Modern|Organic Modern|Mid-Century|Mid Century|Classic|Bohemian|Minimalist|Rustic|Industrial|Coastal|Farmhouse|Glam|Art Deco|Eclectic|Scandinavian|Japandi|Maximalist|Old World)$/i;
  const MOODS = /^(Serene|Calming|Dramatic|Bold|Subtle|Elegant|Romantic|Sophisticated|Energetic|Cozy|Luxurious|Playful|Refined|Warm)$/i;
  const MATERIALS = /^(Grasscloth|Linen|Velvet|Silk|Cotton|Wool|Vinyl|Performance Vinyl|Faux Leather|Faux-Leather|Leather|Paper|Mylar|Mica|Cork|Hemp|Jute|Sisal|Raffia|Abaca|Beaded|Sequin|Glass Bead|Flock|Flocked|Embroidered|Hand-Painted|Acoustic)$/i;
  const TEXTURES = /Texture|Weave|Tweed|Boucle|Plaid|Stripe|Damask|Chevron|Geometric|Floral|Botanical|Animal|Trellis|Lattice|Diamond|Houndstooth|Plain|Solid/i;
  const APPS = /^(Architectural|Commercial|Hospitality|Residential|Class A Fire Rated|Class A|Class B|Class C|Type II|Type III|Heavy Duty|Acoustic Rated|FR|Fire Rated|Wipeable|Scrubbable|Bleach Cleanable|Mildew Resistant|Stain Resistant)$/i;
  const COLLECTIONS = /^(Trending|Best Seller|New Arrival|Limited Edition).*$/i;
  const COLOR_PREFIX = /^Color\s*:\s*/i;
  const CERTS = /Fire Rated|Class [A-Z]|Type II|Type III|Heavy Duty/i;

  // Skip patterns: lowercase-only utility tags, mfr SKUs/handles, AI flags
  const SKIP = (t) => (
    /^[a-z][a-z0-9-]*$/.test(t) ||           // all-lowercase tokens (mfr-handles, sku slugs)
    /^AI-/i.test(t) ||
    /^Phillipe Romano$/i.test(t) ||
    /^decor wallcovering$/i.test(t) ||
    /^performance-vinyl$/i.test(t) ||
    /^vinyl$/i.test(t) && tags.includes('Vinyl')
  );

  const groups = {
    colors: new Set(),
    style: new Set(),
    mood: new Set(),
    rooms: new Set(),
    materials: new Set(),
    textures: new Set(),
    certifications: new Set(),
    application: new Set(),
    collection: new Set(),
    other: new Set()
  };

  for (const t of tags) {
    if (SKIP(t)) continue;
    if (COLOR_PREFIX.test(t)) {
      groups.colors.add(t.replace(COLOR_PREFIX, '').trim());
      continue;
    }
    if (CERTS.test(t)) { groups.certifications.add(t); continue; }
    if (ROOMS.test(t)) { groups.rooms.add(t); continue; }
    if (STYLES.test(t)) { groups.style.add(t); continue; }
    if (MOODS.test(t)) { groups.mood.add(t); continue; }
    if (MATERIALS.test(t)) { groups.materials.add(t); continue; }
    if (APPS.test(t)) { groups.application.add(t); continue; }
    if (COLLECTIONS.test(t)) { groups.collection.add(t); continue; }
    if (TEXTURES.test(t)) { groups.textures.add(t); continue; }
    // Title-cased single-word colorways → colors heuristic
    if (/^[A-Z][a-z]+$/.test(t) && t.length <= 12) { groups.colors.add(t); continue; }
    groups.other.add(t);
  }
  // To array
  const out = {};
  for (const [k, v] of Object.entries(groups)) {
    const arr = [...v];
    if (arr.length) out[k] = arr;
  }
  return out;
}

// Sanitize body_html: allow paragraphs, lists, line-breaks, basic emphasis. Strip scripts/styles/iframes.
function safeBodyHtml(html) {
  if (!html) return '';
  return String(html)
    .replace(/<script[\s\S]*?<\/script>/gi, '')
    .replace(/<style[\s\S]*?<\/style>/gi, '')
    .replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
    .replace(/\son\w+="[^"]*"/gi, '')
    .replace(/\son\w+='[^']*'/gi, '');
}

function memoMailto(p) {
  const sub = encodeURIComponent(`Memo Sample Request — ${p.dw_sku || p.title}`);
  const body = encodeURIComponent(
    `Hi Phillipe Romano,\n\nI'd like to request a free memo sample of:\n\n` +
    `  ${p.title}\n  SKU: ${p.dw_sku || ''}\n\n` +
    `Please ship to:\n  Name:\n  Company:\n  Address:\n  City, State, ZIP:\n  Phone:\n\nThank you.`
  );
  return `mailto:${PR_EMAIL}?subject=${sub}&body=${body}`;
}

const PR_ORIGIN = 'https://philipperomano.com';
const PR_DESC = 'Phillipe Romano commercial wallcoverings, naturals, and fabrics. Order memo samples free.';
const layout = (title, body, { q='', type='', cat='', canonical='', ogImage='', ogTitle='', ogDesc='' } = {}) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)} — Phillipe Romano</title>
<meta name="description" content="${esc(PR_DESC)}">
${canonical ? `<link rel="canonical" href="${esc(canonical)}">\n` : ''}<meta property="og:type" content="website">
<meta property="og:site_name" content="Phillipe Romano">
<meta property="og:title" content="${esc(ogTitle || (title + ' — Phillipe Romano'))}">
<meta property="og:description" content="${esc(ogDesc || PR_DESC)}">
${canonical ? `<meta property="og:url" content="${esc(canonical)}">\n` : ''}${ogImage ? `<meta property="og:image" content="${esc(ogImage)}">\n` : ''}<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
<style>
  :root {
    --bg:#faf7f2; --fg:#1a1714; --muted:#7c736a; --rule:#d8cec0;
    --accent:#7d3c00; --accent-soft:#b8865c; --card:#ffffff;
    --serif:'Cormorant Garamond', 'Playfair Display', 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}
  .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
  header.top{padding:24px 32px;border-bottom:1px solid var(--rule);background:#fff;display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap}
  .brand{font-family:var(--serif);font-weight:600;font-size:32px;letter-spacing:.04em;text-transform:uppercase}
  .brand small{display:block;font-family:var(--sans);font-size:11px;letter-spacing:.18em;font-weight:500;color:var(--muted);margin-top:4px}
  .contact{font-size:13px;color:var(--muted);text-align:right;line-height:1.7;letter-spacing:.04em}
  .contact a{color:var(--fg)}
  .contact a:hover{color:var(--accent)}
  .contact .phone{font-family:var(--serif);font-size:18px;color:var(--fg);font-weight:600;letter-spacing:.06em}
  nav.types{display:flex;gap:18px;padding:14px 32px;background:#fff;border-bottom:1px solid var(--rule);flex-wrap:wrap;align-items:center}
  nav.types a{font-size:13px;font-weight:500;color:var(--muted);padding:6px 0;border-bottom:2px solid transparent;letter-spacing:.04em;text-transform:uppercase}
  nav.types a:hover{color:var(--fg)}
  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:#fff;padding:8px 12px;border-radius:0;min-width:240px;font:inherit;font-size:13px;outline:none;color:var(--fg)}
  form.search input:focus{border-color:var(--accent)}
  form.search button{border:1px solid var(--accent);background:var(--accent);color:#fff;padding:8px 16px;font:inherit;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:500;cursor:pointer}
  main{max-width:1400px;margin:0 auto;padding:32px}
  .meta-line{font-size:12px;color:var(--muted);margin-bottom:18px;letter-spacing:.06em;text-transform:uppercase}
  .controls{display:flex;gap:24px;align-items:center;flex-wrap:wrap;margin-bottom:24px}
  .controls label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:500;display:flex;gap:8px;align-items:center}
  .controls select{border:1px solid var(--rule);background:#fff;padding:6px 10px;font:inherit;font-size:13px;color:var(--fg);outline:none;cursor:pointer}
  .controls select:focus{border-color:var(--accent)}
  .controls input[type=range]{cursor:pointer;accent-color:var(--accent)}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cardmin,220px),1fr));gap:24px}
  .card{background:var(--card);border:1px solid var(--rule);transition:box-shadow .15s, transform .12s}
  .card:hover{box-shadow:0 8px 24px rgba(0,0,0,.06);transform:translateY(-2px)}
  .card a.thumb{display:block;aspect-ratio:1/1;background:#f0ece4;overflow:hidden}
  .card a.thumb img{width:100%;height:100%;object-fit:cover;display:block}
  .card .info{padding:14px 14px 16px}
  .card .info .t{font-family:var(--serif);font-size:18px;font-weight:500;line-height:1.25;margin-bottom:4px;color:var(--fg)}
  .card .info .sub{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
  .card .actions{display:flex;border-top:1px solid var(--rule)}
  .card .actions a{flex:1;padding:10px 12px;text-align:center;font-size:11px;text-transform:uppercase;letter-spacing:.08em;font-weight:500;color:var(--muted);transition:all .12s}
  .card .actions a + a{border-left:1px solid var(--rule)}
  .card .actions a:hover{background:var(--bg);color:var(--fg)}
  .card .actions a.cta{color:var(--accent);font-weight:600}
  .card .actions a.cta:hover{background:var(--accent);color:#fff}
  .pager{display:flex;justify-content:center;gap:8px;margin-top:48px}
  .pager a, .pager span{padding:8px 14px;border:1px solid var(--rule);background:#fff;font-size:13px;min-width:44px;text-align:center}
  .pager a:hover{border-color:var(--accent);color:var(--accent)}
  .pager .cur{background:var(--accent);color:#fff;border-color:var(--accent)}
  .empty{padding:80px 20px;text-align:center;color:var(--muted)}
  footer{padding:48px 32px;border-top:1px solid var(--rule);background:#fff;color:var(--muted);font-size:12px;letter-spacing:.06em;text-align:center;margin-top:80px}
  footer a{color:var(--accent)}
  /* Detail page */
  .detail{display:grid;grid-template-columns:1.2fr 1fr;gap:48px;padding:48px 32px;max-width:1400px;margin:0 auto}
  .detail .hero{background:#f0ece4;aspect-ratio:1/1}
  .detail .hero img{width:100%;height:100%;object-fit:cover;display:block}
  .detail h1{font-family:var(--serif);font-size:48px;font-weight:500;line-height:1.05;margin-bottom:8px}
  .detail .crumb{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:18px}
  .detail .pt{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);font-weight:600;margin-bottom:32px}
  .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:.06em;font-size:11px;font-weight:500}
  .detail .actions{display:flex;gap:12px;margin-top:32px}
  .detail .actions a{padding:14px 28px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;font-weight:600}
  .detail .btn-primary{background:var(--accent);color:#fff;border:1px solid var(--accent)}
  .detail .btn-primary:hover{background:#5e2d00}
  .detail .btn-secondary{border:1px solid var(--fg);color:var(--fg)}
  .detail .btn-secondary:hover{background:var(--fg);color:#fff}
  @media (max-width:780px){.detail{grid-template-columns:1fr;padding:24px}}
</style>
</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 'wallco-page-theme'. Toggle UI
     hidden until ?theme=N or localStorage opt-in. Shares the wallco-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('wallco-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('wallco-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('wallco-page-theme', t); } catch(e){}
      sync(t);
    });
  });
})();
</script>
<header class="top">
  <a href="/" class="brand">Phillipe Romano<small>A Designer Wallcoverings Exclusive</small></a>
</header>
<nav class="types">
  <a href="/" ${!type && !cat ? 'class="active"' : ''}>All</a>
  <a href="/naturals" ${cat==='naturals' ? 'class="active"' : ''}>Naturals (${NATURALS_COUNT.toLocaleString()})</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 8,227 SKUs…">
    ${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}
    <button>Search</button>
  </form>
</nav>
${body}
<footer>
  Phillipe Romano is an exclusive collection of <a href="${DW_SHOPIFY}">Designer Wallcoverings</a>. Memo samples are always free. © ${new Date().getFullYear()}
</footer>
</body>
</html>`;

// /naturals — friendly shortcut → /?cat=naturals
app.get('/naturals', (req, res) => {
  const u = new URL('http://x' + req.url);
  u.searchParams.set('cat', 'naturals');
  u.pathname = '/';
  res.redirect(302, '/?' + u.searchParams.toString());
});

app.get('/', (req, res) => {
  const q = (req.query.q || '').toString().toLowerCase().trim();
  const type = (req.query.type || '').toString();
  const cat  = (req.query.cat  || '').toString().toLowerCase();
  const sort = (req.query.sort || '').toString();
  const page = parseInt(req.query.page, 10) || 1;

  // Source from ENRICHED (junk-filtered + _isNatural flag set) — not raw DATA
  let filtered = ENRICHED;
  if (cat === 'naturals') filtered = filtered.filter(p => p._isNatural);
  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));
    });
  }
  filtered = sortProducts(filtered, sort);

  const { items, page: cur, pages, total } = paginate(filtered, page);

  const cards = items.map(p => `
    <article class="card">
      <a class="thumb" href="/p/${esc(p.handle||'')}">
        ${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}
      </a>
      <div class="info">
        <a href="/p/${esc(p.handle||'')}">
          <div class="t">${esc(p.title)}</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 noreferrer">Order Memo</a>
      </div>
    </article>
  `).join('');

  const SORTS = [
    ['', 'Newest'], ['sku', 'SKU A→Z'], ['title', 'Title A→Z'], ['type', 'Type'],
  ];
  const sortOptions = SORTS.map(([v, lbl]) =>
    `<option value="${v}"${sort === v ? ' selected' : ''}>${lbl}</option>`).join('');

  const body = `
    <main>
      <h1 class="sr-only">Phillipe Romano — ${esc(cat === 'naturals' ? 'Naturals' : (type || 'All Products'))}</h1>
      <div class="meta-line">
        ${q || type ? `Showing ${total.toLocaleString()} ${type ? `<strong style="color:var(--fg)">${esc(type)}</strong>` : ''} ${q ? `matching "<strong style="color:var(--fg)">${esc(q)}</strong>"` : ''}` : `${total.toLocaleString()} products available`}
      </div>
      <div class="controls">
        <label>Sort
          <select id="sortSelect">${sortOptions}</select>
        </label>
        <label>Density
          <input type="range" id="densityRange" min="160" max="340" step="20" value="220">
        </label>
      </div>
      ${items.length === 0 ? '<div class="empty">No products match. Try a different search or category.</div>' : `<div class="grid">${cards}</div>`}
      ${pages > 1 ? `<div id="scroll-sentinel" style="padding:20px;text-align:center;color:var(--muted);font-size:13px;"><span id="loading-status">Loading more…</span></div>` : ''}
    </main>
    <script>
      (function () {
        var sortSel = document.getElementById('sortSelect');
        var dens = document.getElementById('densityRange');
        var grid = document.querySelector('.grid');
        var sentinel = document.getElementById('scroll-sentinel');

        // ========== SORT & DENSITY (unchanged) ==========
        if (sortSel) {
          var savedSort = localStorage.getItem('pr.sort');
          var params = new URLSearchParams(location.search);
          if (savedSort !== null && !params.has('sort') && savedSort !== '') {
            params.set('sort', savedSort);
            location.replace(location.pathname + '?' + params.toString());
            return;
          }
          sortSel.addEventListener('change', function () {
            localStorage.setItem('pr.sort', sortSel.value);
            var p = new URLSearchParams(location.search);
            if (sortSel.value) p.set('sort', sortSel.value); else p.delete('sort');
            p.delete('page');
            location.search = p.toString();
          });
        }
        if (dens) {
          var savedDens = localStorage.getItem('pr.density');
          if (savedDens) { dens.value = savedDens; }
          var apply = function () {
            document.documentElement.style.setProperty('--cardmin', dens.value + 'px');
          };
          apply();
          dens.addEventListener('input', function () {
            localStorage.setItem('pr.density', dens.value);
            apply();
          });
        }

        // ========== INFINITE SCROLL ==========
        if (grid && sentinel) {
          var currentPage = parseInt(new URLSearchParams(location.search).get('page')) || 1;
          var isLoading = false;
          var hasMore = true;

          var scrollObserver = new IntersectionObserver(function(entries) {
            for (var i = 0; i < entries.length; i++) {
              if (entries[i].isIntersecting && hasMore && !isLoading) {
                loadNextPage();
              }
            }
          }, { rootMargin: '400px 0px' });

          scrollObserver.observe(sentinel);

          function loadNextPage() {
            if (isLoading || !hasMore) return;
            isLoading = true;
            var nextPage = currentPage + 1;
            var url = new URL(location.href);
            url.searchParams.set('page', nextPage);

            fetch(url.toString())
              .then(function(res) { return res.text(); })
              .then(function(html) {
                var parser = new DOMParser();
                var doc = parser.parseFromString(html, 'text/html');
                var nextGrid = doc.querySelector('.grid');

                if (!nextGrid || nextGrid.children.length === 0) {
                  hasMore = false;
                  document.getElementById('loading-status').textContent = 'No more products';
                  sentinel.style.color = '#ccc';
                  return;
                }

                Array.from(nextGrid.children).forEach(function(card) {
                  var clone = card.cloneNode(true);
                  grid.appendChild(clone);
                });

                currentPage = nextPage;

                // Check if next page exists by looking for sentinel in fetched doc
                var nextSentinel = doc.querySelector('#scroll-sentinel');
                if (!nextSentinel) {
                  hasMore = false;
                  document.getElementById('loading-status').textContent = 'All products loaded';
                  sentinel.style.color = '#ccc';
                } else {
                  document.getElementById('loading-status').textContent = 'Loading more…';
                }

                isLoading = false;
              })
              .catch(function(err) {
                console.error('Infinite scroll failed:', err);
                document.getElementById('loading-status').textContent = 'Error loading more';
                sentinel.style.color = '#c66c4d';
                isLoading = false;
              });
          }
        }
      })();
    </script>
  `;
  // Canonical (DTD verdict B): keep content filters (cat, type), drop volatile
  // view params (sort, page, q) so all sorted/paginated/searched views of a
  // listing consolidate to one canonical. og:image = first product on the page.
  const canonParams = new URLSearchParams();
  if (cat) canonParams.set('cat', cat);
  if (type) canonParams.set('type', type);
  const canonical = PR_ORIGIN + '/' + (canonParams.toString() ? '?' + canonParams.toString() : '');
  const ogImage = (items[0] && items[0].image_url) || '';
  res.set('Content-Type','text/html').send(layout(`${cat === 'naturals' ? 'Naturals' : (type || 'All Products')}${q ? ` · ${q}` : ''}`, body, { q, type, cat, canonical, ogImage }));
});

app.get('/p/:handle', (req, res) => {
  const p = ENRICHED.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 body = `
    <div class="detail">
      <div class="hero">${p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}</div>
      <div>
        <div class="crumb"><a href="/">Phillipe Romano</a> / ${esc(NORM_TYPE(p.product_type))}</div>
        <div class="pt">${esc(NORM_TYPE(p.product_type))}</div>
        <h1>${esc(p.title)}</h1>
        <div class="row"><div class="k">SKU</div><div>${esc(p.dw_sku || '—')}</div></div>
        ${p.mfr_sku ? `<div class="row"><div class="k">Mfr SKU</div><div>${esc(p.mfr_sku)}</div></div>` : ''}
        <div class="row"><div class="k">Type</div><div>${esc(p.product_type)}</div></div>
        ${p.retail_price ? `<div class="row"><div class="k">Retail</div><div>$${parseFloat(p.retail_price).toFixed(2)} /yd</div></div>` : ''}
        <div class="actions">
          <a class="btn-primary" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</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(p.title, body, {
    canonical: PR_ORIGIN + '/p/' + encodeURIComponent(p.handle || ''),
    ogImage: p.image_url || '',
  }));
});

app.get('/health', (_req, res) => res.json({ ok: true, products: ENRICHED.length, types: TYPES }));

// Loopback-only on the local Mac. Set BIND=0.0.0.0 in prod env when needed.
const BIND = process.env.BIND || '127.0.0.1';
app.listen(PORT, BIND, () => console.log(`Phillipe Romano on http://${BIND}:${PORT} · ${DATA.length} products`));