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