/*! * PHASE 6L — Nuva Consultations -> IS60 secure handoff activation * * This file replaces nuva6h_consultation_identity_v1.txt. It keeps every * Phase 6G/6G.1/6H behavior COMPLETELY UNCHANGED -- the started/completed * registration engine below (bootstrapIfNeeded, checkAll, migrateDedupeRecord, * sendConsultationEvent, and the CONSULTATIONS config) is copied byte-for-byte * from nuva6h_consultation_identity_v1.txt. Nothing in that engine was * edited, reordered, or removed. * * The ONLY new capabilities this file adds: once a consultation has a * legitimate, durable, server-confirmed completion (exactly what the * unchanged 6H engine above already tracks -- never a grandfathered/stale * pre-6G.1 completion, see maybeActivateHandoff() below for why that case is * structurally excluded), (a) resolve that consultation's own canonical * customer_ref via the new Phase 6L.1 endpoint, then (b) attempt the * existing Phase 6K secure handoff exactly once per distinct * consultation_ref. * * WHAT THIS FILE DOES, AND ONLY THIS (new capabilities): * Calls the existing, unmodified Phase 6L.1 endpoint -- * POST https://is60.store/wp-json/is60/v1/resolve-customer-ref -- with * exactly { consultation_ref }, to learn the one, already-existing, * canonical customer_ref IS60 has on file for it (see * maybeResolveCustomerRef() below). On success, stores ONLY the * server-returned customer_ref in the reserved customerRefKey slot this * file already defined. Then calls the existing, unmodified Phase 6K * endpoint -- POST https://is60.store/wp-json/is60/v1/consultation-handoff * -- with exactly { consultation_ref, customer_ref }, read from this * browser's own existing durable state. On success, stores the one-time * opaque token IS60 returns in a new, separate, short-lived localStorage * slot for a FUTURE phase to consume when it wires up the actual * Nuva -> IS60 navigation step -- this file does not redirect the visitor * anywhere, and does not touch is60-bridge.js, the product catalog, * product matching, or any outbound product link. That wiring is * explicitly out of scope for this phase (see the accompanying Phase 6L * and 6L.1 reports). * * WHAT THIS FILE DELIBERATELY DOES NOT DO: * - It never puts consultation_ref, customer_ref, email, phone, name, or * any assessment/recommendation data in a URL. Both new network calls * this file makes are POSTs with a JSON body, never a query string. * - It never sends a customer_ref TO the resolution endpoint -- only * consultation_ref. The customer_ref used for the handoff call is * accepted ONLY from that endpoint's own successful response body, * never asserted or echoed by this client itself. * - It never sends PII, assessment data, recommendation data, payment * data, or any WooCommerce ID to either endpoint -- the resolution * request body is exactly { consultation_ref }, nothing else. * - It does not read or send any assessment answer, skin profile, * recommendation, or safety-screening field -- exactly the same * restriction the unchanged 6H engine above already observes. * - It does not create a consultation merely to enable a handoff -- it * only ever reads a consultation_ref the unchanged engine above already * registered through a real server round-trip. * - It does not hand off stale, pre-6G.1 localStorage data. A grandfathered * baseline consultation (see bootstrapIfNeeded() below, unchanged from * 6G.1/6H) never receives a server-confirmed refKey value -- grandfathering * deliberately never calls the server -- so gating on refKey being present * (step 2 of maybeActivateHandoff) structurally excludes every stale/ * grandfathered record without any new stale-data logic being invented. * - It does not automatically mark a consultation paid or an entitlement * issued -- it never calls any endpoint other than the existing 6K * handoff issuance endpoint, and that endpoint itself grants nothing. * - It does not repeat a handoff attempt for the same lifecycle event: a * revisit or refresh of an already-handled consultation_ref is * recognized via a new, separate, per-type handoff-state record and * skipped -- see maybeActivateHandoff() below. * - It does not introduce a second identity system. It never mints, * derives, or guesses a customer_ref itself -- the only value ever * written to customerRefKey is the exact string the Phase 6L.1 endpoint * returned for this exact consultation_ref, looked up server-side * against IS60's own existing consultation<->customer link. The * localStorage keys this file adds are: (a) customerRefKey, populated * now by maybeResolveCustomerRef() below (previously reserved but * never written, since no endpoint existed yet to populate it); (b) a * small customerRefStateKey record so a definitive resolution failure * for a given consultation_ref is not retried forever; and (c) a small * handoff-state record used only for this file's own duplicate- * prevention bookkeeping (unchanged from the previous version of this * file). * - It never creates or mutates a consultation or customer record from * the client. Both maybeResolveCustomerRef() and maybeActivateHandoff() * only ever call existing, unmodified, read-mostly IS60 endpoints that * themselves never create anything from this input. * - It never reinterprets nuva_ref -- that value is never read here. * - It does not touch is60-bridge.js, NuvaProductCatalogAdmin, * NuvaProductRecommendationUI, NuvaCart, NuvaCheckout, product matching, * pricing, or campaigns. * - It fails silently and never blocks the consultation experience: a * network failure, a rejected handoff, or a bug in this new capability * can never affect the assessment, its results, or a later visit to * IS60 -- wrapped in its own try/catch, exactly like every other * network call in this file. * * SCOPE: this file only understands the two consultation types Nuva * currently has, exactly like the engine it is built on top of. * -------------------------------------------------------------------------- */ (function () { 'use strict'; var IS60_ENDPOINT = 'https://is60.store/wp-json/is60/v1/consultations'; var IS60_HANDOFF_ENDPOINT = 'https://is60.store/wp-json/is60/v1/consultation-handoff'; var IS60_RESOLVE_CUSTOMER_REF_ENDPOINT = 'https://is60.store/wp-json/is60/v1/resolve-customer-ref'; // Set once this script (in ANY of its versions -- 6G, 6G.1, 6H, or this // one) has run at least once in this browser. Used only to decide // whether a completed submission found on the very first run is a // pre-existing one to grandfather (see bootstrapIfNeeded below) or a // genuinely new one to register normally. Left with its original // Phase 6G name so a browser that already ran an earlier version of // this script is recognized as already bootstrapped. UNCHANGED FROM 6H. var BOOTSTRAP_KEY = 'nuva_consultation_identity_bootstrap_v1'; var CONSULTATIONS = [ { type: 'quick_skin_match', progressKey: 'nuva_quick_skin_match_progress', submissionKey: 'nuva_quick_skin_match_submission', dedupeKey: 'nuva_quick_skin_match_consultation_dedupe', refKey: 'nuva_quick_skin_match_consultation_ref', // New in 6L, reserved/bookkeeping slots -- see file header. customerRefKey: 'nuva_quick_skin_match_customer_ref', // New in 6L.1 -- duplicate-prevention for resolution attempts only. customerRefStateKey: 'nuva_quick_skin_match_customer_ref_state', handoffStateKey: 'nuva_quick_skin_match_handoff_state', handoffTokenKey: 'nuva_quick_skin_match_handoff_token' }, { type: 'advanced_skin_scan', progressKey: 'nuva_advanced_skin_scan_progress', submissionKey: 'nuva_advanced_skin_scan_submission', dedupeKey: 'nuva_advanced_skin_scan_consultation_dedupe', refKey: 'nuva_advanced_skin_scan_consultation_ref', // New in 6L, reserved/bookkeeping slots -- see file header. customerRefKey: 'nuva_advanced_skin_scan_customer_ref', // New in 6L.1 -- duplicate-prevention for resolution attempts only. customerRefStateKey: 'nuva_advanced_skin_scan_customer_ref_state', handoffStateKey: 'nuva_advanced_skin_scan_handoff_state', handoffTokenKey: 'nuva_advanced_skin_scan_handoff_token' } ]; function readJSON(key) { try { var raw = window.localStorage.getItem(key); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function readLocal(key) { try { return window.localStorage.getItem(key); } catch (e) { return null; } } function writeLocal(key, value) { try { window.localStorage.setItem(key, value); } catch (e) { /* fail silently -- localStorage is a convenience cache here, never the source of truth. */ } } function randomToken() { try { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return window.crypto.randomUUID().replace(/-/g, ''); } } catch (e) { /* fall through to the weaker fallback below */ } return 'tok' + Date.now().toString(36) + Math.random().toString(36).slice(2); } // --------------------------------------------------------------------- // EVERYTHING BELOW THIS LINE, UP TO checkAll()'s closing brace, IS THE // UNCHANGED PHASE 6H ENGINE. Copied verbatim -- no edits. // --------------------------------------------------------------------- function migrateDedupeRecord(oldRecord, attemptKey) { return { attemptKey: attemptKey, token: oldRecord.token, startedSent: !!oldRecord.sent, completedSent: !!oldRecord.sent, baseline: !!oldRecord.baseline }; } function isNewShape(record) { return !!record && Object.prototype.hasOwnProperty.call(record, 'startedSent'); } function sendConsultationEvent(cfg, dedupeRecord, status) { var payload = JSON.stringify({ consultation_type: cfg.type, client_dedupe_ref: 'nuva_' + dedupeRecord.token, status: status }); try { fetch(IS60_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: payload }) .then(function (res) { if (!res.ok) { return null; } return res.json(); }) .then(function (data) { if (data && data.consultation_ref) { if (status === 'completed') { dedupeRecord.completedSent = true; } else { dedupeRecord.startedSent = true; } writeLocal(cfg.dedupeKey, JSON.stringify(dedupeRecord)); writeLocal(cfg.refKey, data.consultation_ref); } }) .catch(function () { /* fail silently -- will retry on the next tick since the relevant *Sent flag was never set to true. */ }); } catch (e) { /* fail silently -- registration is best-effort and never blocks the consultation experience. */ } } function bootstrapIfNeeded() { var already; try { already = window.localStorage.getItem(BOOTSTRAP_KEY); } catch (e) { already = null; } if (already) { return; } // First-ever run of ANY version of this script in this browser. // Anything that already shows as a completed submission right now // predates this script's existence here -- grandfather it (record // it as a known baseline that has already been "sent", without // ever actually sending it) instead of registering it. A dedupe // record that already exists for a type is left untouched, since // that type has already been through this script's normal // lifecycle at least once. Unmodified from Phase 6G.1/6H. // // IMPORTANT (Phase 6L note, not a behavior change): because this // branch NEVER calls sendConsultationEvent(), it never writes // cfg.refKey either. A grandfathered/baseline consultation // therefore never has a server-confirmed consultation_ref sitting // in this browser -- which is exactly what lets maybeActivateHandoff() // below use "does cfg.refKey have a value?" as a complete, correct // stale-data guard without inventing any new logic for it. for (var i = 0; i < CONSULTATIONS.length; i++) { var cfg = CONSULTATIONS[i]; if (readJSON(cfg.dedupeKey)) { continue; } var submission = readJSON(cfg.submissionKey); var completedAt = submission && submission.meta && submission.meta.completedAt; if (completedAt) { writeLocal(cfg.dedupeKey, JSON.stringify({ completedAt: completedAt, token: randomToken(), sent: true, baseline: true })); } } try { window.localStorage.setItem(BOOTSTRAP_KEY, String(Date.now())); } catch (e) { /* fail silently -- worst case this re-evaluates next run, which is harmless: a type already given a dedupe record above (or by a real registration) is never re-grandfathered or re-sent. */ } } function checkAll() { for (var i = 0; i < CONSULTATIONS.length; i++) { var cfg = CONSULTATIONS[i]; var progress = readJSON(cfg.progressKey); var startedAt = progress && progress.state && progress.state.meta && progress.state.meta.startedAt; var submission = readJSON(cfg.submissionKey); var submissionMeta = submission && submission.meta; var completedAt = submissionMeta && submissionMeta.completedAt; var completedStartedAt = submissionMeta && submissionMeta.startedAt; var completedBelongsToCurrentAttempt = !!completedAt && (!startedAt || completedStartedAt === startedAt); var attemptKey = startedAt || (completedBelongsToCurrentAttempt ? (completedStartedAt || completedAt) : null); if (!attemptKey) { continue; // Nothing to report for this type yet. } var rawRecord = readJSON(cfg.dedupeKey); var dedupeRecord; if (rawRecord && !isNewShape(rawRecord)) { dedupeRecord = migrateDedupeRecord(rawRecord, attemptKey); writeLocal(cfg.dedupeKey, JSON.stringify(dedupeRecord)); } else { dedupeRecord = rawRecord; } if (!dedupeRecord || dedupeRecord.attemptKey !== attemptKey) { dedupeRecord = { attemptKey: attemptKey, token: randomToken(), startedSent: false, completedSent: false }; writeLocal(cfg.dedupeKey, JSON.stringify(dedupeRecord)); } if (completedBelongsToCurrentAttempt && !dedupeRecord.completedSent) { sendConsultationEvent(cfg, dedupeRecord, 'completed'); } else if (!completedBelongsToCurrentAttempt && startedAt && !dedupeRecord.startedSent) { sendConsultationEvent(cfg, dedupeRecord, 'started'); } // ------------------------------------------------------------ // NEW IN PHASE 6L -- the only new call site in checkAll(). Runs // after the unchanged 6H logic above, using only state that // logic already produced. Never alters dedupeRecord itself. // ------------------------------------------------------------ maybeActivateHandoff(cfg, dedupeRecord); } } // --------------------------------------------------------------------- // NEW IN PHASE 6L.1 -- secure customer_ref resolution. // --------------------------------------------------------------------- // Resolves this browser's own consultationRef to its already-existing, // canonical customer_ref via the new Phase 6L.1 endpoint, and stores // ONLY the server-returned value. Never sends customer_ref, PII, // assessment data, recommendation data, payment data, or any // WooCommerce ID -- the request body is exactly { consultation_ref }. // Never creates or mutates a consultation or customer record -- the // endpoint itself is a read-only lookup against a row Phase 6G/6H's own // code already wrote. Fails open and fails closed at once, by design: // a network/CORS failure leaves nothing recorded so the next tick // retries (fails open -- never blocks anything), while a malformed // consultation_ref or any other definitive rejection from the server // is recorded so it is not retried forever (fails closed -- no // customer_ref is ever accepted from anywhere but a genuine 200 // response body). function maybeResolveCustomerRef(cfg, consultationRef) { try { // Already resolved -- nothing to do. This also means a // successful resolution is never repeated for the same or a // later consultation_ref of this type once it has one on file. if (readLocal(cfg.customerRefKey)) { return; } // Duplicate-prevention for the resolution attempt itself: at // most one outcome (success or a definitive rejection) is acted // on per distinct consultation_ref. A revisit or refresh of the // same completed consultation must not re-issue a resolution // request once one has already concluded for it. var state = readJSON(cfg.customerRefStateKey); if (state && state.consultationRef === consultationRef) { return; } var payload = JSON.stringify({ consultation_ref: consultationRef }); fetch(IS60_RESOLVE_CUSTOMER_REF_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: payload }) .then(function (res) { if (!res.ok) { // Definitive rejection (malformed/nonexistent // consultation_ref, or no customer_ref on file). // Record it so this consultation_ref is not // retried forever -- the consultation, Nuva // results, and IS60 all remain fully usable. writeLocal(cfg.customerRefStateKey, JSON.stringify({ consultationRef: consultationRef, resolved: false })); return null; } return res.json(); }) .then(function (data) { // Accept customer_ref ONLY from a successful response // body -- never client-asserted, never derived locally. if (data && data.success && data.customer_ref) { writeLocal(cfg.customerRefKey, data.customer_ref); writeLocal(cfg.customerRefStateKey, JSON.stringify({ consultationRef: consultationRef, resolved: true })); } }) .catch(function () { /* Network/CORS failure only -- fail silently and retry on the next tick, since customerRefStateKey was never written. Never affects the consultation experience. */ }); } catch (e) { /* fail silently -- resolution is best-effort and must never block or alter the consultation, its results, or anything on IS60. */ } } // --------------------------------------------------------------------- // NEW IN PHASE 6L -- the secure handoff activation. // --------------------------------------------------------------------- // A consultation may only be handed off once it has a real, // server-confirmed completion AND a real, server-confirmed customer_ref. // Both are read from existing durable state -- nothing here mints, // derives, or guesses either value. function maybeActivateHandoff(cfg, dedupeRecord) { try { // 1. Only a consultation this engine has itself confirmed // completed (via a real 'completed' server response) is // eligible. A merely-started consultation is not handed off. if (!dedupeRecord || !dedupeRecord.completedSent) { return; } // 2. Require a real, server-confirmed consultation_ref. This is // ALSO the complete stale-data guard: a grandfathered/baseline // record (see bootstrapIfNeeded above) never reaches this value // because grandfathering never calls the server. No separate // "is this stale" check is needed beyond this. var consultationRef = readLocal(cfg.refKey); if (!consultationRef) { return; } // 2b. NEW IN PHASE 6L.1: attempt to resolve this consultation's // own canonical customer_ref via the new Phase 6L.1 endpoint if // it isn't already on file (see maybeResolveCustomerRef() above // for its own gating/dedupe/fail-open behavior). This call is // fire-and-forget/asynchronous, exactly like every other network // call in this file -- so on the very tick a consultation first // completes, customerRefKey is typically still empty below and // step 3 correctly does nothing yet; the resolution response // then lands before the next 2-second tick, and THAT tick's // step 3 finds a real customer_ref and proceeds. maybeResolveCustomerRef(cfg, consultationRef); // 3. Require a real customer_ref -- now potentially populated by // step 2b above, but never trusted from anywhere else. If no // valid customer_ref exists yet (resolution still pending, or a // past resolution attempt failed), do nothing this tick. Per the // governing spec ("If no valid customer_ref/consultation_ref // exists, do nothing"), that is exactly what happens here. var customerRef = readLocal(cfg.customerRefKey); if (!customerRef) { return; } // 4. Duplicate-prevention: at most one handoff attempt per // distinct consultation_ref, regardless of outcome. A revisit or // refresh of the same completed consultation must not re-issue // a request. var handoffState = readJSON(cfg.handoffStateKey); if (handoffState && handoffState.consultationRef === consultationRef) { return; } var payload = JSON.stringify({ consultation_ref: consultationRef, customer_ref: customerRef }); fetch(IS60_HANDOFF_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: payload }) .then(function (res) { if (!res.ok) { // Definitive rejection (e.g. the two refs don't // actually belong together, or one no longer // exists). Mark this consultation_ref as attempted // so it is not retried forever -- the consultation, // Nuva results, and IS60 all remain fully usable; // nothing else is affected by this failure. writeLocal(cfg.handoffStateKey, JSON.stringify({ consultationRef: consultationRef, tokenIssued: false })); return null; } return res.json(); }) .then(function (data) { if (data && data.token) { writeLocal(cfg.handoffStateKey, JSON.stringify({ consultationRef: consultationRef, tokenIssued: true })); // Stored only for a FUTURE phase to consume when it // wires up the actual Nuva -> IS60 navigation step. // Never placed in a URL by this file. Short-lived, // matching the token's own server-side TTL. writeLocal(cfg.handoffTokenKey, JSON.stringify({ token: data.token, expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000 })); } }) .catch(function () { /* Network/CORS failure only -- fail silently and retry on the next tick, since handoffStateKey was never written. Never affects the consultation experience. */ }); } catch (e) { /* fail silently -- handoff activation is best-effort and must never block or alter the consultation, its results, or anything on IS60. */ } } // A consultation can start and/or complete within the same page view // (no navigation away and back), so a single check on load isn't // enough -- poll lightly until this tab is closed or navigated away. // UNCHANGED FROM 6H (same interval, same call sites). bootstrapIfNeeded(); checkAll(); window.setInterval(checkAll, 2000); })();