← back to Dw Yolo Loop
artmura-site/server.js
173 lines
/* DW vendor-landing — editorial brand lookbook over a vendor's catalog.
Read-only: links OUT to the live Shopify store for purchase (no commerce duplication).
Config-driven (site.config.js) so other DW lines reuse the same template. */
const express = require('express');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const VENDOR = process.env.VENDOR || 'artmura';
const CFG = require('./site.config.js')[VENDOR];
if (!CFG) { console.error(`No site.config for VENDOR=${VENDOR}`); process.exit(1); }
// TK-11200 — showroom-only guard. A showroom line (Phillip Jeffries) stays "addressable
// but not discoverable"; this app IS a discovery surface (a public, browsable, indexable
// grid on its own subdomain), so it refuses to serve one. Checks the config's vendor/line
// AND the VENDOR slug, so a renamed slug can't slip past. Override: ALLOW_SHOWROOM=1.
const showroom = require('./showroom.js');
for (const cand of [CFG.vendor, CFG.line, VENDOR]) {
if (cand && !showroom.assertNotShowroom(cand, 'serving a public vendor microsite')) process.exit(2);
}
// BUNDLE=1 → self-contained deploy: read data/images from alongside the app (./_data, ./_images),
// so the host needs only this one directory (no repo-relative ../ paths).
const BUNDLE = process.env.BUNDLE === '1';
const HANDLES_FILE = CFG.handlesFile || 'scripts/artmura-onboard/data/artmura-store-handles.json';
const PKG = BUNDLE ? path.join(__dirname, '_data', path.basename(CFG.dataFile)) : path.join(ROOT, CFG.dataFile);
const COLORS_PATH = BUNDLE ? path.join(__dirname, '_data', path.basename(CFG.colorsFile || 'none.json')) : path.join(ROOT, CFG.colorsFile || 'none.json');
const HANDLES_PATH = BUNDLE ? path.join(__dirname, '_data', path.basename(HANDLES_FILE)) : path.join(ROOT, HANDLES_FILE);
const IMG_DIR = BUNDLE ? path.join(__dirname, '_images') : path.join(ROOT, CFG.imagePrefix || '.');
const PORT = process.env.PORT || 9921;
const app = express();
// --- load + normalize catalog ---
// Artmura has downloaded local images (CFG.localImages); generic lines use CDN URLs directly.
function localImages(r) {
const mapped = (r.images || []).map((src, i) => {
if (!CFG.localImages) return src; // pass CDN url straight through
const base = src.split('/').pop().split('?')[0];
const ext = path.extname(base) || '.jpg';
return `/images/${r.mfr_sku}-${i + 1}${ext}`;
});
// dedup repeated images (vendors often repeat the same shot) — keeps the gallery honest
const seen = new Set();
return mapped.filter(u => { const k = (u || '').split('?')[0]; if (!u || seen.has(k)) return false; seen.add(k); return true; });
}
// derive a clean { eyebrow=pattern, name=colorway } from a "Pattern - Colorway" title.
// strips trailing redundant "Wallcovering(s)" and NEVER uses raw tags (which are often junk
// like "Collection: Texture Resource 8" / "Bedroom" / "Priced Per Single Roll").
function splitName(r) {
const t = (r.title || '').replace(/(\s+Wallcoverings?){1,2}\s*$/i, '').replace(/\s{2,}/g, ' ').trim();
const d = t.lastIndexOf(' - ');
if (d > 0) return { eyebrow: t.slice(0, d).trim(), name: t.slice(d + 3).trim() };
return { eyebrow: r.collection_book || '', name: t }; // no " - ": whole title is the name
}
let CATALOG = [];
function load() {
const d = JSON.parse(fs.readFileSync(PKG, 'utf8'));
let colors = {}, handles = {};
try { colors = JSON.parse(fs.readFileSync(COLORS_PATH, 'utf8')); } catch {}
try { handles = JSON.parse(fs.readFileSync(HANDLES_PATH, 'utf8')); } catch {}
// defense in depth: drop individually showroom-TAGGED products even on a sellable
// vendor (shared-label lines like MDC under "Phillipe Romano"). Snapshots built before
// the build-line.js guard existed can still carry them.
const before = d.products.length;
const rows = d.products.filter(r => !showroom.isShowroomProduct(r));
const droppedShowroom = before - rows.length;
CATALOG = rows.map(r => {
const imgs = localImages(r);
const c = colors[r.mfr_sku] || {};
// build-line lines carry the live DW handle directly; Artmura maps newwall-sku → DW handle
const liveHandle = handles[(r.mfr_sku || '').toUpperCase()] || r.handle || null;
const nm = splitName(r);
return {
store_url: liveHandle ? `${CFG.storeBase}/products/${liveHandle}` : CFG.storeBase,
display_eyebrow: nm.eyebrow, display_name: nm.name,
hex: c.hex || null, hue: c.hue ?? null, color_bucket: c.bucket || null,
sat: c.sat ?? null, val: c.val ?? null, colorway: c.colorway || null,
styles: c.styles || [], motifs: c.motifs || [],
handle: r.handle,
sku: r.mfr_sku,
series: r.pattern_series,
color: r.color,
title: r.title,
book: r.collection_book,
tags: r.tags,
price: r.price_newwall_retail, // newwall retail reference
sample_price: r.sample_price,
width: r.dimensions,
substrate: r.substrate,
grade: r.grade,
coverage: r.wall_coverage,
lead_time: r.lead_time,
origin: r.origin,
sold_by: r.sold_by,
body_html: r.body_html,
// (vendor's own product_url intentionally NOT exposed — this landing links only to the DW store)
swatch: imgs[0] || null, // flat pattern swatch
room: imgs[1] || imgs[0] || null, // styled room shot when present
images: imgs,
published_at: r.published_at,
};
});
if (droppedShowroom) console.log(`[${VENDOR}] [showroom] hid ${droppedShowroom} showroom-only product(s) from the grid`);
console.log(`[${VENDOR}] loaded ${CATALOG.length} products`);
}
load();
// --- API ---
app.get('/api/config', (_req, res) => res.json({
// house identity (Designer Wallcoverings) — drives the corner wordmark + nav + footer
house: CFG.house || CFG.vendor, houseUrl: CFG.houseUrl || CFG.storeBase,
houseTagline: CFG.houseTagline || '', nav: CFG.nav || [],
// featured line
vendor: CFG.vendor, line: CFG.line || CFG.vendor, wordmark: CFG.wordmark,
eyebrow: CFG.eyebrow || '', kicker: CFG.kicker, tagline: CFG.tagline,
booksHeading: CFG.booksHeading, title: CFG.title, metaDescription: CFG.metaDescription,
storeBase: CFG.storeBase, collectionUrl: CFG.collectionUrl || CFG.storeBase, palette: CFG.palette,
about: CFG.about || '',
}));
app.get('/api/products', (_req, res) => res.json({ count: CATALOG.length, products: CATALOG }));
app.get('/api/product/:handle', (req, res) => {
const p = CATALOG.find(x => x.handle === req.params.handle);
if (!p) return res.status(404).json({ error: 'not found' });
res.json(p);
});
// "Pairs well with" — interior-designer logic: same/adjacent color family, different
// pattern series (contrast of scale), prefer same collection book. Returns up to 6.
app.get('/api/pairs/:handle', (req, res) => {
const p = CATALOG.find(x => x.handle === req.params.handle);
if (!p) return res.status(404).json({ error: 'not found' });
const hueDist = (a, b) => { if (a == null || b == null) return 180; const d = Math.abs(a - b) % 360; return d > 180 ? 360 - d : d; };
const scored = CATALOG.filter(x => x.handle !== p.handle).map(x => {
let s = 0;
if (x.color_bucket && x.color_bucket === p.color_bucket) s += 40; // same color family
s += Math.max(0, 30 - hueDist(x.hue, p.hue) / 2); // hue proximity
if (x.series !== p.series) s += 20; // different motif/scale
if (x.book && x.book === p.book) s += 12; // same book
if (x.color_bucket && ['grey', 'white', 'black'].includes(x.color_bucket) && !['grey','white','black'].includes(p.color_bucket)) s += 8; // neutral pairs with chromatic
return { x, s };
}).sort((a, b) => b.s - a.s).slice(0, 6).map(o => o.x);
res.json({ pairs: scored });
});
app.get('/api/facets', (_req, res) => {
const books = {}, series = {}, colors = {}, styles = {}, motifs = {};
for (const p of CATALOG) {
if (p.book) books[p.book] = (books[p.book] || 0) + 1;
if (p.series) series[p.series] = (series[p.series] || 0) + 1;
if (p.color_bucket) colors[p.color_bucket] = (colors[p.color_bucket] || 0) + 1;
for (const s of p.styles || []) styles[s] = (styles[s] || 0) + 1;
for (const m of p.motifs || []) motifs[m] = (motifs[m] || 0) + 1;
}
res.json({
total: CATALOG.length,
books: Object.entries(books).sort((a, b) => b[1] - a[1]),
series: Object.entries(series).sort((a, b) => b[1] - a[1]),
colors: Object.entries(colors).sort((a, b) => b[1] - a[1]),
styles: Object.entries(styles).sort((a, b) => b[1] - a[1]),
motifs: Object.entries(motifs).sort((a, b) => b[1] - a[1]),
});
});
// --- images (served from the gitignored ref dir) ---
app.use('/images', express.static(IMG_DIR, { maxAge: '7d' }));
app.use(express.static(path.join(__dirname, 'public')));
// clean PDP route
app.get('/product/:handle', (_req, res) =>
res.sendFile(path.join(__dirname, 'public', 'product.html')));
app.listen(PORT, () => console.log(`${CFG.line} landing (${CFG.house}) → http://127.0.0.1:${PORT}`));