/*! * Nuva Product Catalog Admin — Phase 6B * --------------------------------------------------------------------------- * PURPOSE * Adds catalog STORAGE + ADMINISTRATION + PLACEHOLDER-VISIBILITY GATING on * top of the verified Phase 6A matching engine. This module does NOT * re-implement matching: every compatibility decision is delegated to the * existing, unmodified window.NuvaProductMatchingEngine.generateProductMatches(). * * Phase 6A's own internal NUVA_PRODUCT_CATALOG (inside * nuva_product_matching_engine.js) is untouched by this file and remains * empty, exactly as Phase 6A left it. This module keeps its OWN catalog * array and passes it explicitly as the third argument to * generateProductMatches(assessment, recommendation, thisModulesCatalog) — * which is exactly what that function's public API is designed for * (products param overrides the default catalog lookup). Phase 6A's file * is not edited in any way. * * WHERE THIS CATALOG LIVES * `CATALOG` below — a plain in-memory array, module-scope, holding BOTH * real products and development placeholder products (each product object * carries its own `isPlaceholder` flag — see Part 2 of the Phase 6B spec). * Development placeholder DATA itself lives in a separate file * (nuva_dev_placeholder_products.js) and is registered into this module * via registerPlaceholderProducts() — kept separate so that replacing * placeholders with real products later never touches this logic file. * * PLACEHOLDER VISIBILITY (Part 3) * SETTINGS.allowPlaceholderProducts defaults to false. getActiveCatalog(), * getRecommendations(), and isProductPubliclyVisible() all gate on this * flag — a placeholder product is invisible to every customer-facing path * whenever it is false, with no separate code path to forget to check. * * ADMIN SETTINGS (Part 30) * productRecommendationsEnabled (default true) * allowPlaceholderProducts (default false) * maxDisplayedRecommendations (default 4) * * SAFETY (Part 7/26) * getRecommendations() calls the existing matching engine first. If that * engine returns status:'professional-guidance' (Phase 6A's own absolute * safety override — unmodified), this module returns sectionStatus: * 'safety-blocked' BEFORE even checking productRecommendationsEnabled. * Safety is checked first, unconditionally, every time. * * PUBLIC API — window.NuvaProductCatalogAdmin * version * getSettings() / updateSettings(partial) * createBlankProduct() — delegates to the Phase 6A schema * addProduct(product) -> {ok, product, errors} * updateProduct(productId, updates) -> {ok, product, errors} * setStatus(productId, status) -> {ok, product, errors} * deleteProduct(productId) -> boolean * duplicateAsDraft(productId) -> {ok, product, errors} * getProduct(productId) * listProducts(filter) * searchProducts(query) * validateProduct(product, opts) * isProductPubliclyVisible(productId) * getActiveCatalog() — resolved, placeholder-gated, active-only * getRecommendations(assessment, recommendation, displayLimit) * — displayLimit (Phase 6C, Part "no hard-coded max of 3") is OPTIONAL. * Omit it and behavior is unchanged from Phase 6B (defaults to * SETTINGS.maxDisplayedRecommendations). Pass a larger number (e.g. * the totalCompatible value from a prior call) to get more of the * already-computed compatible set — e.g. for a "View More" UI * affordance. This never changes what the matching engine computes, * only how much of it is returned for display. * registerPlaceholderProducts(products) — used once by the placeholder data file * _internals: { CATALOG, SETTINGS } — exposed for testing only */ (function (root) { 'use strict'; var ADMIN_VERSION = '1.0'; var STATUS_VALUES = ['draft', 'active', 'inactive']; var SETTINGS = { productRecommendationsEnabled: true, allowPlaceholderProducts: false, maxDisplayedRecommendations: 4 }; // The one catalog array. Real products and development placeholders live // here side by side, distinguished only by the isPlaceholder flag on each // product — never by which array they were pushed into. var CATALOG = []; function getMatchingEngine() { var engine = root.NuvaProductMatchingEngine; if (!engine || typeof engine.generateProductMatches !== 'function') { throw new Error('NuvaProductCatalogAdmin requires window.NuvaProductMatchingEngine (Phase 6A) to be loaded first.'); } return engine; } function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function createBlankProduct() { var base = getMatchingEngine().createBlankProduct(); // Phase 6B additions layered on top of the Phase 6A schema (Part 1) — // nothing removed or renamed from the existing schema. base.isPlaceholder = false; base.displayOrder = null; return base; } /* =========================================================================== * VALIDATION (Part 37) * =========================================================================== */ function validateProduct(product, opts) { opts = opts || {}; var errors = []; if (!product || typeof product !== 'object') { return { valid: false, errors: ['Product must be an object.'] }; } if (!product.productId) errors.push('productId is required.'); if (!product.sku) errors.push('sku is required.'); if (!product.name) errors.push('name is required.'); if (!product.category) errors.push('category is required.'); var forActivation = !!opts.forActivation; if (forActivation) { var hasApplication = !!(product.application && (product.application.face || product.application.body)); if (!hasApplication) errors.push('application (face and/or body) is required before a product can be activated.'); if (!Array.isArray(product.targetConcerns) || !product.targetConcerns.length) { errors.push('targetConcerns must have at least one value before a product can be activated.'); } // Compatibility (skin type / sensitivity / reactivity) is validated for // SHAPE, not forced to be non-empty: Phase 6A's matching engine treats // an undeclared compatibility list as neutral/conservative rather than // a rejection (Part 6.3), so a product intentionally left compatible // with "everyone" is a valid, activatable product — it just means // every customer gets a neutral (non-blocking) compatibility signal. if (!Array.isArray(product.suitableSkinTypes) || !Array.isArray(product.sensitivityCompatibility) || !Array.isArray(product.reactivityCompatibility)) { errors.push('suitableSkinTypes, sensitivityCompatibility, and reactivityCompatibility must all be arrays (may be empty) before activation.'); } if (!Array.isArray(product.recommendationDirections) || !product.recommendationDirections.length) { errors.push('recommendationDirections must have at least one value before activation.'); } if (product.price === null || product.price === undefined || product.price === '') { errors.push('price is required before a product can be activated for purchase.'); } if (!product.stock || !product.stock.status || product.stock.status === 'unknown') { errors.push('stock.status must be set (not "unknown") before activation.'); } if (!product.imageUrl) errors.push('imageUrl is required before a product can be activated for display.'); } return { valid: errors.length === 0, errors: errors }; } function findIndexById(productId) { for (var i = 0; i < CATALOG.length; i++) { if (CATALOG[i].productId === productId) return i; } return -1; } function skuTaken(sku, excludingProductId) { if (!sku) return false; return CATALOG.some(function (p) { return p.sku === sku && p.productId !== excludingProductId; }); } /* =========================================================================== * PRODUCT CRUD (Part 7) * =========================================================================== */ function addProduct(product) { if (!product || !product.productId) { return { ok: false, product: null, errors: ['productId is required.'] }; } if (findIndexById(product.productId) !== -1) { return { ok: false, product: null, errors: ['A product with productId "' + product.productId + '" already exists.'] }; } if (skuTaken(product.sku, product.productId)) { return { ok: false, product: null, errors: ['A product with sku "' + product.sku + '" already exists.'] }; } var baseChecks = validateProduct(product, { forActivation: false }); if (!baseChecks.valid) { return { ok: false, product: null, errors: baseChecks.errors }; } if (product.status === 'active') { var activationChecks = validateProduct(product, { forActivation: true }); if (!activationChecks.valid) { return { ok: false, product: null, errors: activationChecks.errors }; } } var toStore = clone(product); if (STATUS_VALUES.indexOf(toStore.status) === -1) toStore.status = 'draft'; if (typeof toStore.isPlaceholder !== 'boolean') toStore.isPlaceholder = false; if (!toStore.metadata) toStore.metadata = {}; var now = new Date().toISOString(); toStore.metadata.createdAt = toStore.metadata.createdAt || now; toStore.metadata.updatedAt = now; CATALOG.push(toStore); return { ok: true, product: clone(toStore), errors: [] }; } function updateProduct(productId, updates) { var idx = findIndexById(productId); if (idx === -1) return { ok: false, product: null, errors: ['No product found with productId "' + productId + '".'] }; var merged = clone(CATALOG[idx]); for (var k in updates) { if (Object.prototype.hasOwnProperty.call(updates, k) && k !== 'productId') merged[k] = updates[k]; } if (merged.sku !== CATALOG[idx].sku && skuTaken(merged.sku, productId)) { return { ok: false, product: null, errors: ['A product with sku "' + merged.sku + '" already exists.'] }; } var baseChecks = validateProduct(merged, { forActivation: false }); if (!baseChecks.valid) return { ok: false, product: null, errors: baseChecks.errors }; if (merged.status === 'active') { var activationChecks = validateProduct(merged, { forActivation: true }); if (!activationChecks.valid) return { ok: false, product: null, errors: activationChecks.errors }; } merged.metadata = merged.metadata || {}; merged.metadata.updatedAt = new Date().toISOString(); CATALOG[idx] = merged; return { ok: true, product: clone(merged), errors: [] }; } function setStatus(productId, status) { if (STATUS_VALUES.indexOf(status) === -1) { return { ok: false, product: null, errors: ['status must be one of: ' + STATUS_VALUES.join(', ')] }; } return updateProduct(productId, { status: status }); } function deleteProduct(productId) { var idx = findIndexById(productId); if (idx === -1) return false; CATALOG.splice(idx, 1); return true; } function duplicateAsDraft(productId) { var idx = findIndexById(productId); if (idx === -1) return { ok: false, product: null, errors: ['No product found with productId "' + productId + '".'] }; var copy = clone(CATALOG[idx]); copy.productId = productId + '-copy-' + Date.now(); copy.sku = ''; // must be assigned a new unique SKU before this draft can be activated copy.status = 'draft'; copy.metadata = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: copy.metadata && copy.metadata.version || '1.0' }; return addProduct(copy); } function getProduct(productId) { var idx = findIndexById(productId); return idx === -1 ? null : clone(CATALOG[idx]); } function listProducts(filter) { filter = filter || {}; return CATALOG.filter(function (p) { if (filter.status && p.status !== filter.status) return false; if (typeof filter.isPlaceholder === 'boolean' && p.isPlaceholder !== filter.isPlaceholder) return false; if (filter.category && p.category !== filter.category) return false; return true; }).map(clone); } function searchProducts(query) { var q = (query || '').toLowerCase(); if (!q) return listProducts(); return CATALOG.filter(function (p) { return (p.name || '').toLowerCase().indexOf(q) !== -1 || (p.productId || '').toLowerCase().indexOf(q) !== -1 || (p.sku || '').toLowerCase().indexOf(q) !== -1 || (p.category || '').toLowerCase().indexOf(q) !== -1; }).map(clone); } /* =========================================================================== * PLACEHOLDER REGISTRATION (Part 2) — called once by the data file * =========================================================================== */ function registerPlaceholderProducts(products) { var results = []; (products || []).forEach(function (p) { var withFlag = clone(p); withFlag.isPlaceholder = true; // enforced here regardless of what the data file set results.push(addProduct(withFlag)); }); return results; } /* =========================================================================== * SETTINGS (Part 30) * =========================================================================== */ function getSettings() { return clone(SETTINGS); } function updateSettings(partial) { partial = partial || {}; if (typeof partial.productRecommendationsEnabled === 'boolean') SETTINGS.productRecommendationsEnabled = partial.productRecommendationsEnabled; if (typeof partial.allowPlaceholderProducts === 'boolean') SETTINGS.allowPlaceholderProducts = partial.allowPlaceholderProducts; if (typeof partial.maxDisplayedRecommendations === 'number' && partial.maxDisplayedRecommendations > 0) SETTINGS.maxDisplayedRecommendations = partial.maxDisplayedRecommendations; return getSettings(); } /* =========================================================================== * VISIBILITY + ACTIVE CATALOG RESOLUTION (Part 3, Part 4, Part 27) * =========================================================================== */ function isProductPubliclyVisible(productId) { var idx = findIndexById(productId); if (idx === -1) return false; var p = CATALOG[idx]; if (p.status !== 'active') return false; if (p.isPlaceholder && !SETTINGS.allowPlaceholderProducts) return false; return true; } /** * PART 16 — every product resolves to a URL: its own declared productUrl * if set, otherwise a temporary development route built from productId so * nothing needs a hand-built page per product. */ function getProductUrl(product) { if (!product) return ''; if (product.productUrl) return product.productUrl; return '?nuva_product=' + encodeURIComponent(product.productId); } function isProductPurchasable(productId) { if (!isProductPubliclyVisible(productId)) return false; var idx = findIndexById(productId); var p = CATALOG[idx]; return !!(p.stock && p.stock.status === 'in-stock' && p.price !== null && p.price !== undefined); } function getActiveCatalog() { return CATALOG.filter(function (p) { if (p.status !== 'active') return false; if (p.isPlaceholder && !SETTINGS.allowPlaceholderProducts) return false; return true; }).map(clone); } /* =========================================================================== * PART 5/23/24/26/29 — CUSTOMER-FACING RECOMMENDATION RESOLUTION * =========================================================================== * Delegates every compatibility decision to the unmodified Phase 6A * NuvaProductMatchingEngine. This function only decides WHICH catalog to * hand it (placeholder-gated, active-only) and how to present the result * (section state + a display-limited slice), never re-deriving matches. */ function getRecommendations(assessment, recommendation, displayLimit) { var engine = getMatchingEngine(); var activeCatalog = getActiveCatalog(); var matchResult = engine.generateProductMatches(assessment, recommendation, activeCatalog); // Safety is checked first, unconditionally — it overrides the global // switch, individual product status, and everything else (Part 26). if (matchResult.status === 'professional-guidance') { return { sectionStatus: 'safety-blocked', matchResult: matchResult, displayedProducts: [], totalCompatible: 0 }; } if (!SETTINGS.productRecommendationsEnabled) { return { sectionStatus: 'disabled', matchResult: matchResult, displayedProducts: [], totalCompatible: 0 }; } if (matchResult.status === 'catalog-empty') { return { sectionStatus: 'catalog-empty', matchResult: matchResult, displayedProducts: [], totalCompatible: 0 }; } if (matchResult.status !== 'matched') { return { sectionStatus: 'no-match', matchResult: matchResult, displayedProducts: [], totalCompatible: 0 }; } // PART 10/29 — the full compatible set is preserved on matchResult; // maxDisplayedRecommendations only trims what is shown, never what was // computed. var compatible = (matchResult.matches || []).filter(function (m) { return m.matchStatus === 'strong-match' || m.matchStatus === 'compatible'; }); // PART 28 — displayOrder is presentation-only. Strong matches are still // shown ahead of merely-compatible ones; displayOrder only breaks ties // within that grouping, and is never itself a quality ranking. var withOrder = compatible.map(function (m, idx) { var product = getProduct(m.productId); var order = product && typeof product.displayOrder === 'number' ? product.displayOrder : Number.MAX_SAFE_INTEGER; return { match: m, product: product, order: order, originalIndex: idx }; }).filter(function (entry) { return !!entry.product; }); withOrder.sort(function (a, b) { var aStrong = a.match.matchStatus === 'strong-match' ? 0 : 1; var bStrong = b.match.matchStatus === 'strong-match' ? 0 : 1; if (aStrong !== bStrong) return aStrong - bStrong; if (a.order !== b.order) return a.order - b.order; return a.originalIndex - b.originalIndex; }); // PART "no hard-coded max of 3" (Phase 6C) — an explicit, valid // displayLimit argument overrides the admin-configured display count // for THIS call only (e.g. a "View More" expansion re-requesting the // full already-computed compatible set). It never limits what // generateProductMatches evaluated above, and the default behavior // (no third argument) is byte-for-byte what Phase 6B always did. var effectiveLimit = (typeof displayLimit === 'number' && displayLimit > 0) ? displayLimit : SETTINGS.maxDisplayedRecommendations; var limited = withOrder.slice(0, effectiveLimit); return { sectionStatus: 'matched', matchResult: matchResult, displayedProducts: limited.map(function (entry) { return { product: entry.product, match: entry.match }; }), totalCompatible: withOrder.length }; } var api = { version: ADMIN_VERSION, getSettings: getSettings, updateSettings: updateSettings, createBlankProduct: createBlankProduct, addProduct: addProduct, updateProduct: updateProduct, setStatus: setStatus, deleteProduct: deleteProduct, duplicateAsDraft: duplicateAsDraft, getProduct: getProduct, listProducts: listProducts, searchProducts: searchProducts, validateProduct: validateProduct, isProductPubliclyVisible: isProductPubliclyVisible, isProductPurchasable: isProductPurchasable, getProductUrl: getProductUrl, getActiveCatalog: getActiveCatalog, getRecommendations: getRecommendations, registerPlaceholderProducts: registerPlaceholderProducts, _internals: { CATALOG: CATALOG, // exposed by reference for tests only SETTINGS: SETTINGS } }; root.NuvaProductCatalogAdmin = api; if (typeof module !== 'undefined' && module.exports) { module.exports = api; } })(typeof window !== 'undefined' ? window : this);