export declare const TRACKER_SOURCE = "/* TeleportHQ first-party analytics tracker. Anonymous, cookieless by default. */\nconst SERVER_URL = process.env.NEXT_PUBLIC_TELEPORT_ANALYTICS_URL\nconst PUBLIC_KEY = process.env.NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY\n\nconst HEARTBEAT_INTERVAL_MS = 30000\nconst FLUSH_INTERVAL_MS = 5000\nconst FLUSH_AT_QUEUE_SIZE = 10\nconst MAX_BATCH = 25\nconst RETRY_DELAYS_MS = [1000, 5000, 15000]\nconst SESSION_WINDOW_MS = 30 * 60 * 1000\n\n// Tracker v2 budgets (see the Growth spec, \"Tracker v2 contract\").\nconst CLICK_FULL_PER_PAGE = 40\nconst RAGE_WINDOW_MS = 700\nconst RAGE_RADIUS_PX = 30\nconst RAGE_CLICKS = 3\nconst DEAD_CLICK_WAIT_MS = 500\nconst ERROR_AFTER_CLICK_MS = 1000\nconst FRICTION_PER_PAGE = 10\nconst ERRORS_PER_PAGE = 5\nconst FOLD_SAMPLE_RATE = 0.05\nconst FOLD_ELEMENTS_MAX = 10\nconst SCROLL_BANDS = 10\nconst VITALS_SEND_AFTER_MS = 10000\nconst SEARCH_DEBOUNCE_MS = 1000\nconst SEARCH_MIN_CHARS = 2\n\nconst INTERACTIVE_SELECTOR =\n 'a, button, [role=\"button\"], input[type=\"submit\"], [data-tp-event], [data-tp-track]'\nconst ELEMENT_MARKER_ATTR = 'data-tq-el'\nconst CTA_SELECTOR = 'button, input[type=\"submit\"], [data-tq-cta]'\nconst SEARCH_INPUT_SELECTOR = 'input[data-tq-search-for]'\n\nlet initialized = false\nlet disabled = false\nlet forbiddenCount = 0\n\nlet sessionId = null\nlet visitorId = null\nlet pageLoadId = null\nlet seq = 0\nlet currentPath = null\nlet initialReferrer = null\nlet utm = null\nlet landingQueryKeys = []\n// Pages the owner keeps out of analytics (admin-panel pages by default), as\n// path patterns baked in at publish time, and the site's locales so a prefixed\n// address matches the same list.\nlet excludedPaths = []\nlet siteLocales = []\n\nlet visibleSince = null\nlet visibleAccumMs = 0\nlet maxScrollPct = 0\n\nlet queue = []\nlet flushTimer = null\nlet heartbeatTimer = null\nlet retryAttempt = 0\n\n// \u2500\u2500 Per page load state (reset by resetPageMetrics) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet clicksThisPage = 0\nlet frictionThisPage = 0\nlet errorsThisPage = 0\nlet recentClicks = []\nlet rageReported = false\nlet lastClickEl = null\nlet lastClickAt = 0\nlet lastClickPath = null\nlet scrollDwell = []\nlet currentBand = 0\nlet bandSince = null\nlet formsStarted = {}\nlet formsSubmitted = {}\nlet vitalsSent = false\nlet vitalsTimer = null\nlet vitals = { lcp: null, inp: null, cls: null, ttfb: null }\nlet mutationCount = 0\nlet mutationObserver = null\nlet searchTimers = {}\n\nfunction uuid() {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID()\n }\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0\n return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)\n })\n}\n\nfunction hasConsent() {\n try {\n return window.localStorage.getItem('cookieConsent') === 'accepted'\n } catch (e) {\n return false\n }\n}\n\nfunction isTrackingPossible() {\n if (disabled || typeof window === 'undefined') {\n return false\n }\n if (!SERVER_URL || !PUBLIC_KEY) {\n return false\n }\n // Fail safe: a misconfigured deploy can leave the unresolved\n // \"teleporthq.secrets.*\" placeholder (or any non-absolute value) baked into\n // these build-time vars. Never fire requests at a non-absolute URL \u2014 that\n // would point every beacon at the host site's own origin instead of the\n // analytics API.\n if (SERVER_URL.indexOf('http') !== 0 || PUBLIC_KEY.indexOf('teleporthq.secrets.') === 0) {\n return false\n }\n const host = window.location.hostname\n if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') {\n return false\n }\n if (typeof navigator !== 'undefined' && navigator.webdriver) {\n return false\n }\n return true\n}\n\nfunction setupIdentity() {\n if (!hasConsent()) {\n // Consentless mode: nothing is stored on the device. The session id only\n // lives in JS memory; the server stitches sessions via its anonymized\n // daily visitor hash.\n sessionId = uuid()\n visitorId = null\n return\n }\n\n try {\n visitorId = readOrCreateVisitorId()\n\n const now = Date.now()\n const storedSession = window.sessionStorage.getItem('tp_sid')\n const storedAt = Number(window.sessionStorage.getItem('tp_sid_t') || 0)\n\n if (storedSession && now - storedAt < SESSION_WINDOW_MS) {\n sessionId = storedSession\n } else {\n sessionId = uuid()\n }\n storeSession(now)\n } catch (e) {\n sessionId = uuid()\n visitorId = null\n }\n}\n\nfunction readOrCreateVisitorId() {\n let id = window.localStorage.getItem('tp_aid')\n if (!id) {\n id = uuid()\n window.localStorage.setItem('tp_aid', id)\n }\n return id\n}\n\nfunction storeSession(now) {\n window.sessionStorage.setItem('tp_sid', sessionId)\n window.sessionStorage.setItem('tp_sid_t', String(now))\n}\n\n// The cookie banner's Accept click writes the consent while the tracker is\n// already running, so identity is re-checked before every send instead of only\n// at boot. The visit that accepted is then the one recognised as the visitor's\n// first, and the running session continues under the persistent id (the server\n// upgrades it instead of opening a second one). A consent that disappears stops\n// the persistent id at once, in a fresh anonymous session so nothing sent\n// afterwards joins the consented one.\nfunction syncConsentIdentity() {\n const consented = hasConsent()\n if (consented === (visitorId !== null)) {\n return\n }\n\n if (!consented) {\n sessionId = uuid()\n visitorId = null\n return\n }\n\n try {\n visitorId = readOrCreateVisitorId()\n storeSession(Date.now())\n } catch (e) {\n visitorId = null\n }\n}\n\nfunction touchSession() {\n if (visitorId === null) {\n return\n }\n try {\n window.sessionStorage.setItem('tp_sid_t', String(Date.now()))\n } catch (e) {\n /* storage unavailable */\n }\n}\n\nfunction parseUtm() {\n try {\n const params = new URLSearchParams(window.location.search)\n const read = (key) => {\n const value = params.get(key)\n return value ? value.slice(0, 255) : null\n }\n const parsed = {\n source: read('utm_source'),\n medium: read('utm_medium'),\n campaign: read('utm_campaign'),\n term: read('utm_term'),\n content: read('utm_content'),\n }\n const hasAny = Object.keys(parsed).some((key) => parsed[key])\n return hasAny ? parsed : null\n } catch (e) {\n return null\n }\n}\n\n// The landing address's query parameter NAMES, never their values: a buyer\n// back from their bank or wallet lands on one whose names say so\n// (payment=success, payment_intent with redirect_status), and the server then\n// keeps them in the visit they shopped in. Shaped and capped the way the server\n// accepts them, so no name can carry an \"=\", \"@\", \"/\" or a space.\nconst QUERY_KEY_PATTERN = /^[A-Za-z0-9_.[\\]-]{1,64}$/\nconst MAX_QUERY_KEYS = 20\n\nfunction parseQueryKeys() {\n const keys = []\n try {\n new URLSearchParams(window.location.search).forEach((_value, key) => {\n if (\n keys.length < MAX_QUERY_KEYS &&\n keys.indexOf(key) === -1 &&\n QUERY_KEY_PATTERN.test(key)\n ) {\n keys.push(key)\n }\n })\n } catch (e) {\n /* an address the browser cannot read names nothing */\n }\n return keys\n}\n\nfunction endpoint(suffix) {\n return SERVER_URL.replace(/\\/$/, '') + '/events/' + PUBLIC_KEY + (suffix || '')\n}\n\nfunction markForbidden(status) {\n if (status === 403 || status === 401) {\n forbiddenCount += 1\n if (forbiddenCount >= 3) {\n // Analytics disabled server-side \u2014 go silent for this page lifetime\n disabled = true\n queue = []\n if (flushTimer) clearInterval(flushTimer)\n if (heartbeatTimer) clearInterval(heartbeatTimer)\n }\n } else {\n forbiddenCount = 0\n }\n}\n\nfunction sendBatch(events, useBeacon) {\n if (events.length === 0) {\n return Promise.resolve(true)\n }\n\n const body = JSON.stringify({ events: events })\n const url = endpoint('/batch')\n\n // text/plain is a CORS-safelisted content type, so the request skips the\n // preflight. That preflight is what makes an application/json beacon fail on\n // page unload (the browser can't complete OPTIONS while the page is dying),\n // and it also doubles every normal batch into OPTIONS+POST. The server reads\n // the JSON body regardless of this content type.\n if (useBeacon && typeof navigator !== 'undefined' && navigator.sendBeacon) {\n try {\n const blob = new Blob([body], { type: 'text/plain;charset=UTF-8' })\n return Promise.resolve(navigator.sendBeacon(url, blob))\n } catch (e) {\n /* fall through to fetch */\n }\n }\n\n return fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'text/plain;charset=UTF-8' },\n body: body,\n keepalive: true,\n })\n .then((response) => {\n markForbidden(response.status)\n return response.status < 400\n })\n .catch(() => false)\n}\n\nfunction flush(useBeacon) {\n if (queue.length === 0 || disabled) {\n return\n }\n\n const batch = queue.slice(0, MAX_BATCH)\n queue = queue.slice(batch.length)\n\n sendBatch(batch, useBeacon).then((ok) => {\n if (ok) {\n retryAttempt = 0\n return\n }\n if (retryAttempt < RETRY_DELAYS_MS.length && !disabled) {\n // Re-queue once per backoff step, then drop \u2014 analytics is lossy-tolerant\n queue = batch.concat(queue)\n const delay = RETRY_DELAYS_MS[retryAttempt]\n retryAttempt += 1\n setTimeout(() => flush(false), delay)\n }\n })\n}\n\nfunction enqueue(event) {\n if (disabled) {\n return\n }\n queue.push(event)\n touchSession()\n if (queue.length >= FLUSH_AT_QUEUE_SIZE) {\n flush(false)\n }\n}\n\n// Arms the visitor has actually seen. The head script assigns every experiment\n// in the project (so the arm is stable before the element is on screen) and\n// publishes window.__tpExp. Counting a session for a test the visitor never\n// saw would dilute the result, so the stamp is only experiments whose element\n// is on this page, plus ones they saw earlier \u2014 a checkout page still carries\n// the arm of the hero that led there. \"id:a,id2:b\", tests on this page first.\nconst EXPERIMENT_SEEN_KEY = 'tq_exp_seen'\nconst EXPERIMENT_ID_PATTERN = /^[a-z0-9_-]{1,32}$/\n\nfunction readSeenExperiments() {\n try {\n const raw = window.localStorage.getItem(EXPERIMENT_SEEN_KEY)\n const parsed = raw ? JSON.parse(raw) : null\n if (!parsed || typeof parsed !== 'object') {\n return {}\n }\n return parsed\n } catch (e) {\n return {}\n }\n}\n\nfunction experimentIsOnPage(id) {\n try {\n return !!document.querySelector('[data-tq-exp=\"' + id + '\"]')\n } catch (e) {\n return false\n }\n}\n\nfunction experimentStamp() {\n try {\n const assigned = typeof window !== 'undefined' ? window.__tpExp : null\n if (!assigned || typeof assigned !== 'object') {\n return ''\n }\n const seen = readSeenExperiments()\n const ids = Object.keys(assigned).sort()\n const onPage = []\n const earlier = []\n const nextSeen = {}\n let changed = false\n for (let i = 0; i < ids.length; i++) {\n const id = ids[i]\n if (!EXPERIMENT_ID_PATTERN.test(id)) {\n continue\n }\n const arm = assigned[id]\n if (arm !== 'a' && arm !== 'b') {\n continue\n }\n if (experimentIsOnPage(id)) {\n nextSeen[id] = arm\n if (seen[id] !== arm) {\n changed = true\n }\n onPage.push(id + ':' + arm)\n } else if (seen[id] === 'a' || seen[id] === 'b') {\n nextSeen[id] = seen[id]\n earlier.push(id + ':' + seen[id])\n }\n }\n if (changed || Object.keys(seen).length !== Object.keys(nextSeen).length) {\n try {\n window.localStorage.setItem(EXPERIMENT_SEEN_KEY, JSON.stringify(nextSeen))\n } catch (e) {\n /* private mode: the arm still rides on this page's events */\n }\n }\n return onPage.concat(earlier).join(',')\n } catch (e) {\n return ''\n }\n}\n\nfunction baseEvent(type) {\n syncConsentIdentity()\n seq += 1\n const event = {\n type: type,\n pageLoadId: pageLoadId,\n seq: seq - 1,\n sessionId: sessionId,\n visitorId: visitorId,\n path: currentPath,\n clientTs: Date.now(),\n }\n // Every event and outcome of a page under an A/B test says which arm it saw.\n const exp = experimentStamp()\n if (exp) {\n event.exp = exp\n }\n return event\n}\n\n// The viewport class the editor's breakpoints use: phone, tablet, desktop.\nfunction viewportClass() {\n try {\n const width = window.innerWidth || (window.screen && window.screen.width) || 0\n return width < 768 ? 'm' : width < 1024 ? 't' : 'd'\n } catch (e) {\n return 'd'\n }\n}\n\nfunction visibleTimeMs() {\n let total = visibleAccumMs\n if (visibleSince !== null) {\n total += Date.now() - visibleSince\n }\n return Math.max(0, Math.round(total))\n}\n\nfunction resetPageMetrics() {\n visibleAccumMs = 0\n visibleSince = document.visibilityState === 'visible' ? Date.now() : null\n maxScrollPct = 0\n clicksThisPage = 0\n frictionThisPage = 0\n errorsThisPage = 0\n recentClicks = []\n rageReported = false\n lastClickEl = null\n lastClickAt = 0\n scrollDwell = []\n for (let i = 0; i < SCROLL_BANDS; i++) {\n scrollDwell.push(0)\n }\n currentBand = currentScrollBand()\n bandSince = visibleSince\n formsStarted = {}\n formsSubmitted = {}\n vitalsSent = false\n vitals = { lcp: null, inp: null, cls: null, ttfb: null }\n if (vitalsTimer) {\n clearTimeout(vitalsTimer)\n vitalsTimer = null\n }\n}\n\nfunction documentHeight() {\n try {\n const doc = document.documentElement\n return Math.max(doc.scrollHeight || 0, window.innerHeight || 0, 1)\n } catch (e) {\n return 1\n }\n}\n\nfunction documentWidth() {\n try {\n const doc = document.documentElement\n return Math.max(doc.scrollWidth || 0, window.innerWidth || 0, 1)\n } catch (e) {\n return 1\n }\n}\n\nfunction currentScrollPct() {\n try {\n const doc = document.documentElement\n const scrollable = doc.scrollHeight - window.innerHeight\n if (scrollable <= 0) {\n return 100\n }\n return Math.min(100, Math.round((window.scrollY / scrollable) * 100))\n } catch (e) {\n return 0\n }\n}\n\n// Which tenth of the document the middle of the viewport is in.\nfunction currentScrollBand() {\n try {\n const middle = (window.scrollY || 0) + (window.innerHeight || 0) / 2\n const band = Math.floor((middle / documentHeight()) * SCROLL_BANDS)\n return Math.max(0, Math.min(SCROLL_BANDS - 1, band))\n } catch (e) {\n return 0\n }\n}\n\n// Closes the dwell of the band the viewport has been on and opens the next.\nfunction settleScrollBand(nextBand) {\n const now = Date.now()\n if (bandSince !== null && scrollDwell.length === SCROLL_BANDS) {\n scrollDwell[currentBand] += Math.max(0, now - bandSince)\n }\n currentBand = nextBand\n bandSince = document.visibilityState === 'visible' ? now : null\n}\n\nfunction pathnameOf(url) {\n if (typeof url !== 'string' || !url) {\n return null\n }\n return url.split('?')[0].split('#')[0] || '/'\n}\n\n// \u2500\u2500 Pages kept out of analytics \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// The address without query, fragment, trailing slash or locale prefix, so\n// \"/fr/admin-panel/orders/\" is judged as \"/admin-panel/orders\".\nfunction normalizeTrackedPath(path) {\n let clean = pathnameOf(path) || '/'\n while (clean.length > 1 && clean.charAt(clean.length - 1) === '/') {\n clean = clean.slice(0, -1)\n }\n const firstSegment = clean.split('/')[1]\n if (firstSegment && siteLocales.indexOf(firstSegment) !== -1) {\n clean = clean.slice(firstSegment.length + 1) || '/'\n }\n return clean\n}\n\n// A pattern ending in \"*\" matches by prefix (a dynamic page \"/products/[id]\" is\n// published as \"/products/*\"); any other pattern matches exactly.\nfunction pathMatchesPattern(path, pattern) {\n if (typeof pattern !== 'string' || !pattern) {\n return false\n }\n if (pattern.charAt(pattern.length - 1) === '*') {\n return path.indexOf(pattern.slice(0, -1)) === 0\n }\n return path === normalizeTrackedPath(pattern)\n}\n\nfunction isPathExcluded(path) {\n if (excludedPaths.length === 0) {\n return false\n }\n const normalized = normalizeTrackedPath(path)\n for (let i = 0; i < excludedPaths.length; i++) {\n if (pathMatchesPattern(normalized, excludedPaths[i])) {\n return true\n }\n }\n return false\n}\n\n// True when a route event resolves to the page we are already tracking. The\n// pages-router emits routeChangeStart/Complete for the SAME path during initial\n// hydration (and when a link points at the current page), which would otherwise\n// double-count the pageview and emit a bogus 0ms page_leave.\nfunction samePathAsCurrent(url) {\n const next = pathnameOf(url)\n return currentPath !== null && next !== null && next === currentPath\n}\n\nfunction trackPageview(isFirstLoad) {\n if (!isTrackingPossible()) {\n return\n }\n\n const newPath = window.location.pathname || '/'\n // Defensive same-path guard (the route handlers also guard on the router's\n // destination url): never emit a second pageview for the page already shown.\n if (!isFirstLoad && currentPath !== null && newPath === currentPath) {\n return\n }\n\n if (isPathExcluded(newPath)) {\n // No page identity on a page kept out of analytics: clicks, scroll,\n // heartbeats, leaves and commerce steps all stay silent (they guard on\n // pageLoadId) until the visitor reaches a tracked page.\n currentPath = newPath\n pageLoadId = null\n return\n }\n\n pageLoadId = uuid()\n seq = 0\n currentPath = newPath\n resetPageMetrics()\n\n const event = baseEvent('pageview')\n event.referrer = isFirstLoad ? (document.referrer || null) : null\n event.utm = isFirstLoad ? utm : null\n if (isFirstLoad && landingQueryKeys.length > 0) {\n event.queryKeys = landingQueryKeys\n }\n event.screenW = window.screen && window.screen.width ? window.screen.width : null\n event.vc = viewportClass()\n enqueue(event)\n\n scheduleVitals()\n sampleFold()\n}\n\nfunction trackLeave(useBeacon) {\n if (!isTrackingPossible() || !pageLoadId) {\n return\n }\n\n settleScrollBand(currentBand)\n sendVitals()\n reportAbandonedForms()\n\n const event = baseEvent('page_leave')\n event.timeOnPageMs = visibleTimeMs()\n event.maxScrollPct = maxScrollPct\n event.scrollDwell = scrollDwell.slice(0, SCROLL_BANDS)\n event.vc = viewportClass()\n enqueue(event)\n\n if (useBeacon) {\n flush(true)\n }\n}\n\nfunction sendHeartbeat() {\n if (!isTrackingPossible() || document.visibilityState !== 'visible' || !pageLoadId) {\n return\n }\n syncConsentIdentity()\n\n // Heartbeats go direct (never queued) \u2014 a stale heartbeat is worthless.\n // text/plain keeps this preflight-free too (see sendBatch).\n fetch(endpoint('/heartbeat'), {\n method: 'POST',\n headers: { 'Content-Type': 'text/plain;charset=UTF-8' },\n body: JSON.stringify({\n sessionId: sessionId,\n visitorId: visitorId,\n pageLoadId: pageLoadId,\n path: currentPath,\n }),\n keepalive: true,\n })\n .then((response) => markForbidden(response.status))\n .catch(() => undefined)\n}\n\n// \u2500\u2500 Element identity \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// The marker the editor stamped at publish on the elements that matter.\nfunction markerOf(element) {\n try {\n const marked = element && element.closest ? element.closest('[' + ELEMENT_MARKER_ATTR + ']') : null\n if (!marked) {\n return { el: null, element: null }\n }\n const value = marked.getAttribute(ELEMENT_MARKER_ATTR)\n return value && /^[a-z0-9]{6,8}$/.test(value) ? { el: value, element: marked } : { el: null, element: null }\n } catch (e) {\n return { el: null, element: null }\n }\n}\n\nfunction elementPoint(domEvent, markedElement) {\n try {\n const point = {\n ex: null,\n ey: null,\n px: Math.round(((window.scrollX || 0) + domEvent.clientX) / documentWidth() * 1000) / 1000,\n py: Math.round(((window.scrollY || 0) + domEvent.clientY) / documentHeight() * 1000) / 1000,\n vw: window.innerWidth || null,\n vh: window.innerHeight || null,\n }\n if (markedElement && markedElement.getBoundingClientRect) {\n const rect = markedElement.getBoundingClientRect()\n if (rect.width > 0 && rect.height > 0) {\n point.ex = Math.round(((domEvent.clientX - rect.left) / rect.width) * 1000) / 1000\n point.ey = Math.round(((domEvent.clientY - rect.top) / rect.height) * 1000) / 1000\n }\n }\n return point\n } catch (e) {\n return null\n }\n}\n\nfunction hrefOf(target) {\n if (!target || target.tagName !== 'A' || !target.getAttribute('href')) {\n return null\n }\n const raw = target.getAttribute('href')\n if (raw.indexOf('http') === 0) {\n try {\n const parsed = new URL(raw)\n return parsed.origin === window.location.origin ? parsed.pathname : raw.slice(0, 512)\n } catch (e) {\n return raw.slice(0, 512)\n }\n }\n return raw.split('?')[0].split('#')[0].slice(0, 512) || null\n}\n\n// \u2500\u2500 Friction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction emitFriction(kind, el, point) {\n if (frictionThisPage >= FRICTION_PER_PAGE || !pageLoadId) {\n return\n }\n frictionThisPage += 1\n const event = baseEvent('friction')\n event.kind = kind\n event.el = el\n event.vc = viewportClass()\n if (point) {\n event.point = point\n }\n enqueue(event)\n}\n\n// Three clicks within the window and the radius: the visitor is hammering\n// something that does not answer. Reported once per cluster.\nfunction detectRage(domEvent, el, point) {\n const now = Date.now()\n recentClicks.push({ t: now, x: domEvent.clientX, y: domEvent.clientY })\n recentClicks = recentClicks.filter((click) => now - click.t <= RAGE_WINDOW_MS)\n if (recentClicks.length < RAGE_CLICKS) {\n rageReported = false\n return\n }\n const first = recentClicks[0]\n const nearby = recentClicks.every(\n (click) => Math.abs(click.x - first.x) <= RAGE_RADIUS_PX && Math.abs(click.y - first.y) <= RAGE_RADIUS_PX\n )\n if (nearby && !rageReported) {\n rageReported = true\n emitFriction('rage', el, point)\n }\n if (!nearby) {\n rageReported = false\n }\n}\n\nfunction looksClickable(target) {\n try {\n if (target.closest && target.closest(INTERACTIVE_SELECTOR)) {\n return true\n }\n const style = window.getComputedStyle ? window.getComputedStyle(target) : null\n return !!style && style.cursor === 'pointer'\n } catch (e) {\n return false\n }\n}\n\n// A click that changed nothing: no navigation, no focus change and no DOM\n// mutation anywhere on the page within the wait. Mutations are counted\n// page-wide, not under the clicked node, because the thing a click opens (a\n// menu, a dialog) usually lives elsewhere in the tree \u2014 and the clicked node\n// itself may be gone by then.\nfunction scheduleDeadCheck(el, point) {\n const pathBefore = window.location.pathname\n const focusBefore = document.activeElement\n const mutationsBefore = mutationCount\n setTimeout(() => {\n try {\n if (!pageLoadId || window.location.pathname !== pathBefore) {\n return\n }\n if (document.activeElement !== focusBefore) {\n return\n }\n if (mutationCount !== mutationsBefore) {\n return\n }\n emitFriction('dead', el, point)\n } catch (e) {\n /* never break the host page */\n }\n }, DEAD_CLICK_WAIT_MS)\n}\n\nfunction startMutationObserver() {\n try {\n if (mutationObserver || typeof MutationObserver === 'undefined' || !document.body) {\n return\n }\n mutationObserver = new MutationObserver(() => {\n mutationCount += 1\n })\n mutationObserver.observe(document.body, {\n childList: true,\n attributes: true,\n characterData: true,\n subtree: true,\n })\n } catch (e) {\n /* observer unavailable */\n }\n}\n\nfunction onClickCapture(domEvent) {\n if (!isTrackingPossible() || !pageLoadId) {\n return\n }\n\n try {\n const target = domEvent.target\n if (!target || !target.closest) {\n return\n }\n const interactive = target.closest(INTERACTIVE_SELECTOR)\n const marker = markerOf(target)\n const point = elementPoint(domEvent, marker.element)\n\n lastClickEl = marker.el\n lastClickAt = Date.now()\n lastClickPath = currentPath\n\n detectRage(domEvent, marker.el, point)\n if (looksClickable(target)) {\n scheduleDeadCheck(marker.el, point)\n }\n\n clicksThisPage += 1\n // Past the per-page budget only every other non-interactive click is\n // kept; interactive ones always are (they are what the reports name).\n if (!interactive && clicksThisPage > CLICK_FULL_PER_PAGE && clicksThisPage % 2 === 1) {\n return\n }\n\n const source = interactive || target\n const namedAttr = source.getAttribute ? source.getAttribute('data-tp-event') : null\n\n const event = baseEvent('click')\n event.el = marker.el\n event.vc = viewportClass()\n event.point = point\n event.click = {\n tag: source.tagName ? source.tagName.toLowerCase().slice(0, 16) : null,\n id: interactive && source.id ? source.id.slice(0, 64) : null,\n // The clicked element's own classes only, space-separated and capped, so a\n // funnel step can match \".add-to-cart\" or \"button.cta\" (classList covers\n // SVG elements too, where className is not a string).\n cls:\n interactive && source.classList && source.classList.length\n ? Array.prototype.slice.call(source.classList).join(' ').slice(0, 255)\n : null,\n // Text only for interactive elements: a button's label is the owner's\n // copy; a paragraph's text could be anything the visitor is reading.\n text: interactive && source.textContent ? source.textContent.trim().slice(0, 64) : null,\n href: interactive ? hrefOf(source) : null,\n name: namedAttr && /^[a-zA-Z0-9_-]{1,64}$/.test(namedAttr) ? namedAttr : null,\n }\n enqueue(event)\n } catch (e) {\n /* never break the host page */\n }\n}\n\n// \u2500\u2500 Forms (names only, never values) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction formOf(target) {\n try {\n return target && target.closest ? target.closest('form') : null\n } catch (e) {\n return null\n }\n}\n\nfunction formKey(form) {\n const marker = markerOf(form)\n return marker.el || form.id || form.getAttribute('name') || 'form'\n}\n\nfunction fieldName(target) {\n try {\n const name = target.getAttribute('name') || target.id || markerOf(target).el\n return name ? String(name).slice(0, 64) : null\n } catch (e) {\n return null\n }\n}\n\nfunction emitForm(kind, form, field) {\n if (!pageLoadId) {\n return\n }\n const key = formKey(form)\n const event = baseEvent('form')\n event.kind = kind\n event.el = markerOf(form).el\n event.vc = viewportClass()\n if (field) {\n event.field = field\n }\n if (formsStarted[key]) {\n event.ms = Math.max(0, Date.now() - formsStarted[key])\n }\n enqueue(event)\n}\n\nfunction onFocusIn(domEvent) {\n if (!isTrackingPossible() || !pageLoadId) {\n return\n }\n try {\n const form = formOf(domEvent.target)\n if (!form) {\n return\n }\n const key = formKey(form)\n if (!formsStarted[key]) {\n formsStarted[key] = Date.now()\n emitForm('start', form, null)\n }\n const field = fieldName(domEvent.target)\n if (field) {\n emitForm('focus', form, field)\n }\n } catch (e) {\n /* never break the host page */\n }\n}\n\nfunction onFocusOut(domEvent) {\n if (!isTrackingPossible() || !pageLoadId) {\n return\n }\n try {\n const form = formOf(domEvent.target)\n const field = form ? fieldName(domEvent.target) : null\n if (form && field && formsStarted[formKey(form)]) {\n emitForm('blur', form, field)\n }\n } catch (e) {\n /* never break the host page */\n }\n}\n\nfunction onInvalid(domEvent) {\n if (!isTrackingPossible() || !pageLoadId) {\n return\n }\n try {\n const form = formOf(domEvent.target)\n const field = form ? fieldName(domEvent.target) : null\n if (form && field) {\n emitForm('error', form, field)\n }\n } catch (e) {\n /* never break the host page */\n }\n}\n\nfunction onSubmitCapture(domEvent) {\n if (!isTrackingPossible() || !pageLoadId) {\n return\n }\n try {\n const form = domEvent.target && domEvent.target.tagName === 'FORM' ? domEvent.target : null\n if (!form) {\n return\n }\n const key = formKey(form)\n if (!formsStarted[key]) {\n formsStarted[key] = Date.now()\n }\n formsSubmitted[key] = true\n emitForm('submit', form, null)\n flush(false)\n } catch (e) {\n /* never break the host page */\n }\n}\n\n// On leave: every form that was started and never sent is an abandon, named\n// after the field the visitor touched last.\nfunction reportAbandonedForms() {\n try {\n const forms = document.querySelectorAll ? document.querySelectorAll('form') : []\n for (let i = 0; i < forms.length; i++) {\n const form = forms[i]\n const key = formKey(form)\n if (formsStarted[key] && !formsSubmitted[key]) {\n const active = document.activeElement\n const field = active && formOf(active) === form ? fieldName(active) : null\n emitForm('abandon', form, field)\n }\n }\n } catch (e) {\n /* never break the host page */\n }\n}\n\n// \u2500\u2500 Speed as the visitor saw it \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction observeVitals() {\n if (typeof PerformanceObserver === 'undefined') {\n return\n }\n const observe = (type, handler, options) => {\n try {\n const observer = new PerformanceObserver((list) => {\n try {\n handler(list.getEntries())\n } catch (e) {\n /* ignore */\n }\n })\n observer.observe(Object.assign({ type: type, buffered: true }, options || {}))\n } catch (e) {\n /* this entry type is not supported here */\n }\n }\n observe('largest-contentful-paint', (entries) => {\n const last = entries[entries.length - 1]\n if (last) {\n vitals.lcp = Math.round(last.startTime)\n }\n })\n observe('layout-shift', (entries) => {\n for (let i = 0; i < entries.length; i++) {\n if (!entries[i].hadRecentInput) {\n vitals.cls = Math.round(((vitals.cls || 0) + entries[i].value) * 1000) / 1000\n }\n }\n })\n observe(\n 'event',\n (entries) => {\n for (let i = 0; i < entries.length; i++) {\n const duration = Math.round(entries[i].duration)\n if (vitals.inp === null || duration > vitals.inp) {\n vitals.inp = duration\n }\n }\n },\n { durationThreshold: 40 }\n )\n try {\n const navigation = performance.getEntriesByType('navigation')[0]\n if (navigation && navigation.responseStart) {\n vitals.ttfb = Math.round(navigation.responseStart)\n }\n } catch (e) {\n /* no navigation timing */\n }\n}\n\nfunction scheduleVitals() {\n if (vitalsTimer) {\n clearTimeout(vitalsTimer)\n }\n vitalsTimer = setTimeout(sendVitals, VITALS_SEND_AFTER_MS)\n}\n\nfunction sendVitals() {\n if (vitalsSent || !pageLoadId) {\n return\n }\n if (vitals.lcp === null && vitals.inp === null && vitals.cls === null && vitals.ttfb === null) {\n return\n }\n vitalsSent = true\n const event = baseEvent('vitals')\n event.vitals = { lcp: vitals.lcp, inp: vitals.inp, cls: vitals.cls, ttfb: vitals.ttfb }\n event.vc = viewportClass()\n enqueue(event)\n}\n\n// \u2500\u2500 Script errors (message only, never a stack) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction reportError(message, source, line) {\n if (!isTrackingPossible() || !pageLoadId || errorsThisPage >= ERRORS_PER_PAGE) {\n return\n }\n errorsThisPage += 1\n const now = Date.now()\n const afterClick = lastClickAt && now - lastClickAt <= ERROR_AFTER_CLICK_MS && lastClickPath === currentPath\n const event = baseEvent('error')\n event.error = {\n message: String(message || 'Error').slice(0, 200),\n source: source ? String(source).slice(0, 255) : null,\n line: typeof line === 'number' ? line : null,\n }\n event.el = afterClick ? lastClickEl : null\n event.vc = viewportClass()\n enqueue(event)\n if (afterClick) {\n emitFriction('error', lastClickEl, null)\n }\n}\n\nfunction onWindowError(domEvent) {\n try {\n reportError(domEvent && domEvent.message, domEvent && domEvent.filename, domEvent && domEvent.lineno)\n } catch (e) {\n /* never break the host page */\n }\n}\n\nfunction onUnhandledRejection(domEvent) {\n try {\n const reason = domEvent && domEvent.reason\n reportError(reason && reason.message ? reason.message : String(reason), null, null)\n } catch (e) {\n /* never break the host page */\n }\n}\n\n// \u2500\u2500 Where the calls to action sit on first paint (1 page load in 20) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction sampleFold() {\n if (Math.random() >= FOLD_SAMPLE_RATE) {\n return\n }\n const loadId = pageLoadId\n const measure = () => {\n try {\n if (pageLoadId !== loadId || !document.querySelectorAll) {\n return\n }\n const elements = document.querySelectorAll(CTA_SELECTOR)\n const vh = window.innerHeight || 1\n let emitted = 0\n for (let i = 0; i < elements.length && emitted < FOLD_ELEMENTS_MAX; i++) {\n const marker = markerOf(elements[i])\n if (!marker.el || marker.element !== elements[i]) {\n continue\n }\n const rect = elements[i].getBoundingClientRect()\n if (rect.width === 0 && rect.height === 0) {\n continue\n }\n const event = baseEvent('fold')\n event.el = marker.el\n event.y = Math.max(0, Math.min(300, Math.round(((rect.top + (window.scrollY || 0)) / vh) * 100)))\n event.vc = viewportClass()\n enqueue(event)\n emitted += 1\n }\n } catch (e) {\n /* never break the host page */\n }\n }\n if (typeof window.requestAnimationFrame === 'function') {\n window.requestAnimationFrame(() => window.requestAnimationFrame(measure))\n } else {\n setTimeout(measure, 0)\n }\n}\n\n// \u2500\u2500 Outcomes (what the visit produced) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Money outcomes (purchase, booking_made) are never sent from the browser \u2014\n// the site's server reports them with its own key \u2014 so a visitor can neither\n// invent revenue nor block it.\nconst SERVER_ONLY_OUTCOMES = ['purchase', 'booking_made']\nconst FLUSH_NOW_OUTCOMES = ['lead', 'signup', 'newsletter', 'booking_blocked']\n\n// One submit of a form is one lead, however many handlers report it: the\n// platform's own submission handler AND every workflow bound to the form\n// (Growth's \"save the sender as a contact\", the owner's own) each call this on\n// the same submit. The same form within this window is the same submit.\nconst LEAD_DEDUPE_MS = 2000\nconst lastLeadAt = {}\n\nfunction claimLead(formId) {\n if (!formId) {\n return true\n }\n const now = Date.now()\n const previous = lastLeadAt[formId]\n if (previous != null && now - previous < LEAD_DEDUPE_MS) {\n return false\n }\n lastLeadAt[formId] = now\n return true\n}\n\nfunction trackOutcome(name, detail) {\n try {\n if (typeof name !== 'string' || !name || SERVER_ONLY_OUTCOMES.indexOf(name) !== -1) {\n return\n }\n if (!initialized || !isTrackingPossible() || !pageLoadId) {\n return\n }\n const info = detail && typeof detail === 'object' ? detail : {}\n if (name === 'lead' && !claimLead(info.recordId != null ? String(info.recordId) : info.rid != null ? String(info.rid) : '')) {\n return\n }\n const event = baseEvent('outcome')\n event.name = name\n if (info.value != null) event.value = info.value\n if (info.currency) event.currency = info.currency\n if (info.outcomeId || info.id) event.outcomeId = String(info.outcomeId || info.id)\n if (info.recordId != null || info.rid != null) event.rid = String(info.recordId != null ? info.recordId : info.rid)\n if (info.query != null || info.q != null) event.q = String(info.query != null ? info.query : info.q).slice(0, 64)\n if (info.results != null) event.results = info.results\n enqueue(event)\n if (FLUSH_NOW_OUTCOMES.indexOf(name) !== -1) {\n flush(true)\n }\n } catch (e) {\n /* never break the host page */\n }\n}\n\n// The ids a workflow stamps on its trigger context, so a server segment that\n// reports an order can attribute it to this very visit.\nfunction analyticsIds() {\n // No pageLoadId: a page kept out of analytics, like the store's admin. Its\n // visitor is the merchant, who must never be credited with the sale.\n if (!initialized || !isTrackingPossible() || !pageLoadId) {\n return null\n }\n syncConsentIdentity()\n return {\n sessionId: sessionId,\n visitorId: visitorId,\n pageLoadId: pageLoadId,\n path: currentPath,\n // The arm of the page the order or booking was placed from, so a server\n // outcome counts for the same experiment as the clicks that led to it.\n exp: experimentStamp() || null,\n }\n}\n\n// \u2500\u2500 On-site search (the typed words, debounced; results when the box says) \u2500\u2500\u2500\nfunction onSearchInput(domEvent) {\n if (!isTrackingPossible() || !pageLoadId) {\n return\n }\n try {\n const input = domEvent.target\n if (!input || !input.matches || !input.matches(SEARCH_INPUT_SELECTOR)) {\n return\n }\n const key = input.getAttribute('data-tq-search-for') || 'search'\n if (searchTimers[key]) {\n clearTimeout(searchTimers[key])\n }\n searchTimers[key] = setTimeout(() => {\n try {\n const query = String(input.value || '').trim()\n if (query.length < SEARCH_MIN_CHARS) {\n return\n }\n const resultsAttr = input.getAttribute('data-tq-search-results')\n const results = resultsAttr != null && resultsAttr !== '' && isFinite(Number(resultsAttr)) ? Number(resultsAttr) : null\n trackOutcome('search', { query: query, results: results })\n } catch (e) {\n /* never break the host page */\n }\n }, SEARCH_DEBOUNCE_MS)\n } catch (e) {\n /* never break the host page */\n }\n}\n\n// \u2500\u2500 Owner mode: the published page with the owner's heatmap drawn on it \u2500\u2500\u2500\u2500\u2500\n// The editor's \"View on live site\" opens the page with a short-lived, signed\n// token in the fragment. The fragment never reaches any server; the token is\n// read once and removed from the address; nothing is tracked for this view\n// (the owner's own clicks would pollute the map); and the map is fetched from\n// the public heatmap route and drawn over the page, for the viewport class the\n// owner is looking at \u2014 resize the window to see the phone map.\nconst HEAT_HASH_PREFIX = '#tq-heat='\nconst HEAT_RANGE_DAYS = 30\nconst HEAT_DENSITY_WIDTH = 600\nconst HEAT_DENSITY_MAX_HEIGHT = 6000\nconst HEAT_SPOT_RADIUS = 18\nconst HEAT_LABELED_SHARE_MIN = 1\nconst HEAT_BANDS = 10\nconst HEAT_Z_INDEX = 2147483000\nconst HEAT_RELAYOUT_MS = 200\nconst HEAT_ROOT_ATTR = 'data-tq-heatmap'\n\nlet ownerHeatmapToken = null\nlet ownerHeatmap = null\n\nfunction readOwnerHeatmapToken() {\n try {\n const hash = window.location.hash || ''\n if (hash.indexOf(HEAT_HASH_PREFIX) !== 0) {\n return null\n }\n const token = decodeURIComponent(hash.slice(HEAT_HASH_PREFIX.length))\n if (window.history && window.history.replaceState) {\n window.history.replaceState(null, '', window.location.pathname + window.location.search)\n }\n return token || null\n } catch (e) {\n return null\n }\n}\n\nfunction heatmapUrl(token, path, vc) {\n const to = new Date()\n const from = new Date(to.getTime() - HEAT_RANGE_DAYS * 86400000)\n let tz = 'UTC'\n try {\n tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'\n } catch (e) {\n /* UTC */\n }\n return (\n SERVER_URL.replace(/\\/$/, '') +\n '/stats/public/heatmap/' +\n PUBLIC_KEY +\n '?token=' +\n encodeURIComponent(token) +\n '&path=' +\n encodeURIComponent(path) +\n '&vc=' +\n vc +\n '&from=' +\n encodeURIComponent(from.toISOString()) +\n '&to=' +\n encodeURIComponent(to.toISOString()) +\n '&tz=' +\n encodeURIComponent(tz)\n )\n}\n\n// Cool to hot \u2014 blue, green, yellow, red \u2014 by the summed alpha, 0\u20131.\nconst HEAT_RAMP = [\n [0, 59, 130, 246],\n [0.35, 34, 197, 94],\n [0.65, 250, 204, 21],\n [1, 239, 68, 68],\n]\n\nfunction heatRampColor(value) {\n const v = Math.max(0, Math.min(1, value))\n for (let i = 1; i < HEAT_RAMP.length; i++) {\n const upper = HEAT_RAMP[i]\n if (v <= upper[0]) {\n const lower = HEAT_RAMP[i - 1]\n const span = upper[0] - lower[0]\n const t = span > 0 ? (v - lower[0]) / span : 0\n return [\n Math.round(lower[1] + (upper[1] - lower[1]) * t),\n Math.round(lower[2] + (upper[2] - lower[2]) * t),\n Math.round(lower[3] + (upper[3] - lower[3]) * t),\n ]\n }\n }\n const last = HEAT_RAMP[HEAT_RAMP.length - 1]\n return [last[1], last[2], last[3]]\n}\n\n// The alpha one spot adds: strong for a few points, fainter as they pile up.\nfunction heatSpotAlpha(count) {\n if (count <= 0) {\n return 0\n }\n return Math.min(0.6, Math.max(0.04, 3 / Math.sqrt(count)))\n}\n\nfunction drawHeatDensity(canvas, points, width, height) {\n canvas.width = Math.max(1, Math.round(width))\n canvas.height = Math.max(1, Math.round(height))\n const context = canvas.getContext('2d')\n if (!context) {\n return\n }\n context.clearRect(0, 0, canvas.width, canvas.height)\n if (!points.length) {\n return\n }\n const alpha = heatSpotAlpha(points.length)\n const radius = HEAT_SPOT_RADIUS\n for (let i = 0; i < points.length; i++) {\n const x = (points[i].px / 1000) * canvas.width\n const y = (points[i].py / 1000) * canvas.height\n const gradient = context.createRadialGradient(x, y, 0, x, y, radius)\n gradient.addColorStop(0, 'rgba(0, 0, 0, ' + alpha + ')')\n gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')\n context.fillStyle = gradient\n context.fillRect(x - radius, y - radius, radius * 2, radius * 2)\n }\n const image = context.getImageData(0, 0, canvas.width, canvas.height)\n const data = image.data\n for (let offset = 0; offset < data.length; offset += 4) {\n const summed = data[offset + 3] / 255\n if (summed === 0) {\n continue\n }\n const rgb = heatRampColor(summed)\n data[offset] = rgb[0]\n data[offset + 1] = rgb[1]\n data[offset + 2] = rgb[2]\n data[offset + 3] = Math.round(Math.min(1, 0.15 + summed * 0.85) * 255)\n }\n context.putImageData(image, 0, 0)\n}\n\nfunction heatNode(tag, cssText, text) {\n const node = document.createElement(tag)\n node.style.cssText = cssText\n if (text) {\n node.textContent = text\n }\n return node\n}\n\nfunction heatButton(label, onClick) {\n const button = heatNode(\n 'button',\n 'pointer-events:auto;border:1px solid #9ca3af;background:#fff;color:#111827;border-radius:6px;padding:4px 10px;font:12px/1.2 system-ui,sans-serif;cursor:pointer',\n label\n )\n button.type = 'button'\n button.addEventListener('click', onClick)\n return button\n}\n\nfunction removeOwnerHeatmap() {\n if (!ownerHeatmap) {\n return\n }\n try {\n if (ownerHeatmap.root.parentNode) {\n ownerHeatmap.root.parentNode.removeChild(ownerHeatmap.root)\n }\n if (ownerHeatmap.legend.parentNode) {\n ownerHeatmap.legend.parentNode.removeChild(ownerHeatmap.legend)\n }\n window.removeEventListener('resize', ownerHeatmap.onResize)\n } catch (e) {\n /* nothing to undo */\n }\n ownerHeatmap = null\n}\n\nfunction vcLabel(vc) {\n return vc === 'm' ? 'Mobile' : vc === 't' ? 'Tablet' : 'Desktop'\n}\n\n// Lays the map out over the document: the density canvas (or the scroll\n// bands), and a labelled box on every element with a share of the clicks.\n// Runs again, debounced, when the window resizes.\nfunction layoutOwnerHeatmap() {\n const state = ownerHeatmap\n if (!state) {\n return\n }\n const width = documentWidth()\n const height = documentHeight()\n const root = state.root\n root.style.width = width + 'px'\n root.style.height = height + 'px'\n while (root.firstChild) {\n root.removeChild(root.firstChild)\n }\n\n if (state.mode === 'clicks') {\n const canvas = heatNode('canvas', 'position:absolute;inset:0;width:100%;height:100%')\n root.appendChild(canvas)\n const ratio = height / Math.max(1, width)\n drawHeatDensity(\n canvas,\n state.data.points,\n HEAT_DENSITY_WIDTH,\n Math.min(HEAT_DENSITY_MAX_HEIGHT, Math.max(1, HEAT_DENSITY_WIDTH * ratio))\n )\n const elements = state.data.elements || []\n const scrollX = window.scrollX || 0\n const scrollY = window.scrollY || 0\n for (let i = 0; i < elements.length; i++) {\n const element = elements[i]\n if (element.share < HEAT_LABELED_SHARE_MIN) {\n continue\n }\n const target = document.querySelector('[' + ELEMENT_MARKER_ATTR + '=\"' + element.el + '\"]')\n if (!target) {\n continue\n }\n const rect = target.getBoundingClientRect()\n const warning = element.rage > 0 || element.dead > 0\n const color = warning ? '#d97706' : '#2563eb'\n const box = heatNode(\n 'div',\n 'position:absolute;box-sizing:border-box;border:2px solid ' +\n color +\n ';left:' +\n (rect.left + scrollX) +\n 'px;top:' +\n (rect.top + scrollY) +\n 'px;width:' +\n rect.width +\n 'px;height:' +\n rect.height +\n 'px'\n )\n const label =\n element.share.toFixed(element.share < 10 ? 1 : 0) +\n '% \u00B7 ' +\n element.clicks +\n ' clicks' +\n (element.rage > 0 ? ' \u00B7 ' + element.rage + ' rage' : '') +\n (element.dead > 0 ? ' \u00B7 ' + element.dead + ' dead' : '')\n box.appendChild(\n heatNode(\n 'span',\n 'position:absolute;left:-2px;top:0;transform:translateY(-100%);padding:2px 8px;border-radius:4px 4px 0 0;background:' +\n color +\n ';color:#fff;font:12px/1.4 system-ui,sans-serif;white-space:nowrap',\n label\n )\n )\n root.appendChild(box)\n }\n } else {\n const bands = state.data.scroll || []\n const reached = []\n for (let i = 0; i < HEAT_BANDS; i++) {\n reached.push(0)\n }\n for (let i = 0; i < bands.length; i++) {\n if (bands[i].band >= 0 && bands[i].band < HEAT_BANDS) {\n reached[bands[i].band] = bands[i].reachedPct\n }\n }\n for (let i = 0; i < HEAT_BANDS; i++) {\n const band = heatNode(\n 'div',\n 'position:absolute;left:0;right:0;top:' +\n (i * 100) / HEAT_BANDS +\n '%;height:' +\n 100 / HEAT_BANDS +\n '%;border-top:1px dashed rgba(255,255,255,0.6);background:rgba(239,68,68,' +\n (0.08 + (reached[i] / 100) * 0.42) +\n ')'\n )\n band.appendChild(\n heatNode(\n 'span',\n 'position:absolute;top:6px;right:10px;padding:2px 8px;border-radius:4px;background:#111827;color:#f9fafb;font:12px/1.4 system-ui,sans-serif;white-space:nowrap',\n Math.round(reached[i]) + '% reach here'\n )\n )\n root.appendChild(band)\n }\n }\n}\n\nfunction renderOwnerHeatmap(data, vc) {\n removeOwnerHeatmap()\n if (!document.body) {\n return\n }\n const root = heatNode(\n 'div',\n 'position:absolute;left:0;top:0;pointer-events:none;z-index:' + HEAT_Z_INDEX\n )\n root.setAttribute(HEAT_ROOT_ATTR, '')\n const legend = heatNode(\n 'div',\n 'position:fixed;left:12px;top:12px;display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:8px;background:rgba(17,24,39,0.92);color:#f9fafb;font:12px/1.4 system-ui,sans-serif;z-index:' +\n (HEAT_Z_INDEX + 1) +\n ';pointer-events:auto;box-shadow:0 2px 8px rgba(0,0,0,0.3)'\n )\n legend.setAttribute(HEAT_ROOT_ATTR, 'legend')\n const text = heatNode('span', '')\n const toggle = heatButton('', function () {\n if (!ownerHeatmap) {\n return\n }\n ownerHeatmap.mode = ownerHeatmap.mode === 'clicks' ? 'scroll' : 'clicks'\n updateLegend()\n layoutOwnerHeatmap()\n })\n const close = heatButton('Close', removeOwnerHeatmap)\n legend.appendChild(text)\n legend.appendChild(toggle)\n legend.appendChild(close)\n\n let resizeTimer = null\n const onResize = function () {\n if (resizeTimer) {\n clearTimeout(resizeTimer)\n }\n resizeTimer = setTimeout(layoutOwnerHeatmap, HEAT_RELAYOUT_MS)\n }\n\n ownerHeatmap = { root: root, legend: legend, data: data, mode: 'clicks', onResize: onResize }\n\n function updateLegend() {\n const clicks = (data.points || []).length\n const sample = data.sampleRate < 1 ? ' (' + Math.round(data.sampleRate * 100) + '% sample)' : ''\n text.textContent =\n (ownerHeatmap.mode === 'clicks' ? 'Clicks' : 'Scroll') +\n ' \u00B7 ' +\n vcLabel(vc) +\n ' \u00B7 Last ' +\n HEAT_RANGE_DAYS +\n ' days \u00B7 ' +\n (clicks ? clicks + ' clicks' + sample : 'no clicks yet') +\n ' \u00B7 resize the window for another device'\n toggle.textContent = ownerHeatmap.mode === 'clicks' ? 'Show scroll' : 'Show clicks'\n }\n\n updateLegend()\n document.body.appendChild(root)\n document.body.appendChild(legend)\n window.addEventListener('resize', onResize)\n layoutOwnerHeatmap()\n}\n\n// Fetches this page's map for the viewport class in use and draws it.\n// Resolves false when there is nothing to draw (no token, bad token, the\n// owner looking at a page the site never served) and never throws.\nexport function showOwnerHeatmap(token) {\n if (typeof window === 'undefined' || !token || !SERVER_URL || !PUBLIC_KEY) {\n return Promise.resolve(false)\n }\n const vc = viewportClass()\n const path = window.location.pathname || '/'\n return fetch(heatmapUrl(token, path, vc), { method: 'GET' })\n .then(function (response) {\n if (!response || response.status >= 400 || typeof response.json !== 'function') {\n throw new Error('heatmap unavailable')\n }\n return response.json()\n })\n .then(function (data) {\n renderOwnerHeatmap(data, vc)\n return true\n })\n .catch(function () {\n return false\n })\n}\n\nexport function initTeleportAnalytics(options) {\n if (initialized || typeof window === 'undefined') {\n return\n }\n initialized = true\n\n if (options && Array.isArray(options.excludedPaths)) {\n excludedPaths = options.excludedPaths\n }\n if (options && Array.isArray(options.locales)) {\n siteLocales = options.locales\n }\n\n ownerHeatmapToken = readOwnerHeatmapToken()\n if (ownerHeatmapToken) {\n // The owner reading the map is not a visit: nothing is tracked for this\n // view, and the map is drawn instead.\n disabled = true\n showOwnerHeatmap(ownerHeatmapToken)\n return\n }\n\n if (!isTrackingPossible()) {\n return\n }\n\n setupIdentity()\n utm = parseUtm()\n observeVitals()\n startMutationObserver()\n landingQueryKeys = parseQueryKeys()\n\n document.addEventListener('click', onClickCapture, true)\n document.addEventListener('focusin', onFocusIn, true)\n document.addEventListener('focusout', onFocusOut, true)\n document.addEventListener('invalid', onInvalid, true)\n document.addEventListener('submit', onSubmitCapture, true)\n document.addEventListener('input', onSearchInput, true)\n window.addEventListener('error', onWindowError)\n window.addEventListener('unhandledrejection', onUnhandledRejection)\n\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'visible') {\n visibleSince = Date.now()\n bandSince = visibleSince\n sendHeartbeat()\n } else {\n if (visibleSince !== null) {\n visibleAccumMs += Date.now() - visibleSince\n visibleSince = null\n }\n settleScrollBand(currentBand)\n sendVitals()\n flush(true)\n }\n })\n\n window.addEventListener(\n 'scroll',\n () => {\n const pct = currentScrollPct()\n if (pct > maxScrollPct) {\n maxScrollPct = pct\n }\n const band = currentScrollBand()\n if (band !== currentBand) {\n settleScrollBand(band)\n }\n },\n { passive: true }\n )\n\n // pagehide covers tab close, navigation away and most mobile terminations.\n // When even this never fires (power loss, process kill), the server's\n // heartbeat window + session finalizer close the visit.\n window.addEventListener('pagehide', () => {\n trackLeave(true)\n })\n\n flushTimer = setInterval(() => flush(false), FLUSH_INTERVAL_MS)\n heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS)\n\n trackPageview(true)\n sendHeartbeat()\n}\n\nexport function trackRouteLeave(url) {\n // A same-path routeChangeStart (hydration) is not a real leave \u2014 skip it so we\n // don't enqueue a 0ms page_leave. Genuine end-of-visit goes through pagehide\n // (trackLeave(true)), which is intentionally unconditional.\n if (samePathAsCurrent(url)) {\n return\n }\n trackLeave(false)\n}\n\nexport function trackRouteChange(url) {\n if (samePathAsCurrent(url)) {\n return\n }\n if (ownerHeatmapToken) {\n // Owner mode follows the owner from page to page: the map of the page\n // they just opened, with the same token while it lasts.\n showOwnerHeatmap(ownerHeatmapToken)\n return\n }\n trackPageview(false)\n sendHeartbeat()\n}\n\n// The site's not-found page announced itself (the tracker component sees the\n// router on \"/404\"): the address the visitor asked for is a missing page.\nexport function trackNotFound() {\n try {\n if (!isTrackingPossible() || !pageLoadId) {\n return\n }\n emitFriction('notfound', null, null)\n } catch (e) {\n /* never break the host page */\n }\n}\n\nexport { trackOutcome, analyticsIds }\n\n// \u2500\u2500 Commerce funnel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// The five steps of a storefront purchase, in the GA4 vocabulary a merchant\n// already knows. They are emitted as their own event TYPE rather than as named\n// clicks: a page view and a purchase are not clicks, and mixing them into the\n// clicks report would make both harder to read.\n//\n// A FIXED allowlist, not free text. The name becomes a grouping dimension in\n// the funnel builder and the daily roll-up, so an unbounded set would let a\n// typo (or a compromised page) fan the table out into thousands of one-row\n// groups \u2014 and a mistyped step is a step that silently never matches.\nconst COMMERCE_EVENTS = [\n 'view_item',\n 'add_to_cart',\n 'remove_from_cart',\n 'begin_checkout',\n 'purchase',\n]\n\n// Orders already counted, so a shopper refreshing the confirmation page (or\n// coming back to it from their history) cannot inflate the conversion number.\n// Kept in localStorage because the whole point is to survive a reload; capped\n// so a busy device cannot grow it without bound.\nconst PURCHASE_LEDGER_KEY = 'tp_purchases'\nconst PURCHASE_LEDGER_MAX = 50\n\nfunction readPurchaseLedger() {\n try {\n const raw = window.localStorage.getItem(PURCHASE_LEDGER_KEY)\n const parsed = raw ? JSON.parse(raw) : []\n return Array.isArray(parsed) ? parsed : []\n } catch (e) {\n // Private mode, disabled storage, corrupted value: an empty ledger means a\n // duplicate purchase MIGHT be counted, which is a far better failure than\n // throwing inside a checkout.\n return []\n }\n}\n\n// True when this order has been counted before. Recording and checking are one\n// function so no caller can test without also marking.\nfunction claimPurchase(orderId) {\n if (!orderId) {\n // Without an id there is nothing to dedupe on. Counting it is the honest\n // choice: dropping every anonymous purchase would under-report far more\n // than the occasional refresh over-reports.\n return true\n }\n const ledger = readPurchaseLedger()\n if (ledger.indexOf(orderId) !== -1) {\n return false\n }\n ledger.push(orderId)\n try {\n window.localStorage.setItem(\n PURCHASE_LEDGER_KEY,\n JSON.stringify(ledger.slice(-PURCHASE_LEDGER_MAX))\n )\n } catch (e) {\n /* the event still goes out; only the dedupe is lost */\n }\n return true\n}\n\n// Mirror to GA4 / GTM when the merchant configured one. The dataLayer contract\n// is GA4's own, so a merchant's existing Ads and GA4 funnels work with no\n// tagging work \u2014 which is the entire reason to duplicate the event.\nfunction pushToDataLayer(name, detail) {\n try {\n if (typeof window === 'undefined' || !Array.isArray(window.dataLayer)) {\n return\n }\n const payload = { event: name }\n if (detail && detail.currency) {\n payload.currency = detail.currency\n }\n if (detail && detail.value != null) {\n payload.value = detail.value\n }\n if (detail && detail.orderId) {\n payload.transaction_id = detail.orderId\n }\n if (detail && detail.items) {\n payload.items = detail.items\n }\n window.dataLayer.push({ ecommerce: null })\n window.dataLayer.push(payload)\n } catch (e) {\n /* never break the host page */\n }\n}\n\n// The record a commerce step is about: the product id of the first line, or\n// an explicit record id.\nfunction commerceRecordId(detail) {\n if (!detail) {\n return null\n }\n if (detail.recordId != null) {\n return String(detail.recordId)\n }\n if (Array.isArray(detail.items) && detail.items[0] && detail.items[0].item_id != null) {\n return String(detail.items[0].item_id)\n }\n return null\n}\n\n// The one entry point. Safe to call before the tracker has initialised, after\n// it has disabled itself, and on a page where analytics was never enabled \u2014\n// every one of those is a silent no-op rather than a throw, because the callers\n// are checkout and add-to-cart handlers where an exception costs a sale.\nexport function trackCommerceEvent(name, detail) {\n try {\n if (COMMERCE_EVENTS.indexOf(name) === -1) {\n return\n }\n // The dataLayer mirror runs even when our own tracker is off: the merchant's\n // GA4 property is theirs, and it should not depend on our analytics being\n // enabled.\n pushToDataLayer(name, detail)\n\n // No pageLoadId: the shopper is on a page kept out of analytics.\n if (!initialized || !isTrackingPossible() || !pageLoadId) {\n return\n }\n if (name === 'purchase') {\n // Money is reported by the site's server (analytics-track-outcome in the\n // checkout workflow), never by the browser. The ledger still runs so a\n // refreshed confirmation page cannot re-push to the merchant's dataLayer.\n claimPurchase(detail && detail.orderId)\n return\n }\n\n const event = baseEvent('commerce')\n event.name = name\n event.rid = commerceRecordId(detail)\n if (detail && detail.value != null) event.value = detail.value\n if (detail && detail.currency) event.currency = detail.currency\n event.vc = viewportClass()\n enqueue(event)\n // Checkout ends in a redirect to a payment provider, which can tear the page\n // down before the 5s flush timer fires. \"begin_checkout\" is worth losing\n // nothing of.\n if (name === 'begin_checkout') {\n flush(true)\n }\n } catch (e) {\n /* never break a checkout */\n }\n}\n\n// \u2500\u2500 View-based funnel steps \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"view_item\", \"begin_checkout\" and \"purchase\" are things a page IS, not things\n// a shopper clicks \u2014 so instead of threading an emit node through three\n// different page-load workflows, the page declares what it represents and this\n// reports it.\n//\n// The contract is one attribute set on the page's content wrapper:\n//\n// data-tq-commerce-view=\"view_item\" which step this page is\n// data-tq-commerce-id=\"\" what it is about\n// data-tq-commerce-value=\"49.99\" optional monetary value\n// data-tq-commerce-currency=\"USD\" optional currency\n//\n// Declarative on purpose: a builder that adds a new page type gets analytics by\n// naming the step, with no analytics code to write and nothing to keep in sync.\nconst COMMERCE_VIEW_ATTR = 'data-tq-commerce-view'\n\n// One report per PAGE LOAD, so a re-render (a variant click, a filter change)\n// cannot re-count the same view. Reset by the route-change hook below.\nlet reportedViewKey = null\n\nfunction readCommerceViewElement() {\n try {\n return document.querySelector('[' + COMMERCE_VIEW_ATTR + ']')\n } catch (e) {\n return null\n }\n}\n\nexport function trackCommerceView() {\n try {\n const element = readCommerceViewElement()\n if (!element) {\n return\n }\n const name = element.getAttribute(COMMERCE_VIEW_ATTR)\n if (COMMERCE_EVENTS.indexOf(name) === -1) {\n return\n }\n const id = element.getAttribute('data-tq-commerce-id') || null\n // The path alone is not enough: a details page keeps its route while the\n // shopper switches variant, and two different products share one route\n // template. The id is what makes a second view a genuinely different one.\n const key = name + '|' + (id || currentPath || '')\n if (reportedViewKey === key) {\n return\n }\n reportedViewKey = key\n\n const rawValue = element.getAttribute('data-tq-commerce-value')\n const value = rawValue != null && rawValue !== '' ? Number(rawValue) : null\n trackCommerceEvent(name, {\n orderId: name === 'purchase' ? id : null,\n recordId: name === 'purchase' ? null : id,\n currency: element.getAttribute('data-tq-commerce-currency') || null,\n value: value != null && isFinite(value) ? value : null,\n })\n } catch (e) {\n /* never break the host page */\n }\n}\n\n// Called by the tracker component after a client-side navigation. The marker\n// element only exists once the new page has painted, so the caller defers it.\nexport function resetCommerceView() {\n reportedViewKey = null\n}\n"; //# sourceMappingURL=tracker-source.d.ts.map