1/*! 2 * ShopFlare Widget Loader 3 * ------------------------------------------------------------------ 4 * Dual entry: 5 * 1) KI-Chatbot (preferred): 6 * <script async src="https://www.shopflare.ai/widget.js" 7 * data-widget-id="UUID"></script> 8 * 2) Produktberater (legacy Shopify / App Embed): 9 * <script src="â¦/widget.js" data-shopflare-id="ID" data-mode="popup" defer></script> 10 * 11 * Chatbot public API: window.ShopflareWidget 12 * Events: shopflare:ready | open | close | chat-started | lead-created | error 13 * Loader version reported via ShopflareWidget.version and heartbeat. 14 */ 15(function (global) { 16 "use strict"; 17 18 var LOADER_VERSION = "2.4.6"; 19 var PROD_SITE = "https://www.shopflare.ai"; 20 var PROD_API = "https://api.shopflare.ai"; 21 // 60px default launcher + 40px safe space on every side. This keeps the 22 // glow-heavy presets inside the iframe until their configured size arrives. 23 var IFRAME_CLOSED_W = 140; 24 var IFRAME_CLOSED_H = 140; 25 // Distance from the closed iframe edge to the visible launcher bubble edge. 26 // KEEP IN SYNC with LAUNCHER_INSET in ChatbotWidget.tsx. The configured 27 // Abstand describes the visible bubble, not the transparent shadow gutter. 28 var LAUNCHER_EDGE_COMPENSATION = 40; 29 30 function findScript() { 31 if (document.currentScript) return document.currentScript; 32 var nodes = document.querySelectorAll( 33 'script[data-widget-id],script[data-shopflare-id],script[src*="widget.js"]', 34 ); 35 return nodes.length ? nodes[nodes.length - 1] : null; 36 } 37 38 var script = findScript(); 39 if (!script) return; 40 41 function isLocalHost(hostname) { 42 return ( 43 hostname === "localhost" || 44 hostname === "127.0.0.1" || 45 hostname === "[::1]" || 46 hostname === "0.0.0.0" 47 ); 48 } 49 50 function resolveSiteOrigin() { 51 var attr = (script.getAttribute("data-origin") || "").replace(/\/$/, ""); 52 if (attr) { 53 try { 54 var u = new URL(attr); 55 if (!isLocalHost(u.hostname) || script.getAttribute("data-allow-localhost") === "1") { 56 return u.origin; 57 } 58 } catch (e) {} 59 } 60 try { 61 var src = script.getAttribute("src") || ""; 62 if (src) { 63 var su = new URL(src, global.location && global.location.href); 64 if (isLocalHost(su.hostname)) { 65 // Production customers must never receive localhost from the loader. 66 if (script.getAttribute("data-allow-localhost") === "1") return su.origin; 67 return PROD_SITE; 68 } 69 return su.origin; 70 } 71 } catch (e2) {} 72 return PROD_SITE; 73 } 74 75 function resolveApiBase(siteOrigin) { 76 var attr = (script.getAttribute("data-api") || "").replace(/\/$/, ""); 77 if (attr) return attr; 78 try { 79 var host = new URL(siteOrigin).hostname; 80 if (isLocalHost(host)) { 81 if (script.getAttribute("data-allow-localhost") === "1") { 82 return (script.getAttribute("data-api") || "http://127.0.0.1:54321").replace(/\/$/, ""); 83 } 84 return PROD_API; 85 } 86 if (host === "www.shopflare.ai" || host === "shopflare.ai") return PROD_API; 87 if (host === "api.shopflare.ai") return siteOrigin; 88 } catch (e) {} 89 return PROD_API; 90 } 91 92 var SITE_ORIGIN = resolveSiteOrigin(); 93 var API_BASE = resolveApiBase(SITE_ORIGIN); 94 95 var chatbotId = ( 96 script.getAttribute("data-widget-id") || 97 script.getAttribute("data-shopflare-widget-id") || 98 "" 99 ).trim(); 100 var beraterId = (script.getAttribute("data-shopflare-id") || "").trim(); 101 102 /* ============================================================ KI Chatbot */ 103 104 function initChatbot(widgetId) { 105 var flag = "__shopflare_chatbot_" + widgetId; 106 if (global[flag]) return; 107 global[flag] = true; 108 109 var state = { 110 ready: false, 111 open: false, 112 hidden: false, 113 frame: null, 114 config: null, 115 consent: null, // null | true | false 116 context: {}, 117 destroyed: false, 118 messageHandler: null, 119 timers: [], 120 configOk: true, 121 }; 122 123 function emit(name, detail) { 124 try { 125 var ev = new CustomEvent(name, { 126 detail: Object.assign( 127 { widgetId: widgetId, loaderVersion: LOADER_VERSION }, 128 detail || {}, 129 ), 130 }); 131 global.dispatchEvent(ev); 132 } catch (e) {}
133 try { 134 if (global.ShopflareWidget && typeof global.ShopflareWidget._on === "function") { 135 global.ShopflareWidget._on(name, detail); 136 } 137 } catch (e2) {} 138 } 139 140 function reportError(code, message) { 141 var c = code || "error"; 142 emit("shopflare:error", { code: c, message: String(message || "").slice(0, 200) }); 143 if ( 144 c === "host_blocked" || 145 c === "inline_container_missing" || 146 c === "duplicate_embed" || 147 c.indexOf("config_") === 0 || 148 c === "widget_config_failed" 149 ) { 150 sendHeartbeat({ 151 ok: false, 152 error_code: c === "config_fetch_failed" ? "config_unavailable" : c, 153 config_ok: false, 154 }); 155 } 156 } 157 158 /* KEEP IN SYNC with src/lib/pageRules.ts (normalizePagePath / matchPageRuleValue / evaluatePageTargeting) */ 159 function normalizePagePath(input) { 160 var raw = String(input || "").trim(); 161 if (!raw) return "/"; 162 try { 163 if (/^https?:\/\//i.test(raw)) { 164 raw = new URL(raw).pathname || "/"; 165 } else if (raw.indexOf("?") >= 0 || raw.indexOf("#") >= 0) { 166 var q = raw.indexOf("?"); 167 var h = raw.indexOf("#"); 168 var cut = raw.length; 169 if (q >= 0) cut = Math.min(cut, q); 170 if (h >= 0) cut = Math.min(cut, h); 171 raw = raw.slice(0, cut) || "/"; 172 } 173 } catch (e) {} 174 var path = (raw.split("?")[0] || "").split("#")[0] || "/"; 175 if (path.charAt(0) !== "/") path = "/" + path; 176 path = path.toLowerCase(); 177 if (path.length > 1 && path.charAt(path.length - 1) === "/") path = path.slice(0, -1); 178 return path || "/"; 179 } 180 181 function escapeRegex(s) { 182 return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 183 } 184 185 function matchPageRuleValue(path, operator, value) { 186 var p = normalizePagePath(path); 187 var v = String(value || "").trim(); 188 if (!v) return false; 189 if (/^https?:\/\//i.test(v) || v.indexOf("://") >= 0) { 190 v = normalizePagePath(v); 191 } else { 192 v = v.toLowerCase(); 193 if ( 194 operator !== "contains" && 195 operator !== "ends_with" && 196 v.charAt(0) !== "/" && 197 v.indexOf("*") < 0 198 ) { 199 if (operator === "exact" || operator === "starts_with" || operator === "wildcard") { 200 v = ("/" + v).replace(/^\/\//, "/"); 201 } 202 } 203 if ( 204 (operator === "exact" || operator === "starts_with") && 205 v.length > 1 && 206 v.charAt(v.length - 1) === "/" 207 ) { 208 v = v.slice(0, -1); 209 } 210 } 211 if (operator === "exact") return p === normalizePagePath(v); 212 if (operator === "starts_with") return p.indexOf(v.toLowerCase()) === 0; 213 if (operator === "contains") return p.indexOf(v.toLowerCase()) >= 0; 214 if (operator === "ends_with") { 215 var needle = v.toLowerCase(); 216 return p.length >= needle.length && p.slice(p.length - needle.length) === needle; 217 } 218 if (operator === "wildcard") { 219 var pattern = v.toLowerCase(); 220 var re = new RegExp("^" + pattern.split("*").map(escapeRegex).join(".*") + "$"); 221 return re.test(p); 222 } 223 return false; 224 } 225 226 function evaluatePageTargeting(urlOrPath, targeting) { 227 var mode = 228 targeting && (targeting.mode === "include" || targeting.mode === "exclude") 229 ? targeting.mode 230 : "all"; 231 var rules = targeting && Array.isArray(targeting.rules) ? targeting.rules : []; 232 var path = normalizePagePath(urlOrPath); 233 var enabled = []; 234 for (var i = 0; i < rules.length; i++) { 235 var r = rules[i]; 236 if (r && r.enabled !== false && String(r.value || "").trim()) enabled.push(r); 237 } 238 for (var j = 0; j < enabled.length; j++) { 239 var ex = enabled[j]; 240 if (ex.action !== "exclude") continue; 241 if (matchPageRuleValue(path, ex.operator || "starts_with", ex.value)) { 242 return { show: false, matchedRule: ex, reason: "exclude_match" }; 243 } 244 } 245 if (mode === "include") { 246 var includes = []; 247 for (var k = 0; k < enabled.length; k++) { 248 if (enabled[k].action === "include") includes.push(enabled[k]); 249 } 250 if (!includes.length) return { show: false, matchedRule: null, reason: "no_rules" }; 251 for (var m = 0; m < includes.length; m++) { 252 var inc = includes[m]; 253 if (matchPageRuleValue(path, inc.operator || "starts_with", inc.value)) { 254 return { show: true, matchedRule: inc, reason: "include_match" }; 255 } 256 } 257 return { show: false, matchedRule: null, reason: "no_include_match" }; 258 } 259 return { show: true, matchedRule: null, reason: "mode_all" }; 260 } 261 262 function pathAllowed(cfg) { 263 var pages = (cfg && cfg.pages) || {}; 264 // Prefer structured rules; fall back to legacy patterns â contains rules 265 var targeting = { mode: pages.mode || "all", rules: pages.rules }; 266 if ( 267 (!targeting.rules || !targeting.rules.length) && 268 Array.isArray(pages.patterns) && 269 pages.patterns.length 270 ) { 271 var action = targeting.mode === "include" ? "include" : "exclude"; 272 targeting.rules = pages.patterns.map(function (p) { 273 return { action: action, operator: "contains", value: String(p || ""), enabled: true }; 274 }); 275 } 276 return evaluatePageTargeting(global.location.pathname || "/", targeting).show; 277 } 278 279 function deviceAllowed(cfg) { 280 var mode = ((cfg && cfg.devices) || {}).mode || "all"; 281 if (mode === "all") return true; 282 var w = global.innerWidth || 1024; 283 var mobile = w < 768; 284 if (mode === "mobile") return mobile; 285 if (mode === "desktop") return !mobile; 286 return true; 287 } 288 289 function hostAllowed(cfg) { 290 var domains = ((cfg && cfg.privacy) || {}).allowed_embed_domains || []; 291 if (!domains.length) return true; 292 var host = "";
293 try { 294 host = (global.location.hostname || "").toLowerCase(); 295 } catch (e) { 296 return false; 297 } 298 for (var i = 0; i < domains.length; i++) { 299 var d = String(domains[i] || "").toLowerCase(); 300 if (!d) continue; 301 if (host === d || host.slice(-(d.length + 1)) === "." + d) return true; 302 } 303 return false; 304 } 305 306 function deviceCategory() { 307 var w = global.innerWidth || 1024; 308 if (w < 768) return "mobile"; 309 if (w < 1024) return "tablet"; 310 return "desktop"; 311 } 312 313 /** Lightweight install verification only â no page/conversation content. */ 314 function sendHeartbeat(opts) { 315 try { 316 var host = ""; 317 try { 318 host = (global.location.hostname || "").toLowerCase(); 319 } catch (e0) { 320 host = ""; 321 } 322 if (!host) return; 323 var payload = { 324 chatbot_id: widgetId, 325 host: host, 326 loader_version: LOADER_VERSION, 327 ok: !(opts && opts.ok === false), 328 device: deviceCategory(), 329 }; 330 if (opts && opts.error_code) payload.error_code = String(opts.error_code).slice(0, 64); 331 if (opts && opts.error_code === null) payload.error_code = null; 332 if (opts && typeof opts.config_ok === "boolean") payload.config_ok = opts.config_ok; 333 var url = API_BASE + "/api/widget/" + encodeURIComponent(widgetId) + "/heartbeat"; 334 var body = JSON.stringify(payload); 335 // text/plain is deliberately CORS-safelisted. This public, metadata-only 336 // heartbeat must work on every merchant storefront without a preflight. 337 var headers = { "Content-Type": "text/plain;charset=UTF-8", Accept: "application/json" }; 338 if (navigator.sendBeacon) { 339 try { 340 var blob = new Blob([body], { type: "text/plain;charset=UTF-8" }); 341 if (navigator.sendBeacon(url, blob)) return; 342 } catch (e1) {} 343 } 344 fetch(url, { 345 method: "POST", 346 mode: "cors", 347 credentials: "omit", 348 keepalive: true, 349 headers: headers, 350 body: body, 351 }).catch(function () {}); 352 } catch (e) {} 353 } 354 355 // A data-lang value is an explicit integration override. Otherwise the 356 // public configuration routes the widget from the embedding page's domain 357 // and (optionally) its path. This keeps every language on one bot/database. 358 function resolveLanguage(cfg) { 359 try { 360 var explicit = (script.getAttribute("data-lang") || "").trim().toLowerCase(); 361 if (explicit) return explicit.slice(0, 5); 362 } catch (e) {} 363 364 var routing = (cfg && cfg.language_routing) || {}; 365 var fallback = String(routing.default_lang || "de") 366 .toLowerCase() 367 .slice(0, 5); 368 var host = ""; 369 var path = "/"; 370 try { 371 host = String(global.location.hostname || "") 372 .toLowerCase() 373 .replace(/^www\./, ""); 374 path = String(global.location.pathname || "/").toLowerCase() || "/"; 375 } catch (e2) {} 376 var rules = Array.isArray(routing.rules) ? routing.rules : []; 377 var winner = null; 378 var winnerScore = -1; 379 for (var i = 0; i < rules.length; i++) { 380 var rule = rules[i] || {}; 381 var lang = String(rule.lang || "") 382 .toLowerCase() 383 .slice(0, 5); 384 var prefix = String(rule.path || "/").toLowerCase() || "/"; 385 var domain = String(rule.domain || "") 386 .toLowerCase() 387 .replace(/^https?:\/\//, "") 388 .split("/")[0] 389 .replace(/^www\./, ""); 390 if (!lang || path.indexOf(prefix.charAt(0) === "/" ? prefix : "/"
390+ prefix) !== 0) continue; 391 if (domain && !(host === domain || host.slice(-(domain.length + 1)) === "." + domain)) 392 continue; 393 var score = prefix.length + (domain ? 1000 + domain.length : 0); 394 if (score > winnerScore) { 395 winner = lang; 396 winnerScore = score; 397 } 398 } 399 return winner || fallback || "de"; 400 } 401 402 function buildSrc(cfg) { 403 var q = []; 404 var lang = resolveLanguage(cfg); 405 if (lang) q.push("lang=" + encodeURIComponent(lang)); 406 if (isInlineMode(cfg)) q.push("embed=open"); 407 // Auto-open is driven by open_trigger via postMessage â do not force embed=open here. 408 if (state.consent === true) q.push("consent=1"); 409 if (state.consent === false) q.push("consent=0"); 410 return ( 411 SITE_ORIGIN + 412 "/widget/" + 413 encodeURIComponent(widgetId) + 414 (q.length ? "?" + q.join("&") : "") 415 ); 416 } 417 418 function isInlineMode(cfg) { 419 var attr = (script.getAttribute("data-mode") || "").toLowerCase(); 420 if (attr === "inline") return true; 421 if (attr === "popup" || attr === "floating") return false; 422 var mode = cfg && cfg.display && cfg.display.mode; 423 return mode === "inline"; 424 } 425 426 function resolveInlineHost(widgetId) { 427 var sel = script.getAttribute("data-container") || "#shopflare-chat-" + widgetId; 428 try { 429 return document.querySelector(sel); 430 } catch (e) { 431 return null; 432 } 433 } 434 435 function applyFrameBox(frame, cfg, open) { 436 var launcher = (cfg && cfg.launcher) || {}; 437 var side = launcher.position === "bottom-left" ? "left" : "right"; 438 var ox = typeof launcher.offset_x === "number" ? launcher.offset_x : 24; 439 var oy = typeof launcher.offset_y === "number" ? launcher.offset_y : 24; 440 if (isInlineMode(cfg)) { 441 var display = (cfg && cfg.display) || {}; 442 var minH = typeof display.inline_min_height === "number" ? display.inline_min_height : 480; 443 var vh = typeof display.inline_height_vh === "number" ? display.inline_height_vh : 70; 444 frame.style.cssText = 445 "position:relative;display:" + 446 (state.hidden ? "none" : "block") + 447 ";width:100%;max-width:100%;" + 448 "height:min(" + 449 vh + 450 "vh, 720px);min-height:" + 451 Math.max(320, minH) + 452 "px;" + 453 "max-height:90vh;border:0;border-radius:16px;background:transparent;" + 454 "pointer-events:auto;z-index:1;"; 455 return; 456 } 457 // Closed: compensate internal bubble inset so ox/oy is the visual margin. 458 // Allow negative frame offsets so Abstand below the compensation still moves. 459 var frameX = open ? ox : ox - LAUNCHER_EDGE_COMPENSATION; 460 var frameY = open ? oy : oy - LAUNCHER_EDGE_COMPENSATION; 461 var maxX = Math.max(0, frameX); 462 var maxY = Math.max(0, frameY); 463 frame.style.cssText = 464 "position:fixed;bottom:" + 465 frameY + 466 "px;" + 467 (side === "left" ? "left:" : "right:") + 468 frameX + 469 "px;" + 470 "border:0;background:transparent;z-index:2147483647;pointer-events:auto;" + 471 "transition:width .25s ease,height .25s ease;" + 472 "max-width:calc(100vw - " + 473 maxX + 474 "px);max-height:calc(100vh - " + 475 maxY + 476 "px);" + 477 (open ? "" : "width:" + IFRAME_CLOSED_W + "px;height:" + IFRAME_CLOSED_H + "px;") + 478 (state.hidden ? "display:none;" : "display:block;"); 479 if (!open) { 480 frame.style.clipPath = "circle(34px at 70px 70px)"; 481 frame.style.webkitClipPath = frame.style.clipPath; 482 } 483 } 484 485 function postToFrame(payload) { 486 if (!state.frame || !state.frame.contentWindow) return; 487 try { 488 state.frame.contentWindow.postMessage( 489 Object.assign({ shopflare: true, widgetId: widgetId }, payload), 490 SITE_ORIGIN, 491 ); 492 } catch (e) {} 493 } 494 495 function productContextAllowed() { 496 var privacy = (state.config && state.config.privacy) || {}; 497 var collection = privacy.collection || {}; 498 return collection.product_context !== false; 499 } 500 501 function mount(cfg) { 502 if (state.destroyed) return; 503 if (document.getElementById("shopflare-widget")) { 504 state.frame = document.getElementById("shopflare-widget"); 505 return; 506 } 507 var frame = document.createElement("iframe"); 508 frame.id = "shopflare-widget"; 509 frame.title = "ShopFlare Chat"; 510 frame.allow = "clipboard-write"; 511 frame.setAttribute("data-shopflare-loader", LOADER_VERSION); 512 frame.src = buildSrc(cfg); 513 var inline = isInlineMode(cfg);
514 applyFrameBox(frame, cfg, inline); 515 if (inline) { 516 var host = resolveInlineHost(widgetId); 517 if (!host) { 518 reportError("inline_container_missing", "Inline container not found"); 519 return; 520 } 521 if (host.querySelector("iframe#shopflare-widget")) { 522 state.frame = host.querySelector("iframe#shopflare-widget"); 523 return; 524 } 525 host.appendChild(frame); 526 } else { 527 document.body.appendChild(frame); 528 } 529 state.frame = frame; 530 state.inline = inline; 531 532 state.messageHandler = function (e) { 533 if (!e || !e.data) return; 534 // Accept messages from the widget origin only. 535 if (e.origin !== SITE_ORIGIN || e.source !== frame.contentWindow) return; 536 var d = e.data; 537 if (d.shopflare === "size") { 538 // Blur the actual merchant page at the iframe boundary. A blur inside 539 // a cross-origin iframe cannot sample the page behind it. 540 state.glassSurface = d.glass === true && d.customShadow !== true; 541 var shadowInsets = d.shadowInsets || {}; 542 var launcherInset = Number.isFinite(Number(d.launcherInset)) 543 ? Number(d.launcherInset) 544 : LAUNCHER_EDGE_COMPENSATION; 545 var shadowTop = Number.isFinite(Number(shadowInsets.top)) ? Number(shadowInsets.top) : 16; 546 var shadowRight = Number.isFinite(Number(shadowInsets.right)) 547 ? Number(shadowInsets.right) 548 : 16; 549 var shadowBottom = Number.isFinite(Number(shadowInsets.bottom)) 550 ? Number(shadowInsets.bottom) 551 : 16; 552 var shadowLeft = Number.isFinite(Number(shadowInsets.left)) 553 ? Number(shadowInsets.left) 554 : 16; 555 state.glassClip = 556 d.isOpen === true 557 ? "inset(" + 558 shadowTop + 559 "px " + 560 shadowRight + 561 "px " + 562 shadowBottom + 563 "px " + 564 shadowLeft + 565 "px round 40px)" 566 : "inset(40px round 999px)"; 567 frame.style.backdropFilter = state.glassSurface ? "blur(22px) saturate(150%)" : ""; 568 frame.style.webkitBackdropFilter = state.glassSurface ? "blur(22px) saturate(150%)" : ""; 569 frame.style.background = state.glassSurface ? "rgba(255,255,255,0.06)" : "transparent"; 570 if (!state.inline) { 571 var launcher = (cfg && cfg.launcher) || {}; 572 var side = launcher.position === "bottom-left" ? "left" : "right"; 573 var ox = typeof launcher.offset_x === "number" ? launcher.offset_x : 24; 574 var oy = typeof launcher.offset_y === "number" ? launcher.offset_y : 24; 575 // Anchor the visible surfaceânot the transparent iframeâto the 576 // configured offset. The child reports its actual launcher inset, 577 // while open panels use their independently calculated shadow 578 // gutters. Negative iframe coordinates are valid and necessary. 579 var frameBottom = d.isOpen ? oy - shadowBottom : oy - launcherInset; 580 var frameSide = d.isOpen 581 ? ox - (side === "left" ? shadowLeft : shadowRight) 582 : ox - launcherInset; 583 frame.style.bottom = frameBottom + "px"; 584 frame.style.left = side === "left" ? frameSide + "px" : ""; 585 frame.style.right = side === "right" ? frameSide + "px" : ""; 586 if (typeof d.w === "number") frame.style.width = d.w + "px"; 587 if (typeof d.h === "number") frame.style.height = d.h + "px"; 588 } 589 if (typeof d.isOpen === "boolean" && d.isOpen !== state.open) { 590 state.open = d.isOpen; 591 emit(d.isOpen ? "shopflare:open" : "shopflare:close", {}); 592 } 593 } 594 if (d.shopflare === "clip" && !state.inline) { 595 frame.style.clipPath = state.glassSurface 596 ? state.open 597 ? state.glassClip 598 : d.clipPath || state.glassClip 599 : d.clipPath || ""; 600 frame.style.webkitClipPath = frame.style.clipPath; 601 } 602 if (d.shopflare === "ready") { 603 state.ready = true; 604 if (productContextAllowed() && Object.keys(state.context).length) { 605 postToFrame({ 606 shopflare: "cmd", 607 cmd: "context", 608 context: state.context, 609 replace: true, 610 }); 611 } 612 emit("shopflare:ready", { version: LOADER_VERSION }); 613 if ( 614 !(cfg.privacy && cfg.privacy.collection && cfg.privacy.collection.diagnostics === false) 615 ) 616 sendHeartbeat({ 617 ok: state.configOk !== false, 618 config_ok: state.configOk !== false, 619 error_code: state.configOk === false ? "config_unavailable" : null, 620 }); 621 startOpenTriggers(cfg); 622 } 623 if (d.shopflare === "open" || (d.shopflare === "size" && d.isOpen === true)) { 624 markOpened(); 625 } 626 if (d.shopflare === "dismiss" || d.shopflare === "greeting-dismiss") { 627 markDismissed(); 628 } 629 if (d.shopflare === "chat-started") { 630 emit("shopflare:chat-started", { 631 conversationId: d.conversationId ? String(d.conversationId).slice(0, 64) : null, 632 }); 633 } 634 if (d.shopflare === "lead-created") { 635 emit("shopflare:lead-created", { 636 leadId: d.leadId ? String(d.leadId).slice(0, 64) : null, 637 }); 638 } 639 if (d.shopflare === "error") { 640 reportError(d.code || "widget_error", d.message || "Widget error"); 641 } 642 if (d.shopflare === "consent") { 643 state.consent = !!d.granted; 644 } 645 }; 646 global.addEventListener("message", state.messageHandler); 647 } 648 649 /* KEEP IN SYNC conceptually with src/lib/openTriggers.ts */ 650 function otStorageKey() { 651 return "sf_ot_v1_" + widgetId; 652 } 653 654 function otBackend(cfg) { 655 var privacy = (cfg && cfg.privacy) || {}; 656 // Consent granted or not required â durable first-party localStorage. 657 // Otherwise session-only caps (no cross-visit persistence without consent). 658 if (privacy.consent_required && state.consent !== true) return sessionStorage; 659 return localStorage; 660 } 661 662 function readOtStore(cfg) {
663 try { 664 var backend = otBackend(cfg); 665 var raw = backend.getItem(otStorageKey()); 666 if (!raw) { 667 var fresh = { 668 events: {}, 669 firstSeenAt: Date.now(), 670 sessionOpened: false, 671 dismissedAt: null, 672 }; 673 backend.setItem(otStorageKey(), JSON.stringify(fresh)); 674 return fresh; 675 } 676 var parsed = JSON.parse(raw); 677 if (!parsed || typeof parsed !== "object") throw new Error("bad"); 678 if (!parsed.events) parsed.events = {}; 679 if (!parsed.firstSeenAt) parsed.firstSeenAt = Date.now(); 680 return parsed; 681 } catch (e) { 682 return { events: {}, firstSeenAt: Date.now(), sessionOpened: false, dismissedAt: null }; 683 } 684 } 685 686 function writeOtStore(cfg, store) { 687 try { 688 otBackend(cfg).setItem( 689 otStorageKey(), 690 JSON.stringify({ 691 events: store.events || {}, 692 firstSeenAt: store.firstSeenAt || Date.now(), 693 sessionOpened: !!store.sessionOpened, 694 dismissedAt: store.dismissedAt || null, 695 }), 696 ); 697 } catch (e) {} 698 } 699 700 function markOpened() { 701 var cfg = state.config; 702 if (!cfg) return; 703 var store = readOtStore(cfg); 704 store.sessionOpened = true; 705 writeOtStore(cfg, store); 706 state.otOpened = true; 707 } 708 709 function markDismissed() { 710 var cfg = state.config; 711 if (!cfg) return; 712 var store = readOtStore(cfg); 713 store.dismissedAt = Date.now(); 714 writeOtStore(cfg, store); 715 state.otDismissed = true; 716 } 717 718 function countRecent(events, key, days) { 719 var list = (events && events[key]) || []; 720 if (!days || days <= 0) return list.length; 721 var cut = Date.now() - days * 86400000; 722 var n = 0; 723 for (var i = 0; i < list.length; i++) if (list[i] >= cut) n++; 724 return n; 725 } 726 727 function pushEvent(cfg, key) { 728 var store = readOtStore(cfg); 729 if (!store.events[key]) store.events[key] = []; 730 store.events[key].push(Date.now()); 731 // keep last 40 732 if (store.events[key].length > 40) store.events[key] = store.events[key].slice(-40); 733 writeOtStore(cfg, store); 734 return store; 735 } 736 737 function isDesktopDevice() { 738 return ( 739 (global.innerWidth || 1024) >= 768 && 740 !("ontouchstart" in global && (global.innerWidth || 0) < 1024) 741 ); 742 } 743 744 function withinServiceHours(hours) { 745 if (!hours || !hours.enabled) return true; 746 try { 747 var tz = hours.timezone || "Europe/Berlin"; 748 var fmt = new Intl.DateTimeFormat("en-GB", { 749 timeZone: tz, 750 weekday: "short", 751 hour: "2-digit", 752 minute: "2-digit", 753 hour12: false, 754 }); 755 var parts = fmt.formatToParts(new Date()); 756 var map = {}; 757 for (var i = 0; i < parts.length; i++) map[parts[i].type] = parts[i].value; 758 var dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; 759 var day = dayMap[map.weekday]; 760 var days = hours.days || []; 761 if (days.length && days.indexOf(day) < 0) return false; 762 var hm = String(hours.start || "09:00").split(":"); 763 var he = String(hours.end || "18:00").split(":"); 764 var cur = Number(map.hour) * 60 + Number(map.minute); 765 var a = Number(hm[0]) * 60 + Number(hm[1] || 0); 766 var b = Number(he[0]) * 60 + Number(he[1] || 0); 767 if (a <= b) return cur >= a && cur <= b; 768 return cur >= a || cur <= b; 769 } catch (e) { 770 return true; 771 } 772 } 773 774 function pathMatchesPrefixes(prefixes) { 775 if (!prefixes || !prefixes.length) return true; 776 var path = (global.location.pathname || "/").toLowerCase(); 777 for (var i = 0; i < prefixes.length; i++) { 778 var p = String(prefixes[i] || "").toLowerCase(); 779 if (!p) continue; 780 if (path.indexOf(p) >= 0) return true; 781 } 782 return false; 783 } 784 785 function conditionsOk(cfg, ot) { 786 var c = (ot && ot.conditions) || {}; 787 if (c.respect_page_targeting !== false && !pathAllowed(cfg)) return false;
788 if (!pathMatchesPrefixes(c.page_paths)) return false; 789 if (!withinServiceHours(c.service_hours)) return false; 790 var store = readOtStore(cfg); 791 var first = store.firstSeenAt || Date.now(); 792 var isReturning = Date.now() - first > 30 * 60 * 1000; 793 if (c.visitor === "new" && isReturning) return false; 794 if (c.visitor === "returning" && !isReturning) return false; 795 if (c.skip_after_dismiss && (store.dismissedAt || state.otDismissed)) return false; 796 if (c.skip_if_opened && (store.sessionOpened || state.otOpened || state.open)) return false; 797 var minT = Number(c.min_time_on_page_sec) || 0; 798 if (minT > 0 && Date.now() - (state.otPageStart || Date.now()) < minT * 1000) return false; 799 return true; 800 } 801 802 function waitMinTime(cfg, ot, fn) { 803 var minT = Number((ot.conditions && ot.conditions.min_time_on_page_sec) || 0); 804 var elapsed = Date.now() - (state.otPageStart || Date.now()); 805 var wait = Math.max(0, minT * 1000 - elapsed); 806 schedule(fn, wait); 807 } 808 809 function fireTrigger(cfg, ot, kind) { 810 if (state.otFired || state.destroyed) return; 811 if (!conditionsOk(cfg, ot)) return; 812 var store = readOtStore(cfg); 813 var max = 1; 814 var days = 7; 815 if (kind === "greeting") { 816 max = ot.greeting.max_per_visitor; 817 days = ot.greeting.cooldown_days; 818 } else if (kind === "auto") { 819 max = ot.auto.max_per_visitor; 820 days = ot.auto.cooldown_days; 821 } else if (kind === "scroll") { 822 max = ot.scroll.max_per_visitor; 823 days = ot.scroll.cooldown_days; 824 } else if (kind === "exit") { 825 max = ot.exit_intent.max_per_visitor; 826 days = ot.exit_intent.cooldown_days; 827 } 828 if (max > 0 && countRecent(store.events, kind, days) >= max) return; 829 state.otFired = true; 830 pushEvent(cfg, kind); 831 if (kind === "greeting") { 832 postToFrame({ 833 shopflare: "cmd", 834 cmd: "greeting", 835 text: (ot.greeting && ot.greeting.text) || cfg.welcome_message || "", 836 }); 837 } else { 838 postToFrame({ shopflare: "cmd", cmd: "open" }); 839 markOpened(); 840 } 841 } 842 843 function startOpenTriggers(cfg) { 844 if (state.otStarted || state.inline) return; 845 state.otStarted = true; 846 state.otPageStart = Date.now(); 847 var ot = cfg.open_trigger || null; 848 // Legacy fallback 849 if (!ot && cfg.timing && cfg.timing.auto_open) { 850 ot = { 851 mode: "auto", 852 auto: { 853 delay_sec: Math.round((cfg.timing.auto_open_delay_ms || 0) / 1000), 854 max_per_visitor: 1, 855 cooldown_days: 0, 856 devices: "all", 857 }, 858 conditions: { 859 min_time_on_page_sec: 0, 860 visitor: "any", 861 page_paths: [], 862 respect_page_targeting: true, 863 service_hours: { enabled: false }, 864 skip_after_dismiss: true, 865 skip_if_opened: true, 866 }, 867 greeting: {}, 868 scroll: { percent: 50 }, 869 exit_intent: {}, 870 }; 871 } 872 if (!ot || !ot.mode || ot.mode === "click") return; 873 874 if (ot.mode === "greeting") { 875 waitMinTime(cfg, ot, function () { 876 schedule( 877 function () { 878 fireTrigger(cfg, ot, "greeting"); 879 }, 880 (Number(ot.greeting && ot.greeting.delay_sec) || 0) * 1000, 881 ); 882 }); 883 return; 884 } 885 886 if (ot.mode === "auto") { 887 var devices = (ot.auto && ot.auto.devices) || "all"; 888 var desk = isDesktopDevice(); 889 if (devices === "desktop" && !desk) return; 890 if (devices === "mobile" && desk) return; 891 waitMinTime(cfg, ot, function () { 892 schedule( 893 function () { 894 fireTrigger(cfg, ot, "auto"); 895 }, 896 (Number(ot.auto && ot.auto.delay_sec) || 0) * 1000, 897 ); 898 }); 899 return; 900 } 901 902 if (ot.mode === "scroll") { 903 var pct = Math.min(100, Math.max(10, Number(ot.scroll && ot.scroll.percent) || 50)); 904 var onScroll = function () { 905 if (state.otFired) return; 906 var doc = document.documentElement; 907 var body = document.body; 908 var scrollTop = global.pageYOffset || doc.scrollTop || body.scrollTop || 0; 909 var height = Math.max(doc.scrollHeight, body.scrollHeight) - (global.innerHe
909ight || 0); 910 var progress = height > 0 ? (scrollTop / height) * 100 : 100; 911 if (progress >= pct) { 912 global.removeEventListener("scroll", onScroll); 913 waitMinTime(cfg, ot, function () { 914 fireTrigger(cfg, ot, "scroll"); 915 }); 916 } 917 }; 918 global.addEventListener("scroll", onScroll, { passive: true }); 919 state.otScrollHandler = onScroll; 920 onScroll(); 921 return; 922 } 923 924 if (ot.mode === "exit_intent") { 925 if (!isDesktopDevice()) return; 926 var onExit = function (e) { 927 if (state.otFired) return; 928 if (!e || e.clientY > 8) return; 929 document.removeEventListener("mouseout", onExit); 930 waitMinTime(cfg, ot, function () { 931 fireTrigger(cfg, ot, "exit"); 932 }); 933 }; 934 document.addEventListener("mouseout", onExit); 935 state.otExitHandler = onExit; 936 } 937 } 938 939 function schedule(fn, ms) { 940 var t = global.setTimeout(fn, ms); 941 state.timers.push(t); 942 return t; 943 } 944 945 function boot(cfg) { 946 state.config = cfg; 947 if (!productContextAllowed()) state.context = {}; 948 if (!hostAllowed(cfg)) { 949 reportError("host_blocked", "Widget not allowed on this host"); 950 return; 951 } 952 if (!pathAllowed(cfg)) return; 953 if (!deviceAllowed(cfg)) return; 954 955 var privacy = (cfg && cfg.privacy) || {}; 956 // `consent_required` controls the consent prompt inside the widget. It 957 // must not prevent the iframe itself from mounting. Pre-mount blocking is 958 // opt-in through the extended `widget_privacy.consent_mode` setting. 959 var consentMode = privacy.consent_mode || "immediate"; 960 state.consentMode = consentMode; 961 962 function doMount() { 963 if (state.destroyed || state.frame) return; 964 mount(cfg); 965 // Installation succeeds only after the iframe reports that it is ready. 966 } 967 968 function wireCmp(cmp) { 969 if (!cmp || cmp === "none") return; 970 if (cmp === "cookiebot") { 971 global.addEventListener("CookiebotOnAccept", function () { 972 api.consent.grant(); 973 }); 974 global.addEventListener("CookiebotOnDecline", function () { 975 api.consent.revoke(); 976 }); 977 try { 978 if ( 979 global.Cookiebot && 980 global.Cookiebot.consent && 981 global.Cookiebot.consent.marketing 982 ) { 983 api.consent.grant(); 984 } 985 } catch (eCb) {} 986 return; 987 } 988 if (cmp === "usercentrics") { 989 var onUc = function () { 990 try { 991 if (global.UC_UI && typeof global.UC_UI.isConsentRequired === "function") { 992 if (!global.UC_UI.isConsentRequired()) api.consent.grant(); 993 } 994 } catch (eUc) {} 995 }; 996 global.addEventListener("UC_UI_INITIALIZED", onUc); 997 global.addEventListener("UC_CONSENT", function (ev) { 998 try { 999 var detail = ev && ev.detail; 1000 if (detail && detail.consent === true) api.consent.grant(); 1001 else if (detail && detail.consent === false) api.consent.revoke(); 1002 } catch (e2) {} 1003 }); 1004 onUc(); 1005 return; 1006 } 1007 if (cmp === "consentmanager") { 1008 var poll = function () { 1009 try { 1010 if (typeof global.__cmp === "function") { 1011 global.__cmp("getCMPData", null, function (data) { 1012 try { 1013 if (data && data.consentData && data.consentData.purposeConsents) { 1014 api.consent.grant(); 1015 } 1016 } catch (e3) {} 1017 }); 1018 } 1019 } catch (e4) {} 1020 }; 1021 global.addEventListener("cmpEvent", function (ev) { 1022 try { 1023 if (ev && ev.detail && ev.detail.event === "consent") api.consent.grant(); 1024 } catch (e5) {} 1025 }); 1026 poll(); 1027 state.timers.push(global.setInterval(poll, 2500)); 1028 } 1029 } 1030
1031 var delay = (cfg.timing && cfg.timing.script_delay_ms) || 0; 1032 schedule(function () { 1033 wireCmp(privacy.consent_cmp); 1034 // after_consent / manual: do not mount iframe until grant() 1035 if (consentMode === "immediate" || state.consent === true) { 1036 doMount(); 1037 } else { 1038 state._pendingMount = doMount; 1039 emit("shopflare:ready", { version: LOADER_VERSION, awaitingConsent: true }); 1040 } 1041 }, delay); 1042 } 1043 1044 function configNumber(value, fallback) { 1045 var parsed = Number(value); 1046 return isFinite(parsed) ? parsed : fallback; 1047 } 1048 1049 function normalizeConfig(data) { 1050 if (!data || typeof data !== "object" || Array.isArray(data)) { 1051 throw new Error("config_invalid"); 1052 } 1053 if (data.ok === false) throw new Error(data.code || "config_invalid"); 1054 1055 var design = 1056 data.widget_design && typeof data.widget_design === "object" ? data.widget_design : {}; 1057 var targeting = 1058 design.page_targeting && typeof design.page_targeting === "object" 1059 ? design.page_targeting 1060 : {}; 1061 var widgetPrivacy = 1062 data.widget_privacy && typeof data.widget_privacy === "object" ? data.widget_privacy : {}; 1063 var collection = 1064 widgetPrivacy.collection && typeof widgetPrivacy.collection === "object" 1065 ? widgetPrivacy.collection 1066 : {}; 1067 var consentMode = widgetPrivacy.consent_mode; 1068 if ( 1069 consentMode !== "immediate" && 1070 consentMode !== "after_consent" && 1071 consentMode !== "manual" 1072 ) { 1073 consentMode = "immediate"; 1074 } 1075 1076 return Object.assign({}, data, { 1077 ok: true, 1078 id: data.id || widgetId, 1079 launcher: { 1080 position: data.launcher_position || "bottom-right", 1081 offset_x: configNumber(data.launcher_offset_x, 24), 1082 offset_y: configNumber(data.launcher_offset_y, 24), 1083 size: configNumber(data.launcher_size, 60), 1084 shape: data.launcher_shape || "circle", 1085 background_color: data.launcher_bg_color || data.brand_color || "#6d28d9", 1086 icon_color: data.launcher_icon_color || "#ffffff", 1087 icon_url: data.launcher_icon_url || "", 1088 label: data.launcher_label || "", 1089 }, 1090 timing: { 1091 script_delay_ms: configNumber(design.script_delay_ms, 0), 1092 show_delay_ms: configNumber(design.show_delay_ms, 0), 1093 auto_open: design.auto_open === true, 1094 auto_open_delay_ms: configNumber(design.auto_open_delay_ms, 0), 1095 hide_launcher_until_delay: design.hide_launcher_until_delay !== false, 1096 }, 1097 pages: { 1098 mode: targeting.mode || design.page_mode || "all", 1099 rules: Array.isArray(targeting.rules) ? targeting.rules : [], 1100 patterns: Array.isArray(design.page_patterns) ? design.page_patterns : [], 1101 }, 1102 devices: { mode: design.device_mode || "all" }, 1103 privacy: { 1104 consent_required: data.consent_required !== false, 1105 consent_mode: consentMode, 1106 consent_cmp: widgetPrivacy.consent_cmp || "none", 1107 collect_chat_origin: data.collect_chat_origin !== false, 1108 store_client_chat_history: data.store_client_chat_history !== false, 1109 allowed_embed_domains: Array.isArray(data.allowed_embed_domains) 1110 ? data.allowed_embed_domains 1111 : [], 1112 collection: Object.assign({ diagnostics: true, product_context: true }, collection), 1113 }, 1114 display: { 1115 mode: design.display_mode || "floating", 1116 inline_height_vh: configNumber(design.inline_height_vh, 70), 1117 inline_min_height: configNumber(design.inline_min_height_px, 480), 1118 }, 1119 open_trigger: 1120 design.open_trigger && typeof design.open_trigger === "object" 1121 ? design.open_trigger 1122 : null, 1123 }); 1124 } 1125 1126 function fetchConfig(attempt) { 1127 attempt = attempt || 0; 1128 var url = API_BASE + "/api/widget/" + encodeURIComponent(widgetId) + "/config"; 1129 var controller = new AbortController(); 1130 var timeout = global.setTimeout(function () { 1131 controller.abort(); 1132 }, 10000); 1133 return fetch(url, { 1134 method: "GET", 1135 mode: "cors", 1136 credentials: "omit", 1137 // Placement and visibility settings are edited from the dashboard and 1138 // must take effect on the next storefront load. Never reuse a stale 1139 // browser/service-worker response for widget configuration. 1140 cache: "no-store", 1141 signal: controller.signal, 1142 // Keep this a CORS-safelisted GET so merchant storefronts do not need 1143 // a preflight request merely to load the public widget configuration. 1144 headers: { Accept: "application/json" }, 1145 }) 1146 .then(function (res) { 1147 if (!res.ok) throw new Error("config_http_" + res.status); 1148 return res.json(); 1149 }) 1150 .then(function (data) { 1151 if (!data || typeof data !== "object" || Array.isArray(data) || data.error) { 1152 throw new Error("config_invalid"); 1153 } 1154 return normalizeConfig(data); 1155 }) 1156 .finally(function () { 1157 global.clearTimeout(timeout); 1158 }) 1159 .catch(function (error) { 1160 if ( 1161 !state.destroyed && 1162 attempt < 1 &&
1163 !/^config_(http_4\d\d|invalid)$/.test(error.message || "") 1164 ) { 1165 return fetchConfig(attempt + 1); 1166 } 1167 throw error; 1168 }); 1169 } 1170 1171 var api = { 1172 version: LOADER_VERSION, 1173 widgetId: widgetId, 1174 open: function () { 1175 state.open = true; 1176 postToFrame({ shopflare: "cmd", cmd: "open" }); 1177 emit("shopflare:open", { source: "api" }); 1178 }, 1179 close: function () { 1180 state.open = false; 1181 postToFrame({ shopflare: "cmd", cmd: "close" }); 1182 emit("shopflare:close", { source: "api" }); 1183 }, 1184 toggle: function () { 1185 if (state.open) api.close(); 1186 else api.open(); 1187 }, 1188 hide: function () { 1189 state.hidden = true; 1190 if (state.frame) state.frame.style.display = "none"; 1191 }, 1192 show: function () { 1193 state.hidden = false; 1194 if (state.frame) state.frame.style.display = "block"; 1195 }, 1196 setContext: function (ctx) { 1197 if (!ctx || typeof ctx !== "object") return; 1198 /* KEEP IN SYNC with src/lib/widgetHostContext.ts â allowlisted keys only */ 1199 var ALLOW = { 1200 page_type: "page_type", 1201 page: "page_type", 1202 product_id: "product_id", 1203 sku: "product_id", 1204 product_name: "product_name", 1205 product_url: "product_url", 1206 variant_id: "variant_id", 1207 product_available: "product_available", 1208 product_price: "product_price", 1209 current_price: "product_price", 1210 currency_code: "currency_code", 1211 cart_value: "cart_value", 1212 cart_item_count: "cart_item_count", 1213 shop_locale: "shop_locale", 1214 customer_locale: "customer_locale", 1215 customer_logged_in: "customer_logged_in", 1216 commerce_platform: "commerce_platform", 1217 locale: "customer_locale", 1218 }; 1219 var FORBIDDEN = { 1220 system_prompt: 1, 1221 prompt: 1, 1222 instructions: 1, 1223 instruction: 1, 1224 override: 1, 1225 jailbreak: 1, 1226 model: 1, 1227 temperature: 1, 1228 tools: 1, 1229 messages: 1, 1230 userid: 1, 1231 email: 1, 1232 token: 1, 1233 jwt: 1, 1234 identity_token: 1, 1235 password: 1, 1236 secret: 1, 1237 api_key: 1, 1238 apikey: 1, 1239 }; 1240 var safe = {}; 1241 var clear = []; 1242 var keys = Object.keys(ctx); 1243 var maxKeys = 16; 1244 for (var i = 0; i < keys.length && i < maxKeys; i++) { 1245 var k = keys[i]; 1246 if (FORBIDDEN[String(k).toLowerCase()]) continue; 1247 var canon = ALLOW[k]; 1248 if (!canon) continue; 1249 var v = ctx[k]; 1250 if (v === null) { 1251 clear.push(canon); 1252 continue; 1253 } 1254 if (canon === "cart_value" || canon === "cart_item_count" || canon === "product_price") { 1255 var n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN; 1256 if (!isFinite(n) || n < 0) continue; 1257 if (canon === "cart_value" && n <= 1e6) safe.cart_value = Math.round(n * 100) / 100; 1258 if (canon === "cart_item_count" && n <= 10000) safe.cart_item_count = Math.floor(n); 1259 if (canon === "product_price" && n <= 1e6) 1260 safe.product_price = Math.round(n * 100) / 100; 1261 continue; 1262 } 1263 if (canon === "product_available" || canon === "customer_logged_in") { 1264 if (typeof v === "boolean") safe[canon] = v; 1265 continue; 1266 } 1267 if (canon === "currency_code") { 1268 var currency = String(v || "") 1269 .trim() 1270 .toUpperCase(); 1271 if (/^[A-Z]{3}$/.test(currency)) safe.currency_code = currency; 1272 continue; 1273 } 1274 if (canon === "commerce_platform") { 1275 if (String(v || "").toLowerCase() === "shopify") safe.commerce_platform = "shopify"; 1276 continue; 1277 } 1278 if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") continue; 1279 if (typeof v === "boolean") continue; 1280 var s = String(v) 1281 .replace(/[\u0000-\u001f\u007f]/g, "") 1282 .trim(); 1283 if (!s) continue; 1284 if (canon === "product_url") { 1285 if (!/^https?:\/\//i.test(s)) continue; 1286 safe.product_url = s.slice(0, 500); 1287 } else { 1288 safe[canon] = s.slice(0, 200); 1289 } 1290 } 1291 for (var c = 0; c < clear.length; c++) delete state.context[clear[c]]; 1292 state.context = Object.assign({}, state.context, safe); 1293 try { 1294 if (JSON.stringify(state.context).length > 2048) { 1295 state.context = safe; 1296 } 1297 } catch (eCtx) {} 1298 if (productContextAllowed()) { 1299 postToFrame({ shopflare: "cmd", cmd: "context", context: state.context, replace: true }); 1300 } 1301 }, 1302 identify: function (payload) { 1303 if (!payload || typeof payload !== "object") return; 1304 // Authenticated identity requires a server-signed token; reject bare userId/email. 1305 if ((payload.userId || payload.email) && !payload.token) { 1306 reportError("identify_rejected", "Authenticated identity requires a signed token"); 1307 return; 1308 } 1309 if (payload.token && typeof payload.token === "string") { 1310 postToFrame({ 1311 shopflare: "cmd", 1312 cmd: "identify", 1313 token: String(payload.token).slice(0, 4096), 1314 }); 1315 return; 1316 } 1317 if (payload.anonymousId && typeof payload.anonymousId === "string") { 1318 postToFrame({ 1319 shopflare: "cmd", 1320 cmd: "identify", 1321 anonymousId: String(payload.anonymousId).slice(0, 128), 1322 }); 1323 } 1324 }, 1325 consent: { 1326 grant: function () { 1327 state.consent = true; 1328 if (typeof state._pendingMount === "function") { 1329 var m = state._pendingMount; 1330 state._pendingMount = null; 1331 try { 1332 m(); 1333 } catch (eM) {} 1334 } 1335 postToFrame({ shopflare: "cmd", cmd: "consent", granted: true }); 1336 }, 1337 revoke: function () { 1338 state.consent = false;
1339 postToFrame({ shopflare: "cmd", cmd: "consent", granted: false }); 1340 // Optional collection stops; keep iframe for necessary chat UI if already mounted. 1341 }, 1342 }, 1343 destroy: function () { 1344 state.destroyed = true; 1345 for (var i = 0; i < state.timers.length; i++) global.clearTimeout(state.timers[i]); 1346 state.timers = []; 1347 if (state.messageHandler) global.removeEventListener("message", state.messageHandler); 1348 if (state.frame && state.frame.parentNode) state.frame.parentNode.removeChild(state.frame); 1349 state.frame = null; 1350 try { 1351 delete global[flag]; 1352 } catch (e) { 1353 global[flag] = false; 1354 } 1355 }, 1356 _listeners: {}, 1357 on: function (event, fn) { 1358 if (!api._listeners[event]) api._listeners[event] = []; 1359 api._listeners[event].push(fn); 1360 }, 1361 _on: function (event, detail) { 1362 var list = api._listeners[event] || []; 1363 for (var i = 0; i < list.length; i++) { 1364 try { 1365 list[i](detail); 1366 } catch (e) {} 1367 } 1368 }, 1369 }; 1370 1371 global.ShopflareWidget = api; 1372 1373 function start() { 1374 fetchConfig() 1375 .then(function (cfg) { 1376 state.configOk = true; 1377 boot(cfg); 1378 }) 1379 .catch(function (err) { 1380 state.configOk = false; 1381 var raw = err && err.message ? String(err.message) : "config_unavailable"; 1382 reportError(raw.indexOf("config_") === 0 ? raw : "config_fetch_failed", raw); 1383 }); 1384 } 1385 1386 if (document.readyState === "loading") { 1387 document.addEventListener("DOMContentLoaded", start, { once: true }); 1388 } else { 1389 start(); 1390 } 1391 } 1392 1393 /* ======================================================== Produktberater */ 1394 1395 function initBerater() { 1396 var ORIGIN = SITE_ORIGIN; 1397 var BOOTSTRAP_URL = API_BASE + "/functions/v1/widget-bootstrap"; 1398 var id = beraterId; 1399 var shop = script.getAttribute("data-shop") || ""; 1400 var mode = (script.getAttribute("data-mode") || "popup").toLowerCase(); 1401 var auto = script.getAttribute("data-auto") === "1"; 1402 var manual = script.getAttribute("data-manual") === "1"; 1403 var size = parseInt(script.getAttribute("data-size") || "60", 10) || 60; 1404 var ox = parseInt(script.getAttribute("data-offset-x") || "24", 10) || 0; 1405 var oy = parseInt(script.getAttribute("data-offset-y") || "24", 10) || 0; 1406 var color = script.getAttribute("data-color") || "#111111"; 1407 var icon = script.getAttribute("data-icon") || ""; 1408 1409 function src(embed) { 1410 return ( 1411 ORIGIN + 1412 "/berater/" + 1413 encodeURIComponent(id) + 1414 "?embed=" + 1415 embed + 1416 (auto ? "&auto=1" : "") + 1417 (manual ? "&manual=1" : "") 1418 ); 1419 } 1420 1421 function idle(fn) { 1422 if (typeof global.requestIdleCallback === "function") { 1423 global.requestIdleCallback(fn, { timeout: 2000 }); 1424 } else { 1425 setTimeout(fn, 200); 1426 } 1427 } 1428 1429 function ready(fn) { 1430 if (document.readyState === "loading") { 1431 document.addEventListener( 1432 "DOMContentLoaded", 1433 function () { 1434 idle(fn); 1435 }, 1436 { once: true }, 1437 ); 1438 } else { 1439 idle(fn); 1440 } 1441 } 1442 1443 function initPopup() { 1444 var frame = null; 1445 var open = false; 1446 var btn = document.createElement("button"); 1447 btn.type = "button"; 1448 btn.setAttribute("aria-label", "Produktberater öffnen"); 1449 btn.style.cssText = 1450 "position:fixed;z-index:2147483000;right:" + 1451 ox + 1452 "px;bottom:" + 1453 oy + 1454 "px;" + 1455 "width:" + 1456 size + 1457 "px;height:" + 1458 size + 1459 "px;border:0;border-radius:9999px;" + 1460 "background:" + 1461 color + 1462 ";color:#fff;cursor:pointer;padding:0;" + 1463 "box-shadow:0 6px 24px rgba(0,0,0,.22);display:flex;align-items:center;" + 1464 "justify-content:center;transition:transform .15s ease"; 1465 btn.onmouseenter = function () { 1466 btn.style.transform = "scale(1.06)"; 1467 }; 1468 btn.onmouseleave = function () { 1469 btn.style.transform = "scale(1)"; 1470 }; 1471 1472 if (icon) { 1473 var img = document.createElement("img"); 1474 img.src = icon; 1475 img.alt = ""; 1476 img.loading = "lazy"; 1477 img.decoding = "async"; 1478 img.style.cssText = "width:56%;height:56%;object-fit:contain;pointer-events:none"; 1479 btn.appendChild(img); 1480 } else { 1481 btn.innerHTML = 1482 '<svg width="' + 1483 Math.round(size * 0.44) + 1484 '" height="' + 1485 Math.round(size * 0.44) + 1486 '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + 1487 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + 1488 '<path d="M12 3l1.9 4.6L18.5 9l-4.6 1.9L12 15l-1.9-4.1L5.5 9l4.6-1.4z"/>' + 1489 '<path d="M18 15l.9 2.1L21 18l-2.1.9L18 21l-.9-2.1L15 18l2.1-.9z"/></svg>'; 1490 } 1491 1492 function mount() { 1493 if (frame) return frame; 1494 frame = document.createElement("iframe"); 1495 frame.src = src("popup"); 1496 frame.title = "Produktberater"; 1497 frame.allow = "clipboard-write"; 1498 frame.setAttribute("loading", "lazy"); 1499 var sideGap = Math.max(12, Math.min(ox, 24)); 1500 var bottomGap = Math.max(12, oy + size + 10); 1501 frame.style.cssText = 1502 "position:fixed;z-index:2147483001;right:" + 1503 sideGap + 1504 "px;bottom:" + 1505 bottomGap + 1506 "px;" + 1507 "width:min(400px,calc(100vw - " + 1508 sideGap * 2 + 1509 "px));height:min(620px,calc(100dvh - " + 1510 (bottomGap + 12) + 1511 "px));" + 1512 "max-height:calc(100vh - " + 1513 (bottomGap + 12) + 1514 "px);" + 1515 "border:0;border-radius:20px;background:transparent;display:none;" + 1516 "box-shadow:0 18px 60px rgba(0,0,0,.28)"; 1517 document.body.appendChild(frame); 1518 return frame; 1519 } 1520
1521 function toggle() { 1522 var f = mount(); 1523 open = !open; 1524 f.style.display = open ? "block" : "none"; 1525 btn.setAttribute("aria-expanded", open ? "true" : "false"); 1526 } 1527 1528 btn.addEventListener("click", toggle); 1529 document.body.appendChild(btn); 1530 1531 var warmed = false; 1532 function warm() { 1533 if (warmed) return; 1534 warmed = true; 1535 var l = document.createElement("link"); 1536 l.rel = "preconnect"; 1537 l.href = ORIGIN; 1538 document.head.appendChild(l); 1539 } 1540 btn.addEventListener("mouseenter", warm, { once: true }); 1541 btn.addEventListener("touchstart", warm, { once: true, passive: true }); 1542 1543 global.addEventListener("message", function (e) { 1544 if (e.origin !== ORIGIN) return; 1545 var d = e.data || {}; 1546 if (d.id && String(d.id) !== String(id)) return; 1547 if (d.type === "shopflare:hide") { 1548 btn.remove(); 1549 if (frame) frame.remove(); 1550 } 1551 if (d.type === "shopflare:close" && frame) { 1552 open = false; 1553 frame.style.display = "none"; 1554 } 1555 }); 1556 } 1557 1558 function initInline() { 1559 var host = 1560 document.querySelector('[data-shopflare-inline="' + id + '"]') || 1561 document.getElementById("shopflare-advisor-" + id); 1562 if (!host) return; 1563 var loaded = false; 1564 function load() { 1565 if (loaded) return; 1566 loaded = true; 1567 var frame = document.createElement("iframe"); 1568 frame.src = src("inline"); 1569 frame.title = "Produktberater"; 1570 frame.setAttribute("loading", "lazy"); 1571 frame.style.cssText = 1572 "width:100%;max-width:100%;height:620px;border:0;display:block;background:transparent"; 1573 host.appendChild(frame); 1574 global.addEventListener("message", function (e) { 1575 if (e.origin !== ORIGIN) return; 1576 var d = e.data || {}; 1577 if (String(d.id) !== String(id)) return; 1578 if (d.type === "shopflare:resize" && d.height) frame.style.height = d.height + "px"; 1579 if (d.type === "shopflare:hide") host.remove(); 1580 }); 1581 } 1582 if ("IntersectionObserver" in global) { 1583 var io = new IntersectionObserver( 1584 function (entries) { 1585 for (var i = 0; i < entries.length; i++) { 1586 if (entries[i].isIntersecting) { 1587 io.disconnect(); 1588 load(); 1589 } 1590 } 1591 }, 1592 { rootMargin: "300px" }, 1593 ); 1594 io.observe(host); 1595 } else { 1596 load(); 1597 } 1598 } 1599 1600 function start() { 1601 if (!id) return; 1602 var flag = "__shopflare_" + id; 1603 if (global[flag]) return; 1604 global[flag] = true; 1605 if (mode === "inline") initInline(); 1606 else initPopup(); 1607 } 1608 1609 ready(function () { 1610 if (id) { 1611 start(); 1612 return; 1613 } 1614 if (!shop) return; 1615 fetch(BOOTSTRAP_URL + "?shop=" + encodeURIComponent(shop), { cache: "default" }) 1616 .then(function (response) { 1617 var contentType = response.headers.get("content-type") || ""; 1618 return response.ok && contentType.indexOf("application/json") !== -1 1619 ? response.json() 1620 : null; 1621 }) 1622 .then(function (payload) { 1623 if (!payload || !payload.resolved_id) return; 1624 id = String(payload.resolved_id); 1625 start(); 1626 }) 1627 .catch(function () {}); 1628 }); 1629 } 1630 1631 function initShopifyChatbotByShop() { 1632 var shop = (script.getAttribute("data-shop") || "").toLowerCase().trim(); 1633 if (!/^[a-z0-9-]+\.myshopify\.com$/.test(shop)) return; 1634 fetch(API_BASE + "/functions/v1/shopify-chatbot-bootstrap?shop=" + encodeURIComponent(shop), { 1635 method: "GET", 1636 mode: "cors", 1637 credentials: "omit", 1638 headers: { Accept: "application/json", "x-shopflare-loader": LOADER_VERSION }, 1639 }) 1640 .then(function (response) { 1641 return response.ok ? response.json() : null; 1642 }) 1643 .then(function (payload) { 1644 if (!payload || !payload.widget_id) return; 1645 chatbotId = String(payload.widget_id); 1646 initChatbot(chatbotId); 1647 }) 1648 .catch(function () {}); 1649 } 1650 1651 /* ============================================================== boot */ 1652 if (chatbotId) {
1653 initChatbot(chatbotId); 1654 } else if ( 1655 script.getAttribute("data-shopflare-platform") === "shopify" && 1656 script.getAttribute("data-shop") 1657 ) { 1658 initShopifyChatbotByShop(); 1659 } else if (beraterId || script.getAttribute("data-shop")) { 1660 initBerater(); 1661 } 1662})(typeof window !== "undefined" ? window : this);
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.