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