← back to Quadrille House Site

scripts/build-house.js

216 lines

#!/usr/bin/env node
/**
 * Quadrille HOUSE multi-brand line builder.
 *
 * Produces ONE snapshot data/house.json with EVERY brand merged + a `brand` field,
 * a `cta_mode` per product (live | quote), and image arrays.
 *
 *  - 8 net-new brands  → read from local PG  quadrille_house_catalog
 *    (HELD, no cost → cta_mode='quote'  → memo-sample / quote request CTA)
 *  - China Seas        → read LIVE from Shopify (priced, real product link)
 *    (cta_mode='live'  → "View & order on Designer Wallcoverings")
 *
 * HARD: real brand names + real colorway names (no private-label).  No prices on the
 * landing for any brand.  Vendor own-site URLs are NEVER carried (zero vendor URLs).
 *
 *   node scripts/build-house.js
 */
const fs = require('fs');
const path = require('path');
const { Client } = require('pg');

const OUT = path.join(__dirname, '..', 'data', 'house.json');
const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API = '2024-10';
const STORE_BASE = 'https://www.designerwallcoverings.com';

const sleep = ms => new Promise(r => setTimeout(r, ms));

// --- simple, deterministic dominant-color bucket from a hex (for color filter) ---
function hexToBucket(hex) {
  if (!hex) return null;
  const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return null;
  const n = parseInt(m[1], 16);
  const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
  const v = max / 255, s = max === 0 ? 0 : d / max;
  let h = 0;
  if (d !== 0) {
    if (max === r) h = ((g - b) / d) % 6;
    else if (max === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h *= 60; if (h < 0) h += 360;
  }
  let bucket;
  if (v > 0.9 && s < 0.12) bucket = 'white';
  else if (v < 0.18) bucket = 'black';
  else if (s < 0.15) bucket = 'grey';
  else if (h < 15 || h >= 345) bucket = 'red';
  else if (h < 45) bucket = 'orange';
  else if (h < 70) bucket = (v < 0.55 ? 'brown' : 'gold');
  else if (h < 170) bucket = 'green';
  else if (h < 200) bucket = 'teal';
  else if (h < 255) bucket = 'blue';
  else if (h < 300) bucket = 'purple';
  else bucket = 'pink';
  return { hex: '#' + m[1], hue: Math.round(h), sat: +s.toFixed(2), val: +v.toFixed(2), bucket };
}

function parseImgs(row) {
  const out = [];
  const push = u => { if (u && typeof u === 'string' && /^https?:/.test(u)) out.push(u.split('?')[0]); };
  push(row.image_url);
  for (const col of ['all_images', 'room_setting_images']) {
    let v = row[col];
    if (!v) continue;
    try { const arr = typeof v === 'string' ? JSON.parse(v) : v; if (Array.isArray(arr)) arr.forEach(push); }
    catch { /* not JSON — ignore */ }
  }
  // dedup, preserve order
  const seen = new Set();
  return out.filter(u => { if (seen.has(u)) return false; seen.add(u); return true; });
}

// derive eyebrow=pattern, name=colorway from real fields (NEVER private-label)
function display(brand, pattern, color, title) {
  const eyebrow = (pattern || '').trim();
  const name = (color || '').trim() || (title || '').trim() || eyebrow;
  return { eyebrow, name };
}

async function loadNetNew() {
  const pg = new Client({ connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified' });
  await pg.connect();
  const { rows } = await pg.query(`
    SELECT brand, mfr_sku, pattern_name, color_name, collection, composition, width,
           roll_length, pattern_repeat, match_type, finish, application, coverage,
           material, design, description, ai_description, image_url, all_images,
           room_setting_images, dominant_color_hex, color_hex, dw_sku, product_type
    FROM quadrille_house_catalog
    WHERE COALESCE(image_rejected,false)=false
    ORDER BY brand, pattern_name, color_name`);
  await pg.end();

  return rows.map((r, i) => {
    const imgs = parseImgs(r);
    const nm = display(r.brand, r.pattern_name, r.color_name, r.mfr_sku);
    const c = hexToBucket(r.dominant_color_hex || r.color_hex) || {};
    // stable handle from real sku — used for /product/<handle> on THIS microsite only
    const handle = (r.mfr_sku || `${r.brand}-${i}`).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
    return {
      brand: r.brand,
      cta_mode: 'sample',                // net-new: sell a $4.25 sample variant (no roll price — no cost)
      sample_price: 4.25,                // flat sample price (China Seas model), cost-independent
      handle,
      sku: r.mfr_sku,
      series: r.pattern_name || null,    // pattern = the series/filter
      color: r.color_name || null,
      title: [r.pattern_name, r.color_name].filter(Boolean).join(' — ') || r.mfr_sku,
      display_eyebrow: nm.eyebrow,
      display_name: nm.name,
      book: r.collection || r.brand,
      composition: r.composition || r.material || null,
      width: r.width || null,
      repeat: r.pattern_repeat || null,
      match_type: r.match_type || null,
      finish: r.finish || null,
      application: r.application || null,
      coverage: r.coverage || null,
      product_type: r.product_type || 'Wallcoverings',
      body_html: r.ai_description || r.description || null,
      hex: c.hex || null, hue: c.hue ?? null, color_bucket: c.bucket || null,
      sat: c.sat ?? null, val: c.val ?? null,
      swatch: imgs[0] || null,
      room: imgs[1] || imgs[0] || null,
      images: imgs,
      store_url: null,                   // held → no live product link; CTA = quote form
      published_at: null,
    };
  });
}

async function api(p, tries = 5) {
  for (let i = 0; i < tries; i++) {
    const res = await fetch(`https://${STORE}/admin/api/${API}${p}`, { headers: { 'X-Shopify-Access-Token': TOKEN } });
    if (res.status === 429 || res.status >= 500) { await sleep(2000 * (i + 1)); continue; }
    await sleep(120);
    return res;
  }
  throw new Error(`api ${p} failed`);
}

async function loadChinaSeas() {
  if (!TOKEN) { console.warn('[china-seas] no SHOPIFY_ADMIN_TOKEN — skipping live brand'); return []; }
  let url = `/products.json?vendor=${encodeURIComponent('China Seas')}&status=active&limit=250&fields=id,handle,title,tags,images,variants,product_type,published_at`;
  const prods = [];
  while (url) {
    const res = await api(url);
    const link = res.headers.get('Link') || '';
    const d = await res.json();
    prods.push(...(d.products || []));
    const m = link.split(',').find(s => s.includes('rel="next"'));
    url = m ? m.slice(m.indexOf('<') + 1, m.indexOf('>')).replace(/^https:\/\/[^/]+\/admin\/api\/[^/]+/, '') : null;
  }
  console.log(`[china-seas] ${prods.length} live products`);
  return prods.map(p => {
    const imgs = (p.images || []).map(im => im.src.split('?')[0]);
    // strip trailing " | China Seas" + redundant "Fabric/Wallcovering"
    const base = (p.title || '').replace(/\s*\|\s*China Seas\s*$/i, '').replace(/\s+(Fabric|Wallcoverings?)\s*$/i, '').trim();
    // China Seas titles read "Pattern Colorway ... Fabric" — keep the whole base as name, pattern = first token group
    const tags = (p.tags || '').split(',').map(s => s.trim()).filter(Boolean);
    const series = tags.find(t => /pattern|design/i.test(t)) || base.split(' ').slice(0, 2).join(' ');
    return {
      brand: 'China Seas',
      cta_mode: 'live',                 // priced + live → real product link
      handle: p.handle,
      sku: (p.variants?.[0]?.sku || '').replace(/-sample$/i, '') || p.handle,
      series,
      color: base,
      title: base,
      display_eyebrow: series,
      display_name: base,
      book: 'China Seas',
      composition: null, width: null, repeat: null, match_type: null,
      finish: null, application: null, coverage: null,
      product_type: p.product_type || 'Wallcoverings',
      body_html: null,
      hex: null, hue: null, color_bucket: null, sat: null, val: null,
      swatch: imgs[0] || null,
      room: imgs[1] || imgs[0] || null,
      images: imgs,
      store_url: `${STORE_BASE}/products/${p.handle}`,
      published_at: p.published_at || null,
    };
  });
}

// DW banned-word rule: "Wallpaper" → "Wallcovering" across customer-facing fields
function deWallpaper(p) {
  const fix = s => typeof s === 'string'
    ? s.replace(/Wallpapers/g, 'Wallcoverings').replace(/wallpapers/g, 'wallcoverings')
        .replace(/Wallpaper/g, 'Wallcovering').replace(/wallpaper/g, 'wallcovering')
    : s;
  for (const k of ['title', 'color', 'series', 'display_eyebrow', 'display_name', 'book', 'body_html', 'product_type']) p[k] = fix(p[k]);
  return p;
}

(async () => {
  console.log('Building Quadrille house snapshot…');
  const [netnew, china] = await Promise.all([loadNetNew(), loadChinaSeas()]);
  const products = [...china, ...netnew].map(deWallpaper);

  const byBrand = {};
  for (const p of products) byBrand[p.brand] = (byBrand[p.brand] || 0) + 1;
  console.log('  brands:', byBrand);

  fs.writeFileSync(OUT, JSON.stringify({
    house: 'Quadrille',
    captured_count: products.length,
    brands: Object.entries(byBrand).sort((a, b) => b[1] - a[1]),
    products,
  }, null, 2));
  console.log(`  → ${OUT}  (${products.length} products)`);
})().catch(e => { console.error(e); process.exit(1); });