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">×</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.