PageSourceSearch

https://milann.co.in/chat-agent/panel/widget/widget.js?v=1

js milann.co.in collected 2026-09-26 12:27:29 UTC 41,051 bytes, 1,004 lines download raw bytes

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.