1/** 2 * ============================================ 3 * Chat Support Widget ââ¬â Embeddable Script 4 * ============================================ 5 * 6 * Usage: 7 * <script src="https://example.com/widget.js" data-client-id="YOUR_ID"></script> 8 * 9 * Optional attributes: 10 * data-theme-color="#6C5CE7" ââ¬â Custom primary color 11 * data-position="bottom-right" ââ¬â Widget position 12 * data-api-url="https://..." ââ¬â API base URL 13 * 14 * ============================================ 15 */ 16 17(function() { 18 'use strict'; 19 20 // ============================================ 21 // Configuration from script tag 22 // ============================================ 23 const scriptTag = document.currentScript || document.querySelector('script[data-client-id]'); 24 const CONFIG = { 25 clientId: scriptTag?.getAttribute('data-client-id') || 'demo_client_001', 26 themeColor: scriptTag?.getAttribute('data-theme-color') || null, 27 apiUrl: scriptTag?.getAttribute('data-api-url') || (scriptTag ? new URL('../api', scriptTag.src).href : '/api'), 28 position: scriptTag?.getAttribute('data-position') || 'bottom-right', 29 }; 30 31 // ============================================ 32 // State 33 // ============================================ 34 const STATE = { 35 isOpen: false, 36 sessionId: null, 37 lastMessageId: 0, 38 messages: [], 39 unreadCount: 0, 40 isPolling: false, 41 pollAbort: null, 42 clientName: '', 43 themeColor: CONFIG.themeColor || '#4a6aac', 44 welcomeMessage: '', 45 uploadFile: null, 46 fallbackTimer: null, 47 lastAgentReply: Date.now(), 48 soundEnabled: true, 49 pendingTempIds: new Set(), 50 visitorInfo: null, 51 }; 52 53 // ============================================ 54 // Sound ââ¬â notification beep (base64 encoded tiny wav) 55 // ============================================ 56 const NOTIFICATION_SOUND = (() => { 57 try { 58 const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 59 return { 60 play() { 61 if (!STATE.soundEnabled) return; 62 const osc = audioCtx.createOscillator(); 63 const gain = audioCtx.createGain(); 64 osc.connect(gain); 65 gain.connect(audioCtx.destination); 66 osc.frequency.value = 800; 67 osc.type = 'sine'; 68 gain.gain.setValueAtTime(0.3, audioCtx.currentTime); 69 gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3); 70 osc.start(audioCtx.currentTime); 71 osc.stop(audioCtx.currentTime + 0.3); 72 } 73 }; 74 } catch(e) { 75 return { play() {} }; 76 } 77 })(); 78 79 // ============================================ 80 // CSS ââ¬â Embedded for single-file deployment 81 // ============================================ 82 const WIDGET_CSS = ` 83@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); 84 85:host { 86 --primary: #4a6aac; 87 --primary-hover: color-mix(in srgb, var(--primary) 85%, black); 88 --primary-light: color-mix(in srgb, var(--primary) 10%, transparent); 89 --bg-dark: #ffffff; 90 --bg-card: #f7f8fa; 91 --bg-chat: #f4f6f9; 92 --bg-input: #ffffff; 93 --text-primary: #1a1a2e; 94 --text-secondary: #555577; 95 --text-muted: #888899; 96 --border: rgba(0,0,0,0.09); 97 --shadow-lg: 0 20px 60px rgba(74,106,172,0.18); 98 --shadow-bubble: 0 8px 30px rgba(74,106,172,0.35); 99 --radius: 16px; 100 --radius-sm: 10px; 101 --radius-msg: 18px; 102 --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 103 --visitor-bg: var(--primary); 104 --visitor-text: #ffffff; 105 --agent-bg: #ffffff; 106 --agent-text: #1a1a2e; 107 --system-text: var(--text-muted); 108 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 109} 110 111* { margin: 0; padding: 0; box-sizing: border-box; } 112 113.chat-bubble { 114 position: fixed; bottom: 40px; right: 24px; 115 width: 85px; height: 85px; border-radius: 50%; 116 background: #fff; border: none; cursor: pointer; 117 display: flex; align-items: center; justify-content: center; 118 box-shadow: var(--shadow-bubble); 119 transition: var(--transition); 120 z-index: 2147483646; 121 animation: bubbleIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); 122} 123.chat-bubble:hover { transform: scale(1.1); box-shadow: 0 12px 40px color-mix(in srgb, var(--primary) 60%, transparent); } 124.chat-bubble:active { transform: scale(0.95); } 125.chat-bubble svg { width: 45px; height: 45px; fill: #78377c; transition: var(--transition); } 126.chat-bubble.open svg.icon-chat { display: none; } 127.chat-bubble.open svg.icon-close { display: block; } 128.chat-bubble:not(.open) svg.icon-chat { display: block; } 129.chat-bubble:not(.open) svg.icon-close { display: none; } 130 131.chat-badge { 132 position: absolute; top: -4px; right: -4px; 133 min-width: 22px; height: 22px; border-radius: 11px; 134 background: #ff4757; color: white; font-size: 11px; font-weight: 700; 135 display: flex; align-items: center; justify-content: center; 136 padding: 0 6px; border: 2px solid var(--bg-dark); 137 animation: badgePop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); 138 box-shadow: 0 2px 10px rgba(255,71,87,0.4); 139} 140.chat-badge.hidden { display: none; } 141 142.chat-window { 143 position: fixed; bottom: 100px; right: 24px; 144 width: 400px; height: 600px; max-height: calc(100vh - 130px); 145 background: var(--bg-dark); 146 border-radius: var(--radius); overflow: hidden; 147 box-shadow: var(--shadow-lg); 148 display: flex; flex-direction: column; 149 z-index: 2147483645; 150 transform: scale(0) translateY(20px); opacity: 0; 151 transform-origin: bottom right; 152 transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease; 153 pointer-events: none; 154 border: 1px solid var(--border); 155} 156.chat-window.open { transform: scale(1) translateY(0); opacity: 1; pointer-events: auto; } 157 158.chat-header { 159 background: var(--primary); 160 padding: 14px 16px; color: white; flex-shrink: 0; 161 display: flex; align-items: center; gap: 12px; 162} 163.chat-header-logo { 164 width: 48px; height: 48px; border-radius: 10px; 165 background: white; flex-shrink: 0; 166 display: flex; align-items: center; justify-content: center; 167 overflow: hidden; padding: 4px; 168} 169.chat-header-logo img { width: 100%; height: 100%; object-fit: contain; } 170.chat-header-info { display: flex; flex-direction: column; flex: 1; } 171.chat-header-title { font-size: 15px; font-weight: 700; margin-bottom: 2px; letter-spacing: -0.2px; } 172.chat-header-subtitle { font-size: 11px; opacity: 0.9; } 173.chat-header-status { display: flex; align-items: center; gap: 5px; margin-top: 4px; } 174.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #4ade80; animation: pulse 2s infinite; flex-shrink: 0; } 175 176.chat-messages { 177 flex: 1; overflow-y: auto; padding: 16px; 178 display: flex; flex-direction: column; gap: 6px; 179 background: var(--bg-chat); scroll-behavior: smooth; 180} 181.chat-messages::-webkit-scrollbar { width: 5px; } 182.chat-messages::-webkit-scrollbar-track { background: transparent; } 183.chat-messages::-webkit-scrollbar-thumb { background: rgba(74,106,172,0.2); border-radius: 3px; } 184 185.message { display: flex; flex-direction: column; max-width: 82%; animation: messageIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } 186.message.visitor { align-self: flex-end; align-items: flex-end; } 187.message.agent, .message.system { align-self: flex-start; align-items: flex-start; } 188 189.message-content { 190 padding: 10px 16px; border-radius: var(--radius-msg); 191 font-size: 14px; line-height: 1.5; word-break: break-word; 192} 193.message.visitor .message-content { background: var(--visitor-bg); color: var(--visitor-text); border-bottom-right-radius: 6px; } 194.message.agent .message-content { background: #ffffff; color: var(--agent-text); border-bottom-left-radius: 6px; border: 1px solid rgba(74,106,172,0.15); box-shadow: 0 1px 4px rgba(0,0,0,0.06); } 195.message.system .message-content { 196 background: transparent; color: var(--system-text); 197 font-size: 12px; text-align: center; padding: 6px 12px; font-style: italic; 198} 199.message.system { align-self: center; max-width: 90%; } 200.message-time { font-size: 10px; color: var(--text-muted); margin-top: 4px; padding: 0 4px; } 201 202.message-image { max-width: 220px; border-radius: var(--radius-sm); cursor: pointer; transition: var(--transition); } 203.message-image:hover { opacity: 0.85; transform: scale(1.02); } 204 205.message-content a { color: inherit; text-decoration: underline; text-underline-offset: 2px; opacity: 0.9; } 206.message-content a:hover { opacity: 1; } 207 208.typing-indicator { display: none; align-self: flex-start; padding: 12px 18px; background: #ffffff; border: 1px solid rgba(74,106,172,0.15); border-radius: var(--radius-msg); border-bottom-left-radius: 6px; animation: messageIn 0.3s ease; } 209.typing-indicator.active { display: flex; gap: 5px; align-items: center; } 210.typing-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); animation: typingBounce 1.4s ease-in-out infinite; } 211.typing-dot:nth-child(2) { animation-delay: 0.2s; } 212.typing-dot:nth-child(3) { animation-delay: 0.4s; } 213 214.chat-input-area { 215 padding: 12px 16px 14px; background: var(--bg-dark); 216 border-top: 1px solid var(--border); 217 display: flex; align-items: flex-end; gap: 10px; flex-shrink: 0; 218} 219.chat-input-wrapper { flex: 1; } 220.chat-input { 221 width: 100%; padding: 12px 16px; background: var(--bg-input); 222 border: 1px solid var(--border); border-radius: 24px; 223 color: var(--text-primary); font-size: 14px; font-family: inherit; 224 outline: none; resize: none; max-height: 100px; line-height: 1.4; 225 transition: var(--transition); 226} 227.chat-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); } 228.chat-input::placeholder { color: var(--text-muted); } 229 230.chat-btn { 231 width: 42px; height: 42px; border-radius: 50%; border: none; 232 cursor: pointer; display: flex; align-items: center; justify-content: center; 233 transition: var(--transition); flex-shrink: 0; 234} 235.btn-send { background: var(--primary); color: white; } 236.btn-send:hover { background: var(--primary-hover); transform: scale(1.05); } 237.btn-send:active { transform: scale(0.95); } 238.btn-send:disabled { opacity: 0.4;
238 cursor: not-allowed; transform: none; } 239.btn-upload { background: transparent; color: var(--text-muted); width: 36px; height: 36px; } 240.btn-upload:hover { color: var(--primary); background: var(--primary-light); } 241.btn-upload svg, .btn-send svg { width: 18px; height: 18px; fill: currentColor; } 242.file-input { display: none; } 243 244.upload-preview { display: none; padding: 8px 16px; background: var(--bg-dark); border-top: 1px solid var(--border); } 245.upload-preview.active { display: flex; align-items: center; gap: 10px; } 246.upload-preview img { width: 50px; height: 50px; object-fit: cover; border-radius: 8px; } 247.upload-preview .remove-upload { margin-left: auto; background: none; border: none; color: #ff4757; cursor: pointer; font-size: 18px; padding: 4px 8px; } 248 249.chat-powered { text-align: center; padding: 6px; font-size: 10px; color: var(--text-muted); background: var(--bg-dark); border-top: 1px solid var(--border); flex-shrink: 0; } 250.chat-powered a { color: var(--text-muted); text-decoration: none; } 251 252@media (max-width: 480px) { 253 .chat-window { bottom: 0; right: 0; left: 0; width: 100%; height: 100%; max-height: 100vh; border-radius: 0; transform: translateY(100%); transform-origin: bottom center; } 254 .chat-window.open { transform: translateY(0); } 255 .chat-bubble { bottom: 16px; right: 16px; width: 70px; height: 70px; } 256 .chat-bubble svg { width: 35px; height: 35px; } 257 svg.icon-close {fill:#fff;} 258} 259 260@keyframes bubbleIn { 0% { transform: scale(0) rotate(-180deg); opacity: 0; } 100% { transform: scale(1) rotate(0); opacity: 1; } } 261@keyframes badgePop { 0% { transform: scale(0); } 50% { transform: scale(1.3); } 100% { transform: scale(1); } } 262@keyframes messageIn { 0% { opacity: 0; transform: translateY(12px) scale(0.95); } 100% { opacity: 1; transform: translateY(0) scale(1); } } 263@keyframes typingBounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-8px); opacity: 1; } } 264@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } 265 266.prechat-form { 267 flex: 1; display: flex; flex-direction: column; justify-content: center; 268 padding: 28px 24px; background: var(--bg-chat); 269} 270.prechat-form.hidden { display: none; } 271.prechat-title { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; text-align: center; } 272.prechat-subtitle { font-size: 13px; color: var(--text-secondary); margin-bottom: 22px; text-align: center; } 273.prechat-field { margin-bottom: 14px; } 274.prechat-label { display: block; font-size: 12px; font-weight: 600; color: #4a6aac; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; } 275.prechat-input { 276 width: 100%; padding: 11px 14px; background: var(--bg-input); 277 border: 1px solid var(--border); border-radius: 10px; 278 color: var(--text-primary); font-size: 14px; font-family: inherit; outline: none; 279 transition: var(--transition); 280} 281.prechat-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); } 282.prechat-input::placeholder { color: var(--text-muted); } 283.prechat-submit { 284 width: 100%; padding: 12px; background: var(--primary); color: white; 285 border: none; border-radius: 12px; font-size: 15px; font-weight: 600; 286 font-family: inherit; cursor: pointer; margin-top: 6px; 287 transition: var(--transition); 288} 289.prechat-submit:hover { background: var(--primary-hover); transform: translateY(-1px); } 290.prechat-submit:active { transform: translateY(0); } 291.prechat-submit:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } 292 293.chat-body { flex: 1; display: flex; flex-direction: column; overflow: hidden; } 294.chat-body.hidden { display: none; } 295.icon-chat{ 296 /*background-image: url(https://www.milann.co.in/assets/imgs/logo-min.png); 297 background-size: contain; 298 background-repeat: no-repeat; 299 background-position: center;*/ 300} 301.icon-chat path{ 302 /*display: none;*/ 303} 304 305@media (max-width: 480px) { 306 .chat-bubble.open{ 307 top: 10px; 308 background: none; 309 right: 0; 310 } 311 .chat-bubble.open .cws{ 312 display :none; 313 } 314 315 316 317 318 .chat-bubble{ 319 bottom: 60px; 320 } 321 .chat-bubble .cws { 322 display: none; 323 } 324} 325`; 326 327 // ============================================ 328 // HTML Template 329 // ============================================ 330 const WIDGET_HTML = ` 331 <button class="chat-bubble" id="chatBubble" aria-label="Open chat"> 332 <svg class="icon-chat" viewBox="0 0 24 24"> <path d="M4 4h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8l-4 4v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm2 5h12v2H6V9zm0 4h8v2H6v-2z"/></svg> 333 <svg class="icon-close" viewBox="0 0 24 24">
333<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg> 334 <span class="chat-badge hidden" id="chatBadge">0</span> 335 336 <div class="cws" style=" 337 position: absolute; 338 top: 83px; 339 font-weight: bold; 340 width: 149px; 341 color: #79357e; 342 font-size: 18px; 343 background: #fff; 344 padding: 10px 15px; 345">Chat With Us</div> 346 </button> 347 348 <div class="chat-window" id="chatWindow"> 349 <div class="chat-header"> 350 <div class="chat-header-logo"> 351 <img src="https://www.milann.co.in/assets/imgs/logo-min.png" alt="Milann" /> 352 </div> 353 <div class="chat-header-info"> 354 <div class="chat-header-title" id="headerTitle">Milann Fertility Specialist</div> 355 <div class="chat-header-subtitle" id="headerSubtitle">We are here to help you</div> 356 <div class="chat-header-status"> 357 <span class="status-dot"></span> 358 <span style="font-size:11px;opacity:0.85">Online</span> 359 </div> 360 </div> 361 </div> 362 363 <div class="prechat-form" id="prechatForm"> 364 <div class="prechat-title">Welcome!â¹</div> 365 <div class="prechat-subtitle">Please share your details to start chatting</div> 366 <div class="prechat-field"> 367 <label class="prechat-label" for="prechatName">Name *</label> 368 <input class="prechat-input" id="prechatName" type="text" placeholder="Your name" required> 369 </div> 370 <div class="prechat-field"> 371 <label class="prechat-label" for="prechatEmail">Email *</label> 372 <input class="prechat-input" id="prechatEmail" type="email" placeholder="[email protected]" required> 373 </div> 374 <div class="prechat-field"> 375 <label class="prechat-label" for="prechatPhone">Phone</label> 376 <input class="prechat-input" id="prechatPhone" type="tel" placeholder="+91 98765 43210"> 377 </div> 378 <button class="prechat-submit" id="prechatSubmit">Start Chat</button> 379 </div> 380 381 <div class="chat-body hidden" id="chatBody"> 382 <div class="chat-messages" id="chatMessages"> 383 <!-- Messages render here --> 384 </div> 385 386 <div class="typing-indicator" id="typingIndicator"> 387 <span class="typing-dot"></span> 388 <span class="typing-dot"></span> 389 <span class="typing-dot"></span> 390 </div> 391 392 <div class="upload-preview" id="uploadPreview"> 393 <img id="uploadThumb" src="" alt="Upload preview"> 394 <span id="uploadName" style="font-size:12px;color:var(--text-secondary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span> 395 <button class="remove-upload" id="removeUpload" aria-label="Remove upload">X</button> 396 </div> 397 398 <div class="chat-input-area"> 399 <button class="chat-btn btn-upload" id="btnUpload" aria-label="Upload image"> 400 <svg viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg> 401 </button> 402 <input type="file" class="file-input" id="fileInput" accept="image/*"> 403 <div class="chat-input-wrapper"> 404 <textarea class="chat-input" id="chatInput" placeholder="Type a message..." rows="1"></textarea> 405 </div> 406 <button class="chat-btn btn-send" id="btnSend" disabled aria-label="Send message"> 407 <svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg> 408 </button> 409 </div> 410 </div> 411 412 <div class="chat-powered"> 413 Powered by <a href="https://themarkdesign.com/">The Mark</a> 414 </div> 415 </div> 416 `; 417 418 // ============================================ 419 // Widget Class 420 // ============================================ 421 class ChatWidget { 422 constructor() { 423 this.container = null; 424 this.shadow = null; 425 this.els = {}; 426 this.init(); 427 } 428 429 // ---- Initialization ----
430 async init() { 431 this.createContainer(); 432 this.bindEvents(); 433 this.restoreVisitorInfo(); 434 this.restoreSession(); 435 436 if (!STATE.sessionId) { 437 // New visitor or no session ââ¬â check if we have visitor info 438 if (STATE.visitorInfo) { 439 // Returning visitor with saved info, start session directly 440 this.showChatBody(); 441 await this.startSession(); 442 } else { 443 // Brand new visitor ââ¬â show prechat form 444 this.showPrechatForm(); 445 } 446 } else { 447 // Existing session ââ¬â go straight to chat 448 this.showChatBody(); 449 this.loadCachedMessages(); 450 this.startPolling(); 451 } 452 } 453 454 createContainer() { 455 // Create host element 456 this.container = document.createElement('div'); 457 this.container.id = 'chat-widget-host'; 458 document.body.appendChild(this.container); 459 460 // Attach Shadow DOM for style isolation 461 this.shadow = this.container.attachShadow({ mode: 'open' }); 462 463 // Inject CSS 464 const style = document.createElement('style'); 465 style.textContent = WIDGET_CSS; 466 this.shadow.appendChild(style); 467 468 // Inject HTML 469 const wrapper = document.createElement('div'); 470 wrapper.innerHTML = WIDGET_HTML; 471 this.shadow.appendChild(wrapper); 472 473 // Cache element references 474 const ids = [ 475 'chatBubble', 'chatBadge', 'chatWindow', 'chatMessages', 476 'chatInput', 'btnSend', 'btnUpload', 'fileInput', 477 'typingIndicator', 'uploadPreview', 'uploadThumb', 478 'uploadName', 'removeUpload', 'headerTitle', 'headerSubtitle', 479 'prechatForm', 'prechatName', 'prechatEmail', 'prechatPhone', 480 'prechatSubmit', 'chatBody' 481 ]; 482 ids.forEach(id => { 483 this.els[id] = this.shadow.getElementById(id); 484 }); 485 } 486 487 bindEvents() { 488 // Toggle chat 489 this.els.chatBubble.addEventListener('click', () => this.toggleChat()); 490 491 // Send message 492 this.els.btnSend.addEventListener('click', () => this.sendMessage()); 493 this.els.chatInput.addEventListener('keydown', (e) => { 494 if (e.key === ' ' || e.key === 'Space' || e.code === 'Space') { 495 e.stopPropagation(); // ââ  prevents space from bubbling to the page 496 } 497 if (e.key === 'Enter' && !e.shiftKey) { 498 e.preventDefault(); 499 this.sendMessage(); 500 } 501 }); 502 503 // Input monitoring 504 this.els.chatInput.addEventListener('input', () => { 505 this.els.btnSend.disabled = !this.els.chatInput.value.trim() && !STATE.uploadFile; 506 this.autoResizeInput(); 507 }); 508 509 // File upload 510 this.els.btnUpload.addEventListener('click', () => this.els.fileInput.click()); 511 this.els.fileInput.addEventListener('change', (e) => this.handleFileSelect(e)); 512 this.els.removeUpload.addEventListener('click', () => this.clearUpload()); 513 514 // Pre-chat form submit 515 this.els.prechatSubmit.addEventListener('click', () => this.handlePrechatSubmit()); 516 // Allow Enter key in prechat form 517 [this.els.prechatName, this.els.prechatEmail, this.els.prechatPhone].forEach(input => { 518 input.addEventListener('keydown', (e) => { 519 if (e.key === 'Enter') { 520 e.preventDefault(); 521 this.handlePrechatSubmit(); 522 } 523 }); 524 }); 525 } 526 527 // ---- Chat Toggle ---- 528 toggleChat() { 529 STATE.isOpen = !STATE.isOpen; 530 this.els.chatWindow.classList.toggle('open', STATE.isOpen); 531 this.els.chatBubble.classList.toggle('open', STATE.isOpen); 532 533 if (STATE.isOpen) { 534 STATE.unreadCount = 0; 535 this.updateBadge(); 536 this.scrollToBottom(); 537 // Focus the right element 538 if (!STATE.visitorInfo && !STATE.sessionId) { 539 setTimeout(() => this.els.prechatName.focus(), 350); 540 } else { 541 setTimeout(() => this.els.chatInput.focus(), 350); 542 } 543 } 544 } 545 546 // ---- Session Management ---- 547 async startSession() { 548 try { 549 const visitorName = STATE.visitorInfo ? STATE.visitorInfo.name : 'Visitor'; 550 const res = await this.api('start-chat.php', { 551 method: 'POST', 552 body: JSON.stringify({ 553 client_id: CONFIG.clientId, 554 visitor_name: visitorName, 555 visitor_email: STATE.visitorInfo ? STATE.visitorInfo.email : '', 556 visitor_phone: STATE.visitorInfo ? STATE.visitorInfo.phone : '' 557 }) 558 }); 559 560 if (res.success) { 561 STATE.sessionId = res.session_id; 562 STATE.clientName = res.client_name || 'Support'; 563 STATE.welcomeMessage = res.welcome_message; 564 565 if (res.theme_color && !CONFIG.themeColor) { 566 STATE.themeColor = res.theme_color; 567 this.applyThemeColor(res.theme_color); 568 } 569 570 this.els.headerTitle.textContent = STATE.clientName; 571 this.saveSession(); 572 this.startPolling(); 573 } 574 } catch (err) { 575 console.error('[ChatWidget] Failed to start session:', err); 576 } 577 } 578 579 saveSession() { 580 try { 581 localStorage.setItem('chat_widget_session', JSON.stringify({ 582 sessionId: STATE.sessionId, 583 clientName: STATE.clientName, 584 lastMessageId: STATE.lastMessageId,
585 themeColor: STATE.themeColor 586 })); 587 } catch(e) {} 588 } 589 590 restoreSession() { 591 try { 592 const saved = JSON.parse(localStorage.getItem('chat_widget_session')); 593 if (saved && saved.sessionId) { 594 STATE.sessionId = saved.sessionId; 595 STATE.clientName = saved.clientName || 'Support'; 596 STATE.lastMessageId = saved.lastMessageId || 0; 597 this.els.headerTitle.textContent = STATE.clientName; 598 599 if (saved.themeColor && !CONFIG.themeColor) { 600 this.applyThemeColor(saved.themeColor); 601 } 602 } 603 } catch(e) {} 604 } 605 606 loadCachedMessages() { 607 try { 608 const cached = JSON.parse(localStorage.getItem('chat_widget_messages') || '[]'); 609 cached.forEach(msg => this.renderMessage(msg, false)); 610 this.scrollToBottom(false); 611 } catch(e) {} 612 } 613 614 cacheMessages() { 615 try { 616 // Keep last 100 messages 617 const toCache = STATE.messages.slice(-100); 618 localStorage.setItem('chat_widget_messages', JSON.stringify(toCache)); 619 } catch(e) {} 620 } 621 622 applyThemeColor(color) { 623 const host = this.shadow.host; 624 host.style.setProperty('--primary', color); 625 } 626 627 // ---- Pre-chat Form ---- 628 showPrechatForm() { 629 this.els.prechatForm.classList.remove('hidden'); 630 this.els.chatBody.classList.add('hidden'); 631 } 632 633 showChatBody() { 634 this.els.prechatForm.classList.add('hidden'); 635 this.els.chatBody.classList.remove('hidden'); 636 } 637 638 async handlePrechatSubmit() { 639 const name = this.els.prechatName.value.trim(); 640 const email = this.els.prechatEmail.value.trim(); 641 const phone = this.els.prechatPhone.value.trim(); 642 643 // Validate required fields 644 if (!name) { 645 this.els.prechatName.focus(); 646 return; 647 } 648 if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 649 this.els.prechatEmail.focus(); 650 return; 651 } 652 653 // Save visitor info 654 STATE.visitorInfo = { name, email, phone }; 655 this.saveVisitorInfo(); 656 657 // Switch to chat UI 658 this.els.prechatSubmit.disabled = true; 659 this.els.prechatSubmit.textContent = 'Starting...'; 660 661 this.showChatBody(); 662 await this.startSession(); 663 664 this.els.prechatSubmit.disabled = false; 665 this.els.prechatSubmit.textContent = 'Start Chat'; 666 } 667 668 saveVisitorInfo() { 669 try { 670 localStorage.setItem('chat_widget_visitor', JSON.stringify(STATE.visitorInfo)); 671 } catch(e) {} 672 } 673 674 restoreVisitorInfo() { 675 try { 676 const saved = JSON.parse(localStorage.getItem('chat_widget_visitor')); 677 if (saved && saved.name && saved.email) { 678 STATE.visitorInfo = saved; 679 } 680 } catch(e) {} 681 } 682 683 // ---- Messaging ---- 684 async sendMessage() { 685 const text = this.els.chatInput.value.trim(); 686 687 // Handle image upload 688 if (STATE.uploadFile) { 689 await this.uploadAndSend(); 690 return; 691 } 692 693 if (!text) return; 694 695 // Clear input 696 this.els.chatInput.value = ''; 697 this.els.btnSend.disabled = true; 698 this.autoResizeInput(); 699 700 // Render optimistically 701 const tempId = 'temp-' + Date.now(); 702 const tempMsg = { 703 id: tempId, 704 sender: 'visitor', 705 type: 'text', 706 content: text, 707 created_at: new Date().toISOString() 708 }; 709 STATE.pendingTempIds.add(tempId); 710 this.renderMessage(tempMsg); 711 this.scrollToBottom(); 712 713 try { 714 await this.api('send-message.php', { 715 method: 'POST', 716 body: JSON.stringify({ 717 session_id: STATE.sessionId, 718 content: text, 719 type: 'text' 720 }) 721 }); 722 this.resetFallbackTimer(); 723 } catch (err) { 724 console.error('[ChatWidget] Send failed:', err); 725 } 726 } 727 728 async uploadAndSend() { 729 const formData = new FormData(); 730 formData.append('file', STATE.uploadFile); 731 732 try { 733 // Show typing indicator while uploading 734 this.showTyping(true); 735 736 const uploadRes = await this.api('upload.php', { 737 method: 'POST', 738 body: formData, 739 isFormData: true 740 }); 741 742 this.showTyping(false); 743 744 if (uploadRes.success) { 745 // Send image message 746 const tempId = 'temp-' + Date.now(); 747 const tempMsg = { 748 id: tempId, 749 sender: 'visitor', 750 type: 'image', 751 content: uploadRes.url, 752 created_at: new Date().toISOString() 753 }; 754 STATE.pendingTempIds.add(tempId); 755 this.renderMessage(tempMsg); 756 this.scrollToBottom(); 757 758 await this.api('send-message.php', { 759 method: 'POST', 760 body: JSON.stringify({ 761 session_id: STATE.sessionId, 762 content: uploadRes.url, 763 type: 'image' 764 }) 765 }); 766 767 this.clearUpload(); 768 this.resetFallbackTimer(); 769 } 770 } catch (err) { 771 this.showTyping(false); 772 console.error('[ChatWidget] Upload failed:', err); 773 } 774 } 775 776 // ---- Long Polling ---- 777 async startPolling() { 778 if (STATE.isPolling) return; 779 STATE.isPolling = true; 780 this.poll(); 781 } 782 783 async poll() { 784 if (!STATE.sessionId) return; 785 786 try { 787 const res = await this.api( 788 `get-messages.php?session_id=${encodeURIComponent(STATE.sessionId)}&last_id=${STATE.lastMessageId}`, 789 { method: 'GET' } 790 ); 791 792 if (res.success && res.messages && res.messages.length > 0) {
793 res.messages.forEach(msg => { 794 // Skip if already in STATE by server id 795 if (STATE.messages.find(m => m.id === msg.id)) return; 796 797 // If this is a visitor message and we have a pending temp, remove the temp DOM element 798 if (msg.sender === 'visitor' && STATE.pendingTempIds.size > 0) { 799 const tempId = STATE.pendingTempIds.values().next().value; 800 if (tempId) { 801 // Remove temp from DOM 802 const tempEl = this.els.chatMessages.querySelector(`[data-msg-id="${tempId}"]`); 803 if (tempEl) tempEl.remove(); 804 // Remove temp from STATE.messages 805 const idx = STATE.messages.findIndex(m => m.id === tempId); 806 if (idx !== -1) STATE.messages.splice(idx, 1); 807 STATE.pendingTempIds.delete(tempId); 808 } 809 } 810 811 STATE.messages.push(msg); 812 STATE.lastMessageId = Math.max(STATE.lastMessageId, msg.id); 813 this.renderMessage(msg); 814 815 // Notification for non-visitor messages 816 if (msg.sender !== 'visitor') { 817 if (!STATE.isOpen) { 818 STATE.unreadCount++; 819 this.updateBadge(); 820 } 821 NOTIFICATION_SOUND.play(); 822 823 if (msg.sender === 'agent') { 824 STATE.lastAgentReply = Date.now(); 825 } 826 } 827 }); 828 829 this.scrollToBottom(); 830 this.saveSession(); 831 this.cacheMessages(); 832 } 833 } catch (err) { 834 // Ignore polling errors, just retry 835 } 836 837 // Continue polling 838 if (STATE.isPolling) { 839 setTimeout(() => this.poll(), 1000); 840 } 841 } 842 843 stopPolling() { 844 STATE.isPolling = false; 845 } 846 847 // ---- Fallback Timer ---- 848 resetFallbackTimer() { 849 clearTimeout(STATE.fallbackTimer); 850 STATE.fallbackTimer = setTimeout(() => { 851 // Check if agent has replied since the timer started 852 const elapsed = Date.now() - STATE.lastAgentReply; 853 if (elapsed >= 900000) { // 15 minutes 854 const fallbackMsg = { 855 id: 'fallback-' + Date.now(), 856 sender: 'system', 857 type: 'text', 858 content: 'All our Agents are busy, we will contact you as soon as possible', 859 created_at: new Date().toISOString() 860 }; 861 this.renderMessage(fallbackMsg); 862 this.scrollToBottom(); 863 } 864 }, 900000); // 15 minutes 865 } 866 867 // ---- Rendering ---- 868 renderMessage(msg, animate = true) { 869 const div = document.createElement('div'); 870 div.className = `message ${msg.sender}`; 871 div.setAttribute('data-msg-id', msg.id);
872 if (!animate) div.style.animation = 'none'; 873 874 let contentHTML = ''; 875 876 if (msg.type === 'image') { 877 contentHTML = `<img class="message-image" src="${this.escapeHtml(msg.content)}" alt="Shared image" loading="lazy" onclick="window.open('${this.escapeHtml(msg.content)}', '_blank')">`; 878 } else { 879 contentHTML = `<div class="message-content">${this.linkify(this.escapeHtml(msg.content))}</div>`; 880 } 881 882 const time = this.formatTime(msg.created_at); 883 div.innerHTML = `${contentHTML}<span class="message-time">${time}</span>`; 884 885 this.els.chatMessages.appendChild(div); 886 887 // Track message 888 if (!STATE.messages.find(m => m.id === msg.id)) { 889 STATE.messages.push(msg); 890 } 891 } 892 893 // ---- UI Helpers ---- 894 scrollToBottom(smooth = true) { 895 const el = this.els.chatMessages; 896 setTimeout(() => { 897 el.scrollTo({ 898 top: el.scrollHeight, 899 behavior: smooth ? 'smooth' : 'auto' 900 }); 901 }, 50); 902 } 903 904 updateBadge() { 905 if (STATE.unreadCount > 0) { 906 this.els.chatBadge.textContent = STATE.unreadCount > 99 ? '99+' : STATE.unreadCount; 907 this.els.chatBadge.classList.remove('hidden'); 908 } else { 909 this.els.chatBadge.classList.add('hidden'); 910 } 911 } 912 913 showTyping(show) { 914 this.els.typingIndicator.classList.toggle('active', show); 915 if (show) this.scrollToBottom(); 916 } 917 918 autoResizeInput() { 919 const el = this.els.chatInput; 920 el.style.height = 'auto'; 921 el.style.height = Math.min(el.scrollHeight, 100) + 'px'; 922 } 923 924 handleFileSelect(e) { 925 const file = e.target.files[0]; 926 if (!file) return; 927 928 // Validate client-side 929 const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; 930 if (!allowedTypes.includes(file.type)) { 931 alert('Only image files (JPG, PNG, GIF, WebP) are allowed.'); 932 return; 933 } 934 if (file.size > 5 * 1024 * 1024) { 935 alert('File too large. Maximum size is 5MB.'); 936 return; 937 } 938 939 STATE.uploadFile = file; 940 this.els.btnSend.disabled = false; 941 942 // Show preview 943 const reader = new FileReader(); 944 reader.onload = (ev) => { 945 this.els.uploadThumb.src = ev.target.result; 946 this.els.uploadName.textContent = file.name; 947 this.els.uploadPreview.classList.add('active'); 948 }; 949 reader.readAsDataURL(file); 950 } 951 952 clearUpload() { 953 STATE.uploadFile = null; 954 this.els.fileInput.value = ''; 955 this.els.uploadPreview.classList.remove('active'); 956 this.els.uploadThumb.src = ''; 957 this.els.uploadName.textContent = ''; 958 this.els.btnSend.disabled = !this.els.chatInput.value.trim(); 959 } 960 961 // ---- Utilities ---- 962 async api(endpoint, options = {}) { 963 const url = `${CONFIG.apiUrl}/${endpoint}`; 964 const headers = options.isFormData ? {} : { 'Content-Type': 'application/json' }; 965 966 const res = await fetch(url, { 967 ...options, 968 headers: { ...headers, ...options.headers } 969 }); 970 971 return res.json(); 972 } 973 974 escapeHtml(str) { 975 const div = document.createElement('div'); 976 div.appendChild(document.createTextNode(str)); 977 return div.innerHTML; 978 } 979 980 linkify(text) { 981 const urlRegex = /(https?:\/\/[^\s<]+)/g; 982 return text.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'); 983 } 984 985 formatTime(isoString) { 986 try { 987 const date = new Date(isoString); 988 return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 989 } catch(e) { 990 return ''; 991 } 992 } 993 } 994 995 // ============================================ 996 // Auto-initialize when DOM is ready 997 // ============================================ 998 if (document.readyState === 'loading') { 999 document.addEventListener('DOMContentLoaded', () => new ChatWidget()); 1000 } else { 1001 new ChatWidget(); 1002 } 1003 1004})();
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.