https://datasystem.pl/themes/datasystem/assets/js/felix-widget.js?v=0d3b5d861f281c96f35ead831939adbe
1/** 2 * Felix chat widget loader. This file no longer moves, measures or observes any 3 * other vendor's widget. The ONLY thing it still does to Thulium is hide it 4 * while the Felix panel is open, and put it back when the panel closes. 5 * 6 * The widget app owns its look, title, greeting AND PLACEMENT: margins arrive 7 * from the panel via `felix:place` and are the SINGLE SOURCE OF TRUTH. Nothing 8 * in this file competes with them â every offset below is a panel value, applied 9 * as sent. Nothing is measured any more: no getBoundingClientRect, no 10 * offsetHeight, no observer. Base snippet supplied by the shop owner 2026-09-07. 11 * 12 * PER-ENVIRONMENT CONFIGURATION 13 * ----------------------------- 14 * This is a STATIC asset (nginx `^~ /themes/`, `public, immutable, 30d`) that 15 * Smarty never renders, so it cannot read Configuration::get. The two values 16 * that differ between dv1 and pr1 therefore arrive as data- attributes on this 17 * script tag, filled in by _partials/felix-widget.tpl: 18 * data-felix-origin Configuration::get('DS_FELIX_WIDGET_ORIGIN') 19 * data-felix-host Configuration::get('PS_SHOP_DOMAIN_SSL') (derived) 20 * FAIL-CLOSED: the origin is both the `event.origin` comparand and the 21 * postMessage targetOrigin, so anything that is not a clean https origin means 22 * render nothing at all. 23 * 24 * THULIUM: VISIBILITY ONLY, AND ONLY ON A DECLARED STATE 25 * ------------------------------------------------------- 26 * This file used to place, measure and observe the Thulium widget. All of that 27 * is GONE and stays gone (44959858c). What comes back â and ONLY this â is 28 * hiding Thulium while the Felix panel is open, so the two chats cannot overlap: 29 * 30 * ALLOWED one class on <body> (THULIUM_HIDE_CLASS) carrying one CSS rule, 31 * `display:none` on Thulium's own selectors. Visibility, nothing 32 * else. `open:false` REMOVES the class and RESTORES the widget â 33 * the hide is a state, never a one-way trapdoor. 34 * FORBIDDEN any position/top/right/bottom/left written for Thulium, any 35 * --ds-thulium-* / --ds-felix-* custom property, any ResizeObserver 36 * on Thulium's wrapper, any MutationObserver over <body>. That is 37 * the layer removed in 44959858c: it had latched onto the LOADING 38 * VIEWPORT (--ds-thulium-side:172px where the real 80px bubble 39 * implies 132px) and never recomputed. Placing someone else's 40 * widget from a measurement of ours is not coming back. 41 * NOT DONE the reverse hide (`body.thulium-is-open` hiding Felix). Knowing 42 * Thulium's state means observing Thulium, which is precisely the 43 * layer that was removed. 44 * 45 * The hide is driven by a DECLARED state (`felix:state`), never by guessing from 46 * a height, and it is gated on `revealed`: a Felix that is not on screen yet 47 * must never take Thulium off it. 48 * 49 * CONSEQUENCE, ACCEPTED: both widgets use z-index 2147483647, so a tie is broken 50 * by DOM order and Thulium â which mounts later, from head.tpl â paints on top. 51 * While the Felix panel is open Thulium is display:none, so the tie does not 52 * arise; while it is closed, the two are separated by the two panels' margins. 53 * 54 * OPEN STATE IS DECLARED, NOT MEASURED â AND THE HEURISTIC IS THE FALLBACK 55 * ------------------------------------------------------------------------ 56 * `felix:state {open, cause}` is the source of truth for "is the Felix panel 57 * open". It drives all three consumers: the mobile fullscreen sheet, the 58 * checkout height cap and the Thulium hide. `cause` is one of user | auto | 59 * host | init â where `init` is the one-off handshake snapshot after 60 * felix:ready, i.e. the state the widget WOKE UP in rather than a change. All 61 * four are treated identically here: every consumer reads `open`, none branches 62 * on `cause`, and the value is passed to the page untouched for whoever cares. 63 * OPEN_THRESHOLD / isOpenHeight() survive as a FALLBACK ONLY, consulted while no 64 * valid `felix:state` has ever arrived â i.e. under widget builds older than 65 * this contract, which must keep working unchanged. From the first valid 66 * `felix:state` on, the declared value wins for the rest of the page's life and 67 * the reported height is never consulted again (see isOpenNow()). The Thulium
68 * hide is NOT part of that fallback: with no declared state this file does not 69 * touch <body> at all. 70 * 71 * WHAT THE SHOP'S OWN PAGE GETS 72 * ----------------------------- 73 * The state is re-broadcast so theme code never has to listen to a cross-origin 74 * postMessage itself: 75 * window.addEventListener('felix:state', e => e.detail.open / e.detail.cause) 76 * window.Felix.isOpen() / window.Felix.open() / window.Felix.close() 77 * Only whitelisted, re-validated fields are copied into that event â the raw 78 * `event.data` object from the iframe never leaves the message handler. 79 * 80 * ABSOLUTE PANEL MARGINS + NO PRE-PANEL PAINT â shop owner's call 2026-09-09 81 * ------------------------------------------------------------------------- 82 * The margins from `felix:place` are taken LITERALLY. No clamp, no default, no 83 * fallback of ours: negative values and values far past the viewport are applied 84 * exactly as sent, because the panel â not this file â is the single source of 85 * truth for placement. The only surviving check is a TYPE check (finite number), 86 * which protects the style string from `NaN` / `Infinity` / objects; it is not a 87 * policy on the value. ACCEPTED RISK, raised and waived: a wrong panel value CAN 88 * push the bubble off-screen, and the fix for that lives in the panel. 89 * The same rule covers the SIZE. The `120` this file used to start from was 90 * copied out of the panel snippet and is exactly as much "ours" as the old 91 * 20/20 margins were; the real size is the app's own `max(80, inviteSize + 16)` 92 * and it arrives in `felix:resize`. So the bubble is revealed only once BOTH 93 * messages have landed â `felix:place` for where it goes and `feli
93x:resize` for 94 * how big it is. Neither alone is enough. 95 * Until then the iframe is mounted `visibility:hidden` â NOT `display:none`, 96 * the widget has to load and lay out to be able to send those two messages at 97 * all â with no offset of ours and with a bootstrap box that is a LOADING 98 * VIEWPORT, never a rendered size (see BOOTSTRAP_BOX). Not one number of ours 99 * reaches the screen. 100 */ 101(function () { 102 'use strict'; 103 104 if (window.__felixWidgetLoaded || document.getElementById('chat-widget-iframe')) { 105 return; 106 } 107 108 // ---- PER-ENVIRONMENT CONFIGURATION ------------------------------------- 109 var SCRIPT_EL = document.currentScript || 110 document.querySelector('script[data-felix-origin]'); 111 var ORIGIN_RE = /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+(:\d{1,5})?$/i; 112 var HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; 113 114 function readConfig(el, name, re) { 115 var raw = el ? (el.getAttribute(name) || '') : ''; 116 raw = raw.trim().replace(/\/+$/, ''); 117 return re.test(raw) ? raw : ''; 118 } 119 120 var WIDGET_ORIGIN = readConfig(SCRIPT_EL, 'data-felix-origin', ORIGIN_RE); 121 var SHOP_HOST = readConfig(SCRIPT_EL, 'data-felix-host', HOST_RE); 122 123 if (!WIDGET_ORIGIN || !SHOP_HOST) { 124 if (window.console && console.warn) { 125 console.warn('[felix-widget] disabled: missing or malformed configuration ' + 126 '(data-felix-origin / data-felix-host). Set DS_FELIX_WIDGET_ORIGIN ' + 127 'in this shop\'s configuration before deploying the widget.'); 128 } 129 return; 130 } 131 132 window.__felixWidgetLoaded = true; 133 134 var WIDGET_REV = '20260820'; 135 // autoopen=0 â AUDIT-2026-08-20, the app once shipped an unconditional 5s 136 // auto-open which on an MPA fired on every page view, checkout included. 137 // v=<rev> â cache-buster for the iframe DOCUMENT. 138 var WIDGET_SRC = WIDGET_ORIGIN + '/?host=' + encodeURIComponent(SHOP_HOST) + 139 '&mode=messages' + 140 '&autoopen=0' + 141 '&v=' + WIDGET_REV; 142 143 // ---- PLACEMENT STATE â OWNED BY THE PANEL ------------------------------- 144 // The four margins start as null, NOT as numbers. null means "the panel has 145 // not spoken yet" and is the flag the whole no-pre-paint rule hangs on: a 146 // number here could only ever be ours, and ours are not allowed on screen. 147 // `position` and `mobileMarginsOn` keep a default because they select WHICH 148 // panel value applies, they are not values themselves. 149 var P = { 150 position: 'right', 151 marginBottom: null, 152 marginSide: null, 153 mobileMarginsOn: false, 154 mobileMarginBottom: null, 155 mobileMarginSide: null 156 }; 157 158 // Flipped once BOTH felix:place (usable margins) and felix:resize (a real 159 // size) have arrived; never reset. 160 var revealed = false; 161 162 var MOBILE_BP = 640; // px, panel's breakpoint for the mobile margin set 163 // There is deliberately NO margin bound any more. The previous 0..200 clamp 164 // was removed on the shop owner's explicit decision: it silently rewrote panel 165 // values (a negative flush-to-corner offset became 0) and made the panel a 166 // suggestion rather than the source of truth. Flush to the corner is now a 167 // panel setting â `margin 0` â not a special case in this file. 168 169 // px, the box the iframe is given WHILE HIDDEN. This is a LOADING VIEWPORT, 170 // not a size: the widget needs a non-degenerate window to lay itself out in 171 // before it can compute and report its real size via felix:resize. The reveal 172 // gate below requires that message, so this number is always overwritten 173 // before anything becomes visible â it can never be the size on screen. 174 // 0x0 was considered and rejected: a widget measuring itself inside a 175 // degenerate viewport can report nothing at all, and the bubble would then 176 // never appear â the same class of trap as loading="lazy" on an off-screen 177 // iframe. visibility:hidden already keeps the box off the screen AND out of 178 // hit-testing (CSS 2.1 §11.2; a hidden box is not a pointer-event target and 179 // is not returned by elementFromPoint), so it cannot steal a click either. 180 var BOOTSTRAP_BOX = 120; 181 var SANE_MAX = 4096; // px, ceiling when window dimensions are unreadable 182 // px, reported height above which FELIX is ASSUMED open. FALLBACK ONLY: read 183 // exclusively while no valid `felix:state` has ever arrived (widget builds 184 // older than that contract). It drives the mobile fullscreen sheet and the 185 // checkout cap in that situation, and it never drives the Thulium hide â that 186 // one requires a declared state, full stop. 187 var OPEN_THRESHOLD = 180; 188 189 // ---- THULIUM: VISIBILITY ONLY ------------------------------------------- 190 // The ONE class this file may hang on <body>, carrying the ONE meaning "hide 191 // Thulium". Named for what it DOES rather than for Felix's state, so that 192 // nothing else gets hung on it the way the removed layer did with
193 // `felix-is-open`. Added only on a declared felix:state, removed again on 194 // open:false â reversible by construction. 195 var THULIUM_HIDE_CLASS = 'felix-hides-thulium'; 196 // Thulium's own selectors. They appear in exactly one CSS rule (display:none, 197 // under the class above) and NOWHERE else: this file never queries them, never 198 // measures them and never writes a style on them. The list is built once, and 199 // the class name below is interpolated from the constant, so the rule and the 200 // class can never drift apart. 201 var THULIUM_SELECTORS = [ 202 '#thulium-chat-widget', 203 '.thulium-widget', 204 "[class*='thlm-']", 205 "[class*='thulium-chat']", 206 "[id*='thulium-chat']", 207 "[id^='thulium-widget'][id$='-wrapper']", 208 '#thulium-widget-button-frame__wrapper', 209 "iframe[src*='thulium.com']", 210 "iframe[title*='Thulium']" 211 ]; 212 213 // Transactional pages: the chat must NEVER cover the order form. PrestaShop 214 // writes the controller name into <body id> (layout-both-columns.tpl:34), and 215 // the core overrides page_name to "checkout" on the order controller, so both 216 // spellings are listed. `order-detail` and the account pages are intentionally 217 // absent â there is nothing to protect there. 218 var CHECKOUT_PAGES = ['cart', 'checkout', 'order', 'order-confirmation']; 219 // Share of the viewport height the open panel may take on those pages. 0.72 220 // keeps the summary/CTA strip visible behind the panel, so the customer can 221 // always see where they are and reach the page underneath. 222 var CHECKOUT_MAX_VH = 0.72; 223 224 function isCheckoutPage() { 225 var id = (document.body && document.body.id) || ''; 226 for (var i = 0; i < CHECKOUT_PAGES.length; i++) { 227 if (id === CHECKOUT_PAGES[i]) { 228 return true; 229 } 230 } 231 return false; 232 } 233 234 function isMobile() { 235 return window.innerWidth <= MOBILE_BP; 236 } 237 238 // FALLBACK heuristic. Do not call this directly to decide behaviour â call 239 // isOpenNow(), which prefers the declared state and only lands here when there 240 // is none. 241 function isOpenHeight(h) { 242 return h > OPEN_THRESHOLD; 243 } 244 245 // The DECLARED open state from `felix:state`, or null while the widget has 246 // never sent one. `null` is the flag the whole fallback hangs on: a boolean 247 // here means the widget told us, and from that instant the measured height is 248 // irrelevant everywhere. 249 var declaredOpen = null; 250 251 // The single answer to "is the Felix panel open" for the mobile fullscreen 252 // sheet and the checkout height cap. Declared state wins; the height is 253 // consulted ONLY before the first valid felix:state. 254 function isOpenNow(h) { 255 if (declaredOpen !== null) { 256 return declaredOpen; 257 } 258 return isOpenHeight(h); 259 } 260 261 // MARGINS: type check only. A finite number â including a negative one and one 262 // far bigger than the viewport â is passed through untouched. Anything that is 263 // not a finite number (string, object, undefined, NaN, +/-Infinity) is refused, 264 // because it would land in a CSS string as `NaNpx` and void the declaration. 265 function acceptPx(value) { 266 if (typeof value !== 'number' || !isFinite(value)) { 267 return null; 268 } 269 return value; 270 } 271 272 // SIZES ONLY (felix:resize width/height). This one still clamps, on purpose: 273 // it guards against an iframe larger than the viewport, which is a rendering 274 // concern of ours, not a placement value owned by the panel. 275 function sanitisePx(value, min, max) { 276 if (typeof value !== 'number' || !isFinite(value)) { 277 return null; 278 } 279 var lo = (typeof min === 'number' && isFinite(min)) ? min : 0; 280 var hi = (typeof max === 'number' && isFinite(max) && max > 0) ? max : SANE_MAX; 281 if (value < lo) { 282 return lo; 283 } 284 return value > hi ? hi : value; 285 } 286 287 // True once the panel has supplied both desktop margins. Everything that could 288 // put a pixel on the screen is gated on this. 289 function hasPlacement() { 290 return typeof P.marginBottom === 'number' && typeof P.marginSide === 'number'; 291 } 292
293 // NOTE the `|| 0` that used to be here is gone: it turned a missing value into 294 // OUR zero. The mobile set falls back to the desktop set instead â still a 295 // panel value â and this function is only ever reached with hasPlacement() 296 // true, so neither branch can return null. 297 function activeMargins() { 298 var mobile = (window.innerWidth <= MOBILE_BP) && P.mobileMarginsOn; 299 return { 300 bottom: (mobile && typeof P.mobileMarginBottom === 'number') 301 ? P.mobileMarginBottom : P.marginBottom, 302 side: (mobile && typeof P.mobileMarginSide === 'number') 303 ? P.mobileMarginSide : P.marginSide 304 }; 305 } 306 307 // ---- IFRAME ------------------------------------------------------------- 308 var f = document.createElement('iframe'); 309 f.src = WIDGET_SRC; 310 f.id = 'chat-widget-iframe'; 311 // Two chat launchers sit side by side and DOM order is non-deterministic, so a 312 // bare title="Felix" is not a usable accessible name. 313 f.title = 'Asystent AI â pomoc techniczna'; 314 f.setAttribute('aria-label', 'Asystent AI â pomoc techniczna'); 315 f.setAttribute('allow', 'clipboard-write'); 316 // Everything here is placement-NEUTRAL. `position:fixed` from the very first 317 // frame keeps the hidden box out of document flow (visibility:hidden still 318 // reserves space in flow, which would add a phantom 120px block at the end of 319 // <body>), and no bottom/left/right is set, so the box has no offset of ours. 320 // `visibility:hidden` â not display:none â because the widget must still load, 321 // lay out and send its felix:place. place() flips it to visible on the first 322 // panel message. 323 f.style.cssText = 'position:fixed;visibility:hidden;border:0;background:transparent;' + 324 'z-index:2147483647;max-width:100vw;max-height:100vh;' + 325 'width:' + BOOTSTRAP_BOX + 'px;height:' + BOOTSTRAP_BOX + 'px;' + 326 'transition:width .2s ease,height .2s ease'; 327 328 // null, not 120: the size is UNKNOWN until felix:resize says otherwise. These 329 // feed the window-resize handler, which must therefore tolerate null. 330 var lastW = null; 331 var lastH = null; 332 333 // Second half of the reveal gate â see hasPlacement() for the first. 334 function hasSize() { 335 return typeof lastW === 'number' && typeof lastH === 'number'; 336 } 337 338 // MOBILE FULLSCREEN + CHECKOUT EXCEPTION â restored from the previous 339 // implementation, unchanged in intent. 340 // phone + open -> .felix-fullscreen (inset:0, 100vw/100dvh) 341 // phone + open + CHECKOUT -> NO fullscreen. The widget is deliberately not 342 // removed from cart/checkout (pre-sales help is 343 // worth the most exactly there) â it is demoted 344 // to a corner panel capped at CHECKOUT_MAX_VH so 345 // the order form stays visible and tappable. 346 // While fullscreen, the panel margins do not apply: inset:0 owns all four 347 // sides. That is intended â a fullscreen sheet has no gutter to configure. 348 function applySize(w, h) { 349 // Reached from the window-resize listener with lastW/lastH still null before 350 // the first felix:resize. Nothing is known and nothing is visible, so there 351 // is nothing to recompute â and `null` must never reach a CSS string. 352 if (typeof w !== 'number' || typeof h !== 'number') { 353 return; 354 } 355 // BOTH consumers now ask isOpenNow(): the declared felix:state if there is 356 // one, the height heuristic only when there is not. 357 if (isMobile() && isOpenNow(h) && !isCheckoutPage()) { 358 f.classList.add('felix-fullscreen'); 359 f.style.width = ''; 360 f.style.height = ''; 361 f.style.bottom = ''; 362 return; 363 } 364 f.classList.remove('felix-fullscreen'); 365 var maxH = window.innerHeight || SANE_MAX; 366 if (isCheckoutPage() && isOpenNow(h)) { 367 maxH = Math.min(maxH, Math.round((window.innerHeight || SANE_MAX) * CHECKOUT_MAX_VH)); 368 } 369 f.style.width = Math.min(w, window.innerWidth || SANE_MAX) + 'px'; 370 f.style.height = Math.min(h, maxH) + 'px'; 371 place(); 372 } 373 374 // The ONLY writer of the bubble's offsets, and the only place that can make it 375 // visible. Before the panel has spoken it does nothing at all â that is the 376 // whole no-pre-paint rule. 377 function place() { 378 if (!hasPlacement()) { 379 return; 380 } 381 var m = activeMargins(); 382 f.style.bottom = m.bottom + 'px'; 383 if (P.position === 'left') { 384 f.style.left = m.side + 'px'; 385 f.style.right = 'auto'; 386 } else { 387 f.style.right = m.side + 'px'; 388 f.style.left = 'auto'; 389 } 390 // BOTH halves required: position from felix:place, size from felix:resize. 391 // Whichever arrives second gets here â applySize() ends in place() too â so 392 // the box is guaranteed to already carry the app's own size, never 393 // BOOTSTRAP_BOX, at the instant it becomes visible. 394 if (!revealed && hasSize()) { 395 revealed = true; 396 f.style.visibility = 'visible'; 397 // A felix:state may have landed while the box was still hidden. The hide 398 // is gated on `revealed`, so this is the moment it becomes due.
399 syncThulium(); 400 } 401 // This function writes to the Felix iframe and, through syncThulium() and 402 // only at the reveal, to ONE class on <body>. Never to <html>, never to a 403 // custom property, never to another vendor's element. 404 } 405 406 // The ONLY writer of a <body> class in this file, and it writes exactly one. 407 // Silent while nothing has been declared: under a widget build that never 408 // sends felix:state the shop behaves exactly as it did before this contract â 409 // <body> untouched, Thulium untouched. Also gated on `revealed`, because 410 // hiding a competitor's chat on behalf of a Felix that is not on screen yet 411 // would leave the customer with no chat at all. 412 function syncThulium() { 413 if (!document.body || declaredOpen === null) { 414 return; 415 } 416 if (revealed && declaredOpen === true) { 417 document.body.classList.add(THULIUM_HIDE_CLASS); 418 } else { 419 // open:false (or a not-yet-revealed Felix) RESTORES Thulium. The hide has 420 // exactly one lever and it moves both ways. 421 document.body.classList.remove(THULIUM_HIDE_CLASS); 422 } 423 } 424 425 // Re-broadcast to the SHOP's own page. Only the two whitelisted, re-validated 426 // values are carried â a FRESH detail object, never the `event.data` the 427 // iframe sent, so nothing the widget invents can reach theme code. 428 function broadcastState(open, cause) { 429 if (typeof window.dispatchEvent !== 'function' || 430 typeof window.CustomEvent !== 'function') { 431 return; 432 } 433 var ev; 434 try { 435 ev = new window.CustomEvent('felix:state', { 436 detail: { open: open, cause: cause } 437 }); 438 } catch (err) { 439 return; // no usable CustomEvent constructor â no broadcast, no throw 440 } 441 window.dispatchEvent(ev); 442 } 443 444 function injectStyles() { 445 if (document.getElementById('felix-widget-style')) { 446 return; 447 } 448 // Two kinds of rule live here and nothing else: 449 // 1. rules scoped to #chat-widget-iframe â Felix's own element; 450 // 2. ONE rule that sets `display:none` on Thulium's selectors, and only 451 // under body.<THULIUM_HIDE_CLASS>. 452 // Rule 2 may never grow a second declaration. Position, offsets and custom 453 // properties for a foreign widget are what the removed layer did. 454 var thuliumHide = THULIUM_SELECTORS.map(function (sel) { 455 return 'body.' + THULIUM_HIDE_CLASS + ' ' + sel; 456 }).join(',') + '{display:none !important;}'; 457 var css = 458 /* OPEN on a phone: the iframe fills the ENTIRE viewport, edge to edge. 459 inset:0 pins all four sides; 100dvh tracks the dynamic viewport so the 460 browser URL bar cannot clip the bottom, with a 100vh fallback. Scoped to 461 <=991px to cover phone+tablet, but JS only adds the class below 462 MOBILE_BP, so behaviour matches the previous implementation. NOT applied 463 on the checkout path â see applySize(). */ 464 '@media (max-width:991px){' + 465 '#chat-widget-iframe.felix-fullscreen{' + 466 'top:0 !important;right:0 !important;bottom:0 !important;left:0 !important;' + 467 'inset:0 !important;' + 468 'width:100vw !important;' + 469 'height:100vh !important;' + 470 'height:100dvh !important;' + 471 'max-width:100vw !important;' + 472 'max-height:100vh !important;' + 473 'max-height:100dvh !important;' + 474 'border-radius:0 !important;' + 475 '}' + 476 '}' + 477 /* WCAG 2.3.3 â respect reduced motion. */ 478 '@media (prefers-reduced-motion:reduce){' + 479 '#chat-widget-iframe{transition:none !important;}' + 480 '}' + 481 /* THULIUM â VISIBILITY ONLY, one declaration, reversible. The class is 482 added on a declared `felix:state {open:true}` and removed again on 483 `open:false`; with no declared state it is never added, so this rule is 484 inert under older widget builds. */ 485 thuliumHide; 486 var style = document.createElement('style'); 487 style.id = 'felix-widget-style'; 488 style.appendChild(document.createTextNode(css)); 489 (document.head || document.documentElement).appendChild(style); 490 } 491 492 // ---- MESSAGES FROM THE WIDGET ------------------------------------------ 493 window.addEventListener('message', function (e) { 494 if (e.origin !== WIDGET_ORIGIN) { 495 return; 496 } 497 var d = e.data; 498 if (!d || typeof d !== 'object') { 499 return; 500 } 501 502 if (d.type === 'felix:place') { 503 // Value allowlist, not just a type check. 504 if (d.position === 'left' || d.position === 'right') { 505 P.position = d.position; 506 } 507 if (typeof d.mobileMarginsOn === 'boolean') { 508 P.mobileMarginsOn = d.mobileMarginsOn; 509 } 510 // Taken as sent â see the header. Only a non-finite / non-number value is 511 // dropped, and dropping it leaves the previous panel value in place. 512 ['marginBottom', 'marginSide', 'mobileMarginBottom', 'mobileMarginSide'] 513 .forEach(function (k) { 514 var v = acceptPx(d[k]); 515 if (v !== null) { 516 P[k] = v; 517 } 518 }); 519 // Applies the margins and, on the first usable message, reveals the box. 520 place(); 521 return; 522 } 523 524 if (d.type === 'felix:state') { 525 // WHITELIST, NOT A COPY. `d` is data from another window: nothing here is 526 // spread, merged or forwarded. Exactly two fields are read, each is 527 // re-validated, and a fresh object is built from them downstream. 528 // open must be a real boolean. 'true', 1 and truthy objects are NOT 529 // coerced â a coerced `open` would blank a competitor's chat off 530 // the shop on the strength of a string. 531 // cause must be one of the FOUR agreed values: user | auto | host | 532 // init. `init` is the handshake snapshot the widget sends once 533 // after felix:ready â not a transition, but the first time the 534 // shop learns the state at all, so rejecting it would leave this 535 // file on the height heuristic for the whole page. An unknown 536 // cause means a widget NEWER than this contract, so the message 537 // is dropped WHOLE rather than published with a value invented 538 // here; the file then keeps running on the fallback heuristic 539 // until a message it understands arrives. Trade-off accepted 540 // knowingly: adding a cause on the widget side is a contract 541 // change and needs this list extended. 542 // `cause` is carried through to the page UNCHANGED and is never branched 543 // on here â the hide below follows `open` alone, whatever the reason. If 544 // anything ever needs to suppress an auto-open on cart/checkout, it must 545 // test for 'auto' ONLY: 'user' is a customer's own click and 'init' is a 546 // panel that was already open, and suppressing either would take the chat 547 // away from someone who is using it. 548 // `at` is the widget's own clock. Nothing here needs it, so it is neither 549 // trusted, nor stored, nor forwarded. 550 if (typeof d.open !== 'boolean') { 551 return; 552 } 553 if (d.cause !== 'user' && d.cause !== 'auto' &&
554 d.cause !== 'host' && d.cause !== 'init') { 555 return; 556 } 557 declaredOpen = d.open; 558 // Re-run the two consumers that may have been using the heuristic. It 559 // bails out by itself while no size has been reported yet. 560 applySize(lastW, lastH); 561 syncThulium(); 562 broadcastState(d.open, d.cause); 563 return; 564 } 565 566 if (d.type === 'felix:resize') { 567 var w = sanitisePx(d.width, 0, window.innerWidth || SANE_MAX); 568 var h = sanitisePx(d.height, 0, window.innerHeight || SANE_MAX); 569 if (w === null || h === null) { 570 return; 571 } 572 lastW = w; 573 lastH = h; 574 // applySize() resizes the iframe and ends in place(). The open state is 575 // consumed THERE (fullscreen / checkout cap) via isOpenNow(), so a height 576 // that arrives after a felix:state can no longer overrule it. This branch 577 // never touches <body>: the Thulium hide follows felix:state alone. 578 applySize(w, h); 579 } 580 }); 581 582 // A viewport change must re-evaluate fullscreen and the checkout cap too, not 583 // only the margins. Before the first felix:resize lastW/lastH are still null 584 // and applySize() bails out on them â nothing is visible at that point anyway. 585 window.addEventListener('resize', function () { applySize(lastW, lastH); }); 586 587 // Public API â targetOrigin is the VALIDATED origin, never '*'. 588 window.FelixOpen = function () { 589 try { 590 f.contentWindow.postMessage({ type: 'felix:open' }, WIDGET_ORIGIN); 591 } catch (err) { /* iframe not ready â no-op */ } 592 }; 593 window.FelixClose = function () { 594 try { 595 f.contentWindow.postMessage({ type: 'felix:close' }, WIDGET_ORIGIN); 596 } catch (err) { /* iframe not ready â no-op */ } 597 }; 598 599 // Page-side API, so theme code never has to listen to a cross-origin 600 // postMessage itself. `open`/`close` are the existing senders under stable 601 // names; window.FelixOpen / window.FelixClose stay for whatever already calls 602 // them. 603 window.Felix = { 604 isOpen: function () { 605 if (declaredOpen !== null) { 606 return declaredOpen; 607 } 608 // FALLBACK: no felix:state has ever arrived (older widget build). Answer 609 // from the last reported height, and `false` while even that is unknown â 610 // an unknown state is reported as closed, never as open, because `open` 611 // is the answer that hides things. 612 return hasSize() ? isOpenHeight(lastH) : false; 613 }, 614 open: window.FelixOpen, 615 close: window.FelixClose 616 }; 617 618 function mount() { 619 if (!document.body) { 620 return; 621 } 622 injectStyles(); 623 document.body.appendChild(f); 624 place(); 625 } 626 627 if (document.body) { 628 mount(); 629 } else { 630 document.addEventListener('DOMContentLoaded', mount); 631 } 632})();
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.