← back to Dw Vendor Microsites
vendors/hygge/viewer/server.js
418 lines
#!/usr/bin/env node
/**
* DW Vendor Microsite — generalized catalog viewer (LOCAL, read-only browse + sample-order).
*
* Generalized from ~/Projects/cowtan-lines/viewer/server.js. The engine is
* vendor-agnostic: buildRows / sorters / facets / the /api/sample-request flow are
* uniform across every vendor's catalog schema (the staging JSONL is already
* normalized to the canonical cowtan shape by lib/export-catalog.sh).
*
* What is vendor-specific (read from argv / env / fieldmap.json):
* - JSONL source path : argv[3] || env VENDOR_JSONL || ../staging/cowtan.jsonl
* - banner / page title : env VENDOR_TITLE || fieldmap.title || 'Vendor Trade Lines'
* - samples-only policy : fieldmap.samples_only (no prices/checkout when true)
*
* 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 vendor CDN URLs, used as-is (never upscaled).
* node server.js [port] [jsonl] [fieldmap]
* port default 0 = OS-assigned free port
* jsonl default env VENDOR_JSONL || ../staging/cowtan.jsonl
* fieldmap default env VENDOR_FIELDMAP || ./fieldmap.json (optional)
*/
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'); // repo/vendors/<v>/viewer -> repo/staging
const SRC = process.argv[3] || process.env.VENDOR_JSONL || path.join(__dirname, '..', 'staging', 'cowtan.jsonl');
const FIELDMAP_PATH = process.argv[4] || process.env.VENDOR_FIELDMAP || path.join(__dirname, 'fieldmap.json');
const PUBLIC = path.join(__dirname, 'public');
const DATA = path.join(__dirname, 'data');
const REQUESTS = path.join(DATA, 'sample-requests.jsonl');
let FIELDMAP = {};
try { if (fs.existsSync(FIELDMAP_PATH)) FIELDMAP = JSON.parse(fs.readFileSync(FIELDMAP_PATH, 'utf8')); } catch { FIELDMAP = {}; }
const TITLE = process.env.VENDOR_TITLE || FIELDMAP.title || 'Vendor Trade Lines';
const SAMPLES_ONLY = FIELDMAP.samples_only !== false; // default true (samples-only / no checkout)
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;
}
// ---- ENHANCED bucketing (Steve's spec) ------------------------------------
// Full HSL from #rrggbb -> {h:0..360, s:0..1, l:0..1} or null.
function hsl(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;
if ([r, g, b].some((v) => Number.isNaN(v))) return null;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
const l = (mx + mn) / 2;
let s = 0, h = 0;
if (d !== 0) {
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
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, s, l };
}
// Canonical color families (order = display order; 0-count still listed by facets()).
const COLOR_FAMILIES = ['Red', 'Orange/Brown', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Neutral/Beige', 'Black', 'Grey', 'White'];
// ai_colors hint words -> family (fallback when hex absent/ambiguous).
const AI_COLOR_TO_FAMILY = {
red: 'Red', crimson: 'Red', scarlet: 'Red', burgundy: 'Red', maroon: 'Red', wine: 'Red',
orange: 'Orange/Brown', terracotta: 'Orange/Brown', rust: 'Orange/Brown', brown: 'Orange/Brown', tan: 'Orange/Brown', camel: 'Orange/Brown', copper: 'Orange/Brown', bronze: 'Orange/Brown', amber: 'Orange/Brown', coffee: 'Orange/Brown', chocolate: 'Orange/Brown',
yellow: 'Yellow', gold: 'Yellow', mustard: 'Yellow', ochre: 'Yellow',
green: 'Green', olive: 'Green', sage: 'Green', lime: 'Green', emerald: 'Green', forest: 'Green', moss: 'Green',
teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
blue: 'Blue', navy: 'Blue', indigo: 'Blue', cobalt: 'Blue', azure: 'Blue', denim: 'Blue',
purple: 'Purple', violet: 'Purple', lavender: 'Purple', plum: 'Purple', mauve: 'Purple', lilac: 'Purple',
pink: 'Pink', rose: 'Pink', blush: 'Pink', fuchsia: 'Pink', magenta: 'Pink', coral: 'Pink', salmon: 'Pink',
beige: 'Neutral/Beige', cream: 'Neutral/Beige', ivory: 'Neutral/Beige', taupe: 'Neutral/Beige', sand: 'Neutral/Beige', linen: 'Neutral/Beige', natural: 'Neutral/Beige', oatmeal: 'Neutral/Beige', greige: 'Neutral/Beige', stone: 'Neutral/Beige',
black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
grey: 'Grey', gray: 'Grey', silver: 'Grey', slate: 'Grey', pewter: 'Grey', smoke: 'Grey',
white: 'White', chalk: 'White', alabaster: 'White', snow: 'White',
};
// hue-degrees -> chromatic family bucket.
function familyFromHue(h) {
if (h < 15 || h >= 345) return 'Red';
if (h < 45) return 'Orange/Brown';
if (h < 70) return 'Yellow';
if (h < 160) return 'Green';
if (h < 195) return 'Teal';
if (h < 255) return 'Blue';
if (h < 290) return 'Purple';
if (h < 345) return 'Pink';
return 'Red';
}
// Derive the color family for a row. hex first (HSL), ai_colors as fallback hint.
function colorFamily(hex, aiColors) {
const c = hsl(hex);
if (c) {
// low-saturation -> achromatic by luminance
if (c.s < 0.12) {
if (c.l < 0.18) return 'Black';
if (c.l > 0.88) return 'White';
if (c.l > 0.70) return 'Neutral/Beige';
return 'Grey';
}
// low-ish sat + warm hue + mid/high lum -> beige rather than a vivid family
if (c.s < 0.22 && c.l > 0.62 && (c.h < 70 || c.h >= 330)) return 'Neutral/Beige';
return familyFromHue(c.h);
}
// hex missing/invalid -> ai_colors hint (first matchable word)
if (Array.isArray(aiColors)) {
for (const w of aiColors) {
const key = String(w || '').trim().toLowerCase();
if (AI_COLOR_TO_FAMILY[key]) return AI_COLOR_TO_FAMILY[key];
// partial: "dark green" -> green
for (const word of key.split(/[^a-z]+/)) if (AI_COLOR_TO_FAMILY[word]) return AI_COLOR_TO_FAMILY[word];
}
}
return null;
}
// Canonical styles (display order). Derived from ai_styles + ai_patterns keywords.
const STYLE_BUCKETS = ['Floral', 'Botanical', 'Damask', 'Toile', 'Stripe', 'Geometric', 'Plaid/Check', 'Trellis', 'Animal/Fauna', 'Scenic/Mural', 'Abstract', 'Texture/Plain', 'Metallic', 'Traditional', 'Modern'];
const STYLE_KEYWORDS = [
['Floral', ['floral', 'flower', 'rose', 'peony', 'bloom', 'blossom', 'chintz']],
['Botanical', ['botanical', 'leaf', 'leaves', 'fern', 'palm', 'foliage', 'tree', 'vine', 'garden', 'jungle', 'tropical']],
['Damask', ['damask', 'medallion', 'ogee']],
['Toile', ['toile']],
['Stripe', ['stripe', 'striped', 'pinstripe', 'ticking']],
['Plaid/Check', ['plaid', 'check', 'tartan', 'gingham', 'houndstooth', 'windowpane']],
['Trellis', ['trellis', 'lattice', 'fretwork']],
['Geometric', ['geometric', 'chevron', 'herringbone', 'diamond', 'hexagon', 'grid', 'dot', 'polka', 'moroccan', 'fret', 'greek key', 'op art']],
['Animal/Fauna', ['animal', 'bird', 'butterfly', 'fauna', 'leopard', 'zebra', 'tiger', 'peacock', 'fish', 'insect', 'skin', 'hide']],
['Scenic/Mural', ['scenic', 'mural', 'landscape', 'panoramic', 'chinoiserie', 'view', 'cityscape', 'map']],
['Metallic', ['metallic', 'foil', 'mica', 'gilt', 'gilded', 'shimmer', 'glitter']],
['Texture/Plain', ['texture', 'textured', 'plain', 'solid', 'grasscloth', 'linen', 'woven', 'weave', 'sisal', 'cork', 'faux', 'concrete', 'stucco', 'plaster', 'leather', 'suede', 'tweed']],
['Abstract', ['abstract', 'organic', 'watercolor', 'marble', 'painterly', 'brushstroke', 'ombre', 'tie dye', 'splatter']],
['Traditional', ['traditional', 'classic', 'heritage', 'colonial', 'victorian']],
['Modern', ['modern', 'contemporary', 'minimal', 'mid-century', 'midcentury', 'scandinavian']],
];
// First matching style bucket from the row's ai_styles + ai_patterns words.
function styleBucket(aiStyles, aiPatterns) {
const words = [];
for (const arr of [aiStyles, aiPatterns]) if (Array.isArray(arr)) for (const w of arr) words.push(String(w || '').toLowerCase());
const blob = ' ' + words.join(' ') + ' ';
for (const [bucket, kws] of STYLE_KEYWORDS) for (const kw of kws) if (blob.includes(kw)) return bucket;
return null;
}
// Material buckets from material / product_type strings.
const MATERIAL_BUCKETS = ['Non-woven', 'Paper', 'Vinyl', 'Grasscloth', 'Fabric/Textile', 'Cork', 'Mylar/Metallic', 'Leather/Suede', 'Mural', 'Other'];
const MATERIAL_KEYWORDS = [
['Non-woven', ['non-woven', 'nonwoven', 'non woven']],
['Grasscloth', ['grasscloth', 'grass cloth', 'sisal', 'jute', 'hemp', 'raffia', 'arrowroot', 'seagrass']],
['Vinyl', ['vinyl', 'pvc', 'type ii', 'type 2', 'commercial']],
['Cork', ['cork']],
['Mylar/Metallic', ['mylar', 'metallic', 'foil', 'mica']],
['Leather/Suede', ['leather', 'suede', 'ultrasuede', 'hide']],
['Mural', ['mural', 'panel', 'panoramic']],
['Fabric/Textile', ['fabric', 'textile', 'linen', 'silk', 'cotton', 'wool', 'velvet', 'woven', 'weave']],
['Paper', ['paper', 'wallpaper', 'wallcovering', 'pulp']],
];
function materialBucket(material, ptype) {
const blob = ' ' + (String(material || '') + ' ' + String(ptype || '')).toLowerCase() + ' ';
if (blob.trim() === '') return null;
for (const [bucket, kws] of MATERIAL_KEYWORDS) for (const kw of kws) if (blob.includes(kw)) return bucket;
return null;
}
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
const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
const aiColors = parseArr(r.ai_colors), aiStyles = parseArr(r.ai_styles), aiPatterns = parseArr(r.ai_patterns);
const enriched = (aiColors.length > 0 || aiStyles.length > 0); // ai_colors OR ai_styles non-empty
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: aiColors,
ai_styles: aiStyles,
ai_patterns: aiPatterns,
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,
// ENHANCED buckets (server-side)
family: colorFamily(r.color_hex, aiColors),
style: styleBucket(aiStyles, aiPatterns),
material_bucket: materialBucket(r.material, ptype),
enriched,
_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),
// group by canonical color family (display order), nulls last, then by hue within
family: (a, b) => {
const ia = a.family == null ? 999 : COLOR_FAMILIES.indexOf(a.family);
const ib = b.family == null ? 999 : COLOR_FAMILIES.indexOf(b.family);
if (ia !== ib) return ia - ib;
const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue, hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
return ha - hb;
},
// group by canonical style (display order), nulls last, then pattern name
style: (a, b) => {
const ia = a.style == null ? 999 : STYLE_BUCKETS.indexOf(a.style);
const ib = b.style == null ? 999 : STYLE_BUCKETS.indexOf(b.style);
return ia - ib || cmpStr(a.pattern_name, b.pattern_name);
},
};
function facets() {
// ENHANCED facets: color-family / style / material (bucketed) REPLACE raw color_primary
// as the color facet. Plus enriched, line, product_type, collection.
const fam = {}, sty = {}, mat = {};
const f = { line: {}, product_type: {}, collection: {} };
let enrichedN = 0, unenrichedN = 0;
for (const r of ROWS) {
const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
bump(fam, r.family);
bump(sty, r.style);
bump(mat, r.material_bucket);
bump(f.line, r.line);
bump(f.product_type, r.product_type);
bump(f.collection, r.collection);
if (r.enriched) enrichedN++; else unenrichedN++;
}
// canonical-ordered facets: every value listed even at count 0 (UI greys 0s)
const ordered = (canon, counts) => canon.map((value) => ({ value, count: counts[value] || 0 }));
// count-desc for free-form facets; collection alpha (searchable)
const byCount = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
const byAlpha = (counts) => Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
return {
family: ordered(COLOR_FAMILIES, fam),
style: ordered(STYLE_BUCKETS, sty),
material_bucket: ordered(MATERIAL_BUCKETS, mat),
enriched: [
{ value: 'enriched', count: enrichedN },
{ value: 'unenriched', count: unenrichedN },
],
line: byCount(f.line),
product_type: byCount(f.product_type),
collection: byAlpha(f.collection),
};
}
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/config') {
return json(res, 200, { title: TITLE, samples_only: SAMPLES_ONLY, total: ROWS.length,
shopify: {
domain: process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com',
storefrontToken: process.env.SHOPIFY_STOREFRONT_TOKEN || '',
sampleVariantId: process.env.SHOPIFY_SAMPLE_VARIANT_ID || ''
} });
}
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: pipe-separated ('|'). Buckets + free-form + enriched.
const sel = (k) => { const v = u.searchParams.get(k); return v ? v.split('|').filter(Boolean) : []; };
const fFam = sel('family'), fStyle = sel('style'), fMat = sel('material_bucket');
const fEnr = sel('enriched'); // 'enriched' | 'unenriched' (multi tolerated -> OR)
const fLine = sel('line'), fType = sel('product_type'), fColl = sel('collection');
let rows = ROWS;
const inAny = (arr, val) => arr.length === 0 || arr.includes(val == null ? '' : val);
const enrMatch = (r) => {
if (!fEnr.length) return true;
const wantE = fEnr.includes('enriched'), wantU = fEnr.includes('unenriched');
return (wantE && r.enriched) || (wantU && !r.enriched);
};
rows = rows.filter((r) =>
inAny(fFam, r.family) && inAny(fStyle, r.style) && inAny(fMat, r.material_bucket) &&
inAny(fLine, r.line) && inAny(fType, r.product_type) && inAny(fColl, r.collection) && enrMatch(r));
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(),
vendor: TITLE,
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(`${TITLE} viewer -> http://localhost:${addr.port} (${ROWS.length} products${SAMPLES_ONLY ? ', samples-only' : ''})`);
});