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