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 // 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, '&') 1929 .replace(/</g, '<') 1930 .replace(/>/g, '>') 1931 .replace(/"/g, '"') 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.