← back to Cowtan Lines

viewer/server.js

232 lines

#!/usr/bin/env node
/**
 * Cowtan & Co. — Trade Lines catalog viewer (LOCAL, read-only browse + sample-order).
 *
 * Reads ../staging/cowtan.jsonl (8,796 products across Cowtan & Tout, Colefax & Fowler,
 * Manuel Canovas and the other designs.cowtan.com sub-brands) and serves:
 *   GET  /api/skus            paginated, sorted, filtered product feed
 *   GET  /api/facets          facet values + counts for the filter bar
 *   POST /api/sample-request  appends a sample request to data/sample-requests.jsonl
 * plus the static front-end in public/.
 *
 * These lines are intentionally OFF Shopify — the ONLY purchase action is a no-payment
 * sample request. No prices are sold, no Shopify links, no checkout.
 *
 * Zero dependencies. Images are live CloudFront URLs, used as-is (never upscaled).
 *   node server.js [port]   (default 0 = OS-assigned free port)
 */
const http = require('http');
const fs = require('fs');
const path = require('path');

const PORT = parseInt(process.argv[2] || '0', 10);
const DIR = path.join(__dirname, '..', 'staging');
const SRC = path.join(DIR, 'cowtan.jsonl');
const PUBLIC = path.join(__dirname, 'public');
const DATA = path.join(__dirname, 'data');
const REQUESTS = path.join(DATA, 'sample-requests.jsonl');

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)
  : []);

const num = (v) => { if (v == null) return null; const m = String(v).match(/[\d.]+/); return m ? parseFloat(m[0]) : null; };

// perceived luminance 0..255 from #rrggbb
function lum(hex) {
  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
  const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// hue 0..360 from #rrggbb (for color-wheel sort)
function hue(hex) {
  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
  let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
  if (d === 0) return -1; // grayscale -> sort to end
  let h;
  if (mx === r) h = ((g - b) / d) % 6;
  else if (mx === g) h = (b - r) / d + 2;
  else h = (r - g) / d + 4;
  h *= 60; if (h < 0) h += 360;
  return h;
}

function buildRows() {
  const rows = [];
  let i = 0;
  for (const r of readJsonl(SRC)) {
    let images = [];
    if (Array.isArray(r.all_images)) images = r.all_images;
    else if (typeof r.all_images === 'string' && r.all_images.trim()) { try { images = JSON.parse(r.all_images); } catch { images = []; } }
    if (!images.length && r.image_url) images = [r.image_url];
    const ptype = (r.product_type || '').replace(/^./, (c) => c.toUpperCase()); // normalize casing
    rows.push({
      idx: i++,
      sku: r.dw_sku || r.mfr_sku || String(i),
      mfr_sku: r.mfr_sku || null,
      pattern_name: r.pattern_name || r.mfr_sku || '(untitled)',
      color_name: r.color_name || null,
      collection: r.collection || null,
      product_type: ptype || null,
      material: r.material || null,
      width: r.width || null,
      repeat_v: r.repeat_v || null,
      repeat_h: r.repeat_h || null,
      color_primary: r.color_primary || null,
      color_hex: r.color_hex || null,
      ai_colors: r.ai_colors || [],
      ai_styles: r.ai_styles || [],
      ai_patterns: r.ai_patterns || [],
      repeat_classification: r.repeat_classification || null,
      finish: r.finish || null,
      application: r.application || null,
      coverage: r.coverage || null,
      fire_rating: r.fire_rating || null,
      line: r.line || r.material || null,
      image: r.image_url || images[0] || null,
      images,
      image_count: images.length,
      _lum: lum(r.color_hex),
      _hue: hue(r.color_hex),
    });
  }
  return rows;
}

let ROWS = buildRows();
fs.watchFile(SRC, { interval: 5000 }, () => { ROWS = buildRows(); });

const cmpStr = (a, b) => String(a == null ? '' : a).localeCompare(String(b == null ? '' : b), undefined, { sensitivity: 'base' });
const nullsLast = (v) => (v == null ? Infinity : v);

const sorters = {
  newest: null, // natural staged order
  pattern: (a, b) => cmpStr(a.pattern_name, b.pattern_name),
  color_name: (a, b) => cmpStr(a.color_name, b.color_name),
  collection: (a, b) => cmpStr(a.collection, b.collection) || cmpStr(a.pattern_name, b.pattern_name),
  line: (a, b) => cmpStr(a.line, b.line) || cmpStr(a.pattern_name, b.pattern_name),
  product_type: (a, b) => cmpStr(a.product_type, b.product_type) || cmpStr(a.pattern_name, b.pattern_name),
  material: (a, b) => cmpStr(a.material, b.material) || cmpStr(a.pattern_name, b.pattern_name),
  // light -> dark: brightest first; missing-hex always sort last
  light: (a, b) => { const la = a._lum == null ? -1 : a._lum, lb = b._lum == null ? -1 : b._lum; return lb - la; },
  // dark -> light: darkest first; missing-hex always sort last
  dark: (a, b) => { const la = a._lum == null ? 999 : a._lum, lb = b._lum == null ? 999 : b._lum; return la - lb; },
  hue: (a, b) => {
    // grayscale (-1) and null sort to the very end
    const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue;
    const hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
    return ha - hb;
  },
  sku: (a, b) => cmpStr(a.mfr_sku || a.sku, b.mfr_sku || b.sku),
};

function facets() {
  const f = { line: {}, product_type: {}, collection: {}, material: {}, color_primary: {}, repeat_classification: {} };
  for (const r of ROWS) {
    const bump = (k, v) => { if (v == null || v === '') return; f[k][v] = (f[k][v] || 0) + 1; };
    bump('line', r.line);
    bump('product_type', r.product_type);
    bump('collection', r.collection);
    bump('material', r.material);
    bump('color_primary', r.color_primary);
    bump('repeat_classification', r.repeat_classification);
  }
  // sort each facet by count desc, except collection alpha (327 of them, searchable)
  const out = {};
  for (const k of Object.keys(f)) {
    let entries = Object.entries(f[k]);
    if (k === 'collection') entries.sort((a, b) => a[0].localeCompare(b[0]));
    else entries.sort((a, b) => b[1] - a[1]);
    out[k] = entries.map(([value, count]) => ({ value, count }));
  }
  return out;
}

const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };

const server = http.createServer((req, res) => {
  const u = new URL(req.url, 'http://localhost');

  if (u.pathname === '/api/facets') {
    return json(res, 200, { total: ROWS.length, facets: facets() });
  }

  if (u.pathname === '/api/skus') {
    const q = (u.searchParams.get('q') || '').trim().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') || '60', 10), 200);
    // multi-select facets: comma-separated; collection can be one of many
    const sel = (k) => { const v = u.searchParams.get(k); return v ? v.split('|').filter(Boolean) : []; };
    const fLine = sel('line'), fType = sel('product_type'), fColl = sel('collection');
    const fMat = sel('material'), fColor = sel('color_primary'), fRep = sel('repeat_classification');

    let rows = ROWS;
    const inAny = (arr, val) => arr.length === 0 || arr.includes(val == null ? '' : val);
    rows = rows.filter((r) =>
      inAny(fLine, r.line) && inAny(fType, r.product_type) && inAny(fColl, r.collection) &&
      inAny(fMat, r.material) && inAny(fColor, r.color_primary) && inAny(fRep, r.repeat_classification));
    if (q) {
      rows = rows.filter((r) => (
        (r.pattern_name || '') + ' ' + (r.color_name || '') + ' ' + (r.mfr_sku || '') + ' ' +
        (r.sku || '') + ' ' + (r.collection || '')).toLowerCase().includes(q));
    }
    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
    const page = rows.slice(offset, offset + limit);
    return json(res, 200, { total: rows.length, all: ROWS.length, offset, limit, rows: page });
  }

  if (u.pathname === '/api/sample-request' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
    req.on('end', () => {
      let p; try { p = JSON.parse(body || '{}'); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
      const name = (p.name || '').toString().trim();
      const email = (p.email || '').toString().trim();
      const items = Array.isArray(p.items) ? p.items.slice(0, 200) : [];
      if (!name || !email || !/.+@.+\..+/.test(email) || !items.length) {
        return json(res, 400, { ok: false, error: 'name, valid email, and at least one item are required' });
      }
      const rec = {
        ts: new Date().toISOString(),
        name, email,
        company: (p.company || '').toString().trim() || null,
        phone: (p.phone || '').toString().trim() || null,
        address: (p.address || '').toString().trim() || null,
        notes: (p.notes || '').toString().trim() || null,
        items: items.map((it) => ({
          sku: (it.sku || '').toString(),
          mfr_sku: (it.mfr_sku || '').toString() || null,
          pattern_name: (it.pattern_name || '').toString() || null,
          color_name: (it.color_name || '').toString() || null,
          line: (it.line || '').toString() || null,
        })),
        ua: (req.headers['user-agent'] || '').slice(0, 200),
      };
      try {
        fs.mkdirSync(DATA, { recursive: true });
        fs.appendFileSync(REQUESTS, JSON.stringify(rec) + '\n');
      } catch (e) {
        return json(res, 500, { ok: false, error: 'could not save request' });
      }
      return json(res, 200, { ok: true, count: rec.items.length });
    });
    return;
  }

  // static
  let pth = u.pathname === '/' ? '/index.html' : u.pathname;
  const file = path.join(PUBLIC, path.normalize(pth).replace(/^(\.\.[/\\])+/, ''));
  if (!file.startsWith(PUBLIC) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end('not found'); }
  const ext = path.extname(file);
  const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon' }[ext] || 'text/plain';
  res.writeHead(200, { 'Content-Type': mime });
  res.end(fs.readFileSync(file));
});

server.listen(PORT, () => {
  const addr = server.address();
  console.log(`Cowtan Trade Lines viewer -> http://localhost:${addr.port}  (${ROWS.length} products)`);
});