PageSourceSearch

https://live-chat-forge.lovable.app/widget.js

js live-chat-forge.lovable.app collected 2026-10-03 03:35:49 UTC 48,315 bytes, 1,712 lines download raw bytes

1/**
2 * ChatFlow Widget Loader
3 * Embeddable chat widget for websites
4 */
5(function() {
6  'use strict';
7
8  // Configuration
9  const SUPABASE_URL = 'https://vgldjfqkrneelfnqsbgi.supabase.co';
10  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZnbGRqZnFrcm5lZWxmbnFzYmdpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUwMjEwMjMsImV4cCI6MjA4MDU5NzAyM30.5AKm1SIckcW-f08fgry14pCWhGrHB0U8-dD0v6jqtGA';
11
12  // Widget state
13  let config = {
14    color: '#6366f1',
15    position: 'right',
16    greeting: '👋 Hi there! How can we help you today?',
17    headerTitle: 'Chat with us',
18    headerSubtitle: 'We typically reply within a few minutes'
19  };
20  let isOpen = false;
21  let visitorId = null;
22  let conversationId = null;
23  let messages = [];
24  let container = null;
25  let audioContext = null;
26
27  // Play notification sound using Web Audio API
28  function playNotificationSound() {
29    try {
30      if (!audioContext) {
31        audioContext = new (window.AudioContext || window.webkitAudioContext)();
32      }
33      
34      // Resume audio context if suspended (browser autoplay policy)
35      if (audioContext.state === 'suspended') {
36        audioContext.resume();
37      }
38
39      const oscillator = audioContext.createOscillator();
40      const gainNode = audioContext.createGain();
41      
42      oscillator.connect(gainNode);
43      gainNode.connect(audioContext.destination);
44      
45      // Pleasant notification tone
46      oscillator.frequency.setValueAtTime(880, audioContext.currentTime); // A5 note
47      oscillator.frequency.setValueAtTime(1174.66, audioContext.currentTime + 0.1); // D6 note
48      
49      oscillator.type = 'sine';
50      
51      // Fade in and out for smoother sound
52      gainNode.gain.setValueAtTime(0, audioContext.currentTime);
53      gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.05);
54      gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 0.3);
55      
56      oscillator.start(audioContext.currentTime);
57      oscillator.stop(audioContext.currentTime + 0.3);
58    } catch (e) {
59      console.log('Could not play notification sound:', e);
60    }
61  }
62
63  // Analytics state
64  let sessionId = null;
65  let pageStartTime = Date.now();
66  let maxScrollDepth = 0;
67  let clicksTracked = [];
68  let visibleElements = {};
69  let analyticsInterval = null;
70
71  // Engagement popup state
72  let sectionsScrolled = 0;
73  let engagementPopupShown = false;
74  let engagementConfig = null;
75
76  // Generate or retrieve visitor ID
77  function getVisitorId() {
78    let vid = localStorage.getItem('chatflow_visitor_id');
79    if (!vid) {
80      vid = 'v_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
81      localStorage.setItem('chatflow_visitor_id', vid);
82    }
83    return vid;
84  }
85
86  // Generate session ID
87  function getSessionId() {
88    let sid = sessionStorage.getItem('chatflow_session_id');
89    if (!sid) {
90      sid = 's_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
91      sessionStorage.setItem('chatflow_session_id', sid);
92    }
93    return sid;
94  }
95
96  // Get browser info
97  function getBrowserInfo() {
98    const ua = navigator.userAgent;
99    let browser = 'Unknown';
100    let device = 'Desktop';
101    let os = 'Unknown';
102
103    // Browser detection
104    if (ua.includes('Chrome')) browser = 'Chrome';
105    else if (ua.includes('Firefox')) browser = 'Firefox';
106    else if (ua.includes('Safari')) browser = 'Safari';
107    else if (ua.includes('Edge')) browser = 'Edge';
108
109    // Device detection
110    if (/Mobi|Android/i.test(ua)) device = 'Mobile';
111    else if (/Tablet|iPad/i.test(ua)) device = 'Tablet';
112
113    // OS detection
114    if (ua.includes('Windows')) os = 'Windows';
115    else if (ua.includes('Mac')) os = 'macOS';
116    else if (ua.includes('Linux')) os = 'Linux';
117    else if (ua.includes('Android')) os = 'Android';
118    else if (ua.includes('iOS') || ua.includes('iPhone')) os = 'iOS';
119
120    return { browser, device, os };
121  }
122
123  // Get UTM parameters
124  function getUTMParams() {
125    const params = new URLSearchParams(window.location.search);
126    return {
127      utm_source: params.get('utm_source'),
128      utm_medium: params.get('utm_medium'),
129      utm_campaign: params.get('utm_campaign')
130    };
131  }
132
133  // API call helper
134  async function apiCall(endpoint, data, timeoutMs = 15000) {
135    const ctrl = new AbortController();
136    const timer = setTimeout(() => ctrl.abort(), timeoutMs);
137    try {
138      const response = await fetch(`${SUPABASE_URL}/functions/v1/${endpoint}`, {
139        method: 'POST',
140        headers: {
141          'Content-Type': 'application/json',
142          'apikey': SUPABASE_ANON_KEY
143        },
144        body: JSON.stringify(data),
145        signal: ctrl.signal
146      });
147      return await response.json();
148    } catch (error) {
149      console.error('ChatFlow API error:', endpoint, error);
150      return { error: error.message };
151    } finally {
152      clearTimeout(timer);
153    }
154  }
155
156  // Initialize widget
157  async function init(userConfig = {}) {
158    config = { ...config, ...userConfig };
159    visitorId = getVisitorId();
160    sessionId = getSessionId();
161
162    const browserInfo = getBrowserInfo();
163    const utmParams = getUTMParams();
164
165    // Create widget UI immediately so the chat is always visible
166    createWidget();
167
168    // Start heartbeat
169    startHeartbeat();
170
171    // Initialize analytics tracking
172    initAnalytics();
173
174    // Initialize engagement tracking
175    initEngagement();
176
177    // Page-context floating teaser bubbles (non-blocking)
178    initTeasers();
179
180    // Initialize with backend (non-blocking)
181    apiCall('widget-init', {
182      visitor_id: visitorId,
183      website_id: window.location.hostname,
184      current_page: window.location.pathname,
185      referrer_url: document.referrer,
186      browser: browserInfo.browser,
187      device: browserInfo.device,
188      os: browserInfo.os,
189      screen_resolution: `${window.screen.width}x${window.screen.height}`,
190      ...utmParams
191    }).then((result) => {
192      if (result && result.success) {
193        if (result.conversation) {
194          conversationId = result.conversation.id;
195        }
196        if (result.messages && result.messages.length) {
197          messages = result.messages;
198          renderMessages();
199        }
200      }
201    });
202
203    console.log('ChatFlow widget initialized');
204  }
205
206  // ================== ANALYTICS TRACKING ==================
207  
208  function initAnalytics() {
209    // Track initial pageview
210    trackPageView();
211    
212    // Track clicks
213    document.addEventListener('click', handleClick, true);
214    
215    // Track scroll
216    window.addEventListener('scroll', handleScroll, { passive: true });
217    
218    // Track element visibility
219    setupVisibilityTracking();
220    
221    // Track page exit
222    window.addEventListener('beforeunload', handlePageExit);
223    
224    // Send analytics periodically (every 30 seconds)
225    analyticsInterval = setInterval(sendScrollAnalytics, 30000);
226  }
227  
228  function trackPageView() {
229    sendAnalyticsEvent({
230      event_type: 'pageview',
231      page_url: window.location.pathname,
232      page_title: document.title,
233      referrer_url: document.referrer,
234      viewport_width: window.innerWidth,
235      viewport_height: window.innerHeight
236    });
237  }
238  
239  function handleClick(event) {
240    const target = event.target;
241    if (!target) return;
242    
243    // Don't track clicks on the widget itself
244    if (target.closest('#chatflow-widget')) return;
245    
246    const selector = getElementSelector(target);
247    const text = (target.textContent || '').slice(0, 100);
248    
249    sendAnalyticsEvent({
250      event_type: 'click',
251      page_url: window.location.pathname,
252      element_selector: selector,
253      element_text: text,
254      click_x: event.clientX,
255      click_y: event.clientY,
256      viewport_width: window.innerWidth,
257      viewport_height: window.innerHeight
258    });
259  }
260  
261  function handleScroll() {
262    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
263    const docHeight = Math.max(
264      document.body.scrollHeight,
265      document.documentElement.scrollHeight,
266      document.body.offsetHeight,
267      document.documentElement.offsetHeight
268    ) - window.innerHeight;
269    
270    const scrollPercent = docHeight > 0 ? Math.round((scrollTop / docHeight) * 100) : 0;
271    
272    if (scrollPercent > maxScrollDepth) {
273      maxScrollDepth = scrollPercent;
274    }
275  }
276  
277  function sendScrollAnalytics() {
278    if (maxScrollDepth > 0) {
279      sendAnalyticsEvent({
280        event_type: 'scroll',
281        page_url: window.location.pathname,
282        scroll_depth: maxScrollDepth,
283        max_scroll_depth: maxScrollDepth,
284        time_on_page: Date.now() - pageStartTime,
285        viewport_width: window.innerWidth,
286        viewport_height: window.innerHeight
287      });
288    }
289  }
290  
291  function setupVisibilityTracking() {
292    // Track visibility of major sections
293    const observer = new IntersectionObserver((entries) => {
294      entries.forEach(entry => {
295        const selector = getElementSelector(entry.target);
296        if (entry.isIntersecting) {
297          if (!visibleElements[selector]) {
298            visibleElements[selector] = { startTime: Date.now(), totalTime: 0 };
299          }
300        } else if (visibleElements[selector] && visibleElements[selector].startTime) {
301          visibleElements[selector].totalTime += Date.now() - visibleElements[selector].startTime;
302          visibleElements[selector].startTime = null;
303        }
304      });
305    }, { threshold: 0.5 });
306    
307    // Observe major sections (headers, main, sections, articles, nav)
308    setTimeout(() => {
309      const sections = document.querySelectorAll('header, main, section, article, nav, footer, [data-section], .section');
310      sections.forEach(section => observer.observe(section));
311    }, 1000);
312  }
313  
314  function handlePageExit() {
315    // Calculate final visible element times
316    const finalVisibleElements = [];
317    for (const [selector, data] of Object.entries(visibleElements)) {
318      let totalTime = data.totalTime;
319      if (data.startTime) {
320        totalTime += Date.now() - data.startTime;
321      }
322      if (totalTime > 100) { // Only track if visible for more than 100ms
323        finalVisibleElements.push({ selector, time_visible_ms: totalTime });
324      }
325    }
326    
327    // Send exit event with accumulated data
328    const exitData = {
329      event_type: 'exit',
330      page_url: window.location.pathname,
331      max_scroll_depth: maxScrollDepth,
332      time_on_page: Date.now() - pageStartTime,
333      visible_elements: finalVisibleElements,
334      viewport_width: window.innerWidth,
335      viewport_height: window.innerHeight
336    };
337    
338    // Use sendBeacon for reliable exit tracking
339    const payload = JSON.stringify({
340      visitor_id: visitorId,
341      session_id: sessionId,
342      ...exitData
343    });
344    
345    navigator.sendBeacon(
346      `${SUPABASE_URL}/functions/v1/widget-analytics`,
347      new Blob([payload], { type: 'application/json' })
348    );
349    
350    // Also send offline status
351    navigator.sendBeacon(
352      `${SUPABASE_URL}/functions/v1/widget-heartbeat`,
353      new Blob([JSON.stringify({ visitor_id: visitorId, is_online: false })], { type: 'application/json' })
354    );
355  }
356  
357  function sendAnalyticsEvent(eventData) {
358    apiCall('widget-analytics', {
359      visitor_id: visitorId,
360      session_id: sessionId,
361      ...eventData
362    }).catch(err => console.log('Analytics error:', err));
363  }
364  
365  function getElementSelector(element) {
366    if (!element || element === document.body || element === document.documentElement) {
367      return 'body';
368    }
369    
370    let selector = element.tagName.toLowerCase();
371    
372    if (element.id) {
373      return `#${element.id}`;
374    }
375    
376    if (element.className && typeof element.className === 'string') {
377      const classes = element.className.split(' ').filter(c => c && !c.includes('cf-')).slice(0, 2);
378      if (classes.length) {
379        selector += '.' + classes.join('.');
380      }
381    }
382    
383    // Add data attributes
384    if (element.dataset.section) {
385      selector += `[data-section="${element.dataset.section}"]`;
386    }
387    
388    return selector;
389  }
390
391  // ================== ENGAGEMENT POPUP ==================
392
393  async function initEngagement() {
394    // Fetch engagement config
395    const websiteId = window.location.hostname;
396    const result = await apiCall('engagement-config', { websiteId });
397    
398    if (result.config && result.config.is_enabled) {
399      engagementConfig = result.config;
400      setupSectionTracking();
401    }
402  }
403
404  function setupSectionTracking() {
405    if (!engagementConfig) return;
406
407    const sectionObserver = new IntersectionObserver((entries) => {
408      entries.forEach(entry => {
409        if (entry.isIntersecting && !entry.target.dataset.cfSeen) {
410          entry.target.dataset.cfSeen = 'true';
411          sectionsScrolled++;
412          
413          // Check if we should show engagement popup
414          if (sectionsScrolled >= engagementConfig.scroll_trigger_sections && !engagementPopupShown) {
415            setTimeout(() => {
416              showEngagementPopup();
417            }, engagementConfig.popup_delay_ms || 500);
418          }
419        }
420      });
421    }, { threshold: 0.3 });
422
423    // Observe all major sections
424    setTimeout(() => {
425      const sections = document.querySelectorAll('section, article, [data-section], .section, main > div, header, footer');
426      sections.forEach(section => sectionObserver.observe(section));
427    }, 1000);
428  }
429
430  function showEngagementPopup() {
431    if (engagementPopupShown || !engagementConfig) return;
432    engagementPopupShown = true;
433
434    // Check if already dismissed this session
435    if (sessionStorage.getItem('cf_engagement_dismissed')) return;
436
437    // Create the engagement popup
438    const popup = document.createElement('div');
439    popup.id = 'cf-engagement-popup';
440    popup.innerHTML = getEngagementPopupHTML();
441    document.body.appendChild(popup);
442
443    // Add styles
444    addEngagementStyles();
445
446    // Bind events
447    setTimeout(() => {
448      popup.classList.add('cf-engagement-visible');
449    }, 100);
450
451    document.getElementById('cf-engagement-close')?.addEventListener('click', hideEngagementPopup);
452    document.getElementById('cf-engagement-cta')?.addEventListener('click', () => {
453      hideEngagementPopup();
454      toggleWidget(true);
455    });
456  }
457
458  function getEngagementPopupHTML() {
459    const message = engagementConfig?.popup_message || 'Happy to have you here! May I know your requirements?';
460    
461    return `
462      <div class="cf-engagement-container">
463        <!-- Animated Character -->
464        <div class="cf-character">
465          <div class="cf-character-body">
466            <div class="cf-character-head">
467              <div class="cf-character-face">
468                <div class="cf-character-eyes">
469                  <div class="cf-character-eye cf-eye-left">
470                    <div class="cf-character-pupil"></div>
471                  </div>
472                  <div class="cf-character-eye cf-eye-right">
473                    <div class="cf-character-pupil"></div>
474                  </div>
475                </div>
476                <div class="cf-character-mouth"></div>
477              </div>
478              <div class="cf-character-antenna">
479                <div class="cf-character-antenna-ball"></div>
480              </div>
481            </div>
482            <div class="cf-character-torso">
483              <div class="cf-character-arm cf-arm-left"></div>
484              <div class="cf-character-arm cf-arm-right cf-arm-waving">
485                <div class="cf-character-hand">
486                  <!-- Banner held by character -->
487                  <div class="cf-banner">
488                    <div class="cf-banner-content">
489                      <p class="cf-banner-text">${message}</p>
490                      <button class="cf-banner-cta" id="cf-engagement-cta">
491                        <span>Chat with us</span>
492                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
493                          <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
494                        </svg>
495                      </button>
496                    </div>
497                    <div class="cf-banner-sparkles">
498                      <div class="cf-sparkle cf-sparkle-1">✨</div>
499                      <div class="cf-sparkle cf-sparkle-2">⭐</div>
500                      <div class="cf-sparkle cf-sparkle-3">✨</div>
501                    </div>
502                  </div>
503                </div>
504              </div>
505            </div>
506            <div class="cf-character-legs">
507              <div class="cf-character-leg cf-leg-left"></div>
508              <div class="cf-character-leg cf-leg-right"></div>
509            </div>
510          </div>
511          <div class="cf-character-shadow"></div>
512        </div>
513        <button class="cf-engagement-close" id="cf-engagement-close">×</button>
514      </div>
515    `;
516  }
517
518  function addEngagementStyles() {
519    if (document.getElementById('cf-engagement-styles')) return;
520
521    const styles = document.createElement('style');
522    styles.id = 'cf-engagement-styles';
523    styles.textContent = `
524      #cf-engagement-popup {
525        position: fixed;
526        bottom: 20px;
527        right: 20px;
528        z-index: 2147483646;
529        opacity: 0;
530        transform: translateY(20px) scale(0.9);
531        transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
532        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
533      }
534
535      #cf-engagement-popup.cf-engagement-visible {
536        opacity: 1;
537        transform: translateY(0) scale(1);
538      }
539
540      .cf-engagement-container {
541        position: relative;
542      }
543
544      .cf-engagement-close {
545        position: absolute;
546        top: -10px;
547        right: -10px;
548        width: 28px;
549        height: 28px;
550        border-radius: 50%;
551        background: #1a1a1a;
552        color: white;
553        border: 2px solid white;
554        font-size: 18px;
555        cursor: pointer;
556        display: flex;
557        align-items: center;
558        justify-content: center;
559        z-index: 10;
560        transition: transform 0.2s;
561        box-shadow: 0 2px 8px rgba(0,0,0,0.2);
562      }
563
564      .cf-engagement-close:hover {
565        transform: scale(1.1);
566        background: #333;
567      }
568
569      /* Character Styles */
570      .cf-character {
571        position: relative;
572        animation: cf-bounce 2s ease-in-out infinite;
573      }
574
575      @keyframes cf-bounce {
576        0%, 100% { transform: translateY(0); }
577        50% { transform: translateY(-8px); }
578      }
579
580      .cf-character-body {
581        position: relative;
582        display: flex;
583        flex-direction: column;
584        align-items: center;
585      }
586
587      .cf-character-head {
588        width: 60px;
589        height: 55px;
590        background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
591        border-radius: 30px 30px 25px 25px;
592        position: relative;
593        box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
594      }
595
596      .cf-character-antenna {
597        position: absolute;
598        top: -15px;
599        left: 50%;
600        transform: translateX(-50%);
601        width: 4px;
602        height: 15px;
603        background: linear-gradient(to top, #6366f1, #a5b4fc);
604        border-radius: 2px;
605        animation: cf-antenna-wobble 1s ease-in-out infinite;
606      }
607
608      @keyframes cf-antenna-wobble {
609        0%, 100% { transform: translateX(-50%) rotate(-5deg); }
610        50% { transform: translateX(-50%) rotate(5deg); }
611      }
612
613      .cf-character-antenna-ball {
614        position: absolute;
615        top: -8px;
616        left: 50%;
617        transform: translateX(-50%);
618        width: 12px;
619        height: 12px;
620        background: linear-gradient(135deg, #fbbf24, #f59e0b);
621        border-radius: 50%;
622        box-shadow: 0 0 10px rgba(251, 191, 36, 0.6);
623        animation: cf-glow 1.5s ease-in-out infinite alternate;
624      }
625
626      @keyframes cf-glow {
627        from { box-shadow: 0 0 10px rgba(251, 191, 36, 0.6); }
628        to { box-shadow: 0 0 20px rgba(251, 191, 36, 0.9); }
629      }
630
631      .cf-character-face {
632        position: absolute;
633        top: 12px;
634        left: 50%;
635        transform: translateX(-50%);
636        width: 100%;
637        display: flex;
638        flex-direction: column;
639        align-items: center;
640      }
641
642      .cf-character-eyes {
643        display: flex;
644        gap: 12px;
645      }
646
647      .cf-character-eye {
648        width: 14px;
649        height: 14px;
650        background: white;
651        border-radius: 50%;
652        display: flex;
653        align-items: center;
654        justify-content: center;
655        animation: cf-blink 4s infinite;
656      }
657
658      @keyframes cf-blink {
659        0%, 48%, 52%, 100% { transform: scaleY(1); }
660        50% { transform: scaleY(0.1); }
661      }
662
663      .cf-character-pupil {
664        width: 6px;
665        height: 6px;
666        background: #1a1a1a;
667        border-radius: 50%;
668        animation: cf-look 3s ease-in-out infinite;
669      }
670
671      @keyframes cf-look {
672        0%, 100% { transform: translateX(0); }
673        25% { transform: translateX(2px); }
674        75% { transform: translateX(-2px); }
675      }
676
677      .cf-character-mouth {
678        width: 20px;
679        height: 10px;
680        border-radius: 0 0 10px 10px;
681        background: #1a1a1a;
682        margin-top: 6px;
683        animation: cf-smile 2s ease-in-out infinite;
684      }
685
686      @keyframes cf-smile {
687        0%, 100% { transform: scaleX(1); }
688        50% { transform: scaleX(1.1); }
689      }
690
691      .cf-character-torso {
692        width: 50px;
693        height: 35px;
694        background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
695        border-radius: 8px 8px 15px 15px;
696        margin-top: -5px;
697        position: relative;
698        box-shadow: 0 4px 10px rgba(99, 102, 241, 0.3);
699      }
700
701      .cf-character-arm {
702        position: absolute;
703        width: 12px;
704        height: 35px;
705        background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
706        border-radius: 6px;
707        top: 5px;
708      }
709
710      .cf-arm-left {
711        left: -15px;
712        transform: rotate(15deg);
713      }
714
715      .cf-arm-right {
716        right: -15px;
717        transform-origin: top center;
718        animation: cf-wave 1s ease-in-out infinite;
719      }
720
721      @keyframes cf-wave {
722        0%, 100% { transform: rotate(-20deg); }
723        50% { transform: rotate(-60deg); }
724      }
725
726      .cf-character-hand {
727        position: absolute;
728        bottom: -5px;
729        left: 50%;
730        transform: translateX(-50%);
731        width: 16px;
732        height: 16px;
733        background: #a5b4fc;
734        border-radius: 50%;
735        overflow: visible;
736      }
737
738      .cf-character-legs {
739        display: flex;
740        gap: 8px;
741        margin-top: -3px;
742      }
743
744      .cf-character-leg {
745        width: 14px;
746        height: 25px;
747        background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
748        border-radius: 7px 7px 10px 10px;
749      }
750
751      .cf-leg-left {
752        animation: cf-leg-move 1s ease-in-out infinite;
753      }
754
755      .cf-leg-right {
756        animation: cf-leg-move 1s ease-in-out infinite reverse;
757      }
758
759      @keyframes cf-leg-move {
760        0%, 100% { transform: translateY(0); }
761        50% { transform: translateY(-3px); }
762      }
763
764      .cf-character-shadow {
765        width: 40px;
766        height: 8px;
767        background: rgba(0, 0, 0, 0.15);
768        border-radius: 50%;
769        margin-top: 5px;
770        animation: cf-shadow 2s ease-in-out infinite;
771      }
772
773      @keyframes cf-shadow {
774        0%, 100% { transform: scale(1); opacity: 0.15; }
775        50% { transform: scale(0.9); opacity: 0.1; }
776      }
777
778      /* Banner Styles */
779      .cf-banner {
780        position: absolute;
781        left: 25px;
782        top: -100px;
783        width: 280px;
784        max-width: 280px;
785        background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
786        border-radius: 20px;
787        padding: 20px;
788        box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(99, 102, 241, 0.1);
789        animation: cf-banner-appear 0.5s ease-out forwards;
790        transform-origin: bottom left;
791      }
792
793      @keyframes cf-banner-appear {
794        from { opacity: 0; transform: scale(0.8) rotate(-5deg); }
795        to { opacity: 1; transform: scale(1) rotate(0deg); }
796      }
797
798      .cf-banner::before {
799        content: '';
800        position: absolute;
801        bottom: -10px;
802        left: 20px;
803        width: 20px;
804        height: 20px;
805        background: white;
806        transform: rotate(45deg);
807        box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1);
808      }
809
810      .cf-banner-content {
811        position: relative;
812        z-index: 1;
813      }
814
815      .cf-banner-text {
816        font-size: 15px;
817        color: #1a1a1a;
818        line-height: 1.5;
819        margin: 0 0 15px 0;
820        font-weight: 500;
821        white-space: normal;
822        word-wrap: break-word;
823        display: block;
824        width: 100%;
825      }
826
827      .cf-banner-cta {
828        display: flex;
829        align-items: center;
830        gap: 8px;
831        background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
832        color: white;
833        border: none;
834        padding: 12px 20px;
835        border-radius: 25px;
836        font-size: 14px;
837        font-weight: 600;
838        cursor: pointer;
839        transition: all 0.3s ease;
840        box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
841      }
842
843      .cf-banner-cta:hover {
844        transform: translateY(-2px) scale(1.02);
845        box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
846      }
847
848      .cf-banner-sparkles {
849        position: absolute;
850        top: 0;
851        left: 0;
852        right: 0;
853        bottom: 0;
854        pointer-events: none;
855        overflow: hidden;
856      }
857
858      .cf-sparkle {
859        position: absolute;
860        font-size: 16px;
861        animation: cf-sparkle-float 2s ease-in-out infinite;
862      }
863
864      .cf-sparkle-1 { top: -5px; right: 10px; animation-delay: 0s; }
865      .cf-sparkle-2 { top: 20px; right: -10px; animation-delay: 0.5s; }
866      .cf-sparkle-3 { bottom: 10px; right: 0; animation-delay: 1s; }
867
868      @keyframes cf-sparkle-float {
869        0%, 100% { opacity: 0; transform: translateY(0) scale(0.8); }
870        50% { opacity: 1; transform: translateY(-10px) scale(1); }
871      }
872
873      /* Mobile responsive */
874      @media (max-width: 480px) {
875        #cf-engagement-popup {
876          right: 10px;
877          bottom: 10px;
878        }
879
880        .cf-banner {
881          min-width: 240px;
882          padding: 15px;
883          left: -200px;
884          top: -90px;
885        }
886
887        .cf-character-head {
888          width: 50px;
889          height: 45px;
890        }
891
892        .cf-character-torso {
893          width: 40px;
894          height: 30px;
895        }
896      }
897    `;
898    document.head.appendChild(styles);
899  }
900
901  function hideEngagementPopup() {
902    const popup = document.getElementById('cf-engagement-popup');
903    if (popup) {
904      popup.classList.remove('cf-engagement-visible');
905      sessionStorage.setItem('cf_engagement_dismissed', 'true');
906      setTimeout(() => popup.remove(), 400);
907    }
908  }
909
910  // ================== WIDGET UI ==================
911
912  function createWidget() {
913    // Remove existing widget if any
914    const existing = document.getElementById('chatflow-widget');
915    if (existing) existing.remove();
916
917    container = document.createElement('div');
918    container.id = 'chatflow-widget';
919    container.innerHTML = getWidgetHTML();
920    document.body.appendChild(container);
921
922    // Add styles
923    addStyles();
924
925    // Bind events
926    bindEvents();
927
928    // Render messages
929    renderMessages();
930  }
931
932  function getWidgetHTML() {
933    const positionClass = config.position === 'left' ? 'cf-left' : 'cf-right';
934    
935    return `
936      <div class="cf-container ${positionClass}">
937        <!-- Contextual floating teaser bubbles -->
938        <div class="cf-teasers ${isOpen ? 'cf-hidden' : ''}" id="cf-teasers"></div>
939
940        <!-- Chat window -->
941        <div class="cf-window ${isOpen ? 'cf-open' : ''}" id="cf-window">
942          <div class="cf-header" style="background-color: ${config.color}">
943            <div class="cf-header-info">
944              <div class="cf-header-title">${config.headerTitle}</div>
945              <div class="cf-header-subtitle">${config.headerSubtitle}</div>
946            </div>
947            <button class="cf-close" id="cf-close">
948              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
949                <line x1="18" y1="6" x2="6" y2="18"></line>
950                <line x1="6" y1="6" x2="18" y2="18"></line>
951              </svg>
952            </button>
953          </div>
954          <div class="cf-messages" id="cf-messages"></div>
955          <div class="cf-input-area">
956            <input type="text" class="cf-input" id="cf-input" placeholder="Type a message...">
957            <button class="cf-send" id="cf-send" style="background-color: ${config.color}">
958              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
959                <line x1="22" y1="2" x2="11" y2="13"></line>
960                <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
961              </svg>
962            </button>
963          </div>
964        </div>
965
966        <!-- Launcher button with notification badge -->
967        <button class="cf-launcher ${isOpen ? 'cf-hidden' : ''}" id="cf-launcher" style="background-color: #22c55e">
968          <span class="cf-badge cf-hidden" id="cf-badge">1</span>
969          <svg class="cf-icon-heart" width="34" height="34" viewBox="0 0 24 24" fill="white" stroke="none">
970            <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
971          </svg>
972        </button>
973      </div>
974    `;
975  }
976
977  function addStyles() {
978    if (document.getElementById('chatflow-styles')) return;
979
980    const styles = document.createElement('style');
981    styles.id = 'chatflow-styles';
982    styles.textContent = `
983      #chatflow-widget * {
984        box-sizing: border-box;
985        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
986      }
987
988      .cf-container {
989        position: fixed;
990        bottom: 20px;
991        z-index: 2147483647;
992      }
993
994      .cf-container.cf-right {
995        right: 20px;
996      }
997
998      .cf-container.cf-left {
999        left: 20px;
1000      }
1001
1002      .cf-hidden {
1003        display: none !important;
1004      }
1005
1006      /* Contextual teaser bubbles */
1007      .cf-teasers {
1008        position: absolute;
1009        bottom: 82px;
1010        width: 300px;
1011        max-width: calc(100vw - 40px);
1012        display: flex;
1013        flex-direction: column;
1014        justify-content: flex-end;
1015        gap: 10px;
1016        pointer-events: none;
1017      }
1018
1019      .cf-container.cf-right .cf-teasers {
1020        right: 0;
1021        align-items: flex-end;
1022      }
1023
1024      .cf-container.cf-left .cf-teasers {
1025        left: 0;
1026        align-items: flex-start;
1027      }
1028
1029      .cf-teaser {
1030        pointer-events: auto;
1031        position: relative;
1032        background: #ffffff;
1033        color: #14181f;
1034        border-radius: 18px;
1035        padding: 12px 34px 12px 16px;
1036        box-shadow: 0 8px 28px rgba(15, 23, 42, 0.18);
1037        font-size: 14px;
1038        line-height: 1.45;
1039        text-align: left;
1040        white-space: normal;
1041        word-break: normal;
1042        overflow-wrap: anywhere;
1043        width: auto;
1044        max-width: 300px;
1045        cursor: pointer;
1046        opacity: 0;
1047        transform: translateY(24px) scale(0.96);
1048        animation: cf-float-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) forwards;
1049        transition: box-shadow 0.2s ease, transform 0.2s ease;
1050      }
1051
1052      .cf-teaser:hover {
1053        box-shadow: 0 12px 34px rgba(15, 23, 42, 0.26);
1054      }
1055
1056      .cf-teaser.cf-teaser-cta {
1057        background: linear-gradient(135deg, #22c55e, #16a34a);
1058        color: #ffffff;
1059        font-weight: 600;
1060      }
1061
1062      .cf-teaser-close {
1063        position: absolute;
1064        top: 6px;
1065        right: 8px;
1066        background: none;
1067        border: none;
1068        color: inherit;
1069        opacity: 0.45;
1070        cursor: pointer;
1071        font-size: 16px;
1072        line-height: 1;
1073        padding: 2px;
1074      }
1075
1076      .cf-teaser-close:hover {
1077        opacity: 0.9;
1078      }
1079
1080      .cf-teaser-out {
1081        animation: cf-float-out 0.35s ease forwards;
1082      }
1083
1084      @keyframes cf-float-up {
1085        0% { opacity: 0; transform: translateY(28px) scale(0.94); }
1086        60% { opacity: 1; transform: translateY(-4px) scale(1.01); }
1087        100% { opacity: 1; transform: translateY(0) scale(1); }
1088      }
1089
1090      @keyframes cf-float-out {
1091        to { opacity: 0; transform: translateY(-14px) scale(0.96); }
1092      }
1093
1094      /* Launcher */
1095      .cf-launcher {
1096        width: 60px;
1097        height: 60px;
1098        border-radius: 50%;
1099        border: 3px solid white;
1100        cursor: pointer;
1101        display: flex;
1102        align-items: center;
1103        justify-content: center;
1104        color: white;
1105        box-shadow: 0 4px 16px rgba(34, 197, 94, 0.4);
1106        position: relative;
1107      }
1108
1109      .cf-launcher::before,
1110      .cf-launcher::after {
1111        content: '';
1112        position: absolute;
1113        top: 50%;
1114        left: 50%;
1115        width: 100%;
1116        height: 100%;
1117        border-radius: 50%;
1118        border: 2px solid white;
1119        transform: translate(-50%, -50%);
1120        animation: cf-ripple 2s ease-out infinite;
1121      }
1122
1123      .cf-launcher::after {
1124        animation-delay: 1s;
1125      }
1126
1127      @keyframes cf-ripple {
1128        0% {
1129          width: 100%;
1130          height: 100%;
1131          opacity: 0.8;
1132        }
1133        100% {
1134          width: 180%;
1135          height: 180%;
1136          opacity: 0;
1137        }
1138      }
1139
1140      .cf-launcher:hover {
1141        transform: scale(1.1);
1142      }
1143
1144      .cf-launcher:hover::before,
1145      .cf-launcher:hover::after {
1146        animation-play-state: paused;
1147      }
1148
1149      /* Notification badge */
1150      .cf-badge {
1151        position: absolute;
1152        top: -4px;
1153        right: -4px;
1154        background: #ef4444;
1155        color: white;
1156        font-size: 12px;
1157        font-weight: 600;
1158        min-width: 20px;
1159        height: 20px;
1160        border-radius: 10px;
1161        display: flex;
1162        align-items: center;
1163        justify-content: center;
1164        padding: 0 6px;
1165        animation: cf-pulse 2s infinite;
1166      }
1167
1168      @keyframes cf-pulse {
1169        0%, 100% { transform: scale(1); }
1170        50% { transform: scale(1.1); }
1171      }
1172
1173      /* Chat window */
1174      .cf-window {
1175        position: fixed;
1176        bottom: 90px;
1177        width: 380px;
1178        height: 520px;
1179        max-height: calc(100vh - 120px);
1180        background: white;
1181        border-radius: 16px;
1182        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
1183        display: none;
1184        flex-direction: column;
1185        overflow: hidden;
1186        animation: cf-scale-in 0.2s ease;
1187        z-index: 2147483647;
1188      }
1189
1190      .cf-window.cf-open {
1191        display: flex;
1192      }
1193
1194      .cf-container.cf-right .cf-window {
1195        right: 20px;
1196      }
1197
1198      .cf-container.cf-left .cf-window {
1199        left: 20px;
1200      }
1201
1202      /* Header */
1203      .cf-header {
1204        padding: 20px;
1205        color: white;
1206        display: flex;
1207        align-items: flex-start;
1208        justify-content: space-between;
1209      }
1210
1211      .cf-header-title {
1212        font-size: 18px;
1213        font-weight: 600;
1214        margin-bottom: 4px;
1215      }
1216
1217      .cf-header-subtitle {
1218        font-size: 13px;
1219        opacity: 0.85;
1220      }
1221
1222      .cf-close {
1223        background: rgba(255, 255, 255, 0.25);
1224        border: 2px solid rgba(255, 255, 255, 0.4);
1225        color: white;
1226        width: 36px;
1227        height: 36px;
1228        border-radius: 50%;
1229        cursor: pointer;
1230        display: flex;
1231        align-items: center;
1232        justify-content: center;
1233        flex-shrink: 0;
1234        transition: all 0.2s ease;
1235        padding: 0;
1236      }
1237
1238      .cf-close svg {
1239        width: 20px;
1240        height: 20px;
1241        stroke: white;
1242        stroke-width: 2;
1243      }
1244
1245      .cf-close:hover {
1246        background: rgba(255, 255, 255, 0.4);
1247        border-color: rgba(255, 255, 255, 0.6);
1248        transform: scale(1.05);
1249      }
1250
1251      /* Messages */
1252      .cf-messages {
1253        flex: 1;
1254        overflow-y: auto;
1255        padding: 16px;
1256        background: #f9fafb;
1257      }
1258
1259      .cf-message {
1260        max-width: 80%;
1261        margin-bottom: 12px;
1262        animation: cf-fade-up 0.2s ease;
1263      }
1264
1265      .cf-message-visitor {
1266        margin-left: auto;
1267      }
1268
1269      .cf-message-bubble {
1270        padding: 12px 16px;
1271        border-radius: 16px;
1272        font-size: 14px;
1273        line-height: 1.5;
1274      }
1275
1276      .cf-message-visitor .cf-message-bubble {
1277        background: ${config.color};
1278        color: white;
1279        border-bottom-right-radius: 4px;
1280      }
1281
1282      .cf-message-agent .cf-message-bubble {
1283        background: white;
1284        color: #1a1a1a;
1285        border-bottom-left-radius: 4px;
1286        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
1287      }
1288
1289      .cf-message-time {
1290        font-size: 11px;
1291        color: #999;
1292        margin-top: 4px;
1293      }
1294
1295      .cf-message-visitor .cf-message-time {
1296        text-align: right;
1297      }
1298
1299      /* New message highlight */
1300      .cf-message-new {
1301        animation: cf-highlight 1s ease;
1302      }
1303
1304      @keyframes cf-highlight {
1305        0%, 50% { background-color: rgba(99, 102, 241, 0.1); }
1306        100% { background-color: transparent; }
1307      }
1308
1309      /* Input */
1310      .cf-input-area {
1311        padding: 16px;
1312        background: white;
1313        border-top: 1px solid #eee;
1314        display: flex;
1315        gap: 8px;
1316      }
1317
1318      .cf-input {
1319        flex: 1;
1320        padding: 12px 16px;
1321        border: 1px solid #e5e7eb;
1322        border-radius: 24px;
1323        font-size: 14px;
1324        outline: none;
1325      }
1326
1327      .cf-input:focus {
1328        border-color: ${config.color};
1329      }
1330
1331      .cf-send {
1332        width: 44px;
1333        height: 44px;
1334        border-radius: 50%;
1335        border: none;
1336        color: white;
1337        cursor: pointer;
1338        display: flex;
1339        align-items: center;
1340        justify-content: center;
1341        padding: 0;
1342      }
1343
1344      .cf-send svg {
1345        width: 18px;
1346        height: 18px;
1347        stroke: white;
1348        stroke-width: 2;
1349        fill: none;
1350      }
1351
1352      .cf-send:disabled {
1353        opacity: 0.5;
1354        cursor: not-allowed;
1355      }
1356
1357      /* Animations */
1358      @keyframes cf-fade-up {
1359        from {
1360          opacity: 0;
1361          transform: translateY(10px);
1362        }
1363        to {
1364          opacity: 1;
1365          transform: translateY(0);
1366        }
1367      }
1368
1369      @keyframes cf-scale-in {
1370        from {
1371          opacity: 0;
1372          transform: scale(0.95);
1373        }
1374        to {
1375          opacity: 1;
1376          transform: scale(1);
1377        }
1378      }
1379
1380      /* Mobile responsive */
1381      @media (max-width: 480px) {
1382        .cf-window {
1383          width: calc(100vw - 40px);
1384          height: calc(100vh - 120px);
1385          max-height: 600px;
1386        }
1387      }
1388    `;
1389    document.head.appendChild(styles);
1390  }
1391
1392  function bindEvents() {
1393    // Launcher click
1394    document.getElementById('cf-launcher')?.addEventListener('click', () => {
1395      toggleWidget(true);
1396    });
1397
1398    // Close button
1399    document.getElementById('cf-close')?.addEventListener('click', () => {
1400      toggleWidget(false);
1401    });
1402
1403    // Teaser bubbles are bound when rendered (see showTeaser)
1404
1405    // Send message
1406    document.getElementById('cf-send')?.addEventListener('click', sendMessage);
1407
1408    // Input enter key
1409    document.getElementById('cf-input')?.addEventListener('keypress', (e) => {
1410      if (e.key === 'Enter') sendMessage();
1411    });
1412  }
1413
1414  function toggleWidget(open) {
1415    isOpen = open;
1416    const window = document.getElementById('cf-window');
1417    const launcher = document.getElementById('cf-launcher');
1418    const teasers = document.getElementById('cf-teasers');
1419    const badge = document.getElementById('cf-badge');
1420
1421    if (open) {
1422      window?.classList.add('cf-open');
1423      launcher?.classList.add('cf-hidden');
1424      teasers?.classList.add('cf-hidden');
1425      badge?.classList.add('cf-hidden');
1426      document.getElementById('cf-input')?.focus();
1427      // Clear unread count when opened
1428      unreadCount = 0;
1429    } else {
1430      window?.classList.remove('cf-open');
1431      launcher?.classList.remove('cf-hidden');
1432    }
1433  }
1434
1435  // Unread message counter
1436  let unreadCount = 0;
1437
1438  function showNewMessageNotification(count) {
1439    const badge = document.getElementById('cf-badge');
1440
1441    if (badge && !isOpen) {
1442      badge.textContent = count;
1443      badge.classList.remove('cf-hidden');
1444    }
1445  }
1446
1447  // ================== CONTEXTUAL TEASER BUBBLES ==================
1448
1449  let teasersDismissed = false;
1450
1451  function getPageContext() {
1452    const parts = [];
1453    document.querySelectorAll('h1, h2').forEach((el) => {
1454      const t = el.textContent?.trim();
1455      if (t) parts.push(t);
1456    });
1457    const meta = document.querySelector('meta[name="description"]')?.getAttribute('content');
1458    if (meta) parts.unshift(meta);
1459    return parts.slice(0, 12).join(' | ').slice(0, 800);
1460  }
1461
1462  async function initTeasers() {
1463    if (teasersDismissed) return;
1464
1465    const result = await apiCall('widget-teasers', {
1466      website_id: window.location.hostname,
1467      page_path: window.location.pathname,
1468      page_url: window.location.href,
1469      page_title: document.title,
1470      page_context: getPageContext()
1471    });
1472
1473    const teasers = Array.isArray(result?.teasers) ? result.teasers.filter(Boolean) : [];
1474    if (!teasers.length) return;
1475
1476    teasers.forEach((text, i) => {
1477      setTimeout(() => showTeaser(text, i === teasers.length - 1), 1200 + i * 2600);
1478    });
1479  }
1480
1481  function showTeaser(text, isCta) {
1482    if (teasersDismissed || isOpen) return;
1483    const wrap = document.getElementById('cf-teasers');
1484    if (!wrap) return;
1485
1486    const bubble = document.createElement('div');
1487    bubble.className = 'cf-teaser' + (isCta ? ' cf-teaser-cta' : '');
1488    bubble.innerHTML = `<span>${escapeHtml(text)}</span><button class="cf-teaser-close" aria-label="Dismiss">&times;</button>`;
1489
1490    bubble.addEventListener('click', () => openChatFromTeaser(text));
1491    bubble.querySelector('.cf-teaser-close')?.addEventListener('click', (e) => {
1492      e.stopPropagation();
1493      teasersDismissed = true;
1494      wrap.innerHTML = '';
1495    });
1496
1497    wrap.appendChild(bubble);
1498
1499    // Keep only the last 3 bubbles floating
1500    while (wrap.children.length > 3) {
1501      wrap.removeChild(wrap.firstElementChild);
1502    }
1503
1504    // Auto fade the oldest bubble after a while
1505    setTimeout(() => {
1506      if (bubble.isConnected && wrap.children.length > 1) {
1507        bubble.classList.add('cf-teaser-out');
1508        setTimeout(() => bubble.remove(), 350);
1509      }
1510    }, 16000);
1511  }
1512
1513  async function openChatFromTeaser(text) {
1514    const wrap = document.getElementById('cf-teasers');
1515    if (wrap) wrap.innerHTML = '';
1516    teasersDismissed = true;
1517
1518    // Show the teaser as the opening agent message
1519    if (!messages.some(m => m.content === text)) {
1520      messages.push({
1521        id: `teaser-${Date.now()}`,
1522        content: text,
1523        sender_type: 'agent',
1524        created_at: new Date().toISOString(),
1525        isNew: true
1526      });
1527    }
1528
1529    toggleWidget(true);
1530    renderMessages();
1531
1532    // Persist it so agents/AI share the same context in the dashboard
1533    const result = await apiCall('widget-message', {
1534      visitor_id: visitorId,
1535      conversation_id: conversationId,
1536      content: text,
1537      sender_type: 'agent',
1538      website_id: window.location.hostname
1539    });
1540
1541    if (result?.conversation_id) conversationId = result.conversation_id;
1542  }
1543
1544  async function sendMessage() {
1545    const input = document.getElementById('cf-input');
1546    const content = input?.value?.trim();
1547    
1548    if (!content) return;
1549
1550    // Clear input
1551    input.value = '';
1552
1553    // Add message to UI immediately
1554    messages.push({
1555      id: Date.now(),
1556      content,
1557      sender_type: 'visitor',
1558      created_at: new Date().toISOString()
1559    });
1560    renderMessages();
1561
1562    // Send to backend
1563    const result = await apiCall('widget-message', {
1564      visitor_id: visitorId,
1565      conversation_id: conversationId,
1566      content,
1567      sender_type: 'visitor',
1568      website_id: window.location.hostname
1569    });
1570
1571    if (result.success) {
1572      conversationId = result.conversation_id;
1573      
1574      // If AI response was returned, add it to messages
1575      if (result.ai_response) {
1576        messages.push({
1577          id: result.ai_response.id,
1578          content: result.ai_response.content,
1579          sender_type: 'agent',
1580          created_at: result.ai_response.created_at || new Date().toISOString(),
1581          isNew: true
1582        });
1583        renderMessages();
1584        playNotificationSound();
1585      }
1586    }
1587  }
1588
1589  function renderMessages() {
1590    const container = document.getElementById('cf-messages');
1591    if (!container) return;
1592
1593    if (messages.length === 0) {
1594      container.innerHTML = `
1595        <div style="text-align: center; color: #999; padding: 40px 20px; font-size: 14px;">
1596          <p>
1596Start a conversation! We're here to help.</p>
1597        </div>
1598      `;
1599      return;
1600    }
1601
1602    container.innerHTML = messages.map(msg => `
1603      <div class="cf-message cf-message-${msg.sender_type} ${msg.isNew ? 'cf-message-new' : ''}">
1604        <div class="cf-message-bubble">${escapeHtml(msg.content)}</div>
1605        <div class="cf-message-time">${formatTime(msg.created_at)}</div>
1606      </div>
1607    `).join('');
1608
1609    // Scroll to bottom
1610    container.scrollTop = container.scrollHeight;
1611  }
1612
1613  function escapeHtml(text) {
1614    const div = document.createElement('div');
1615    div.textContent = text;
1616    return div.innerHTML;
1617  }
1618
1619  function formatTime(dateStr) {
1620    const date = new Date(dateStr);
1621    return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1622  }
1623
1624  // Heartbeat to check for new messages and update presence
1625  let heartbeatInterval = null;
1626  let lastMessageId = null;
1627
1628  function startHeartbeat() {
1629    // Initial heartbeat
1630    doHeartbeat();
1631
1632    // Regular heartbeat every 3 seconds for faster agent message detection
1633    heartbeatInterval = setInterval(doHeartbeat, 3000);
1634
1635    // Track page changes
1636    window.addEventListener('beforeunload', () => {
1637      // This is now handled in handlePageExit for analytics
1638    });
1639  }
1640
1641  async function doHeartbeat() {
1642    const result = await apiCall('widget-heartbeat', {
1643      visitor_id: visitorId,
1644      current_page: window.location.pathname
1645    });
1646
1647    if (result.success && result.newMessages?.length > 0) {
1648      let newAgentMessages = 0;
1649      
1650      // Add new agent messages
1651      result.newMessages.forEach(msg => {
1652        if (!messages.find(m => m.id === msg.id)) {
1653          msg.isNew = true;
1654          messages.push(msg);
1655          
1656          // Count new agent messages
1657          if (msg.sender_type === 'agent') {
1658            newAgentMessages++;
1659            unreadCount++;
1660          }
1661        }
1662      });
1663      
1664      renderMessages();
1665      
1666      // If there are new agent messages and chat is not open, auto-open and notify
1667      if (newAgentMessages > 0) {
1668        // Play notification sound
1669        playNotificationSound();
1670        
1671        if (!isOpen) {
1672          // Auto-open the chat window
1673          toggleWidget(true);
1674          
1675          // Also show notification badge (will be visible briefly before opening)
1676          showNewMessageNotification(unreadCount);
1677        }
1678        
1679        // Clear the "new" flag after animation
1680        setTimeout(() => {
1681          messages.forEach(m => m.isNew = false);
1682        }, 1000);
1683      }
1684    }
1685
1686    if (result.conversation_id) {
1687      conversationId = result.conversation_id;
1688    }
1689  }
1690
1691  // Expose global API
1692  window.ChatFlow = window.ChatFlow || {};
1693  window.ChatFlow.init = init;
1694  window.ChatFlow.open = () => toggleWidget(true);
1695  window.ChatFlow.close = () => toggleWidget(false);
1696  window.ChatFlow.toggle = () => toggleWidget(!isOpen);
1697
1698  // Auto-init (wait for DOM so document.body exists)
1699  function bootstrap() {
1700    try {
1701      init(window.ChatFlowConfig || {});
1702    } catch (e) {
1703      console.error('ChatFlow init failed:', e);
1704    }
1705  }
1706
1707  if (document.readyState === 'loading') {
1708    document.addEventListener('DOMContentLoaded', bootstrap);
1709  } else {
1710    bootstrap();
1711  }
1712})();

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.