PageSourceSearch

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

js boxnow.hr collected 2026-09-24 08:44:53 UTC 68,030 bytes, 2,116 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  // Pin the mobile overlay to the visual viewport so the input stays
910  // above the on-screen keyboard (iOS Safari + Android Chrome).
911  function updateViewportVars() {
912    const vv = window.visualViewport
913    const height = vv?.height ?? window.innerHeight
914    const width = vv?.width ?? window.innerWidth
915    const top = vv?.offsetTop ?? 0
916    const left = vv?.offsetLeft ?? 0
917    const layoutHeight = window.innerHeight
918    const keyboardOpen = layoutHeight - height > 80
919    const host = shadow.host
920    host.style.setProperty('--vh', `${height * 0.01}px`)
921    host.style.setProperty('--vv-top', `${top}px`)
922    host.style.setProperty('--vv-left', `${left}px`)
923    host.style.setProperty('--vv-width', `${width}px`)
924    host.style.setProperty('--vv-height', `${height}px`)
925    host.style.setProperty('--kb-safe', keyboardOpen ? '0' : '1')
926
927    const popup = shadow.getElementById('chat-popup')
928    if (!popup) return
929    const pinToKeyboard =
930      window.matchMedia('(max-width: 768px)').matches &&
931      popup.classList.contains('popup-open')
932    if (pinToKeyboard) {
933      popup.style.top = `${top}px`
934      popup.style.left = `${left}px`
935      popup.style.width = `${width}px`
936      popup.style.height = `${height}px`
937      popup.style.maxHeight = `${height}px`
938      popup.style.right = 'auto'
939      popup.style.bottom = 'auto'
940    } else {
941      popup.style.top = ''
942      popup.style.left = ''
943      popup.style.width = ''
944      popup.style.height = ''
945      popup.style.maxHeight = ''
946      popup.style.right = ''
947      popup.style.bottom = ''
948    }
949  }
950  let viewportRaf = 0
951  function scheduleViewportUpdate() {
952    if (viewportRaf) return
953    viewportRaf = requestAnimationFrame(() => {
954      viewportRaf = 0
955      updateViewportVars()
956    })
957  }
958  window.addEventListener('resize', scheduleViewportUpdate)
959  window.addEventListener('orientationchange', () => {
960    updateViewportVars()
961    setTimeout(updateViewportVars, 150)
962    setTimeout(updateViewportVars, 400)
963  })
964  if (window.visualViewport) {
965    window.visualViewport.addEventListener('resize', scheduleViewportUpdate)
966    window.visualViewport.addEventListener('scroll', scheduleViewportUpdate)
967  }
968  updateViewportVars()
969
970  // Ensure safe-area env() works when the host page allows it
971  ;(function ensureViewportFitCover() {
972    let meta = document.querySelector('meta[name="viewport"]')
973    if (!meta) {
974      meta = document.createElement('meta')
975      meta.name = 'viewport'
976      meta.content =
977        'width=device-width, initial-scale=1, viewport-fit=cover'
978      document.head.appendChild(meta)
979      return
980    }
981    if (!/viewport-fit\s*=\s*cover/i.test(meta.content)) {
982      meta.content = `${meta.content.replace(/,\s*$/, '')}, viewport-fit=cover`
983    }
984  })()
985
986  // Variable to track if greeting bubble has been closed
987  let isGreetingClosed = false
988
989  // Create the widget container HTML inside the shadow DOM
990  wrapper.innerHTML += `
991      <div id="chat-widget-container">
992        <!-- Greeting Bubble -->
993        <div id="greeting-bubble">
994        <button
995          id="close-greeting-bubble"
996          aria-label="Close greeting message"
997          type="button"
998        >
999          <svg
1000            xmlns="http://www.w3.org/2000/svg"
1001            fill="none"
1002            viewBox="0 0 24 24"
1003            stroke="currentColor"
1004            stroke-width="2"
1005            aria-hidden="true"
1006            focusable="false"
1007          >
1008            <path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
1009          </svg>
1010        </button>             
1011          ${
1012            language === 'en'
1013              ? 'Welcome! How can I assist you today?'
1014              : config.welcomeMessage
1015          }
1016        </div>
1017        <!-- Chat Bubble Icon -->
1018        <div id="chat-bubble">
1019          <img
1020            src="${homeURL}/assets/boynew.png"
1021            alt="Chat"
1022            width="120"
1023            height="120"
1024            style="display: block; object-fit: contain; transition: filter 0.3s ease; filter: drop-shadow(0px 2px 5px #222); cursor: pointer;"
1025            onmouseover="this.style.filter='drop-shadow(0px 2px 10px #000)';"
1026            onmouseout="this.style.filter='drop-shadow(0px 2px 5px #222)';"
1027          />
1028        </div>
1029        <!-- Chat Popup -->
1030        <div id="chat-popup" class="absolute bottom-5 right-0 flex flex-col text-sm chat-popup-custom-width">
1031          <!-- Chat Header -->
1032          <div id="chat-header" class="relative text-white">
1033            <div class="header-brand">
1034              <img src="${homeURL}/assets/circleboy.png" alt="Bot Icon" class="circleboy" />
1035              <div class="text-container">
1036                <div style="font-size: 1.15rem; font-weight: 500; font-family: 'Effra', sans-serif; color: #fff !important; margin-bottom: 2px;">
1037                  Bono
1038                </div>
1039                <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;">
1040                  ${
1041                    language === 'en'
1042                      ? 'Hello! I’m an AI chatbot'
1043                      : config.headerTitleOne
1044                  }
1045                </div>
1046                <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;">
1047                  ${
1048                    language === 'en'
1049                      ? 'designed to assist you!'
1050                      : config.headerTitleTwo
1051                  }
1052                </div>
1053              </div>
1054            </div>
1055            <div class="header-actions">
1056              <button id="sound-toggle" type="button" aria-label="Toggle sound">
1057                <img src="${homeURL}/assets/sound-on.png" alt="" />
1058              </button>
1059              <button id="fullscreen-popup" type="button" aria-label="Fullscreen">
1060                <img src="${homeURL}/assets/fullscreen.png" alt="" />
1061              </button>
1062              <button id="close-popup" type="button" aria-label="Close chat">
1063                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
1064                  <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
1065                </svg>
1066              </button>
1067            </div>
1068          </div>
1069          <!-- Chat Messages -->
1070          <div id="chat-messages" class="flex-1 overflow-y-auto"></div>
1071          <!-- Input Area -->
1072          <div id="chat-input-container">
1073            <div class="flex flex-col items-center justify-center" style="gap: 8px;">
1074              <div id="chat-input-row">
1075                <input type="text" id="chat-input" placeholder="Type your message..." />
1076                <button id="chat-submit" type="button" class="cursor-pointer" aria-label="Send message">
1077                  <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">
1078                    <path d="M5 12h14" />
1079                    <path d="M13 6l6 6-6 6" />
1080                  </svg>
1081                </button>
1082              </div>
1083              <div class="beta-version">
1084                ${
1085                  language === 'en'
1086                    ? '* Our digital assistant is in beta and is constantly evolving.'
1087                    : config.footerText
1088                }
1089              </div>
1090            </div>
1091          </div>
1092        </div>
1093      </div>
1094    `
1095
1096  // For ease, define a helper to select elements from the shadow root
1097  function $(selector) {
1098    return shadow.querySelector(selector)
1099  }
1100
1101  // Get DOM elements from our shadow root
1102  const chatInput = $('#chat-input')
1103  const chatSubmit = $('#chat-submit')
1104  const chatMessages = $('#chat-messages')
1105  const chatBubble = $('#chat-bubble')
1106  const chatPopup = $('#chat-popup')
1107  const closePopup = $('#close-popup')
1108  const fullscreenPopup = $('#fullscreen-popup')
1109  const greetingBubble = $('#greeting-bubble')
1110  const closeGreeting = $('#close-greeting-bubble')
1111
1112  let messages = getStoredConversationFromSession()
1113  let threadId = getThreadId()
1114  let cachedQuestions = []
1115  let isSoundEnabled = true // default on
1116
1117  function updateSessionStorage() {
1118    sessionStorage.setItem('chatHistory', JSON.stringify(messages))
1119  }
1120
1121  // Show greeting when the page is ready
1122  window.addEventListener('load', () => {
1123    showGreetingBubble()
1124  })
1125
1126  // Sound toggle button functionality
1127  const soundToggleButton = $('#sound-toggle')
1128  soundToggleButton.addEventListener('click', () => {
1129    isSoundEnabled = !isSoundEnabled
1130    soundToggleButton.innerHTML = `
1131        <img src="${homeURL}/assets/${
1132          isSoundEnabled ? 'sound-on' : 'sound-off'
1133        }.png" alt="" />
1134      `
1135  })
1136  function playSound(sound) {
1137    if (isSoundEnabled && sound) {
1138      sound.play()
1139    }
1140  }
1141  const receiveSound = new Audio(`${homeURL}/assets/popmessage.mp3`)
1142  const clickSound = new Audio(`${homeURL}/assets/questionsound.mp3`)
1143
1144  // Greeting bubble close
1145  closeGreeting.addEventListener('click', hideGreetingBubble)
1146  function showGreetingBubble() {
1147    if (!isGreetingClosed) {
1148      greetingBubble.style.transition = ''
1149      greetingBubble.classList.add('show')
1150    }
1151  }
1152  function hideGreetingBubble(instant = false) {
1153    if (instant) {
1154      greetingBubble.style.transition = 'none'
1155      greetingBubble.classList.remove('show')
1156      // Keep it non-animated while closed so it cannot fade out later
1157      void greetingBubble.offsetWidth
1158    } else {
1159      greetingBubble.classList.remove('show')
1160    }
1161    isGreetingClosed = true
1162  }
1163
1164  function getStoredConversationFromSession() {
1165    const storedData = sessionStorage.getItem('chatHistory')
1166    if (!storedData) return []
1167    try {
1168      return JSON.parse(storedData)
1169    } catch (error) {
1170      console.error('Error parsing chatHistory from sessionStorage:', error)
1171      return []
1172    }
1173  }
1174
1175  // Same lifetime as chatHistory: gone when the browser session ends.
1176  // Drop a leftover localStorage id so an old conversation cannot resume.
1177  function getThreadId() {
1178    localStorage.removeItem('thread-id')
1179    return sessionStorage.getItem('thread-id')
1180  }
1181  function cleanMessage(text) {
1182    return text.replace(/【\d+:\d+†[^】]+】/g, '')
1183  }
1184
1185  // Fullscreen helper.
1186  // requestFullscreen() on a node inside the shadow tree reports the host
1187  // as document.fullscreenElement, not #chat-popup.
1188  function getFullscreenElement() {
1189    return document.fullscreenElement || document.webkitFullscreenElement || null
1190  }
1191
1192  function isChatFullscreen() {
1193    const fullscreenElement = getFullscreenElement()
1194    if (!fullscreenElement) return false
1195    return fullscreenElement === chatPopup || fullscreenElement === widgetHost
1196  }
1197
1198  function exitChatFullscreen() {
1199    const exit = document.exitFullscreen || document.webkitExitFullscreen
1200    if (!exit) return Promise.resolve()
1201    try {
1202      const result = exit.call(document)
1203      return result && typeof result.then === 'function'
1204        ? result
1205        : Promise.resolve()
1206    } catch (err) {
1207      return Promise.reject(err)
1208    }
1209  }
1210
1211  async function tryEnterFullscreen() {
1212    if (isChatFullscreen()) return
1213    try {
1214      await chatPopup.requestFullscreen()
1215    } catch (err) {
1216      console.error('Failed to enter fullscreen:', err)
1217    }
1218  }
1219
1220  // Toggle chat popup open/close
1221  function fillPopupContent() {
1222    if (messages && messages.length > 0) {
1223      if (chatMessages.childElementCount === 0) {
1224        loadMessagesFromMemory()
1225      }
1226      return
1227    }
1228    // Already prepared during idle prefetch — avoid DOM work on open
1229    if ($('#question-buttons')) return
1230    if (cachedQuestions.length > 0) {
1231      generateQuestionButtons(cachedQuestions, true)
1232    } else {
1233      loadQuestionsFromBlob()
1234    }
1235  }
1236
1237  function preparePopupContent() {
1238    if (messages && messages.length > 0) return
1239    if (!cachedQuestions.length) return
1240    if ($('#question-buttons')) return
1241    generateQuestionButtons(cachedQuestions, true)
1242  }
1243
1244  function warmPopupPaint() {
1245    // Force one off-screen composite/layout pass before the user clicks
1246    const prevVisibility = chatPopup.style.visibility
1247    const prevOpacity = chatPopup.style.opacity
1248    chatPopup.style.visibility = 'visible'
1249    chatPopup.style.opacity = '0.001'
1250    void chatPopup.offsetHeight
1251    requestAnimationFrame(() => {
1252      chatPopup.style.visibility = prevVisibility
1253      chatPopup.style.opacity = prevOpacity
1254    })
1255  }
1256
1257  function isMobileViewport() {
1258    return window.matchMedia('(max-width: 768px)').matches
1259  }
1260
1261  function setMobilePageLock(lock) {
1262    const html = document.documentElement
1263    if (lock) {
1264      html.style.overflow = 'hidden'
1265      html.style.overscrollBehavior = 'none'
1266      document.body.style.overflow = 'hidden'
1267      document.body.style.overscrollBehavior = 'none'
1268    } else {
1269      html.style.overflow = ''
1270      html.style.overscrollBehavior = ''
1271      document.body.style.overflow = ''
1272      document.body.style.overscrollBehavior = ''
1273    }
1274  }
1275
1276  function togglePopup() {
1277    const isOpen = chatPopup.classList.contains('popup-open')
1278    const isMobile = isMobileViewport()
1279
1280    if (!isOpen) {
1281      if (isMobile) {
1282        setMobilePageLock(true)
1283      }
1284      hideGreetingBubble(true)
1285
1286      // Open first;
1286 hide launcher on the next frame so click isn't paying both costs
1287      void chatPopup.offsetWidth
1288      chatPopup.classList.add('popup-open')
1289      updateViewportVars()
1290      requestAnimationFrame(() => {
1291        chatBubble.classList.add('hidden')
1292      })
1293
1294      // Content should already be prebuilt; only fill as a fallback after paint
1295      requestAnimationFrame(() => {
1296        fillPopupContent()
1297        if (!isMobile) {
1298          // Focus after open paint — focus() can force style/layout
1299          setTimeout(() => chatInput.focus({ preventScroll: true }), 200)
1300        }
1301      })
1302    } else {
1303      setMobilePageLock(false)
1304      chatPopup.classList.remove('popup-open')
1305      updateViewportVars()
1306      chatBubble.classList.remove('hidden')
1307      if (!isGreetingClosed) {
1308        showGreetingBubble()
1309      }
1310    }
1311  }
1312
1313  async function prefetchQuestions() {
1314    try {
1315      const baseUrl = config.blobUrl
1316      const fileName = language === 'en' ? 'questionsEn.json' : 'questions.json'
1317      const response = await fetch(`${baseUrl}/${fileName}`, {
1318        cache: 'force-cache',
1319      })
1320      const data = await response.json()
1321      cachedQuestions = data.questions || []
1322    } catch (error) {
1323      console.error('Error prefetching questions:', error)
1324    }
1325  }
1326
1327  function preloadPopupAssets() {
1328    ;['circleboy.png', 'copy.png', 'sound-on.png', 'sound-off.png', 'fullscreen.png'].forEach(
1329      (name) => {
1330        const img = new Image()
1331        img.src = `${homeURL}/assets/${name}`
1332      },
1333    )
1334  }
1335
1336  async function loadQuestionsFromBlob() {
1337    try {
1338      if (!cachedQuestions.length) {
1339        await prefetchQuestions()
1340      }
1341      if (cachedQuestions.length) {
1342        generateQuestionButtons(cachedQuestions, messages.length === 0)
1343      }
1344    } catch (error) {
1345      console.error('Error loading questions:', error)
1346    }
1347  }
1348
1349  // Generate question buttons
1350  function generateQuestionButtons(
1351    questions,
1352    showStartingMessage = true,
1353    container = null,
1354  ) {
1355    // Remove any previous instance of the question buttons.
1356    const existing = $('#question-buttons')
1357    if (existing) existing.remove()
1358
1359    // Create the container element
1360    const buttonsContainer = createSafeElement('div', {
1361      id: 'question-buttons',
1362    })
1363
1364    // Add styles
1365    const style = createSafeElement(
1366      'style',
1367      {},
1368      `
1369        .bubble-btn {
1370            display: inline-flex;
1371            align-items: center;
1372            background-color: #ffffff;
1373            color: #257718;
1374            padding: 7px 12px 7px 8px;
1375            border-radius: 999px;
1376            border: 1px solid #45d62e;
1377            font-weight: 500;
1378            font-size: 13px;
1379            max-width: 100%;
1380            cursor: pointer;
1381            white-space: nowrap;
1382            margin: 3px;
1383            transition: transform 0.2s ease-out, opacity 0.2s ease-out, background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
1384            box-shadow: 0 1px 2px rgba(49, 0, 111, 0.06);
1385        }
1386        .bubble-btn:hover {
1387            background-color: #ecfbea;
1388            color: #1f5f14;
1389            border-color: #2fa81d;
1390        }
1391        .bubble-btn img {
1392            margin-right: 0;
1393            width: 14px;
1394            height: 14px;
1395            padding: 0;
1396            background: transparent;
1397            border-radius: 0;
1398            object-fit: contain;
1399            flex-shrink: 0;
1400            display: block;
1401        }
1402        .bubble-btn .chip-icon {
1403            width: 26px;
1404            height: 26px;
1405            margin-right: 8px;
1406            border-radius: 50%;
1407            background: linear-gradient(135deg, #31006f 0%, #5a338c 100%);
1408            display: inline-flex;
1409            align-items: center;
1410            justify-content: center;
1411            flex-shrink: 0;
1412            overflow: hidden;
1413        }
1414        .bubble-btn:hover .chip-icon {
1415            background: linear-gradient(135deg, #260563 0%, #31006f 100%);
1416        }
1417        .bubble-btn:hover img {
1418            background: transparent;
1419        }
1420        .questions-container {
1421            display: flex;
1422            flex-wrap: wrap;
1423            gap: 6px;
1424            justify-content: center;
1425            align-items: center;
1426            width: 100%;
1427        }
1428        #starting-message {
1429            display: block;
1430            width: 100%;
1431            box-sizing: border-box;
1432            margin-bottom: 10px;
1433        }
1434        .separator-hr {
1435            width: 40%;
1436            margin: 12px auto;
1437            border: none;
1438            border-top: 1px solid #efefe6;
1439        }
1440        .bubble-btn.shrink {
1441            transform: scale(0);
1442            opacity: 0;
1443        }
1444    `,
1445    )
1446    buttonsContainer.appendChild(style)
1447
1448    // Only include the starting message if showStartingMessage is true.
1449    if (showStartingMessage) {
1450      const startingMessage = createSafeElement(
1451        'div',
1452        {
1453          id: 'starting-message',
1454          className: 'inline-block msg-start py-2 px-4 mb-3',
1455        },
1456        language === 'en'
1457          ? 'Type question or choose from the options below'
1458          : config.startingMessage,
1459      )
1460      buttonsContainer.appendChild(startingMessage)
1461    } else {
1462      const separator = createSafeElement('hr', {
1463        className: 'separator-hr',
1464      })
1465      buttonsContainer.appendChild(separator)
1466    }
1467
1468    const questionsContainer = createSafeElement('div', {
1469      className: 'questions-container',
1470    })
1471
1472    questions.forEach((question) => {
1473      const button = createSafeElement('button', {
1474        className: 'bubble-btn',
1475      })
1476      // Use data attributes instead of inline onclick
1477      button.dataset.value = question.value
1478      button.addEventListener('click', function () {
1479        window.selectQuestion(this.dataset.value)
1480      })
1481
1482      const img = createSafeElement('img', {
1483        alt: '',
1484      })
1485      prepareChipIcon(urlWithoutQueryParams(question.image)).then((src) => {
1486        img.src = src
1487      })
1488      const iconWrap = createSafeElement('span', {
1489        className: 'chip-icon',
1490      })
1491      iconWrap.appendChild(img)
1492
1493      button.appendChild(iconWrap)
1494      button.appendChild(document.createTextNode(question.text))
1495      questionsContainer.appendChild(button)
1496    })
1497
1498    buttonsContainer.appendChild(questionsContainer)
1499
1500    if (showStartingMessage) {
1501      const separator = createSafeElement('hr', {
1502        className: 'separator-hr',
1503      })
1504      buttonsContainer.appendChild(separator)
1505    }
1506
1507    // Append the generated container
1508    if (container) {
1509      container.appendChild(buttonsContainer)
1510    } else {
1511      chatMessages.appendChild(buttonsContainer)
1512    }
1513
1514    chatMessages.scrollTop = chatMessages.scrollHeight
1515    if (messages.length > 0) playSound(receiveSound)
1516  }
1517
1518  // Expose selectQuestion globally for button onclicks
1519  window.selectQuestion = function (question) {
1520    playSound(clickSound)
1521    // Sanitize the question before passing it to onUserRequest
1522    onUserRequest(escapeHtml(question))
1523    const questionButtons = $('#question-buttons')
1524    if (questionButtons) {
1525      const bubbles = questionButtons.querySelectorAll('.bubble-btn')
1526      bubbles.forEach((bubble, index) => {
1527        setTimeout(() => {
1528          bubble.classList.add('shrink')
1529        }, index * 100)
1530      })
1531      setTimeout(
1532        () => {
1533          questionButtons.remove()
1534        },
1535        bubbles.length * 100 + 300,
1536      )
1537    }
1538  }
1539
1540  // On user request (streaming response)
1541  async function onUserRequest(message) {
1542    chatSubmit.disabled = true
1543    chatInput.disabled = true
1544    triggerBubbleShrink()
1545
1546    // Sanitize user message before displaying
1547    const sanitizedMessage = sanitizeHTML(message)
1548
1549    // Create user message element safely using DOM methods, not innerHTML
1550    const userMessageElement = createSafeElement('div', {
1551      className: 'flex justify-end mb-3',
1552    })
1553
1554    const messageContainer = createSafeElement(
1555      'div',
1556      {
1557        className: 'text-white msg-user max-w-[75%]',
1558      },
1559      sanitizedMessage,
1560    )
1561
1562    userMessageElement.appendChild(messageContainer)
1563    chatMessages.appendChild(userMessageElement)
1564
1565    // Always sanitize before saving to messages array
1566    messages.push({ sender: 'user', message: sanitizedMessage })
1567    updateSessionStorage()
1568    const typingIndicator = createTypingIndicator()
1569    chatMessages.appendChild(typingIndicator)
1570    try {
1571      const response = await fetch(`${homeURL}/message/gpt`, {
1572        method: 'POST',
1573        headers: {
1574          'Content-Type': 'application/json',
1575          'thread-id': threadId || '',
1576          language: language,
1577        },
1578        body: JSON.stringify({ message }),
1579      })
1580
1581      const newThreadId = response.headers.get('thread-id')
1582      if (newThreadId && newThreadId !== threadId) {
1583        sessionStorage.setItem('thread-id', newThreadId)
1584        threadId = newThreadId
1585      }
1586      if (!response.ok)
1587        throw new Error(`HTTP error! status: ${response.status}`)
1588      const assistantMessageElement = createAssistantMessageElement()
1589      assistantMessageElement.buffer = ''
1590      chatMessages.appendChild(assistantMessageElement)
1591      const textContainer = assistantMessageElement.querySelector(
1592        '.assistant-message-content',
1593      )
1594      let firstChunkReceived = false
1595      const reader = response.body.getReader()
1596      const decoder = new TextDecoder()
1597      let buffer = ''
1598      while (true) {
1599        const { done, value } = await reader.read()
1600        if (done) break
1601        buffer += decoder.decode(value, { stream: true })
1602        const chunks = buffer.split('\n\n')
1603        buffer = chunks.pop() || ''
1604        for (const chunk of chunks) {
1605          if (chunk.startsWith('data: ')) {
1606            const data = chunk.slice(6).trim()
1607            if (data === '[DONE]') {
1608              const finalContent = parseMarkdown(
1609                cleanMessage(assistantMessageElement.buffer),
1610              )
1611              textContainer.innerHTML = finalContent
1612              reattachCopyButtons(textContainer)
1613              messages.push({ sender: 'assistant', message: finalContent })
1614              updateSessionStorage()
1615              break
1616            }
1617            try {
1618              const parsed = JSON.parse(data)
1619              const chunkText = parsed.message
1620              if (chunkText) {
1621                if (!firstChunkReceived) {
1622                  typingIndicator?.remove()
1623                  firstChunkReceived = true
1624                }
1625                assistantMessageElement.buffer += chunkText
1626                const currentContent = parseMarkdown(
1627                  cleanMessage(assistantMessageElement.buffer),
1628                )
1629                textContainer.innerHTML =
1630                  currentContent + '<span class="blinking-cursor">|</span>'
1631                reattachCopyButtons(textContainer)
1632                if (isAtBottom(chatMessages)) {
1633                  textContainer.scrollIntoView({
1634                    behavior: 'smooth',
1635                    block: 'nearest',
1636                  })
1637                }
1638              }
1639            } catch (err) {
1640              console.error('Error processing chunk:', err)
1641            }
1642          }
1643        }
1644      }
1645    } catch (error) {
1646      console.error('Fetch error:', error)
1647      showErrorMessage(error.message || 'Connection failed')
1648    } finally {
1649      typingIndicator?.remove()
1650      chatSubmit.disabled = false
1651      chatInput.disabled = false
1652      if (cachedQuestions.length > 0) {
1653        generateQuestionButtons(cachedQuestions, false)
1654      }
1655    }
1656  }
1657
1658  function formatCodeBlock(lang, code) {
1659    // Code should already be sanitized, but do it again to be safe
1660    code = escapeHtml(code.trim())
1661    return `
1662      <div class="code-snippet-container">
1663        <pre><code class="language-${escapeHtml(
1664          lang || 'plaintext',
1665        )}">${code}</code></pre>
1666        <button class="copy-button">
1667          <img src="${homeURL}/assets/copy.png" alt="Copy" />
1668        </button>
1669      </div>`
1670  }
1671
1672  function parseMarkdown(text) {
1673    // First sanitize the entire text
1674    text = sanitizeHTML(text)
1675
1676    // Process code blocks first - note we're handling already sanitized content
1677    const codeBlocks = []
1678    text = text.replace(/```(\w+)?\s*([\s\S]*?)```/g, (match, lang, code) => {
1679      const placeholder = `[CODE_BLOCK_${codeBlocks.length}]`
1680      codeBlocks.push(formatCodeBlock(lang, code))
1681      return placeholder
1682    })
1683
1684    // Keep newlines inside <pre> (don't turn them into <br>)
1685    const preBlocks = []
1686    text = text.replace(/<pre>([\s\S]*?)<\/pre>/gi, (match, content) => {
1687      const placeholder = `[PRE_BLOCK_${preBlocks.length}]`
1688      preBlocks.push(`<pre>${content}</pre>`)
1689      return placeholder
1690    })
1691
1692    const blockTag =
1693      'ul|ol|li|p|h[1-6]|table|thead|tbody|tr|th|td|blockquote|pre|hr'
1694
1695    // Replace markdown with HTML (on already sanitized content)
1696    let processedText = text
1697      // Bold
1698      .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
1699      // Italic
1700      .replace(/\*(.*?)\*/g, '<em>$1</em>')
1701      // Line breaks. Replies already include <br> and also real newlines,
1702      // so cap a run of breaks at one blank line between paragraphs.
1703      .replace(/\n/g, '<br />')
1704      .replace(/(?:<br\s*\/?>\s*){3,}/gi, '<br /><br />')
1705      // Drop <br> next to block/list tags so HTML structure renders cleanly
1706      .replace(
1707        new RegExp(`<br\\s*\\/?>\\s*(<\\/?(?:${blockTag})\\s*\\/?>)`, 'gi'),
1708        '$1',
1709      )
1710      .replace(
1711        new RegExp(`(<\\/?(?:${blockTag})\\s*\\/?>)\\s*<br\\s*\\/?>`, 'gi'),
1712        '$1',
1713      )
1714      // Links - with validated URLs (improved pattern to handle more link types)
1715      .replace(/\[(.*?)\]\((.*?)\)/g, (match, label, url) => {
1716        // First handle mailto links
1717        if (url.startsWith('mailto:')) {
1718          const email = url.substring(7)
1719          if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1720            return `<a href="${url}" style="color: blue !important; text-decoration: underline !important;">${label}</a>`
1721          }
1722          return label
1723        }
1724
1725        // Then handle regular http/https links with more permissive validation
1726        if (/^(https?:\/\/)/.test(url)) {
1727          return `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: blue !important; text-decoration: underline !important;">${label}</a>`
1728        }
1729
1730        // Handle relative or protocol-relative URLs
1731        if (
1732          url.startsWith('/') ||
1733          url.startsWith('./') ||
1734          url.startsWith('../')
1735        ) {
1736          return `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: blue !important; text-decoration: underline !important;">${label}</a>`
1737        }
1738
1739        // If no protocol is specified, assume http
1740        return `<a href="http://${url}" target="_blank" rel="noopener noreferrer" style="color: blue !important; text-decoration: underline !important;">${label}</a>`
1741      })
1742      // Auto-detect URLs and email addresses that aren't in markdown format
1743      .replace(
1744        /(?<![">])(https?:\/\/[^\s<]+)/g,
1745        '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: blue !important; text-decoration: underline !important;">$1</a>',
1746      )
1747      .replace(
1748        /(\s|^|>)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g,
1749        '$1<a href="mailto:$2" style="color: blue !important; text-decoration: underline !important;">
1749$2</a>',
1750      )
1751
1752    // Restore code blocks
1753    codeBlocks.forEach((block, index) => {
1754      processedText = processedText.replace(`[CODE_BLOCK_${index}]`, block)
1755    })
1756
1757    // Restore pre blocks
1758    preBlocks.forEach((block, index) => {
1759      processedText = processedText.replace(`[PRE_BLOCK_${index}]`, block)
1760    })
1761
1762    return processedText
1763  }
1764
1765  function createAssistantMessageElement() {
1766    const element = createSafeElement('div', {
1767      className: 'flex mb-3 assistant-message-streaming',
1768    })
1769
1770    const messageContent = createSafeElement('div', {
1771      className: 'assistant-message-content msg-assistant',
1772      style: {
1773        maxWidth: '100%',
1774        wordBreak: 'break-word',
1775      },
1776    })
1777
1778    element.appendChild(messageContent)
1779    return element
1780  }
1781
1782  function isAtBottom(element) {
1783    return (
1784      element.scrollHeight - element.scrollTop <= element.clientHeight + 100
1785    )
1786  }
1787
1788  function createTypingIndicator() {
1789    const typingIndicator = document.createElement('div')
1790    typingIndicator.id = 'typing-indicator'
1791    typingIndicator.className = 'typing-indicator'
1792    typingIndicator.innerHTML = `
1793        <span class="thinking-text">${
1794          language === 'en' ? 'Thinking' : config.loadingMessage
1795        }</span>
1796        <span class="dots-container">
1797          <div></div><div></div><div></div>
1798        </span>
1799      `
1800    return typingIndicator
1801  }
1802
1803  function reattachCopyButtons(container) {
1804    const copyButtons = container.querySelectorAll('.copy-button')
1805    copyButtons.forEach((originalButton) => {
1806      const clonedButton = originalButton.cloneNode(true)
1807      clonedButton.addEventListener('click', function () {
1808        const codeContainer = this.closest('.code-snippet-container')
1809        if (!codeContainer) return
1810        const codeElement = codeContainer.querySelector('code')
1811        if (!codeElement) return
1812        const codeText = codeElement.innerText
1813        copyToClipboard(codeText, clonedButton)
1814      })
1815      originalButton.replaceWith(clonedButton)
1816    })
1817  }
1818
1819  function showErrorMessage(error) {
1820    const errorElement = document.createElement('div')
1821    errorElement.className = 'flex mb-3'
1822    errorElement.innerHTML = `
1823        <div class="msg-error max-w-[75%]">
1824          ${error.message || 'An error occurred. Please try again.'}
1825        </div>`
1826    chatMessages.appendChild(errorElement)
1827    errorElement.scrollIntoView({ behavior: 'smooth' })
1828  }
1829
1830  chatMessages.addEventListener('click', (event) => {
1831    const button = event.target.closest('.copy-button')
1832    if (!button) return
1833    const codeContainer = button.closest('.code-snippet-container')
1834    if (!codeContainer) return
1835    const codeElement = codeContainer.querySelector('code')
1836    const codeText = codeElement?.innerText || ''
1837    copyToClipboard(codeText, button)
1838  })
1839
1840  function triggerBubbleShrink() {
1841    const questionButtons = $('#question-buttons')
1842    if (questionButtons) {
1843      const bubbles = questionButtons.querySelectorAll('.bubble-btn')
1844      bubbles.forEach((bubble, index) => {
1845        setTimeout(() => {
1846          bubble.classList.add('shrink')
1847        }, index * 100)
1848      })
1849      setTimeout(
1850        () => {
1851          questionButtons.remove()
1852        },
1853        bubbles.length * 100 + 300,
1854      )
1855    }
1856  }
1857
1858  function formatMessageWithCopyButton(message) {
1859    const codeBlockRegex = /```(\w+)?\s*([\s\S]*?)```/g
1860    let formattedMessage = message
1861    formattedMessage = formattedMessage.replace(
1862      codeBlockRegex,
1863      (match, lang, code) => {
1864        if (lang === 'json' && code.trim().startsWith('POST')) {
1865          const [endpointLine, ...jsonLines] = code.split('\n')
1866          const endpoint = endpointLine.trim()
1867          const jsonBody = jsonLines.join('\n')
1868          return `
1869            <div class="code-snippet-container">
1870              <pre><code class="language-http">${escapeHtml(
1871                endpoint,
1872              )}</code></pre>
1873              <button class="copy-button">
1874                <img src="${homeURL}/assets/copy.png" alt="Copy" />
1875              </button>
1876            </div>
1877            <div class="code-snippet-container">
1878              <pre><code class="language-json">${escapeHtml(
1879                jsonBody,
1880              )}</code></pre>
1881              <button class="copy-button">
1882                <img src="${homeURL}/assets/copy.png" alt="Copy" />
1883              </button>
1884            </div>`
1885        } else {
1886          return `
1887            <div class="code-snippet-container">
1888              <pre><code class="language-${lang || 'plaintext'}">${escapeHtml(
1889                code,
1890              )}</code></pre>
1891              <button class="copy-button">
1892                <img src="${homeURL}/assets/copy.png" alt="Copy" />
1893              </button>
1894            </div>`
1895        }
1896      },
1897    )
1898    return `
1899        <div class="assistant-message-content msg-assistant" style="max-width: 100%; word-break: break-word;">
1900          ${formattedMessage}
1901        </div>`
1902  }
1903
1904  function addLinkStyles(shadow) {
1905    const linkStyles = document.createElement('style')
1906    linkStyles.textContent = `
1907      a, a:link, a:visited {
1908        color: #31006f !important;
1909        text-decoration: underline !important;
1910      }
1911      a:hover {
1912        color: #5a338c !important;
1913        text-decoration: underline !important;
1914      }
1915      .assistant-message-content a {
1916        color: #31006f !important;
1917        text-decoration: underline !important;
1918      }
1919      .assistant-message-content a:hover {
1920        color: #5a338c !important;
1921      }
1922    `
1923    shadow.appendChild(linkStyles)
1924  }
1925
1926  function escapeHtml(unsafe) {
1927    return unsafe
1928      .replace(/&/g, '&amp;')
1929      .replace(/</g, '&lt;')
1930      .replace(/>/g, '&gt;')
1931      .replace(/"/g, '&quot;')
1932  }
1933
1934  function copyToClipboard(text, button) {
1935    if (!navigator.clipboard) {
1936      console.error('Clipboard API not available')
1937      return
1938    }
1939    navigator.clipboard
1940      .writeText(text)
1941      .then(() => {
1942        showCopyConfirmation(button, true)
1943      })
1944      .catch((err) => {
1945        console.error('Failed to copy:', err)
1946        showCopyConfirmation(button, false)
1947      })
1948  }
1949
1950  function showCopyConfirmation(button, success = true) {
1951    if (!shadow.contains(button)) {
1952      console.error('Button not in DOM')
1953      return
1954    }
1955    const confirmation = document.createElement('div')
1956    confirmation.className = 'copy-confirmation'
1957    confirmation.textContent = success ? 'Copied!' : 'Failed to copy'
1958    confirmation.style.backgroundColor = success
1959      ? 'rgba(0, 0, 0, 0.7)'
1960      : 'rgba(255, 0, 0, 0.7)'
1961    const buttonStyle = window.getComputedStyle(button)
1962    if (buttonStyle.position === 'static') {
1963      button.style.position = 'relative'
1964    }
1965    confirmation.style.position = 'absolute'
1966    confirmation.style.color = '#fff'
1967    confirmation.style.padding = '4px 8px'
1968    confirmation.style.borderRadius = '4px'
1969    confirmation.style.fontSize = '12px'
1970    confirmation.style.whiteSpace = 'nowrap'
1971    confirmation.style.zIndex = '10'
1972    confirmation.style.top = '100%'
1973    confirmation.style.left = '50%'
1974    confirmation.style.transform = 'translateX(-50%)'
1975    confirmation.style.marginTop = '5px'
1976    confirmation.style.opacity = '0'
1977    confirmation.style.transition = 'opacity 0.3s ease'
1978    button.appendChild(confirmation)
1979    requestAnimationFrame(() => {
1980      confirmation.style.opacity = '1'
1981    })
1982    setTimeout(() => {
1983      confirmation.style.opacity = '0'
1984      setTimeout(() => {
1985        confirmation.remove()
1986      }, 300)
1987    }, 2000)
1988  }
1989
1990  function loadMessagesFromMemory() {
1991    // Clear the chat container.
1992    chatMessages.innerHTML = ''
1993
1994    // Render all stored messages.
1995    messages.forEach(({ sender, message }) => {
1996      if (sender === 'user') {
1997        const messageElement = createSafeElement('div', {
1998          className: 'flex justify-end mb-3',
1999        })
2000
2001        const messageContent = createSafeElement('div', {
2002          className: 'text-white msg-user max-w-[75%]',
2003        })
2004
2005        // Set text content safely
2006        messageContent.innerHTML = message // Message should already be sanitized when stored
2007
2008        messageElement.appendChild(messageContent)
2009        chatMessages.appendChild(messageElement)
2010      } else {
2011        const messageElement = createSafeElement('div', {
2012          className: 'flex mb-3',
2013        })
2014
2015        // Format assistant messages (these should be trusted content)
2016        const formattedMessage = formatMessageWithCopyButton(message)
2017        const tempContainer = createSafeElement('div')
2018        tempContainer.innerHTML = formattedMessage
2019
2020        while (tempContainer.firstChild) {
2021          messageElement.appendChild(tempContainer.firstChild)
2022        }
2023
2024        chatMessages.appendChild(messageElement)
2025      }
2026    })
2027
2028    // Now append bubble questions without the starting message.
2029    if (cachedQuestions && cachedQuestions.length > 0) {
2030      generateQuestionButtons(cachedQuestions, false)
2031    } else {
2032      loadQuestionsFromBlob() // Will load questions and, since history exists, call with false.
2033    }
2034
2035    // Scroll to the bottom after rendering.
2036    setTimeout(() => {
2037      chatMessages.scrollTop = chatMessages.scrollHeight
2038    }, 200)
2039  }
2040
2041  // Event listeners
2042  chatSubmit.addEventListener('click', () => {
2043    const message = chatInput.value.trim()
2044    if (!message) return
2045    chatInput.value = ''
2046    onUserRequest(message)
2047  })
2048  chatInput.addEventListener('keyup', (event) => {
2049    if (event.key === 'Enter') chatSubmit.click()
2050  })
2051  chatBubble.addEventListener('click', togglePopup)
2052  fullscreenPopup.addEventListener('click', async () => {
2053    if (!isChatFullscreen()) {
2054      await tryEnterFullscreen()
2055    } else {
2056      try {
2057        await exitChatFullscreen()
2058      } catch (err) {
2059        console.error('Failed to exit fullscreen:', err)
2060      }
2061    }
2062  })
2063  closePopup.addEventListener('click', () => {
2064    if (!isChatFullscreen()) {
2065      togglePopup()
2066      return
2067    }
2068    exitChatFullscreen()
2069      .then(() => togglePopup())
2070      .catch((err) => {
2071        console.error('Failed to exit fullscreen:', err)
2072        togglePopup()
2073      })
2074  })
2075  if (chatInput) {
2076    chatInput.placeholder =
2077      language === 'en' ? 'Type your message...' : config.chatPlaceholder
2078
2079    // After the keyboard animates, re-pin to the visible area so the
2080    // input stays just above it instead of the page scrolling the chat up.
2081    const pinAfterKeyboard = () => {
2082      updateViewportVars()
2083      ;[50, 150, 300, 500].forEach((ms) => setTimeout(updateViewportVars, ms))
2084    }
2085    chatInput.addEventListener('focus', pinAfterKeyboard)
2086    chatInput.addEventListener('blur', pinAfterKeyboard)
2087  }
2088
2089  // Warm caches so the first open is not competing with network/image decode
2090  preloadPopupAssets()
2091  prefetchQuestions().then(() => {
2092    preparePopupContent()
2093    const warm = () => warmPopupPaint()
2094    if (typeof requestIdleCallback === 'function') {
2095      requestIdleCallback(warm, { timeout: 1500 })
2096    } else {
2097      setTimeout(warm, 300)
2098    }
2099  })
2100} //End initChatWidget
2101
2102// When the chatbot script is loaded, grab the script tag to get the home URL and language setting.
2103window.addEventListener('DOMContentLoaded', () => {
2104  const scriptEl = document.getElementById('chatbot-autoload')
2105  if (!scriptEl) return
2106
2107  // Prevent multiple initializations
2108  if (window.chatWidgetInitialized) return
2109  window.chatWidgetInitialized = true
2110
2111  const homeURL = new URL(scriptEl.src).origin
2112  const language = window.location.pathname.startsWith('/en')
2113    ? 'en'
2114    : config.country
2115  initChatWidget(homeURL, language)
2116})

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.