1/** 2 * Synchronous bootstrap for Octane quiz â results handoff. 3 * Loaded in <head> before React so interceptors run before Octane redirects. 4 */ 5;(function () { 6 // Scope to the quiz funnel only â this matches `/quiz` and `/quiz/results` 7 // (the redirect target, which still needs the guard + restore listener). 8 // Everywhere else we bail before patching fetch/XHR site-wide. 9 if (!/^\/quiz(\/|$)/.test(window.location.pathname)) return 10 11 // Font CSS is loaded via XHR (not fetch) so Datadog RUM fetch instrumentation 12 // cannot stack-overflow during the Poppins iframe inject feedback loop. 13 14 var RESULT_KEY = 'octane:lastQuizResult' 15 var RESULTS_URL_KEY = 'octane:lastQuizResultsUrl' 16 var RESULT_COOKIE = 'octane_quiz_result' 17 var Q1_BODY_PARTS_KEY = 'octane:q1BodyParts' 18 // Live Q&A from the quiz DOM â Klaviyo property keys use these titles so 19 // they always match what the shopper saw (Octane answers keys are stale). 20 // KEEP IN SYNC with app/utils/quiz-dom-answers.ts 21 var QUIZ_DOM_ANSWERS_KEY = 'octane:quizDomAnswers' 22 // Body-part option labels â used to detect Q1 in the DOM even if Product 23 // renames the question title. Captured title is stored with the parts. 24 var Q1_BODY_PART_LABELS = [ 25 'Underarms', 26 'Underboob', 27 'Skin Folds', 28 'Back', 29 'Privates', 30 'Feet', 31 'Other', 32 ] 33 var COOKIE_MAX_AGE = 60 * 60 * 24 34 // Set once this document has written and verified the result cookie. Distinct 35 // from "a result cookie exists", which may be a stale 24h one. 36 var cookiePersisted = false 37 // Budget for the percent-encoded cookie value â see writeResultCookie. Must 38 // match MAX_COOKIE_BYTES in app/utils/quiz-result-cookie.ts, which is where 39 // the two are asserted equal. 40 var MAX_COOKIE_BYTES = 3800 41 // How long the handoff overlay waits before declaring the handoff dead. Also 42 // bounds the poll for a late persist, so the two cannot disagree about when 43 // the handoff is over. 44 var HANDOFF_FAILSAFE_MS = 8000 45 // Extra grace once location.replace() has been called. The document stays on 46 // /quiz until the replacement commits, so a slow /quiz/results load looks 47 // exactly like a handoff that never happened. 48 var HANDOFF_NAV_GRACE_MS = 12000 49 var RESULT_POLL_MS = 50 50 // Sentinel key for the storage write probes. Written and removed in place. 51 var STORAGE_PROBE_KEY = 'octane:probe' 52 var SUBMIT_PAGE_RE = /\/_quiz_\/submit_page/ 53 54 /** 55 * True once this document is on the results page. 56 * 57 * Shared so the overlay, the failsafe and the redirect cannot disagree about 58 * where the shopper is â they gate each other, and a divergence between them 59 * strands the handoff. 60 */ 61 function onResultsPage() { 62 return window.location.pathname.indexOf('/quiz/results') !== -1 63 } 64 65 /** 66 * True while this document is still showing the quiz itself. 67 * 68 * Distinct from "not on /quiz/results": the funnel routes are SPA-navigable, 69 * so a shopper can leave for any page in the same document while a handoff is 70 * mid-flight. 71 */ 72 function onQuizLanding() { 73 return /^\/quiz\/?$/.test(window.location.pathname) 74 } 75 76 function extractPayload(detail) { 77 if (!detail || typeof detail !== 'object') return detail 78 return detail.state && detail.state.headless_results_page 79 ? detail.state.headless_results_page 80 : detail 81 } 82 83 function hasProducts(payload) { 84 if (!payload || typeof payload !== 'object') return false 85 var blocks = 86 payload.product_blocks || 87 payload.products || 88 (payload.state && 89 payload.state.info && 90 payload.state.info.product_blocks) || 91 (payload.state && 92 payload.state.headless_results_page && 93 payload.state.headless_results_page.product_blocks) 94 return Array.isArray(blocks) && blocks.length > 0 95 } 96 97 // Mirror of sanitizePayloadForStorage in octane-results.ts â strip email + 98 // klaviyo_* answer keys before anything touches storage or a cookie. 99 function stripAnswers(value) { 100 if (!value || typeof value !== 'object') return value 101 var out = {} 102 for (var key in value) { 103 if (!Object.prototype.hasOwnProperty.call(value, key)) continue 104 if (key.indexOf('klaviyo_') === 0) continue 105 out[key] = value[key] 106 } 107 return out 108 } 109 110 function sanitizePayload(payload) { 111 if (!payload || typeof payload !== 'object') return payload 112 var cloned 113 try { 114 cloned = JSON.parse(JSON.stringify(payload)) 115 } catch (e) { 116 return payload 117 } 118 119 delete cloned.email 120 if (cloned.answers) 121 cloned.answers = stripAnswers(ensureQ1Answer(cloned.answers)) 122 123 var state = cloned.state 124 if (state) { 125 var headless = state.headless_results_page 126 if (headless) { 127 delete headless.email 128 if (headless.answers) 129 headless.answers = stripAnswers(ensureQ1Answer(headless.answers)) 130 } 131 // The #r= restore shape keeps answers (and a `user` object) under 132 // `headless_results_page_accessed`. 133 var accessed = state.headless_results_page_accessed
134 if (accessed) { 135 delete accessed.email 136 delete accessed.user 137 if (accessed.answers) 138 accessed.answers = stripAnswers(ensureQ1Answer(accessed.answers)) 139 } 140 var info = state.info 141 if (info && info.answers) 142 info.answers = stripAnswers(ensureQ1Answer(info.answers)) 143 } 144 145 return cloned 146 } 147 148 // Pull the recommended products out of any payload shape Octane emits. 149 function extractProducts(payload) { 150 if (!payload || typeof payload !== 'object') return [] 151 var out = [] 152 var blocks = payload.product_blocks 153 if (Array.isArray(blocks)) { 154 for (var i = 0; i < blocks.length; i++) { 155 var row = blocks[i] 156 if (Array.isArray(row)) { 157 for (var j = 0; j < row.length; j++) { 158 if (row[j] && typeof row[j] === 'object') out.push(row[j]) 159 } 160 } 161 } 162 } 163 var info = payload.state && payload.state.info 164 if (!out.length && info && Array.isArray(info.product_blocks)) { 165 for (var k = 0; k < info.product_blocks.length; k++) { 166 var c = info.product_blocks[k] && info.product_blocks[k].containers 167 if (c) { 168 for (var key in c) { 169 if (c[key] && c[key].data) out.push(c[key].data) 170 } 171 } 172 } 173 } 174 return out 175 } 176 177 // --- lume_quiz_v2 analytics ---------------------------------------------- 178 // KEEP IN SYNC with app/utils/klaviyo/build-quiz-subscribe-properties.ts 179 // 180 // Handoff must not wait on Klaviyo/GTM. Critical path is persist â redirect. 181 // Analytics are stashed in sessionStorage and drained on /quiz/results so 182 // dataLayer.push survives the hard navigation (fetch keepalive alone does 183 // not guarantee promise callbacks after unload). 184 var quizAnalyticsPushed = false 185 var QUIZ_KLAVIYO_STATUS_URL = '/api/quiz/klaviyo-status' 186 var QUIZ_PENDING_ANALYTICS_KEY = 'octane:pendingLumeQuizV2' 187 188 function extractEmail(payload) { 189 if (!payload || typeof payload !== 'object') return '' 190 var email = payload.email 191 var state = payload.state 192 if (!email && state) { 193 // Cover every completion shape (top-level, headless wrapper, and the 194 // hash-restore `state.info` payload) so the new-customer coupon email 195 // always reaches Klaviyo. 196 email = 197 (state.headless_results_page && state.headless_results_page.email) || 198 (state.info && state.info.email) 199 } 200 return typeof email === 'string' ? email.trim() : '' 201 } 202 203 function shouldSkipQuizPropertyQuestion(rawQuestion) { 204 if (!rawQuestion || rawQuestion.indexOf('klaviyo_') === 0) return true 205 var key = rawQuestion.replace(/^Octane:\s*/i, '').trim() 206 return ( 207 /^quiz result$/i.test(key) || /^what is your question\??$/i.test(key) 208 ) 209 } 210 211 function formatQuizPropertyValue(value) { 212 if (Array.isArray(value)) { 213 return value 214 .map(function (item) { 215 return String(item).trim() 216 }) 217 .filter(Boolean) 218 .join(', ') 219 } 220 return value == null ? '' : String(value).trim() 221 } 222 223 // Distinct from v1 `Web Quiz - ` so marketing can search "Web Quiz v2". 224 var KLAVIYO_PROPERTY_PREFIX = 'Web Quiz v2 - ' 225 226 function klaviyoPropertyKey(suffix) { 227 return KLAVIYO_PROPERTY_PREFIX + suffix 228 } 229 230 // KEEP IN SYNC with app/utils/klaviyo/build-quiz-subscribe-properties.ts 231 function extractQuizResultsHash(quizResultsUrl) { 232 if (!quizResultsUrl || typeof quizResultsUrl !== 'string') return '' 233 var hashIndex = quizResultsUrl.indexOf('#') 234 if (hashIndex === -1) return '' 235 var hash = quizResultsUrl.slice(hashIndex).trim() 236 return /^#r=/i.test(hash) ? hash : '' 237 } 238 239 function resolveQuizPublicOrigin() { 240 var host = (location.hostname || '').toLowerCase() 241 return host === 'lumedeodorant.com' 242 ? 'https://lumedeodorant.com' 243 : 'https://lumestaging.com' 244 } 245 246 function buildQuizResultsPublicUrl(quizResultsUrl) { 247 var hash = extractQuizResultsHash(quizResultsUrl) 248 if (!hash) return '' 249 return resolveQuizPublicOrigin() + '/quiz/results' + hash 250 } 251 252 function buildQuizProductPublicUrl(link) { 253 if (!link || typeof link !== 'string') return '' 254 var trimmed = link.trim() 255 if (!trimmed) return '' 256 var origin = resolveQuizPublicOrigin() 257 try { 258 var url = new URL(trimmed, origin) 259 if (url.pathname.indexOf('/products/') !== 0) return trimmed 260 return origin + url.pathname + url.search + url.hash 261 } catch (e) { 262 if (trimmed.indexOf('/products/') === 0) return origin + trimmed 263 return trimmed 264 } 265 } 266 267 function resolveQuizResultsUrl(payload) { 268 if (payload && typeof payload.quiz_results_url === 'string') { 269 return payload.quiz_results_url 270 } 271 try { 272 return ( 273 sessionStorage.getItem(RESULTS_URL_KEY) || 274 localStorage.getItem(RESULTS_URL_KEY) || 275 '' 276 ) 277 } catch (e) { 278 return '' 279 } 280 } 281 282 function buildQuizSubscribeProperties(payload) { 283 var properties = {} 284 // Prefer live DOM titles â Octane completion keys are often stale / 285 // placeholders ("What is your question?"). Fall back to Octane answers
286 // only when this session never captured the quiz DOM. 287 var domAnswers = readQuizDomAnswers() 288 if (domAnswers.length) { 289 for (var d = 0; d < domAnswers.length; d++) { 290 var entry = domAnswers[d] 291 if (!entry || shouldSkipQuizPropertyQuestion(entry.question)) 292 continue 293 var domFormatted = formatQuizPropertyValue(entry.parts) 294 if (!domFormatted) continue 295 properties[klaviyoPropertyKey(entry.question)] = domFormatted 296 } 297 } else { 298 var answers = reasonAnswers(payload) 299 if (answers && typeof answers === 'object') { 300 for (var rawQuestion in answers) { 301 if ( 302 !Object.prototype.hasOwnProperty.call( 303 answers, 304 rawQuestion 305 ) 306 ) 307 continue 308 if (shouldSkipQuizPropertyQuestion(rawQuestion)) continue 309 var formatted = formatQuizPropertyValue( 310 answers[rawQuestion] 311 ) 312 if (!formatted) continue 313 properties[ 314 klaviyoPropertyKey( 315 rawQuestion.replace(/^Octane:\s*/i, '').trim() 316 ) 317 ] = formatted 318 } 319 } 320 } 321 var products = extractProducts(payload) 322 for (var i = 0; i < products.length && i < 2; i++) { 323 var product = products[i] 324 if (!product || typeof product !== 'object') continue 325 var label = 326 i === 0 ? 'Recommended Product' : 'Recommended Product 2' 327 if (product.title) 328 properties[klaviyoPropertyKey(label)] = product.title 329 if (product.handle) 330 properties[klaviyoPropertyKey(label + ' Handle')] = 331 product.handle 332 var productLink = product.link || product.full_url 333 var productUrl = buildQuizProductPublicUrl(productLink) 334 if (productUrl) 335 properties[klaviyoPropertyKey(label + ' URL')] = productUrl 336 if (product.type) 337 properties[klaviyoPropertyKey(label + ' Type')] = product.type 338 var image = product.image || product.image_url 339 if (image) 340 properties[klaviyoPropertyKey(label + ' Image')] = image 341 } 342 var resultsUrl = buildQuizResultsPublicUrl(resolveQuizResultsUrl(payload)) 343 if (resultsUrl) { 344 properties[klaviyoPropertyKey('Quiz Results URL')] = resultsUrl 345 } 346 return properties 347 } 348 349 function stashPendingQuizAnalytics(payload) { 350 var email = extractEmail(payload) 351 if (!email || email.indexOf('@') === -1) return 352 try { 353 sessionStorage.setItem( 354 QUIZ_PENDING_ANALYTICS_KEY, 355 JSON.stringify({ 356 email: email, 357 properties: buildQuizSubscribeProperties(payload), 358 }) 359 ) 360 } catch (e) { 361 console.error('[quiz] failed to stash pending lume_quiz_v2', e) 362 } 363 } 364 365 function readAndClearPendingQuizAnalytics() { 366 var raw = null 367 try { 368 raw = sessionStorage.getItem(QUIZ_PENDING_ANALYTICS_KEY) 369 if (raw) sessionStorage.removeItem(QUIZ_PENDING_ANALYTICS_KEY) 370 } catch (e) { 371 return null 372 } 373 if (!raw) return null 374 try { 375 var pending = JSON.parse(raw) 376 if ( 377 !pending || 378 typeof pending.email !== 'string' || 379 pending.email.indexOf('@') === -1 380 ) { 381 return null 382 } 383 return { 384 email: pending.email.trim(), 385 properties: 386 pending.properties && typeof pending.properties === 'object' 387 ? pending.properties 388 : {}, 389 } 390 } catch (e) { 391 return null 392 } 393 } 394 395 function pushLumeQuizV2Event(email, properties) { 396 if (quizAnalyticsPushed) return 397 if (!email || email.indexOf('@') === -1) return 398 // Latch before the async fetch â submit_page intercept, 399 // octane.quiz.completed, and results-page drain can overlap. 400 quizAnalyticsPushed = true 401 if (typeof window.dataLayer === 'undefined') window.dataLayer = [] 402 403 var props = properties && typeof properties === 'object' ? properties : {} 404 405 function emitLumeQuizV2(userStatus) { 406 window.dataLayer.push({ 407 event: 'lume_quiz_v2', 408 email: email, 409 userStatus: userStatus, 410 properties: props, 411 }) 412 } 413 414 window 415 .fetch(QUIZ_KLAVIYO_STATUS_URL, { 416 method: 'POST', 417 headers: { 'content-type': 'application/json' }, 418 body: JSON.stringify({ email: email }), 419 // Survives hard navigation if drain races unload; primary path 420 // runs on /quiz/results after redirect. 421 keepalive: true, 422 }) 423 .then(function (response) { 424 return response.json().catch(function () { 425 return { userStatus: 'returning' } 426 }) 427 }) 428 .then(function (body) { 429 var userStatus = 430 body && body.userStatus === 'new' ? 'new' : 'returning' 431 emitLumeQuizV2(userStatus) 432 }) 433 .catch(function (err) { 434 console.error('[quiz] lume_quiz_v2 push failed', err) 435 emitLumeQuizV2('returning') 436 }) 437 } 438
439 /** Complete stashed analytics on /quiz/results (or retry if nav failed). */ 440 function drainPendingQuizAnalytics() { 441 var pending = readAndClearPendingQuizAnalytics() 442 if (!pending) return 443 pushLumeQuizV2Event(pending.email, pending.properties) 444 } 445 446 function beginQuizHandoff(rawData) { 447 var payload = extractPayload( 448 rawData && rawData.state && rawData.state.headless_results_page 449 ? { state: rawData.state } 450 : rawData 451 ) 452 if (!hasProducts(payload)) return false 453 showHandoffOverlay() 454 // The submit_page response is in hand, so restart the failsafe: from here 455 // the only work left is the persist and the navigation. Without this the 456 // budget is still counting down from Octane's calculating animation, which 457 // showHandoffOverlay raises well before this point. 458 rearmHandoffFailsafe() 459 finalizeQuizHandoff(rawData) 460 return true 461 } 462 463 function finalizeQuizHandoff(rawData) { 464 var payload = extractPayload( 465 rawData && rawData.state && rawData.state.headless_results_page 466 ? { state: rawData.state } 467 : rawData 468 ) 469 // Analytics never block navigation. Stash first so /quiz/results can 470 // drain after the hard redirect; Skip (no email) is a no-op stash. 471 stashPendingQuizAnalytics(payload) 472 if (!persistFromSubmitResponse(rawData)) return 473 redirectToResults(rawData) 474 } 475 476 // --- Match-gallery "Because youâ¦" bullets -------------------------------- 477 // Plain-JS PORT of buildMatchGalleryReasons() in 478 // app/components/quiz-results/match-gallery-reasons.ts. Lives here so the 479 // cookie written before the hard redirect already carries the curated 480 // bullets, letting /quiz/results render them server-side on first arrival 481 // (no post-hydration pop-in). Only these derived strings ride in the cookie 482 // â raw answers + email still never do. 483 // KEEP IN SYNC with match-gallery-reasons.ts (rules + skip patterns). The 484 // shared fixtures in match-gallery-reasons.test.ts define expected output. 485 var REASON_MAX = 3 486 var REASON_SKIP = [ 487 /quiz result|results page/i, 488 /your question|tell us|anything else/i, 489 /shopped|purchased|new to lume|returning|customer before|been with us/i, 490 /email|first name|last name|phone/i, 491 /hormonal|perimenopause|menopause/i, 492 /not working|routine|problem|struggle|frustrat/i, 493 /benefit|matter most|beyond underarms|sweating less/i, 494 /life.?stage|which best describes|postpartum|new mom/i, 495 ] 496 var REASON_SKIP_ANSWER = [/^not currently$/i] 497 var REASON_ALUMINUM_ANSWER = 498 /prefer aluminum-free|aluminum-free options|prefer aluminum for extra sweat|prefer aluminum(?!-?free)|open to either|aluminum-free or aluminum-containing|no preference|i'?m open to either/i 499 var REASON_RULES = [ 500 // Q1 â Body part(s) 501 { q: /odor|sweat|where|area|experience|body part|looking for a better solution/i, a: /underarm/i, r: "Because you're looking for a better underarm solution" }, 502 { q: /odor|sweat|where|area|experience|body part|looking for a better solution/i, a: /underboob|skin.?fold|back|private|privates|groin|intimate|feet|foot|other|whole body|full body|everywhere/i, r: "Because you're looking for a better whole body solution" }, 503 // Aluminum preference â answer-only (live key or mislabeled "What is your question?") 504 { a: /prefer aluminum-free|aluminum-free options/i, r: 'Because you prefer aluminum-free options' }, 505 { a: /open to either|aluminum-free or aluminum-containing|no preference|i'?m open to either/i, r: 'Because you have no aluminum preference' }, 506 { a: /prefer aluminum for extra sweat|prefer aluminum(?!-?free)/i, r: 'Because you prefer aluminum for extra sweat protection' }, 507 // Format preference 508 { q: /format|prefer to apply|how.*apply|application/i, a: /stick/i, r: 'Because you prefer a stick deodorant' }, 509 { q: /format|prefer to apply|how.*apply|application/i, a: /spray/i, r: 'Because you prefer a spray deodorant' }, 510 { q: /format|prefer to apply|how.*apply|application/i, a: /cream/i, r: 'Because you prefer a cream deodorant' }, 511 // Scent style 512 { q: /scent|smell|fragrance|style/i, a: /warm.*cozy|cozy.*warm/i, r: 'Because your scent style is Warm & Cozy' }, 513 { q: /scent|smell|fragrance|style/i, a: /sweet.*fruit|fruit.*sweet|soft.*sweet|sweet.*soft/i, r: 'Because your scent style is Sweet & Fruity' }, 514 { q: /scent|smell|fragrance|style/i, a: /fresh.*clean|clean.*fresh/i, r: 'Because your scent style is Fresh & Clean' }, 515 { q: /scent|smell|fragrance|style/i, a: /unscented|fragrance.?free|no scent|prefer unscented/i, r: 'Because your scent style is Unscented' }, 516 // Fixture / shorthand keys
517 { a: /^underarms?$/i, r: "Because you're looking for a better underarm solution" }, 518 { a: /^(soft.?sweet|sweet.?fruit(y)?)$/i, r: 'Because your scent style is Sweet & Fruity' }, 519 { a: /^warm.?cozy$/i, r: 'Because your scent style is Warm & Cozy' }, 520 ] 521 522 function reasonAnswers(payload) { 523 if (!payload || typeof payload !== 'object') return null 524 var state = payload.state || {} 525 var headless = state.headless_results_page 526 var info = state.info 527 var answers = null 528 if (payload.answers && typeof payload.answers === 'object') 529 answers = payload.answers 530 else if (headless && headless.answers) answers = headless.answers 531 else if (info && info.answers) answers = info.answers 532 return ensureQ1Answer(answers) 533 } 534 535 /** 536 * Octane often omits Q1 (body parts) from the final `answers` object even 537 * though the shopper answered it â it drives recommendation path only. 538 * We snapshot the live question title + selections from the quiz DOM and 539 * inject them here so gallery bullets / Klaviyo keys track Product copy. 540 */ 541 function normalizeCapturedParts(value) { 542 if (!Array.isArray(value) || !value.length) return [] 543 return value.filter(function (part) { 544 return typeof part === 'string' && part.trim() 545 }) 546 } 547 548 function readCapturedQ1() { 549 try { 550 var raw = sessionStorage.getItem(Q1_BODY_PARTS_KEY) 551 if (!raw) return null 552 var parsed = JSON.parse(raw) 553 // Legacy: bare parts array 554 if (Array.isArray(parsed)) { 555 var legacyParts = normalizeCapturedParts(parsed) 556 return legacyParts.length 557 ? { question: '', parts: legacyParts } 558 : null 559 } 560 if (!parsed || typeof parsed !== 'object') return null 561 var parts = normalizeCapturedParts(parsed.parts) 562 if (!parts.length) return null 563 var question = 564 typeof parsed.question === 'string' ? parsed.question.trim() : '' 565 return { question: question, parts: parts } 566 } catch (e) { 567 return null 568 } 569 } 570 571 function isQ1AnswerKey(rawKey) { 572 var question = String(rawKey || '').replace(/^Octane:\s*/i, '') 573 return /experience odor or sweat|looking for a better solution|where.*odor|body part/i.test( 574 question 575 ) 576 } 577 578 function formatQ1AnswerKey(question) { 579 return 'Octane: ' + String(question || '').replace(/^Octane:\s*/i, '').trim() 580 } 581 582 // Prefer live DOM title for Q1. Octane's completion payload can still emit 583 // a stale answers key even when the editor + Custom Properties UI match the 584 // shopper-facing question â remap so Klaviyo gets the live title. 585 function ensureQ1Answer(answers) { 586 if (!answers || typeof answers !== 'object') return answers 587 var captured = readCapturedQ1() 588 if (!captured || !captured.question) return answers 589 590 var liveKey = formatQ1AnswerKey(captured.question) 591 var existingQ1Keys = [] 592 for (var key in answers) { 593 if (!Object.prototype.hasOwnProperty.call(answers, key)) continue 594 if (isQ1AnswerKey(key)) existingQ1Keys.push(key) 595 } 596 597 var value 598 if (existingQ1Keys.length) { 599 value = answers[existingQ1Keys[0]] 600 } else if (captured.parts && captured.parts.length) { 601 value = captured.parts.join('|') 602 } else { 603 return answers 604 } 605 606 if ( 607 existingQ1Keys.length === 1 && 608 existingQ1Keys[0] === liveKey && 609 typeof answers[liveKey] !== 'undefined' 610 ) { 611 return answers 612 } 613 614 var out = {} 615 out[liveKey] = value 616 for (var k in answers) { 617 if (!Object.prototype.hasOwnProperty.call(answers, k)) continue 618 if (isQ1AnswerKey(k)) continue 619 // Skip prototype-pollution keys from untrusted answer payloads. 620 if (k === '__proto__' || k === 'constructor' || k === 'prototype') { 621 continue 622 } 623 out[k] = answers[k] 624 } 625 return out 626 } 627 628 function extractQuestionStep(root) { 629 var match = String(root.innerText || '').match( 630 /QUESTION\s+(\d+)\s*\/\s*\d+/i 631 ) 632 return match ? parseInt(match[1], 10) : 0 633 } 634 635 function extractQuestionTitle(root) { 636 var lines = String(root.innerText || '') 637 .split('\n') 638 .map(function (line) { 639 return line.trim() 640 }) 641 .filter(Boolean) 642 for (var i = 0; i < lines.length; i++) { 643 if (!/^QUESTION\s+\d+/i.test(lines[i])) continue 644 var next = lines[i + 1] 645 if ( 646 next && 647 !/^select all that apply/i.test(next) && 648 !/^next$/i.test(next) && 649 !/^â?\s*back$/i.test(next) 650 ) { 651 return next 652 } 653 } 654 return '' 655 } 656 657 function optionLabel(el) { 658 return (el.innerText || el.getAttribute('aria-label') || '').trim() 659 } 660 661 function isOptionChecked(el) { 662 return ( 663 el.getAttribute('aria-checked') === 'true' || 664 el.getAttribute('aria-checked') === 'mixed' 665 ) 666 } 667 668 function selectedOptionLabels(root) { 669 var selected = [] 670 var boxes = root.querySelectorAll('[role="checkbox"]') 671 for (var i = 0; i < boxes.length; i++) { 672 if (!isOptionChecked(boxes[i])) continue 673 var boxLabel = optionLabel(boxes[i]) 674 if (boxLabel) selected.push(boxLabel) 675 } 676 var radios = root.querySelectorAll('[role="radio"]') 677 for (var r = 0; r < radios.length; r++) { 678 if (!isOptionChecked(radios[r])) continue 679 var radioLabel = optionLabel(radios[r]) 680 if (radioLabel) selected.push(radioLabel) 681 } 682 return selected 683 } 684 685 function normalizeDomAnswerParts(value) { 686 if (!Array.isArray(value) || !value.length) return [] 687 return value.filter(function (part) { 688 return typeof part === 'string' && part.trim() 689 }) 690 } 691 692 function readQuizDomAnswers() { 693 try { 694 var raw = sessionStorage.getItem(QUIZ_DOM_ANSWERS_KEY) 695 if (!raw) return [] 696 var parsed = JSON.parse(raw) 697 if (!Array.isArray(parsed)) return [] 698 var out = [] 699 for (var i = 0; i < parsed.length; i++) { 700 var item = parsed[i] 701 if (!item || typeof item !== 'object') continue 702 var step = Number(item.step) 703 var question = 704 typeof item.question === 'string' ? item.question.trim() : '' 705 var parts = normalizeDomAnswerParts(item.parts) 706 if (!step || step < 1 || !question || !parts.length) continue 707 out.push({ step: step, question: question, parts: parts }) 708 } 709 out.sort(function (a, b) { 710 return a.step - b.step 711 }) 712 return out 713 } catch (e) { 714 return [] 715 } 716 } 717 718 function writeQuizDomAnswers(entries) { 719 try { 720 sessionStorage.setItem(QUIZ_DOM_ANSWERS_KEY, JSON.stringify(entries)) 721 } catch (e) {} 722 } 723 724 function snapshotQuizAnswersFromDom() { 725 var root = 726 document.querySelector('#octane-quiz-wrapper') || 727 document.querySelector('.octane-ai-quiz-rendered') 728 if (!root) return 729 730 var step = extractQuestionStep(root) 731 var question = extractQuestionTitle(root) 732 var parts = selectedOptionLabels(root) 733 if (!step || !question || !parts.length) return 734 735 var entries = readQuizDomAnswers() 736 var replaced = false 737 for (var i = 0; i < entries.length; i++) { 738 if (entries[i].step !== step) continue 739 entries[i] = { step: step, question: question, parts: parts } 740 replaced = true 741 break 742 } 743 if (!replaced) { 744 entries.push({ step: step, question: question, parts: parts }) 745 } 746 entries.sort(function (a, b) { 747 return a.step - b.step 748 }) 749 writeQuizDomAnswers(entries) 750 751 // Gallery reasons still read the Q1-specific store. 752 snapshotQ1BodyPartsFromDom(root, question, parts) 753 } 754 755 function snapshotQ1BodyPartsFromDom(root, question, parts) { 756 root = 757 root || 758 document.querySelector('#octane-quiz-wrapper') || 759 document.querySelector('.octane-ai-quiz-rendered') 760 if (!root) return 761 762 // Detect Q1 by body-part option labels (stable if Product renames the title). 763 var boxes = root.querySelectorAll('[role="checkbox"]') 764 var matchedLabels = 0 765 var selected = [] 766 for (var i = 0; i < boxes.length; i++) { 767 var el = boxes[i] 768 var label = optionLabel(el) 769 if (!label) continue 770 var canonical = null 771 for (var j = 0; j < Q1_BODY_PART_LABELS.length; j++) { 772 if (label.toLowerCase() === Q1_BODY_PART_LABELS[j].toLowerCase()) { 773 canonical = Q1_BODY_PART_LABELS[j] 774 matchedLabels++ 775 break 776 } 777 } 778 if (!canonical) continue 779 if (isOptionChecked(el)) selected.push(canonical) 780 } 781 // Need several body-part options on screen to treat this as Q1. 782 if (matchedLabels < 3 || !selected.length) return 783 784 question = question || extractQuestionTitle(root) 785 if (!question) return 786 // Prefer canonical body-part labels when we detected Q1. 787 parts = selected 788 789 try { 790 sessionStorage.setItem( 791 Q1_BODY_PARTS_KEY, 792 JSON.stringify({ question: question, parts: parts }) 793 ) 794 } catch (e) {} 795 } 796 797 function bindQuizDomAnswerCapture() { 798 if (location.pathname !== '/quiz') return 799 // Fresh quiz attempt â don't carry answers from a prior run. 800 if (location.hash.indexOf('r=') === -1) { 801 try { 802 sessionStorage.removeItem(Q1_BODY_PARTS_KEY) 803 sessionStorage.removeItem(QUIZ_DOM_ANSWERS_KEY) 804 } catch (e) {} 805 } 806 document.addEventListener( 807 'click', 808 function () {
809 setTimeout(snapshotQuizAnswersFromDom, 0) 810 }, 811 true 812 ) 813 setInterval(function () { 814 if (location.pathname === '/quiz') snapshotQuizAnswersFromDom() 815 }, 600) 816 } 817 818 function reasonSkipped(question, answer) { 819 if ( 820 /your question|tell us|anything else/i.test(question) && 821 answer && 822 REASON_ALUMINUM_ANSWER.test(answer) 823 ) { 824 return false 825 } 826 for (var i = 0; i < REASON_SKIP.length; i++) { 827 if (REASON_SKIP[i].test(question)) return true 828 } 829 return false 830 } 831 832 function reasonSkippedAnswer(answer) { 833 for (var i = 0; i < REASON_SKIP_ANSWER.length; i++) { 834 if (REASON_SKIP_ANSWER[i].test(answer)) return true 835 } 836 return false 837 } 838 839 function reasonEntries(answers) { 840 var entries = [] 841 for (var rawQ in answers) { 842 if (!Object.prototype.hasOwnProperty.call(answers, rawQ)) continue 843 if (rawQ.indexOf('klaviyo_') === 0) continue 844 var question = rawQ.replace(/^Octane:\s*/i, '').trim() 845 var value = answers[rawQ] 846 if (Array.isArray(value)) { 847 for (var i = 0; i < value.length; i++) { 848 if (typeof value[i] !== 'string') continue 849 var trimmed = value[i].trim() 850 if (!trimmed || reasonSkippedAnswer(trimmed)) continue 851 if (reasonSkipped(question, trimmed)) continue 852 entries.push({ question: question, answer: trimmed }) 853 } 854 continue 855 } 856 if (typeof value !== 'string' || !value.trim()) continue 857 if (/^default results page$/i.test(value.trim())) continue 858 // Split only on the pipe (Octane's multi-select delimiter); commas 859 // appear inside single answer sentences. 860 var parts = value.split('|') 861 for (var k = 0; k < parts.length; k++) { 862 var a = parts[k].trim() 863 if (!a || reasonSkippedAnswer(a)) continue 864 if (reasonSkipped(question, a)) continue 865 entries.push({ question: question, answer: a }) 866 } 867 } 868 return entries 869 } 870 871 function reasonMatch(entry) { 872 for (var i = 0; i < REASON_RULES.length; i++) { 873 var rule = REASON_RULES[i] 874 if (rule.q && !rule.q.test(entry.question)) continue 875 if (!rule.a.test(entry.answer)) continue 876 return rule.r 877 } 878 return null 879 } 880 881 function reasonFallback(answer) { 882 var normalized = (answer || '').trim() 883 if (!normalized) return '' 884 if (/[.!?]/.test(normalized)) return '' 885 if (normalized.split(/\s+/).length > 4) return '' 886 if (/^because you /i.test(normalized)) { 887 return normalized.charAt(0).toUpperCase() + normalized.slice(1) 888 } 889 return 'Because you ' + normalized.charAt(0).toLowerCase() + normalized.slice(1) 890 } 891 892 function buildReasons(payload) { 893 var answers = reasonAnswers(payload) 894 if (!answers) return [] 895 var reasons = [] 896 var seen = {} 897 var entries = reasonEntries(answers) 898 function push(reason) { 899 if (!reason || seen[reason]) return false 900 seen[reason] = true 901 reasons.push(reason) 902 return reasons.length >= REASON_MAX 903 } 904 for (var i = 0; i < entries.length; i++) { 905 var matched = reasonMatch(entries[i]) 906 if (matched && push(matched)) return reasons 907 } 908 for (var j = 0; j < entries.length; j++) { 909 if (reasonMatch(entries[j])) continue 910 if (push(reasonFallback(entries[j].answer))) return reasons 911 } 912 return reasons 913 } 914 915 // Compact result for the SSR cookie â only the fields the results loader 916 // needs. The server re-normalizes (incl. Shopify handle resolution), so we 917 // keep the raw Octane handle/link/title and drop the multi-KB Octane chrome 918 // that blows past the ~4KB cookie limit. 919 function buildCookieResult(payload) { 920 var products = extractProducts(payload) 921 if (!products.length) return null 922 var slim = products.map(function (p) { 923 return { 924 title: p.title, 925 handle: p.handle, 926 link: p.link, 927 type: p.type, 928 price: p.price, 929 image: p.image || p.image_url, 930 variants: Array.isArray(p.variants) 931 ? p.variants.map(function (v) { 932 return { 933 id: v.id, 934 title: v.title, 935 price: v.price, 936 selling_plans: v.selling_plans, 937 } 938 }) 939 : undefined, 940 } 941 }) 942 var result = { products: slim } 943 // Curated bullets so /quiz/results SSRs them on first arrival. 944 var reasons = buildReasons(payload) 945 if (reasons.length) result.reasons = reasons 946 return result 947 } 948 949 function readStoredResult() { 950 for (var i = 0; i < 2; i++) { 951 try { 952 var storage = i === 0 ? sessionStorage : localStorage 953 var raw = storage.getItem(RESULT_KEY) 954 if (!raw) continue 955 if (hasProducts(JSON.parse(raw))) return true 956 } catch (e) {} 957 } 958 // The cookie is the SSR source of truth, so a shopper whose web storage 959 // denies writes (private mode, in-app webviews, exhausted quota) still has 960 // a renderable result. Without this fallback the completion handler treats 961 // them as result-less and never redirects, stranding them on /quiz looking 962 // at Octane's own unstyled results. 963 // 964 // Gated on THIS document's verified write, not on cookie presence: the 965 // cookie lives for 24h, so a bare presence check would let a previous 966 // run's result satisfy the persist race this function exists to cover. 967 return cookiePersisted 968 } 969 970 function writeResultCookie(serialized) { 971 try { 972 if (!serialized) return false 973 // The ~4KB browser cap applies to the encoded value the browser 974 // stores, and JSON percent-encodes to roughly 1.5x raw â measure the 975 // encoded form or the browser silently drops an oversized cookie and 976 // /quiz/results loses its SSR fast path. 977 var encoded = encodeURIComponent(serialized) 978 if (encoded.length > MAX_COOKIE_BYTES) { 979 console.error( 980 '[quiz] bootstrap result too large for cookie (' + 981 encoded.length + 982 'B encoded, max ' + 983 MAX_COOKIE_BYTES + 984 'B)' 985 ) 986 return false 987 } 988 var secure = location.protocol === 'https:' ? '; Secure' : '' 989 var expires = new Date( 990 Date.now() + COOKIE_MAX_AGE * 1000 991 ).toUTCString() 992 document.cookie = 993 RESULT_COOKIE + 994 '=' + 995 encoded + 996 '; expires=' + 997 expires + 998 '; path=/quiz; SameSite=Lax' + 999 secure 1000 // Read back rather than assume. A browser that silently drops the
1001 // cookie (over cap, storage partitioned, cookies disabled) leaves the 1002 // assignment looking successful, and reporting a phantom success is 1003 // what sends a shopper to /quiz/results with nothing to render. 1004 cookiePersisted = 1005 document.cookie.indexOf(RESULT_COOKIE + '=' + encoded) !== -1 1006 return cookiePersisted 1007 } catch (e) { 1008 return false 1009 } 1010 } 1011 1012 function getSession() { 1013 return window.sessionStorage 1014 } 1015 1016 function getLocal() { 1017 return window.localStorage 1018 } 1019 1020 /** 1021 * Best-effort write to one storage. Returns whether it landed. 1022 * 1023 * Takes a getter, not a Storage: when storage is blocked by policy the 1024 * `window.sessionStorage` access itself throws (SecurityError in Chrome), so 1025 * resolving it at the call site would throw outside this try and abort the 1026 * caller before it can fall back to the cookie. 1027 */ 1028 function storeItem(getStorage, key, value) { 1029 try { 1030 getStorage().setItem(key, value) 1031 return true 1032 } catch (e) { 1033 return false 1034 } 1035 } 1036 1037 function persistCompletion(detail) { 1038 try { 1039 var payload = extractPayload(detail) 1040 if (!hasProducts(payload)) return false 1041 var serialized = JSON.stringify(sanitizePayload(payload)) 1042 1043 // Each destination is written independently. Sharing one try block let a 1044 // single storage rejection (private mode, in-app webview, exhausted 1045 // quota) abort the cookie write too, even though cookies were fine â 1046 // which left the shopper with no renderable result at all. 1047 var stored = storeItem(getSession, RESULT_KEY, serialized) 1048 stored = storeItem(getLocal, RESULT_KEY, serialized) || stored 1049 1050 // Cookie carries only the compact result so the redirect can SSR. 1051 var cookieResult = buildCookieResult(payload) 1052 var cookieWritten = cookieResult 1053 ? writeResultCookie(JSON.stringify(cookieResult)) 1054 : false 1055 1056 var accessed = 1057 detail && detail.state && detail.state.headless_results_page_accessed 1058 var resultsUrl = 1059 (detail && detail.quiz_results_url) || 1060 (accessed && accessed.quiz_results_url) || 1061 (payload && payload.quiz_results_url) 1062 if (resultsUrl) { 1063 storeItem(getSession, RESULTS_URL_KEY, resultsUrl) 1064 storeItem(getLocal, RESULTS_URL_KEY, resultsUrl) 1065 } 1066 1067 // The handoff only needs ONE destination to have taken. Reporting 1068 // failure when the cookie landed would skip the redirect and strand the 1069 // shopper on /quiz. 1070 if (!stored && !cookieWritten) { 1071 console.error( 1072 '[quiz] bootstrap persist failed â no storage and no cookie' 1073 ) 1074 return false 1075 } 1076 return true 1077 } catch (e) { 1078 console.error('[quiz] bootstrap persist failed', e) 1079 return false 1080 } 1081 } 1082 1083 function persistFromSubmitResponse(data) { 1084 if (!data || typeof data !== 'object') return false 1085 // Pass the response through unchanged: extractPayload already prefers 1086 // state.headless_results_page, and rebuilding as { state } would drop 1087 // top-level fields like quiz_results_url. 1088 return persistCompletion(data) 1089 } 1090 1091 // Cover the quiz section with our own overlay so Octane's calculating screen 1092 // and inline results page can never come into view during the handoff. 1093 // Positioned absolute INSIDE the quiz section (#quiz-scroll-anchor) so it 1094 // covers exactly what the embed occupies, scrolls with the page (not pinned 1095 // to the viewport), and leaves the header/footer untouched. Appended to the 1096 // section itself â a sibling of the embed, not a descendant â so the CSS that 1097 // hides Octane's calculating screen (which targets .octane-ai-quiz-rendered) 1098 // can't hide the overlay too. inset:0 means it grows to match the section if 1099 // Octane's results render makes it taller. Falls back to a fixed full-view 1100 // overlay if the section isn't found. 1101 var handoffOverlayShown = false 1102 var handoffScrollRestore = null 1103 // Set when redirectToResults() calls location.replace(). Reset per handoff 1104 // attempt so a retake in the same document starts from a clean slate. 1105 var handoffNavigationRequested = false 1106 // Pending failsafe clocks, cleared whenever the failsafe is re-armed. 1107 var handoffFailsafeTimer = 0 1108 var handoffGraceTimer = 0 1109 // The hero hidden for this handoff, held so the failsafe can be re-armed 1110 // after the overlay is already up and still restore it on teardown. 1111 var handoffHero = null 1112 1113 /** Restart the failsafe clock from now. No-op when no overlay is up. */ 1114 function rearmHandoffFailsafe() { 1115 if (!handoffOverlayShown) return 1116 scheduleHandoffFailsafe(handoffHero) 1117 } 1118 function showHandoffOverlay() { 1119 // A completion firing while already on /quiz/results is an Octane `#r=` 1120 // hash restore, not a /quizâ/quiz/results handoff: React renders the 1121 // results in place, so there's nothing to cover. Showing the overlay here 1122 // would strand the shopper â the failsafe below intentionally never tears 1123 // it down on /quiz/results, so it would sit on top of the rendered results 1124 // forever (cold-load / incognito deep-link bug). 1125 if (onResultsPage()) return 1126 if (handoffOverlayShown) return 1127 handoffOverlayShown = true 1128 handoffNavigationRequested = false 1129 1130 // Reset before the try, and the failsafe armed in `finally`, so that a 1131 // throw while building the overlay still leaves something scheduled to
1132 // tear it down and report. Otherwise the catch swallows the error and the 1133 // shopper keeps a permanent overlay over neutered scroll APIs â the exact 1134 // failure this overlay exists to bound. 1135 handoffHero = null 1136 try { 1137 if (document.getElementById('oct-handoff-overlay')) return 1138 var style = document.createElement('style') 1139 style.textContent = 1140 '@keyframes oct-handoff-spin{to{transform:rotate(360deg)}}' 1141 document.head.appendChild(style) 1142 1143 var section = document.getElementById('quiz-scroll-anchor') 1144 1145 // Keep the quiz hero from popping back into view during the handoff â 1146 // its scroll-based visibility can flip back to visible as the layout 1147 // settles. visibility (not display) so the hero's height is preserved 1148 // and hiding it doesn't reflow the page upward (which reads as a scroll 1149 // jump). We navigate away moments later, so this is safe. 1150 handoffHero = document.getElementById('quiz-hero') 1151 if (handoffHero) handoffHero.style.visibility = 'hidden' 1152 1153 // Stop Octane from auto-scrolling the page on completion (it 1154 // scrollIntoView's / scrollTo's to its results) while leaving the user 1155 // free to scroll manually â wheel/trackpad scrolling doesn't go through 1156 // these APIs, so only programmatic scrolls are neutralized. Restored by 1157 // the failsafe if navigation never lands. 1158 try { 1159 var origScrollTo = window.scrollTo 1160 var origScroll = window.scroll 1161 var origScrollBy = window.scrollBy 1162 var origScrollIntoView = Element.prototype.scrollIntoView 1163 var noop = function () {} 1164 window.scrollTo = noop 1165 window.scroll = noop 1166 window.scrollBy = noop 1167 Element.prototype.scrollIntoView = noop 1168 handoffScrollRestore = function () { 1169 window.scrollTo = origScrollTo 1170 window.scroll = origScroll 1171 window.scrollBy = origScrollBy 1172 Element.prototype.scrollIntoView = origScrollIntoView 1173 } 1174 } catch (e) {} 1175 1176 var overlay = document.createElement('div') 1177 overlay.id = 'oct-handoff-overlay' 1178 overlay.setAttribute('aria-hidden', 'true') 1179 var base = 1180 'z-index:2147483647;display:flex;flex-direction:column;' + 1181 'align-items:center;justify-content:flex-start;gap:20px;background:#fff;' + 1182 'text-align:center;padding:24px;box-sizing:border-box' 1183 1184 var text = document.createElement('p') 1185 text.textContent = 'Putting your recommendation togetherâ¦' 1186 text.style.cssText = 1187 'margin:0;font-family:Poppins,sans-serif;font-size:16px;' + 1188 'font-weight:600;line-height:1.4;color:var(--color-gray-900,#201f21)' 1189 1190 var spinner = document.createElement('div') 1191 spinner.style.cssText = 1192 'width:40px;height:40px;border-radius:50%;' + 1193 'border:3px solid var(--color-pink-100,#fedde7);' + 1194 'border-top-color:var(--color-pink-500,#de1b83);' + 1195 'animation:oct-handoff-spin 0.8s linear infinite' 1196 1197 overlay.appendChild(text) 1198 overlay.appendChild(spinner) 1199 1200 if (section) { 1201 var sectionCS = window.getComputedStyle(section) 1202 if (sectionCS.position === 'static') { 1203 section.style.position = 'relative' 1204 } 1205 // Top-align the text + spinner and match the section's own top 1206 // padding so they sit where the quiz content normally starts, 1207 // rather than dead-center in the section. The overlay fills the 1208 // padding box (inset:0), so padding-top = section's paddingTop 1209 // lands the content at the quiz content's top edge. 1210 overlay.style.cssText = 1211 'position:absolute;inset:0;' + 1212 base + 1213 ';padding-top:' + 1214 sectionCS.paddingTop 1215 section.appendChild(overlay) 1216 } else { 1217 overlay.style.cssText = 'position:fixed;inset:0;' + base 1218 ;(document.body || document.documentElement).appendChild(overlay) 1219 } 1220 } catch (e) { 1221 // Swallowed, but the failsafe below is armed regardless â a half-built 1222 // overlay still has to be torn down and reported. 1223 } finally { 1224 scheduleHandoffFailsafe(handoffHero) 1225 } 1226 } 1227 1228 /** 1229 * Restore the page to its pre-handoff state. 1230 * 1231 * Every step is independent: a throw partway through would otherwise leave 1232 * `handoffOverlayShown` set and the scroll APIs neutered for the rest of the 1233 * document, since this is the only thing that puts them back. 1234 */ 1235 function teardownHandoffOverlay(hero) { 1236 try { 1237 var el = document.getElementById('oct-handoff-overlay') 1238 if (el && el.parentNode) el.parentNode.removeChild(el) 1239 } catch (e) { 1240 // A stuck overlay is bad; skipping the scroll restore below is worse. 1241 } 1242 try { 1243 if (handoffScrollRestore) handoffScrollRestore() 1244 } catch (e) { 1245 // Nulled either way â a restore that threw won't succeed on a retry. 1246 } 1247 handoffScrollRestore = null 1248 try { 1249 if (hero) hero.style.visibility = '' 1250 } catch (e) { 1251 // The hero is cosmetic here; never let it hold back handoffOverlayShown. 1252 } 1253 handoffOverlayShown = false 1254 } 1255 1256 /** 1257 * Report a handoff that never delivered the shopper to /quiz/results. 1258 * 1259 * This is the "I submitted and never got my results" case, and the one 1260 * failure the /quiz/results diagnostics cannot see â they only run once 1261 * that route loads. console.error is the channel that reaches Datadog 1262 * Logs via forwardErrorsToLogs. 1263 */ 1264 function reportHandoffFailure(reason) { 1265 // Probe a WRITE, not a read. Private mode and in-app webviews commonly 1266 // allow getItem while rejecting setItem, so a read probe reports "ok" 1267 // for exactly the shoppers who are broken. 1268 var storage = 'ok' 1269 try { 1270 getSession().setItem(STORAGE_PROBE_KEY, '1') 1271 } catch (e) { 1272 storage = 'blocked' 1273 } 1274 // Removed separately so a throwing removeItem cannot leave the sentinel 1275 // behind in storage. 1276 try { 1277 getSession().removeItem(STORAGE_PROBE_KEY) 1278 } catch (e) { 1279 // Writable is what the probe reports; a failed cleanup doesn't change it. 1280 } 1281 // Each field is an independent signal, so don't route any of them 1282 // through readStoredResult() â it falls back to the cookie, which would 1283 // make two of these report the same fact. 1284 var inStorage = false 1285 try { 1286 inStorage = !!getSession().getItem(RESULT_KEY) 1287 } catch (e) {} 1288 // Serialized rather than concatenated, so the line stays parseable 1289 // whatever a field holds â and to match the shape 1290 // quiz-redirect-diagnostics.ts logs for the sibling failure. 1291 var context 1292 try { 1293 context = JSON.stringify({ 1294 reason: reason, 1295 cookiePersisted: cookiePersisted, 1296 resultInStorage: inStorage, 1297 storageWritable: storage, 1298 }) 1299 } catch (e) { 1300 context = '{"reason":"' + reason + '"}' 1301 } 1302 console.error( 1303 '[quiz] handoff never landed â still on /quiz after completion ' + 1304 context 1305 ) 1306 } 1307
1308 /** 1309 * Tear the overlay down, and report, when the handoff never completes. 1310 * 1311 * The two failures are separated because they need different fixes and 1312 * because conflating them makes the signal unusable. `never-navigated` 1313 * means no redirect was ever issued, so the persist is what failed. 1314 * `navigation-never-committed` means location.replace() ran and the new 1315 * document never arrived. The second waits longer on purpose: pathname 1316 * stays /quiz for the whole in-flight navigation, so without the grace 1317 * period every slow connection reports a broken handoff and loses its 1318 * overlay mid-navigation. 1319 */ 1320 function scheduleHandoffFailsafe(hero) { 1321 // Re-arming replaces the pending clock rather than adding a second one. 1322 // The overlay goes up when Octane's calculating animation appears, which 1323 // is before the submit_page round trip â so measured from there the budget 1324 // has to cover Octane calculating, the network, the persist and the 1325 // redirect, and a slow connection burns it while the handoff is still 1326 // healthy. beginQuizHandoff restarts it once the response is actually in 1327 // hand, so the budget measures the part we control. 1328 window.clearTimeout(handoffFailsafeTimer) 1329 window.clearTimeout(handoffGraceTimer) 1330 var finish = function (reason) { 1331 if (onResultsPage()) return 1332 // Still tear down anywhere else â nothing else un-patches the scroll 1333 // APIs â but only call it a failure if the shopper is still sitting on 1334 // the quiz waiting. One who navigated away during the handoff abandoned 1335 // it, and reporting that buries the shoppers who are actually stuck. 1336 if (onQuizLanding()) reportHandoffFailure(reason) 1337 teardownHandoffOverlay(hero) 1338 } 1339 handoffFailsafeTimer = window.setTimeout(function () { 1340 if (onResultsPage()) return 1341 if (!handoffNavigationRequested) { 1342 finish('never-navigated') 1343 return 1344 } 1345 handoffGraceTimer = window.setTimeout(function () { 1346 finish('navigation-never-committed') 1347 }, HANDOFF_NAV_GRACE_MS) 1348 }, HANDOFF_FAILSAFE_MS) 1349 } 1350 1351 // Octane renders custom rich-text HTML (e.g. the email-step coupon) inside 1352 // same-origin iframes. Parent-page CSS (octane-overrides.css) cannot reach 1353 // those documents. Octane's inline styles also list Helvetica/Arial after 1354 // Poppins â if the face isn't loaded yet (or the Google Fonts <link> never 1355 // resolves in the sandboxed iframe), eyebrow + fine-print render as Arial. 1356 // We inject faces via FontFace API from the parent (where Poppins already 1357 // loads) and strip the Helvetica/Arial fallbacks on coupon nodes. 1358 var POPPINS_FONT_HREF = 1359 'https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800;900&display=swap' 1360 1361 function ensurePoppinsLinkAndBaseStyle(doc) { 1362 if (!doc.querySelector('link[data-lume-poppins]')) { 1363 var link = doc.createElement('link') 1364 link.rel = 'stylesheet' 1365 link.href = POPPINS_FONT_HREF 1366 link.setAttribute('data-lume-poppins', '1') 1367 doc.head.appendChild(link) 1368 } 1369 if (!doc.querySelector('style[data-lume-poppins]')) { 1370 var style = doc.createElement('style') 1371 style.setAttribute('data-lume-poppins', '1') 1372 // No Helvetica/Arial in this stack â if Poppins is missing we want 1373 // a generic sans, not a false "looks fine" system UI font. 1374 style.textContent = 1375 'html,body,*{font-family:"Poppins",sans-serif !important;}' 1376 doc.head.appendChild(style) 1377 } 1378 } 1379 1380 function injectPoppinsCssText(doc, cssText) { 1381 if (!doc || !cssText) return 1382 if (doc.querySelector('style[data-lume-poppins-css]')) return 1383 var style = doc.createElement('style') 1384 style.setAttribute('data-lume-poppins-css', '1') 1385 style.textContent = cssText 1386 doc.head.appendChild(style) 1387 } 1388 1389 // Cache Google Fonts CSS in the parent so every quiz iframe can reuse it. 1390 var poppinsCssTextPromise = null 1391 function fetchPoppinsCssTextViaXhr() { 1392 return new Promise(function (resolve) { 1393 try { 1394 var xhr = new XMLHttpRequest() 1395 xhr.open('GET', POPPINS_FONT_HREF, true) 1396 xhr.onload = function () { 1397 resolve( 1398 xhr.status >= 200 && xhr.status < 300 ? xhr.responseText : '' 1399 ) 1400 } 1401 xhr.onerror = function () { 1402 resolve('') 1403 } 1404 xhr.send() 1405 } catch (e) { 1406 resolve('') 1407 } 1408 }) 1409 } 1410 1411 function fetchPoppinsCssText() { 1412 if (poppinsCssTextPromise) return poppinsCssTextPromise 1413 poppinsCssTextPromise = fetchPoppinsCssTextViaXhr().catch(function () { 1414 poppinsCssTextPromise = null 1415 return '' 1416 }) 1417 return poppinsCssTextPromise 1418 } 1419 1420 // Warm the cache once on bootstrap so inject loops do not hit fetch/XHR. 1421 fetchPoppinsCssText() 1422 1423 function stripCouponSystemFontFallbacks(doc) { 1424 try { 1425 var nodes = doc.querySelectorAll('[style*="font-family"], [style*="fontFamily"]') 1426 var target = '"Poppins", sans-serif' 1427 for (var i = 0; i < nodes.length; i++) { 1428 var el = nodes[i] 1429 var current = el.style && el.style.fontFamily 1430 if (!current || current === target) continue 1431 // Keep Poppins; drop -apple-system / Helvetica / Arial from Octane HTML. 1432 el.style.fontFamily = target 1433 } 1434 } catch (e) {} 1435 } 1436 1437 function markFacesReady(doc) { 1438 try { 1439 doc.documentElement.setAttribute('data-lume-poppins-faces', '1') 1440 stripCouponSystemFontFallbacks(doc) 1441 if (doc.body) { 1442 doc.body.style.transform = 'translateZ(0)' 1443 requestAnimationFrame(function () { 1444 if (doc.body) doc.body.style.transform = '' 1445 }) 1446 } 1447 } catch (e) {} 1448 } 1449 1450 function copyPoppinsFacesIntoIframe(iframe) { 1451 try { 1452 var doc = iframe.contentDocument 1453 var win = iframe.contentWindow 1454 if (!doc || !win) return 1455 1456 if (doc.documentElement.getAttribute('data-lume-poppins-faces') === '1') {
1457 stripCouponSystemFontFallbacks(doc) 1458 return 1459 } 1460 1461 // 1) Preferred: inject the actual @font-face CSS (fetched by parent). 1462 fetchPoppinsCssText().then(function (cssText) { 1463 if (!cssText || !iframe.contentDocument) return 1464 injectPoppinsCssText(iframe.contentDocument, cssText) 1465 var readyDoc = iframe.contentDocument 1466 if (readyDoc.fonts && readyDoc.fonts.load) { 1467 Promise.all([ 1468 readyDoc.fonts.load('400 12px Poppins'), 1469 readyDoc.fonts.load('600 36px Poppins'), 1470 ]) 1471 .catch(function () {}) 1472 .then(function () { 1473 markFacesReady(readyDoc) 1474 }) 1475 } else { 1476 markFacesReady(readyDoc) 1477 } 1478 }) 1479 1480 // 2) Also copy any already-loaded parent faces via FontFace.blob(). 1481 if (!win.FontFace || !document.fonts || !document.fonts.forEach) return 1482 1483 document.fonts.forEach(function (face) { 1484 if (!/poppins/i.test(face.family)) return 1485 if (face.status !== 'loaded') return 1486 if (typeof face.blob !== 'function') return 1487 face 1488 .blob() 1489 .then(function (blob) { 1490 var url = URL.createObjectURL(blob) 1491 var iframeFace = new win.FontFace(face.family, 'url(' + url + ')', { 1492 weight: face.weight, 1493 style: face.style, 1494 stretch: face.stretch, 1495 }) 1496 return iframeFace.load().then(function (loaded) { 1497 doc.fonts.add(loaded) 1498 }) 1499 }) 1500 .catch(function () {}) 1501 }) 1502 } catch (e) {} 1503 } 1504 1505 function injectPoppinsIntoOctaneIframe(iframe) { 1506 try { 1507 if (!iframe || iframe.tagName !== 'IFRAME') return 1508 1509 var doc = iframe.contentDocument 1510 if (!doc || !doc.head) return 1511 1512 ensurePoppinsLinkAndBaseStyle(doc) 1513 stripCouponSystemFontFallbacks(doc) 1514 copyPoppinsFacesIntoIframe(iframe) 1515 } catch (e) {} 1516 } 1517 1518 function watchOctaneIframeDocument(iframe) { 1519 if (!iframe || iframe.tagName !== 'IFRAME') return 1520 if (iframe.dataset && iframe.dataset.lumePoppinsWatching === '1') return 1521 if (iframe.dataset) iframe.dataset.lumePoppinsWatching = '1' 1522 1523 var injecting = false 1524 var injectQueued = false 1525 var docObserver = null 1526 var pollId = null 1527 var attachPoll = null 1528 1529 function facesReady() { 1530 var doc = iframe.contentDocument 1531 return ( 1532 doc && 1533 doc.documentElement && 1534 doc.documentElement.getAttribute('data-lume-poppins-faces') === '1' 1535 ) 1536 } 1537 1538 function stopWatching() { 1539 if (pollId) { 1540 window.clearInterval(pollId) 1541 pollId = null 1542 }
1543 if (attachPoll) { 1544 window.clearInterval(attachPoll) 1545 attachPoll = null 1546 } 1547 if (docObserver) { 1548 docObserver.disconnect() 1549 docObserver = null 1550 } 1551 } 1552 1553 function tryInject() { 1554 if (injecting) { 1555 injectQueued = true 1556 return 1557 } 1558 if (facesReady()) { 1559 stopWatching() 1560 return 1561 } 1562 injecting = true 1563 try { 1564 injectPoppinsIntoOctaneIframe(iframe) 1565 } finally { 1566 injecting = false 1567 if (facesReady()) stopWatching() 1568 if (injectQueued) { 1569 injectQueued = false 1570 tryInject() 1571 } 1572 } 1573 } 1574 1575 function scheduleInject() { 1576 if (facesReady()) { 1577 stopWatching() 1578 return 1579 } 1580 tryInject() 1581 } 1582 1583 iframe.addEventListener('load', scheduleInject) 1584 scheduleInject() 1585 1586 // Octane writes iframe HTML after insertion â poll until Poppins faces 1587 // are actually copied in (link alone is not enough; Helvetica fallback 1588 // still wins until FontFace loads). 1589 var attempts = 0 1590 pollId = window.setInterval(function () { 1591 attempts++ 1592 scheduleInject() 1593 if (facesReady() || attempts > 60) stopWatching() 1594 }, 250) 1595 1596 try { 1597 var attachDocObserver = function () { 1598 var doc = iframe.contentDocument 1599 if (!doc || !doc.documentElement || docObserver) return false 1600 docObserver = new MutationObserver(function () { 1601 scheduleInject() 1602 }) 1603 docObserver.observe(doc.documentElement, { 1604 childList: true, 1605 subtree: true, 1606 }) 1607 return true 1608 } 1609 if (!attachDocObserver()) { 1610 attachPoll = window.setInterval(function () { 1611 if (attachDocObserver()) { 1612 window.clearInterval(attachPoll) 1613 attachPoll = null 1614 } 1615 }, 100) 1616 } 1617 } catch (e) {} 1618 } 1619 1620 function scanOctaneIframes(root) { 1621 try { 1622 var scope = root && root.querySelectorAll ? root : document 1623 var iframes = scope.querySelectorAll( 1624 '.octane-ai-quiz iframe, .octane-ai-quiz-rendered iframe' 1625 ) 1626 for (var i = 0; i < iframes.length; i++) { 1627 watchOctaneIframeDocument(iframes[i]) 1628 } 1629 } catch (e) {} 1630 } 1631 1632 function watchOctaneIframes() { 1633 try { 1634 scanOctaneIframes(document) 1635 var observer = new MutationObserver(function (mutations) { 1636 for (var i = 0; i < mutations.length; i++) { 1637 var added = mutations[i].addedNodes 1638 for (var j = 0; j < added.length; j++) { 1639 var node = added[j] 1640 if (!node || node.nodeType !== 1) continue 1641 if (node.tagName === 'IFRAME') { 1642 watchOctaneIframeDocument(node) 1643 continue 1644 } 1645 if (node.querySelectorAll) scanOctaneIframes(node) 1646 } 1647 } 1648 }) 1649 observer.observe(document.documentElement, { 1650 childList: true, 1651 subtree: true, 1652 }) 1653 document.addEventListener( 1654 'load', 1655 function (event) { 1656 if (event.target && event.target.tagName === 'IFRAME') { 1657 watchOctaneIframeDocument(event.target) 1658 } 1659 }, 1660 true 1661 ) 1662 } catch (e) {} 1663 } 1664 1665 // Octane inserts its "Calculating your resultsâ¦" animation only at the final 1666 // step. Catching that insertion (MutationObserver callbacks run before the 1667 // next paint) lets us raise the overlay before either Octane's calculating 1668 // screen or the results page can paint. 1669 function watchForFinalStep() { 1670 try { 1671 var observer = new MutationObserver(function (mutations) { 1672 for (var i = 0; i < mutations.length; i++) { 1673 var added = mutations[i].addedNodes 1674 for (var j = 0; j < added.length; j++) { 1675 var node = added[j] 1676 if (!node || node.nodeType !== 1) continue 1677 var isLoader = 1678 (node.getAttribute && 1679 /oct-quiz-loading-animation/.test( 1680 node.getAttribute('data-element') || '' 1681 )) || 1682 (node.querySelector && 1683 node.querySelector( 1684 "[data-element~='oct-quiz-loading-animation']" 1685 )) 1686 if (isLoader) { 1687 showHandoffOverlay() 1688 observer.disconnect() 1689 return 1690 } 1691 } 1692 } 1693 }) 1694 observer.observe(document.documentElement, { 1695 childList: true, 1696 subtree: true, 1697 }) 1698 } catch (e) {} 1699 } 1700 1701 // Redirect to our results page the instant the final submit_page response is 1702 // captured. Only the final submission carries products (per-question 1703 // submit_page calls don't), so persistFromSubmitResponse returning true is 1704 // the signal. 1705 // 1706 // One navigation per handoff. `location.replace()` is queued rather than 1707 // immediate and pathname stays /quiz until the replacement document commits, 1708 // so a second caller arriving in that window would otherwise supersede the 1709 // in-flight navigation â with a different `detail`, and so possibly a 1710 // different `#` fragment. `handoffNavigationRequested` resets per handoff 1711 // (see showHandoffOverlay), which keeps retakes working. 1712 function redirectToResults(detail) { 1713 if (onResultsPage()) return 1714 if (handoffNavigationRequested) return 1715 handoffNavigationRequested = true 1716 window.location.replace(resultsTarget(detail)) 1717 } 1718 1719 function resultsTarget(detail) { 1720 var url = (detail && detail.quiz_results_url) || null 1721 if (!url) { 1722 try { 1723 url = 1724 sessionStorage.getItem(RESULTS_URL_KEY) || 1725 localStorage.getItem(RESULTS_URL_KEY) 1726 } catch (e) {} 1727 } 1728 if (url) { 1729 var hashIndex = url.indexOf('#') 1730 if (hashIndex !== -1) return '/quiz/results' + url.slice(hashIndex) 1731 } 1732 return '/quiz/results' 1733 } 1734 1735 // Guards waitForStoredResult so repeat completion events share one poll. 1736 var awaitingStoredResult = false 1737 1738 /** 1739 * Poll for a persisted result, then run the handoff. 1740 * 1741 * Asynchronous by necessity. The persist this waits on happens in a 1742 * pending XHR/fetch handler, so a synchronous spin would hold the main
1743 * thread and keep that handler from ever running â it could only ever 1744 * return the answer it already had. 1745 * 1746 * The overlay goes up only once the result lands, so a spurious completion 1747 * event costs the shopper nothing. Bounded by the same budget as the overlay 1748 * failsafe, and reports on the same channel when it runs out. 1749 */ 1750 function waitForStoredResult(detail) { 1751 if (awaitingStoredResult) return 1752 awaitingStoredResult = true 1753 var deadline = Date.now() + HANDOFF_FAILSAFE_MS 1754 var poll = function () { 1755 // An interceptor that persisted and redirected while this poll was in 1756 // flight has already run the handoff. Stop rather than re-enter it with 1757 // this event's detail, which resolves its own results target. 1758 if (handoffNavigationRequested) { 1759 awaitingStoredResult = false 1760 return 1761 } 1762 if (readStoredResult()) { 1763 awaitingStoredResult = false 1764 showHandoffOverlay() 1765 redirectToResults(detail) 1766 return 1767 } 1768 if (Date.now() >= deadline) { 1769 awaitingStoredResult = false 1770 reportHandoffFailure('never-persisted') 1771 return 1772 } 1773 window.setTimeout(poll, RESULT_POLL_MS) 1774 } 1775 window.setTimeout(poll, RESULT_POLL_MS) 1776 } 1777 1778 // Intercept submit_page â persist before Octane reads the response and navigates. 1779 var originalFetch = window.fetch.bind(window) 1780 window.fetch = function () { 1781 var args = arguments 1782 return originalFetch.apply(window, args).then(function (response) { 1783 try { 1784 var requestUrl = 1785 typeof args[0] === 'string' 1786 ? args[0] 1787 : args[0] && args[0].url 1788 ? args[0].url 1789 : String(args[0]) 1790 if (SUBMIT_PAGE_RE.test(requestUrl)) { 1791 return response 1792 .clone() 1793 .json() 1794 .then(function (data) { 1795 beginQuizHandoff(data) 1796 return response 1797 }) 1798 .catch(function () { 1799 return response 1800 }) 1801 } 1802 } catch (e) {} 1803 return response 1804 }) 1805 } 1806 1807 if (typeof XMLHttpRequest !== 'undefined') { 1808 var originalOpen = XMLHttpRequest.prototype.open 1809 // Register the load listener during open(), not send(). Listeners fire in 1810 // registration order, and one added in send() is registered after anything 1811 // Octane attached between open() and send() â which lets its handler defeat 1812 // the persist by throwing or calling stopImmediatePropagation(). open() is 1813 // the earliest hook available. It is not a guarantee of running first: a 1814 // handler Octane attaches before it calls open() still precedes ours. 1815 XMLHttpRequest.prototype.open = function (method, url) { 1816 this.__octaneUrl = String(url) 1817 // open() can be called more than once on one instance. One listener is 1818 // enough, and duplicates would re-parse the same response. 1819 if (!this.__octaneListenerAttached) { 1820 this.__octaneListenerAttached = true 1821 this.addEventListener('load', function () { 1822 var requestUrl = this.__octaneUrl 1823 if (!requestUrl || !SUBMIT_PAGE_RE.test(requestUrl)) return 1824 try { 1825 beginQuizHandoff(JSON.parse(this.responseText)) 1826 } catch (e) {} 1827 }) 1828 } 1829 return originalOpen.apply(this, arguments) 1830 } 1831 } 1832 1833 // Octane's navigation to its results URL cannot be intercepted. Every member 1834 // of `Location` is [LegacyUnforgeable]: `assign`, `replace` and `href` are 1835 // own, non-configurable properties on the location instance, so they are 1836 // absent from `Location.prototype` and cannot be redefined. Patching the 1837 // prototype creates a shadowed function that never runs (verified in 1838 // Chromium, Firefox and WebKit). 1839 // 1840 // So a persist that lands after Octane navigates cannot be recovered by 1841 // holding the navigation back. Ordering is the only defense, and both 1842 // interceptors above provide it: the fetch wrapper resolves Octane's promise 1843 // only after persist completes, and the XHR listener is registered during 1844 // open() so it fires before Octane's own handler. 1845 1846 document.addEventListener( 1847 'octane.quiz.completed', 1848 function (event) { 1849 var detail = event.detail 1850 if (beginQuizHandoff(detail)) return 1851 // persistCompletion covers the case where the event detail carries 1852 // the products. In Octane's inline "results page" mode the detail is 1853 // empty but the submit_page XHR/fetch interception already stored the 1854 // result â so fall back to the stored result. 1855 if (readStoredResult()) { 1856 showHandoffOverlay() 1857 redirectToResults(detail) 1858 return 1859 } 1860 // Nothing persisted yet. Both interceptors above run before Octane can 1861 // act on the submit_page response, so reaching here means the event 1862 // came from somewhere else â wait for the persist rather than dropping 1863 // the completion. 1864 waitForStoredResult(detail) 1865 }, 1866 true 1867 ) 1868 1869 watchForFinalStep()
1870 watchOctaneIframes() 1871 bindQuizDomAnswerCapture() 1872 // Drain on /quiz/results after hard redirect (and on /quiz if nav failed). 1873 drainPendingQuizAnalytics() 1874 1875 // Signal the quiz routes that the funnel engine is installed. The pathname 1876 // guard above runs at document load, so an SPA navigation into /quiz would 1877 // otherwise leave fetch/XHR unpatched â the routes check this flag 1878 // and force one full reload to install (see ensureQuizBootstrapInstalled). 1879 window.__lumeQuizBootstrapInstalled = true 1880})()
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.