← back to Designer Wallcoverings

onboarding/sangetsu-lilycolor/viewer/server.js

126 lines

#!/usr/bin/env node
/**
 * Staging viewer for the Sangetsu + Lilycolor onboarding catalogs (OFFLINE / read-only).
 * Reads the two staging JSONL files, flattens them to a uniform SKU row, and serves a
 * paginated /api/skus endpoint behind a sort + density + infinite-scroll grid.
 *
 * HARD: read-only. No Shopify, no dw_unified, no publish. Pure local preview of staged data.
 *   node viewer/server.js [port]   (default 9931)
 */
const http = require('http');
const fs = require('fs');
const path = require('path');

const PORT = parseInt(process.argv[2] || '9931', 10);
const DIR = path.join(__dirname, '..', 'staging');
const LILY = path.join(DIR, 'lilycolor-unified-staging.jsonl');   // trade pricebook + specs + price + image manifest
const SANG = path.join(DIR, 'sangetsu-staging.jsonl');
const PUBLIC = path.join(__dirname, 'public');
const HENRY_IMG = '/Volumes/Henry/dw-lily-images';   // selectively-fetched pattern swatches

const readJsonl = (f) => (fs.existsSync(f)
  ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
  : []);

// Flatten both vendors to one uniform SKU row: {source, sku, title, width, image, url, tags[]}
function buildRows() {
  const rows = [];
  // Lilycolor: unified trade row — one SKU, full specs + price + image manifest
  for (const r of readJsonl(LILY)) {
    rows.push({
      source: 'lilycolor',
      sku: r.mfr_sku,
      prefix: r.mfr_prefix || null,
      title: r.title_ja || r.mfr_sku,
      catalog: r.source_catalog || null,                  // WILL / LIGHT / V-wall / MATERIALS / Import Selection
      width: r.width || null,
      composition: (r.functions && r.functions.length) ? r.functions.join(', ') : null,
      fire: (r.fire_ratings && r.fire_ratings[0]) || null,
      price_yen: r.list_price_yen || null,                 // Japan LIST price (¥), not confirmed net cost
      repeat: (r.repeat_tate_cm && r.repeat_yoko_cm) ? `${r.repeat_tate_cm}×${r.repeat_yoko_cm}cm` : null,
      // prefer the locally-fetched clean swatch on Henry; else shop-CDN swatch-first preview
      image: (fs.existsSync(path.join(HENRY_IMG, `${r.mfr_sku}.jpg`)) ? `/img/lily/${r.mfr_sku}` : (r.preview_images && r.preview_images[0])) || null,
      image_count: r.image_count || 0,
      image_kinds: r.image_kinds || [],
      url: r.shop_handle ? `https://shop.lilycolor.co.jp/products/${r.shop_handle}` : null,
      is_sample: false,
    });
  }
  // Sangetsu: one PATTERN explodes to N colorway SKUs, each with its own full-res image
  for (const r of readJsonl(SANG)) {
    const imgs = r.sku_images || {};
    for (const sku of (r.colorway_skus || [])) {
      rows.push({
        source: 'sangetsu',
        sku,
        prefix: r.mfr_prefix || null,
        title: r.pattern || sku,
        width: (r.spec && r.spec.width) || null,
        composition: (r.spec && r.spec.is_grasscloth) ? 'Grasscloth' : null,
        fire: (r.spec && r.spec.type) || null,
        image: imgs[sku] || null,
        url: r.source_url || null,
        is_sample: false,
      });
    }
  }
  return rows;
}

let ROWS = buildRows();
const reload = () => { ROWS = buildRows(); };
fs.watchFile(SANG, { interval: 4000 }, reload);   // pick up Sangetsu staging as it grows
fs.watchFile(LILY, { interval: 8000 }, reload);
let lastImgCount = -1;                              // re-resolve primary images as swatches land on Henry
setInterval(() => {
  try { const n = fs.existsSync(HENRY_IMG) ? fs.readdirSync(HENRY_IMG).length : 0; if (n !== lastImgCount) { lastImgCount = n; reload(); } } catch {}
}, 6000);

const sorters = {
  newest: null, // server natural order
  sku: (a, b) => String(a.sku).localeCompare(String(b.sku)),
  title: (a, b) => String(a.title).localeCompare(String(b.title)),
  width: (a, b) => (parseFloat(a.width) || 0) - (parseFloat(b.width) || 0),
  price: (a, b) => (a.price_yen || 1e9) - (b.price_yen || 1e9),
  images: (a, b) => (b.image_count || 0) - (a.image_count || 0),
};

const server = http.createServer((req, res) => {
  const u = new URL(req.url, `http://localhost:${PORT}`);
  if (u.pathname === '/api/skus') {
    const src = u.searchParams.get('source') || 'all';
    const q = (u.searchParams.get('q') || '').toLowerCase();
    const sort = u.searchParams.get('sort') || 'newest';
    const offset = parseInt(u.searchParams.get('offset') || '0', 10);
    const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10), 500);
    let rows = ROWS;
    if (src !== 'all') rows = rows.filter((r) => r.source === src);
    if (q) rows = rows.filter((r) => (r.sku + ' ' + r.title).toLowerCase().includes(q));
    const counts = { all: ROWS.length, lilycolor: ROWS.filter((r) => r.source === 'lilycolor').length, sangetsu: ROWS.filter((r) => r.source === 'sangetsu').length };
    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
    const page = rows.slice(offset, offset + limit);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ total: rows.length, counts, offset, limit, rows: page }));
    return;
  }
  // locally-fetched Lily pattern swatches from Henry
  if (u.pathname.startsWith('/img/lily/')) {
    const sku = decodeURIComponent(u.pathname.slice('/img/lily/'.length)).replace(/[^A-Za-z0-9-]/g, '');
    const f = path.join(HENRY_IMG, `${sku}.jpg`);
    if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
    res.writeHead(404); return res.end('no image');
  }
  // static
  let p = u.pathname === '/' ? '/index.html' : u.pathname;
  const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
  if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
  const ext = path.extname(file);
  const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain';
  res.writeHead(200, { 'Content-Type': mime });
  res.end(fs.readFileSync(file));
});

server.listen(PORT, () => {
  console.log(`Staging viewer → http://localhost:${PORT}  (lilycolor ${ROWS.filter((r) => r.source === 'lilycolor').length} / sangetsu ${ROWS.filter((r) => r.source === 'sangetsu').length} SKUs)`);
});