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