← back to Atomic50 Onboard
build-snapshot.js
152 lines
#!/usr/bin/env node
/**
* Atomic 50 microsite snapshot builder.
* Reads dw_unified.atomic50_catalog → data/products.json (self-contained feed
* for the public editorial lookbook). QUOTE-ONLY: NO prices are emitted. Vendor
* URLs (product_url) are STRIPPED — the microsite never links to atomic50ceilings.com.
*
* $0 (local) — local Postgres read only.
*/
const { Client } = require('pg');
const fs = require('fs');
const path = require('path');
const OUT = path.join(__dirname, 'data', 'products.json');
function bucketFromHex(hex) {
if (!hex || !/^#?[0-9a-f]{6}$/i.test(hex)) return null;
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
const l = (max + min) / 2 / 255;
if (d < 22) { if (l > 0.8) return 'white'; if (l < 0.2) return 'black'; return 'grey'; }
let hue;
if (max === r) hue = ((g - b) / d) % 6; else if (max === g) hue = (b - r) / d + 2; else hue = (r - g) / d + 4;
hue = (hue * 60 + 360) % 360;
if (hue < 20 || hue >= 345) return 'red';
if (hue < 45) return 'orange';
if (hue < 70) return 'gold';
if (hue < 170) return 'green';
if (hue < 255) return 'blue';
if (hue < 300) return 'purple';
return 'pink';
}
function hueFromHex(hex) {
if (!hex || !/^#?[0-9a-f]{6}$/i.test(hex)) return null;
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16) / 255, g = parseInt(h.slice(2, 4), 16) / 255, b = parseInt(h.slice(4, 6), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
if (d < 0.001) return null;
let hue;
if (max === r) hue = ((g - b) / d) % 6; else if (max === g) hue = (b - r) / d + 2; else hue = (r - g) / d + 4;
return (hue * 60 + 360) % 360;
}
(async () => {
const c = new Client({ host: '/tmp', database: 'dw_unified' });
await c.connect();
const { rows } = await c.query(
`SELECT id, mfr_sku, dw_sku, pattern_name, color_name, collection, product_type,
material, width, length, repeat_v, image_url, all_images, spin_gif_url,
ai_description, ai_styles, ai_patterns, dominant_color_hex, color_hex,
fire_rating, finish, application, created_at
FROM atomic50_catalog
-- exclude the scraped rep/resource page (LBI-BOYD) — not a real product
WHERE mfr_sku IS DISTINCT FROM 'LBI-BOYD'
ORDER BY id`
);
const jarr = (v) => {
if (!v) return [];
if (Array.isArray(v)) return v;
try { return JSON.parse(v); } catch { return []; }
};
const products = rows.map((r) => {
const hex = r.dominant_color_hex || r.color_hex || null;
const styles = jarr(r.ai_styles);
const patterns = jarr(r.ai_patterns);
// display name: pattern name is the mfr SKU (AT50-xx) for tiles; keep it — NEVER "Unknown"
const name = r.pattern_name || r.mfr_sku || r.color_name || null;
// all_images is a jsonb-style array stored as TEXT (e.g. '["url1","url2"]').
// Parse it as JSON — NEVER split on commas/whitespace (that shatters URLs
// that contain query strings into un-parseable fragments). jarr() handles
// both a native array and a JSON-array string. Keep only clean http(s) URLs.
const isUrl = (u) => typeof u === 'string' && /^https?:\/\//i.test(u);
const images = [];
if (isUrl(r.image_url)) images.push(r.image_url);
for (const im of jarr(r.all_images)) {
if (isUrl(im) && !images.includes(im)) images.push(im);
}
// Finish-swatch gallery set: the powder-coat finish images are the whole
// selling point for tin tiles. De-duped + capped to the first 12 so the
// hover-cycle stays snappy (products carry 40+ finish variants). HTTPS-only
// (already enforced by isUrl → these are images.squarespace-cdn.com, no
// mixed-content). Spin GIFs are NULL upstream and never enter this set.
const SWATCH_CAP = 12;
const swatches = images.slice(0, SWATCH_CAP);
return {
handle: (r.mfr_sku || String(r.id)).toLowerCase().replace(/[^a-z0-9]+/g, '-'),
sku: r.mfr_sku || null,
dw_sku: r.dw_sku || null, // DWJT-600xxx (assigned at Shopify draft time; may be null in staging)
display_name: name,
color: r.color_name || 'Unfinished',
product_type: r.product_type,
collection: r.collection,
material: r.material,
width: r.width,
length: r.length,
repeat: r.repeat_v,
style: styles[0] || null,
styles,
patterns,
pattern: patterns[0] || null,
fire_rating: r.fire_rating || 'ASTM E84 Class A',
finish: r.finish || 'Unfinished tin (40+ powder-coat colors to order)',
application: r.application || 'Ceiling · Wall panel · Backsplash · Wainscot',
swatch: images[0] || null,
// Spin GIFs are stored as plain-HTTP Kamatera URLs that (a) 404 at that path
// today and (b) would be mixed-content on the HTTPS site. Only emit an HTTPS
// spin URL so the lookbook never fires blocked/broken image requests. The
// spin-hosting fix is tracked in the deploy memo; swatch (HTTPS CDN) is primary.
spin: (r.spin_gif_url && /^https:\/\//i.test(r.spin_gif_url)) ? r.spin_gif_url : null,
swatches, // capped, de-duped finish set for the hover-cycle gallery (grid + PDP)
images,
color_bucket: bucketFromHex(hex),
hue: hueFromHex(hex),
body_html: r.ai_description || '',
created_at: r.created_at,
// NO price fields — quote-only line.
};
});
// facets for the left panel
const facetOf = (key) => {
const m = {};
for (const p of products) { const v = p[key]; if (v == null || v === '') continue; m[v] = (m[v] || 0) + 1; }
return Object.entries(m).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
};
const facets = {
total: products.length,
types: facetOf('product_type'),
collections: facetOf('collection'),
styles: facetOf('style'),
patterns: facetOf('pattern'),
colors: facetOf('color_bucket'),
};
const snap = {
built_at: new Date().toISOString(),
line: 'Atomic 50',
quote_only: true,
sample_price: 4.25,
products,
facets,
};
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, JSON.stringify(snap, null, 1));
console.log(`[atomic50] wrote ${products.length} products → ${OUT} (quote-only, 0 prices, 0 vendor urls)`);
await c.end();
})().catch((e) => { console.error(e); process.exit(1); });