← back to Ffepurchasing
server.js
329 lines
/**
* FFE PURCHASING — B2B trade portal + public hospitality FF&E catalog
* Curated slice from live designerwallcoverings.com Shopify catalog.
*/
const express = require('express');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
const PORT = process.env.PORT || 9883;
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 : [];
// Read-time rail membership: a product belongs to a rail if its `aesthetic` OR
// any `tag` equals the rail key (or a declared synonym). Sister-site catalogs
// have a degenerate `aesthetic` ("all"), so naive `p.aesthetic === key`
// matching collapses every rail to empty — the richer signal lives in `tags`.
// Prefer the shared matcher on dev; fall back to a self-contained copy on prod
// where the _shared/ tree isn't deployed.
let railMatch;
try {
railMatch = require('../_shared/rail-match');
} catch (e) {
const norm = s => String(s == null ? '' : s).trim().toLowerCase();
const productInRail = (p, key, syn) => {
const vals = [norm(key)].concat((syn && Array.isArray(syn[key]) ? syn[key] : []).map(norm));
if (vals.includes(norm(p && p.aesthetic))) return true;
const tags = p && Array.isArray(p.tags) ? p.tags : [];
return tags.some(t => vals.includes(norm(t)));
};
railMatch = {
productInRail,
buildRails(products, railKeys, opts) {
opts = opts || {}; const syn = opts.synonyms || null;
const minShare = opts.minShare != null ? opts.minShare : 0.08;
const minItems = opts.minItems != null ? opts.minItems : 4;
const perRail = opts.perRail != null ? opts.perRail : 12;
const maxShare = opts.maxShare != null ? opts.maxShare : 0.99;
const list = Array.isArray(products) ? products : []; const N = list.length || 1; const out = [];
for (const key of (railKeys || [])) {
const m = list.filter(p => productInRail(p, key, syn)); const c = m.length;
if (c >= minItems && c / N >= minShare && c / N <= maxShare) out.push({ key, aesthetic: key, count: c, items: m.slice(0, perRail) });
}
return out;
},
railFacets(products, railKeys, syn) {
const list = Array.isArray(products) ? products : []; const f = {};
for (const key of (railKeys || [])) f[key] = list.filter(p => productInRail(p, key, syn || null)).length;
return f;
}
};
}
const { productInRail, buildRails, railFacets } = railMatch;
// Synonyms come from the site's own config (prod-safe); the shared default map
// is a dev-only convenience when _shared/ is present.
let RAIL_SYN = siteCfg.railSynonyms || {};
if (!siteCfg.railSynonyms) { try { RAIL_SYN = require('../_shared/rail-synonyms.json'); } catch (e) {} }
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;
if (/bh.?90210|beverly.?hills.?90210|iconic.{0,4}bh/i.test(t)) return true;
return false;
}
// Catalog now lives in dw_unified.microsite_products — populated async at
// startup (see bottom of file). Falls back to data/products.json if PG is down.
let PRODUCTS = [];
let DROPPED = 0;
// graphics-loop pass 12: niche keyword filter — only show products that fit this site's niche
const NICHE_POS = ["contract","commercial","hospitality","hotel","restaurant","class a","type-ii","trade","wholesale","specifier"];
const NICHE_NEG = ["solid","blank","plain"];
function nicheFit(p) {
const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
if (NICHE_NEG.some(n => blob.includes(n.toLowerCase()))) return false;
return NICHE_POS.length === 0 || NICHE_POS.some(k => blob.includes(k.toLowerCase()));
}
// Niche-filtered list, populated at startup alongside PRODUCTS.
let PRODUCTS_NICHE = [];
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
// DW STANDARD: sort by color/style/sku/title for any product-rendering site.
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';
}
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));
// List-view sortable columns — every column sortable in both directions.
if (mode === 'title-desc') return [...list].sort((a,b) => String(b.title || '').localeCompare(String(a.title || '')));
if (mode === 'sku-desc') return [...list].sort((a,b) => String(b.sku || b.handle || '').localeCompare(String(a.sku || a.handle || '')));
if (mode === 'vendor') return [...list].sort((a,b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
if (mode === 'vendor-desc') return [...list].sort((a,b) => String(b.vendor || '').localeCompare(String(a.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
if (mode === 'aesthetic') return [...list].sort((a,b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
if (mode === 'aesthetic-desc') return [...list].sort((a,b) => String(b.aesthetic || '').localeCompare(String(a.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
return list;
}
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json({ limit: '256kb' }));
// 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'));
// Universal contact module — modals, /api/send-inquiry, /api/send-sample, /zd-loader.js
require('./_universal-contact')(app, { siteName: "FFE Purchasing", zdColor: "#d4a847", zdPosition: 'right' });
require('./_universal-auth')(app, { siteName: "ffepurchasing" });
// 404-guard: never serve local snapshot files (.bak, .bak.*, .pre-*) even if
// one accidentally lands in public/. Fires before express.static.
app.use((req, res, next) => {
if (/\.(bak)(\.|$)|\.pre-/i.test(req.path)) return res.status(404).type('text/plain').send('Not found');
next();
});
// Clean-URL canonicalization: 301 the legacy *.html forms to their extension-less
// routes (runs before express.static so the .html never serves a duplicate URL).
app.get(/^\/(.+)\.html$/i, (req, res) => {
const slug = req.params[0];
res.redirect(301, slug === 'index' ? '/' : '/' + slug);
});
require('../_shared/_universal-promo-banner')(app, {}); // new-arrivals promo strip (Tier B)
app.use(express.static(path.join(__dirname, 'public')));
// Shared filter — applied identically by /api/products and /api/facets so facet
// counts reflect the ACTUAL filtered result set (drill-down), not the whole niche
// catalog. Pass `skip` to exclude one dimension (used to compute that dimension's
// own counts over the set narrowed by the OTHER active filters).
function filterProducts(opts, skip) {
const { q, aesthetic, vendor } = opts || {};
let list = PRODUCTS_NICHE;
if (q && skip !== 'q') {
const needle = String(q).toLowerCase();
list = list.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.tags || []).some(t => t.toLowerCase().includes(needle)));
}
if (aesthetic && aesthetic !== 'all' && skip !== 'aesthetic') list = list.filter(p => productInRail(p, aesthetic, RAIL_SYN));
if (vendor && vendor !== 'all' && skip !== 'vendor') list = list.filter(p => p.vendor === vendor);
return list;
}
app.get('/api/products', (req, res) => {
const { q, aesthetic, vendor, page = 1, limit = 24, sort = 'newest'} = req.query;
let list = filterProducts({ q, aesthetic, 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) });
// (replaced) old: pages: Math.ceil(total / lim), items: list.slice(start, start + lim) });
});
app.get('/api/sliders', (req, res) => {
const rails = buildRails(PRODUCTS_NICHE, SITE_RAILS, { synonyms: RAIL_SYN });
res.json({ rails: rails.map(r => ({ aesthetic: r.aesthetic, items: r.items })) });
});
app.get('/api/facets', (req, res) => {
const { q, aesthetic, vendor } = req.query;
// Drill-down counts: each dimension is counted over the set narrowed by the
// OTHER active filters (skip its own value), and `total` reflects the fully
// filtered set — not the unfiltered niche catalog.
const aesthetics = {}; const vendors = {};
for (const p of filterProducts({ q, aesthetic, vendor }, 'aesthetic')) {
aesthetics[p.aesthetic] = (aesthetics[p.aesthetic] || 0) + 1;
}
for (const p of filterProducts({ q, aesthetic, vendor }, 'vendor')) {
vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
}
res.json({ aesthetics, vendors, total: filterProducts({ q, aesthetic, vendor }).length });
});
app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));
// Trade-account inquiry (stub) — appends to data/trade-inquiries.jsonl. No outbound email.
app.post('/api/trade-inquiry', (req, res) => {
const { email, company, name, role } = req.body || {};
if (!email || !company) return res.status(400).json({ ok:false, error:'email and company required' });
if (String(email).length > 200 || String(company).length > 200) return res.status(400).json({ ok:false, error:'too long' });
const row = {
ts: new Date().toISOString(),
ip: (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').toString().split(',')[0].trim(),
email: String(email).slice(0, 200),
company: String(company).slice(0, 200),
name: name ? String(name).slice(0, 120) : '',
role: role ? String(role).slice(0, 80) : '',
ua: (req.headers['user-agent'] || '').toString().slice(0, 200)
};
try {
fs.appendFileSync(path.join(__dirname, 'data', 'trade-inquiries.jsonl'), JSON.stringify(row) + '\n');
} catch (e) {
console.error(`[${__SITE}] trade-inquiry write failed:`, e.message);
return res.status(500).json({ ok:false });
}
res.json({ ok:true });
});
// Trade-account stub page
app.get('/trade-account', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'trade-account.html'));
});
// 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_NICHE) {
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(`[ffepurchasing] /sample display-slug collision — keeping raw-only for "${d}"`);
}
SAMPLE_DISPLAY = map;
console.log(`[ffepurchasing] /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_NICHE.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`);
});
// sitemap.xml + robots.txt for SEO
app.get('/robots.txt', (req, res) => {
res.type('text/plain').send(`User-agent: *
Allow: /
Sitemap: https://ffepurchasing.com/sitemap.xml
`);
});
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://ffepurchasing.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 });
// 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 = rows.filter(p => !isJunk(p));
DROPPED = rows.length - PRODUCTS.length;
console.log(`[${__SITE}] loaded ${rows.length} from dw_unified, kept ${PRODUCTS.length}, dropped ${DROPPED}`);
loaded = true;
} catch (e) {
console.error(`[${__SITE}] PG catalog load failed (${e.message}) — falling back to data/products.json`);
}
}
if (!loaded) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
PRODUCTS = (Array.isArray(raw) ? raw : []).filter(p => !isJunk(p));
console.log(`[${__SITE}] loaded ${PRODUCTS.length} from data/products.json (static mode)`);
} catch (_) { PRODUCTS = []; }
}
PRODUCTS_NICHE = PRODUCTS.filter(nicheFit);
console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}`);
buildSampleDisplayMap(); // Step 2: redacted->raw /sample resolver keys
app.listen(PORT, '127.0.0.1', () => {
console.log(`ffepurchasing listening on http://127.0.0.1:${PORT}`);
});
})();