← back to Apartmentwallpaper
server.js
453 lines
/**
* APARTMENT 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 || 9882;
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 = ["peel","stick","removable","renter","apartment","temporary"];
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 slice — recomputed once the catalog loads (see startup IIFE).
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;
// 404-guard: never serve snapshot/backup files from the static root.
app.use((req, res, next) => {
if (/\.(bak|pre-[^/]*)(\.[^/]*)?$|\.bak(\.[^/]*)?$|\/index\.html\.pre-/i.test(req.path)
|| /\.bak\b/i.test(req.path) || /\.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 vendor field).
app.use(require('../_shared/api-vendor-redact'));
// Clean URLs: 301 the legacy /foo.html form to the extension-less /foo.
// Runs BEFORE express.static so the static handler can't serve the .html copy.
for (const page of ['care', 'history', 'sourcing', 'trade', 'vocabulary']) {
app.get('/' + page + '.html', (req, res) => res.redirect(301, '/' + page));
}
require('../_shared/_universal-promo-banner')(app, {}); // new-arrivals promo strip (Tier B)
app.use(express.static(path.join(__dirname, 'public')));
app.get('/privacy', (req, res) => res.sendFile(path.join(__dirname, 'public', 'privacy.html')));
// Clean-URL routes for editorial pages (extension-less nav links).
const PAGE_ROUTES = {
'/about': 'history.html',
'/history': 'history.html',
'/care': 'care.html',
'/sourcing': 'sourcing.html',
'/trade': 'trade.html',
'/vocabulary': 'vocabulary.html',
};
for (const [route, file] of Object.entries(PAGE_ROUTES)) {
app.get(route, (req, res) => {
res.sendFile(path.join(__dirname, 'public', file));
});
}
function sortProducts(list, sort) {
if (!sort || sort === 'newest') return list;
const arr = list.slice();
const num = v => { const n = Number(v); return isNaN(n) ? null : n; };
switch (sort) {
case 'title':
return arr.sort((a, b) => String(a.title || '').localeCompare(String(b.title || '')));
case 'sku':
return arr.sort((a, b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
case 'color':
return arr.sort((a, b) => String(a.color || a.color_name || '').localeCompare(String(b.color || b.color_name || '')));
case 'style':
return arr.sort((a, b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')));
case 'price-asc':
return arr.sort((a, b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
case 'price-desc':
return arr.sort((a, b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
// List-view sortable columns — every column sortable in both directions.
case 'title-desc':
return arr.sort((a, b) => String(b.title || '').localeCompare(String(a.title || '')));
case 'sku-desc':
return arr.sort((a, b) => String(b.sku || b.handle || '').localeCompare(String(a.sku || a.handle || '')));
case 'vendor':
return arr.sort((a, b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
case 'vendor-desc':
return arr.sort((a, b) => String(b.vendor || '').localeCompare(String(a.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
case 'aesthetic':
return arr.sort((a, b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
case 'aesthetic-desc':
return arr.sort((a, b) => String(b.aesthetic || '').localeCompare(String(a.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
case 'light-dark':
case 'dark-light':
case 'wheel':
// hue/lightness sort if a color hint exists; otherwise leave natural order
return arr;
default:
return arr;
}
}
// Shared query-filter — applied identically by /api/products AND /api/facets so
// facet counts reflect the actual filtered result set, not the whole catalog.
// `skip` lets the facets endpoint exclude one dimension (drill-down counts).
function filterProducts(query, skip) {
let list = PRODUCTS_NICHE;
const { q, aesthetic, vendor, tag } = query;
if (skip !== 'q' && q) {
const needle = String(q).toLowerCase();
list = list.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 !== 'aesthetic' && aesthetic && aesthetic !== 'all') list = list.filter(p => p.aesthetic === aesthetic);
if (skip !== 'tag' && tag) {
const wanted = String(tag).toLowerCase();
list = list.filter(p => (p.tags || []).some(t => String(t).toLowerCase() === wanted));
}
if (skip !== 'vendor' && vendor && vendor !== 'all') list = list.filter(p => p.vendor === vendor);
return list;
}
app.get('/api/products', (req, res) => {
const { sort, page = 1, limit = 24 } = req.query;
let list = filterProducts(req.query);
list = sortProducts(list, sort);
const total = list.length;
const pageNum = Math.max(1, parseInt(page) || 1);
const lim = Math.max(1, Math.min(60, parseInt(limit, 10) || 24));
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) => {
// Each facet dimension is counted over the set filtered by the OTHER active
// filters (drill-down counts), so the numbers track the live result set
// instead of the unfiltered catalog.
const aesthetics = {}; const vendors = {};
for (const p of filterProducts(req.query, 'aesthetic')) {
aesthetics[p.aesthetic] = (aesthetics[p.aesthetic] || 0) + 1;
}
for (const p of filterProducts(req.query, 'vendor')) {
vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
}
res.json({ aesthetics, vendors, total: filterProducts(req.query).length });
});
app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));
// 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(`[apartmentwallpaper] /sample display-slug collision — keeping raw-only for "${d}"`);
}
SAMPLE_DISPLAY = map;
console.log(`[apartmentwallpaper] /sample dual-key map: ${map.size} display slugs, ${collided.size} collisions (raw-only)`);
}
// Dual-key product resolver — raw handle/sku first (old bookmarks + buy path),
// then the redacted display slug. Shared by /sample/:handle and /products/:handle.
function resolveProduct(key) {
return PRODUCTS_NICHE.find(x => x.handle === key || x.sku === key)
|| SAMPLE_DISPLAY.get(key) || null;
}
// /sample/:handle — stays ON-SITE. Never redirect to designerwallcoverings.com
// (Steve's #1 rule). Sends the visitor to the on-site PDP with the sample anchor;
// the client card-override opens the Sample modal directly when JS is on.
app.get('/sample/:handle', (req, res) => {
const p = resolveProduct(req.params.handle);
if (!p) return res.status(404).send('Not found');
res.redirect(302, `/products/${encodeURIComponent(p.handle)}#sample`);
});
// ---- Per-product PDP (SSR) — real, GMC-valid product pages on our own domain --
function pdpEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
// Image allowlist mirrors the client safeImg (DW Shopify CDN + same-origin /img proxy).
function pdpImg(u) {
return (/^https:\/\/(?:cdn\.shopify\.com|designerwallcoverings\.com)\//.test(u || '')
|| /^\/img\/[A-Za-z0-9._-]+$/.test(u || '')) ? u : '/hero-bg.jpg';
}
function renderPDP(p) {
const title = p.title || p.pattern_name || 'Peel & Stick Wallpaper';
const sku = p.sku || p.handle;
const img = pdpImg(p.image_url);
const price = (typeof p.price === 'number' && p.price > 0) ? p.price : null;
const priceStr = price != null ? price.toFixed(2) : null;
const inStock = p.availability !== 'out of stock';
const availLabel = inStock ? 'In stock' : 'Out of stock';
const availSchema = inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';
const desc = (p.description || '').trim();
const metaDesc = (desc || `${title} — removable peel-and-stick wallcovering. Renter-friendly, no-damage install.`).slice(0, 300);
const body = (p.body_html && String(p.body_html).trim()) || `<p>${pdpEsc(desc)}</p>`;
const canonical = `https://apartmentwallpaper.com/products/${encodeURIComponent(p.handle)}`;
// Product JSON-LD (structured-data-jsonld shape). brand ALWAYS "Designer
// Wallcoverings"; price + availability MUST match the visible HTML above.
const graph = [
{ '@type': 'Organization', '@id': 'https://apartmentwallpaper.com/#org', name: 'Designer Wallcoverings', url: 'https://apartmentwallpaper.com' },
{ '@type': 'WebSite', '@id': 'https://apartmentwallpaper.com/#website', name: 'Apartment Wallpaper', url: 'https://apartmentwallpaper.com', publisher: { '@id': 'https://apartmentwallpaper.com/#org' } },
{
'@type': 'Product', name: title, sku, image: [img],
description: metaDesc, brand: { '@type': 'Brand', name: 'Designer Wallcoverings' },
...(price != null ? { offers: { '@type': 'Offer', price: priceStr, priceCurrency: 'USD', availability: availSchema, url: canonical, itemCondition: 'https://schema.org/NewCondition' } } : {}),
},
{ '@type': 'BreadcrumbList', itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://apartmentwallpaper.com/' },
{ '@type': 'ListItem', position: 2, name: title, item: canonical },
] },
];
const ld = JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(/</g, '\\u003c');
return `<!doctype html>
<html lang="en"><head>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5278231299883833" crossorigin="anonymous"></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-KP5P2XHN8Y"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-KP5P2XHN8Y');</script>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${pdpEsc(title)} — Apartment Wallpaper</title>
<meta name="description" content="${pdpEsc(metaDesc)}">
<link rel="canonical" href="${pdpEsc(canonical)}">
<meta property="og:type" content="product">
<meta property="og:title" content="${pdpEsc(title)}">
<meta property="og:description" content="${pdpEsc(metaDesc)}">
<meta property="og:image" content="${pdpEsc(img)}">
<meta property="og:url" content="${pdpEsc(canonical)}">
${price != null ? `<meta property="product:price:amount" content="${priceStr}"><meta property="product:price:currency" content="USD">` : ''}
<script type="application/ld+json">${ld}</script>
<link rel="stylesheet" href="/page-theme.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:ui-sans-serif,system-ui;background:#F4F1EC;color:#1A2B40;line-height:1.6;min-height:100vh}
.pdp{max-width:1080px;margin:0 auto;padding:96px 24px 64px}
.crumb{font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#7a7268;margin-bottom:22px}
.crumb a{color:#7a7268;text-decoration:none} .crumb a:hover{color:#1A2B40}
.grid{display:grid;grid-template-columns:1.1fr 1fr;gap:48px;align-items:start}
@media(max-width:760px){.grid{grid-template-columns:1fr;gap:28px}.pdp{padding:80px 18px 48px}}
.hero{width:100%;aspect-ratio:1/1;object-fit:cover;background:#e8e4dc;border:1px solid #e5e0d6}
.eyebrow{font-size:10px;letter-spacing:0.22em;text-transform:uppercase;color:#7CC288;margin-bottom:14px}
h1{font-family:Georgia,serif;font-style:italic;font-size:40px;line-height:1.12;letter-spacing:-0.01em;margin-bottom:16px}
@media(max-width:760px){h1{font-size:30px}}
.sku{font-size:12px;letter-spacing:0.12em;text-transform:uppercase;color:#8a8278;margin-bottom:20px}
.price{font-size:26px;font-weight:600;margin-bottom:8px}
.avail{font-size:12px;letter-spacing:0.1em;text-transform:uppercase;margin-bottom:24px}
.avail.in{color:#3f8f52}.avail.out{color:#a15}
.desc{color:#3a322c;margin-bottom:28px;font-size:15px}
.body{color:#3a322c;font-size:14px;margin-bottom:28px}.body p{margin-bottom:12px}
.actions{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:28px}
.buy-btn,.sample-cta{padding:14px 26px;font:700 11px ui-sans-serif;letter-spacing:0.2em;text-transform:uppercase;border:0;cursor:pointer;border-radius:6px;text-decoration:none;display:inline-block}
.buy-btn{background:#1a1a1a;color:#fff}.buy-btn:hover{background:#333}
.aw-no-buy .buy-btn{display:none}
.sample-cta{background:transparent;color:#1A2B40;border:1px solid #1A2B40}.sample-cta:hover{background:#1A2B40;color:#fff}
nav.foot{margin-top:56px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.10);font-size:11px;letter-spacing:0.18em;text-transform:uppercase}
nav.foot a{margin-right:22px;text-decoration:none;color:#1A2B40}
</style>
</head><body data-pdp>
<div class="pdp">
<div class="crumb"><a href="/">Apartment Wallpaper</a> / <a href="/">Browse</a> / ${pdpEsc(title)}</div>
<div class="grid">
<img class="hero" src="${pdpEsc(img)}" alt="${pdpEsc(title)}" width="1080" height="1080">
<div>
<div class="eyebrow">Designer Wallcoverings · Peel & Stick</div>
<h1>${pdpEsc(title)}</h1>
<div class="sku">SKU ${pdpEsc(sku)}</div>
${price != null ? `<div class="price">$${priceStr}</div>` : ''}
<div class="avail ${inStock ? 'in' : 'out'}">${availLabel}</div>
${desc ? `<div class="desc">${pdpEsc(desc)}</div>` : ''}
<div class="actions">
${p.shopify_id ? `<button class="buy-btn" data-pid="${pdpEsc(p.shopify_id)}">Add to Cart</button>` : ''}
<a class="sample-cta" href="/sample/${encodeURIComponent(p.handle)}" onclick="event.preventDefault();awOpenSample&&awOpenSample(this)" data-sku="${pdpEsc(sku)}" data-title="${pdpEsc(title)}" data-img="${pdpEsc(img)}">Order a Sample</a>
</div>
<div class="body">${body}</div>
</div>
</div>
<nav class="foot">
<a href="/">Browse</a><a href="/history">History</a><a href="/vocabulary">Vocabulary</a><a href="/sourcing">Sourcing</a><a href="/care">Care</a><a href="/trade">Trade</a><a href="/about">About</a>
</nav>
</div>
<script>
// Sample CTA — mailto fallback (no client sample-modal on the SSR PDP page).
function awOpenSample(el){
var sku=el.getAttribute('data-sku'), t=el.getAttribute('data-title');
location.href='mailto:info@apartmentwallpaper.com?subject='+encodeURIComponent('Sample request: '+t+' ('+sku+')')
+'&body='+encodeURIComponent('I would like to request a sample of '+t+' (SKU '+sku+').');
}
</script>
<script src="/dw-header.js" defer></script>
<script src="/page-theme.js" defer></script>
<script src="/aw-config.js"></script>
<script src="/buybutton.js" defer></script>
</body></html>`;
}
app.get('/products/:handle', (req, res) => {
const p = resolveProduct(req.params.handle);
if (!p) return res.status(404).type('html').send('<!doctype html><meta charset=utf-8><title>Not found</title><body style="font-family:ui-sans-serif;padding:80px;text-align:center"><h1>404</h1><p>That pattern isn\'t here. <a href="/">Browse the collection</a>.</p>');
res.type('html').send(renderPDP(p));
});
// /about → served by the "short history" page (no dedicated about.html exists)
app.get('/about', (req, res) => res.sendFile(path.join(__dirname, 'public', 'history.html')));
// Buy Button config — exposes the Shopify Storefront token (env) to the client so
// checkout happens ON apartmentwallpaper.com. Empty token => buybutton.js hides
// Add-to-Cart and the Sample flow stays the only CTA (graceful, never broken).
app.get('/aw-config.js', (req, res) => {
res.type('application/javascript').set('Cache-Control', 'no-store').send(
'window.AW_CFG=' + JSON.stringify({
domain: process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com',
storefrontToken: process.env.SHOPIFY_STOREFRONT_TOKEN || ''
}) + ';'
);
});
// sitemap.xml + robots.txt for SEO
app.get('/robots.txt', (req, res) => {
res.type('text/plain').send(`User-agent: *
Allow: /
Sitemap: https://apartmentwallpaper.com/sitemap.xml
`);
});
app.get('/sitemap.xml', (req, res) => {
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<');
const staticUrls = ['/', '/history', '/vocabulary', '/sourcing', '/care', '/trade', '/about']
.map(u => ` <url><loc>https://apartmentwallpaper.com${u}</loc><changefreq>weekly</changefreq></url>`);
const productUrls = PRODUCTS_NICHE.map(p =>
` <url><loc>https://apartmentwallpaper.com/products/${esc(encodeURIComponent(p.handle))}</loc><changefreq>weekly</changefreq></url>`);
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${staticUrls.concat(productUrls).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
// Pre-warm the vendor-neutral /img token map so direct /img/<token> hits resolve
// immediately after a restart (closes the cold-start 404 window).
try {
const warmed = require('../_shared/api-vendor-redact').warm(PRODUCTS);
console.log(`[${__SITE}] /img token map pre-warmed: ${warmed} entries`);
} catch (e) { console.error(`[${__SITE}] /img warm skipped: ${e.message}`); }
app.listen(PORT, '127.0.0.1', () => {
console.log(`apartmentwallpaper listening on http://127.0.0.1:${PORT}`);
});
})();