PageSourceSearch

https://shopflare.ai/widget.js

js shopflare.ai collected 2026-10-05 14:50:45 UTC 59,860 bytes, 1,662 lines download raw bytes

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.