← back to Blockprintedwallpaper
server.js
278 lines
/**
* BLOCK-PRINTED WALLPAPER — DW family vertical
* Curated slice from live designerwallcoverings.com Shopify catalog.
*/
try { require('dotenv').config({ path: require('path').join(__dirname, '.env') }); } catch (e) {}
const express = require('express');
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 : [];
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 = ["woodblock","block-printed","block print","hand block","hand-stamped","hand printed","artisan"];
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();
app.use(express.json({ limit: "256kb" }));
// Site config — prefer _shared/site-config (canonical) but fall back to an
// inline minimal loader so this server.js is self-contained on Kamatera.
let cfg;
try {
cfg = require('../_shared/site-config').load(__dirname);
} catch (_) {
const domain = siteCfg.domain || `${SITE_SLUG}.com`;
cfg = Object.freeze({
slug: SITE_SLUG, siteName: siteCfg.siteName || SITE_SLUG,
domain, siteEmail: siteCfg.siteEmail || `info@${domain}`,
contact: { siteName: siteCfg.siteName || SITE_SLUG, domain,
siteEmail: siteCfg.siteEmail || `info@${domain}`,
zdColor: (siteCfg.theme && siteCfg.theme.accent) || '#C9A14B',
zdPosition: siteCfg.zdPosition || 'right' },
auth: { siteName: SITE_SLUG },
});
}
require("./_universal-contact")(app, cfg.contact);
require("./_universal-auth")(app, cfg.auth);
app.locals.siteConfig = cfg;
// Fleet-wide DW vendor-name redactor on /api/* (drops vendors facet, replaces vendor field).
app.use(require('../_shared/api-vendor-redact'));
// Clean-URL canonicalization — 301 the old `.html` form to the extension-less
// route so there's one canonical URL per page (no duplicate-content / SEO
// split). Must run BEFORE express.static, else static serves the .html first.
const CLEAN_REDIRECTS = ['index', 'history', 'vocabulary', 'sourcing', 'care', 'trade'];
app.get(CLEAN_REDIRECTS.map(s => '/' + s + '.html'), (req, res) => {
const slug = req.path.replace(/^\//, '').replace(/\.html$/, '');
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')));
function sortProducts(list, sort) {
if (!sort || sort === 'newest') return list;
const arr = list.slice();
const txt = (p, ...keys) => keys.map(k => (p[k] || '')).join(' ').toLowerCase();
switch (sort) {
case 'title':
return arr.sort((a, b) => txt(a, 'title').localeCompare(txt(b, 'title')));
case 'title-desc':
return arr.sort((a, b) => txt(b, 'title').localeCompare(txt(a, 'title')));
case 'sku':
return arr.sort((a, b) => txt(a, 'sku', 'handle').localeCompare(txt(b, 'sku', 'handle')));
case 'sku-desc':
return arr.sort((a, b) => txt(b, 'sku', 'handle').localeCompare(txt(a, 'sku', 'handle')));
case 'style':
return arr.sort((a, b) => txt(a, 'aesthetic').localeCompare(txt(b, 'aesthetic')) || txt(a, 'title').localeCompare(txt(b, 'title')));
case 'aesthetic':
return arr.sort((a, b) => txt(a, 'aesthetic').localeCompare(txt(b, 'aesthetic')) || txt(a, 'title').localeCompare(txt(b, 'title')));
case 'aesthetic-desc':
return arr.sort((a, b) => txt(b, 'aesthetic').localeCompare(txt(a, 'aesthetic')) || txt(b, 'title').localeCompare(txt(a, 'title')));
case 'vendor':
return arr.sort((a, b) => txt(a, 'vendor').localeCompare(txt(b, 'vendor')) || txt(a, 'title').localeCompare(txt(b, 'title')));
case 'vendor-desc':
return arr.sort((a, b) => txt(b, 'vendor').localeCompare(txt(a, 'vendor')) || txt(b, 'title').localeCompare(txt(a, 'title')));
case 'price-asc':
return arr.sort((a, b) => (Number(a.price) || 0) - (Number(b.price) || 0));
case 'price-desc':
return arr.sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0));
case 'color':
case 'light-dark':
case 'dark-light':
case 'wheel':
// No per-product color data in this catalog — keep natural order.
return arr;
default:
return list;
}
}
app.get('/api/products', (req, res) => {
const { q, aesthetic, vendor, sort, page = 1, limit = 24 } = req.query;
let list = PRODUCTS_NICHE;
if (q) {
const needle = q.toLowerCase();
list = list.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.tags || []).some(t => t.toLowerCase().includes(needle)));
}
if (aesthetic && aesthetic !== 'all') list = list.filter(p => p.aesthetic === aesthetic);
if (vendor && vendor !== 'all') list = list.filter(p => p.vendor === vendor);
list = sortProducts(list, sort);
const total = list.length;
const pageNum = Math.max(1, parseInt(page));
const lim = Math.min(60, parseInt(limit));
const start = (pageNum - 1) * lim;
res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total / lim), items: list.slice(start, start + lim) });
});
app.get('/api/sliders', (req, res) => {
const out = [];
for (const a of SITE_RAILS) {
const items = PRODUCTS_NICHE.filter(p => p.aesthetic === a).slice(0, 12);
if (items.length >= 4) out.push({ aesthetic: a, items });
}
res.json({ rails: out });
});
app.get('/api/facets', (req, res) => {
const aesthetics = {}; const vendors = {};
for (const p of PRODUCTS_NICHE) {
aesthetics[p.aesthetic] = (aesthetics[p.aesthetic] || 0) + 1;
vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
}
res.json({ aesthetics, vendors, total: PRODUCTS_NICHE.length });
});
app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));
// /about — the brand history page (history.html). Nav links to /about; serve it here.
app.get('/about', (req, res) => res.sendFile(path.join(__dirname, 'public', 'history.html')));
// Clean-URL routes for extension-less nav links present across all page
// templates (history/vocabulary/sourcing/care/trade). Without these,
// express.static would not auto-strip .html and every cross-page nav 404'd.
const CLEAN_PAGES = ['history', 'vocabulary', 'sourcing', 'care', 'trade'];
for (const slug of CLEAN_PAGES) {
app.get('/' + slug, (req, res) => res.sendFile(path.join(__dirname, 'public', slug + '.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(`[blockprintedwallpaper] /sample display-slug collision — keeping raw-only for "${d}"`);
}
SAMPLE_DISPLAY = map;
console.log(`[blockprintedwallpaper] /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://blockprintedwallpaper.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://blockprintedwallpaper.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 });
// 404 guard — JSON for /api/*, friendly HTML redirect to home for everything
// else. Keeps drive-by URL probes from getting a stack trace or blank page.
app.use((req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'not_found', path: req.path });
}
res.status(404).type('html').send(
'<!doctype html><meta charset=utf-8><title>Not found · blockprintedwallpaper</title>' +
'<meta http-equiv="refresh" content="0; url=/">' +
'<style>body{font-family:Inter,system-ui,sans-serif;background:#FAFAFA;color:#000;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center}a{color:#b85c40;text-decoration:none;border-bottom:1px solid #b85c40;padding-bottom:2px}</style>' +
'<main><p style="font-size:11px;letter-spacing:0.32em;text-transform:uppercase;font-weight:700;color:#8B7866;margin-bottom:16px">404</p>' +
'<p style="font-size:18px;margin-bottom:24px">That page isn’t here.</p>' +
'<a href="/">Return home</a></main>'
);
});
// 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(`blockprintedwallpaper listening on http://127.0.0.1:${PORT}`);
});
})();