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