← back to Artmura Internal

scripts/build-products-json.js

171 lines

#!/usr/bin/env node
/**
 * Internal Artmura viewer data — build data/products.json from the LIVE DW
 * Shopify storefront feed (designerwallcoverings.com/products.json), filtered
 * to vendor Artmura. The local shopify_products mirror is sparse for Artmura
 * (no SKUs/prices), so the live feed is the authoritative source — same source
 * the public artmura microsite (dw-vendor-landing template) builds from.
 * INTERNAL ONLY. Retail = live variant price (samples excluded). $0 (public feed).
 */
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');

const OUT = path.join(__dirname, '..', 'data', 'products.json');
const FEED = 'https://www.designerwallcoverings.com/products.json';
const VENDOR = 'Artmura';

const PW = (() => {
  try {
    const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
    const m = env.match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m);
    return m ? m[1].replace(/^["']|["']$/g, '').trim() : '';
  } catch { return ''; }
})();

const BUCKET = {
  white: 'white', ivory: 'white', cream: 'white', beige: 'white',
  grey: 'grey', gray: 'grey', silver: 'grey', neutral: 'grey', taupe: 'grey',
  black: 'black', charcoal: 'black',
  red: 'red', burgundy: 'red',
  orange: 'orange', rust: 'orange', copper: 'orange', terracotta: 'orange',
  brown: 'brown', tan: 'brown', bronze: 'brown', chocolate: 'brown',
  gold: 'gold', yellow: 'gold', mustard: 'gold',
  green: 'green', olive: 'green', sage: 'green', emerald: 'green',
  teal: 'teal', turquoise: 'teal', aqua: 'teal',
  blue: 'blue', navy: 'blue', indigo: 'blue',
  purple: 'purple', violet: 'purple', lavender: 'purple', plum: 'purple',
  pink: 'pink', rose: 'pink', blush: 'pink', magenta: 'pink',
};
const BUCKET_ORDER = ['white','grey','black','pink','red','orange','brown','gold','green','teal','blue','purple'];
const BUCKET_HUE   = { red:0, orange:30, gold:50, green:120, teal:175, blue:215, purple:280, pink:330, brown:25, white:null, grey:null, black:null };

const clean = s => (s == null ? s : String(s)
  .replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering'));

function bucketFor(tags) {
  for (const t of (tags || [])) {
    const key = String(t || '').toLowerCase().trim();
    if (BUCKET[key]) return BUCKET[key];
  }
  return null;
}

async function fetchFeed() {
  const all = [];
  for (let page = 1; page <= 40; page++) {
    const r = await fetch(`${FEED}?limit=250&page=${page}`, { headers: { 'User-Agent': 'DW-internal-viewer' } });
    if (!r.ok) throw new Error(`feed page ${page}: HTTP ${r.status}`);
    const j = await r.json();
    if (!j.products || j.products.length === 0) break;
    all.push(...j.products);
  }
  return all.filter(p => (p.vendor || '').trim() === VENDOR);
}

// Vendor ops meta — Artmura is CARRIED BY NEWWALL (Steve 2026-07-02): purchasing
// goes through the NewWall distributor account, so contact comes from fmpro's
// New Wall rows. buy_from makes the request emails greet "NewWall team" while
// the page still shows Artmura as the line.
// DISCOUNT: NewWall's 20% is LINE-SPECIFIC (Steve 2026-07-02 "only certain
// lines we get 20% off" — fmpro confirms only Coordonné + Escher by JV at 20%).
// Artmura's discount is UNCONFIRMED → left null; Get Price asks the vendor to
// confirm net cost + applicable discount. Set ARTMURA_DISCOUNT_PCT when confirmed.
const BUY_FROM = 'NewWall';
const ARTMURA_DISCOUNT_PCT = null; // unconfirmed — do NOT inherit newwall's blanket 20%
async function fetchVendorMeta() {
  const out = { name: VENDOR, buy_from: BUY_FROM, phone: null, email: null, account_number: null,
    discount_pct: null, pricing_unit: null, pricing_model: null, pricing_notes: null, sample_price: null };
  if (!PW) return out;
  const pool = new Pool({ host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW, max: 1 });
  try {
    const vr = (await pool.query(`SELECT pricing_unit, pricing_model, pricing_notes, sample_price FROM vendor_registry WHERE vendor_code = 'newwall' LIMIT 1`)).rows[0] || {};
    // fmpro New Wall rows are messy: the main row has the phone, a sub-line row has the real email
    const fmEmail = (await pool.query(`SELECT email_1 FROM fmpro WHERE vendor_name ILIKE '%new%wall%' AND email_1 LIKE '%@%' LIMIT 1`)).rows[0] || {};
    const fmPhone = (await pool.query(`SELECT phone, account_num FROM fmpro WHERE vendor_name ILIKE 'new wall' AND phone IS NOT NULL LIMIT 1`)).rows[0] || {};
    const acct = (fmPhone.account_num || '').trim();
    const acctOk = /^[A-Za-z0-9][A-Za-z0-9\-\/. ]{1,19}$/.test(acct) && !/discount|resale|on file|%/i.test(acct);
    Object.assign(out, {
      phone: fmPhone.phone || null,
      email: fmEmail.email_1 || null,
      account_number: acctOk ? acct : null,
      discount_pct: ARTMURA_DISCOUNT_PCT,
      pricing_unit: vr.pricing_unit || null,
      pricing_model: vr.pricing_model || null,
      pricing_notes: 'NewWall discount is line-specific; Artmura % unconfirmed — use Get Price',
      sample_price: vr.sample_price != null ? Number(vr.sample_price) : null,
    });
  } catch (e) { console.error('vendor meta lookup failed (using defaults):', e.message); }
  await pool.end().catch(() => {});
  return out;
}

async function main() {
  const [feed, vendorMeta] = await Promise.all([fetchFeed(), fetchVendorMeta()]);

  const products = [];
  let dropped = 0;
  for (const p of feed) {
    const imgs = (p.images || []).map(i => i.src).filter(Boolean);
    if (imgs.length === 0) { dropped++; continue; }
    // retail = the sellable (non-sample) variant's price; samples are $4.25 noise
    const sellable = (p.variants || []).filter(v => !/sample/i.test(v.title || '')).sort((a, b) => Number(b.price) - Number(a.price));
    const v0 = sellable[0] || (p.variants || [])[0] || {};
    const price = v0.price != null && Number(v0.price) > 0 ? Number(v0.price) : null;
    const sku = v0.sku || null;
    const dwSku = (sku && /^DW[A-Z]{2}-\d+/.test(sku)) ? sku : null;
    const title = clean((p.title || '').replace(/\s*\|\s*Artmura\s*$/i, ''));
    const bucket = bucketFor(p.tags);
    products.push({
      handle: p.handle,
      dw_sku: dwSku,
      sku: dwSku ? null : sku,                    // vendor/model # when it isn't our DW sku
      title: `${title} | ${VENDOR}`,
      display_eyebrow: VENDOR,
      display_name: title,
      series: title.replace(/\s+(Wallcovering|Mural).*$/i, '') || title,
      color: null,
      book: p.product_type || VENDOR,
      color_bucket: bucket,
      hue: bucket ? BUCKET_HUE[bucket] : null,
      hex: null, sat: null,
      val: bucket === 'white' ? 1 : bucket === 'black' ? 0 : 0.5,
      width: null, length: null, repeat: null, material: null, match: null,
      price, cost: null, price_code: null,
      body_html: clean((p.body_html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()),
      swatch: imgs[0] || null,
      room: imgs[1] || imgs[0] || null,
      images: imgs.slice(0, 12),
      published_at: p.published_at || p.created_at,
      inquiry_sku: dwSku || sku || p.handle,
    });
  }

  const facets = { books: {}, series: {}, colors: {} };
  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;
  }
  const orderedColors = BUCKET_ORDER.filter(b => facets.colors[b]).map(b => [b, facets.colors[b]]);

  const snapshot = {
    built_at: new Date().toISOString(),
    source: 'designerwallcoverings.com live Shopify feed, vendor=Artmura (INTERNAL viewer)',
    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, 300),
      colors: orderedColors,
    },
    products,
  };
  fs.writeFileSync(OUT, JSON.stringify(snapshot));
  console.log(`products.json -> ${OUT}`);
  console.log(`  products: ${products.length} | dropped (no image): ${dropped} | with price: ${products.filter(x => x.price).length}`);
}
main().catch(e => { console.error(e); process.exit(1); });