← back to Fabricut Internal

scripts/build-fabricut.js

168 lines

#!/usr/bin/env node
/**
 * Fabricut microsite data — build data/products.json from dw_unified.fabricut_catalog.
 * MODE=internal (default) → full data incl. cost/trade + vendor account (basic-auth site).
 * MODE=public            → customer-facing: NO cost, NO trade, NO account#; per-yard retail only.
 * Self-contained: emits its OWN internal PDP handles + a memo-sample/inquiry CTA. $0 local PG read.
 */
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');

const MODE = (process.env.MODE || 'internal').toLowerCase();
const PUBLIC = MODE === 'public';
const OUT = path.join(__dirname, '..', 'data', 'products.json');
const PW = (() => {
  try { const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
    const m = env.match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m); if (m) return m[1].replace(/^["']|["']$/g, '').trim(); } catch {}
  return process.env.PGPASSWORD || '';
})();
// Prefer the /tmp socket (fast local mirror); fall back to TCP.
const pool = new Pool(fs.existsSync('/tmp/.s.PGSQL.5432')
  ? { host: '/tmp', database: 'dw_unified' }
  : { host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW });

const clean = s => (s == null ? s : String(s)
  .replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering'));
const cap = s => String(s || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).trim();
const jparse = v => { if (v == null) return null; try { return typeof v === 'string' ? JSON.parse(v) : v; } catch { return null; } };
const slug = s => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');

const BUCKET_ORDER = ['white','grey','black','pink','red','orange','brown','gold','green','teal','blue','purple'];
// hex → HSV + nearest color bucket (real per-SKU color sort, better than tag buckets)
function hexHSV(hex) {
  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim()); if (!m) return null;
  const n = parseInt(m[1], 16), r = (n >> 16 & 255) / 255, g = (n >> 8 & 255) / 255, b = (n & 255) / 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
  let h = 0; if (d) { 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: mx ? d / mx : 0, v: mx };
}
function bucketFromHSV(hsv) {
  if (!hsv) return null; const { h, s, v } = hsv;
  if (v < 0.15) return 'black'; if (s < 0.12) return v > 0.75 ? 'white' : 'grey';
  if (h < 15 || h >= 345) return 'red'; if (h < 40) return v < 0.55 ? 'brown' : 'orange';
  if (h < 65) return 'gold'; if (h < 160) return 'green'; if (h < 195) return 'teal';
  if (h < 255) return 'blue'; if (h < 290) return 'purple'; return 'pink';
}

async function main() {
  const { rows } = await pool.query(`
    SELECT dw_sku, mfr_sku, pattern_name, color_name, product_type, collection, book_name,
           width, length, repeat_v, material, match_type, finish, backing, fire_codes, scale,
           country_origin, roll_yards, min_order_yards, msrp_unit, vendor_msrp,
           price_retail, price_trade, ai_description, ai_styles, ai_patterns, ai_colors,
           dominant_color_hex, color_hex, ai_background_color, all_images, image_url,
           coordinating_patterns, created_at
    FROM fabricut_catalog
    WHERE full_scraped = true AND product_url ~ 'fabricut.com'
      AND NULLIF(pattern_name,'') IS NOT NULL
    ORDER BY pattern_name, color_name, dw_sku
  `);

  // vendor ops meta (internal PDP: Call Vendor, Our Account #, pricing basis)
  let vendorMeta = { name: 'Fabricut' };
  if (!PUBLIC) {
    const vr = (await pool.query(
      `SELECT vendor_name, vendor_discount_pct, pricing_unit, pricing_model, pricing_notes, sample_price
       FROM vendor_registry WHERE vendor_code = 'fabricut'`).catch(() => ({ rows: [] }))).rows[0] || {};
    const fm = (await pool.query(
      `SELECT phone, email_1, account_num FROM fmpro WHERE vendor_name = 'Fabricut'
         AND account_num IS NOT NULL LIMIT 1`).catch(() => ({ rows: [] }))).rows[0] || {};
    vendorMeta = {
      name: vr.vendor_name || 'Fabricut', phone: fm.phone || null, email: fm.email_1 || null,
      account_number: fm.account_num || null,
      discount_pct: vr.vendor_discount_pct != null ? Number(vr.vendor_discount_pct) : null,
      pricing_unit: vr.pricing_unit || 'Yard', pricing_model: vr.pricing_model || null,
      pricing_notes: vr.pricing_notes || null,
      sample_price: vr.sample_price != null ? Number(vr.sample_price) : 4.25,
    };
  }

  const products = [];
  let dropped = 0;
  for (const r of rows) {
    const imgs = String(r.all_images || r.image_url || '').split(',').map(s => s.trim()).filter(Boolean);
    const primary = imgs[0] || r.image_url || null;
    if (!primary) { dropped++; continue; }
    const styles = (jparse(r.ai_styles) || []).filter(Boolean).map(cap);
    const pats = (jparse(r.ai_patterns) || []).filter(Boolean);
    const colors = (jparse(r.ai_colors) || []).map(c => c && c.name).filter(Boolean);
    const hex = r.dominant_color_hex || r.color_hex || null;
    const hsv = hexHSV(hex);
    const isComm = r.product_type === 'Commercial Wallcovering';
    const perYard = r.vendor_msrp != null ? Number(r.vendor_msrp) : (r.price_retail != null ? Number(r.price_retail) : null);
    const rollYd = Number(r.roll_yards) || 0;
    const specBits = [];
    if (r.material) specBits.push(`Content: ${clean(r.material)}`);
    if (r.width) specBits.push(`Width: ${r.width}`);
    if (rollYd) specBits.push(`Sold per yard in ${rollYd}-yard rolls`);
    if (r.fire_codes) specBits.push(`Flammability: ${r.fire_codes}`);
    const body = (r.ai_description ? `<p>${clean(r.ai_description)}</p>` : '')
      + (specBits.length ? `<p>${specBits.join(' · ')}</p>` : '')
      || `<p>${cap(r.pattern_name)} in ${cap(r.color_name)} by Fabricut.</p>`;
    const p = {
      handle: `${slug(r.pattern_name)}-${slug(r.color_name)}--${(r.dw_sku || '').toLowerCase()}`,
      dw_sku: r.dw_sku, sku: r.mfr_sku || null,
      title: clean(`${cap(r.pattern_name)} ${cap(r.color_name)} | Fabricut`),
      display_eyebrow: clean(r.pattern_name || ''),
      display_name: clean(r.color_name || r.pattern_name || ''),
      series: clean(r.pattern_name) || null,
      color: clean(r.color_name) || null,
      book: r.book_name || r.collection || (isComm ? 'Commercial' : 'Fabricut'),
      style: styles[0] || null, styles,
      patterns: pats,
      ai_colors: colors,
      color_bucket: bucketFromHSV(hsv), hue: hsv ? Math.round(hsv.h) : null,
      hex, sat: hsv ? +hsv.s.toFixed(3) : null, val: hsv ? +hsv.v.toFixed(3) : null,
      bg_color: r.ai_background_color || null,
      width: r.width || null,
      length: rollYd ? `${rollYd} Yards` : (r.length || null),
      repeat: r.repeat_v && !/^0\.0+/.test(String(r.repeat_v)) ? r.repeat_v : null,
      material: clean(r.material) || null,
      match: r.match_type || null, finish: r.finish || null, backing: r.backing || null,
      fire: r.fire_codes || null, scale: r.scale || null, country: r.country_origin || null,
      unit: isComm ? 'Priced Per Yard' : 'Priced Per Yard',
      roll_yards: rollYd || null,
      min_order: rollYd ? `${rollYd} Yards` : (r.min_order_yards ? `${r.min_order_yards} Yards` : null),
      price: perYard != null && perYard > 0 ? perYard : null,   // per-yard retail
      price_unit: 'yard',
      body_html: body,
      swatch: primary, room: imgs[1] || primary, images: imgs.slice(0, 12),
      coordinating: r.coordinating_patterns && !/other colors/i.test(r.coordinating_patterns) ? clean(r.coordinating_patterns) : null,
      published_at: r.created_at, inquiry_sku: r.dw_sku,
    };
    if (!PUBLIC) { p.cost = r.price_trade != null && Number(r.price_trade) > 0 ? Number(r.price_trade) : null; }
    products.push(p);
  }

  const facets = { books: {}, series: {}, colors: {}, styles: {} };
  for (const p of products) {
    if (p.book) facets.books[p.book] = (facets.books[p.book] || 0) + 1;
    if (p.series) facets.series[p.series] = (facets.series[p.series] || 0) + 1;
    if (p.color_bucket) facets.colors[p.color_bucket] = (facets.colors[p.color_bucket] || 0) + 1;
    if (p.style) facets.styles[p.style] = (facets.styles[p.style] || 0) + 1;
  }
  const orderedColors = BUCKET_ORDER.filter(b => facets.colors[b]).map(b => [b, facets.colors[b]]);

  const snapshot = {
    built_at: new Date().toISOString(),
    source: `dw_unified.fabricut_catalog (${PUBLIC ? 'PUBLIC — no cost' : 'INTERNAL'})`,
    mode: MODE, count: products.length, dropped_no_image: dropped, vendor: vendorMeta,
    facets: {
      total: products.length,
      books: Object.entries(facets.books).sort((a, b) => b[1] - a[1]),
      series: Object.entries(facets.series).sort((a, b) => b[1] - a[1]).slice(0, 400),
      styles: Object.entries(facets.styles).sort((a, b) => b[1] - a[1]),
      colors: orderedColors,
    },
    products,
  };
  fs.writeFileSync(OUT, JSON.stringify(snapshot));
  console.log(`[${MODE}] products.json -> ${OUT}`);
  console.log(`  products: ${products.length} | dropped(no image): ${dropped}`);
  console.log(`  colors: ${orderedColors.map(c => c[0] + ':' + c[1]).join(', ')}`);
  console.log(`  patterns: ${snapshot.facets.series.length} | books: ${snapshot.facets.books.length} | styles: ${snapshot.facets.styles.length}`);
  await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });