← back to Bleachresistantfabrics

server.js

250 lines

/**
 * BLEACH RESISTANT FABRICS — DW family vertical
 * Hospitality + healthcare contract fabrics filtered for bleach-cleanable / Crypton /
 * vinyl-coated / performance / antimicrobial. Read-only.
 *
 * Catalog now lives in dw_unified.microsite_products (Stage-2 Phase A migration);
 * loaded async at startup via the shared admin-catalog module. Falls back to the
 * static data/products.json bundled in this repo if PG is unreachable.
 */
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');

// 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(`[bleachresistantfabrics] 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 = 'bleachresistantfabrics';
const SITE_SLUG = siteCfg.slug || SITE;          // do not assume slug === dir name
const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];
const PORT = process.env.PORT || siteCfg.port || 9921;
const DW_SHOPIFY = 'https://designerwallcoverings.com';

// Static-JSON fallback loader — used only if the PG read-path fails at startup.
function loadStaticRaw() {
  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}] static fallback load failed — ${e.message}`);
    return [];
  }
}

function isJunk(p) {
  if (!p.image_url || !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;
}

// Niche filter: anything whose tags/title surface a bleach-cleanable signal.
// This is a hospitality/healthcare-cleanability niche, so the catch-net is wide:
// vinyl-coated wallcovering and Type-II vinyl are functionally "bleach-resistant"
// surfaces under the same hospitality cleaning protocol as Crypton fabric.
const NICHE_RE = /crypton|bleach|vinyl|performance|healthcare|antimicrobial|moisture|stain[\s-]?resist|easy[\s-]?clean|wipe[\s-]?clean|type[\s-]?(2|ii)|class[\s-]?a|contract|commercial/i;
function nicheFit(p) {
  const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ') + ' ' + (p.product_type || '')).toLowerCase();
  return NICHE_RE.test(blob);
}

// Run a raw catalog source (PG rows or static JSON) through junk + niche filters.
function pipeline(raw) {
  const clean = (Array.isArray(raw) ? raw : []).filter(p => !isJunk(p));
  return clean.filter(nicheFit);
}

// Filled async at startup (see bottom of file). PG read-path → static fallback.
let PRODUCTS = [];

// Sort helpers (DW standard)
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,
  red: 20, pink: 21, coral: 22, orange: 23, peach: 24,
  yellow: 30, gold: 31, mustard: 32, olive: 33,
  green: 40, mint: 42, teal: 50, aqua: 51, turquoise: 52,
  blue: 60, navy: 61, indigo: 62,
  purple: 70, violet: 71, lavender: 72, plum: 73, magenta: 74,
};
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', 'mid-century', 'rustic', 'industrial', 'geometric', 'floral', 'botanical', 'damask', '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';
}
function sortProducts(list, mode) {
  if (mode === 'sku') return [...list].sort((a, b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
  if (mode === 'title') return [...list].sort((a, b) => String(a.title || '').localeCompare(String(b.title || '')));
  if (mode === 'color') return [...list].sort((a, b) => colorRank(a) - colorRank(b) || String(a.title || '').localeCompare(String(b.title || '')));
  if (mode === 'style') return [...list].sort((a, b) => styleKey(a).localeCompare(styleKey(b)) || String(a.title || '').localeCompare(String(b.title || '')));
  if (mode === 'price-asc') return [...list].sort((a, b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
  if (mode === 'price-desc') return [...list].sort((a, b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
  return list;
}

// Cleanability rail matchers
const RAIL_MATCHERS = {
  'crypton':           /crypton/i,
  'vinyl-coated':      /vinyl[\s-]?coat|coated[\s-]?vinyl|type[\s-]?(2|ii)/i,
  'performance':       /performance|stain[\s-]?resist|easy[\s-]?clean|wipe[\s-]?clean/i,
  'healthcare':        /healthcare|health[\s-]?care|medical|hospital(?!ity)/i,
  'moisture-barrier':  /moisture|water[\s-]?repel|water[\s-]?proof|backing/i,
  'antimicrobial':     /antimicrobial|microbe|silver[\s-]?ion|copper[\s-]?ion/i,
};
function matchesRail(p, key) {
  const re = RAIL_MATCHERS[key];
  if (!re) return true;
  const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
  return re.test(blob);
}

// Apply the active query/rail/vendor filters to a product list. `skip` lets a
// facet exclude its own dimension so its counts reflect the rest of the active
// filter set (standard drill-down facet behaviour), not the whole catalog.
function filterProducts(list, { q, rail, vendor } = {}, skip = {}) {
  let out = list;
  if (q && !skip.q) {
    const needle = String(q).toLowerCase();
    out = out.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.tags || []).some(t => String(t).toLowerCase().includes(needle)));
  }
  if (rail && rail !== 'all' && RAIL_MATCHERS[rail] && !skip.rail) out = out.filter(p => matchesRail(p, rail));
  if (vendor && vendor !== 'all' && !skip.vendor) out = out.filter(p => p.vendor === vendor);
  return out;
}

const app = express();
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json({ limit: '256kb' }));
app.use((req, res, next) => { res.set('Cache-Control', 'no-store, must-revalidate'); next(); });
// 404-guard snapshot/backup paths so they NEVER serve from static root even
// if a stale file ends up on disk (matches *.bak, *.bak.*, *.pre-*, *.orig, *~).
app.use((req, res, next) => {
  if (/\.(bak|orig)(\.|$)|\.pre-|~$/i.test(req.path)) return res.status(404).send('Not found');
  next();
});

// Fleet-wide DW vendor-name redactor on /api/* (drops `vendors` facet, replaces
// any `vendor` field with "Designer Wallcoverings", strips `?vendor=` query).
app.use(require('../_shared/api-vendor-redact'));

require('../_shared/_universal-promo-banner')(app, { slug: 'bleachresistantfabrics' });
app.use(express.static(path.join(__dirname, 'public')));

app.get('/api/products', (req, res) => {
  const { q, rail, vendor, page = 1, limit = 24, sort = 'newest' } = req.query;
  let list = filterProducts(PRODUCTS, { q, rail, vendor });
  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, items: list.slice(start, start + lim) });
});

app.get('/api/rails', (req, res) => {
  const { q, vendor } = req.query;
  // Count each rail over the active filter set MINUS the rail dimension itself,
  // so the numbers tell the user "how many match if I pick this rail" given
  // their current query/vendor selection — not raw whole-catalog totals.
  const base = filterProducts(PRODUCTS, { q, vendor }, { rail: true });
  const counts = {};
  for (const k of Object.keys(RAIL_MATCHERS)) counts[k] = base.filter(p => matchesRail(p, k)).length;
  res.json({ total: base.length, counts });
});

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

// 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(`[bleachresistantfabrics] /sample display-slug collision — keeping raw-only for "${d}"`);
  }
  SAMPLE_DISPLAY = map;
  console.log(`[bleachresistantfabrics] /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://bleachresistantfabrics.com/sitemap.xml\n`);
});
app.get('/sitemap.xml', (req, res) => {
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://bleachresistantfabrics.com/</loc><changefreq>weekly</changefreq></url>
</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 });

// Load the catalog. When admin is enabled, prefer dw_unified; else static JSON.
(async () => {
  let loaded = false;
  if (catalog) {
    try {
      const rows = await catalog.getProducts(SITE_SLUG);
      PRODUCTS = pipeline(rows);
      console.log(`[${SITE}] loaded ${rows.length} from dw_unified → niche ${PRODUCTS.length}`);
      loaded = true;
    } catch (e) {
      console.error(`[${SITE}] PG catalog load failed (${e.message}) — falling back to data/products.json`);
    }
  }
  if (!loaded) {
    const raw = loadStaticRaw();
    PRODUCTS = pipeline(raw);
    console.log(`[${SITE}] loaded ${raw.length} from data/products.json (static mode) → niche ${PRODUCTS.length}`);
  }
  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}`);
  });
})();