Spaces:
Runtime error
Runtime error
| (function () { | |
| // Auto-detect the API base URL from the script source | |
| const scriptTag = document.currentScript; | |
| const scriptSrc = scriptTag ? scriptTag.src : ''; | |
| const detectedBase = scriptSrc ? new URL(scriptSrc).origin : 'http://localhost:8000'; | |
| const WIDGET_API_BASE = detectedBase; | |
| console.log('CustomerAgentWidget: Script loaded from', WIDGET_API_BASE); | |
| function createChatWidget(arg1, arg2 = {}) { | |
| let websiteId, config; | |
| if (typeof arg1 === 'object' && arg1 !== null && arg1.websiteId) { | |
| // Handle init({ websiteId: 123, ...config }) | |
| config = { ...arg2, ...arg1 }; | |
| websiteId = config.websiteId; | |
| } else { | |
| // Handle init(123, { ...config }) | |
| websiteId = arg1; | |
| config = arg2; | |
| } | |
| console.log('CustomerAgentWidget: Initializing with', { websiteId, config }); | |
| const apiBase = config.apiUrl || 'http://localhost:8000'; | |
| const widgetContainer = document.createElement('div'); | |
| widgetContainer.id = 'customer-agent-widget'; | |
| const position = config.position || 'bottom-right'; | |
| const size = config.size || 'medium'; | |
| const sizeMap = { small: '50px', medium: '60px', large: '70px' }; | |
| widgetContainer.style.cssText = ` | |
| position: fixed; | |
| ${position.includes('bottom') ? 'bottom: 20px;' : 'top: 20px;'} | |
| ${position.includes('right') ? 'right: 20px;' : 'left: 20px;'} | |
| z-index: 10000; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| transition: all 0.3s ease; | |
| pointer-events: none; | |
| `; | |
| const chatBox = document.createElement('div'); | |
| chatBox.className = 'ca-widget-chat-box'; | |
| const header = document.createElement('div'); | |
| header.style.cssText = ` | |
| background: linear-gradient(135deg, ${config.primaryColor || '#4F46E5'}, ${adjustColor(config.primaryColor || '#4F46E5', -30)}); /* Defaults to Indigo-600 */ | |
| color: ${config.textColor || 'white'}; | |
| padding: 20px; | |
| font-weight: 600; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| position: relative; | |
| overflow: hidden; | |
| `; | |
| const statusDot = document.createElement('div'); | |
| statusDot.style.cssText = ` | |
| width: 8px; | |
| height: 8px; | |
| background: #10B981; | |
| border-radius: 50%; | |
| margin-right: 8px; | |
| animation: pulse 2s infinite; | |
| `; | |
| const headerContent = document.createElement('div'); | |
| headerContent.style.cssText = 'display: flex; align-items: center; flex-direction: column; align-items: flex-start;'; | |
| headerContent.innerHTML = ` | |
| <div style="display: flex; align-items: center;"> | |
| ${statusDot.outerHTML} | |
| <span style="font-size: 16px;">Customer Supporte</span> | |
| </div> | |
| <span style="font-size: 12px; opacity: 0.8; margin-top: 2px;">We're here to help!</span> | |
| `; | |
| // SVG Icons | |
| const icons = { | |
| mic: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>`, | |
| call: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`, | |
| maximize: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/></svg>`, | |
| minimize: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/></svg>`, | |
| minimize: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/></svg>`, | |
| close: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>`, | |
| send: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`, | |
| chat: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`, | |
| volume: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`, | |
| stop: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>`, | |
| speed: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>` | |
| }; | |
| const headerControls = document.createElement('div'); | |
| headerControls.style.cssText = 'display: flex; align-items: center; gap: 8px;'; | |
| const createHeaderBtn = (html, id) => { | |
| const btn = document.createElement('button'); | |
| btn.id = id; | |
| btn.innerHTML = html; | |
| btn.style.cssText = ` | |
| background: rgba(255,255,255,0.15); | |
| border: none; | |
| color: white; | |
| cursor: pointer; | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s ease; | |
| backdrop-filter: blur(10px); | |
| `; | |
| btn.addEventListener('mouseenter', () => { | |
| btn.style.background = 'rgba(255,255,255,0.25)'; | |
| btn.style.transform = 'scale(1.05)'; | |
| }); | |
| btn.addEventListener('mouseleave', () => { | |
| btn.style.background = 'rgba(255,255,255,0.15)'; | |
| btn.style.transform = 'scale(1)'; | |
| }); | |
| return btn; | |
| }; | |
| const micBtn = createHeaderBtn(icons.mic, 'mic-btn'); | |
| micBtn.title = 'Voice Input (Coming Soon)'; | |
| micBtn.style.opacity = '0.5'; | |
| micBtn.style.cursor = 'not-allowed'; | |
| const callBtn = createHeaderBtn(icons.call, 'call-btn'); | |
| callBtn.title = 'Call Support (Coming Soon)'; | |
| callBtn.style.opacity = '0.5'; | |
| callBtn.style.cursor = 'not-allowed'; | |
| const fullScreenBtn = createHeaderBtn(icons.maximize, 'fullscreen-btn'); | |
| fullScreenBtn.title = 'Toggle Full Screen'; | |
| const closeBtn = createHeaderBtn(icons.close, 'close-chat'); | |
| // Enhanced Close Button Styling | |
| closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; | |
| closeBtn.style.backdropFilter = 'blur(12px)'; | |
| closeBtn.addEventListener('mouseenter', () => { | |
| closeBtn.style.background = '#EF4444'; // Red on hover | |
| closeBtn.style.transform = 'rotate(90deg) scale(1.1)'; | |
| closeBtn.style.boxShadow = '0 0 15px rgba(239, 68, 68, 0.4)'; | |
| }); | |
| closeBtn.addEventListener('mouseleave', () => { | |
| closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; | |
| closeBtn.style.transform = 'rotate(0deg) scale(1)'; | |
| closeBtn.style.boxShadow = 'none'; | |
| }); | |
| // headerControls.appendChild(micBtn); // Removed as requested | |
| headerControls.appendChild(callBtn); | |
| headerControls.appendChild(fullScreenBtn); | |
| headerControls.appendChild(closeBtn); | |
| headerControls.appendChild(callBtn); | |
| headerControls.appendChild(fullScreenBtn); | |
| headerControls.appendChild(closeBtn); | |
| header.appendChild(headerContent); | |
| header.appendChild(headerControls); | |
| const messages = document.createElement('div'); | |
| messages.className = 'messages-container'; | |
| messages.style.cssText = ` | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px; | |
| background: linear-gradient(to bottom, #fafafa, #ffffff); | |
| scroll-behavior: smooth; | |
| `; | |
| // Custom scrollbar | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| #customer-agent-widget > * { | |
| pointer-events: auto; | |
| } | |
| #customer-agent-widget * { | |
| box-sizing: border-box; | |
| } | |
| #customer-agent-widget .messages-container { | |
| overscroll-behavior: contain; | |
| } | |
| #customer-agent-widget *::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| #customer-agent-widget *::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| border-radius: 3px; | |
| } | |
| #customer-agent-widget *::-webkit-scrollbar-thumb { | |
| background: #c1c1c1; | |
| border-radius: 3px; | |
| } | |
| #customer-agent-widget *::-webkit-scrollbar-thumb:hover { | |
| background: #a8a8a8; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| @keyframes slideUp { | |
| from { transform: translateY(20px); opacity: 0; } | |
| to { transform: translateY(0); opacity: 1; } | |
| } | |
| @keyframes bounce { | |
| 0%, 20%, 53%, 80%, 100% { transform: translate3d(0,0,0); } | |
| 40%, 43% { transform: translate3d(0,-8px,0); } | |
| 70% { transform: translate3d(0,-4px,0); } | |
| 90% { transform: translate3d(0,-2px,0); } | |
| } | |
| /* Responsive Styles */ | |
| .ca-widget-chat-box { | |
| display: none; | |
| background: white; | |
| border-radius: 24px; | |
| box-shadow: 0 25px 80px -15px rgba(0, 0, 0, 0.25); | |
| overflow: hidden; | |
| flex-direction: column; | |
| transform: scale(0.95); | |
| opacity: 0; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255,255,255,0.2); | |
| position: absolute; | |
| bottom: 80px; | |
| right: 0; | |
| width: 380px; | |
| height: 600px; | |
| max-height: calc(100vh - 120px); | |
| max-width: calc(100vw - 40px); | |
| } | |
| .ca-widget-chat-box.active { | |
| display: flex; | |
| transform: scale(1); | |
| opacity: 1; | |
| } | |
| .ca-widget-chat-box.fullscreen { | |
| width: 100vw !important; | |
| height: 100vh !important; | |
| bottom: 0 !important; | |
| right: 0 !important; | |
| border-radius: 0 !important; | |
| position: fixed !important; | |
| top: 0 !important; | |
| left: 0 !important; | |
| max-height: 100vh !important; | |
| } | |
| @media (max-width: 580px) { | |
| .ca-widget-chat-box { | |
| width: calc(100vw - 30px); | |
| max-width: 450px; | |
| height: calc(100vh - 100px); | |
| max-height: 700px; | |
| bottom: 75px; | |
| right: -10px; /* Slight offset to align better with button */ | |
| border-radius: 20px; | |
| } | |
| #customer-agent-widget { | |
| right: 15px !important; | |
| bottom: 15px !important; | |
| } | |
| .ca-widget-chat-box.fullscreen { | |
| width: 100vw !important; | |
| height: 100vh !important; | |
| max-width: 100vw !important; | |
| max-height: 100vh !important; | |
| right: -15px !important; /* Counteract container right:15px */ | |
| bottom: -15px !important; /* Counteract container bottom:15px */ | |
| } | |
| } | |
| .speech-controls { | |
| display: flex; | |
| gap: 8px; | |
| margin-top: 8px; | |
| opacity: 0; | |
| transition: opacity 0.2s ease; | |
| align-items: center; | |
| } | |
| .ca-widget-chat-bubble:hover .speech-controls { | |
| opacity: 1; | |
| } | |
| .speech-btn { | |
| background: rgba(0,0,0,0.05); | |
| border: none; | |
| padding: 4px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: #6B7280; | |
| transition: all 0.2s ease; | |
| } | |
| .speech-btn:hover { | |
| background: rgba(0,0,0,0.1); | |
| color: #374151; | |
| } | |
| .speed-indicator { | |
| font-size: 10px; | |
| font-weight: bold; | |
| color: #6B7280; | |
| background: rgba(0,0,0,0.05); | |
| padding: 2px 4px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .speed-indicator:hover { | |
| background: rgba(0,0,0,0.1); | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| const inputContainer = document.createElement('div'); | |
| inputContainer.style.cssText = ` | |
| padding: 15px; | |
| border-top: 1px solid rgba(0,0,0,0.05); | |
| background: white; | |
| backdrop-filter: blur(10px); | |
| flex-shrink: 0; | |
| `; | |
| const messageForm = document.createElement('div'); | |
| messageForm.style.cssText = 'display: flex; gap: 10px; margin-bottom: 10px;'; | |
| const micButton = document.createElement('button'); | |
| micButton.innerHTML = icons.mic; | |
| micButton.title = "Voice Input"; | |
| micButton.style.cssText = ` | |
| background: none; | |
| border: none; | |
| color: #9CA3AF; | |
| cursor: pointer; | |
| padding: 8px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s ease; | |
| `; | |
| micButton.addEventListener('mouseenter', () => micButton.style.color = config.primaryColor || '#3B82F6'); | |
| micButton.addEventListener('mouseleave', () => micButton.style.color = '#9CA3AF'); | |
| const inputWrapper = document.createElement('div'); | |
| inputWrapper.style.cssText = ` | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| position: relative; | |
| border: 2px solid #e5e7eb; | |
| border-radius: 25px; | |
| background: #f9fafb; | |
| transition: all 0.2s ease; | |
| `; | |
| const input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.placeholder = 'Type your message...'; | |
| input.style.cssText = ` | |
| flex: 1; | |
| padding: 12px 16px; | |
| padding-right: 44px; /* Room for mic button */ | |
| border: none; | |
| background: transparent; | |
| outline: none; | |
| font-size: 14px; | |
| color: #374151; | |
| `; | |
| // micButton state: Disabled until voice functionality is added | |
| micButton.disabled = true; | |
| micButton.style.opacity = '0.4'; | |
| micButton.style.cursor = 'not-allowed'; | |
| micButton.style.display = 'flex'; // Restore visibility | |
| inputWrapper.appendChild(input); | |
| inputWrapper.appendChild(micButton); | |
| input.addEventListener('focus', () => { | |
| inputWrapper.style.borderColor = config.primaryColor || '#3B82F6'; | |
| inputWrapper.style.background = 'white'; | |
| inputWrapper.style.boxShadow = `0 0 0 3px ${config.primaryColor || '#3B82F6'}20`; | |
| }); | |
| input.addEventListener('blur', () => { | |
| inputWrapper.style.borderColor = '#e5e7eb'; | |
| inputWrapper.style.background = '#f9fafb'; | |
| inputWrapper.style.boxShadow = 'none'; | |
| }); | |
| const sendButton = document.createElement('button'); | |
| sendButton.innerHTML = icons.send; | |
| sendButton.style.cssText = ` | |
| background: ${config.primaryColor || '#3B82F6'}; | |
| color: ${config.textColor || 'white'}; | |
| border: none; | |
| padding: 0; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| width: 44px; | |
| height: 44px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 16px; | |
| transition: all 0.2s ease; | |
| box-shadow: 0 2px 8px ${config.primaryColor || '#3B82F6'}40; | |
| flex-shrink: 0; | |
| `; | |
| // sendButton hover effects ... | |
| const contactForm = document.createElement('div'); | |
| contactForm.style.cssText = 'display: none; gap: 5px; flex-direction: column;'; | |
| contactForm.innerHTML = ` | |
| <input type="text" placeholder="Your name (optional)" id="visitor-name" style="padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;"> | |
| <input type="email" placeholder="Your email (optional)" id="visitor-email" style="padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;"> | |
| <button id="contact-owner" style="background: #10B981; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">Contact Owner</button> | |
| `; | |
| const toggleButton = document.createElement('button'); | |
| toggleButton.innerHTML = icons.chat; | |
| toggleButton.style.cssText = ` | |
| width: ${sizeMap[size]}; | |
| height: ${sizeMap[size]}; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, ${config.primaryColor || '#3B82F6'}, ${adjustColor(config.primaryColor || '#3B82F6', -20)}); | |
| color: ${config.textColor || 'white'}; | |
| border: none; | |
| font-size: ${size === 'small' ? '18px' : size === 'large' ? '28px' : '24px'}; | |
| cursor: pointer; | |
| box-shadow: 0 8px 25px rgba(0,0,0,0.15); | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| position: relative; | |
| overflow: hidden; | |
| `; | |
| // Add ripple effect | |
| toggleButton.addEventListener('click', (e) => { | |
| const ripple = document.createElement('div'); | |
| ripple.style.cssText = ` | |
| position: absolute; | |
| border-radius: 50%; | |
| background: rgba(255,255,255,0.6); | |
| transform: scale(0); | |
| animation: ripple 0.6s linear; | |
| pointer-events: none; | |
| `; | |
| const rect = toggleButton.getBoundingClientRect(); | |
| const size = Math.max(rect.width, rect.height); | |
| ripple.style.width = ripple.style.height = size + 'px'; | |
| ripple.style.left = (e.clientX - rect.left - size / 2) + 'px'; | |
| ripple.style.top = (e.clientY - rect.top - size / 2) + 'px'; | |
| toggleButton.appendChild(ripple); | |
| setTimeout(() => ripple.remove(), 600); | |
| }); | |
| // Add hover effects | |
| toggleButton.addEventListener('mouseenter', () => { | |
| toggleButton.style.transform = 'scale(1.1)'; | |
| toggleButton.style.boxShadow = '0 12px 35px rgba(0,0,0,0.2)'; | |
| }); | |
| toggleButton.addEventListener('mouseleave', () => { | |
| toggleButton.style.transform = 'scale(1)'; | |
| toggleButton.style.boxShadow = '0 8px 25px rgba(0,0,0,0.15)'; | |
| }); | |
| // Add CSS for ripple animation | |
| if (!document.querySelector('#ripple-style')) { | |
| const rippleStyle = document.createElement('style'); | |
| rippleStyle.id = 'ripple-style'; | |
| rippleStyle.textContent = ` | |
| @keyframes ripple { | |
| to { transform: scale(4); opacity: 0; } | |
| } | |
| `; | |
| document.head.appendChild(rippleStyle); | |
| } | |
| let currentSpeechSpeed = 1.0; | |
| const speeds = [0.5, 1.0, 1.5, 2.0]; | |
| function cycleSpeed() { | |
| const currentIndex = speeds.indexOf(currentSpeechSpeed); | |
| currentSpeechSpeed = speeds[(currentIndex + 1) % speeds.length]; | |
| // Update all speed indicators | |
| document.querySelectorAll('.speed-indicator').forEach(el => { | |
| el.textContent = currentSpeechSpeed + 'x'; | |
| }); | |
| } | |
| let chatHistory = []; | |
| let showingContactForm = false; | |
| let sessionId = localStorage.getItem(`chat-session-${websiteId}`) || generateSessionId(); | |
| let visitorName = ''; | |
| let visitorEmail = ''; | |
| let websocket = null; | |
| function generateSessionId() { | |
| const id = 'session-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); | |
| localStorage.setItem(`chat-session-${websiteId}`, id); | |
| return id; | |
| } | |
| function connectWebSocket() { | |
| try { | |
| const wsUrl = apiBase.replace(/^http/, 'ws') + `/ws/${websiteId}`; | |
| websocket = new WebSocket(wsUrl); | |
| websocket.onopen = () => { | |
| console.log('WebSocket connected for session:', sessionId); | |
| console.log('Website ID:', websiteId); | |
| // Register session with server | |
| websocket.send(JSON.stringify({ | |
| type: 'register_session', | |
| session_id: sessionId, | |
| website_id: websiteId | |
| })); | |
| }; | |
| websocket.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| console.log('Received WebSocket message:', data); console.log('Needs Contact:', data.needs_owner_contact); | |
| if (data.type === 'admin_response' || data.type === 'ai_response') { | |
| // Remove typing indicator if any | |
| hideTypingIndicator(); | |
| // Show response | |
| const msgText = data.message || data.response; | |
| addMessage(msgText, false, data.needs_owner_contact, data.sender || 'Support Team'); | |
| } else if (data.type === 'session_registered') { | |
| console.log('Session registered successfully:', data.session_id); | |
| } | |
| }; | |
| websocket.onclose = () => { | |
| console.log('WebSocket disconnected'); | |
| // Reconnect after 3 seconds | |
| setTimeout(connectWebSocket, 3000); | |
| }; | |
| websocket.onerror = (error) => { | |
| console.error('WebSocket error:', error); | |
| }; | |
| } catch (error) { | |
| console.error('Failed to connect WebSocket:', error); | |
| } | |
| } | |
| function addMessage(text, isUser = false, showContact = false, senderName = null) { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.style.cssText = ` | |
| margin-bottom: 16px; | |
| display: flex; | |
| ${isUser ? 'justify-content: flex-end;' : 'justify-content: flex-start;'} | |
| animation: slideUp 0.3s ease; | |
| `; | |
| if (!isUser) { | |
| const avatar = document.createElement('div'); | |
| avatar.style.cssText = ` | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 50%; | |
| background: ${config.primaryColor || '#3B82F6'}; | |
| color: white; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 14px; | |
| margin-right: 8px; | |
| flex-shrink: 0; | |
| align-self: flex-end; | |
| `; | |
| avatar.textContent = '🤖'; | |
| messageDiv.appendChild(avatar); | |
| } | |
| const bubble = document.createElement('div'); | |
| bubble.style.cssText = ` | |
| max-width: 75%; | |
| padding: 12px 16px; | |
| border-radius: ${isUser ? '20px 20px 4px 20px' : '20px 20px 20px 4px'}; | |
| font-size: 14px; | |
| line-height: 1.4; | |
| word-wrap: break-word; | |
| position: relative; | |
| ${isUser | |
| ? `background: linear-gradient(135deg, ${config.primaryColor || '#3B82F6'}, ${adjustColor(config.primaryColor || '#3B82F6', -10)}); color: ${config.textColor || 'white'}; box-shadow: 0 2px 8px ${config.primaryColor || '#3B82F6'}30;` | |
| : `background: white; color: #374151; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border: 1px solid #f0f0f0;` | |
| } | |
| transition: all 0.2s ease; | |
| `; | |
| // Add sender name for admin messages | |
| if (senderName && !isUser) { | |
| const senderLabel = document.createElement('div'); | |
| senderLabel.style.cssText = 'font-size: 11px; color: #6B7280; margin-bottom: 4px; font-weight: 500;'; | |
| senderLabel.textContent = senderName; | |
| bubble.appendChild(senderLabel); | |
| } | |
| const messageText = document.createElement('div'); | |
| // Enhanced Markdown Parser | |
| const parseMarkdown = (text) => { | |
| let html = text; | |
| // 1. Parse Links: [text](url) -> <a href="url">text</a> | |
| const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; | |
| html = html.replace(linkRegex, (match, linkText, url) => { | |
| return `<a href="${url}" target="_blank" style="color: ${config.primaryColor || '#3B82F6'}; text-decoration: underline;">${linkText}</a>`; | |
| }); | |
| // 2. Parse Italics: _text_ -> <em>text</em> | |
| const italicRegex = /_([^_]+)_/g; | |
| html = html.replace(italicRegex, '<em>$1</em>'); | |
| return html; | |
| }; | |
| messageText.innerHTML = parseMarkdown(text); | |
| bubble.appendChild(messageText); | |
| // Add Speech Controls for Bot Messages | |
| if (!isUser) { | |
| bubble.className = 'ca-widget-chat-bubble'; | |
| const speechControls = document.createElement('div'); | |
| speechControls.className = 'speech-controls'; | |
| const playBtn = document.createElement('button'); | |
| playBtn.className = 'speech-btn'; | |
| playBtn.innerHTML = icons.volume; | |
| playBtn.title = 'Speak Response'; | |
| const speedBtn = document.createElement('div'); | |
| speedBtn.className = 'speed-indicator'; | |
| speedBtn.textContent = currentSpeechSpeed + 'x'; | |
| speedBtn.title = 'Change Speed'; | |
| let isSpeaking = false; | |
| let utterance = null; | |
| playBtn.onclick = () => { | |
| if (isSpeaking) { | |
| window.speechSynthesis.cancel(); | |
| playBtn.innerHTML = icons.volume; | |
| isSpeaking = false; | |
| } else { | |
| window.speechSynthesis.cancel(); // Stop any current speech | |
| utterance = new SpeechSynthesisUtterance(text); | |
| utterance.rate = currentSpeechSpeed; | |
| utterance.onend = () => { | |
| playBtn.innerHTML = icons.volume; | |
| isSpeaking = false; | |
| }; | |
| utterance.onerror = () => { | |
| playBtn.innerHTML = icons.volume; | |
| isSpeaking = false; | |
| }; | |
| playBtn.innerHTML = icons.stop; | |
| window.speechSynthesis.speak(utterance); | |
| isSpeaking = true; | |
| } | |
| }; | |
| speedBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| cycleSpeed(); | |
| if (isSpeaking && utterance) { | |
| // If speaking, restart with new speed | |
| playBtn.click(); // Stop | |
| setTimeout(() => playBtn.click(), 50); // Restart | |
| } | |
| }; | |
| speechControls.appendChild(playBtn); | |
| speechControls.appendChild(speedBtn); | |
| bubble.appendChild(speechControls); | |
| } | |
| // Add hover effect for bot messages | |
| if (!isUser) { | |
| bubble.addEventListener('mouseenter', () => { | |
| bubble.style.transform = 'translateY(-1px)'; | |
| bubble.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; | |
| }); | |
| bubble.addEventListener('mouseleave', () => { | |
| bubble.style.transform = 'translateY(0)'; | |
| bubble.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'; | |
| }); | |
| } | |
| messageDiv.appendChild(bubble); | |
| messages.appendChild(messageDiv); | |
| // Smooth scroll to bottom | |
| setTimeout(() => { | |
| messages.scrollTo({ | |
| top: messages.scrollHeight, | |
| behavior: 'smooth' | |
| }); | |
| }, 100); | |
| chatHistory.push({ text, isUser }); | |
| if (showContact && !showingContactForm) { | |
| showContactForm(); | |
| } | |
| } | |
| function showContactForm() { | |
| showingContactForm = true; | |
| contactForm.style.display = 'flex'; | |
| messageForm.style.display = 'none'; | |
| // Check if contact message already exists to prevent duplicates | |
| const existingContactMsg = inputContainer.querySelector('.contact-prompt-message'); | |
| if (!existingContactMsg) { | |
| const contactMsg = document.createElement('div'); | |
| contactMsg.className = 'contact-prompt-message'; | |
| contactMsg.style.cssText = 'text-align: center; color: #6B7280; font-size: 12px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;'; | |
| contactMsg.innerHTML = '<span>Would you like the owner to contact you directly?</span><button id="close-contact" style="background: none; border: none; color: #6B7280; cursor: pointer; font-size: 14px;">✕</button>'; | |
| inputContainer.insertBefore(contactMsg, contactForm); | |
| } | |
| } | |
| // Client-side content filter | |
| function isInappropriate(text) { | |
| const inappropriate = [ | |
| /\b(porn|sex|nude|xxx|adult|erotic)\b/i, | |
| /\b(fuck|shit|damn|bitch|asshole)\b/i, | |
| /\b(kill|die|murder|bomb|weapon)\b/i | |
| ]; | |
| return inappropriate.some(pattern => pattern.test(text)); | |
| } | |
| async function sendMessage() { | |
| const message = input.value.trim(); | |
| if (!message) return; | |
| // Filter inappropriate content on client side | |
| if (isInappropriate(message)) { | |
| addMessage(message, true); | |
| input.value = ''; | |
| addMessage('I\'m designed to provide helpful business information. Please keep our conversation professional and appropriate.'); | |
| return; | |
| } | |
| addMessage(message, true); | |
| input.value = ''; | |
| showTypingIndicator(); // Show typing animation | |
| // OPTIMIZED: Use WebSocket if available | |
| if (websocket && websocket.readyState === WebSocket.OPEN) { | |
| try { | |
| websocket.send(JSON.stringify({ | |
| type: 'chat_message', | |
| message: message, | |
| visitor_name: visitorName, | |
| visitor_email: visitorEmail, // Captured if available | |
| website_id: websiteId, | |
| session_id: sessionId | |
| })); | |
| // Typing indicator assumes socket will reply eventually | |
| // (We don't get a promise here, relying on onmessage) | |
| return; | |
| } catch (e) { | |
| console.warn("Socket send failed, falling back to HTTP"); | |
| fallbackToHttp(message); | |
| } | |
| } else { | |
| fallbackToHttp(message); | |
| } | |
| } | |
| async function fallbackToHttp(message) { | |
| try { | |
| const responsePromise = fetch(`${apiBase}/api/chat`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'ngrok-skip-browser-warning': 'true' | |
| }, | |
| body: JSON.stringify({ | |
| message: message, | |
| website_id: websiteId, | |
| session_id: sessionId, | |
| visitor_name: visitorName, | |
| visitor_email: visitorEmail | |
| }) | |
| }); | |
| // Dynamic Status Updates (Engagement Milestones) | |
| const messages = [ | |
| "Anas is deep searching...", | |
| "Contextualizing your request...", | |
| "Summarizing information...", | |
| "Almost there! Formulating answer..." | |
| ]; | |
| let msgIndex = 0; | |
| const statusInterval = setInterval(() => { | |
| if (msgIndex < messages.length) { | |
| showTypingIndicator(messages[msgIndex]); | |
| msgIndex++; | |
| } | |
| }, 2000); | |
| const response = await responsePromise; | |
| clearInterval(statusInterval); | |
| if (!response.ok) throw new Error('Network response was not ok'); | |
| const data = await response.json(); | |
| hideTypingIndicator(); // Hide typing animation | |
| // Add AI response to chat | |
| addMessage(data.response, false, data.needs_owner_contact); | |
| } catch (error) { | |
| console.error('Error sending message:', error); | |
| hideTypingIndicator(); | |
| addMessage("Only Anas knows the answer to that... Let me connect you.", false, true); | |
| showContactForm(); | |
| } | |
| } | |
| async function contactOwner() { | |
| const name = document.getElementById('visitor-name').value; | |
| const email = document.getElementById('visitor-email').value; | |
| // Find the last USER message (skip AI responses) | |
| let lastMessage = ''; | |
| for (let i = chatHistory.length - 1; i >= 0; i--) { | |
| if (chatHistory[i].isUser) { | |
| lastMessage = chatHistory[i].text; | |
| break; | |
| } | |
| } | |
| // Store visitor info for future messages | |
| visitorName = name; | |
| visitorEmail = email; | |
| console.log('[Widget v1.2] Contacting owner. Last User Message:', lastMessage); | |
| try { | |
| const response = await fetch(`${apiBase}/api/actions/contact-owner`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'ngrok-skip-browser-warning': 'true' | |
| }, | |
| body: JSON.stringify({ | |
| website_id: websiteId, | |
| session_id: sessionId, // Pass session ID to update anonymous user | |
| visitor_name: name, | |
| visitor_email: email, | |
| message: lastMessage, | |
| chat_context: JSON.stringify(chatHistory) | |
| }) | |
| }); | |
| const data = await response.json(); | |
| addMessage(data.message); | |
| contactForm.style.display = 'none'; | |
| } catch (error) { | |
| addMessage('Failed to contact owner. Please try again.'); | |
| } | |
| } | |
| // Enhanced toggle functionality | |
| toggleButton.onclick = () => { | |
| const isOpening = !chatBox.classList.contains('active'); | |
| if (isOpening) { | |
| chatBox.classList.add('active'); | |
| toggleButton.innerHTML = icons.close; | |
| toggleButton.style.animation = 'bounce 0.6s ease'; | |
| if (window.innerWidth <= 580) { | |
| document.body.style.overflow = 'hidden'; | |
| } | |
| } else { | |
| chatBox.classList.remove('active'); | |
| chatBox.classList.remove('fullscreen'); // Reset fullscreen when closing | |
| isFullScreen = false; | |
| fullScreenBtn.innerHTML = icons.maximize; | |
| toggleButton.innerHTML = icons.chat; | |
| widgetContainer.style.zIndex = '10000'; | |
| document.body.style.overflow = ''; | |
| } | |
| }; | |
| // Full Screen Toggle Logic | |
| let isFullScreen = false; | |
| fullScreenBtn.onclick = () => { | |
| isFullScreen = !isFullScreen; | |
| if (isFullScreen) { | |
| chatBox.classList.add('fullscreen'); | |
| fullScreenBtn.innerHTML = icons.minimize; | |
| widgetContainer.style.zIndex = '100000'; | |
| } else { | |
| chatBox.classList.remove('fullscreen'); | |
| fullScreenBtn.innerHTML = icons.maximize; | |
| widgetContainer.style.zIndex = '10000'; | |
| } | |
| }; | |
| closeBtn.onclick = () => { | |
| chatBox.classList.remove('active'); | |
| chatBox.classList.remove('fullscreen'); | |
| isFullScreen = false; | |
| fullScreenBtn.innerHTML = icons.maximize; | |
| toggleButton.innerHTML = icons.chat; | |
| widgetContainer.style.zIndex = '10000'; | |
| }; | |
| // Close button hover effects handled by createHeaderBtn logic | |
| sendButton.onclick = sendMessage; | |
| input.onkeypress = (e) => { | |
| if (e.key === 'Enter') sendMessage(); | |
| }; | |
| document.addEventListener('click', (e) => { | |
| if (e.target.id === 'contact-owner') { | |
| contactOwner(); | |
| } else if (e.target.id === 'close-contact') { | |
| contactForm.style.display = 'none'; | |
| messageForm.style.display = 'flex'; | |
| showingContactForm = false; | |
| const contactMsg = inputContainer.querySelector('.contact-prompt-message'); | |
| if (contactMsg) { | |
| contactMsg.remove(); | |
| } | |
| } | |
| }); | |
| // Assembly | |
| messageForm.appendChild(inputWrapper); | |
| messageForm.appendChild(sendButton); | |
| inputContainer.appendChild(messageForm); | |
| inputContainer.appendChild(contactForm); | |
| chatBox.appendChild(header); | |
| chatBox.appendChild(messages); | |
| chatBox.appendChild(inputContainer); | |
| widgetContainer.appendChild(chatBox); | |
| widgetContainer.appendChild(toggleButton); | |
| if (document.body) { | |
| document.body.appendChild(widgetContainer); | |
| console.log('CustomerAgentWidget: Widget appended to DOM'); | |
| } else { | |
| console.log('CustomerAgentWidget: document.body not ready, waiting for DOMContentLoaded'); | |
| document.addEventListener('DOMContentLoaded', () => { | |
| document.body.appendChild(widgetContainer); | |
| console.log('CustomerAgentWidget: Widget appended to DOM (delayed)'); | |
| }); | |
| } | |
| // Connect WebSocket for real-time messages | |
| connectWebSocket(); | |
| // Initial greeting with typing indicator | |
| setTimeout(() => { | |
| showTypingIndicator(); | |
| setTimeout(() => { | |
| hideTypingIndicator(); | |
| // Get dynamic greeting based on website tone | |
| fetch(`${apiBase}/api/websites/${websiteId}/greeting`, { | |
| headers: { 'ngrok-skip-browser-warning': 'true' } | |
| }) | |
| .then(response => response.json()) | |
| .then(data => addMessage(data.greeting)) | |
| .catch(() => addMessage('Hello! I\'m Anas, your virtual assistant. How can I help you today? 👋')); | |
| }, 1500); | |
| }, 1000); | |
| function showTypingIndicator(statusText = "") { | |
| let typingDiv = document.getElementById('typing-indicator'); | |
| // If already exists, just update text | |
| if (typingDiv) { | |
| const statusEl = typingDiv.querySelector('.typing-status'); | |
| if (statusEl) statusEl.textContent = statusText; | |
| return; | |
| } | |
| typingDiv = document.createElement('div'); | |
| typingDiv.id = 'typing-indicator'; | |
| typingDiv.style.cssText = ` | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-start; | |
| margin-bottom: 16px; | |
| animation: slideUp 0.3s ease; | |
| `; | |
| const avatarAndBubble = document.createElement('div'); | |
| avatarAndBubble.style.cssText = 'display: flex; align-items: flex-end;'; | |
| const avatar = document.createElement('div'); | |
| avatar.style.cssText = ` | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 50%; | |
| background: ${config.primaryColor || '#3B82F6'}; | |
| color: white; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 14px; | |
| margin-right: 8px; | |
| `; | |
| avatar.textContent = '🤖'; | |
| const bubble = document.createElement('div'); | |
| bubble.style.cssText = ` | |
| background: white; | |
| padding: 12px 16px; | |
| border-radius: 20px 20px 20px 4px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.1); | |
| border: 1px solid #f0f0f0; | |
| `; | |
| const dots = document.createElement('div'); | |
| dots.innerHTML = '<span>●</span><span>●</span><span>●</span>'; | |
| dots.style.cssText = ` | |
| display: flex; | |
| gap: 4px; | |
| color: #9CA3AF; | |
| `; | |
| // Animate dots | |
| const spans = dots.querySelectorAll('span'); | |
| spans.forEach((span, i) => { | |
| span.style.animation = `pulse 1.4s ease-in-out ${i * 0.2}s infinite`; | |
| }); | |
| bubble.appendChild(dots); | |
| avatarAndBubble.appendChild(avatar); | |
| avatarAndBubble.appendChild(bubble); | |
| typingDiv.appendChild(avatarAndBubble); | |
| if (statusText) { | |
| const statusEl = document.createElement('div'); | |
| statusEl.className = 'typing-status'; | |
| statusEl.style.cssText = 'font-size: 11px; color: #9CA3AF; margin-left: 40px; margin-top: 4px; font-style: italic;'; | |
| statusEl.textContent = statusText; | |
| typingDiv.appendChild(statusEl); | |
| } | |
| messages.appendChild(typingDiv); | |
| messages.scrollTo({ | |
| top: messages.scrollHeight, | |
| behavior: 'smooth' | |
| }); | |
| } | |
| function hideTypingIndicator() { | |
| const indicator = document.getElementById('typing-indicator'); | |
| if (indicator) { | |
| indicator.remove(); | |
| } | |
| } | |
| } | |
| // Auto-initialize | |
| const script = document.currentScript; | |
| const websiteId = script ? script.getAttribute('data-website-id') : null; | |
| const configData = script ? script.getAttribute('data-config') : null; | |
| let config = { | |
| theme: 'blue', | |
| position: 'bottom-right', | |
| size: 'medium', | |
| primaryColor: '#3B82F6', | |
| textColor: '#FFFFFF', | |
| backgroundColor: '#FFFFFF' | |
| }; | |
| if (configData) { | |
| try { | |
| config = { ...config, ...JSON.parse(configData) }; | |
| } catch (e) { | |
| console.warn('Invalid widget config, using defaults'); | |
| } | |
| } | |
| if (websiteId) { | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => createChatWidget(websiteId, config)); | |
| } else { | |
| createChatWidget(websiteId, config); | |
| } | |
| } | |
| // Helper function to adjust color brightness | |
| function adjustColor(color, amount) { | |
| const usePound = color[0] === '#'; | |
| const col = usePound ? color.slice(1) : color; | |
| const num = parseInt(col, 16); | |
| let r = (num >> 16) + amount; | |
| let g = (num >> 8 & 0x00FF) + amount; | |
| let b = (num & 0x0000FF) + amount; | |
| r = r > 255 ? 255 : r < 0 ? 0 : r; | |
| g = g > 255 ? 255 : g < 0 ? 0 : g; | |
| b = b > 255 ? 255 : b < 0 ? 0 : b; | |
| return (usePound ? '#' : '') + (r << 16 | g << 8 | b).toString(16).padStart(6, '0'); | |
| } | |
| window.CustomerAgentWidget = { init: createChatWidget }; | |
| })(); |