/*! * Nuva Product Matching Engine — Phase 6A * --------------------------------------------------------------------------- * PURPOSE * Infrastructure + foundation layer that will let real Nuva products be * evaluated against a customer's existing Free or Advanced assessment and * ingredient recommendation, once those products exist. It does NOT: * - invent products, prices, SKUs, images or URLs * - replace or duplicate the assessment / ingredient-recommendation logic * - talk to any network/API/AI service * - render any customer-facing UI * * This file is a single, self-contained, dependency-free module. It reads * the OUTPUT of the existing engines (never their internals, never their * raw questionnaire logic) and is safe to load on any page after those * engines, or to run standalone in Node for testing. * * ARCHITECTURE (see Part 27/28 of the Phase 6A spec) * * FREE: Quick Skin Match -> NuvaAssessmentEngine -> NuvaIngredientRecommendationEngine -> [this module] -> catalog * ADVANCED: Advanced Skin Scan -> NuvaAdvancedAssessmentEngine -> NuvaAdvancedIngredientRecommendationEngine -> [this module] -> catalog * * This module sits strictly AFTER the ingredient recommendation step. It * never re-scores concerns, never re-derives skin type/sensitivity, and * never overrides the Tea Tree Oil / assertive-ingredient compatibility * logic that already lives in Phase 4A and Phase 5C. It only asks: "given * what those engines already decided, which of today's (zero) catalog * products would be compatible?" * * WHERE THE CATALOG LIVES * `NUVA_PRODUCT_CATALOG` below, a plain in-memory array, is the single * source of truth for this phase. It starts empty ([]) and stays empty in * production until real products exist. See "ADDING A PRODUCT LATER" further * down for the exact, documented workflow. This is intentionally NOT * localStorage (customer browser storage must never be treated as an * authoritative product catalog) and intentionally NOT a database/API * (unnecessary for a catalog that starts at zero items and does not yet * need multi-admin editing). * * INPUT CONTRACT (what this module actually reads) * Free assessment — the object returned by * window.NuvaAssessmentEngine.generateSkinAssessment(...) * Free recommendation — the object returned by * window.NuvaIngredientRecommendationEngine.generateIngredientRecommendation(...) * Advanced assessment — the object returned by * window.NuvaAdvancedAssessmentEngine.generateAdvancedAssessment(...) * Advanced recommendation — the object returned by * window.NuvaAdvancedIngredientRecommendationEngine.generateAdvancedIngredientRecommendation(...) * * Both shapes were inspected directly from the live, published engines * before writing this file (never guessed). They differ in structure (the * Free recommendation is flat, the Advanced recommendation is nested), so * `normalizeAssessment()` / `normalizeRecommendation()` below adapt both * into one internal shape used only inside this module. Nothing is written * back into either engine's data, and neither engine is modified. * * PUBLIC API * window.NuvaProductMatchingEngine = { * version, // matching engine version string * catalogVersion, // catalog schema version string * generateProductMatches(assessment, recommendation, products), * getCatalog(), // returns the live production catalog (read-only copy) * getCatalogVersion(), * createBlankProduct(), // convenience factory following PART 1's schema * CATEGORY_OPTIONS, SKIN_TYPES, SENSITIVITY_LEVELS, REACTIVITY_LEVELS, * APPLICATION_AREAS, CONCERN_CATEGORIES, // controlled taxonomies (Part 2/3) * _internals: { ... } // exposed for testing only, not a stable API * } * * generateProductMatches is deterministic: the same three inputs always * produce the same output. No randomization, no timers, no network calls. */ (function (root) { 'use strict'; var MATCHING_VERSION = '1.0'; var CATALOG_VERSION = '1.0'; /* =========================================================================== * PART 16 — THE PRODUCT CATALOG (starts and, for this phase, stays empty) * =========================================================================== * This is the ONLY place product data should ever be added. It is a plain * array living in module scope — no database, no external API, no * dependency on customer localStorage. Populating it does not require * touching the questionnaire, assessment, or ingredient-recommendation code * in any phase. */ var NUVA_PRODUCT_CATALOG = []; /* =========================================================================== * PART 2 — CATEGORY OPTIONS (controlled list, not a claim Nuva sells these) * =========================================================================== */ var CATEGORY_OPTIONS = [ 'cleanser', 'face-serum', 'face-cream', 'moisturizer', 'face-mask', 'toner', 'treatment-active', 'sunscreen', 'body-lotion', 'body-cream', 'body-serum', 'body-wash', 'body-treatment', 'spot-targeted-care', 'other' ]; /* =========================================================================== * PART 3 — CONTROLLED TAGS / TAXONOMY * =========================================================================== */ var SKIN_TYPES = ['oily', 'dry', 'combination', 'normal', 'sensitive', 'mixed-variable', 'undetermined']; var SENSITIVITY_LEVELS = ['low', 'moderate', 'elevated', 'unknown']; var REACTIVITY_LEVELS = ['low', 'moderate', 'elevated', 'unknown']; var APPLICATION_AREAS = ['face', 'body', 'specific-face-area', 'specific-body-area']; /* * Canonical concern-category taxonomy. * * This is NOT a new competing taxonomy. It is copied verbatim from the * category set already produced by window.NuvaAdvancedAssessmentEngine * (confirmed live: assessment.concerns.categories uses exactly these 11 * values). The Free flow's own internal category labels (blemish, * oil-balance, uneven-tone, hydration, radiance-glow, texture, * gentle-barrier, basic, body-mark — confirmed live from * NuvaIngredientRecommendationEngine._internals.CONCERN_CATEGORY) are * mapped onto this same canonical set by FREE_CATEGORY_MAP below, purely * inside this module's adapter layer. Neither engine was changed. */ var CONCERN_CATEGORIES = [ 'blemish', 'pigmentation', 'radiance', 'hydration', 'sensitivity', 'texture', 'aging', 'marks', 'body', 'recurring', 'general' ]; // Free-flow internal category -> canonical CONCERN_CATEGORIES value. var FREE_CATEGORY_MAP = { 'blemish': 'blemish', 'oil-balance': 'texture', 'uneven-tone': 'pigmentation', 'hydration': 'hydration', 'radiance-glow': 'radiance', 'texture': 'texture', 'gentle-barrier': 'sensitivity', 'basic': 'general', 'body-mark': 'marks' }; /* * Free-flow concern LABEL -> Free-flow internal category, copied verbatim * (all 35 entries) from the live, published * window.NuvaIngredientRecommendationEngine._internals.CONCERN_CATEGORY * map on 2026-09-16, so this module does not need to reach into that * engine's internals at runtime (which would be fragile — order-of-load * dependent in the browser, and unavailable when this module is loaded * standalone, e.g. under Node for testing). Nothing here recomputes or * second-guesses that engine's own category assignment; it is a static, * read-only copy used purely as an input-shape adapter. If Phase 4A's own * concern list ever changes, this copy should be refreshed to match — it * intentionally does not fall back to guessing. */ var FREE_CONCERN_LABEL_TO_CATEGORY = { 'Acne & breakouts': 'blemish', 'Bumps / clogged pores': 'blemish', 'Blackheads / whiteheads': 'blemish', 'Excess oil': 'oil-balance', 'Dark spots / pigmentation': 'uneven-tone', 'Uneven-looking skin tone': 'uneven-tone', 'Post-acne marks': 'uneven-tone', 'Dryness / dehydration': 'hydration', 'Dull-looking skin': 'radiance-glow', 'Rough or uneven texture': 'texture', 'Enlarged-looking pores': 'texture', 'Fine lines / signs of aging': 'texture', 'Facial scarring': 'texture', 'Redness / irritation': 'gentle-barrier', 'Sensitive or reactive skin': 'gentle-barrier', 'Other facial concern': 'basic', 'Body acne': 'blemish', 'Back or chest bumps': 'blemish', 'Ingrown-hair-related bumps': 'blemish', 'Stretch marks': 'body-mark', 'Body scars / marks': 'body-mark', 'Dark underarms': 'uneven-tone', 'Dark inner thighs': 'uneven-tone', 'Uneven body tone': 'uneven-tone', 'Rough / bumpy body skin': 'texture', 'Dry body skin': 'hydration', 'Other body concern': 'basic', 'Eczema / recurring dry patches': 'gentle-barrier', 'Itching': 'gentle-barrier', 'Flaking / scaling': 'gentle-barrier', 'Recurring redness': 'gentle-barrier', 'Burning or stinging': 'gentle-barrier', 'Recurring rash': 'gentle-barrier', 'Extremely sensitive skin': 'gentle-barrier', 'Other recurring skin concern': 'gentle-barrier' }; /* * PART 4 — RECOMMENDATION DIRECTION IDs * These are the exact recommendation IDs already produced by the live * engines (confirmed from NuvaIngredientRecommendationEngine._internals * .RECOMMENDATIONS and NuvaAdvancedIngredientRecommendationEngine * ._internals.DIRECTIONS). Listed here only so a future product's * `recommendationDirections` field has a documented, stable set of values * to reference. This module does not invent new direction IDs and does not * change how either engine computes them. */ var RECOMMENDATION_DIRECTIONS = [ 'blemish-focused', 'gentle-blemish-support', 'oil-balance', // Free-flow only 'uneven-tone', 'radiance-glow', 'hydration-barrier', 'gentle-barrier-support', 'texture-support', 'body-mark-support', 'basic-skin-support', 'professional-guidance' // safety-path recommendationId, never product-matched ]; /* =========================================================================== * PART 1 — PRODUCT SCHEMA * =========================================================================== * createBlankProduct() returns a fresh object following this exact shape. * Every future product pushed into NUVA_PRODUCT_CATALOG should start from * this factory (see "ADDING A PRODUCT LATER" below) so the schema never * silently drifts between products. */ function createBlankProduct() { return { productId: '', sku: '', name: '', status: 'draft', // 'draft' | 'active' | 'discontinued' category: '', // one of CATEGORY_OPTIONS subcategory: '', application: { face: false, body: false, specificAreas: [] }, targetConcerns: [], // values from CONCERN_CATEGORIES suitableSkinTypes: [], // values from SKIN_TYPES sensitivityCompatibility: [],// values from SENSITIVITY_LEVELS this product is safe for reactivityCompatibility: [], // values from REACTIVITY_LEVELS this product is safe for keyIngredients: [], ingredientsToAvoid: [], incompatibleCombinations: [], usage: { morning: false, evening: false, frequency: '', instructions: '' }, recommendationDirections: [], // values from RECOMMENDATION_DIRECTIONS description: '', imageUrl: '', productUrl: '', price: null, currency: 'NGN', stock: { status: 'unknown', quantity: null }, // Forward-looking, intentionally unused in Phase 6A (Part 15 — Complete // Program preparation). Left present but empty so the schema does not // have to change shape again when that future phase arrives. routineProgram: { schedule: [], amPmRoutine: null, usageFrequency: '', roadmapDay: null, professionalReviewNotes: [] }, metadata: { createdAt: '', updatedAt: '', version: CATALOG_VERSION } }; } /* =========================================================================== * ADDING A PRODUCT LATER — the documented, 10-step future workflow (Part 17) * =========================================================================== * None of these steps touch the questionnaire, the Free/Advanced assessment * engines, or the Free/Advanced ingredient recommendation engines. Adding a * product is entirely local to this file (or a future catalog-data file — * see the note at the end of this block). * * 1. Call `NuvaProductMatchingEngine.createBlankProduct()` to get a fresh * object in the correct schema shape (Part 1). Do not hand-roll a * product object from scratch — always start from this factory so the * schema cannot silently drift between products. * 2. Fill in real product identity: productId (stable, unique), sku, * name, status ('draft' until ready to appear, 'active' once real). * 3. Set category (one value from CATEGORY_OPTIONS) and, optionally, * subcategory. * 4. Set the real product image: imageUrl. Use the actual hosted product * photo — never a placeholder or stock image passed off as real. * 5. Set the real product page: productUrl. This should point at wherever * the future product page for this item will live. * 6. Set the real price and currency (currency defaults to 'NGN' from the * schema) and stock (status + quantity) once that data is known. Until * then leave price: null and stock.status: 'unknown' — never invent a * placeholder price. * 7. Tag concern compatibility: targetConcerns (values from * CONCERN_CATEGORIES — the canonical 11-value taxonomy shared with the * Advanced assessment engine, not a new one). * 8. Tag ingredient data: keyIngredients (what the product actually * contains), ingredientsToAvoid (ingredients this product should not * be combined/recommended with), incompatibleCombinations (routine * conflicts, e.g. other actives it should not be layered with). * 9. Tag compatibility + routing data: suitableSkinTypes (from * SKIN_TYPES), sensitivityCompatibility (from SENSITIVITY_LEVELS), * reactivityCompatibility (from REACTIVITY_LEVELS), application * (face/body/specificAreas), usage (morning/evening/frequency/ * instructions), and recommendationDirections (values from * RECOMMENDATION_DIRECTIONS — this is what lets the matcher associate * the product with what Phase 4A / Phase 5C already decided about a * customer's skincare direction). * 10. Push the completed object onto NUVA_PRODUCT_CATALOG (e.g. * `NUVA_PRODUCT_CATALOG.push(myRealProduct);`) and set status to * 'active'. The product is now automatically visible to * generateProductMatches() and will be evaluated against every future * assessment — no other code, on this page or any other, needs to * change. * * Where step 10 physically happens: for Phase 6A, NUVA_PRODUCT_CATALOG is * declared directly in this file (see "WHERE THE CATALOG LIVES" at the * top). A future phase may choose to move the array's contents into a * separate, purely-data catalog file (e.g. nuva_product_catalog_data.js) * that this module reads from — that is a data-location change only and * does not require touching generateProductMatches(), the schema, or any * assessment/recommendation engine. */ /* =========================================================================== * ADAPTER LAYER — normalize Free vs Advanced outputs into one internal shape * =========================================================================== * Nothing here changes, re-derives, or second-guesses the source engines' * conclusions. It only reshapes already-computed values into a common * internal object so the matching logic in PART 6 does not need to branch * on "is this Free or Advanced" more than once. */ function arr(v) { return Array.isArray(v) ? v : []; } function str(v) { return typeof v === 'string' ? v : ''; } function lower(v) { return str(v).toLowerCase(); } function normalizeSkinType(rawType) { var t = lower(rawType); if (!t) return 'undetermined'; if (t.indexOf('mixed') !== -1 || t.indexOf('variable') !== -1) return 'mixed-variable'; if (t.indexOf('undetermined') !== -1 || t.indexOf('unclear') !== -1 || t.indexOf('not sure') !== -1) return 'undetermined'; if (t.indexOf('combination') !== -1) return 'combination'; if (t.indexOf('oily') !== -1) return 'oily'; if (t.indexOf('dry') !== -1) return 'dry'; if (t.indexOf('sensitive') !== -1) return 'sensitive'; if (t.indexOf('normal') !== -1) return 'normal'; return 'undetermined'; } function normalizeLevel(rawLevel) { // Shared normalizer for sensitivity/reactivity style strings, whatever // their exact source wording ("Elevated", "Reported reactions to // specific products", "None reported", "Unclear", "Low", ...). var t = lower(rawLevel); if (!t) return 'unknown'; if (t.indexOf('elevat') !== -1 || t.indexOf('reported reaction') !== -1 || t.indexOf('reported pattern') !== -1) return 'elevated'; if (t.indexOf('moderate') !== -1 || t.indexOf('somewhat') !== -1) return 'moderate'; if (t.indexOf('none') !== -1 || t.indexOf('low') !== -1 || t.indexOf('not really') !== -1) return 'low'; if (t.indexOf('unclear') !== -1 || t.indexOf('unknown') !== -1) return 'unknown'; return 'unknown'; } function normalizeConfidence(rawConfidence) { var t = lower(rawConfidence); if (t.indexOf('high') !== -1) return 'high'; if (t.indexOf('moderate') !== -1 || t.indexOf('medium') !== -1) return 'moderate'; if (t.indexOf('low') !== -1) return 'low'; return 'unknown'; } /** * Detects and normalizes an assessment object from either flow. * Returns null if the input does not look like a recognizable assessment * (the caller treats that the same as "no assessment available"). */ function normalizeAssessment(assessment) { if (!assessment || typeof assessment !== 'object') return null; var isAdvanced = !!assessment.skinProfile && !!assessment.sensitivityProfile && !!assessment.concerns && Array.isArray(assessment.concerns.categories); var isFree = !!assessment.skinProfile && !!assessment.concerns && !isAdvanced; if (!isAdvanced && !isFree) return null; var safetyFlags = arr(assessment.safety && assessment.safety.flags); var requiresProfessionalAttention = !!(assessment.safety && assessment.safety.requiresProfessionalAttention); if (isAdvanced) { var advConcernCategories = []; arr(assessment.concerns.categories).forEach(function (c) { if (CONCERN_CATEGORIES.indexOf(c) !== -1 && advConcernCategories.indexOf(c) === -1) { advConcernCategories.push(c); } }); return { consultationType: str(assessment.consultationType) || 'advanced-skin-scan', flowType: 'advanced', skinType: normalizeSkinType(assessment.skinProfile.likelySkinType), skinTypeConfidence: normalizeConfidence(assessment.skinProfile.skinTypeConfidence), sensitivity: normalizeLevel(assessment.skinProfile.sensitivityLevel), reactivity: normalizeLevel(assessment.skinProfile.reactivityLevel), overallConfidence: normalizeConfidence(assessment.confidence), concernCategories: advConcernCategories, priorityConcernLabels: arr(assessment.concerns.priorityAreas), primaryConcernLabel: assessment.concerns.primary ? (function () { var match = arr(assessment.concerns.selected).filter(function (c) { return c && c.value === assessment.concerns.primary; })[0]; return match ? match.label : (arr(assessment.concerns.priorityAreas)[0] || ''); })() : (arr(assessment.concerns.priorityAreas)[0] || ''), applicationFace: arr(assessment.locations && assessment.locations.face).length > 0, applicationBody: arr(assessment.locations && assessment.locations.body).length > 0, specificFaceAreas: arr(assessment.locations && assessment.locations.face), specificBodyAreas: arr(assessment.locations && assessment.locations.body), knownProblemIngredients: arr(assessment.sensitivityProfile.knownProblemIngredients), reportedNegativeIngredients: deriveReportedNegativeIngredients(assessment.sensitivityProfile), safetyFlags: safetyFlags, requiresProfessionalAttention: requiresProfessionalAttention, source: assessment }; } // Free flow var freeConcernCategories = []; function addFreeCategory(label) { var freeCat = FREE_CONCERN_LABEL_TO_CATEGORY[label]; var canonical = freeCat ? (FREE_CATEGORY_MAP[freeCat] || null) : null; if (canonical && freeConcernCategories.indexOf(canonical) === -1) freeConcernCategories.push(canonical); } addFreeCategory(assessment.concerns.primary); arr(assessment.concerns.secondary).forEach(addFreeCategory); var reportedReactions = arr(assessment.previousExperience && assessment.previousExperience.reportedReactions); var reactivity = reportedReactions.length > 0 ? 'elevated' : (assessment.previousExperience && assessment.previousExperience.sensitivityHistory ? 'moderate' : 'low'); return { consultationType: str(assessment.source && assessment.source.consultationType) || 'quick-skin-match', flowType: 'free', skinType: normalizeSkinType(assessment.skinProfile.likelySkinType), skinTypeConfidence: normalizeConfidence(assessment.skinProfile.skinTypeConfidence), sensitivity: normalizeLevel(assessment.skinProfile.sensitivityLevel), reactivity: normalizeLevel(reactivity), overallConfidence: normalizeConfidence(assessment.skinProfile.skinTypeConfidence), concernCategories: freeConcernCategories, priorityConcernLabels: arr(assessment.concerns.priorityAreas), primaryConcernLabel: str(assessment.concerns.primary), applicationFace: true, // Free flow does not separate face/body per-location the way Advanced does; treated as general/face-first, body flagged via locations text below. applicationBody: arr(assessment.locations).some(function (loc) { return /body|chest|back|thigh|underarm|leg|knee|foot|feet|buttock|stomach|abdomen/i.test(str(loc)); }), specificFaceAreas: arr(assessment.locations), specificBodyAreas: [], knownProblemIngredients: [], reportedNegativeIngredients: deriveReportedNegativeIngredientsFree(assessment.previousExperience), safetyFlags: safetyFlags, requiresProfessionalAttention: requiresProfessionalAttention, source: assessment }; } function deriveReportedNegativeIngredients(sensitivityProfile) { var out = []; arr(sensitivityProfile && sensitivityProfile.knownProblemIngredients).forEach(function (ingredient) { out.push({ ingredient: str(ingredient), reason: 'Previously reported negative experience' }); }); return out; } function deriveReportedNegativeIngredientsFree(previousExperience) { var out = []; arr(previousExperience && previousExperience.reportedReactions).forEach(function (reaction) { var ingredient = (reaction && (reaction.ingredient || reaction.product)) || ''; if (ingredient) out.push({ ingredient: str(ingredient), reason: 'Previously reported negative experience' }); }); return out; } /** * Detects and normalizes a recommendation object from either flow. * Returns null if unrecognizable. */ function normalizeRecommendation(recommendation) { if (!recommendation || typeof recommendation !== 'object') return null; var isAdvanced = !!recommendation.primaryDirection && !!recommendation.ingredientGuidance; var isFree = !isAdvanced && !!recommendation.recommendationId && Array.isArray(recommendation.ingredients); if (!isAdvanced && !isFree) return null; var safety = recommendation.safety || { requiresProfessionalAttention: false, flags: [] }; if (isAdvanced) { var directionIds = []; if (recommendation.recommendationId) directionIds.push(recommendation.recommendationId); arr(recommendation.supportingDirections).forEach(function (d) { if (d && d.directionId && directionIds.indexOf(d.directionId) === -1) directionIds.push(d.directionId); }); var recommendedIngredients = arr(recommendation.ingredientGuidance.recommendedIngredients) .concat(arr(recommendation.primaryDirection && recommendation.primaryDirection.ingredients)); var conditionalIngredients = arr(recommendation.ingredientGuidance.conditionalIngredients); var cautionIngredients = arr(recommendation.ingredientGuidance.ingredientsToApproachCarefully); var avoidIngredients = arr(recommendation.ingredientGuidance.ingredientsToAvoidBasedOnReportedHistory) .map(function (e) { return e && e.ingredient ? e.ingredient : e; }); return { flowType: 'advanced', recommendationId: str(recommendation.recommendationId), directionIds: uniqueStrings(directionIds), recommendedIngredients: uniqueStrings(recommendedIngredients), conditionalIngredients: uniqueStrings(conditionalIngredients), cautionIngredients: uniqueStrings(cautionIngredients), avoidIngredients: uniqueStrings(avoidIngredients), morningSteps: arr(recommendation.routineGuidance && recommendation.routineGuidance.morningDirection), eveningSteps: arr(recommendation.routineGuidance && recommendation.routineGuidance.eveningDirection), requiresProfessionalAttention: !!safety.requiresProfessionalAttention, safetyFlags: arr(safety.flags), source: recommendation }; } // Free flow (flat shape) return { flowType: 'free', recommendationId: str(recommendation.recommendationId), directionIds: recommendation.recommendationId ? [recommendation.recommendationId] : [], recommendedIngredients: uniqueStrings(arr(recommendation.ingredients)), conditionalIngredients: [], cautionIngredients: [], avoidIngredients: [], morningSteps: [], eveningSteps: [], requiresProfessionalAttention: !!safety.requiresProfessionalAttention, safetyFlags: arr(safety.flags), source: recommendation }; } function uniqueStrings(list) { var seen = {}; var out = []; arr(list).forEach(function (v) { var s = str(v); if (s && !seen[s]) { seen[s] = true; out.push(s); } }); return out; } function lowerSet(list) { return arr(list).map(function (v) { return lower(v); }); } /* =========================================================================== * PART 6/7/8/9/11/12/13/14 — MATCHING LOGIC * =========================================================================== */ function buildSafetyResult(normAssessment, normRecommendation) { var flags = (normRecommendation && normRecommendation.safetyFlags && normRecommendation.safetyFlags.length) ? normRecommendation.safetyFlags : (normAssessment ? normAssessment.safetyFlags : []); return { matchingVersion: MATCHING_VERSION, status: 'professional-guidance', products: [], matches: [], primaryMatch: null, supportingMatches: [], rejectedProducts: [], matchingReasons: [], compatibilityNotes: [], totalCandidates: 0, reason: 'This assessment indicates something that may benefit from professional attention, so product recommendations are not shown.', safetyFlags: arr(flags), source: { consultationType: normAssessment ? normAssessment.consultationType : '', assessmentVersion: normAssessment && normAssessment.source ? str(normAssessment.source.assessmentVersion) : '', recommendationVersion: normRecommendation && normRecommendation.source ? str(normRecommendation.source.recommendationVersion) : '' } }; } function buildEmptyCatalogResult(normAssessment, normRecommendation) { return { matchingVersion: MATCHING_VERSION, status: 'catalog-empty', products: [], matches: [], primaryMatch: null, supportingMatches: [], rejectedProducts: [], matchingReasons: [], compatibilityNotes: [], totalCandidates: 0, message: 'No Nuva products are currently available for this recommendation.', source: { consultationType: normAssessment ? normAssessment.consultationType : '', assessmentVersion: normAssessment && normAssessment.source ? str(normAssessment.source.assessmentVersion) : '', recommendationVersion: normRecommendation && normRecommendation.source ? str(normRecommendation.source.recommendationVersion) : '' } }; } /** * Evaluates one product against the normalized assessment + recommendation. * Returns a match object (see PART 9) — never throws on malformed product * data; a product missing required fields is simply treated conservatively * (fewer compatibility signals rather than a crash). */ function evaluateProduct(product, normAssessment, normRecommendation) { var reasons = []; var cautionNotes = []; var supportedConcerns = []; var supportedDirections = []; var hardRejectionReasons = []; // 1. Concern compatibility var productConcerns = arr(product.targetConcerns); productConcerns.forEach(function (c) { if (normAssessment.concernCategories.indexOf(c) !== -1) supportedConcerns.push(c); }); var hasConcernMatch = supportedConcerns.length > 0; if (hasConcernMatch) { reasons.push('Targets a concern from this assessment (' + supportedConcerns.join(', ') + ').'); } // 2. Recommendation-direction compatibility var productDirections = arr(product.recommendationDirections); productDirections.forEach(function (d) { if (normRecommendation.directionIds.indexOf(d) !== -1) supportedDirections.push(d); }); var hasDirectionMatch = supportedDirections.length > 0; if (hasDirectionMatch) { reasons.push('Supports the recommended ingredient direction (' + supportedDirections.join(', ') + ').'); } // 3. Skin-type compatibility (conservative when assessment is uncertain) var suitableSkinTypes = arr(product.suitableSkinTypes); var skinTypeKnown = normAssessment.skinType !== 'undetermined' && normAssessment.skinType !== 'mixed-variable' && normAssessment.overallConfidence !== 'low'; var skinTypeCompatible; if (!suitableSkinTypes.length) { // Product has not declared skin-type suitability yet — neutral, not a rejection. skinTypeCompatible = null; } else if (!skinTypeKnown) { // Uncertain assessment: conservative but not an automatic rejection — // compatible only if the product explicitly welcomes uncertain/mixed // skin, otherwise treated as "not enough information" rather than a // hard rejection. skinTypeCompatible = suitableSkinTypes.indexOf('mixed-variable') !== -1 || suitableSkinTypes.indexOf('undetermined') !== -1 ? true : null; } else { skinTypeCompatible = suitableSkinTypes.indexOf(normAssessment.skinType) !== -1; } if (skinTypeCompatible === true) reasons.push('Suitable for ' + normAssessment.skinType + ' skin.'); if (skinTypeCompatible === false) hardRejectionReasons.push('Not marked suitable for ' + normAssessment.skinType + ' skin.'); // 4. Sensitivity compatibility // A definite (low/moderate/elevated) sensitivity level that conflicts // with a product's declared compatibility list is a hard rejection. // An "unknown"/unclear sensitivity — very common on both Free and // Advanced assessments — is treated the same conservative-but-not- // automatically-rejecting way Part 6.3 requires for uncertain skin // type: it is not enough information to reject a product outright. var sensitivityCompat = arr(product.sensitivityCompatibility); var sensitivityOk; if (!sensitivityCompat.length || normAssessment.sensitivity === 'unknown') { sensitivityOk = null; } else { sensitivityOk = sensitivityCompat.indexOf(normAssessment.sensitivity) !== -1; } if (sensitivityOk === false) hardRejectionReasons.push('Not marked compatible with ' + normAssessment.sensitivity + ' sensitivity.'); if (sensitivityOk === true) reasons.push('Compatible with ' + normAssessment.sensitivity + ' sensitivity.'); // 5. Reactivity compatibility (kept separate from sensitivity, per spec). // Same "unknown is not a rejection" conservatism as sensitivity above. var reactivityCompat = arr(product.reactivityCompatibility); var reactivityOk; if (!reactivityCompat.length || normAssessment.reactivity === 'unknown') { reactivityOk = null; } else { reactivityOk = reactivityCompat.indexOf(normAssessment.reactivity) !== -1; } if (reactivityOk === false) hardRejectionReasons.push('Not marked compatible with ' + normAssessment.reactivity + ' reactivity.'); if (reactivityOk === true) reasons.push('Compatible with ' + normAssessment.reactivity + ' reactivity.'); // 6. Ingredient compatibility var productKeyIngredientsLower = lowerSet(product.keyIngredients); var recommendedLower = lowerSet(normRecommendation.recommendedIngredients); var cautionLower = lowerSet(normRecommendation.cautionIngredients); var avoidLower = lowerSet(normRecommendation.avoidIngredients); var reportedNegativeLower = lowerSet(normAssessment.reportedNegativeIngredients.map(function (e) { return e.ingredient; }) .concat(normAssessment.knownProblemIngredients)); var productAvoidLower = lowerSet(product.ingredientsToAvoid); var ingredientAligned = productKeyIngredientsLower.some(function (ing) { return recommendedLower.indexOf(ing) !== -1; }); if (ingredientAligned) reasons.push('Contains ingredient(s) aligned with the current recommendation.'); var ingredientCautioned = productKeyIngredientsLower.some(function (ing) { return cautionLower.indexOf(ing) !== -1 || avoidLower.indexOf(ing) !== -1; }); if (ingredientCautioned) { cautionNotes.push('Contains an ingredient the current recommendation suggests approaching carefully or avoiding.'); } var reportedHistoryConflict = productKeyIngredientsLower.filter(function (ing) { return reportedNegativeLower.indexOf(ing) !== -1; }); if (reportedHistoryConflict.length) { reportedHistoryConflict.forEach(function (ing) { cautionNotes.push('Contains ' + ing + ', which was recorded as a previously reported negative experience. This is not treated as proof the ingredient caused a reaction, but is noted as a compatibility concern.'); }); } var productDeclaresIngredientRejection = productAvoidLower.some(function (ing) { return recommendedLower.indexOf(ing) !== -1; }); if (productDeclaresIngredientRejection) { hardRejectionReasons.push('Product explicitly lists an ingredient to avoid that conflicts with the recommendation.'); } // 7. Application compatibility var appFace = !!(product.application && product.application.face); var appBody = !!(product.application && product.application.body); var needsFace = normAssessment.applicationFace; var needsBody = normAssessment.applicationBody; var applicationCompatible = null; if (appFace || appBody) { if (needsFace && needsBody) { applicationCompatible = appFace || appBody; // either area still relevant } else if (needsBody && !needsFace) { applicationCompatible = appBody; } else if (needsFace && !needsBody) { applicationCompatible = appFace; } else { applicationCompatible = true; } if (applicationCompatible === false) { hardRejectionReasons.push('Product application area (' + (appFace ? 'face' : '') + (appFace && appBody ? '/' : '') + (appBody ? 'body' : '') + ') does not match the concern location.'); } else { reasons.push('Application area matches the concern location.'); } } // 8. Routine compatibility (informational only for now — no real routine data on any product yet) var routineNote = null; if (product.usage && (product.usage.morning || product.usage.evening)) { routineNote = 'Product declares AM/PM usage information that can be checked against future routine guidance.'; } if (arr(product.incompatibleCombinations).length) { cautionNotes.push('Product declares specific incompatible combinations; these should be checked against the customer\'s full routine once real routine matching is built.'); } var isRejected = hardRejectionReasons.length > 0; var matchStatus; if (isRejected) { matchStatus = 'not-compatible'; } else if (hasDirectionMatch && hasConcernMatch && (skinTypeCompatible !== false) && (sensitivityOk !== false) && (reactivityOk !== false) && (ingredientAligned || !productKeyIngredientsLower.length)) { matchStatus = 'strong-match'; } else if (hasConcernMatch || hasDirectionMatch || ingredientAligned) { matchStatus = 'compatible'; } else { matchStatus = 'not-compatible'; if (!hardRejectionReasons.length) hardRejectionReasons.push('No concern, direction, or ingredient alignment found with this assessment.'); } return { productId: product.productId || '', matchStatus: matchStatus, matchReasons: reasons, rejectionReasons: hardRejectionReasons, supportedConcerns: supportedConcerns, supportedDirections: supportedDirections, compatibilityNotes: reasons, cautionNotes: cautionNotes }; } /** * PART 5 — public entry point. * generateProductMatches(assessment, recommendation, products) * assessment — raw output of a Free or Advanced assessment engine * recommendation — raw output of a Free or Advanced ingredient * recommendation engine * products — optional array of product objects (defaults to the * live production catalog if omitted). Passing an * explicit array is how tests exercise the matcher * with in-memory test products without ever touching * NUVA_PRODUCT_CATALOG. */ function generateProductMatches(assessment, recommendation, products) { var normAssessment = normalizeAssessment(assessment); var normRecommendation = normalizeRecommendation(recommendation); var requiresProfessionalAttention = (normAssessment && normAssessment.requiresProfessionalAttention) || (normRecommendation && normRecommendation.requiresProfessionalAttention) || (assessment && assessment.safety && assessment.safety.requiresProfessionalAttention === true) || (recommendation && recommendation.safety && recommendation.safety.requiresProfessionalAttention === true); // PART 7 — absolute safety override. Checked before anything else, and // checked even if the assessment/recommendation shape could not be fully // normalized, so a malformed-but-safety-flagged input can never slip // through to a product recommendation. if (requiresProfessionalAttention) { return buildSafetyResult(normAssessment, normRecommendation); } var catalog = Array.isArray(products) ? products : getCatalog(); var baseResult = { matchingVersion: MATCHING_VERSION, matches: [], primaryMatch: null, supportingMatches: [], rejectedProducts: [], matchingReasons: [], compatibilityNotes: [], source: { consultationType: normAssessment ? normAssessment.consultationType : '', assessmentVersion: normAssessment && normAssessment.source ? str(normAssessment.source.assessmentVersion) : '', recommendationVersion: normRecommendation && normRecommendation.source ? str(normRecommendation.source.recommendationVersion) : '' } }; // PART 8 — clean empty state when there is nothing to evaluate against. if (!catalog.length) { var emptyResult = buildEmptyCatalogResult(normAssessment, normRecommendation); return emptyResult; } if (!normAssessment || !normRecommendation) { // Inputs did not look like a recognizable assessment/recommendation at // all. Treat conservatively as an empty match set rather than guessing. return { matchingVersion: MATCHING_VERSION, status: 'catalog-empty', products: [], matches: [], primaryMatch: null, supportingMatches: [], rejectedProducts: [], matchingReasons: [], compatibilityNotes: [], totalCandidates: catalog.length, message: 'The assessment or recommendation data could not be read, so no products are shown.', source: baseResult.source }; } // PART 10 — evaluate the ENTIRE catalog, no arbitrary slicing. var matches = catalog.map(function (product) { return evaluateProduct(product, normAssessment, normRecommendation); }); var compatibleMatches = matches.filter(function (m) { return m.matchStatus === 'strong-match' || m.matchStatus === 'compatible'; }); var rejected = matches.filter(function (m) { return m.matchStatus === 'not-compatible'; }); var strongMatches = compatibleMatches.filter(function (m) { return m.matchStatus === 'strong-match'; }); var primaryMatch = strongMatches.length ? strongMatches[0] : (compatibleMatches.length ? compatibleMatches[0] : null); var supportingMatches = compatibleMatches.filter(function (m) { return m !== primaryMatch; }); var status = compatibleMatches.length ? 'matched' : (catalog.length ? 'no-compatible-products' : 'catalog-empty'); return { matchingVersion: MATCHING_VERSION, status: status, products: compatibleMatches.map(function (m) { return m.productId; }), matches: matches, primaryMatch: primaryMatch, supportingMatches: supportingMatches, rejectedProducts: rejected, totalCandidates: catalog.length, matchingReasons: compatibleMatches.reduce(function (acc, m) { return acc.concat(m.matchReasons); }, []), compatibilityNotes: matches.reduce(function (acc, m) { return acc.concat(m.cautionNotes); }, []), source: baseResult.source }; } function getCatalog() { // Returns a shallow-cloned array so callers cannot mutate the // production catalog through the object they got back. return NUVA_PRODUCT_CATALOG.slice(); } function getCatalogVersion() { return CATALOG_VERSION; } var api = { version: MATCHING_VERSION, matchingVersion: MATCHING_VERSION, catalogVersion: CATALOG_VERSION, generateProductMatches: generateProductMatches, getCatalog: getCatalog, getCatalogVersion: getCatalogVersion, createBlankProduct: createBlankProduct, CATEGORY_OPTIONS: CATEGORY_OPTIONS.slice(), SKIN_TYPES: SKIN_TYPES.slice(), SENSITIVITY_LEVELS: SENSITIVITY_LEVELS.slice(), REACTIVITY_LEVELS: REACTIVITY_LEVELS.slice(), APPLICATION_AREAS: APPLICATION_AREAS.slice(), CONCERN_CATEGORIES: CONCERN_CATEGORIES.slice(), RECOMMENDATION_DIRECTIONS: RECOMMENDATION_DIRECTIONS.slice(), _internals: { NUVA_PRODUCT_CATALOG: NUVA_PRODUCT_CATALOG, // exposed by reference for tests only; production code should use getCatalog() normalizeAssessment: normalizeAssessment, normalizeRecommendation: normalizeRecommendation, normalizeSkinType: normalizeSkinType, normalizeLevel: normalizeLevel, evaluateProduct: evaluateProduct, FREE_CATEGORY_MAP: FREE_CATEGORY_MAP } }; root.NuvaProductMatchingEngine = api; if (typeof module !== 'undefined' && module.exports) { module.exports = api; } })(typeof window !== 'undefined' ? window : this);