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