← back to Dw Domain Fleet

shared/catalog.js

119 lines

/**
 * catalog.js — loads data/catalog.json once, exposes niche-filtered slices.
 */
const fs = require('fs');
const path = require('path');

const RAW = (() => {
  try {
    const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'catalog.json'), 'utf8'));
    return Array.isArray(d) ? d : [];
  } catch (e) {
    console.error('[catalog] FATAL: could not load catalog.json —', e.message);
    return [];
  }
})();

// --- junk-product defense (dw-site-build standing rule) ---
const HOUSEHOLD = /lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine/i;
function isJunk(p) {
  if (!p.image_url || !p.image_url.trim()) return true;
  if (!p.handle) return true;
  if (HOUSEHOLD.test(p.title || '')) return true;
  if (/^\d{13,}$/.test(p.sku || '')) return true;
  // TK-11307/TK-11037 (Steve approved Option A, 2026-09-11): the 'display_variant' tag
  // stopped discriminating — it went from 2,166/15,000 products to 100% of the catalog
  // (data-shape change at TK-11186/3161f75), so this check zeroed CLEAN and emptied every
  // microsite grid fleet-wide for 2 days. Removed; showroom hiding is handled by
  // isShowroomProduct, real junk by the checks above.
  return false;
}

// Showroom-only vendors (addressable but not discoverable on microsites).
// Source of truth: config/showroom-vendors.json — matches fix-live-board canonical list. (TK-11200)
const SHOWROOM_VENDORS = (() => {
  try {
    return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config', 'showroom-vendors.json'), 'utf8'))
      .map(v => v.toLowerCase());
  } catch { return []; }
})();

function isShowroomVendor(p) {
  if (!p.vendor || !SHOWROOM_VENDORS.length) return false;
  return SHOWROOM_VENDORS.includes(String(p.vendor).trim().toLowerCase());
}

// Product-level showroom TAG (TK-11307). A shared-vendor showroom line (MDC under the
// shared "Phillipe Romano" label, which also carries SELLABLE lines) is hidden from
// microsite grids by its "ShowroomOnly" tag, WITHOUT hiding the vendor's sellable products.
// Until now MDC was excluded only incidentally via the display_variant junk rule — this
// makes the exclusion rule-based. Mirrors fix-live-board's isShowroomProduct, kept local
// so the deployed fleet stays self-contained.
// TK-11307 (Steve, 2026-09-10): tag is 'ShowroomOnly', not 'Showroom' — the pre-existing
// 'Showroom Line' tag is on 18,518+ ACTIVE SELLABLE products and Shopify/Boost `tag:`
// queries prefix-match, so 'Showroom' is unsafe. Mirrors fix-live-board SHOWROOM_TAG.
const SHOWROOM_TAG = 'showroomonly';
function isShowroomTagged(p) {
  const tags = p.tags;
  const arr = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []);
  return arr.some(t => String(t).trim().toLowerCase() === SHOWROOM_TAG);
}

// A product is showroom-only (addressable-but-not-discoverable) if its vendor is on the
// list OR it carries the ShowroomOnly tag.
function isShowroomProduct(p) {
  return isShowroomVendor(p) || isShowroomTagged(p);
}

const CLEAN = RAW.filter(p => !isJunk(p) && !isShowroomProduct(p));

// --- product-type normalization ---
function normType(t) {
  if (!t) return 'Other';
  const x = String(t).replace(/s$/i, '').trim();
  if (/wallcovering/i.test(x)) return 'Wallcovering';
  if (/wallpaper/i.test(x)) return 'Wallcovering';
  if (/mural/i.test(x)) return 'Mural';
  if (/fabric/i.test(x)) return 'Fabric';
  if (/trim/i.test(x)) return 'Trim';
  return x;
}

/**
 * niche filter — positive keywords (any-match) minus negative keywords (any-match).
 * Matches against title + tags + product_type. If pos is empty → all clean products.
 */
function nicheSlice({ pos = [], neg = [], types = [], limit = 4000 }) {
  const P = pos.map(s => s.toLowerCase());
  const N = neg.map(s => s.toLowerCase());
  const T = types.map(s => s.toLowerCase());
  const out = [];
  for (const p of CLEAN) {
    const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ') + ' ' + (p.product_type || '')).toLowerCase();
    if (N.length && N.some(n => blob.includes(n))) continue;
    if (T.length && !T.includes(normType(p.product_type).toLowerCase())) continue;
    if (P.length && !P.some(k => blob.includes(k))) continue;
    out.push(p);
    if (out.length >= limit) break;
  }
  return out;
}

/**
 * siteExtras — per-site catalog overlay. Returns the products in
 * data/extras/<slug>.json (or [] if none). Used to add site-exclusive products
 * (e.g. the AI-generated LA toile line on asseeninla) WITHOUT writing to the
 * shared Shopify feed and WITHOUT being clobbered by pull-catalog. Each extra is
 * a normal product object (title, handle, image_url, sku, product_type, tags,
 * price, created_at) and may carry an optional buy_url for the sample CTA.
 */
function siteExtras(slug) {
  if (!slug) return [];
  try {
    const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'extras', slug + '.json'), 'utf8'));
    return Array.isArray(d) ? d.filter(p => p && p.image_url && p.handle) : [];
  } catch { return []; }
}

module.exports = { RAW, CLEAN, isJunk, isShowroomVendor, isShowroomTagged, isShowroomProduct, normType, nicheSlice, siteExtras, count: CLEAN.length };