PageSourceSearch

https://lumedeodorant.com/octane-quiz-bootstrap.js

js lumedeodorant.com collected 2026-09-24 18:16:36 UTC 63,679 bytes, 1,880 lines download raw bytes

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.