/*! * Nuva Product Recommendation UI — Phase 6B * --------------------------------------------------------------------------- * Customer-facing rendering layer. Consumes NuvaProductCatalogAdmin's * already-decided section state (Part 5/23/24/26) and turns it into * presentable data + (in a browser) DOM. Never recomputes compatibility, * never talks to the matching engine directly, never exposes internal * scoring or reasoning strings to the customer. * * Split into a pure-data layer (buildRecommendationSectionData / * buildProductCardData — fully testable in Node, no DOM) and a thin DOM * layer (renderRecommendationSection) used only in a real browser. * * LANGUAGE DISCIPLINE (Part 14/33/48) * Explanations are built from structured signals only (which concern * categories/directions the product matched). No internal reason strings * from the matching engine are ever surfaced verbatim. Never uses the * words "AI", "algorithm", "machine learning", "automated decision", * "best product", "#1", "top product", or "perfect product". */ (function (root) { 'use strict'; var UI_VERSION = '1.0'; var CONCERN_LABELS = { blemish: 'blemish-prone skin', pigmentation: 'uneven tone', radiance: 'dullness', hydration: 'hydration', sensitivity: 'sensitivity', texture: 'texture', aging: 'visible signs of aging', marks: 'marks', body: 'body skin concerns', recurring: 'recurring concerns', general: 'everyday skin needs' }; function str(v) { return typeof v === 'string' ? v : ''; } function arr(v) { return Array.isArray(v) ? v : []; } function formatPrice(price, currency) { if (price === null || price === undefined || price === '') return ''; var symbol = currency === 'NGN' || !currency ? '₦' : (currency + ' '); var num = Number(price); if (isNaN(num)) return ''; var formatted = num.toLocaleString ? num.toLocaleString('en-NG', { maximumFractionDigits: 0 }) : String(num); return symbol + formatted; } /** * Builds a short, customer-safe "why this was recommended" sentence from * structured match data only (Part 14). Never mentions scoring internals. */ function buildMatchExplanation(product, match) { var pieces = []; var concerns = arr(match && match.supportedConcerns).map(function (c) { return CONCERN_LABELS[c] || c; }); if (concerns.length) pieces.push('aligns with your assessed ' + concerns.join(' and ')); if (arr(match && match.supportedDirections).length) pieces.push('matches the personalized skincare direction from your consultation'); if (!pieces.length) pieces.push('is compatible with the profile from your consultation'); return 'Recommended because it ' + pieces.join(' and ') + '.'; } function buildProductCardData(entry) { var product = entry.product; var match = entry.match; var admin = root.NuvaProductCatalogAdmin; var available = !!(admin && admin.isProductPurchasable(product.productId)); var viewUrl = admin ? admin.getProductUrl(product) : ('?nuva_product=' + encodeURIComponent(product.productId)); return { productId: product.productId, name: product.name, imageUrl: product.imageUrl || '', category: product.category, shortDescription: str(product.description).slice(0, 160), keyIngredients: arr(product.keyIngredients), whyMatched: buildMatchExplanation(product, match), relevantConcerns: arr(match && match.supportedConcerns).map(function (c) { return CONCERN_LABELS[c] || c; }), usageDirection: [ product.usage && product.usage.morning ? 'Morning' : null, product.usage && product.usage.evening ? 'Evening' : null ].filter(Boolean).join(' & ') || (product.usage && product.usage.frequency) || '', priceLabel: formatPrice(product.price, product.currency), available: available, viewProductUrl: viewUrl, addToCartEnabled: available, isPlaceholder: !!product.isPlaceholder }; } /** * Pure-data version of the recommendation section — no DOM required. * assessment/recommendation are the raw Free or Advanced engine outputs. * * opts.displayLimit (Phase 6C) is optional and passed straight through to * NuvaProductCatalogAdmin.getRecommendations — omit it for the normal, * admin-configured display count (unchanged from Phase 6B), or pass the * previous call's totalCompatible to expand to the full compatible set * (e.g. for a "View More" affordance). This never re-derives matches; it * only asks the same already-computed result for more of it. */ function buildRecommendationSectionData(assessment, recommendation, opts) { opts = opts || {}; var admin = root.NuvaProductCatalogAdmin; if (!admin) throw new Error('NuvaProductRecommendationUI requires window.NuvaProductCatalogAdmin (Phase 6B) to be loaded first.'); var resolved = admin.getRecommendations(assessment, recommendation, opts.displayLimit); if (resolved.sectionStatus === 'safety-blocked' || resolved.sectionStatus === 'disabled') { // Part 5 / Part 26 — render nothing at all. No empty container, no // trace this section exists, regardless of which of the two reasons. return { visible: false, state: resolved.sectionStatus, heading: '', supportingCopy: '', message: '', products: [] }; } if (resolved.sectionStatus === 'catalog-empty' || resolved.sectionStatus === 'no-match') { return { visible: true, state: resolved.sectionStatus, heading: 'Your Recommended Nuva Products', supportingCopy: '', message: 'We’re preparing your product selection. Your personalized skincare direction is ready. Matching Nuva products will appear here as they become available.', products: [] }; } // matched var cards = resolved.displayedProducts.map(buildProductCardData); return { visible: true, state: 'matched', heading: 'Your Recommended Nuva Products', supportingCopy: 'Products shown here are selected based on your assessment, skin profile, concerns, and compatibility information.', message: '', products: cards, totalCompatible: resolved.totalCompatible }; } /* =========================================================================== * DOM rendering (browser only) — thin wrapper around the data builder. * =========================================================================== */ function el(tag, attrs, children) { var node = document.createElement(tag); attrs = attrs || {}; Object.keys(attrs).forEach(function (k) { if (k === 'class') node.className = attrs[k]; else if (k === 'text') node.textContent = attrs[k]; else node.setAttribute(k, attrs[k]); }); (children || []).forEach(function (c) { if (c) node.appendChild(c); }); return node; } function renderProductCard(card) { var img = el('img', { src: card.imageUrl, alt: card.name, class: 'nuva-product-card__image', loading: 'lazy' }); var body = el('div', { class: 'nuva-product-card__body' }, [ el('h3', { class: 'nuva-product-card__name', text: card.name }), el('p', { class: 'nuva-product-card__category', text: card.category }), el('p', { class: 'nuva-product-card__desc', text: card.shortDescription }), card.keyIngredients.length ? el('p', { class: 'nuva-product-card__ingredients', text: 'Key ingredients: ' + card.keyIngredients.join(', ') }) : null, el('p', { class: 'nuva-product-card__why', text: card.whyMatched }), card.usageDirection ? el('p', { class: 'nuva-product-card__usage', text: 'Usage: ' + card.usageDirection }) : null, el('p', { class: 'nuva-product-card__price', text: card.priceLabel || 'Price unavailable' }), el('p', { class: 'nuva-product-card__availability', text: card.available ? 'Available' : 'Currently unavailable' }) ]); var actions = el('div', { class: 'nuva-product-card__actions' }, [ el('a', { class: 'nuva-product-card__view-btn', href: card.viewProductUrl, text: 'View Product' }), el('button', { class: 'nuva-product-card__cta', type: 'button', 'data-product-id': card.productId, 'aria-disabled': card.addToCartEnabled ? 'false' : 'true', text: card.addToCartEnabled ? 'Add to Cart' : 'Unavailable' }) ]); var articleAttrs = { class: 'nuva-product-card', 'data-product-id': card.productId }; if (card.isPlaceholder) articleAttrs['data-dev-placeholder'] = 'true'; return el('article', articleAttrs, [img, body, actions]); } function renderRecommendationSection(container, assessment, recommendation) { if (!container) return null; container.innerHTML = ''; var data = buildRecommendationSectionData(assessment, recommendation); if (!data.visible) { container.setAttribute('data-nuva-section-state', data.state); container.style.display = 'none'; return data; } container.style.display = ''; container.setAttribute('data-nuva-section-state', data.state); var heading = el('h2', { class: 'nuva-recommend__heading', text: data.heading }); container.appendChild(heading); if (data.supportingCopy) { container.appendChild(el('p', { class: 'nuva-recommend__supporting', text: data.supportingCopy })); } if (data.state === 'catalog-empty' || data.state === 'no-match') { container.appendChild(el('p', { class: 'nuva-recommend__message', text: data.message })); return data; } var grid = el('div', { class: 'nuva-recommend__grid' }, data.products.map(renderProductCard)); container.appendChild(grid); // PART "no hard-coded max of 3" (Phase 6C) — the underlying compatible // set is never truncated (see NuvaProductCatalogAdmin.getRecommendations // / Part 10/29). When there are more compatible products than are shown // by default, offer a "View More" control that expands to the full, // already-computed set — never a second matching pass, never a new cap. if (typeof data.totalCompatible === 'number' && data.totalCompatible > data.products.length) { var remaining = data.totalCompatible - data.products.length; var viewMoreBtn = el('button', { class: 'nuva-recommend__view-more', type: 'button', text: 'View ' + remaining + ' More Product' + (remaining === 1 ? '' : 's') }); viewMoreBtn.addEventListener('click', function () { var expandedData = buildRecommendationSectionData(assessment, recommendation, { displayLimit: data.totalCompatible }); var expandedGrid = el('div', { class: 'nuva-recommend__grid' }, expandedData.products.map(renderProductCard)); container.replaceChild(expandedGrid, grid); container.removeChild(viewMoreBtn); }); container.appendChild(viewMoreBtn); } return data; } var api = { version: UI_VERSION, formatPrice: formatPrice, buildMatchExplanation: buildMatchExplanation, buildProductCardData: buildProductCardData, buildRecommendationSectionData: buildRecommendationSectionData, renderRecommendationSection: renderRecommendationSection }; root.NuvaProductRecommendationUI = api; if (typeof module !== 'undefined' && module.exports) { module.exports = api; } })(typeof window !== 'undefined' ? window : this);