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