PageSourceSearch

https://vibeotter-5690a.firebaseapp.com/js/support-chat.js?v=touch-up-support-20260527

js vibeotter-5690a.firebaseapp.com collected 2026-10-03 14:08:28 UTC 63,657 bytes, 1,627 lines download raw bytes

1(function() {
2    'use strict';
3
4    // Don't load on the admin support page
5    if (window.location.pathname.indexOf('/support.html') !== -1) return;
6
7    // Config
8    function getApiUrl() {
9        if (window.VIBEOTTER_SUPPORT_API_URL) return window.VIBEOTTER_SUPPORT_API_URL;
10        if (window.APP_CONFIG && typeof window.APP_CONFIG.getApiUrl === 'function') {
11            return window.APP_CONFIG.getApiUrl();
12        }
13
14        var hostname = window.location.hostname;
15        if (hostname === 'vibeotter.com' || hostname === 'www.vibeotter.com' || hostname.endsWith('.vibeotter.com')) {
16            return 'https://api.vibeotter.com';
17        }
18        if (hostname === 'vibeotter.dev' || hostname === 'www.vibeotter.dev' || hostname.endsWith('.vibeotter.dev')) {
19            return 'https://api.vibeotter.dev';
20        }
21        if (hostname === 'localhost' || hostname === '127.0.0.1') {
22            return 'http://localhost:3000';
23        }
24        if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
25            return 'http://' + hostname + ':3000';
26        }
27        return 'https://api.vibeotter.com';
28    }
29
30    var API_URL = getApiUrl();
31
32    // Localized string lookup (catalogs in /js/i18n-catalog.*.js); falls back
33    // to the key if the i18n runtime is missing
34    function voT(key, params) {
35        return window.voI18n ? window.voI18n.t(key, params) : key;
36    }
37
38    var SUPPORT_CHAT_INTRO_TEXT = voT('support.chat.intro');
39
40    // State
41    var conversationId = null;
42    var socket = null;
43    var isOpen = false;
44    var messages = [];
45    var isAuthenticated = false;
46    var userEmail = '';
47    var authToken = '';
48    var socketConnected = false;
49    var socketRetryCount = 0;
50    var maxSocketRetries = 30; // retry for up to 30 seconds
51    var pollingInterval = null;
52    var pollingForced = false;
53    var lastMessageId = null;
54    var seenMessageIds = new Set();
55    var currentSocketRoom = null;
56    var pendingSocketRoom = null;
57    var guestSessionToken = null;
58    var supportLinkState = parseSupportChatLink();
59    var smsOptedIn = false;
60    var initialized = false;
61    var pendingOpen = false;
62    var hideLauncher = !!window.VIBEOTTER_SUPPORT_HIDE_LAUNCHER;
63    var launcherBottom = window.VIBEOTTER_SUPPORT_LAUNCHER_OFFSET_BOTTOM || '24px';
64    var launcherMobileBottom = window.VIBEOTTER_SUPPORT_LAUNCHER_MOBILE_OFFSET_BOTTOM || window.VIBEOTTER_SUPPORT_LAUNCHER_OFFSET_BOTTOM || '16px';
65    var launcherMobileRight = window.VIBEOTTER_SUPPORT_LAUNCHER_MOBILE_OFFSET_RIGHT || '16px';
66    var launcherMobileHeaderTarget = window.VIBEOTTER_SUPPORT_MOBILE_HEADER_TARGET || '';
67    var launcherMobileHeaderQuery = null;
68
69    // Guest persistence helpers (scoped localStorage for guests only)
70    function saveGuestState(email, convId, token) {
71        try { localStorage.setItem('vo_guest_support', JSON.stringify({ email: email, conversationId: convId, sessionToken: token })); }
72        catch (e) { /* localStorage unavailable */ }
73    }
74    function loadGuestState() {
75        try { var raw = localStorage.getItem('vo_guest_support'); return raw ? JSON.parse(raw) : null; }
76        catch (e) { return null; }
77    }
78    function clearGuestState() {
79        try { localStorage.removeItem('vo_guest_support'); }
80        catch (e) { /* ignore */ }
81    }
82    function getSMSDismissedKey() {
83        return conversationId ? 'vo_support_sms_dismissed_' + conversationId : '';
84    }
85    function isSMSPromptDismissed() {
86        var key = getSMSDismissedKey();
87        if (!key) return false;
88        try { return localStorage.getItem(key) === '1'; }
89        catch (e) { return false; }
90    }
91    function setSMSPromptDismissed() {
92        var key = getSMSDismissedKey();
93        if (!key) return;
94        try { localStorage.setItem(key, '1'); }
95        catch (e) { /* localStorage unavailable */ }
96    }
97    function getSMSWaitMinutes() {
98        var key = conversationId ? 'vo_support_sms_wait_' + conversationId : '';
99        if (key) {
100            try {
101                var stored = parseInt(localStorage.getItem(key), 10);
102                if (stored >= 5 && stored <= 9) return stored;
103            } catch (e) { /* localStorage unavailable */ }
104        }
105
106        var minutes = Math.floor(Math.random() * 5) + 5;
107        if (key) {
108            try { localStorage.setItem(key, String(minutes)); }
109            catch (e) { /* localStorage unavailable */ }
110        }
111        return minutes;
112    }
113
114    function parseSupportChatLink() {
115        function parseParamString(value) {
116            if (!value) return null;
117            var cleaned = value.charAt(0) === '#' || value.charAt(0) === '?' ? value.slice(1) : value;
118            if (cleaned.indexOf('conversation=') === -1 && cleaned.indexOf('support=') === -1) return null;
119            try {
120                var params = new URLSearchParams(cleaned);
121                var shouldOpen = params.get('support') === 'open' || params.get('support_chat') === 'open';
122                var convId = params.get('conversation') || params.get('c');
123                if (!shouldOpen || !convId) return null;
124                return {
125                    conversationId: convId,
126                    token: params.get('token') || ''
127                };
128            } catch (e) {
129                return null;
130            }
131        }
132
133        return parseParamString(window.location.hash) || parseParamString(window.location.search);
134    }
135
136    // DOM references
137    var widgetBtn = null;
138    var chatPanel = null;
139    var messagesContainer = null;
140    var inputArea = null;
141    var guestForm = null;
142    var headerSubtitle = null;
143    var unreadBadge = null;
144    var unreadCount = 0;
145
146    // ─── CSS ───────────────────────────────────────────────────────────────
147
148    function injectStyles() {
149        var style = document.createElement('style');
150        style.id = 'vibeotter-support-chat-styles';
151        style.textContent = '\n' +
152            // Floating button
153            '#vo-support-btn {\n' +
154            '  position: fixed;\n' +
155            '  bottom: ' + launcherBottom + ';\n' +
156            '  right: 24px;\n' +
157            '  width: 56px;\n' +
158            '  height: 56px;\n' +
159            '  border-radius: 50%;\n' +
160            '  background: #2D7D7D;\n' +
161            '  border: none;\n' +
162            '  cursor: pointer;\n' +
163            '  box-shadow: 0 4px 12px rgba(0,0,0,0.25);\n' +
164            '  z-index: 99999;\n' +
165            '  display: flex;\n' +
166            '  align-items: center;\n' +
167            '  justify-content: center;\n' +
168            '  transition: transform 0.2s ease, box-shadow 0.2s ease;\n' +
169            '  padding: 0;\n' +
170            '}\n' +
171            '#vo-support-btn:hover {\n' +
172            '  transform: scale(1.08);\n' +
173            '  box-shadow: 0 6px 18px rgba(0,0,0,0.3);\n' +
174            '}\n' +
175            '#vo-support-btn svg {\n' +
176            '  width: 26px;\n' +
177            '  height: 26px;\n' +
178            '  fill: #fff;\n' +
179            '}\n' +
180            '#vo-support-btn.vo-support-btn-header {\n' +
181            '  position: relative;\n' +
182            '  bottom: auto;\n' +
183            '  right: auto;\n' +
184            '  width: 44px;\n' +
185            '  height: 44px;\n' +
186            '  flex: 0 0 44px;\n' +
187            '  box-shadow: 0 2px 8px rgba(0,0,0,0.18);\n' +
188            '  z-index: auto;\n' +
189            '}\n' +
190            '#vo-support-btn.vo-support-btn-header:hover {\n' +
191            '  transform: none;\n' +
192            '  box-shadow: 0 3px 10px rgba(0,0,0,0.22);\n' +
193            '}\n' +
194            '#vo-support-btn.vo-support-btn-header svg {\n' +
195            '  width: 22px;\n' +
196            '  height: 22px;\n' +
197            '}\n' +
198            '#vo-support-btn.vo-support-btn-hidden {\n' +
199            '  display: none !important;\n' +
200            '}\n' +
201            // Unread badge
202            '#vo-support-unread {\n' +
203            '  position: absolute;\n' +
204            '  top: -4px;\n' +
205            '  right: -4px;\n' +
206            '  background: #e74c3c;\n' +
207            '  color: #fff;\n' +
208            '  font-size: 11px;\n' +
209            '  font-weight: 700;\n' +
210            '  width: 20px;\n' +
211            '  height: 20px;\n' +
212            '  border-radius: 50%;\n' +
213            '  display: none;\n' +
214            '  align-items: center;\n' +
215            '  justify-content: center;\n' +
216            '  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;\n' +
217            '  line-height: 1;\n' +
218            '}\n' +
219            // Chat panel
220            '#vo-support-panel {\n' +
221            '  position: fixed;\n' +
222            '  bottom: 92px;\n' +
223            '  right: 24px;\n' +
224            '  width: min(420px, calc(100vw - 32px));\n' +
225            '  height: min(560px, calc(100vh - 120px));\n' +
226            '  background: #fff;\n' +
227            '  border-radius: 12px 12px 12px 12px;\n' +
228            '  box-shadow: 0 8px 32px rgba(0,0,0,0.18);\n' +
229            '  z-index: 99998;\n' +
230            '  display: none;\n' +
231            '  flex-direction: column;\n' +
232            '  overflow: hidden;\n' +
233            '  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;\n' +
234            '  animation: vo-slide-up 0.25s ease-out;\n' +
235            '}\n' +
236            '#vo-support-panel.vo-open {\n' +
237            '  display: flex;\n' +
238            '}\n' +
239            '@keyframes vo-slide-up {\n' +
240            '  from { opacity: 0; transform: translateY(20px); }\n' +
241            '  to { opacity: 1; transform: translateY(0); }\n' +
242            '}\n' +
243            '@keyframes vo-fade-in {\n' +
244            '  from { opacity: 0; transform: translateY(4px); }\n' +
245            '  to { opacity: 1; transform: translateY(0); }\n' +
246            '}\n' +
247            // Header
248            '#vo-support-header {\n' +
249            '  background: #2D7D7D;\n' +
250            '  color: #fff;\n' +
251            '  padding: 14px 16px;\n' +
252            '  display: flex;\n' +
253            '  align-items: center;\n' +
254            '  justify-content: space-between;\n' +
255            '  flex-shrink: 0;\n' +
256            '}\n' +
257            '#vo-support-header-info h3 {\n' +
258            '  margin: 0;\n' +
259            '  font-size: 15px;\n' +
260            '  font-weight: 600;\n' +
261            '  line-height: 1.3;\n' +
262            '}\n' +
263            '#vo-support-header-info .vo-subtitle {\n' +
264            '  margin: 2px 0 0 0;\n' +
265            '  font-size: 11px;\n' +
266            '  opacity: 0.85;\n' +
267            '  font-weight: 400;\n' +
268            '  line-height: 1.3;\n' +
269            '}\n' +
270            '#vo-support-close {\n' +
271            '  background: none;\n' +
272            '  border: none;\n' +
273            '  color: #fff;\n' +
274            '  font-size: 22px;\n' +
275            '  cursor: pointer;\n' +
276            '  padding: 0 0 0 12px;\n' +
277            '  line-height: 1;\n' +
278            '  opacity: 0.8;\n' +
279            '  transition: opacity 0.15s;\n' +
280            '}\n' +
281            '#vo-support-close:hover { opacity: 1; }\n' +
282            // Messages area
283            '#vo-support-messages {\n' +
284            '  flex: 1;\n' +
285            '  overflow-y: auto;\n' +
286            '  padding: 16px;\n' +
287            '  display: flex;\n' +
288            '  flex-direction: column;\n' +
289            '  gap: 10px;\n' +
290            '  background: #fbfbfb;\n' +
291            '}\n' +
292            '#vo-support-messages::-webkit-scrollbar {\n' +
293            '  width: 6px;\n' +
294            '}\n' +
295            '#vo-support-messages::-webkit-scrollbar-track {\n' +
296            '  background: transparent;\n' +
297            '}\n' +
298            '#vo-support-messages::-webkit-scrollbar-thumb {\n' +
299            '  background: #ccc;\n' +
300            '  border-radius: 3px;\n' +
301            '}\n' +
302            // Individual messages
303            '.vo-msg {\n' +
304            '  max-width: 94%;\n' +
305            '  box-sizing: border-box;\n' +
306            '  min-width: 0;\n' +
307            '  padding: 11px 14px;\n' +
308            '  border-radius: 14px;\n' +
309            '  font-size: 14px;\n' +
310            '  line-height: 1.5;\n' +
311            '  overflow-wrap: normal;\n' +
312            '  word-break: normal;\n' +
313            '  hyphens: none;\n' +
314            '  animation: vo-fade-in 0.2s ease-out;\n' +
315            '}\n' +
316            '.vo-msg-text {\n' +
317            '  white-space: pre-wrap;\n' +
318            '  overflow-wrap: break-word;\n' +
319            '  word-break: normal;\n' +
320            '  hyphens: none;\n' +
321            '  max-width: 100%;\n' +
322            '}\n' +
323            '.vo-msg-user {\n' +
324            '  align-self: flex-end;\n' +
325            '  background: #2D7D7D;\n' +
326            '  color: #fff;\n' +
327            '  border-bottom-right-radius: 4px;\n' +
328            '}\n' +
329            '.vo-msg-admin {\n' +
330            '  max-width: 100%;\n' +
331            '  background: #f0f0f0;\n' +
332            '  color: #1a1a1a;\n' +
333            '  border-bottom-left-radius: 4px;\n' +
334            '}\n' +
335            '.vo-admin-wrapper {\n' +
336            '  display: flex;\n' +
337            '  align-items: flex-start;\n' +
338            '  gap: 8px;\n' +
339            '  align-self: flex-start;\n' +
340            '  width: 100%;\n' +
341            '  max-width: 100%;\n' +
342            '}\n' +
343            '.vo-admin-avatar {\n' +
344            '  width: 30px;\n' +
345            '  height: 30px;\n' +
346            '  border-radius: 50%;\n' +
347            '  flex-shrink: 0;\n' +
348            '  object-fit: cover;\n' +
349            '  margin-top: 1px;\n' +
350            '}\n' +
351            '.vo-admin-name {\n' +
352            '  font-size: 12px;\n' +
353            '  color: #666;\n' +
354            '  font-weight: 600;\n' +
355            '  line-height: 1.2;\n' +
356            '  margin: 0 0 4px 1px;\n' +
357            '}\n' +
358            '.vo-admin-content {\n' +
359            '  display: flex;\n' +
360            '  flex-direction: column;\n' +
361            '  flex: 1;\n' +
362            '  min-width: 0;\n' +
363            '}\n' +
364            '.vo-msg-time {\n' +
365            '  font-size: 10px;\n' +
366            '  opacity: 0.6;\n' +
367            '  margin-bottom: 2px;\n' +
368            '}\n' +
369            '.vo-msg-user .vo-msg-time { text-align: right; }\n' +
370            '.vo-msg-admin .vo-msg-time { text-align: left; }\n' +
371            // Empty state
372            '.vo-empty-state {\n' +
373            '  text-align: center;\n' +
374            '  color: #888;\n' +
375            '  font-size: 13px;\n' +
376            '  padding: 40px 20px;\n' +
377            '  line-height: 1.5;\n' +
378            '}\n' +
379            '.vo-support-intro-note {\n' +
380            '  align-self: stretch;\n' +
381            '  background: #f8fbfb;\n' +
382            '  border: 1px solid #d8e8e6;\n' +
383            '  border-radius: 10px;\n' +
384            '  padding: 11px 12px;\n' +
385            '  color: #253333;\n' +
386            '  font-size: 13px;\n' +
387            '  line-height: 1.45;\n' +
388            '  animation: vo-fade-in 0.2s ease-out;\n' +
389            '}\n' +
390            '.vo-reply-note {\n' +
391            '  text-align: center;\n' +
392            '  color: #999;\n' +
393            '  font-size: 11px;\n' +
394            '  padding: 8px 0 4px 0;\n' +
395            '  flex-shrink: 0;\n' +
396            '}\n' +
397            '.vo-sms-card {\n' +
398            '  align-self: stretch;\n' +
399            '  background: #fff;\n' +
400            '  border: 1px solid #d8e8e6;\n' +
401            '  border-radius: 10px;\n' +
402            '  padding: 12px;\n' +
403            '  box-shadow: 0 1px 3px rgba(0,0,0,0.04);\n' +
404            '  color: #253333;\n' +
405            '  animation: vo-fade-in 0.2s ease-out;\n' +
406            '}\n' +
407            '.vo-sms-title {\n' +
408            '  font-size: 13px;\n' +
409            '  font-weight: 700;\n' +
410            '  margin-bottom: 4px;\n' +
411            '}\n' +
412            '.vo-sms-copy {\n' +
413            '  font-size: 12px;\n' +
414            '  line-height: 1.4;\n' +
415            '  color: #526464;\n' +
416            '  margin-bottom: 10px;\n' +
417            '}\n' +
418            '.vo-sms-form {\n' +
419            '  display: flex;\n' +
420            '  gap: 8px;\n' +
421            '  align-items: center;\n' +
422            '}\n' +
423            '.vo-sms-form input {\n' +
424            '  flex: 1;\n' +
425            '  min-width: 0;\n' +
426            '  border: 1px solid #d5dddd;\n' +
427            '  border-radius: 8px;\n' +
428            '  padding: 9px 10px;\n' +
429            '  font-size: 16px;\n' +
430            '  font-family: inherit;\n' +
431            '  outline: none;\n' +
432            '  box-sizing: border-box;\n' +
433            '}\n' +
434            '.vo-sms-form input:focus {\n' +
435            '  border-color: #2D7D7D;\n' +
436            '}\n' +
437            '.vo-sms-form button {\n' +
438            '  background: #2D7D7D;\n' +
439            '  color: #fff;\n' +
440            '  border: none;\n' +
441            '  border-radius: 8px;\n' +
442            '  padding: 10px 12px;\n' +
443            '  font-size: 13px;\n' +
444            '  font-weight: 700;\n' +
445            '  cursor: pointer;\n' +
446            '  font-family: inherit;\n' +
447            '  white-space: nowrap;\n' +
448            '}\n' +
449            '.vo-sms-form button:disabled {\n' +
450            '  background: #a0c4c4;\n' +
451            '  cursor: default;\n' +
452            '}\n' +
453            '.vo-sms-disclaimer {\n' +
454            '  margin-top: 8px;\n' +
455            '  font-size: 10.5px;\n' +
456            '  line-height: 1.35;\n' +
457            '  color: #718080;\n' +
458            '}\n' +
459            '.vo-sms-actions {\n' +
460            '  display: flex;\n' +
461            '  align-items: center;\n' +
462            '  justify-content: space-between;\n' +
463            '  gap: 8px;\n' +
464            '  margin-top: 8px;\n' +
465            '}\n' +
466            '.vo-sms-error {\n' +
467            '  display: none;\n' +
468            '  color: #c0392b;\n' +
469            '  font-size: 11px;\n' +
470            '}\n' +
471            '.vo-sms-dismiss {\n' +
472            '  border: none;\n' +
473            '  background: transparent;\n' +
474            '  color: #526464;\n' +
475            '  cursor: pointer;\n' +
476            '  font-size: 11px;\n' +
477            '  padding: 0;\n' +
478            '  font-family: inherit;\n' +
479            '  text-decoration: underline;\n' +
480            '}\n' +
481            // Input area
482            '#vo-support-input-area {\n' +
483            '  display: flex;\n' +
484            '  align-items: center;\n' +
485            '  padding: 10px 12px;\n' +
486            '  box-sizing: border-box;\n' +
487            '  border-top: 1px solid #e8e8e8;\n' +
488            '  background: #fff;\n' +
489            '  flex-shrink: 0;\n' +
490            '}\n' +
491            '#vo-support-input {\n' +
492            '  flex: 1;\n' +
493            '  border: 1px solid #ddd;\n' +
494            '  border-radius: 20px;\n' +
495            '  padding: 8px 14px;\n' +
496            '  font-size: 16px;\n' +
497            '  outline: none;\n' +
498            '  font-family: inherit;\n' +
499            '  transition: border-color 0.15s;\n' +
500            '  resize: none;\n' +
501            '  max-height: 80px;\n' +
502            '  min-height: 18px;\n' +
503            '  line-height: 1.4;\n' +
504            '  overflow-y: auto;\n' +
505            '}\n' +
506            '#vo-support-input:focus {\n' +
507            '  border-color: #2D7D7D;\n' +
508            '}\n' +
509            '#vo-support-send {\n' +
510            '  background: #2D7D7D;\n' +
511            '  border: none;\n' +
512            '  color: #fff;\n' +
513            '  width: 34px;\n' +
514            '  height: 34px;\n' +
515            '  border-radius: 50%;\n' +
516            '  margin-left: 8px;\n' +
517            '  cursor: pointer;\n' +
518            '  display: flex;\n' +
519            '  align-items: center;\n' +
520            '  justify-content: center;\n' +
521            '  flex-shrink: 0;\n' +
522            '  transition: background 0.15s;\n' +
523            '}\n' +
524            '#vo-support-send:hover { background: #246868; }\n' +
525            '#vo-support-send:disabled { background: #a0c4c4; cursor: default; }\n' +
526            '#vo-support-send svg {\n' +
527            '  width: 16px;\n' +
528            '  height: 16px;\n' +
529            '  fill: #fff;\n' +
530            '}\n' +
531            // Guest form
532            '#vo-support-guest-form {\n' +
533            '  flex: 1;\n' +
534            '  padding: 20px 16px;\n' +
535            '  display: flex;\n' +
536            '  flex-direction: column;\n' +
537            '  gap: 12px;\n' +
538            '  overflow-y: auto;\n' +
539            '  background: #fafafa;\n' +
540            '}\n' +
541            '#vo-support-guest-form p {\n' +
542            '  margin: 0;\n' +
543            '  font-size: 13px;\n' +
544            '  color: #555;\n' +
545            '  line-height: 1.4;\n' +
546            '}\n' +
547            '#vo-support-guest-form label {\n' +
548            '  font-size: 12px;\n' +
549            '  font-weight: 600;\n' +
550            '  color: #333;\n' +
551            '  display: block;\n' +
552            '  margin-bottom: 4px;\n' +
553            '}\n' +
554            '#vo-support-guest-form input,\n' +
555            '#vo-support-guest-form textarea {\n' +
556            '  width: 100%;\n' +
557            '  padding: 9px 12px;\n' +
558            '  border: 1px solid #ddd;\n' +
559            '  border-radius: 8px;\n' +
560            '  font-size: 16px;\n' +
561            '  font-family: inherit;\n' +
562            '  outline: none;\n' +
563            '  box-sizing: border-box;\n' +
564            '  transition: border-color 0.15s;\n' +
565            '}\n' +
566            '#vo-support-guest-form input:focus,\n' +
567            '#vo-support-guest-form textarea:focus {\n' +
568            '  border-color: #2D7D7D;\n' +
569            '}\n' +
570            '#vo-support-guest-form textarea {\n' +
571            '  min-height: 70px;\n' +
572            '  resize: vertical;\n' +
573            '}\n' +
574            '#vo-guest-submit {\n' +
575            '  background: #2D7D7D;\n' +
576            '  color: #fff;\n' +
577            '  border: none;\n' +
578            '  padding: 10px 20px;\n' +
579            '  border-radius: 8px;\n' +
580            '  font-size: 14px;\n' +
581            '  font-weight: 600;\n' +
582            '  cursor: pointer;\n' +
583            '  font-family: inherit;\n' +
584            '  transition: background 0.15s;\n' +
585            '  margin-top: 4px;\n' +
586            '}\n' +
587            '#vo-guest-submit:hover { background: #246868; }\n' +
588            '#vo-guest-submit:disabled { background: #a0c4c4; cursor: default; }\n' +
589            '.vo-guest-error {\n' +
590            '  color: #e74c3c;\n' +
591            '  font-size: 12px;\n' +
592            '  margin: 0;\n' +
593            '  display: none;\n' +
594            '}\n' +
595            // Builder mobile controls use a 768px breakpoint, so keep launcher positioning in sync.
596            '@media (max-width: 768px) {\n' +
597            '  #vo-support-btn {\n' +
598            '    bottom: ' + launcherMobileBottom + ';\n' +
599            '    right: ' + launcherMobileRight + ';\n' +
600            '  }\n' +
601            '  #vo-support-btn.vo-support-btn-header {\n' +
602            '    bottom: auto;\n' +
603            '    right: auto;\n' +
604            '  }\n' +
605            '  #vo-support-panel {\n' +
606            '    top: 0;\n' +
607            '    bottom: 0;\n' +
608            '    left: 0;\n' +
609            '    right: 0;\n' +
610            '    width: auto;\n' +
611            '    height: auto;\n' +
612            '    max-width: none;\n' +
613            '    max-height: none;\n' +
614            '    border-radius: 0;\n' +
615            '    box-sizing: border-box;\n' +
616            '    animation: none;\n' +
617            '  }\n' +
618            '  #vo-support-input-area {\n' +
619            '    padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));\n' +
620            '  }\n' +
621            '  .vo-sms-form {\n' +
622            '    flex-direction: column;\n' +
623            '    align-items: stretch;\n' +
624            '  }\n' +
625            '}\n';
626        document.head.appendChild(style);
627    }
628
629    // ─── Widget creation ───────────────────────────────────────────────────
630
631    function createWidget() {
632        if (!hideLauncher) {
633            // Floating button
634            widgetBtn = document.createElement('button');
635            widgetBtn.type = 'button';
636            widgetBtn.id = 'vo-support-btn';
637            widgetBtn.setAttribute('aria-label', voT('support.chat.widget_aria'));
638            widgetBtn.innerHTML =
639                '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
640                    '<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/>' +
641                '</svg>' +
642                '<span id="vo-support-unread"></span>';
643            widgetBtn.addEventListener('click', togglePanel);
644            document.body.appendChild(widgetBtn);
645
646            unreadBadge = document.getElementById('vo-support-unread');
647            setupLauncherPlacement();
648        }
649
650        // Chat panel
651        chatPanel = document.createElement('div');
652        chatPanel.id = 'vo-support-panel';
653        chatPanel.innerHTML = buildPanelHTML();
654        document.body.appendChild(chatPanel);
655
656        // Cache references
657        messagesContainer = document.getElementById('vo-support-messages');
658        inputArea = document.getElementById('vo-support-input-area');
659        guestForm = document.getElementById('vo-support-guest-form');
660        headerSubtitle = chatPanel.querySelector('.vo-subtitle');
661
662        // Wire up events
663        document.getElementById('vo-support-close').addEventListener('click', togglePanel);
664        document.getElementById('vo-support-send').addEventListener('click', sendMessage);
665        document.getElementById('vo-support-input').addEventListener('keydown', function(e) {
666            if (e.key === 'Enter' && !e.shiftKey) {
667                e.preventDefault();
668                sendMessage();
669            }
670        });
671
672        var guestSubmitBtn = document.getElementById('vo-guest-submit');
673        if (guestSubmitBtn) {
674            guestSubmitBtn.addEventListener('click', handleGuestSubmit);
675        }
676
677        // Auto-resize the textarea input
678        var input = document.getElementById('vo-support-input');
679        input.addEventListener('input', function() {
680            this.style.height = 'auto';
681            this.style.height = Math.min(this.scrollHeight, 80) + 'px';
682        });
683    }
684
685    function shouldUseMobileHeaderLauncher() {
686        if (!launcherMobileHeaderTarget) return false;
687        if (window.matchMedia) {
688            if (!launcherMobileHeaderQuery) {
689                launcherMobileHeaderQuery = window.matchMedia('(max-width: 768px)');
690            }
691            return launcherMobileHeaderQuery.matches;
692        }
693        return window.innerWidth <= 768;
694    }
695
696    function updateLauncherPlacement() {
697        if (!widgetBtn) return;
698
699        if (shouldUseMobileHeaderLauncher()) {
700            var target = document.querySelector(launcherMobileHeaderTarget);
701            if (target && target.parentNode) {
702                if (widgetBtn.parentNode !== target.parentNode || widgetBtn.nextSibling !== target) {
703                    target.parentNode.insertBefore(widgetBtn, target);
704                }
705                widgetBtn.classList.add('vo-support-btn-header');
706                return;
707            }
708        }
709
710        if (widgetBtn.parentNode !== document.body) {
711            document.body.appendChild(widgetBtn);
712        }
713        widgetBtn.classList.remove('vo-support-btn-header');
714    }
715
716    function setupLauncherPlacement() {
717        if (!launcherMobileHeaderTarget || !widgetBtn) return;
718
719        updateLauncherPlacement();
720        if (window.matchMedia) {
721            if (!launcherMobileHeaderQuery) {
722                launcherMobileHeaderQuery = window.matchMedia('(max-width: 768px)');
723            }
724            if (typeof launcherMobileHeaderQuery.addEventListener === 'function') {
725                launcherMobileHeaderQuery.addEventListener('change', updateLauncherPlacement);
726            } else if (typeof launcherMobileHeaderQuery.addListener === 'function') {
727                launcherMobileHeaderQuery.addListener(updateLauncherPlacement);
728            }
729        } else {
730            window.addEventListener('resize', updateLauncherPlacement);
731        }
732    }
733
734    function buildPanelHTML() {
735        return '' +
736            '<div id="vo-support-header">' +
737                '<div id="vo-support-header-info">' +
738                    '<h3>' + voT('support.chat.header_title') + '</h3>' +
739                    '<div class="vo-subtitle">' + voT('support.chat.header_subtitle') + '</div>' +
740                '</div>' +
741                '<button id="vo-support-close" aria-label="' + voT('support.chat.close_aria') + '">&times;</button>' +
742            '</div>' +
743            '<div id="vo-support-messages"></div>' +
744            '<div class="vo-reply-note" id="vo-reply-note">' + voT('support.chat.reply_note') + '</div>' +
745            '<div id="vo-support-guest-form" style="display:none;">' +
746                '<p>' + SUPPORT_CHAT_INTRO_TEXT + '</p>' +
747                '<div>' +
748                    '<label for="vo-guest-email">' + voT('support.chat.guest_email_label') + '</label>' +
749                    '<input type="email" id="vo-guest-email" placeholder="' + voT('support.chat.guest_email_ph') + '" />' +
750                '</div>' +
751                '<div>' +
752                    '<label for="vo-guest-message">' + voT('support.chat.guest_msg_label') + '</label>' +
753                    '<textarea id="vo-guest-message" placeholder="' + voT('support.chat.guest_msg_ph') + '"></textarea>' +
754                '</div>' +
755                '<p class="vo-guest-error" id="vo-guest-error"></p>' +
756                '<button id="vo-guest-submit">' + voT('support.chat.start_chat') + '</button>' +
757            '</div>' +
758            '<div id="vo-support-input-area" style="display:none;">' +
759                '<textarea id="vo-support-input" rows="1" placeholder="' + voT('support.chat.input_ph') + '"></textarea>' +
760                '<button id="vo-support-send" aria-label="' + voT('support.chat.send_aria') + '">' +
761                    '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
762                        '<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>' +
763                    '</svg>' +
764                '</button>' +
765            '</div>';
766    }
767
768    // ─── Panel toggle ──────────────────────────────────────────────────────
769
770    function openPanel() {
771        if (!initialized) {
772            pendingOpen = true;
773            return;
774        }
775        if (!isOpen) {
776            isOpen = true;
777            chatPanel.classList.add('vo-open');
778            if (widgetBtn) widgetBtn.classList.add('vo-support-btn-hidden');
779            unreadCount = 0;
780            updateUnreadBadge();
781            onPanelOpen();
782        }
783    }
784
785    function closePanel() {
786        if (!initialized || !isOpen) return;
787        isOpen = false;
788        chatPanel.classList.remove('vo-open');
789        if (widgetBtn) widgetBtn.classList.remove('vo-support-btn-hidden');
790    }
791
792    function togglePanel() {
793        if (isOpen) {
794            closePanel();
795        } else {
796            openPanel();
797        }
798    }
799
800    window.VibeOtterSupportChat = {
801        open: openPanel,
802        close: closePanel,
803        toggle: togglePanel
804    };
805
806    function finishPendingOpen() {
807        if (pendingOpen) {
808            pendingOpen = false;
809            openPanel();
810        }
811    }
812
813    function onPanelOpen() {
814        if (conversationId) {
815            showChatView();
816            loadConversation();
817        } else if (isAuthenticated) {
818            // Check server for existing open conversation
819            showChatView();
820            messagesContainer.innerHTML =
821                '<div class="vo-empty-state">' + voT('support.chat.loading') + '</div>';
822            createConversation({ resume_only: true })
823                .then(function(resp) {
824                    if (resp.conversation) {
825                        conversationId = resp.conversation.id;
826                        updateConversationMeta(resp.conversation);
827                        loadConversation();
828                    } else {
829                        showEmptyState();
830                    }
831                })
832                .catch(function() {
833                    showEmptyState();
834                });
835        } else {
836            showGuestForm();
837        }
838    }
839
840    function showChatView() {
841        guestForm.style.display = 'none';
842        messagesContainer.style.display = 'flex';
843        inputArea.style.display = 'flex';
844        document.getElementById('vo-reply-note').style.display = 'block';
845    }
846
847    function showGuestForm() {
848        guestForm.style.display = 'flex';
849        messagesContainer.style.display = 'none';
850        inputArea.style.display = 'none';
851        document.getElementById('vo-reply-note').style.display = 'none';
852    }
853
854    function showEmptyState() {
855        messagesContainer.innerHTML = '';
856        appendSupportIntroNote();
857        messagesContainer.insertAdjacentHTML('beforeend',
858            '<div class="vo-empty-state">' + voT('support.chat.empty_1') + '<br/>' + voT('support.chat.empty_2') + '</div>');
859    }
860
861    function showConversationClosedState() {
862        messages = [];
863        seenMessageIds.clear();
864        smsOptedIn = false;
865        messagesContainer.innerHTML =
866            '<div class="vo-empty-state">' + voT('support.chat.closed_1') + '<br/>' + voT('support.chat.closed_2') + '</div>';
867        showChatView();
868    }
869
870    function updateConversationMeta(conversation) {
871        smsOptedIn = !!(conversation && (conversation.sms_opted_in || conversation.support_sms_phone || conversation.support_sms_opted_in_at));
872    }
873
874    function shouldShowSMSPrompt() {
875        if (!conversationId || smsOptedIn || isSMSPromptDismissed()) return false;
876        return !!messagesContainer.querySelector('.vo-msg');
877    }
878
879    function syncSMSPrompt() {
880        var existing = document.getElementById('vo-sms-opt-in-card');
881        if (existing) existing.remove();
882        if (!shouldShowSMSPrompt()) return;
883
884        var card = document.createElement('div');
885        card.id = 'vo-sms-opt-in-card';
886        card.className = 'vo-sms-card';
887        card.innerHTML =
888            '<div class="vo-sms-title">' + voT('support.chat.sms_wait', { minutes: getSMSWaitMinutes() }) + '</div>' +
889            '<div class="vo-sms-copy">' + voT('support.chat.sms_copy') + '</div>' +
890            '<div class="vo-sms-form">' +
891                '<input id="vo-sms-phone" type="tel" inputmode="tel" autocomplete="tel" placeholder="' + voT('support.chat.sms_phone_ph') + '" />' +
892                '<button id="vo-sms-submit" type="button">' + voT('support.chat.sms_enable') + '</button>' +
893            '</div>' +
894            '<div class="vo-sms-disclaimer">
894' + voT('support.chat.sms_disclaimer') + '</div>' +
895            '<div class="vo-sms-actions">' +
896                '<span class="vo-sms-error" id="vo-sms-error"></span>' +
897                '<button class="vo-sms-dismiss" id="vo-sms-dismiss" type="button">' + voT('support.chat.sms_not_now') + '</button>' +
898            '</div>';
899
900        messagesContainer.appendChild(card);
901
902        document.getElementById('vo-sms-submit').addEventListener('click', handleSMSOptInSubmit);
903        document.getElementById('vo-sms-phone').addEventListener('keydown', function(e) {
904            if (e.key === 'Enter') {
905                e.preventDefault();
906                handleSMSOptInSubmit();
907            }
908        });
909        document.getElementById('vo-sms-dismiss').addEventListener('click', function() {
910            setSMSPromptDismissed();
911            syncSMSPrompt();
912        });
913    }
914
915    function handleSMSOptInSubmit() {
916        var input = document.getElementById('vo-sms-phone');
917        var button = document.getElementById('vo-sms-submit');
918        var error = document.getElementById('vo-sms-error');
919        var phone = input ? input.value.trim() : '';
920
921        if (!phone || phone.replace(/[^\d]/g, '').length < 10) {
922            error.textContent = voT('support.chat.sms_invalid');
923            error.style.display = 'inline';
924            return;
925        }
926
927        error.style.display = 'none';
928        button.disabled = true;
929        button.textContent = voT('support.chat.sms_enabling');
930
931        enableSMSAlerts(phone)
932            .then(function() {
933                smsOptedIn = true;
934                syncSMSPrompt();
935                appendSystemNote(voT('support.chat.sms_enabled'), 'success');
936            })
937            .catch(function(err) {
938                console.error('[VO Support] SMS opt-in error:', err);
939                error.textContent = voT('support.chat.sms_failed');
940                error.style.display = 'inline';
941                button.disabled = false;
942                button.textContent = voT('support.chat.sms_enable');
943            });
944    }
945
946    // ─── Guest form submit ─────────────────────────────────────────────────
947
948    function handleGuestSubmit() {
949        var emailEl = document.getElementById('vo-guest-email');
950        var msgEl = document.getElementById('vo-guest-message');
951        var errEl = document.getElementById('vo-guest-error');
952        var submitBtn = document.getElementById('vo-guest-submit');
953
954        var email = emailEl.value.trim();
955        var msg = msgEl.value.trim();
956
957        // Basic validation
958        if (!email || !isValidEmail(email)) { showGuestError(errEl, voT('support.chat.guest_email_invalid')); return; }
959        if (!msg) { showGuestError(errEl, voT('support.chat.guest_msg_required')); return; }
960
961        errEl.style.display = 'none';
962        submitBtn.disabled = true;
963        submitBtn.textContent = voT('support.chat.starting');
964
965        // Create the guest conversation and first message as one backend operation.
966        createConversation({ guest_email: email, initial_message: msg })
967            .then(function(resp) {
968                conversationId = resp.conversation.id;
969                updateConversationMeta(resp.conversation);
970                guestSessionToken = resp.session_token || null;
971                saveGuestState(email, conversationId, guestSessionToken);
972                joinSocket();
973                showChatView();
974                messagesContainer.innerHTML = '';
975                appendSupportIntroNote();
976                return resp.message;
977            })
978            .then(function(sentMsg) {
979                appendMessage({
980                    sender_type: 'user',
981                    message: msg,
982                    created_at: sentMsg && sentMsg.created_at ? sentMsg.created_at : new Date().toISOString()
983                });
984                if (sentMsg && sentMsg.id) {
985                    seenMessageIds.add(sentMsg.id);
986                    lastMessageId = sentMsg.id;
987                }
988                syncSMSPrompt();
989                scrollToBottom();
990                submitBtn.disabled = false;
991                submitBtn.textContent = voT('support.chat.start_chat');
992            })
993            .catch(function(err) {
994                showGuestError(errEl, voT('support.chat.generic_error'));
995                submitBtn.disabled = false;
996                submitBtn.textContent = voT('support.chat.start_chat');
997                console.error('[VO Support] Guest submit error:', err);
998            });
999    }
1000
1001    function showGuestError(el, text) {
1002        el.textContent = text;
1003        el.style.display = 'block';
1004    }
1005
1006    function isValidEmail(email) {
1007        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
1008    }
1009
1010    // ─── API calls ─────────────────────────────────────────────────────────
1011
1012    function getAuthHeaders() {
1013        var headers = { 'Content-Type': 'application/json' };
1014        var voLocale = window.voI18n && window.voI18n.localeHeader();
1015        headers['X-VO-Locale'] = voLocale === 'es-MX' ? 'es-MX' : 'en';
1016        if (guestSessionToken) {
1017            headers['X-Support-Session'] = guestSessionToken;
1018        }
1019        if (isAuthenticated && authToken) {
1020            headers['Authorization'] = 'Bearer ' + authToken;
1021        }
1022        return headers;
1023    }
1024
1025    // Refresh the Firebase token before each API call (tokens expire)
1026    function refreshToken() {
1027        return new Promise(function(resolve) {
1028            if (isAuthenticated && typeof firebase !== 'undefined' && firebase.auth && firebase.auth().currentUser) {
1029                firebase.auth().currentUser.getIdToken().then(function(token) {
1030                    authToken = token;
1031                    resolve();
1032                }).catch(function() {
1033                    resolve(); // proceed anyway
1034                });
1035            } else {
1036                resolve();
1037            }
1038        });
1039    }
1040
1041    function createConversation(opts) {
1042        var body = { page_url: window.location.href };
1043        if (opts && opts.guest_email) {
1044            body.guest_email = opts.guest_email;
1045            if (opts.guest_name) body.guest_name = opts.guest_name;
1046        }
1047        if (opts && opts.initial_message) {
1048            body.initial_message = opts.initial_message;
1049        }
1050        var url = '/api/support';
1051        if (opts && opts.resume_only) url += '?resume_only=true';
1052        return refreshToken().then(function() {
1053            return fetch(API_URL + url, {
1054                method: 'POST',
1055                headers: getAuthHeaders(),
1056                body: JSON.stringify(body)
1057            });
1058        }).then(function(res) {
1059            if (!res.ok) throw new Error('Failed to create conversation: ' + res.status);
1060            return res.json();
1061        });
1062    }
1063
1064    function loadConversation() {
1065        messagesContainer.innerHTML =
1066            '<div class="vo-empty-state">' + voT('support.chat.loading_messages') + '</div>';
1067
1068        refreshToken().then(function() {
1069            return fetch(API_URL + '/api/support/' + conversationId + '/messages', {
1070                method: 'GET',
1071                headers: getAuthHeaders()
1072            });
1073        }).then(function(res) {
1074            if (!res.ok) {
1075                // Conversation may have been deleted or is invalid
1076                if (res.status === 404) {
1077                    conversationId = null;
1078                    clearGuestState();
1079                    onPanelOpen();
1080                    return null;
1081                }
1082                throw new Error('Failed to load messages: ' + res.status);
1083            }
1084            return res.json();
1085        }).then(function(data) {
1086            if (!data) return;
1087
1088            // If conversation was closed, clear and start fresh
1089            if (data.conversation && data.conversation.status === 'closed') {
1090                conversationId = null;
1091                clearGuestState();
1092                onPanelOpen();
1093                return;
1094            }
1095
1096            updateConversationMeta(data.conversation);
1097
1098            messages = data.messages || [];
1099            seenMessageIds.clear();
1100            messages.forEach(function(msg) {
1101                if (msg.id) seenMessageIds.add(msg.id);
1102            });
1103            renderMessages();
1104            lastMessageId = null;
1105            if (messages.length > 0) {
1106                lastMessageId = messages[messages.length - 1].id;
1107            }
1108            syncSMSPrompt();
1109            scrollToBottom();
1110            joinSocket();
1111        }).catch(function(err) {
1112            console.error('[VO Support] Load conversation error:', err);
1113            messagesContainer.innerHTML =
1114                '<div class="vo-empty-state">' + voT('support.chat.load_failed_1') + '<br/>' + voT('support.chat.load_failed_2') + '</div>';
1115        });
1116    }
1117
1118    function sendMessageToAPI(text) {
1119        return refreshToken().then(function() {
1120            return fetch(API_URL + '/api/support/' + conversationId + '/messages', {
1121                method: 'POST',
1122                headers: getAuthHeaders(),
1123                body: JSON.stringify({ message: text })
1124            });
1125        }).then(function(res) {
1126            if (!res.ok) throw new Error('Failed to send message: ' + res.status);
1127            return res.json();
1128        }).then(function(data) {
1129            return data.message; // unwrap { message: {...} }
1130        });
1131    }
1132
1133    function enableSMSAlerts(phone) {
1134        return refreshToken().then(function() {
1135            return fetch(API_URL + '/api/support/' + conversationId + '/sms-opt-in', {
1136                method: 'POST',
1137                headers: getAuthHeaders(),
1138                body: JSON.stringify({ phone: phone })
1139            });
1140        }).then(function(res) {
1141            if (!res.ok) throw new Error('Failed to enable SMS alerts: ' + res.status);
1142            return res.json();
1143        });
1144    }
1145
1146    // ─── Send message (from input) ────────────────────────────────────────
1147
1148    function sendMessage() {
1149        var input = document.getElementById('vo-support-input');
1150        var text = input.value.trim();
1151        if (!text) return;
1152
1153        input.value = '';
1154        input.style.height = 'auto';
1155
1156        // If no conversation exists yet (authenticated user first message), create one first
1157        if (!conversationId) {
1158            // Optimistically show the message
1159            var tempMsg = {
1160                sender_type: 'user',
1161                message: text,
1162                created_at: new Date().toISOString()
1163            };
1164
1165            // Clear empty state and show message
1166            var emptyState = messagesContainer.querySelector('.vo-empty-state');
1167            if (emptyState) emptyState.remove();
1168            appendMessage(tempMsg);
1169            scrollToBottom();
1170
1171            var sendBtn = document.getElementById('vo-support-send');
1172            sendBtn.disabled = true;
1173
1174            createConversation({ initial_message: text })
1175                .then(function(resp) {
1176                    conversationId = resp.conversation.id;
1177                    updateConversationMeta(resp.conversation);
1178                    joinSocket();
1179                    return resp.message;
1180                })
1181                .then(function(sentMsg) {
1182                    sendBtn.disabled = false;
1183                    if (sentMsg && sentMsg.id) {
1184                        seenMessageIds.add(sentMsg.id);
1185                        lastMessageId = sentMsg.id;
1186                    }
1187                    syncSMSPrompt();
1188                    scrollToBottom();
1189                })
1190                .catch(function(err) {
1191                    console.error('[VO Support] Send error:', err);
1192                    sendBtn.disabled = false;
1193                    appendSystemNote(voT('support.chat.send_failed'));
1194                });
1195            return;
1196        }
1197
1198        // Optimistically add the message
1199        var optimisticMsg = {
1200            sender_type: 'user',
1201            message: text,
1202            created_at: new Date().toISOString(),
1203            _optimistic: true
1204        };
1205
1206        var emptyEl = messagesContainer.querySelector('.vo-empty-state');
1207        if (emptyEl) emptyEl.remove();
1208        appendMessage(optimisticMsg);
1209        scrollToBottom();
1210
1211        sendMessageToAPI(text)
1212            .then(function(sentMsg) {
1213                if (sentMsg && sentMsg.id) {
1214                    seenMessageIds.add(sentMsg.id);
1215                    lastMessageId = sentMsg.id;
1216                }
1217            })
1218            .catch(function(err) {
1219                console.error('[VO Support] Send error:', err);
1220                appendSystemNote(voT('support.chat.send_failed_2'));
1221            });
1222    }
1223
1224    // ─── Message rendering ─────────────────────────────────────────────────
1225
1226    function renderMessages() {
1227        messagesContainer.innerHTML = '';
1228        appendSupportIntroNote();
1229        if (messages.length === 0) {
1230            messagesContainer.insertAdjacentHTML('beforeend',
1231                '<div class="vo-empty-state">' + voT('support.chat.empty_1') + '<br/>' + voT('support.chat.empty_2') + '</div>');
1232            return;
1233        }
1234        messages.forEach(function(msg) {
1235            appendMessage(msg, true); // skipAnimation on bulk render
1236        });
1237        syncSMSPrompt();
1238    }
1239
1240    function appendSupportIntroNote() {
1241        if (!messagesContainer || messagesContainer.querySelector('.vo-support-intro-note')) return;
1242
1243        var note = document.createElement('div');
1244        note.className = 'vo-support-intro-note';
1245        note.textContent = SUPPORT_CHAT_INTRO_TEXT;
1246        messagesContainer.appendChild(note);
1247    }
1248
1249    function appendMessage(msg, skipAnimation) {
1250        var existingSMSCard = document.getElementById('vo-sms-opt-in-card');
1251        if (existingSMSCard) existingSMSCard.remove();
1252
1253        var isUser = msg.sender_type === 'user';
1254
1255        var textEl = document.createElement('div');
1256        textEl.className = 'vo-msg-text';
1257        textEl.textContent = msg.message;
1258
1259        var timeEl = document.createElement('div');
1260        timeEl.className = 'vo-msg-time';
1261        timeEl.textContent = relativeTime(msg.created_at);
1262
1263        if (isUser) {
1264            var div = document.createElement('div');
1265            div.className = 'vo-msg vo-msg-user';
1266            if (skipAnimation) div.style.animation = 'none';
1267            div.appendChild(timeEl);
1268            div.appendChild(textEl);
1269            messagesContainer.appendChild(div);
1270        } else {
1271            // Admin message with avatar and name
1272            var wrapper = document.createElement('div');
1273            wrapper.className = 'vo-admin-wrapper';
1274            if (skipAnimation) wrapper.style.animation = 'none';
1275
1276            var avatar = document.createElement('img');
1277            avatar.className = 'vo-admin-avatar';
1278            avatar.src = '/images/nick-avatar.png';
1279            avatar.alt = 'Nick';
1280
1281            var content = document.createElement('div');
1282            content.className = 'vo-admin-content';
1283
1284            var nameEl = document.createElement('div');
1285            nameEl.className = 'vo-admin-name';
1286            nameEl.textContent = 'Nick';
1287
1288            var bubble = document.createElement('div');
1289            bubble.className = 'vo-msg vo-msg-admin';
1290            if (skipAnimation) bubble.style.animation = '
1290none';
1291            bubble.appendChild(timeEl);
1292            bubble.appendChild(textEl);
1293
1294            content.appendChild(nameEl);
1295            content.appendChild(bubble);
1296            wrapper.appendChild(avatar);
1297            wrapper.appendChild(content);
1298            messagesContainer.appendChild(wrapper);
1299        }
1300
1301        if (!skipAnimation) syncSMSPrompt();
1302    }
1303
1304    function appendSystemNote(text, tone) {
1305        var existingSMSCard = document.getElementById('vo-sms-opt-in-card');
1306        if (existingSMSCard) existingSMSCard.remove();
1307
1308        var div = document.createElement('div');
1309        var color = tone === 'success' ? '#2D7D7D' : '#e74c3c';
1310        div.style.cssText = 'text-align:center;color:' + color + ';font-size:12px;padding:4px 0;';
1311        div.textContent = text;
1312        messagesContainer.appendChild(div);
1313        syncSMSPrompt();
1314        scrollToBottom();
1315    }
1316
1317    function scrollToBottom() {
1318        requestAnimationFrame(function() {
1319            messagesContainer.scrollTop = messagesContainer.scrollHeight;
1320        });
1321    }
1322
1323    // ─── Relative time ─────────────────────────────────────────────────────
1324
1325    function relativeTime(isoStr) {
1326        if (!isoStr) return '';
1327        var now = Date.now();
1328        var then = new Date(isoStr).getTime();
1329        var diff = Math.max(0, Math.floor((now - then) / 1000));
1330
1331        if (diff < 10) return voT('support.chat.time.just_now');
1332        if (diff < 60) return voT('support.chat.time.s_ago', { n: diff });
1333        var min = Math.floor(diff / 60);
1334        if (min < 60) return voT('support.chat.time.m_ago', { n: min });
1335        var hr = Math.floor(min / 60);
1336        if (hr < 24) return voT('support.chat.time.h_ago', { n: hr });
1337        var days = Math.floor(hr / 24);
1338        if (days === 1) return voT('support.chat.time.yesterday');
1339        if (days < 7) return voT('support.chat.time.d_ago', { n: days });
1340        // Fallback: short date in the active locale
1341        var d = new Date(isoStr);
1342        return window.voI18n
1343            ? window.voI18n.fmtDate(d, { month: 'short', day: 'numeric' })
1344            : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
1345    }
1346
1347    // ─── Socket.IO ─────────────────────────────────────────────────────────
1348
1349    function connectSocket() {
1350        if (socket) return; // already created
1351        if (typeof io !== 'undefined') {
1352            try {
1353                socket = io(API_URL, { transports: ['websocket', 'polling'], reconnection: true });
1354                socket.on('support-message', onNewMessage);
1355                socket.on('support-conversation-status', onConversationStatus);
1356                socket.on('joined-support', function(data) {
1357                    if (!data || data.conversationId !== conversationId) return;
1358                    if (data.locale && window.voI18n && window.voI18n.enabled && data.locale !== window.voI18n.locale) {
1359                        window.voI18n.switchTo(data.locale);
1360                        return;
1361                    }
1362                    currentSocketRoom = data.conversationId;
1363                    pendingSocketRoom = null;
1364                    stopPolling();
1365                });
1366                socket.on('support-join-denied', function(data) {
1367                    if (!data || data.conversationId !== conversationId) return;
1368                    pendingSocketRoom = null;
1369                    startPolling(true);
1370                });
1371                socket.on('disconnect', function() {
1372                    socketConnected = false;
1373                });
1374                socket.on('connect', function() {
1375                    socketConnected = true;
1376                    if (conversationId) {
1377                        joinSocket();
1378                    }
1379                });
1380            } catch (e) {
1381                console.error('[VO Support] Socket connect error:', e);
1382                socket = null;
1383                scheduleSocketRetry();
1384            }
1385        } else {
1386            scheduleSocketRetry();
1387        }
1388    }
1389
1390    function scheduleSocketRetry() {
1391        socketRetryCount++;
1392        if (socketRetryCount <= maxSocketRetries) {
1393            setTimeout(connectSocket, 1000);
1394        } else {
1395            // Fall back to polling if socket never connects
1396            startPolling();
1397        }
1398    }
1399
1400    function joinSocket() {
1401        if (socket && socketConnected && conversationId) {
1402            var roomToJoin = conversationId;
1403            if (currentSocketRoom && currentSocketRoom !== conversationId) {
1404                socket.emit('leave-support', currentSocketRoom);
1405                currentSocketRoom = null;
1406            }
1407            pendingSocketRoom = roomToJoin;
1408            refreshToken().then(function() {
1409                if (!socket || !socketConnected || conversationId !== roomToJoin) return;
1410                socket.emit('join-support', {
1411                    conversationId: roomToJoin,
1412                    sessionToken: guestSessionToken,
1413                    authToken: authToken
1414                });
1415                setTimeout(function() {
1416                    if (pendingSocketRoom === roomToJoin) {
1417                        startPolling(true);
1418                    }
1419                }, 1500);
1420            });
1421        }
1422        if (!socketConnected && conversationId) {
1423            startPolling();
1424        }
1425    }
1426
1427    function onNewMessage(msg) {
1428        if (msg.id && seenMessageIds.has(msg.id)) return;
1429        if (msg.id) seenMessageIds.add(msg.id);
1430        if (msg.id) lastMessageId = msg.id;
1431
1432        // Don't duplicate optimistic user messages
1433        if (msg.sender_type === 'user') return;
1434
1435        messages.push(msg);
1436
1437        // If panel is open, render it
1438        if (isOpen) {
1439            var emptyEl = messagesContainer.querySelector('.vo-empty-state');
1440            if (emptyEl) emptyEl.remove();
1441            appendMessage(msg);
1442            scrollToBottom();
1443        } else {
1444            // Increment unread badge
1445            unreadCount++;
1446            updateUnreadBadge();
1447        }
1448    }
1449
1450    function onConversationStatus(event) {
1451        if (!event || event.conversation_id !== conversationId) return;
1452
1453        if (event.status === 'closed') {
1454            if (socket && currentSocketRoom) {
1455                socket.emit('leave-support', currentSocketRoom);
1456                currentSocketRoom = null;
1457            }
1458            stopPolling();
1459            pendingSocketRoom = null;
1460            conversationId = null;
1461            lastMessageId = null;
1462            messages = [];
1463            seenMessageIds.clear();
1464            clearGuestState();
1465            if (isAuthenticated) {
1466                showConversationClosedState();
1467            } else {
1468                showGuestForm();
1469            }
1470        }
1471    }
1472
1473    function updateUnreadBadge() {
1474        if (!unreadBadge) return;
1475        if (unreadCount > 0) {
1476            unreadBadge.textContent = unreadCount > 9 ? '9+' : unreadCount;
1477            unreadBadge.style.display = 'flex';
1478        } else {
1479            unreadBadge.style.display = 'none';
1480        }
1481    }
1482
1483    // ─── Polling fallback ──────────────────────────────────────────────────
1484
1485    function startPolling(force) {
1486        // Only start if not already polling and socket isn't connected
1487        if (pollingInterval) {
1488            if (force) pollingForced = true;
1489            return;
1490        }
1491        if (socketConnected && !force) return;
1492        pollingForced = !!force;
1493        pollingInterval = setInterval(pollMessages, 5000);
1494        pollMessages();
1495    }
1496
1497    function stopPolling() {
1498        pollingForced = false;
1499        if (pollingInterval) {
1500            clearInterval(pollingInterval);
1501            pollingInterval = null;
1502        }
1503    }
1504
1505    function pollMessages() {
1506        if (!conversationId) {
1507            stopPolling();
1508            return;
1509        }
1510        // If socket reconnected, stop polling
1511        if (socketConnected && !pollingForced) {
1512            stopPolling();
1513            return;
1514        }
1515
1516        refreshToken().then(function() {
1517            return fetch(API_URL + '/api/support/' + conversationId + '/messages', {
1518                method: 'GET',
1519                headers: getAuthHeaders()
1520            });
1521        }).then(function(res) {
1522            if (!res.ok) return;
1523            return res.json();
1524        }).then(function(data) {
1525            if (!data || !data.messages) return;
1526            if (data.conversation && data.conversation.status === 'closed') {
1527                onConversationStatus({
1528                    conversation_id: conversationId,
1529                    status: 'closed',
1530                    updated_at: data.conversation.updated_at
1531                });
1532                return;
1533            }
1534            var newMsgs = data.messages;
1535
1536            // Find messages we haven't seen. UUIDs are not chronologically sortable.
1537            newMsgs = newMsgs.filter(function(m) {
1538                return !m.id || !seenMessageIds.has(m.id);
1539            });
1540
1541            newMsgs.forEach(function(msg) {
1542                onNewMessage(msg);
1543            });
1544        }).catch(function() {
1545            // Silently fail on poll errors
1546        });
1547    }
1548
1549    // ─── Initialization ────────────────────────────────────────────────────
1550
1551    function init() {
1552        if (initialized) return;
1553        injectStyles();
1554        createWidget();
1555        initialized = true;
1556
1557        if (supportLinkState && supportLinkState.conversationId) {
1558            conversationId = supportLinkState.conversationId;
1559            if (supportLinkState.token) {
1560                guestSessionToken = supportLinkState.token;
1561                saveGuestState('', conversationId, guestSessionToken);
1562                if (window.history && window.history.replaceState) {
1563                    try {
1564                        window.history.replaceState(null, document.title, window.location.pathname + window.location.search);
1565                    } catch (e) {
1566                        // Some restricted document contexts do not allow URL cleanup.
1567                    }
1568                }
1569            }
1570            pendingOpen = true;
1571        }
1572
1573        // Wait for Firebase auth to settle
1574        var checkAuth = function() {
1575            if (typeof firebase !== 'undefined' && firebase.auth) {
1576                firebase.auth().onAuthStateChanged(function(user) {
1577                    if (user) {
1578                        isAuthenticated = true;
1579                        userEmail = user.email;
1580                        if (!guestSessionToken) {
1581                            clearGuestState();
1582                            guestSessionToken = null;
1583                        }
1584                        user.getIdToken().then(function(token) {
1585                            authToken = token;
1586                            if (conversationId && socketConnected && currentSocketRoom !== conversationId) {
1587                                joinSocket();
1588                            }
1589                        });
1590                    } else {
1591                        isAuthenticated = false;
1592                        userEmail = '';
1593                        authToken = '';
1594                        conversationId = null;
1595                        currentSocketRoom = null;
1596                        // Restore guest state if available
1597                        var guestState = loadGuestState();
1598                        if (guestState) {
1599                            conversationId = guestState.conversationId;
1600                            userEmail = guestState.email;
1601                            guestSessionToken = guestState.sessionToken || null;
1602                        } else if (supportLinkState && supportLinkState.conversationId && supportLinkState.token) {
1603                            conversationId = supportLinkState.conversationId;
1604                            guestSessionToken = supportLinkState.token;
1605                        }
1606                    }
1607                });
1608            }
1609        };
1610
1611        // Try immediately and also after a delay for deferred scripts
1612        checkAuth();
1613        setTimeout(checkAuth, 1000);
1614
1615        // Connect socket
1616        connectSocket();
1617        finishPendingOpen();
1618    }
1619
1620    // Kick off
1621    if (document.readyState === 'loading') {
1622        document.addEventListener('DOMContentLoaded', init);
1623    } else {
1624        init();
1625    }
1626
1627})();

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.