1(function() { 2 const CHATBOT_WIDTH = '400px'; 3 const MOBILE_BREAKPOINT = 768; 4 5 // Configuration and state 6 const config = { 7 isMobile: window.innerWidth <= MOBILE_BREAKPOINT, 8 showFullScreen: false, 9 chatbotId: null, 10 isChatOpen: false, 11 uiConfig: null 12 }; 13 14 // Store UI elements for later updates 15 let banner = null; 16 let fab = null; 17 let container = null; 18 let iframe = null; 19 20 // Export update function to global scope 21 window.updateChatbotConfig = function() { 22 if (!config.chatbotId) return Promise.reject('No chatbot ID configured'); 23 24 return fetchChatbotConfig(config.chatbotId) 25 .then(newConfig => { 26 // Update config 27 config.uiConfig = newConfig; 28 29 // Update UI elements if they exist 30 if (banner && !config.showFullScreen) { 31 updateBanner(banner); 32 } 33 34 if (fab) { 35 updateFAB(fab); 36 } 37 38 // Update chat container position 39 if (container) { 40 updateContainerStyles(container); 41 } 42 43 // Send new config to iframe 44 if (iframe && iframe.contentWindow) { 45 iframe.contentWindow.postMessage({ 46 type: 'update-config', 47 uiConfig: config.uiConfig 48 }, '*'); 49 } 50 51 return config.uiConfig; 52 }); 53 }; 54 55 function fetchChatbotConfig(chatbotId) { 56 return fetch('https://chat.canarymail.io/chatbots/get_ui_config', { 57 method: 'POST', 58 headers: { 'Content-Type': 'application/json' }, 59 body: JSON.stringify({ chatbot_id: chatbotId }) 60 }) 61 .then(response => response.json()) 62 .then(data => { 63 if (data && data.result && data.result.ui_config) { 64 return data.result.ui_config; 65 } else { 66 console.warn('Invalid UI config from API, using default config'); 67 return getDefaultUIConfig(); 68 } 69 }) 70 .catch(error => { 71 console.error('Error loading chatbot config:', error); 72 console.warn('Using default UI config'); 73 return getDefaultUIConfig(); 74 }); 75 } 76 77 // Canary's own chatbot (used on canarymail.io when no config div is present) 78 const CANARY_CHATBOT_ID = 'b6a4724d-b98b-41bd-ac67-24c469701d81'; 79 80 // Banner greeting per page language for Canary's own chatbot; other sites keep their configured text 81 const BANNER_TEXT_BY_LANG = { 82 de: 'Hallo ð Wie können wir Ihnen helfen?' 83 }; 84 85 function getBannerText() { 86 if (config.chatbotId === CANARY_CHATBOT_ID) { 87 const lang = (document.documentElement.lang || '').toLowerCase().split('-')[0]; 88 if (BANNER_TEXT_BY_LANG[lang]) return BANNER_TEXT_BY_LANG[lang]; 89 } 90 return config.uiConfig.messages.banner.value; 91 } 92 93 // Screen-reader labels for the icon-only buttons, by page language 94 const BUTTON_LABELS_BY_LANG = { 95 en: { openChat: 'Open chat', closeBanner: 'Dismiss message' }, 96 de: { openChat: 'Chat öffnen', closeBanner: 'Nachricht schlieÃen' } 97 }; 98 99 function getButtonLabels() { 100 const lang = (document.documentElement.lang || '').toLowerCase().split('-')[0]; 101 return BUTTON_LABELS_BY_LANG[lang] || BUTTON_LABELS_BY_LANG.en; 102 } 103 104 // Keep the banner off mobile, where it covers page content 105 function shouldShowBanner() { 106 return !config.isMobile; 107 } 108 109 function updateBanner(bannerElement) { 110 const messageDiv = bannerElement.querySelector('div'); 111 if (messageDiv) { 112 messageDiv.textContent = getBannerText(); 113 } 114 115 if (!shouldShowBanner()) return; 116 117 // Update position based on uiConfig 118 const position = config.uiConfig.position.value; // Get the position value 119 if (position === 'bottom-left') { 120 bannerElement.style.left = '20px'; // Position on the left 121 bannerElement.style.right = ''; // Clear right 122 bannerElement.style.transition = 'left 0.3s ease'; 123 } else { 124 bannerElement.style.right = '20px'; // Position on the right 125 bannerElement.style.left = ''; // Clear left 126 bannerElement.style.transition = 'right 0.3s ease'; 127 } 128 } 129 130 function updateFAB(fabElement) { 131 fabElement.style.backgroundColor = config.uiConfig.accent_color.value; 132 133 // Update position based on uiConfig 134 const position = config.uiConfig.position.value; // Get the position value 135 if (position === 'bottom-left') { 136 fabElement.style.left = '20px'; // Position on the left 137 fabElement.style.right = ''; // Clear right 138 } else { 139 fabElement.style.right = '20px'; // Position on the right 140 fabElement.style.left = ''; // Clear left 141 } 142 } 143 144 function createBanner() { 145 const banner = document.createElement('div'); 146 banner.id = 'chat-banner'; 147 148 const baseStyles = { 149 position: 'fixed', 150 bottom: '88px', 151 padding: '12px 16px', 152 borderRadius: '12px', 153 boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', 154 display: 'flex', 155 alignItems: 'center', 156 gap: '12px', 157 maxWidth: '300px', 158 zIndex: '1001', 159 pointerEvents: 'auto', 160 fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" 161 }; 162 163 // Set initial position based on uiConfig 164 const position = config.uiConfig.position.value; // Get the position value 165 if (position === 'bottom-left') { 166 baseStyles.left = '-400px'; // Initially hide off-screen to the left 167 baseStyles.right = ''; // Clear right 168 baseStyles.transition = 'left 0.3s ease'; 169 } else { 170 baseStyles.right = '-400px'; // Initially hide off-screen to the right 171 baseStyles.left = ''; // Clear left 172 baseStyles.transition = 'right 0.3s ease'; 173 } 174 175 Object.assign(banner.style, baseStyles);
176 updateBannerTheme(banner, detectTheme()); 177 178 banner.innerHTML = ` 179 <div style="flex: 1; font-size: 15px; line-height: 1.4; color: inherit;"></div> 180 <button type="button" aria-label="${getButtonLabels().closeBanner}" style="background: none; border: none; padding: 4px; cursor: pointer; color: inherit; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: background-color 0.2s ease;"> 181 <svg aria-hidden="true" focusable="false" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 182 <path d="M18 6L6 18"></path> 183 <path d="M6 6l12 12"></path> 184 </svg> 185 </button> 186 `; 187 // Plain text: the banner comes from the chatbot's config and is shown on the host page 188 banner.querySelector('div').textContent = getBannerText(); 189 190 return banner; 191 } 192 193 function updateBannerTheme(banner, theme) { 194 const themeStyles = theme === 'dark' ? { 195 background: '#2d2d2d', 196 color: '#ffffff', 197 boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)' 198 } : { 199 background: 'white', 200 color: '#1a1a1a', 201 boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)' 202 }; 203 204 Object.assign(banner.style, themeStyles); 205 } 206 207 function createFAB() { 208 const fab = document.createElement('button'); 209 fab.id = 'chatbot-fab'; 210 fab.type = 'button'; 211 fab.setAttribute('aria-label', getButtonLabels().openChat); 212 213 const position = config.uiConfig.position.value; // Get the position value 214 215 const fabStyles = { 216 position: 'fixed', 217 bottom: '20px', // Keep this to position it from the bottom 218 width: '56px', 219 height: '56px', 220 borderRadius: '50%', 221 backgroundColor: config.uiConfig.accent_color.value, 222 color: 'white', 223 border: 'none', 224 cursor: 'pointer', 225 boxShadow: '0 2px 12px rgba(0,0,0,0.1)', 226 display: 'flex', 227 alignItems: 'center', 228 justifyContent: 'center', 229 zIndex: '1000', 230 transition: 'transform 0.2s ease' 231 }; 232 233 // Set left or right based on position 234 if (position === 'bottom-left') { 235 fabStyles.left = '20px'; // Position on the left 236 fabStyles.right = ''; // Clear right 237 } else { 238 fabStyles.right = '20px'; // Position on the right 239 fabStyles.left = ''; // Clear left 240 } 241 242 Object.assign(fab.style, fabStyles); 243 244 fab.innerHTML = ` 245 <svg aria-hidden="true" focusable="false" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 246 <path d="M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z" fill="currentColor"/> 247 </svg> 248 `; 249 250 return fab; 251 } 252 253 function createChatContainer() { 254 const container = document.createElement('div'); 255 container.id = 'chatbot-embed'; 256 257 const baseStyles = { 258 position: 'fixed', 259 background: 'transparent', 260 zIndex: '1000', 261 transition: 'opacity 0.3s ease, transform 0.3s ease' 262 }; 263 264 Object.assign(container.style, baseStyles); 265 266 if (config.showFullScreen) { 267 applyFullScreenStyles(container); 268 } else { 269 applyPopupStyles(container); 270 } 271 272 return container; 273 } 274 275 function applyFullScreenStyles(container) { 276 const fullScreenStyles = { 277 position: 'fixed', 278 top: '0', 279 left: '0', 280 width: '100vw', 281 height: '100vh', 282 borderRadius: '0', 283 boxShadow: 'none', 284 display: 'block', 285 opacity: '1', 286 transform: 'none' 287 }; 288 289 Object.assign(container.style, fullScreenStyles); 290 } 291 292 function applyPopupStyles(container) { 293 const styles = config.isMobile ? getMobileStyles() : getDesktopStyles(); 294 Object.assign(container.style, styles, { 295 display: 'none', 296 opacity: '0', 297 transform: 'translateY(20px)' 298 }); 299 } 300 301 function getMobileStyles() { 302 return { 303 position: 'fixed', 304 top: '0', 305 left: '0', 306 right: '', 307 bottom: '', 308 width: '100%', 309 height: '100%', 310 borderRadius: '0', 311 boxShadow: 'none' 312 }; 313 } 314 315 function getDesktopStyles() { 316 const position = config.uiConfig.position.value; // Get the position value 317 const styles = { 318 position: 'fixed', 319 top: '', 320 bottom: '20px', // Always keep this for bottom positioning 321 width: CHATBOT_WIDTH,
322 height: 'min(calc(100vh - 40px), 700px)', 323 borderRadius: '10px', 324 boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)' 325 }; 326 327 // Set left or right based on position 328 if (position === 'bottom-left') { 329 styles.left = '20px'; 330 styles.right = ''; // Clear right 331 } else { 332 styles.right = '20px'; 333 styles.left = ''; // Clear left 334 } 335 336 return styles; 337 } 338 339 function detectTheme() { 340 // Check for explicit dark mode class/attribute on html or body 341 const htmlElement = document.documentElement; 342 const bodyElement = document.body; 343 344 // Get background color 345 const bgColor = getComputedStyle(bodyElement).backgroundColor; 346 const rgbMatch = bgColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)/); 347 348 // Calculate darkness from background color if available 349 const isDarkBg = rgbMatch ? 350 rgbMatch.slice(1) 351 .map(n => parseInt(n)) 352 .reduce((a, b) => a + b) / 3 < 128 353 : false; 354 355 // Common dark mode indicators 356 const isDark = 357 htmlElement.classList.contains('dark') || 358 bodyElement.classList.contains('dark') || 359 htmlElement.getAttribute('data-theme') === 'dark' || 360 bodyElement.getAttribute('data-theme') === 'dark' || 361 isDarkBg 362 // window.matchMedia('(prefers-color-scheme: dark)').matches; 363 364 // Log theme detection reasoning 365 // console.log('Theme detection:', { 366 // 'HTML has dark class': htmlElement.classList.contains('dark'), 367 // 'Body has dark class': bodyElement.classList.contains('dark'), 368 // 'HTML has dark theme': htmlElement.getAttribute('data-theme') === 'dark', 369 // 'Body has dark theme': bodyElement.getAttribute('data-theme') === 'dark', 370 // 'Background color': bgColor, 371 // 'Is background dark': isDarkBg, 372 // 'Final decision': isDark ? 'dark' : 'light' 373 // }); 374 375 return isDark ? 'dark' : 'light'; 376 } 377 378 function createIframe() { 379 const iframe = document.createElement('iframe'); 380 381 const iframeStyles = { 382 width: '100%', 383 height: '100%', 384 border: 'none', 385 borderRadius: config.isMobile ? '0' : '10px', 386 background: 'transparent' 387 }; 388 389 Object.assign(iframe.style, iframeStyles); 390 iframe.allow = 'microphone *'; 391 392 // const iframeUrl = new URL('./chatbot.html', window.location.href); 393 const iframeUrl = new URL('https://canarymail.io/chat/chatbot.html', window.location.href); 394 iframeUrl.searchParams.set('isMobile', config.isMobile); 395 iframeUrl.searchParams.set('theme', detectTheme()); 396 397 // Add show-full-screen parameter to the iframe URL if it's enabled 398 if (config.showFullScreen) { 399 iframeUrl.searchParams.set('showFullScreen', 'true'); 400 } 401 402 iframe.src = iframeUrl.toString(); 403 404 return iframe; 405 } 406 407 // The chat iframe is created on first open (or at init in full-screen mode), so it may not exist yet 408 function postToIframe(message) { 409 if (iframe && iframe.contentWindow) { 410 iframe.contentWindow.postMessage(message, '*'); 411 } 412 } 413 414 function ensureIframe(container) { 415 if (iframe) return iframe; 416 iframe = createIframe(); 417 if (config.chatbotId) { 418 iframe.onload = () => { 419 iframe.contentWindow.postMessage({ 420 type: 'update-chatbot-id', 421 chatbotId: config.chatbotId, 422 uiConfig: config.uiConfig 423 }, '*'); 424 }; 425 } 426 container.appendChild(iframe); 427 return iframe; 428 } 429 430 function setupEventListeners(fab, banner, container) { 431 // Banner close button 432 const bannerCloseBtn = banner.querySelector('button'); 433 setupBannerEvents(bannerCloseBtn, banner); 434 435 // FAB events 436 setupFABEvents(fab, banner, container); 437
438 // Window resize events 439 setupResizeEvents(container); 440 441 // Chat close events 442 setupChatCloseEvents(container, fab, banner); 443 444 // Add theme change detection 445 const observer = new MutationObserver((mutations) => { 446 mutations.forEach((mutation) => { 447 if (mutation.type === 'attributes' && 448 (mutation.attributeName === 'class' || mutation.attributeName === 'data-theme')) { 449 const newTheme = detectTheme(); 450 updateBannerTheme(banner, newTheme); 451 postToIframe({ 452 type: 'theme-change', 453 theme: newTheme 454 }); 455 } 456 }); 457 }); 458 459 // Also observe system color scheme changes 460 const colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); 461 colorSchemeQuery.addEventListener('change', () => { 462 const newTheme = detectTheme(); 463 updateBannerTheme(banner, newTheme); 464 postToIframe({ 465 type: 'theme-change', 466 theme: newTheme 467 }); 468 }); 469 470 // Observe both html and body elements for theme changes 471 observer.observe(document.documentElement, { 472 attributes: true, 473 attributeFilter: ['class', 'data-theme'] 474 }); 475 observer.observe(document.body, { 476 attributes: true, 477 attributeFilter: ['class', 'data-theme'] 478 }); 479 } 480 481 function setupBannerEvents(bannerCloseBtn, banner) { 482 let bannerDismissed = localStorage.getItem('bannerDismissed') === 'true'; // Check local storage 483 484 if (bannerDismissed) { 485 banner.style.display = 'none'; // Hide banner if dismissed 486 return; // Exit early if the banner is already dismissed 487 } 488 489 bannerCloseBtn.addEventListener('mouseover', () => { 490 bannerCloseBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.05)'; 491 }); 492 493 bannerCloseBtn.addEventListener('mouseout', () => { 494 bannerCloseBtn.style.backgroundColor = 'transparent'; 495 }); 496 497 bannerCloseBtn.addEventListener('click', (e) => { 498 e.stopPropagation(); 499 const position = config.uiConfig.position.value; // Get the position value 500 if (position === 'bottom-left') { 501 banner.style.left = '-400px'; // Hides the banner when closed 502 banner.style.right = ''; // Clear right 503 } else { 504 banner.style.right = '-400px'; // Hides the banner when closed 505 banner.style.left = ''; // Clear left 506 } 507 bannerDismissed = true; 508 localStorage.setItem('bannerDismissed', 'true'); // Set local storage 509 }); 510 511 // Show banner after delay only if chat is not open 512 setTimeout(() => { 513 if (!bannerDismissed && !config.isChatOpen && shouldShowBanner()) { 514 const position = config.uiConfig.position.value; // Get the position value 515 if (position === 'bottom-left') { 516 banner.style.left = '20px'; // Show the banner on the left 517 banner.style.right = ''; // Clear right 518 } else { 519 banner.style.right = '20px'; // Show the banner on the right 520 banner.style.left = ''; // Clear left 521 } 522 } 523 }, 2000); 524 } 525 526 function setupFABEvents(fab, banner, container) { 527 fab.addEventListener('mouseenter', () => { 528 fab.style.transform = 'scale(1.05)'; 529 }); 530 531 fab.addEventListener('mouseleave', () => { 532 fab.style.transform = 'scale(1)'; 533 }); 534 535 fab.addEventListener('click', () => { 536 showChat(container, fab, banner); 537 }); 538 } 539 540 function showChat(container, fab, banner) { 541 if (container.style.display === 'none') { 542 ensureIframe(container); 543 config.isChatOpen = true; 544 container.style.display = 'block'; 545 container.offsetHeight; // Force reflow 546 container.style.opacity = '1'; 547 container.style.transform = 'translateY(0)'; 548 fab.style.display = 'none'; 549 550 position = config.uiConfig.position.value; // Get the position value 551 if (position === 'bottom-left') { 552 banner.style.left = '-400px'; 553 banner.style.right = ''; 554 banner.style.transition = 'left 0.3s ease'; 555 }else { 556 banner.style.right = '-400px'; 557 banner.style.left = ''; 558 banner.style.transition = 'right 0.3s ease'; 559 } 560 postToIframe({ type: 'open-chat'}); 561 } 562 } 563 564 function updateContainerStyles(container) { 565 if (config.showFullScreen) { 566 applyFullScreenStyles(container); 567 } else { 568 const resetStyles = { 569 top: '', 570 right: '', 571 bottom: '', 572 left: '' 573 }; 574 Object.assign(container.style, resetStyles); 575 576 const styles = config.isMobile ? getMobileStyles() : getDesktopStyles(); 577 Object.assign(container.style, styles); 578 } 579 } 580 581 function setupResizeEvents(container) { 582 window.addEventListener('resize', () => { 583 const newIsMobile = window.innerWidth <= MOBILE_BREAKPOINT; 584 if (newIsMobile !== config.isMobile) { 585 config.isMobile = newIsMobile; 586 postToIframe({ type: 'screen-resize', isMobile: newIsMobile }); 587 updateContainerStyles(container); 588 if (iframe) { 589 iframe.style.borderRadius = newIsMobile ? '0' : '10px'; 590 } 591 } 592 }); 593 } 594 595 function setupChatCloseEvents(container, fab, banner) { 596 window.addEventListener('message', (event) => { 597 if (event.data === 'close-chat') { 598 hideChat(container, fab, banner); 599 } 600 }); 601 } 602 603 function hideChat(container, fab, banner) { 604 config.isChatOpen = false;
605 container.style.opacity = '0'; 606 container.style.transform = 'translateY(20px)'; 607 setTimeout(() => { 608 container.style.display = 'none'; 609 fab.style.display = 'flex'; 610 }, 300); 611 } 612 613 function createAndSetupUI() { 614 // Create elements 615 banner = createBanner(); 616 fab = createFAB(); 617 container = createChatContainer(); 618 619 // Append elements 620 if (!config.showFullScreen) { 621 document.body.appendChild(banner); 622 } 623 document.body.appendChild(fab); 624 document.body.appendChild(container); 625 626 // Full-screen chat is visible immediately; otherwise load the chat UI on first open 627 if (config.showFullScreen) { 628 ensureIframe(container); 629 } 630 631 // Setup event listeners 632 setupEventListeners(fab, banner, container); 633 } 634 635 function getDefaultUIConfig() { 636 return { 637 "messages": { 638 "banner": { 639 "value": "Hi ð How can we help you today?", 640 "name": "Welcome Banner" 641 }, 642 "salutation": { 643 "value": "Hi!", 644 "name": "Greeting" 645 }, 646 "share_email": { 647 "value": "Please share your email in case we get disconnected", 648 "name": "Email Request" 649 }, 650 "share_email_reminder": { 651 "value": "Before I can help you with that, please share your email", 652 "name": "Email Reminder" 653 }, 654 "email_acknowledgment": { 655 "value": "Thank you! How can I help you today?", 656 "name": "Email Confirmation" 657 }, 658 "invalid_email": { 659 "value": "Please provide a valid email address.", 660 "name": "Email Error" 661 }, 662 "error_processing_message": { 663 "value": "Sorry, there was an error processing your message. Please try again.", 664 "name": "Processing Error" 665 }, 666 "chat_disabled_inactivity": { 667 "value": "The chat has been disabled due to inactivity. Please refresh to start a new chat.", 668 "name": "Timeout Notice" 669 }, 670 "chat_ended_ticket": { 671 "value": "Chat ended - Ticket created", 672 "name": "Ticket Created" 673 }, 674 "chat_ended_inactivity": { 675 "value": "Chat ended due to inactivity", 676 "name": "Chat Timeout" 677 }, 678 "email_input_placeholder": { 679 "value": "Enter your email address...", 680 "name": "Email Placeholder" 681 }, 682 "message_input_placeholder": { 683 "value": "Type your message...", 684 "name": "Message Placeholder" 685 } 686 }, 687 "accent_color": { 688 "value": "#006EFE", 689 "name": "Primary Color" 690 }, 691 "position_options": { 692 "value": [ 693 {"name": "Bottom Right", "value": "bottom-right"}, 694 {"name": "Bottom Left", "value": "bottom-left"} 695 ], 696 "name": "Select Position" 697 }, 698 "position": { 699 "value": "bottom-right", 700 "name": "Position" 701 } 702 } 703 } 704 705 function init() { 706 // Read configuration 707 const configDiv = document.getElementById('canary-chatbot-config'); 708 let chatbotId = null; let showFullScreen = false; 709 if (configDiv !== null && configDiv !== undefined) { 710 chatbotId = configDiv.getAttribute('chatbot-id'); 711 showFullScreen = configDiv.getAttribute('show-full-screen') === 'true'; 712 } else { 713 chatbotId = CANARY_CHATBOT_ID; 714 showFullScreen = false;
715 } 716 717 if (chatbotId !== null && chatbotId !== undefined) { 718 config.chatbotId = chatbotId; 719 config.showFullScreen = showFullScreen; 720 721 fetchChatbotConfig(config.chatbotId) 722 .then(uiConfig => { 723 config.uiConfig = uiConfig; 724 createAndSetupUI(); 725 }); 726 } 727 } 728 729 // Initialize the chatbot 730 init(); 731})();
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.