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(`<${tag}>`, 'gi'), 864 `<${tag}>`, 865 ) 866 sanitized = sanitized.replace( 867 new RegExp(`<\\/${tag}>`, 'gi'), 868 `</${tag}>`, 869 ) 870 } 871 872 // Void / self-closing tags 873 sanitized = sanitized.replace(/<br\s*\/?>/gi, '<br />') 874 sanitized = sanitized.replace(/<hr\s*\/?>/gi, '<hr />') 875 876 // Allow <a> tags with common attributes 877 sanitized = sanitized.replace( 878 /<a(?:\s+href="(.*?)")?(?:\s+target="(.*?)")?(?:\s+rel="(.*?)")?(?:\s+style="(.*?)")?\s*>(.*?)<\/a>/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 " entities 891 sanitized = sanitized.replace(/"/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, '&') 1956 .replace(/</g, '<') 1957 .replace(/>/g, '>') 1958 .replace(/"/g, '"') 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.