PageSourceSearch

https://canarymail.io/chat/chatbot-loader.js

js canarymail.io collected 2026-10-02 03:01:04 UTC 26,444 bytes, 731 lines download raw bytes

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.