← back to Wallsandfabrics

server.js

336 lines

/**
 * Walls & Fabrics — combo storefront for DW wallcoverings + fabrics + murals + trims.
 * Curated slice of the live Designer Wallcoverings catalog (dw_unified).
 */
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 PORT = process.env.PORT || 9934;
const DW_SHOPIFY = 'https://designerwallcoverings.com';
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;
const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];

// Normalize the multiple shapes that ship across DW family datasets:
//   - cork shape: image_url + max_price + product_url
//   - dw_unified shape: images[] + price + dw_url
function normalize(p) {
  const out = { ...p };
  if (!out.image_url) {
    if (Array.isArray(out.images) && out.images.length) {
      out.image_url = typeof out.images[0] === 'string' ? out.images[0] : (out.images[0] && out.images[0].src) || '';
    }
  }
  if (out.max_price == null && out.price != null) out.max_price = Number(out.price);
  if (!out.product_url && out.dw_url) out.product_url = out.dw_url;
  if (!out.sku && out.handle) out.sku = out.handle;
  return out;
}

function isJunk(p) {
  if (!p.image_url || !String(p.image_url).trim()) return true;
  if (!p.handle && !p.sku) return true;
  const t = p.title || '';
  if (/lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine/i.test(t)) return true;
  if (/visual.{0,3}merchandiser/i.test(t)) return true;
  if (/(?:^|\W)image[ _-]?4(?:\W|$)/i.test(t)) return true;
  return false;
}

// Surface tagging — derive a canonical surface bucket per product.
function surfaceOf(p) {
  const pt = String(p.product_type || '').toLowerCase();
  const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
  if (pt.includes('fabric')) return 'fabric';
  if (pt.includes('mural') || /mural/.test(blob)) return 'mural';
  if (pt.includes('trim') || /trim|tassel|braid|fringe/.test(blob)) return 'trim';
  return 'wallcovering';
}

// Catalog now lives in dw_unified.microsite_products — loaded async at startup
// (see bottom of file). PG down → falls back to data/products.json. The surface
// model is preserved: rows go through normalize() → isJunk() → surfaceOf().
let PRODUCTS = [];
let SURFACE_COUNTS = {};

function buildCatalog(raw) {
  const kept = raw.map(normalize).filter(p => !isJunk(p));
  for (const p of kept) p.__surface = surfaceOf(p);
  PRODUCTS = kept;
  SURFACE_COUNTS = PRODUCTS.reduce((acc, p) => { acc[p.__surface] = (acc[p.__surface] || 0) + 1; return acc; }, {});
  console.log(`[${__SITE}] Loaded ${raw.length}, kept ${PRODUCTS.length}, dropped ${raw.length - PRODUCTS.length}`);
  console.log(`[${__SITE}] Surfaces:`, SURFACE_COUNTS);
}

const app = express();
// Strip vendor names from /api/* responses (Steve standing rule — DW vendor
// names NEVER in customer-facing UI). MUST sit above any route registration so
// the res.json() wrapper installs before handlers run.
app.use(require('../_shared/api-vendor-redact'));
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json({ limit: '256kb' }));

require('./_universal-contact')(app, { siteName: 'Walls & Fabrics', zdColor: '#9A6E3A', zdPosition: 'right' });
require('./_universal-auth')(app, { siteName: __SITE });

// Hard 404-guard — never serve snapshot files (.bak, .bak.*, .pre-*) from static.
app.use((req, res, next) => {
  if (/\.(bak|bak\.[^/]+|pre-[^/]*)$/i.test(req.path)) return res.status(404).end();
  next();
});

require('../_shared/_universal-promo-banner')(app, {}); // new-arrivals promo strip (Tier B)
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));

// DW STANDARD: sort by color/style/sku/title — see ~/.claude/CLAUDE.md.
const COLOR_RANK = {
  black:0,gray:1,grey:1,silver:2,white:3,ivory:4,cream:5,beige:6,brown:7,tan:8,khaki:9,copper:10,
  red:20,pink:21,coral:22,orange:23,peach:24,salmon:25,
  yellow:30,gold:31,mustard:32,olive:33,
  green:40,lime:41,mint:42,teal:50,aqua:51,turquoise:52,cyan:53,
  blue:60,navy:61,indigo:62,periwinkle:63,
  purple:70,violet:71,lavender:72,plum:73,magenta:74,fuchsia:75,
};
function colorRank(p) {
  const tags = (p.tags || []).map(t => String(t).toLowerCase());
  for (const t of tags) for (const k of Object.keys(COLOR_RANK)) if (t.includes(k)) return COLOR_RANK[k];
  return 999;
}
const STYLE_TAGS = ['traditional','transitional','modern','contemporary','minimalist','art deco','victorian','mid-century','mid century','rustic','industrial','farmhouse','bohemian','boho','geometric','floral','botanical','damask','toile','grasscloth','silk','linen','vinyl','natural'];
function styleKey(p) {
  const tags = (p.tags || []).map(t => String(t).toLowerCase());
  for (const s of STYLE_TAGS) if (tags.some(t => t.includes(s))) return s;
  return 'zzz';
}

const COLOR_HEX = {
  black:'#0a0a0a',charcoal:'#2a2a2a',gray:'#888',grey:'#888',silver:'#c0c0c0',
  white:'#fff',ivory:'#fffff0',cream:'#f5e9c8',champagne:'#f0d8a8',beige:'#e7d6b6',sand:'#dccfa6',blonde:'#e8d8a0',
  brown:'#6b4a2b',chestnut:'#5a3825',umber:'#5a3a1f',tan:'#c9a36a',khaki:'#bda464',honey:'#d6a23c',
  copper:'#b87333',brass:'#b5a642',bronze:'#9c6b30',gold:'#d4af37',amber:'#c98a32',butterscotch:'#d6943c',saffron:'#d6a93c',ochre:'#b88c3a',mustard:'#caa53d',
  red:'#c92a2a',coral:'#e87a6b',pink:'#e6a4b4',salmon:'#e08e7c',rose:'#d68a9a',magenta:'#c5358a',fuchsia:'#d63aaf',
  orange:'#e07a32',peach:'#f0bb96',apricot:'#e0a373',
  yellow:'#e8c84a',butter:'#f0e1a0',
  olive:'#7a7a36','olive ':'#7a7a36',
  green:'#3d8a4f',lime:'#92c84a',mint:'#aedcc1',
  teal:'#2a8a8a',aqua:'#3ab4b4',turquoise:'#3ab8b0',cyan:'#3acac4',aquamarine:'#9ed5c8',
  blue:'#3a5fb8',navy:'#1a2c52',indigo:'#37327a',periwinkle:'#8a93d4',
  purple:'#6a3a8a',violet:'#7a4aa8',lavender:'#b0a3d6',plum:'#693a55',gilver:'#bfb9b3'
};
function rgbOfTag(tag) {
  const k = String(tag||'').toLowerCase().trim();
  for (const name of Object.keys(COLOR_HEX)) if (k.includes(name)) return COLOR_HEX[name];
  return null;
}
function hexToRgb(h){const m=/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(h||'');return m?[parseInt(m[1],16),parseInt(m[2],16),parseInt(m[3],16)]:null;}
function rgbToHsl(r,g,b){r/=255;g/=255;b/=255;const mx=Math.max(r,g,b),mn=Math.min(r,g,b);let h=0,s=0,l=(mx+mn)/2;if(mx!==mn){const d=mx-mn;s=l>.5?d/(2-mx-mn):d/(mx+mn);switch(mx){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;case b:h=(r-g)/d+4;break;}h*=60;}return[h,s,l];}
function dominantHex(p){
  if (p.dominant_hex_real && /^#[\da-f]{6}$/i.test(p.dominant_hex_real)) return p.dominant_hex_real;
  const tags=(p.tags||[]).map(t=>String(t).toLowerCase());
  for (const t of tags){const hx=rgbOfTag(t);if(hx)return hx;}
  return '#888';
}
// Up to 4 distinct color dots: real-pixel hex first, then tag-derived hexes.
function colorDots(p){
  const seen=new Set();const out=[];
  if (p.dominant_hex_real && /^#[\da-f]{6}$/i.test(p.dominant_hex_real)) {
    out.push(p.dominant_hex_real); seen.add(p.dominant_hex_real.toLowerCase());
  }
  for (const t of (p.tags||[])){
    const hx=rgbOfTag(t);
    if(hx && !seen.has(hx.toLowerCase())){seen.add(hx.toLowerCase());out.push(hx);if(out.length>=4)break;}
  }
  return out;
}
function luminance(p){const rgb=hexToRgb(dominantHex(p));return rgb?(0.2126*rgb[0]+0.7152*rgb[1]+0.0722*rgb[2]):128;}
function hue(p){const rgb=hexToRgb(dominantHex(p));if(!rgb)return 999;const [h,s]=rgbToHsl(rgb[0],rgb[1],rgb[2]);return s<0.08?999:h;}

function sortProducts(list, mode) {
  if (mode === 'sku') return [...list].sort((a,b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
  if (mode === 'title') return [...list].sort((a,b) => String(a.title || '').localeCompare(String(b.title || '')));
  if (mode === 'color') return [...list].sort((a,b) => colorRank(a) - colorRank(b) || String(a.title || '').localeCompare(String(b.title || '')));
  if (mode === 'style') return [...list].sort((a,b) => styleKey(a).localeCompare(styleKey(b)) || String(a.title || '').localeCompare(String(b.title || '')));
  if (mode === 'light-dark') return [...list].sort((a,b) => luminance(b) - luminance(a)); // bright first
  if (mode === 'dark-light') return [...list].sort((a,b) => luminance(a) - luminance(b)); // dark first
  if (mode === 'wheel') return [...list].sort((a,b) => hue(a) - hue(b));
  if (mode === 'price-asc')  return [...list].sort((a,b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
  if (mode === 'price-desc') return [...list].sort((a,b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
  return list;
}


// Shared filter so /api/products and /api/facets narrow identically. `skip`
// names a dimension to leave un-applied — used by /api/facets to count each
// facet over the set narrowed by the OTHER active filters (drill-down), so
// picking one surface doesn't zero the sibling-surface counts.
function filterProducts(list, { q, surface } = {}, skip) {
  let out = list;
  if (skip !== 'q' && q) {
    const needle = String(q).toLowerCase();
    out = out.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.description || p.body_html || '').toLowerCase().includes(needle) || (p.product_type || '').toLowerCase().includes(needle) || (p.vendor || '').toLowerCase().includes(needle) || (p.sku || '').toLowerCase().includes(needle) || (p.tags || []).some(t => String(t).toLowerCase().includes(needle)));
  }
  if (skip !== 'surface' && surface && surface !== 'all') {
    out = out.filter(p => p.__surface === surface);
  }
  return out;
}

app.get('/api/products', (req, res) => {
  // ?vendor= is stripped upstream by _shared/api-vendor-redact and search no
  // longer indexes the vendor field — DW vendor names stay off customer surfaces.
  const { q, surface, sort = 'newest', page = 1, limit = 24 } = req.query;
  // Surface tabs: count each surface over the q-narrowed set (skip 'surface')
  // so the tab counts reflect matches per surface for the active query, not the
  // whole catalog — and don't collapse to the one selected surface.
  const filteredSurfaces = filterProducts(PRODUCTS, { q, surface }, 'surface')
    .reduce((acc, p) => { acc[p.__surface] = (acc[p.__surface] || 0) + 1; return acc; }, {});
  let list = filterProducts(PRODUCTS, { q, surface });
  list = sortProducts(list, sort);
  const total = list.length;
  const pageNum = Math.max(1, parseInt(page) || 1);
  const lim = Math.min(60, parseInt(limit) || 24);
  const start = (pageNum - 1) * lim;
  res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total / lim), sort, surfaces: filteredSurfaces, items: list.slice(start, start + lim) });
});

app.get('/api/facets', (req, res) => {
  // Filter-aware drill-down: each facet is counted over the set narrowed by the
  // OTHER active filters (per-dimension skip) so selecting one surface doesn't
  // zero the sibling-surface counts. total = the fully-filtered length. vendor
  // filter is dropped upstream by api-vendor-redact middleware.
  const { q, surface } = req.query;
  // surfaces: count over the q-narrowed set, skipping the surface filter itself.
  const surfaces = filterProducts(PRODUCTS, { q, surface }, 'surface')
    .reduce((acc, p) => { acc[p.__surface] = (acc[p.__surface] || 0) + 1; return acc; }, {});
  // vendors: count over the fully-filtered set (kept for parity; redacted in UI).
  const filtered = filterProducts(PRODUCTS, { q, surface });
  const vendors = {};
  for (const p of filtered) vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
  res.json({ surfaces, vendors, total: filtered.length });
});

app.get('/api/sliders', (req, res) => {
  const out = [];
  for (const surface of ['wallcovering','fabric','mural','trim']) {
    const items = PRODUCTS.filter(p => p.__surface === surface).slice(0, 12);
    if (items.length >= 4) out.push({ surface, items });
  }
  res.json({ rails: out });
});

app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS.length, surfaces: SURFACE_COUNTS }));

// Step 2 of the vendor handle/sku text-leak fix (2026-06-09): the client's
// /sample/ hrefs now point at the vendor-scrubbed `handle_display` slug
// (emitted by _shared/api-vendor-redact since Step 1), so the raw vendor-bearing
// handle never rides in the URL bar. This route is DUAL-KEY — it resolves the raw
// handle/sku FIRST (old bookmarks + buy path, byte-identical behavior), then falls
// back to a redacted->product map built after catalog load. If two raw handles
// redact to the same display slug, that slug is logged and left raw-only.
const { redactVendorText } = require('../_shared/vendor-text-redact');
let SAMPLE_DISPLAY = new Map(); // redacted display slug -> product
function buildSampleDisplayMap() {
  const map = new Map();
  const collided = new Set();
  for (const p of PRODUCTS) {
    for (const key of [p.handle, p.sku]) {
      if (!key) continue;
      const d = redactVendorText(String(key));
      if (!d || d === key) continue;           // nothing redacted — raw route already matches
      const prev = map.get(d);
      if (prev && prev !== p) { collided.add(d); continue; }
      map.set(d, p);
    }
  }
  for (const d of collided) {
    map.delete(d);
    console.warn(`[wallsandfabrics] /sample display-slug collision — keeping raw-only for "${d}"`);
  }
  SAMPLE_DISPLAY = map;
  console.log(`[wallsandfabrics] /sample dual-key map: ${map.size} display slugs, ${collided.size} collisions (raw-only)`);
}

app.get('/sample/:handle', (req, res) => {
  const key = req.params.handle;
  const p = PRODUCTS.find(x => x.handle === key || x.sku === key) // raw first — old behavior intact
    || SAMPLE_DISPLAY.get(key);                                        // then the redacted display form
  if (!p) return res.status(404).send('Not found');
  res.redirect(302, p.product_url || `${DW_SHOPIFY}/products/${encodeURIComponent(p.handle)}#sample`);
});

app.get('/robots.txt', (req, res) => {
  res.type('text/plain').send(`User-agent: *\nAllow: /\nSitemap: https://wallsandfabrics.com/sitemap.xml\n`);
});
app.get('/sitemap.xml', (req, res) => {
  const urls = ['/'];
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(u => `  <url><loc>https://wallsandfabrics.com${u}</loc><changefreq>weekly</changefreq></url>`).join('\n')}
</urlset>`;
  res.type('application/xml').send(xml);
});

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

function loadJsonFallback(reason) {
  console.error(`[${__SITE}] ${reason} — falling back to data/products.json`);
  let raw = [];
  try {
    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');
  } catch (err) {
    console.error(`[${__SITE}] FATAL: could not load products.json — ${err.message}`);
    raw = [];
  }
  buildCatalog(raw);
}

// Load the catalog from dw_unified, then start listening.
//   • PG down / 0 rows  → data/products.json fallback.
//   • PG rows present but the surface model would drop them all (Stage-2 Phase A
//     migrated wallsandfabrics metadata WITHOUT image_url — this site's source
//     JSON carries images in an images[] array the column-map missed) → also
//     fall back to JSON so the storefront still renders real product images.
//     Once microsite_products.image_url is backfilled for this slug, the PG
//     path takes over automatically with no code change.
(async () => {
  let loaded = false;
  if (catalog) {
    try {
      const rows = await catalog.getProducts(SITE_SLUG);
      if (!rows.length) throw new Error('microsite_products returned 0 rows');
      buildCatalog(rows);
      if (!PRODUCTS.length) {
        loadJsonFallback(`PG returned ${rows.length} rows but the surface model kept 0 (no image_url in microsite_products)`);
      } else {
        console.log(`[${__SITE}] catalog source: dw_unified.microsite_products (${rows.length} rows)`);
      }
      loaded = true;
    } catch (e) {
      loadJsonFallback(`PG catalog load failed (${e.message})`);
    }
  }
  if (!loaded) {
    loadJsonFallback('admin disabled — static mode');
  }
  buildSampleDisplayMap(); // Step 2: redacted->raw /sample resolver keys
  app.listen(PORT, '127.0.0.1', () => {
    console.log(`[${__SITE}] listening on http://127.0.0.1:${PORT}`);
  });
})();