← back to Trending Dw
server.js
211 lines
#!/usr/bin/env node
// trending.designerwallcoverings.com — bestseller-intelligence dashboard.
// Zero-dependency Node http server. Basic-auth gated (admin:DW2024!) by default.
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 9951;
const PUBLIC = path.join(__dirname, 'public');
const DATA = path.join(__dirname, 'data', 'bestsellers.json');
const CATALOG = path.join(__dirname, '..', 'pattern-vault', 'data', 'catalog.json'); // our own catalog (for gap scoring)
const OURCAT = path.join(__dirname, 'data', 'our-catalog.json'); // normalized our-own designs (WPB originals + DW storefront) — built by scripts/attach-images.js
const SHOPIFY_QUEUE = path.join(__dirname, 'data', 'shopify-queue.json'); // staged DW products awaiting the (gated) live Shopify push
const DW_SERIES = 'DWPV'; // DW Pattern Vault — AI-original vault designs
const AUTH = process.env.BASIC_AUTH === undefined ? 'admin:DW2024!' : process.env.BASIC_AUTH; // BASIC_AUTH='' disables (public go-live is gated)
const MIME = { '.html':'text/html', '.js':'application/javascript', '.css':'text/css', '.json':'application/json', '.svg':'image/svg+xml', '.ico':'image/x-icon', '.webp':'image/webp', '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.gif':'image/gif', '.avif':'image/avif' };
const MS_PER_DAY = 86400000;
// Styles that are demonstrably selling but ABSENT/thin in our own catalog = the whitespace.
// Derived from the 2026-07-02 gap analysis against pattern-vault/data/catalog.json.
const GAP_STYLES = new Set([
'Animal-Skin Print', 'Art-Deco Geometric', 'Texture-Effect', 'Boho', 'Tropical',
'Retro Psychedelic', 'Mushroom / Goblincore', 'Paisley / Nomadic', 'Abstract Blur', 'Retro Geometric'
]);
// price band -> suggested DW Pattern Vault license tier
function dwTier(band, style){
if (/mural|panorama|scenic/i.test(style||'')) return 'Mural — $795';
return { '$':'Digital — $149', '$$':'Digital — $149', '$$$':'Commercial — $495', '$$$$':'Exclusive — $2500' }[band] || 'Digital — $149';
}
function enrich(it){ return { ...it, ourCoverage: GAP_STYLES.has(it.style) ? 'gap' : 'covered', dwTier: dwTier(it.priceBand, it.style) }; }
function loadItems(){ try { return (JSON.parse(fs.readFileSync(DATA,'utf8')).items || []).map(enrich); } catch(e){ return []; } }
// freshness meta — a "trending" board must surface HOW STALE it is (the data is only
// as good as its last refresh; a true refresh is metered+gated, so honesty here matters).
function loadMeta(){
try {
const d = JSON.parse(fs.readFileSync(DATA,'utf8'));
const items = d.items || [];
const generatedAt = d.generatedAt || null;
// most recent spottedAt across items = the real "as of" date
const spotted = items.map(i=>i.spottedAt).filter(Boolean).sort();
const asOf = spotted.length ? spotted[spotted.length-1] : generatedAt;
// staleness must be identical whether this runs on Mac2 (Pacific) or Kamatera (UTC), so
// pin the reference to Steve's timezone and do pure date arithmetic (Date.UTC on both
// endpoints — no tz drift). spottedAt is a bare 'YYYY-MM-DD' with no tz of its own.
let staleDays = null;
if (asOf) {
const p = String(asOf).split('-').map(Number);
if (p[0] && p[1] && p[2]) {
const t = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' }).split('-').map(Number); // YYYY-MM-DD in PT
staleDays = Math.round((Date.UTC(t[0],t[1]-1,t[2]) - Date.UTC(p[0],p[1]-1,p[2])) / MS_PER_DAY);
}
}
const withImage = items.filter(i=>i.image).length;
const withRealImage = items.filter(i=>i.realImage).length;
return { generatedAt, asOf, staleDays, count: items.length, imagesAttachedAt: d.imagesAttachedAt || null,
withImage, imageGap: items.length - withImage,
withRealImage, realImageGap: items.length - withRealImage, realImagesAttachedAt: d.realImagesAttachedAt || null,
source: d.source || null };
} catch(e){ return { generatedAt:null, asOf:null, staleDays:null, count:0 }; }
}
// our own catalog coverage summary (best-effort; for the Lanes view "we have N")
function ourCatalogCount(){ try { return (JSON.parse(fs.readFileSync(CATALOG,'utf8'))||[]).length; } catch(e){ return 0; } }
// normalized our-own designs used by /api/matchup ([{title,image,url,vendor,style,color,keywords}])
function loadOurCatalog(){ try { return JSON.parse(fs.readFileSync(OURCAT,'utf8')) || []; } catch(e){ return []; } }
// keyword-score a query ("<style> <color>") against an our-own design row; higher = closer lane match
function matchScore(q, row){
const terms = q.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 2);
if (!terms.length) return 0;
const hay = ((row.keywords||'') + ' ' + (row.style||'') + ' ' + (row.color||'') + ' ' + (row.title||'')).toLowerCase();
let s = 0; for (const t of terms){ if (hay.includes(t)) s += 1; }
return s;
}
function facets(items){
const dim = (key) => {
const m = {};
for (const it of items){ const v = it[key]; if(!v) continue; m[v] = (m[v]||0)+1; }
return Object.entries(m).sort((a,b)=>b[1]-a[1]).map(([value,count])=>({value,count}));
};
return { ourCoverage: dim('ourCoverage'), marketplace: dim('marketplace'), company: dim('company'), style: dim('style'), color: dim('color'), priceBand: dim('priceBand') };
}
function applyFilters(items, sp){
for (const key of ['ourCoverage','marketplace','company','style','color','priceBand']){
const raw = sp.get(key);
if (!raw) continue;
const vals = raw.split(',').filter(Boolean); // multi-select: comma-separated
if (vals.length) items = items.filter(it => vals.includes(it[key]));
}
const search = (sp.get('q')||'').toLowerCase();
if (search) items = items.filter(it => (it.title+' '+it.company+' '+it.style+' '+it.marketplace).toLowerCase().includes(search));
return items;
}
function send(res, code, body, type){ res.writeHead(code, { 'Content-Type': type||'application/json', 'Cache-Control':'no-store' }); res.end(body); }
// ---- DW Shopify staging: assign sequential DWPV SKUs to our-own designs, stage payloads ----
function loadQueue(){ try { return JSON.parse(fs.readFileSync(SHOPIFY_QUEUE,'utf8')) || []; } catch(e){ return []; } }
function saveQueue(q){ fs.writeFileSync(SHOPIFY_QUEUE, JSON.stringify(q, null, 2)); }
function nextSku(q){
const max = q.reduce((m,p)=>{ const n = parseInt(String(p.sku||'').replace(DW_SERIES+'-',''),10); return isNaN(n)?m:Math.max(m,n); }, 0);
return DW_SERIES + '-' + String(max+1).padStart(4,'0');
}
// stage a product for one trending item's OUR-OWN design (idempotent per design)
// INVARIANT: only `image` (our own design) is ever staged. `realImage` is a competitor's
// marketplace image (internal intel only) and must never become a DW product image.
function stageForItem(it, nowIso){
if (!it || !it.image || !it.isOurOriginal) return { error: 'no our-own design to list (GAP lanes need generation first)' };
if (String(it.image).startsWith('/assets/real/')) return { error: 'refusing to stage: image is a competitor marketplace photo (internal intel only)' };
const q = loadQueue();
const designKey = it.imageDesign || it.image;
const existing = q.find(p => p.designKey === designKey);
if (existing) return { sku: existing.sku, staged: true, already: true, product: existing };
const product = {
sku: nextSku(q),
designKey,
title: it.imageDesign || it.title,
vendor: 'Designer Wallcoverings',
series: DW_SERIES,
image: it.image,
style: it.style,
color: it.color,
priceTier: it.dwTier,
trendSignal: it.signal,
signalRank: it.signalRank,
status: 'staged', // staged -> (gated) pushed
stagedAt: nowIso
};
q.push(product); saveQueue(q);
return { sku: product.sku, staged: true, already: false, product };
}
const server = http.createServer((req,res)=>{
if (AUTH){
const expect = 'Basic ' + Buffer.from(AUTH).toString('base64');
if ((req.headers.authorization||'') !== expect){ res.writeHead(401, { 'WWW-Authenticate':'Basic realm="trending-dw"' }); return res.end('Auth required'); }
}
const u = new URL(req.url, 'http://x');
if (u.pathname === '/api/facets'){ return send(res, 200, JSON.stringify(facets(loadItems()))); }
if (u.pathname === '/api/meta'){ return send(res, 200, JSON.stringify(loadMeta())); }
if (u.pathname === '/api/lanes'){ // trend-lane rollup
const items = applyFilters(loadItems(), u.searchParams);
const m = {};
for (const it of items){
const k = it.style; (m[k] = m[k] || { lane:k, count:0, sumSignal:0, coverage:it.ourCoverage, markets:new Set() });
m[k].count++; m[k].sumSignal += (it.signalRank||0); m[k].markets.add(it.marketplace);
}
const lanes = Object.values(m).map(l => ({ lane:l.lane, count:l.count, avgSignal: Math.round(l.sumSignal/l.count), coverage:l.coverage, marketplaces:[...l.markets] }))
.sort((a,b)=> b.avgSignal-a.avgSignal || b.count-a.count);
return send(res, 200, JSON.stringify({ lanes, ourCatalog: ourCatalogCount() }));
}
if (u.pathname === '/api/put-on-shopify'){ // stage an our-own design as a DW product (sequential DWPV SKU)
const id = u.searchParams.get('id');
const it = loadItems().find(x => x.id === id);
if (!it) return send(res, 404, JSON.stringify({ error: 'item not found' }));
const nowIso = new Date(Date.now()).toISOString();
const r = stageForItem(it, nowIso);
return send(res, r.error ? 409 : 200, JSON.stringify(r));
}
if (u.pathname === '/api/shopify-queue'){ // list staged DW products
const q = loadQueue();
return send(res, 200, JSON.stringify({ series: DW_SERIES, count: q.length, products: q }));
}
if (u.pathname === '/api/matchup'){ // trending item -> closest designs in OUR OWN catalog
const q = u.searchParams.get('q') || '';
const cat = loadOurCatalog();
const scored = cat.map(row => ({ row, s: matchScore(q, row) })).filter(x => x.s > 0)
.sort((a,b) => b.s - a.s).slice(0, 12)
.map(x => ({ title: x.row.title, image: x.row.image, url: x.row.url, vendor: x.row.vendor }));
return send(res, 200, JSON.stringify({ matches: scored, total: cat.length }));
}
if (u.pathname === '/api/items'){
let items = applyFilters(loadItems(), u.searchParams);
const sort = u.searchParams.get('sort') || 'signal';
const cmp = {
have: (a,b)=> ((b.realImage?2:(b.image?1:0))-(a.realImage?2:(a.image?1:0))) || (b.signalRank||0)-(a.signalRank||0),
signal: (a,b)=> (b.signalRank||0)-(a.signalRank||0),
gap: (a,b)=> (a.ourCoverage===b.ourCoverage?0:(a.ourCoverage==='gap'?-1:1)) || (b.signalRank||0)-(a.signalRank||0),
titleAZ: (a,b)=> (a.title||'').localeCompare(b.title||''),
company: (a,b)=> (a.company||'').localeCompare(b.company||''),
marketplace: (a,b)=> (a.marketplace||'').localeCompare(b.marketplace||''),
newest: (a,b)=> (b.spottedAt||'').localeCompare(a.spottedAt||'')
}[sort] || (()=>0);
items = items.slice().sort(cmp);
const page = parseInt(u.searchParams.get('page')||'1',10);
const size = parseInt(u.searchParams.get('size')||'12',10);
const total = items.length;
return send(res, 200, JSON.stringify({ total, page, size, gapCount: items.filter(i=>i.ourCoverage==='gap').length, items: items.slice((page-1)*size, page*size) }));
}
let p = u.pathname === '/' ? '/index.html' : u.pathname;
const fp = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[\/\\])+/,''));
fs.readFile(fp, (err,buf)=> err ? send(res, 404, 'Not found', 'text/plain') : send(res, 200, buf, MIME[path.extname(fp)] || 'application/octet-stream'));
});
server.listen(PORT, ()=> console.log('trending-dw on http://127.0.0.1:'+PORT+(AUTH?' (basic-auth '+AUTH.split(':')[0]+')':' (PUBLIC)')));