PageSourceSearch

https://ai.boxnow.hr/chat-widget.js

js boxnow.hr collected 2026-10-01 10:11:51 UTC 69,285 bytes, 2,143 lines download raw bytes

1// Populated at runtime by the server via JSON.stringify()
2const config = {"blobUrl":"https://chatbotquestionshr.blob.core.windows.net/questions","welcomeMessage":"Dobrodošli! Kako vam mogu pomoći danas?","headerTitleOne":"Bok! Ja sam AI chatbot i","headerTitleTwo":"stvoren sam da ti pomognem!","footerText":"* Naš digitalni asistent je u Beta verziji i neprestano se razvija.","startingMessage":"Upiši pitanje ili odaberi jednu od opcija","loadingMessage":"Razmišljam","chatPlaceholder":"Kako ti mogu pomoći","country":"hr"};
3
4// Our main initialization function: pass in homeURL and language
5function initChatWidget(homeURL, language) {
6  function createSafeElement(tag, attributes = {}, textContent = '') {
7    const element = document.createElement(tag)
8
9    // Set attributes safely
10    Object.entries(attributes).forEach(([key, value]) => {
11      if (key === 'className') {
12        element.className = value
13      } else if (key === 'style') {
14        Object.entries(value).forEach(([prop, val]) => {
15          element.style[prop] = val
16        })
17      } else {
18        element.setAttribute(key, value)
19      }
20    })
21
22    // Set text content safely
23    if (textContent) {
24      element.textContent = textContent
25    }
26
27    return element
28  }
29
30  function urlWithoutQueryParams(url) {
31    if (!url || typeof url !== 'string') return url
32    try {
33      const u = new URL(url)
34      u.search = ''
35      return u.href;
36    } catch {
37      const q = url.indexOf('?')
38      return q === -1 ? url : url.slice(0, q)
39    }
40  }
41
42  // Chip art is mixed: white glyphs on transparency, and colored marks on an
43  // opaque light plate. Flatten both to a white glyph so the purple disc shows.
44  function prepareChipIcon(url) {
45    return new Promise((resolve) => {
46      const image = new Image()
47      image.crossOrigin = 'anonymous'
48      image.onload = () => {
49        try {
50          const w = image.naturalWidth || image.width
51          const h = image.naturalHeight || image.height
52          if (!w || !h) {
53            resolve(url)
54            return
55          }
56          const canvas = document.createElement('canvas')
57          canvas.width = w
58          canvas.height = h
59          const ctx = canvas.getContext('2d', { willReadFrequently: true })
60          ctx.drawImage(image, 0, 0)
61          const imageData = ctx.getImageData(0, 0, w, h)
62          const px = imageData.data
63          let transparent = 0
64          const total = px.length / 4
65          for (let i = 3; i < px.length; i += 4) {
66            if (px[i] < 16) transparent++
67          }
68          const hasTransparency = transparent / total > 0.15
69          for (let i = 0; i < px.length; i += 4) {
70            const r = px[i]
71            const g = px[i + 1]
72            const b = px[i + 2]
73            const a = px[i + 3]
74            if (a < 16) {
75              px[i + 3] = 0
76              continue
77            }
78            const max = Math.max(r, g, b)
79            const min = Math.min(r, g, b)
80            const lum = (r + g + b) / 3
81            const sat = max === 0 ? 0 : (max - min) / max
82            // Opaque light plates (the question-mark tiles) are background.
83            if (!hasTransparency && sat < 0.25 && lum > 170) {
84              px[i + 3] = 0
85              continue
86            }
87            px[i] = 255
88            px[i + 1] = 255
89            px[i + 2] = 255
90          }
91          ctx.putImageData(imageData, 0, 0)
92          resolve(canvas.toDataURL('image/png'))
93        } catch {
94          resolve(url)
95        }
96      }
97      image.onerror = () => resolve(url)
98      image.src = url
99    })
100  }
101  const existingHost = document.getElementById('chat-widget-host')
102  if (existingHost) {
103    existingHost.remove()
104    console.log('Removed duplicate chat widget host')
105  }
106
107  // Create a container div that will host our Shadow DOM
108  const widgetHost = document.createElement('div')
109  widgetHost.id = 'chat-widget-host'
110  widgetHost.setAttribute('role', 'region')
111  widgetHost.setAttribute('aria-label', 'Chat widget')
112  // Position the host similarly to before (fixed to bottom/right)
113  widgetHost.style.position = 'fixed'
114  widgetHost.style.bottom = '0'
115  widgetHost.style.right = '0'
116  widgetHost.style.zIndex = '9999'
117  widgetHost.style.width = 'fit-content'
118  widgetHost.style.overflow = 'visible'
119
120  // Append our host to the document body
121  document.body.appendChild(widgetHost)
122
123  // Create the shadow root; mode open so we can access it
124  const shadow = widgetHost.attachShadow({ mode: 'open' })
125
126  // Prefetch Effra so first open does not wait on font download
127  ;['Effra-Regular.woff2', 'Effra-Medium.woff2', 'Effra-Bold.woff2'].forEach(
128    (file) => {
129      const link = document.createElement('link')
130      link.rel = 'preload'
131      link.as = 'font'
132      link.type = 'font/woff2'
133      link.crossOrigin = 'anonymous'
134      link.href = `${homeURL}/assets/fonts/${file}`
135      document.head.appendChild(link)
136    },
137  )
138
139  addLinkStyles(shadow)
140
141  // Load Tailwind separately so it does not delay widget sizing CSS
142  const tailwindLink = document.createElement('link')
143  tailwindLink.rel = 'stylesheet'
144  tailwindLink.href =
145    'https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.16/tailwind.min.css'
146  shadow.appendChild(tailwindLink)
147
148  // Create a wrapper element that will contain our widget
149  const wrapper = document.createElement('div')
150  wrapper.id = 'chat-widget-wrapper'
151  shadow.appendChild(wrapper)
152
153  // Create a style element and include all of the widget’s styles here.
154  const style = document.createElement('style')
155  style.textContent = `
156      @font-face {
157        font-family: "Effra";
158        src: url("${homeURL}/assets/fonts/Effra-Regular.woff2") format("woff2"),
159             url("${homeURL}/assets/fonts/Effra-Regular.ttf") format("truetype");
160        font-weight: 400;
161        font-style: normal;
162        font-display: swap;
163      }
164      @font-face {
165        font-family: "Effra";
166        src: url("${homeURL}/assets/fonts/Effra-Medium.woff2") format("woff2"),
167             url("${homeURL}/assets/fonts/Effra-Medium.ttf") format("truetype");
168        font-weight: 500;
169        font-style: normal;
170        font-display: swap;
171      }
172      @font-face {
173        font-family: "Effra";
174        src: url("${homeURL}/assets/fonts/Effra-Bold.woff2") format("woff2"),
175             url("${homeURL}/assets/fonts/Effra-Bold.ttf") format("truetype");
176        font-weight: 700;
177        font-style: normal;
178        font-display: swap;
179      }
180      :host {
181        all: initial;
182        font-family: "Effra", sans-serif;
183        font-synthesis: none;
184        letter-spacing: 0;
185        --bn-purple: #31006f;
186        --bn-purple-deep: #260563;
187        --bn-green: #2fa81d;
188        --bn-green-hover: #279318;
189        --bn-green-soft: #ecfbea;
190        --bn-green-border: #45d62e;
191        --bn-page: #efe7fa;
192        --bn-surface: #f8f8f4;
193        --bn-card: #ffffff;
194        --bn-text: #2a2a2a;
195        --bn-muted: #5a5a5a;
196        --bn-border: #efefe6;
197        --bn-shadow: 0 12px 40px rgba(49, 0, 111, 0.18);
198      }
199      #chat-widget-wrapper,
200      #chat-widget-wrapper button,
201      #chat-widget-wrapper input,
202      #chat-widget-wrapper textarea {
203        font-family: "Effra", sans-serif;
204      }
205
206      /* Helper classes and keyframes */
207      .hidden {
208        display: none !important;
209      }
210      .blinking-cursor {
211        animation: cursorBlink 1s infinite;
212        color: var(--bn-purple);
213        font-weight: 500;
214        margin-left: 2px;
215      }
216      @keyframes cursorBlink {
217        0%, 100% { opacity: 1; }
218        50% { opacity: 0; }
219      }
220      .beta-version {
221        text-align: center;
222        font-size: 11px;
223        font-weight: 400;
224        color: rgba(255, 255, 255, 0.72);
225        background-color: transparent;
226        line-height: 1.35;
227        max-width: 92%;
228      }
229      #greeting-bubble {
230        position: absolute;
231        bottom: 88px;
232        right: calc(100% - 62px);
233        transform: translate(4px, 0);
234        background-color: var(--bn-green-soft);
235        color: var(--bn-purple);
236        padding: 12px 14px;
237        border-radius: 14px;
238        border: 1px solid var(--bn-green-border);
239        box-shadow: 0 4px 16px rgba(49, 0, 111, 0.12);
240        width: 160px;
241        font-size: 12px;
242        line-height: 1.35;
243        opacity: 0;
244        transition: opacity 0.5s ease, transform 0.5s ease;
245        z-index: 1001;
246        box-sizing: border-box;
247      }
248      #greeting-bubble::after {
249        content: ' ';
250        position: absolute;
251        width: 0;
252        height: 0;
253        right: 10px;
254        bottom: -9px;
255        border: 5px solid;
256        border-color: transparent transparent transparent var(--bn-green-soft);
257        border-top: 0.45rem solid var(--bn-green-soft);
258        border-left: 1.1rem solid transparent;
259        border-right: 0.1rem solid transparent;
260      }
261      #greeting-bubble.show {
262        opacity: 1;
263        transform: translate(0, 0);
264      }
265      #close-greeting-bubble {
266        position: absolute;
267        top: 0;
268        right: 0;
269        transform: translate(50%, -50%);
270        width: 18px;
271        height: 18px;
272        border-radius: 50%;
273        background-color: var(--bn-purple);
274        color: white;
275        display: flex;
276        align-items: center;
277        justify-content: center;
278        box-shadow: 0 2px 8px rgba(49, 0, 111, 0.25);
279        cursor: pointer;
280        border: none;
281        font-size: 9px;
282      }
283      #close-greeting-bubble:hover {
284        background-color: var(--bn-purple-deep);
285      }
286      #chat-widget-container {
287        display: flex;
288        flex-direction: column;
289        align-items: flex-end;
290        position: relative;
291        width: fit-content;
292        overflow: visible;
293      }
294      #chat-bubble {
295        position: relative;
296        overflow: visible;
297        cursor: pointer;
298        left: 0;
299        width: 120px;
300        height: 120px;
301        flex-shrink: 0;
302      }
303      #chat-bubble img {
304        width: 120px;
305        height: 120px;
306        display: block;
307        object-fit: contain;
308      }
309      #chat-submit:disabled {
310        opacity: 0.45;
311        cursor: not-allowed;
312      }
313      .chat-popup-custom-width {
314        width: 420px;
315      }
316      #chat-popup {
317        z-index: 10000;
318        height: 70vh;
319        max-height: 70vh;
320        display: flex;
321        flex-direction: column;
322        opacity: 0;
323        visibility: hidden;
324        pointer-events: none;
325        transform: translate3d(0, 8px, 0);
326        transition: opacity 0.18s ease-out, transform 0.18s ease-out, visibility 0.18s ease-out;
327        background: transparent;
328        border-radius: 20px;
329        overflow: visible;
330        box-shadow: var(--bn-shadow);
331        border: none;
332      }
333      #chat-popup.popup-open {
334        opacity: 1;
335        visibility: visible;
336        pointer-events: auto;
337        transform: translate3d(0, 0, 0);
338      }
339      @media (prefers-reduced-motion: reduce) {
340        #chat-popup {
341          transition-duration: 0.01ms;
342        }
343      }
344      #chat-header {
345        background: linear-gradient(135deg, #31006f 0%, #5a338c 100%);
346        padding: 14px 12px 14px 16px !important;
347        border-bottom: 1px solid rgba(255, 255, 255, 0.08);
348        display: flex;
349        align-items: center;
350        gap: 12px;
351        min-height: 72px;
352        box-sizing: border-box;
353        border-radius: 20px 20px 0 0;
354        overflow: visible;
355        position: relative;
356      }
357      #chat-header .header-brand {
358        display: flex;
359        align-items: center;
360        gap: 12px;
361        min-width: 0;
362        flex: 1;
363        position: relative;
364      }
365      #chat-header .text-container {
366        margin-left: 72px !important;
367        min-width: 0;
368      }
369      #chat-header .header-actions {
370        display: flex;
371        align-items: center;
372        gap: 4px;
373        flex-shrink: 0;
374        margin-left: auto;
375      }
376      #chat-header .header-actions button {
377        width: 36px;
378        height: 36px;
379        border-radius: 10px;
380        display: inline-flex;
381        align-items: center;
382        justify-content: center;
383        transition: background 0.15s ease;
384        background: rgba(255, 255, 255, 0.1);
385        border: 1px solid rgba(255, 255, 255, 0.14);
386        padding: 0;
387        flex-shrink: 0;
388      }
389      #chat-header .header-actions button:hover {
390        background: rgba(255, 255, 255, 0.18);
391      }
392      #chat-header .header-actions img {
393        width: 18px;
394        height: 18px;
395        display: block;
396        /* white-on-black assets: hide black plate, keep white glyph */
397        mix-blend-mode: screen;
398      }
399      #chat-header .header-actions svg {
400        width: 18px;
401        height: 18px;
402        stroke: #fff;
403        display: block;
404      }
405      .circleboy {
406        position: absolute !important;
407        top: -28px !important;
408        left: 0 !important;
409        width: 62px !important;
410        height: 62px !important;
411        border: 4px solid #31006f !important;
412        border-radius: 50%;
413        background-color: #fff;
414        object-fit: cover;
415        flex-shrink: 0;
416        box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);
417        z-index: 12;
418      }
419      #chat-messages {
420        max-width: 100%;
421        background: var(--bn-page);
422        opacity: 1;
423        overflow-x: hidden;
424        overflow-y: auto;
425        position: relative;
426        padding: 18px 16px !important;
427        flex: 1;
428        min-height: 0;
429      }
430      .msg-user {
431        background: var(--bn-green) !important;
432        color: #fff !important;
433        border-radius: 16px 16px 4px 16px !important;
434        box-shadow: 0 2px 8px rgba(47, 168, 29, 0.25);
435        font-size: 14px;
436        line-height: 1.45;
437        padding: 10px 14px !important;
438      }
439      .msg-assistant {
440        background: var(--bn-card) !important;
441        color: var(--bn-text) !important;
442        border-radius: 16px 16px 16px 4px !important;
443        border: 1px solid var(--bn-border);
444        box-shadow: 0 1px 3px rgba(49, 0, 111, 0.06);
445        font-size: 14px;
446        line-height: 1.45;
447        padding: 10px 14px !important;
448      }
449      .msg-start {
450        background: var(--bn-surface) !important;
451        color: var(--bn-muted) !important;
452        border: 1px solid var(--bn-border);
453        border-radius: 14px !important;
454        font-size: 13px;
455        line-height: 1.4;
456      }
457      .msg-error {
458        background: #fff5f5 !important;
459        color: #d32f2f !important;
460        border: 1px solid #ffc9c9;
461        border-radius: 14px !important;
462      }
463      #chat-input-container {
464        background: linear-gradient(135deg, #5a338c 0%, #31006f 100%) !important;
465        padding: 12px 14px !important;
466        border-top: none !important;
467        border-radius: 0 0 20px 20px;
468        overflow: hidden;
469      }
470      #chat-input-row {
471        display: flex;
472        align-items: center;
473        gap: 8px;
474        width: 100%;
475        box-sizing: border-box;
476        background: #efe7fa;
477        border-radius: 999px;
478        padding: 5px 5px 5px 16px;
479        border: 1.5px solid rgba(49, 0, 111, 0.22);
480      }
481      #chat-input {
482        flex: 1;
483        border: none !important;
484        background: transparent !important;
485        color: #2a2a2a !important;
486        font-size: 16px;
487        padding: 10px 8px !important;
488        outline: none;
489        width: auto !important;
490        min-width: 0;
491        line-height: 1.3;
492      }
493      #chat-input::placeholder {
494        color: #695c63;
495        font-size: 13px;
496      }
497      #chat-submit {
498        width: 40px !important;
499        height: 40px !important;
500        border-radius: 50%;
501        background: var(--bn-green) !important;
502        border: none;
503        flex-shrink: 0;
504        display: inline-flex !important;
505        align-items: center;
506        justify-content: center;
507        transition: background 0.15s ease, transform 0.15s ease;
508        box-shadow: 0 2px 8px rgba(47, 168, 29, 0.35);
509      }
510      #chat-submit:hover:not(:disabled) {
511        background: var(--bn-green-hover) !important;
512        transform: scale(1.04);
513      }
514      #chat-submit svg {
515        width: 18px;
516        height: 18px;
517        display: block;
518        stroke: #fff;
519        fill: none;
520      }
521      .typing-indicator {
522        display: inline-flex;
523        justify-content: flex-start;
524        align-items: center;
525        width: auto;
526        background: var(--bn-card);
527        border: 1px solid var(--bn-border);
528        border-radius: 14px;
529        padding: 8px 12px;
530        margin-bottom: 8px;
531      }
532      .typing-indicator .thinking-text {
533        margin-right: 0;
534        font-size: 13px;
535        color: var(--bn-muted);
536      }
537      .typing-indicator .dots-container {
538        display: inline-flex;
539        align-items: center;
540        margin-top: 0;
541        margin-left: 4px;
542      }
543      .typing-indicator .dots-container div {
544        width: 4px;
545        height: 4px;
546        margin: 0 2px;
547        background-color: var(--bn-purple);
548        border-radius: 50%;
549        opacity: 0;
550        animation: typing 1.5s infinite;
551      }
552      @keyframes typing {
553        0% { opacity: 0; }
554        50% { opacity: 1; }
555        100% { opacity: 0; }
556      }
557      #chat-popup:fullscreen .circleboy,
558      #chat-popup:-webkit-full-screen .circleboy {
559        position: static !important;
560        top: auto !important;
561        left: auto !important;
562        width: 48px !important;
563        height: 48px !important;
564        border-width: 2px !important;
565        margin: 0;
566      }
567      #chat-popup:fullscreen .text-container,
568      #chat-popup:-webkit-full-screen .text-container {
569        margin-left: 0 !important;
570      }
571      #chat-popup:fullscreen #chat-header,
572      #chat-popup:-webkit-full-screen #chat-header {
573        border-radius: 0;
574      }
575      .bubble-btn {
576        transition: transform 0.2s ease-out, opacity 0.2s ease-out, background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
577      }
578      .bubble-btn.shrink {
579        transform: scale(0);
580        opacity: 0;
581      }
582      .code-snippet-container {
583        position: relative;
584        background-color: var(--bn-surface);
585        border: 1px solid var(--bn-border);
586        border-radius: 10px;
587        padding: 10px;
588        margin-top: 10px;
589        word-break: break-word;
590        max-width: 100%;
591        box-sizing: border-box;
592      }
593      .code-snippet-container pre,
594      .code-snippet-container code {
595        white-space: pre-wrap;
596        word-break: break-word;
597        margin: 0;
598      }
599      .assistant-message-content code {
600        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
601        font-size: 0.9em;
602        background: var(--bn-surface);
603        border: 1px solid var(--bn-border);
604        border-radius: 4px;
605        padding: 1px 5px;
606      }
607      .assistant-message-content .code-snippet-container code {
608        background: transparent;
609        border: none;
610        padding: 0;
611        font-size: inherit;
612      }
613      .assistant-message-content ul,
614      .assistant-message-content ol {
615        margin: 0.5em 0;
616        padding-left: 1.4em;
617      }
618      .assistant-message-content li {
619        margin: 0.25em 0;
620      }
621      .assistant-message-content p {
622        margin: 0.5em 0;
623      }
624      .assistant-message-content h1,
625      .assistant-message-content h2,
626      .assistant-message-content h3,
627      .assistant-message-content h4,
628      .assistant-message-content h5,
629      .assistant-message-content h6 {
630        margin: 0.6em 0 0.3em;
631        font-weight: 500;
632        line-height: 1.3;
633        color: var(--bn-purple);
634      }
635      .assistant-message-content h1 { font-size: 1.25em; }
636      .assistant-message-content h2 { font-size: 1.15em; }
637      .assistant-message-content h3 { font-size: 1.05em; }
638      .assistant-message-content h4,
639      .assistant-message-content h5,
640      .assistant-message-content h6 { font-size: 1em; }
641      .assistant-message-content blockquote {
642        margin: 0.5em 0;
643        padding-left: 0.75em;
644        border-left: 3px solid var(--bn-green-border);
645        color: var(--bn-muted);
646      }
647      .assistant-message-content hr {
648        border: none;
649        border-top: 1px solid var(--bn-border);
650        margin: 0.75em 0;
651      }
652      .assistant-message-content table {
653        border-collapse: collapse;
654        margin: 0.5em 0;
655        width: 100%;
656        font-size: 0.95em;
657      }
658      .assistant-message-content th,
659      .assistant-message-content td {
660        border: 1px solid var(--bn-border);
661        padding: 4px 8px;
662        text-align: left;
663        vertical-align: top;
664      }
665      .assistant-message-content th {
666        background: var(--bn-surface);
667        font-weight: 500;
668      }
669      .assistant-message-content pre {
670        margin: 0.5em 0;
671        padding: 8px 10px;
672        background: var(--bn-surface);
673        border: 1px solid var(--bn-border);
674        border-radius: 8px;
675        overflow-x: auto;
676        white-space: pre-wrap;
677        word-break: break-word;
678      }
679      .copy-button {
680        position: absolute;
681        top: 10px;
682        right: 10px;
683        background-color: transparent;
684        border: none;
685        cursor: pointer;
686        padding: 0;
687        z-index: 1;
688      }
689      .copy-button img {
690        width: 20px;
691        height: 20px;
692      }
693      .copy-button:hover img {
694        opacity: 0.8;
695      }
696      /* Responsive styling */
697      @media (max-width: 768px) {
698        .circleboy {
699          position: static !important;
700          top: auto !important;
701          left: auto !important;
702          width: 48px !important;
703          height: 48px !important;
704          border-width: 2px !important;
705          margin: 0;
706        }
707        #chat-header .text-container {
708          margin-left: 0 !important;
709        }
710        #chat-header .header-brand {
711          gap: 10px;
712        }
713        #chat-header {
714          border-radius: 0;
715          overflow: hidden;
716          flex-shrink: 0;
717        }
718        #chat-bubble {
719          left: 0;
720          width: 75px;
721          height: 75px;
722        }
723        #chat-bubble img {
724          width: 75px;
725          height: 75px;
726        }
727        #chat-popup {
728          position: fixed;
729          top: var(--vv-top, 0px);
730          left: var(--vv-left, 0px);
731          right: auto;
732          bottom: auto;
733          width: var(--vv-width, 100%);
734          height: var(--vv-height, 100dvh);
735          max-height: var(--vv-height, 100dvh);
736          display: flex;
737          flex-direction: column;
738          background: transparent;
739          z-index: 9999;
740          box-sizing: border-box;
741          border-radius: 0;
742          border: none;
743          overflow: hidden;
744          overscroll-behavior: none;
745          box-shadow: none;
746        }
747        #fullscreen-popup {
748          display: none;
749        }
750        #chat-messages {
751          flex: 1;
752          min-height: 0;
753          overflow-y: auto;
754          box-sizing: border-box;
755          scroll-behavior: smooth;
756          background: var(--bn-page);
757          overscroll-behavior: contain;
758        }
759        #chat-input-container {
760          flex-shrink: 0;
761          background: linear-gradient(135deg, #5a338c 0%, #31006f 100%) !important;
762          padding: 10px 12px !important;
763          padding-bottom: max(
764            10px,
765            calc(10px + env(safe-area-inset-bottom, 0px) * var(--kb-safe, 1))
766          ) !important;
767          border-radius: 0;
768        }
769        #greeting-bubble {
770          position: absolute;
771          bottom: 59px;
772          right: calc(100% - 39px);
773          transform: translate(4px, 0);
774          background-color: var(--bn-green-soft);
775          color: var(--bn-purple);
776          padding: 8px 10px;
777          border-radius: 10px;
778          border: 1px solid var(--bn-green-border);
779          box-shadow: 0 4px 16px rgba(49, 0, 111, 0.12);
780          width: 110px;
781          font-size: 9px;
782          line-height: 1.25;
783          opacity: 0;
784          transition: opacity 0.5s ease, transform 0.5s ease;
785          z-index: 1001;
786          box-sizing: border-box;
787        }
788        #greeting-bubble::after {
789          content: ' ';
790          position: absolute;
791          width: 0;
792          height: 0;
793          right: 8px;
794          bottom: -9px;
795          border: 5px solid;
796          border-color: transparent transparent transparent var(--bn-green-soft);
797          border-top: 0.45rem solid var(--bn-green-soft);
798          border-left: 1.1rem solid transparent;
799          border-right: 0.1rem solid transparent;
800        }
801        #greeting-bubble.show {
802          opacity: 1;
803          transform: translate(0, 0);
804        }
805        #close-greeting-bubble {
806          position: absolute;
807          top: 0;
808          right: 0;
809          transform: translate(50%, -50%);
810          width: 15px;
811          height: 15px;
812          border-radius: 50%;
813          background-color: rgb(38, 5, 99);
814          color: white;
815          display: flex;
816          align-items: center;
817          justify-content: center;
818          box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.25);
819          cursor: pointer;
820          border: 2px solid rgb(38, 5, 99);
821        }
822        #close-greeting-bubble:hover {
823          background-color: rgb(38, 5, 99);
824        }
825      }
826    `
827  shadow.appendChild(style)
828
829  function sanitizeHTML(text) {
830    let sanitized = escapeHtml(text) // First, escape everything
831
832    // Safe formatting tags (no attributes) — common in API/docs answers
833    const allowedTags = [
834      'strong',
835      'b',
836      'em',
837      'i',
838      'u',
839      'code',
840      'pre',
841      'p',
842      'h1',
843      'h2',
844      'h3',
845      'h4',
846      'h5',
847      'h6',
848      'ul',
849      'ol',
850      'li',
851      'table',
852      'thead',
853      'tbody',
854      'tr',
855      'th',
856      'td',
857      'blockquote',
858      'sub',
859      'sup',
860    ]
861    for (const tag of allowedTags) {
862      sanitized = sanitized.replace(
863        new RegExp(`&lt;${tag}&gt;`, 'gi'),
864        `<${tag}>`,
865      )
866      sanitized = sanitized.replace(
867        new RegExp(`&lt;\\/${tag}&gt;`, 'gi'),
868        `</${tag}>`,
869      )
870    }
871
872    // Void / self-closing tags
873    sanitized = sanitized.replace(/&lt;br\s*\/?&gt;/gi, '<br />')
874    sanitized = sanitized.replace(/&lt;hr\s*\/?&gt;/gi, '<hr />')
875
876    // Allow <a> tags with common attributes
877    sanitized = sanitized.replace(
878      /&lt;a(?:\s+href=&quot;(.*?)&quot;)?(?:\s+target=&quot;(.*?)&quot;)?(?:\s+rel=&quot;(.*?)&quot;)?(?:\s+style=&quot;(.*?)&quot;)?\s*&gt;(.*?)&lt;\/a&gt;/g,
879      (match, href, target, rel, style, linkText) => {
880        const validatedHref = href ? `href="${href}"` : ''
881        const validatedTarget = target ? ` target="${target}"` : ''
882        const validatedRel = rel ? ` rel="${rel}"` : ''
883        const validatedStyle = style
884          ? ` style="${style}"`
885          : ' style="color: blue !important; text-decoration: underline !important;"'
886        return `<a ${validatedHref}${validatedTarget}${validatedRel}${validatedStyle}>${linkText}</a>`
887      },
888    )
889
890    // Finally, unescape the &quot; entities
891    sanitized = sanitized.replace(/&quot;/g, '"')
892    return sanitized
893  }
894
895  // Insert a helper style to update --vh for mobile (inside shadow DOM)
896  const vhStyle = document.createElement('style')
897  vhStyle.textContent = `
898      :host {
899        --vh: 1vh;
900        --vv-top: 0px;
901        --vv-left: 0px;
902        --vv-width: 100%;
903        --vv-height: 100dvh;
904        --kb-safe: 1;
905      }
906    `
907  shadow.appendChild(vhStyle)
908
909  // Keep the launcher clear of the page scrollbar, and keep the open panel
910  // a little in from the right edge even when the page does not scroll.
911  // right: 0 is the layout edge: flush with a classic bar, and on top of an
912  // overlay bar. A classic bar only needs a gap; an overlay bar needs its width.
913  function pageScrollbarClearance() {
914    if (isChatFullscreen()) return 0
915    const popup = shadow.getElementById('chat-popup')
916    const isOpen = !!(popup && popup.classList.contains('popup-open'))
917    const root = document.scrollingElement || document.documentElement
918    const classicGutter = Math.max(
919      0,
920      window.innerWidth - document.documentElement.clientWidth,
921    )
922    const hasScrollbar =
923      classicGutter > 0 || root.scrollHeight > root.clientHeight + 1
924    const openGap = isOpen ? 12 : 0
925    if (!hasScrollbar) return openGap
926    if (classicGutter > 0) return Math.max(12, openGap)
927    return Math.max(17, openGap)
928  }
929
930  // Pin the mobile overlay to the visual viewport so the input stays
931  // above the on-screen keyboard (iOS Safari + Android Chrome).
932  function updateViewportVars() {
933    const vv = window.visualViewport
934    const height = vv?.height ?? window.innerHeight
935    const width = vv?.width ?? window.innerWidth
936    const top = vv?.offsetTop ?? 0
937    const left = vv?.offsetLeft ?? 0
938    const layoutHeight = window.innerHeight
939    const keyboardOpen = layoutHeight - height > 80
940    const host = shadow.host
941    host.style.setProperty('--vh', `${height * 0.01}px`)
942    host.style.setProperty('--vv-top', `${top}px`)
943    host.style.setProperty('--vv-left', `${left}px`)
944    host.style.setProperty('--vv-width', `${width}px`)
945    host.style.setProperty('--vv-height', `${height}px`)
946    host.style.setProperty('--kb-safe', keyboardOpen ? '0' : '1')
947    host.style.right = `${pageScrollbarClearance()}px`
948
949    const popup = shadow.getElementById('chat-popup')
950    if (!popup) return
951    const pinToKeyboard =
952      window.matchMedia('(max-width: 768px)').matches &&
953      popup.classList.contains('popup-open')
954    if (pinToKeyboard) {
955      popup.style.top = `${top}px`
956      popup.style.left = `${left}px`
957      popup.style.width = `${width}px`
958      popup.style.height = `${height}px`
959      popup.style.maxHeight = `${height}px`
960      popup.style.right = 'auto'
961      popup.style.bottom = 'auto'
962    } else {
963      popup.style.top = ''
964      popup.style.left = ''
965      popup.style.width = ''
966      popup.style.height = ''
967      popup.style.maxHeight = ''
968      popup.style.right = ''
969      popup.style.bottom = ''
970    }
971  }
972  let viewportRaf = 0
973  function scheduleViewportUpdate() {
974    if (viewportRaf) return
975    viewportRaf = requestAnimationFrame(() => {
976      viewportRaf = 0
977      updateViewportVars()
978    })
979  }
980  window.addEventListener('resize', scheduleViewportUpdate)
981  window.addEventListener('orientationchange', () => {
982    updateViewportVars()
983    setTimeout(updateViewportVars, 150)
984    setTimeout(updateViewportVars, 400)
985  })
986  if (window.visualViewport) {
987    window.visualViewport.addEventListener('resize', scheduleViewportUpdate)
988    window.visualViewport.addEventListener('scroll', scheduleViewportUpdate)
989  }
990  if (typeof ResizeObserver !== 'undefined') {
991    const scrollbarObserver = new ResizeObserver(scheduleViewportUpdate)
992    scrollbarObserver.observe(document.documentElement)
993    if (document.body) scrollbarObserver.observe(document.body)
994  }
995  updateViewportVars()
996
997  // Ensure safe-area env() works when the host page allows it
998  ;(function ensureViewportFitCover() {
999    let meta = document.querySelector('meta[name="viewport"]')
1000    if (!meta) {
1001      meta = document.createElement('meta')
1002      meta.name = 'viewport'
1003      meta.content =
1004        'width=device-width, initial-scale=1, viewport-fit=cover'
1005      document.head.appendChild(meta)
1006      return
1007    }
1008    if (!/viewport-fit\s*=\s*cover/i.test(meta.content)) {
1009      meta.content = `${meta.content.replace(/,\s*$/, '')}, viewport-fit=cover`
1010    }
1011  })()
1012
1013  // Variable to track if greeting bubble has been closed
1014  let isGreetingClosed = false
1015
1016  // Create the widget container HTML inside the shadow DOM
1017  wrapper.innerHTML += `
1018      <div id="chat-widget-container">
1019        <!-- Greeting Bubble -->
1020        <div id="greeting-bubble">
1021        <button
1022          id="close-greeting-bubble"
1023          aria-label="Close greeting message"
1024          type="button"
1025        >
1026          <svg
1027            xmlns="http://www.w3.org/2000/svg"
1028            fill="none"
1029            viewBox="0 0 24 24"
1030            stroke="currentColor"
1031            stroke-width="2"
1032            aria-hidden="true"
1033            focusable="false"
1034          >
1035            <path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
1036          </svg>
1037        </button>             
1038          ${
1039            language === 'en'
1040              ? 'Welcome! How can I assist you today?'
1041              : config.welcomeMessage
1042          }
1043        </div>
1044        <!-- Chat Bubble Icon -->
1045        <div id="chat-bubble">
1046          <img
1047            src="${homeURL}/assets/boynew.png"
1048            alt="Chat"
1049            width="120"
1050            height="120"
1051            style="display: block; object-fit: contain; transition: filter 0.3s ease; filter: drop-shadow(0px 2px 5px #222); cursor: pointer;"
1052            onmouseover="this.style.filter='drop-shadow(0px 2px 10px #000)';"
1053            onmouseout="this.style.filter='drop-shadow(0px 2px 5px #222)';"
1054          />
1055        </div>
1056        <!-- Chat Popup -->
1057        <div id="chat-popup" class="absolute bottom-5 right-0 flex flex-col text-sm chat-popup-custom-width">
1058          <!-- Chat Header -->
1059          <div id="chat-header" class="relative text-white">
1060            <div class="header-brand">
1061              <img src="${homeURL}/assets/circleboy.png" alt="Bot Icon" class="circleboy" />
1062              <div class="text-container">
1063                <div style="font-size: 1.15rem; font-weight: 500; font-family: 'Effra', sans-serif; color: #fff !important; margin-bottom: 2px;">
1064                  Bono
1065                </div>
1066                <div class="m-0" style="color: rgba(255,255,255,0.82) !important; font-size: 0.82rem; font-family: 'Effra', sans-serif; font-weight: 400; line-height: 1.3;">
1067                  ${
1068                    language === 'en'
1069                      ? 'Hello! I’m an AI chatbot'
1070                      : config.headerTitleOne
1071                  }
1072                </div>
1073                <div class="m-0" style="color: rgba(255,255,255,0.82) !important; font-size: 0.82rem; font-family: 'Effra', sans-serif; font-weight: 400; line-height: 1.3;">
1074                  ${
1075                    language === 'en'
1076                      ? 'designed to assist you!'
1077                      : config.headerTitleTwo
1078                  }
1079                </div>
1080              </div>
1081            </div>
1082            <div class="header-actions">
1083              <button id="sound-toggle" type="button" aria-label="Toggle sound">
1084                <img src="${homeURL}/assets/sound-on.png" alt="" />
1085              </button>
1086              <button id="fullscreen-popup" type="button" aria-label="Fullscreen">
1087                <img src="${homeURL}/assets/fullscreen.png" alt="" />
1088              </button>
1089              <button id="close-popup" type="button" aria-label="Close chat">
1090                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
1091                  <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
1092                </svg>
1093              </button>
1094            </div>
1095          </div>
1096          <!-- Chat Messages -->
1097          <div id="chat-messages" class="flex-1 overflow-y-auto"></div>
1098          <!-- Input Area -->
1099          <div id="chat-input-container">
1100            <div class="flex flex-col items-center justify-center" style="gap: 8px;">
1101              <div id="chat-input-row">
1102                <input type="text" id="chat-input" placeholder="Type your message..." />
1103                <button id="chat-submit" type="button" class="cursor-pointer" aria-label="Send message">
1104                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
1105                    <path d="M5 12h14" />
1106                    <path d="M13 6l6 6-6 6" />
1107                  </svg>
1108                </button>
1109              </div>
1110              <div class="beta-version">
1111                ${
1112                  language === 'en'
1113                    ? '* Our digital assistant is in beta and is constantly evolving.'
1114                    : config.footerText
1115                }
1116              </div>
1117            </div>
1118          </div>
1119        </div>
1120      </div>
1121    `
1122
1123  // For ease, define a helper to select elements from the shadow root
1124  function $(selector) {
1125    return shadow.querySelector(selector)
1126  }
1127
1128  // Get DOM elements from our shadow root
1129  const chatInput = $('#chat-input')
1130  const chatSubmit = $('#chat-submit')
1131  const chatMessages = $('#chat-messages')
1132  const chatBubble = $('#chat-bubble')
1133  const chatPopup = $('#chat-popup')
1134  const closePopup = $('#close-popup')
1135  const fullscreenPopup = $('#fullscreen-popup')
1136  const greetingBubble = $('#greeting-bubble')
1137  const closeGreeting = $('#close-greeting-bubble')
1138
1139  let messages = getStoredConversationFromSession()
1140  let threadId = getThreadId()
1141  let cachedQuestions = []
1142  let isSoundEnabled = true // default on
1143
1144  function updateSessionStorage() {
1145    sessionStorage.setItem('chatHistory', JSON.stringify(messages))
1146  }
1147
1148  // Show greeting when the page is ready
1149  window.addEventListener('load', () => {
1150    showGreetingBubble()
1151  })
1152
1153  // Sound toggle button functionality
1154  const soundToggleButton = $('#sound-toggle')
1155  soundToggleButton.addEventListener('click', () => {
1156    isSoundEnabled = !isSoundEnabled
1157    soundToggleButton.innerHTML = `
1158        <img src="${homeURL}/assets/${
1159          isSoundEnabled ? 'sound-on' : 'sound-off'
1160        }.png" alt="" />
1161      `
1162  })
1163  function playSound(sound) {
1164    if (isSoundEnabled && sound) {
1165      sound.play()
1166    }
1167  }
1168  const receiveSound = new Audio(`${homeURL}/assets/popmessage.mp3`)
1169  const clickSound = new Audio(`${homeURL}/assets/questionsound.mp3`)
1170
1171  // Greeting bubble close
1172  closeGreeting.addEventListener('click', hideGreetingBubble)
1173  function showGreetingBubble() {
1174    if (!isGreetingClosed) {
1175      greetingBubble.style.transition = ''
1176      greetingBubble.classList.add('show')
1177    }
1178  }
1179  function hideGreetingBubble(instant = false) {
1180    if (instant) {
1181      greetingBubble.style.transition = 'none'
1182      greetingBubble.classList.remove('show')
1183      // Keep it non-animated while closed so it cannot fade out later
1184      void greetingBubble.offsetWidth
1185    } else {
1186      greetingBubble.classList.remove('show')
1187    }
1188    isGreetingClosed = true
1189  }
1190
1191  function getStoredConversationFromSession() {
1192    const storedData = sessionStorage.getItem('chatHistory')
1193    if (!storedData) return []
1194    try {
1195      return JSON.parse(storedData)
1196    } catch (error) {
1197      console.error('Error parsing chatHistory from sessionStorage:', error)
1198      return []
1199    }
1200  }
1201
1202  // Same lifetime as chatHistory: gone when the browser session ends.
1203  // Drop a leftover localStorage id so an old conversation cannot resume.
1204  function getThreadId() {
1205    localStorage.removeItem('thread-id')
1206    return sessionStorage.getItem('thread-id')
1207  }
1208  function cleanMessage(text) {
1209    return text.replace(/【\d+:\d+†[^】]+】/g, '')
1210  }
1211
1212  // Fullscreen helper.
1213  // requestFullscreen() on a node inside the shadow tree reports the host
1214  // as document.fullscreenElement, not #chat-popup.
1215  function getFullscreenElement() {
1216    return document.fullscreenElement || document.webkitFullscreenElement || null
1217  }
1218
1219  function isChatFullscreen() {
1220    const fullscreenElement = getFullscreenElement()
1221    if (!fullscreenElement) return false
1222    return fullscreenElement === chatPopup || fullscreenElement === widgetHost
1223  }
1224
1225  function exitChatFullscreen() {
1226    const exit = document.exitFullscreen || document.webkitExitFullscreen
1227    if (!exit) return Promise.resolve()
1228    try {
1229      const result = exit.call(document)
1230      return result && typeof result.then === 'function'
1231        ? result
1232        : Promise.resolve()
1233    } catch (err) {
1234      return Promise.reject(err)
1235    }
1236  }
1237
1238  async function tryEnterFullscreen() {
1239    if (isChatFullscreen()) return
1240    try {
1241      await chatPopup.requestFullscreen()
1242    } catch (err) {
1243      console.error('Failed to enter fullscreen:', err)
1244    }
1245  }
1246
1247  // Toggle chat popup open/close
1248  function fillPopupContent() {
1249    if (messages && messages.length > 0) {
1250      if (chatMessages.childElementCount === 0) {
1251        loadMessagesFromMemory()
1252      }
1253      return
1254    }
1255    // Already prepared during idle prefetch — avoid DOM work on open
1256    if ($('#question-buttons')) return
1257    if (cachedQuestions.length > 0) {
1258      generateQuestionButtons(cachedQuestions, true)
1259    } else {
1260      loadQuestionsFromBlob()
1261    }
1262  }
1263
1264  function preparePopupContent() {
1265    if (messages && messages.length > 0) return
1266    if (!cachedQuestions.length) return
1267    if ($('#question-buttons')) return
1268    generateQuestionButtons(cachedQuestions, true)
1269  }
1270
1271  function warmPopupPaint() {
1272    // Force one off-screen composite/layout pass before the user clicks
1273    const prevVisibility = chatPopup.style.visibility
1274    const prevOpacity = chatPopup.style.opacity
1275    chatPopup.style.visibility = 'visible'
1276    chatPopup.style.opacity = '0.001'
1277    void chatPopup.offsetHeight
1278    requestAnimationFrame(() => {
1279      chatPopup.style.visibility = prevVisibility
1280      chatPopup.style.opacity = prevOpacity
1281    })
1282  }
1283
1284  function isMobileViewport() {
1285    return window.matchMedia('(max-width: 768px)').matches
1286  }
1287
1288  function setMobilePageLock(lock) {
1289    const html = document.documentElement
1290    if (lock) {
1291      html.style.overflow = 'hidden'
1292      html.style.overscrollBehavior = 'none'
1293      document.body.style.overflow = 'hidden'
1294      document.body.style.overscrollBehavior = 'none'
1295    } else {
1296      html.style.overflow = ''
1297      html.style.overscrollBehavior = ''
1298      document.body.style.overflow = ''
1299      document.body.style.overscrollBehavior = ''
1300    }
1301  }
1302
1303  function togglePopup() {
1304    const isOpen = chatPopup.classList.contains('popup-open')
1305    const isMobile = isMobileViewport()
1306
1307    if (!isOpen) {
1308      if (isMobile) {
1309        setMobilePageLock(true)
1310      }
1311      hideGreetingBubble(true)
1312
1313      // Open first;
1313 hide launcher on the next frame so click isn't paying both costs
1314      void chatPopup.offsetWidth
1315      chatPopup.classList.add('popup-open')
1316      updateViewportVars()
1317      requestAnimationFrame(() => {
1318        chatBubble.classList.add('hidden')
1319      })
1320
1321      // Content should already be prebuilt; only fill as a fallback after paint
1322      requestAnimationFrame(() => {
1323        fillPopupContent()
1324        if (!isMobile) {
1325          // Focus after open paint — focus() can force style/layout
1326          setTimeout(() => chatInput.focus({ preventScroll: true }), 200)
1327        }
1328      })
1329    } else {
1330      setMobilePageLock(false)
1331      chatPopup.classList.remove('popup-open')
1332      updateViewportVars()
1333      chatBubble.classList.remove('hidden')
1334      if (!isGreetingClosed) {
1335        showGreetingBubble()
1336      }
1337    }
1338  }
1339
1340  async function prefetchQuestions() {
1341    try {
1342      const baseUrl = config.blobUrl
1343      const fileName = language === 'en' ? 'questionsEn.json' : 'questions.json'
1344      const response = await fetch(`${baseUrl}/${fileName}`, {
1345        cache: 'force-cache',
1346      })
1347      const data = await response.json()
1348      cachedQuestions = data.questions || []
1349    } catch (error) {
1350      console.error('Error prefetching questions:', error)
1351    }
1352  }
1353
1354  function preloadPopupAssets() {
1355    ;['circleboy.png', 'copy.png', 'sound-on.png', 'sound-off.png', 'fullscreen.png'].forEach(
1356      (name) => {
1357        const img = new Image()
1358        img.src = `${homeURL}/assets/${name}`
1359      },
1360    )
1361  }
1362
1363  async function loadQuestionsFromBlob() {
1364    try {
1365      if (!cachedQuestions.length) {
1366        await prefetchQuestions()
1367      }
1368      if (cachedQuestions.length) {
1369        generateQuestionButtons(cachedQuestions, messages.length === 0)
1370      }
1371    } catch (error) {
1372      console.error('Error loading questions:', error)
1373    }
1374  }
1375
1376  // Generate question buttons
1377  function generateQuestionButtons(
1378    questions,
1379    showStartingMessage = true,
1380    container = null,
1381  ) {
1382    // Remove any previous instance of the question buttons.
1383    const existing = $('#question-buttons')
1384    if (existing) existing.remove()
1385
1386    // Create the container element
1387    const buttonsContainer = createSafeElement('div', {
1388      id: 'question-buttons',
1389    })
1390
1391    // Add styles
1392    const style = createSafeElement(
1393      'style',
1394      {},
1395      `
1396        .bubble-btn {
1397            display: inline-flex;
1398            align-items: center;
1399            background-color: #ffffff;
1400            color: #257718;
1401            padding: 7px 12px 7px 8px;
1402            border-radius: 999px;
1403            border: 1px solid #45d62e;
1404            font-weight: 500;
1405            font-size: 13px;
1406            max-width: 100%;
1407            cursor: pointer;
1408            white-space: nowrap;
1409            margin: 3px;
1410            transition: transform 0.2s ease-out, opacity 0.2s ease-out, background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
1411            box-shadow: 0 1px 2px rgba(49, 0, 111, 0.06);
1412        }
1413        .bubble-btn:hover {
1414            background-color: #ecfbea;
1415            color: #1f5f14;
1416            border-color: #2fa81d;
1417        }
1418        .bubble-btn img {
1419            margin-right: 0;
1420            width: 14px;
1421            height: 14px;
1422            padding: 0;
1423            background: transparent;
1424            border-radius: 0;
1425            object-fit: contain;
1426            flex-shrink: 0;
1427            display: block;
1428        }
1429        .bubble-btn .chip-icon {
1430            width: 26px;
1431            height: 26px;
1432            margin-right: 8px;
1433            border-radius: 50%;
1434            background: linear-gradient(135deg, #31006f 0%, #5a338c 100%);
1435            display: inline-flex;
1436            align-items: center;
1437            justify-content: center;
1438            flex-shrink: 0;
1439            overflow: hidden;
1440        }
1441        .bubble-btn:hover .chip-icon {
1442            background: linear-gradient(135deg, #260563 0%, #31006f 100%);
1443        }
1444        .bubble-btn:hover img {
1445            background: transparent;
1446        }
1447        .questions-container {
1448            display: flex;
1449            flex-wrap: wrap;
1450            gap: 6px;
1451            justify-content: center;
1452            align-items: center;
1453            width: 100%;
1454        }
1455        #starting-message {
1456            display: block;
1457            width: 100%;
1458            box-sizing: border-box;
1459            margin-bottom: 10px;
1460        }
1461        .separator-hr {
1462            width: 40%;
1463            margin: 12px auto;
1464            border: none;
1465            border-top: 1px solid #efefe6;
1466        }
1467        .bubble-btn.shrink {
1468            transform: scale(0);
1469            opacity: 0;
1470        }
1471    `,
1472    )
1473    buttonsContainer.appendChild(style)
1474
1475    // Only include the starting message if showStartingMessage is true.
1476    if (showStartingMessage) {
1477      const startingMessage = createSafeElement(
1478        'div',
1479        {
1480          id: 'starting-message',
1481          className: 'inline-block msg-start py-2 px-4 mb-3',
1482        },
1483        language === 'en'
1484          ? 'Type question or choose from the options below'
1485          : config.startingMessage,
1486      )
1487      buttonsContainer.appendChild(startingMessage)
1488    } else {
1489      const separator = createSafeElement('hr', {
1490        className: 'separator-hr',
1491      })
1492      buttonsContainer.appendChild(separator)
1493    }
1494
1495    const questionsContainer = createSafeElement('div', {
1496      className: 'questions-container',
1497    })
1498
1499    questions.forEach((question) => {
1500      const button = createSafeElement('button', {
1501        className: 'bubble-btn',
1502      })
1503      // Use data attributes instead of inline onclick
1504      button.dataset.value = question.value
1505      button.addEventListener('click', function () {
1506        window.selectQuestion(this.dataset.value)
1507      })
1508
1509      const img = createSafeElement('img', {
1510        alt: '',
1511      })
1512      prepareChipIcon(urlWithoutQueryParams(question.image)).then((src) => {
1513        img.src = src
1514      })
1515      const iconWrap = createSafeElement('span', {
1516        className: 'chip-icon',
1517      })
1518      iconWrap.appendChild(img)
1519
1520      button.appendChild(iconWrap)
1521      button.appendChild(document.createTextNode(question.text))
1522      questionsContainer.appendChild(button)
1523    })
1524
1525    buttonsContainer.appendChild(questionsContainer)
1526
1527    if (showStartingMessage) {
1528      const separator = createSafeElement('hr', {
1529        className: 'separator-hr',
1530      })
1531      buttonsContainer.appendChild(separator)
1532    }
1533
1534    // Append the generated container
1535    if (container) {
1536      container.appendChild(buttonsContainer)
1537    } else {
1538      chatMessages.appendChild(buttonsContainer)
1539    }
1540
1541    chatMessages.scrollTop = chatMessages.scrollHeight
1542    if (messages.length > 0) playSound(receiveSound)
1543  }
1544
1545  // Expose selectQuestion globally for button onclicks
1546  window.selectQuestion = function (question) {
1547    playSound(clickSound)
1548    // Sanitize the question before passing it to onUserRequest
1549    onUserRequest(escapeHtml(question))
1550    const questionButtons = $('#question-buttons')
1551    if (questionButtons) {
1552      const bubbles = questionButtons.querySelectorAll('.bubble-btn')
1553      bubbles.forEach((bubble, index) => {
1554        setTimeout(() => {
1555          bubble.classList.add('shrink')
1556        }, index * 100)
1557      })
1558      setTimeout(
1559        () => {
1560          questionButtons.remove()
1561        },
1562        bubbles.length * 100 + 300,
1563      )
1564    }
1565  }
1566
1567  // On user request (streaming response)
1568  async function onUserRequest(message) {
1569    chatSubmit.disabled = true
1570    chatInput.disabled = true
1571    triggerBubbleShrink()
1572
1573    // Sanitize user message before displaying
1574    const sanitizedMessage = sanitizeHTML(message)
1575
1576    // Create user message element safely using DOM methods, not innerHTML
1577    const userMessageElement = createSafeElement('div', {
1578      className: 'flex justify-end mb-3',
1579    })
1580
1581    const messageContainer = createSafeElement(
1582      'div',
1583      {
1584        className: 'text-white msg-user max-w-[75%]',
1585      },
1586      sanitizedMessage,
1587    )
1588
1589    userMessageElement.appendChild(messageContainer)
1590    chatMessages.appendChild(userMessageElement)
1591
1592    // Always sanitize before saving to messages array
1593    messages.push({ sender: 'user', message: sanitizedMessage })
1594    updateSessionStorage()
1595    const typingIndicator = createTypingIndicator()
1596    chatMessages.appendChild(typingIndicator)
1597    try {
1598      const response = await fetch(`${homeURL}/message/gpt`, {
1599        method: 'POST',
1600        headers: {
1601          'Content-Type': 'application/json',
1602          'thread-id': threadId || '',
1603          language: language,
1604        },
1605        body: JSON.stringify({ message }),
1606      })
1607
1608      const newThreadId = response.headers.get('thread-id')
1609      if (newThreadId && newThreadId !== threadId) {
1610        sessionStorage.setItem('thread-id', newThreadId)
1611        threadId = newThreadId
1612      }
1613      if (!response.ok)
1614        throw new Error(`HTTP error! status: ${response.status}`)
1615      const assistantMessageElement = createAssistantMessageElement()
1616      assistantMessageElement.buffer = ''
1617      chatMessages.appendChild(assistantMessageElement)
1618      const textContainer = assistantMessageElement.querySelector(
1619        '.assistant-message-content',
1620      )
1621      let firstChunkReceived = false
1622      const reader = response.body.getReader()
1623      const decoder = new TextDecoder()
1624      let buffer = ''
1625      while (true) {
1626        const { done, value } = await reader.read()
1627        if (done) break
1628        buffer += decoder.decode(value, { stream: true })
1629        const chunks = buffer.split('\n\n')
1630        buffer = chunks.pop() || ''
1631        for (const chunk of chunks) {
1632          if (chunk.startsWith('data: ')) {
1633            const data = chunk.slice(6).trim()
1634            if (data === '[DONE]') {
1635              const finalContent = parseMarkdown(
1636                cleanMessage(assistantMessageElement.buffer),
1637              )
1638              textContainer.innerHTML = finalContent
1639              reattachCopyButtons(textContainer)
1640              messages.push({ sender: 'assistant', message: finalContent })
1641              updateSessionStorage()
1642              break
1643            }
1644            try {
1645              const parsed = JSON.parse(data)
1646              const chunkText = parsed.message
1647              if (chunkText) {
1648                if (!firstChunkReceived) {
1649                  typingIndicator?.remove()
1650                  firstChunkReceived = true
1651                }
1652                assistantMessageElement.buffer += chunkText
1653                const currentContent = parseMarkdown(
1654                  cleanMessage(assistantMessageElement.buffer),
1655                )
1656                textContainer.innerHTML =
1657                  currentContent + '<span class="blinking-cursor">|</span>'
1658                reattachCopyButtons(textContainer)
1659                if (isAtBottom(chatMessages)) {
1660                  textContainer.scrollIntoView({
1661                    behavior: 'smooth',
1662                    block: 'nearest',
1663                  })
1664                }
1665              }
1666            } catch (err) {
1667              console.error('Error processing chunk:', err)
1668            }
1669          }
1670        }
1671      }
1672    } catch (error) {
1673      console.error('Fetch error:', error)
1674      showErrorMessage(error.message || 'Connection failed')
1675    } finally {
1676      typingIndicator?.remove()
1677      chatSubmit.disabled = false
1678      chatInput.disabled = false
1679      if (cachedQuestions.length > 0) {
1680        generateQuestionButtons(cachedQuestions, false)
1681      }
1682    }
1683  }
1684
1685  function formatCodeBlock(lang, code) {
1686    // Code should already be sanitized, but do it again to be safe
1687    code = escapeHtml(code.trim())
1688    return `
1689      <div class="code-snippet-container">
1690        <pre><code class="language-${escapeHtml(
1691          lang || 'plaintext',
1692        )}">${code}</code></pre>
1693        <button class="copy-button">
1694          <img src="${homeURL}/assets/copy.png" alt="Copy" />
1695        </button>
1696      </div>`
1697  }
1698
1699  function parseMarkdown(text) {
1700    // First sanitize the entire text
1701    text = sanitizeHTML(text)
1702
1703    // Process code blocks first - note we're handling already sanitized content
1704    const codeBlocks = []
1705    text = text.replace(/```(\w+)?\s*([\s\S]*?)```/g, (match, lang, code) => {
1706      const placeholder = `[CODE_BLOCK_${codeBlocks.length}]`
1707      codeBlocks.push(formatCodeBlock(lang, code))
1708      return placeholder
1709    })
1710
1711    // Keep newlines inside <pre> (don't turn them into <br>)
1712    const preBlocks = []
1713    text = text.replace(/<pre>([\s\S]*?)<\/pre>/gi, (match, content) => {
1714      const placeholder = `[PRE_BLOCK_${preBlocks.length}]`
1715      preBlocks.push(`<pre>${content}</pre>`)
1716      return placeholder
1717    })
1718
1719    const blockTag =
1720      'ul|ol|li|p|h[1-6]|table|thead|tbody|tr|th|td|blockquote|pre|hr'
1721
1722    // Replace markdown with HTML (on already sanitized content)
1723    let processedText = text
1724      // Bold
1725      .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
1726      // Italic
1727      .replace(/\*(.*?)\*/g, '<em>$1</em>')
1728      // Line breaks. Replies already include <br> and also real newlines,
1729      // so cap a run of breaks at one blank line between paragraphs.
1730      .replace(/\n/g, '<br />')
1731      .replace(/(?:<br\s*\/?>\s*){3,}/gi, '<br /><br />')
1732      // Drop <br> next to block/list tags so HTML structure renders cleanly
1733      .replace(
1734        new RegExp(`<br\\s*\\/?>\\s*(<\\/?(?:${blockTag})\\s*\\/?>)`, 'gi'),
1735        '$1',
1736      )
1737      .replace(
1738        new RegExp(`(<\\/?(?:${blockTag})\\s*\\/?>)\\s*<br\\s*\\/?>`, 'gi'),
1739        '$1',
1740      )
1741      // Links - with validated URLs (improved pattern to handle more link types)
1742      .replace(/\[(.*?)\]\((.*?)\)/g, (match, label, url) => {
1743        // First handle mailto links
1744        if (url.startsWith('mailto:')) {
1745          const email = url.substring(7)
1746          if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1747            return `<a href="${url}" style="color: blue !important; text-decoration: underline !important;">${label}</a>`
1748          }
1749          return label
1750        }
1751
1752        // Then handle regular http/https links with more permissive validation
1753        if (/^(https?:\/\/)/.test(url)) {
1754          return `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: blue !important; text-decoration: underline !important;">${label}</a>`
1755        }
1756
1757        // Handle relative or protocol-relative URLs
1758        if (
1759          url.startsWith('/') ||
1760          url.startsWith('./') ||
1761          url.startsWith('../')
1762        ) {
1763          return `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: blue !important; text-decoration: underline !important;">${label}</a>`
1764        }
1765
1766        // If no protocol is specified, assume http
1767        return `<a href="http://${url}" target="_blank" rel="noopener noreferrer" style="color: blue !important; text-decoration: underline !important;">${label}</a>`
1768      })
1769      // Auto-detect URLs and email addresses that aren't in markdown format
1770      .replace(
1771        /(?<![">])(https?:\/\/[^\s<]+)/g,
1772        '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: blue !important; text-decoration: underline !important;">$1</a>',
1773      )
1774      .replace(
1775        /(\s|^|>)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g,
1776        '$1<a href="mailto:$2" style="color: blue !important; text-decoration: underline !important;">
1776$2</a>',
1777      )
1778
1779    // Restore code blocks
1780    codeBlocks.forEach((block, index) => {
1781      processedText = processedText.replace(`[CODE_BLOCK_${index}]`, block)
1782    })
1783
1784    // Restore pre blocks
1785    preBlocks.forEach((block, index) => {
1786      processedText = processedText.replace(`[PRE_BLOCK_${index}]`, block)
1787    })
1788
1789    return processedText
1790  }
1791
1792  function createAssistantMessageElement() {
1793    const element = createSafeElement('div', {
1794      className: 'flex mb-3 assistant-message-streaming',
1795    })
1796
1797    const messageContent = createSafeElement('div', {
1798      className: 'assistant-message-content msg-assistant',
1799      style: {
1800        maxWidth: '100%',
1801        wordBreak: 'break-word',
1802      },
1803    })
1804
1805    element.appendChild(messageContent)
1806    return element
1807  }
1808
1809  function isAtBottom(element) {
1810    return (
1811      element.scrollHeight - element.scrollTop <= element.clientHeight + 100
1812    )
1813  }
1814
1815  function createTypingIndicator() {
1816    const typingIndicator = document.createElement('div')
1817    typingIndicator.id = 'typing-indicator'
1818    typingIndicator.className = 'typing-indicator'
1819    typingIndicator.innerHTML = `
1820        <span class="thinking-text">${
1821          language === 'en' ? 'Thinking' : config.loadingMessage
1822        }</span>
1823        <span class="dots-container">
1824          <div></div><div></div><div></div>
1825        </span>
1826      `
1827    return typingIndicator
1828  }
1829
1830  function reattachCopyButtons(container) {
1831    const copyButtons = container.querySelectorAll('.copy-button')
1832    copyButtons.forEach((originalButton) => {
1833      const clonedButton = originalButton.cloneNode(true)
1834      clonedButton.addEventListener('click', function () {
1835        const codeContainer = this.closest('.code-snippet-container')
1836        if (!codeContainer) return
1837        const codeElement = codeContainer.querySelector('code')
1838        if (!codeElement) return
1839        const codeText = codeElement.innerText
1840        copyToClipboard(codeText, clonedButton)
1841      })
1842      originalButton.replaceWith(clonedButton)
1843    })
1844  }
1845
1846  function showErrorMessage(error) {
1847    const errorElement = document.createElement('div')
1848    errorElement.className = 'flex mb-3'
1849    errorElement.innerHTML = `
1850        <div class="msg-error max-w-[75%]">
1851          ${error.message || 'An error occurred. Please try again.'}
1852        </div>`
1853    chatMessages.appendChild(errorElement)
1854    errorElement.scrollIntoView({ behavior: 'smooth' })
1855  }
1856
1857  chatMessages.addEventListener('click', (event) => {
1858    const button = event.target.closest('.copy-button')
1859    if (!button) return
1860    const codeContainer = button.closest('.code-snippet-container')
1861    if (!codeContainer) return
1862    const codeElement = codeContainer.querySelector('code')
1863    const codeText = codeElement?.innerText || ''
1864    copyToClipboard(codeText, button)
1865  })
1866
1867  function triggerBubbleShrink() {
1868    const questionButtons = $('#question-buttons')
1869    if (questionButtons) {
1870      const bubbles = questionButtons.querySelectorAll('.bubble-btn')
1871      bubbles.forEach((bubble, index) => {
1872        setTimeout(() => {
1873          bubble.classList.add('shrink')
1874        }, index * 100)
1875      })
1876      setTimeout(
1877        () => {
1878          questionButtons.remove()
1879        },
1880        bubbles.length * 100 + 300,
1881      )
1882    }
1883  }
1884
1885  function formatMessageWithCopyButton(message) {
1886    const codeBlockRegex = /```(\w+)?\s*([\s\S]*?)```/g
1887    let formattedMessage = message
1888    formattedMessage = formattedMessage.replace(
1889      codeBlockRegex,
1890      (match, lang, code) => {
1891        if (lang === 'json' && code.trim().startsWith('POST')) {
1892          const [endpointLine, ...jsonLines] = code.split('\n')
1893          const endpoint = endpointLine.trim()
1894          const jsonBody = jsonLines.join('\n')
1895          return `
1896            <div class="code-snippet-container">
1897              <pre><code class="language-http">${escapeHtml(
1898                endpoint,
1899              )}</code></pre>
1900              <button class="copy-button">
1901                <img src="${homeURL}/assets/copy.png" alt="Copy" />
1902              </button>
1903            </div>
1904            <div class="code-snippet-container">
1905              <pre><code class="language-json">${escapeHtml(
1906                jsonBody,
1907              )}</code></pre>
1908              <button class="copy-button">
1909                <img src="${homeURL}/assets/copy.png" alt="Copy" />
1910              </button>
1911            </div>`
1912        } else {
1913          return `
1914            <div class="code-snippet-container">
1915              <pre><code class="language-${lang || 'plaintext'}">${escapeHtml(
1916                code,
1917              )}</code></pre>
1918              <button class="copy-button">
1919                <img src="${homeURL}/assets/copy.png" alt="Copy" />
1920              </button>
1921            </div>`
1922        }
1923      },
1924    )
1925    return `
1926        <div class="assistant-message-content msg-assistant" style="max-width: 100%; word-break: break-word;">
1927          ${formattedMessage}
1928        </div>`
1929  }
1930
1931  function addLinkStyles(shadow) {
1932    const linkStyles = document.createElement('style')
1933    linkStyles.textContent = `
1934      a, a:link, a:visited {
1935        color: #31006f !important;
1936        text-decoration: underline !important;
1937      }
1938      a:hover {
1939        color: #5a338c !important;
1940        text-decoration: underline !important;
1941      }
1942      .assistant-message-content a {
1943        color: #31006f !important;
1944        text-decoration: underline !important;
1945      }
1946      .assistant-message-content a:hover {
1947        color: #5a338c !important;
1948      }
1949    `
1950    shadow.appendChild(linkStyles)
1951  }
1952
1953  function escapeHtml(unsafe) {
1954    return unsafe
1955      .replace(/&/g, '&amp;')
1956      .replace(/</g, '&lt;')
1957      .replace(/>/g, '&gt;')
1958      .replace(/"/g, '&quot;')
1959  }
1960
1961  function copyToClipboard(text, button) {
1962    if (!navigator.clipboard) {
1963      console.error('Clipboard API not available')
1964      return
1965    }
1966    navigator.clipboard
1967      .writeText(text)
1968      .then(() => {
1969        showCopyConfirmation(button, true)
1970      })
1971      .catch((err) => {
1972        console.error('Failed to copy:', err)
1973        showCopyConfirmation(button, false)
1974      })
1975  }
1976
1977  function showCopyConfirmation(button, success = true) {
1978    if (!shadow.contains(button)) {
1979      console.error('Button not in DOM')
1980      return
1981    }
1982    const confirmation = document.createElement('div')
1983    confirmation.className = 'copy-confirmation'
1984    confirmation.textContent = success ? 'Copied!' : 'Failed to copy'
1985    confirmation.style.backgroundColor = success
1986      ? 'rgba(0, 0, 0, 0.7)'
1987      : 'rgba(255, 0, 0, 0.7)'
1988    const buttonStyle = window.getComputedStyle(button)
1989    if (buttonStyle.position === 'static') {
1990      button.style.position = 'relative'
1991    }
1992    confirmation.style.position = 'absolute'
1993    confirmation.style.color = '#fff'
1994    confirmation.style.padding = '4px 8px'
1995    confirmation.style.borderRadius = '4px'
1996    confirmation.style.fontSize = '12px'
1997    confirmation.style.whiteSpace = 'nowrap'
1998    confirmation.style.zIndex = '10'
1999    confirmation.style.top = '100%'
2000    confirmation.style.left = '50%'
2001    confirmation.style.transform = 'translateX(-50%)'
2002    confirmation.style.marginTop = '5px'
2003    confirmation.style.opacity = '0'
2004    confirmation.style.transition = 'opacity 0.3s ease'
2005    button.appendChild(confirmation)
2006    requestAnimationFrame(() => {
2007      confirmation.style.opacity = '1'
2008    })
2009    setTimeout(() => {
2010      confirmation.style.opacity = '0'
2011      setTimeout(() => {
2012        confirmation.remove()
2013      }, 300)
2014    }, 2000)
2015  }
2016
2017  function loadMessagesFromMemory() {
2018    // Clear the chat container.
2019    chatMessages.innerHTML = ''
2020
2021    // Render all stored messages.
2022    messages.forEach(({ sender, message }) => {
2023      if (sender === 'user') {
2024        const messageElement = createSafeElement('div', {
2025          className: 'flex justify-end mb-3',
2026        })
2027
2028        const messageContent = createSafeElement('div', {
2029          className: 'text-white msg-user max-w-[75%]',
2030        })
2031
2032        // Set text content safely
2033        messageContent.innerHTML = message // Message should already be sanitized when stored
2034
2035        messageElement.appendChild(messageContent)
2036        chatMessages.appendChild(messageElement)
2037      } else {
2038        const messageElement = createSafeElement('div', {
2039          className: 'flex mb-3',
2040        })
2041
2042        // Format assistant messages (these should be trusted content)
2043        const formattedMessage = formatMessageWithCopyButton(message)
2044        const tempContainer = createSafeElement('div')
2045        tempContainer.innerHTML = formattedMessage
2046
2047        while (tempContainer.firstChild) {
2048          messageElement.appendChild(tempContainer.firstChild)
2049        }
2050
2051        chatMessages.appendChild(messageElement)
2052      }
2053    })
2054
2055    // Now append bubble questions without the starting message.
2056    if (cachedQuestions && cachedQuestions.length > 0) {
2057      generateQuestionButtons(cachedQuestions, false)
2058    } else {
2059      loadQuestionsFromBlob() // Will load questions and, since history exists, call with false.
2060    }
2061
2062    // Scroll to the bottom after rendering.
2063    setTimeout(() => {
2064      chatMessages.scrollTop = chatMessages.scrollHeight
2065    }, 200)
2066  }
2067
2068  // Event listeners
2069  chatSubmit.addEventListener('click', () => {
2070    const message = chatInput.value.trim()
2071    if (!message) return
2072    chatInput.value = ''
2073    onUserRequest(message)
2074  })
2075  chatInput.addEventListener('keyup', (event) => {
2076    if (event.key === 'Enter') chatSubmit.click()
2077  })
2078  chatBubble.addEventListener('click', togglePopup)
2079  fullscreenPopup.addEventListener('click', async () => {
2080    if (!isChatFullscreen()) {
2081      await tryEnterFullscreen()
2082    } else {
2083      try {
2084        await exitChatFullscreen()
2085      } catch (err) {
2086        console.error('Failed to exit fullscreen:', err)
2087      }
2088    }
2089  })
2090  closePopup.addEventListener('click', () => {
2091    if (!isChatFullscreen()) {
2092      togglePopup()
2093      return
2094    }
2095    exitChatFullscreen()
2096      .then(() => togglePopup())
2097      .catch((err) => {
2098        console.error('Failed to exit fullscreen:', err)
2099        togglePopup()
2100      })
2101  })
2102  if (chatInput) {
2103    chatInput.placeholder =
2104      language === 'en' ? 'Type your message...' : config.chatPlaceholder
2105
2106    // After the keyboard animates, re-pin to the visible area so the
2107    // input stays just above it instead of the page scrolling the chat up.
2108    const pinAfterKeyboard = () => {
2109      updateViewportVars()
2110      ;[50, 150, 300, 500].forEach((ms) => setTimeout(updateViewportVars, ms))
2111    }
2112    chatInput.addEventListener('focus', pinAfterKeyboard)
2113    chatInput.addEventListener('blur', pinAfterKeyboard)
2114  }
2115
2116  // Warm caches so the first open is not competing with network/image decode
2117  preloadPopupAssets()
2118  prefetchQuestions().then(() => {
2119    preparePopupContent()
2120    const warm = () => warmPopupPaint()
2121    if (typeof requestIdleCallback === 'function') {
2122      requestIdleCallback(warm, { timeout: 1500 })
2123    } else {
2124      setTimeout(warm, 300)
2125    }
2126  })
2127} //End initChatWidget
2128
2129// When the chatbot script is loaded, grab the script tag to get the home URL and language setting.
2130window.addEventListener('DOMContentLoaded', () => {
2131  const scriptEl = document.getElementById('chatbot-autoload')
2132  if (!scriptEl) return
2133
2134  // Prevent multiple initializations
2135  if (window.chatWidgetInitialized) return
2136  window.chatWidgetInitialized = true
2137
2138  const homeURL = new URL(scriptEl.src).origin
2139  const language = window.location.pathname.startsWith('/en')
2140    ? 'en'
2141    : config.country
2142  initChatWidget(homeURL, language)
2143})

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.