1/** 2 * crm-chat-widget 3 * 4 * Zero-dependency embeddable chat widget that relays conversations into the 5 * Spark CRM and Slack. Mounts a launcher button + chat panel, persists a 6 * session token in localStorage, polls for new messages every 4 seconds while 7 * the panel is open. 8 * 9 * Conversation flows are server-driven: the backend sends bot messages with 10 * optional clickable option buttons. The widget renders them and sends 11 * selections back via POST /conversations/:id/flow/select. 12 * 13 * Identity collection (name / email / phone) is conversational: the bot asks 14 * each required field as a chat question before the flow or free chat begins. 15 * 16 * Public API: 17 * CrmChatWidget.init(config) 18 * CrmChatWidget.open() 19 * CrmChatWidget.close() 20 * CrmChatWidget.destroy() 21 */ 22(function (root, factory) { 23 if (typeof module === 'object' && module.exports) { 24 module.exports = factory(); 25 } else { 26 root.CrmChatWidget = factory(); 27 } 28}(typeof self !== 'undefined' ? self : this, function () { 29 'use strict'; 30 31 var DEFAULT_POLL_MS = 4000; 32 // While the panel is closed we poll less often â just enough to surface an 33 // unread badge without adding meaningful load. 34 var DEFAULT_CLOSED_POLL_MS = 15000; 35 var STORAGE_PREFIX = 'crm_chat_session_'; 36 var TYPING_MIN_MS = 900; 37 var TYPING_MAX_MS = 1700; 38 // Roughly how long each character adds to the simulated typing pause, so a 39 // bigger chunk feels like it took a little longer to "type" (clamped to the 40 // MIN/MAX window above). 41 var TYPING_MS_PER_CHAR = 16; 42 // Bot replies shorter than this stay a single bubble (a sentence / short 43 // paragraph should never be split artificially). 44 var CHUNK_SINGLE_MAX = 180; 45 // A paragraph longer than this is broken down into sentences. 46 var CHUNK_MAX_CHARS = 200; 47 // Adjacent tiny segments are merged until a chunk reaches this size so no 48 // bubble ends up trivially short. 49 var CHUNK_MIN_CHARS = 60; 50 51 var TYPING_SUFFIX = { 52 pt: ' est\u00E1 digitando\u2026', 53 en: ' is typing\u2026', 54 es: ' est\u00E1 escribiendo\u2026', 55 }; 56 57 var FAILED_NOTE = { 58 pt: 'N\u00E3o enviada', 59 en: 'Not delivered', 60 es: 'No enviada', 61 }; 62 63 function appendQuery(url, key, value) { 64 if (value == null || value === '') return url; 65 var sep = url.indexOf('?') === -1 ? '?' : '&'; 66 return url + sep + encodeURIComponent(key) + '=' + encodeURIComponent(value); 67 } 68 69 function warn() { 70 try { 71 if (typeof console !== 'undefined' && console.warn) { 72 console.warn.apply(console, ['[crm-chat-widget]'].concat([].slice.call(arguments))); 73 } 74 } catch (_) { /* noop */ } 75 } 76 77 function el(tag, attrs, children) { 78 var node = document.createElement(tag); 79 if (attrs) { 80 for (var k in attrs) { 81 if (k === 'style' && typeof attrs[k] === 'object') { 82 for (var s in attrs[k]) node.style[s] = attrs[k][s]; 83 } else if (k.indexOf('on') === 0 && typeof attrs[k] === 'function') { 84 node.addEventListener(k.substring(2).toLowerCase(), attrs[k]); 85 } else if (k === 'text') { 86 node.textContent = attrs[k]; 87 } else if (k === 'html') { 88 node.innerHTML = attrs[k]; 89 } else { 90 node.setAttribute(k, attrs[k]); 91 } 92 } 93 } 94 if (children) { 95 for (var i = 0; i < children.length; i++) { 96 var c = children[i]; 97 if (c == null) continue; 98 node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); 99 } 100 } 101 return node; 102 } 103 104 function formatTime(iso) { 105 try { 106 var d = new Date(iso); 107 return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 108 } catch (_) { return ''; } 109 } 110 111 function isValidEmail(str) { 112 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); 113 } 114 115 // Reads the GA4 client id from the host site's `_ga` cookie. Cookie format 116 // is "GA1.<n>.<random>.<timestamp>" â the client id is the last two 117 // segments ("<random>.<timestamp>"). Tolerant parsing: returns null when 118 // the cookie is absent or doesn't look like a GA client id. 119 function readGa4ClientId() { 120 try { 121 var cookies = (document.cookie || '').split(';'); 122 for (var i = 0; i < cookies.length; i++) { 123 var pair = cookies[i].trim(); 124 if (pair.indexOf('_ga=') !== 0) continue; 125 var raw = decodeURIComponent(pair.substring(4)); 126 // GA1.x.XXXX.YYYY â XXXX.YYYY ; already-bare XXXX.YYYY passes through. 127 var m = raw.match(/^GA\d+\.\d+\.(.+)$/i); 128 var cid = m ? m[1] : raw; 129 if (/^\d+\.\d+$/.test(cid)) return cid; 130 return null; 131 } 132 } catch (_) { /* noop */ } 133 return null; 134 } 135 136 // Reads Google Ads click ids (gclid/gbraid/wbraid) from the host page URL, 137 // persisting them in sessionStorage so they survive internal navigation 138 // within the session (URL param wins over a previously stored value). 139 function readGoogleClickIds() { 140 var out = {}; 141 var keys = ['gclid', 'gbraid', 'wbraid']; 142 var params = null; 143 try { params = new URLSearchParams(location.search); } catch (_) { params = null; } 144 for (var i = 0; i < keys.length; i++) { 145 var key = keys[i]; 146 var v = null;
147 try { 148 v = params ? params.get(key) : null; 149 if (v) { v = v.trim().slice(0, 255) || null; } 150 if (v) { try { sessionStorage.setItem('crm_click_id_' + key, v); } catch (_) {} } 151 else { try { v = sessionStorage.getItem('crm_click_id_' + key); } catch (_) {} } 152 } catch (_) { /* noop */ } 153 if (v) out[key] = v; 154 } 155 return out; 156 } 157 158 // Merges captured Google click ids into an outgoing payload object. 159 function addGoogleClickIds(payload) { 160 var ids = readGoogleClickIds(); 161 if (ids.gclid) payload.gclid = ids.gclid; 162 if (ids.gbraid) payload.gbraid = ids.gbraid; 163 if (ids.wbraid) payload.wbraid = ids.wbraid; 164 return payload; 165 } 166 167 // Inline SVG icons (zero-dependency, inherit currentColor). 168 var ICONS = { 169 launcher: '<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>', 170 bot: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>', 171 human: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>', 172 send: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>', 173 close: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>', 174 check: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>', 175 arrow: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>', 176 }; 177 178 var widgetState = null; 179 180 function loadSession(storageKey) { 181 try { 182 var raw = localStorage.getItem(storageKey); 183 if (!raw) return null; 184 return JSON.parse(raw); 185 } catch (_) { return null; } 186 } 187 188 function saveSession(storageKey, data) { 189 try { localStorage.setItem(storageKey, JSON.stringify(data)); } catch (_) {} 190 } 191 192 function clearSession(storageKey) { 193 try { localStorage.removeItem(storageKey); } catch (_) {} 194 } 195 196 // Applies the embed's primary color as a CSS custom property on :root so it 197 // can be re-themed at runtime (after the server config loads) without 198 // re-injecting the stylesheet. 199 function setPrimaryColor(primary) { 200 if (!primary) return; 201 try { document.documentElement.style.setProperty('--crm-chat-primary', primary); } catch (_) {} 202 } 203 204 function injectStyles(primary) { 205 setPrimaryColor(primary); 206 if (document.getElementById('crm-chat-widget-styles')) return; 207 var c = 'var(--crm-chat-primary,' + (primary || '#00e0b8') + ')'; 208 var ink = '#0a1220'; 209 var FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif'; 210 var css = '' 211 // ---- Launcher + teaser ------------------------------------------------ 212 + '.crm-chat-launcher{position:fixed;bottom:24px;right:24px;width:60px;height:60px;border-radius:50%;background:' + c + ';color:' + ink + ';border:none;cursor:pointer;box-shadow:0 8px 24px rgba(17,24,39,.24);z-index:2147483646;display:flex;align-items:center;justify-content:center;transition:transform .18s ease,box-shadow .18s ease;}' 213 + '.crm-chat-launcher:hover{transform:translateY(-2px) scale(1.04);box-shadow:0 12px 28px rgba(17,24,39,.3);}' 214 + '.crm-chat-launcher:active{transform:scale(.96);}' 215 + '.crm-chat-launcher svg{display:block;}' 216 + '.crm-chat-badge{position:absolute;top:-3px;right:-3px;min-width:21px;height:21px;padding:0 6px;border-radius:11px;background:#ef4444;color:#fff;
216font-family:' + FONT + ';font-size:12px;font-weight:700;line-height:1;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(17,24,39,.35);box-sizing:border-box;pointer-events:none;animation:crm-chat-pop .2s ease;}' 217 + '.crm-chat-teaser{position:fixed;bottom:34px;right:96px;max-width:236px;background:#fff;color:#1f2937;border-radius:8px;border-bottom-right-radius:3px;box-shadow:0 10px 30px rgba(17,24,39,.18);padding:12px 34px 12px 14px;font-family:' + FONT + ';font-size:14px;line-height:1.4;z-index:2147483646;cursor:pointer;animation:crm-chat-pop .25s ease;}' 218 + '.crm-chat-teaser .crm-chat-teaser-close{position:absolute;top:6px;right:8px;background:none;border:none;color:#9ca3af;font-size:16px;line-height:1;cursor:pointer;padding:2px;}' 219 + '.crm-chat-teaser .crm-chat-teaser-close:hover{color:#4b5563;}' 220 // ---- Panel ------------------------------------------------------------ 221 + '.crm-chat-panel{position:fixed;bottom:96px;right:24px;width:384px;max-width:calc(100vw - 32px);height:600px;max-height:calc(100vh - 120px);background:#fff;border-radius:12px;box-shadow:0 16px 48px rgba(17,24,39,.24);display:flex;flex-direction:column;z-index:2147483647;overflow:hidden;font-family:' + FONT + ';color:#111827;animation:crm-chat-pop .22s ease;}' 222 + '@keyframes crm-chat-pop{from{opacity:0;transform:translateY(12px) scale(.98);}to{opacity:1;transform:none;}}' 223 // ---- Header ----------------------------------------------------------- 224 + '.crm-chat-header{background:#0b1220;color:#fff;padding:16px;display:flex;align-items:center;gap:12px;border-bottom:1px solid rgba(0,224,184,.25);}' 225 + '.crm-chat-header-av{width:42px;height:42px;border-radius:9px;background:rgba(0,224,184,.16);display:flex;align-items:center;justify-content:center;flex-shrink:0;overflow:hidden;position:relative;color:' + c + ';}' 226 + '.crm-chat-header-av img{width:100%;height:100%;object-fit:cover;}' 227 + '.crm-chat-header-av svg{width:22px;height:22px;}' 228 + '.crm-chat-header-av .crm-chat-presence{position:absolute;bottom:0;right:0;width:11px;height:11px;border-radius:50%;background:' + c + ';border:2px solid #0b1220;}' 229 + '.crm-chat-header-info{flex:1;min-width:0;}' 230 + '.crm-chat-header-name{margin:0;font-size:15px;font-weight:700;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}' 231 + '.crm-chat-header-sub{margin:1px 0 0;font-size:12px;opacity:.7;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}' 232 + '.crm-chat-header .crm-chat-close{background:rgba(255,255,255,.1);border:none;color:#fff;cursor:pointer;line-height:0;padding:7px;border-radius:7px;flex-shrink:0;transition:background .15s;}' 233 + '.crm-chat-header .crm-chat-close:hover{background:rgba(255,255,255,.26);}' 234 // ---- Status ----------------------------------------------------------- 235 + '.crm-chat-status{display:flex;align-items:center;gap:7px;padding:8px 16px;font-size:12px;font-weight:600;background:#e6fbf6;color:#06564a;border-bottom:1px solid rgba(17,24,39,.06);}' 236 + '.crm-chat-status .crm-chat-status-ico{font-size:13px;line-height:1;}' 237 + '.crm-chat-status[data-status="waiting_agent"]{background:#fef9c3;color:#854d0e;}' 238 + '.crm-chat-status[data-status="agent_active"]{background:#dcfce7;color:#166534;}' 239 + '.crm-chat-status[data-status="closed"]{background:#f3f4f6;color:#4b5563;}' 240 // ---- Body / rows ------------------------------------------------------ 241 + '.crm-chat-body{flex:1;overflow-y:auto;padding:16px 14px;background:#f5f6f8;display:flex;flex-direction:column;gap:10px;}' 242 + '.crm-chat-row{display:flex;align-items:flex-end;gap:8px;max-width:100%;}' 243 + '.crm-chat-row.visitor{flex-direction:row-reverse;}' 244 + '.crm-chat-avatar{width:28px;height:28px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center;overflow:hidden;color:' + ink + ';}' 245 + '.crm-chat-avatar img{width:100%;height:100%;object-fit:cover;}' 246 + '.crm-chat-avatar.crm-chat-avatar-bot{background:' + c + ';}' 247 + '.crm-chat-avatar.crm-chat-avatar-agent{background:#0f766e;color:#fff;}' 248 + '.crm-chat-avatar svg{width:16px;height:16px;}' 249 // ---- Bubbles ---------------------------------------------------------- 250 + '.crm-chat-msg{max-width:78%;padding:10px 13px;border-radius:10px;font-size:14px;line-height:1.45;word-wrap:break-word;overflow-wrap:anywhere;box-shadow:0 1px 2px rgba(17,24,39,.06);}' 251 + '.crm-chat-msg.visitor{background:' + c + ';color:' + ink + ';border-bottom-right-radius:3px;}' 252 + '.crm-chat-msg.bot{background:#fff;color:#1f2937;border:1px solid #eceef2;border-bottom-left-radius:3px;}' 253 + '.crm-chat-msg.agent{background:#fff;color:#1f2937;border:1px solid #d7e7e4;border-left:3px solid #0f766e;border-bottom-left-radius:3px;}' 254 + '.crm-chat-msg.option-selected{background:#e6fbf6;color:#06564a;border:1px solid #9febdc;border-bottom-right-radius:3px;}' 255 + '.crm-chat-msg .crm-chat-meta{display:block;font-size:11px;opacity:.7;margin-top:5px;}' 256 + '.crm-chat-msg.agent .crm-chat-meta{opacity:1;color:#0f766e;font-weight:600;}' 257 + '.crm-chat-msg.agent .crm-chat-meta .crm-chat-meta-time{color:#9ca3af;font-weight:400;}' 258 + '.crm-chat-msg.crm-chat-msg-failed{opacity:.7;}' 259 + '.crm-chat-msg-failed-note{display:block;font-size:11px;margin-top:5px;color:#7f1d1d;font-weight:600;}' 260 // ---- Flow options ----------------------------------------------------- 261 + '.crm-chat-flow-options{display:flex;flex-wrap:wrap;gap:7px;margin-top:9px;}' 262 + '.crm-chat-flow-options button{background:#fff;color:' + ink + ';border:1.5px solid ' + c + ';border-radius:8px;padding:7px 15px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s,color .15s;}' 263 + '.crm-chat-flow-options button:hover{background:' + c + ';color:' + ink + ';}' 264 + '.crm-chat-flow-options button:disabled{opacity:.45;cursor:not-allowed;}' 265 // ---- Cards ------------------------------------------------------------ 266 + '.crm-chat-card{margin-top:9px;border:1px solid #eceef2;border-radius:10px;overflow:hidden;background:#fff;box-shadow:0 1px 2px rgba(17,24,39,.05);}' 267 + '.crm-chat-card-img{width:100%;height:120px;object-fit:cover;display:block;}' 268 + '.crm-chat-card-body{padding:12px 13px;}' 269 + '.crm-chat-card-title{font-size:14px;font-weight:700;color:#111827;margin:0 0 4px;}' 270 + '.crm-chat-card-sub{font-size:12.5px;color:#6b7280;margin:0 0 8px;line-height:1.4;}' 271 + '.crm-chat-card-bullets{list-style:none;margin:0 0 10px;padding:0;display:flex;flex-direction:column;gap:5px;}' 272 + '.crm-chat-card-bullets li{font-size:12.5px;color:#374151;display:flex;align-items:flex-start;gap:6px;line-height:1.35;}' 273 + '.crm-chat-card-bullets li:before{content:"";width:5px;height:5px;border-radius:50%;background:' + c + ';margin-top:6px;flex-shrink:0;}' 274 + '.crm-chat-card-cta{display:inline-flex;align-items:center;gap:6px;background:' + c + ';color:' + ink + ';border:none;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;font-family:inherit;transition:filter .15s;}' 275 + '.crm-chat-card-cta:hover{filter:brightness(1.08);}' 276 // ---- Handoff ---------------------------------------------------------- 277 + '.crm-chat-handoff{align-self:stretch;margin:4px 6px;background:linear-gradient(180deg,#fff,#f4fbf9);border:1px solid #e5e7eb;border-radius:10px;padding:14px 15px;text-align:center;box-shadow:0 1px 3px rgba(17,24,39,.06);}' 278 + '.crm-chat-handoff-title{font-size:13px;font-weight:700;color:#111827;display:flex;align-items:center;justify-content:center;gap:7px;}' 279 + '.crm-chat-handoff-title:before,.crm-chat-handoff-title:after{content:"";flex:1;height:1px;background:#e5e7eb;}' 280 + '.crm-chat-handoff-body{font-size:13px;color:#374151;margin-top:8px;line-height:1.5;}' 281 + '.crm-chat-handoff-eta{font-size:12px;color:#6b7280;margin-top:6px;}' 282 + '.crm-chat-handoff-summary{margin-top:11px;text-align:left;background:#f5f6f8;border-radius:8px;padding:10px 12px;}' 283 + '.crm-chat-handoff-summary-h{font-size:12px;font-weight:700;color:#111827;margin-bottom:6px;}' 284 + '.crm-chat-handoff-summary ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:5px;}' 285 + '.crm-chat-handoff-summary li{font-size:12.5px;color:#166534;display:flex;align-items:center;gap:7px;}' 286 + '.crm-chat-handoff-summary li svg{color:#16a34a;flex-shrink:0;}' 287 // ---- Compose ---------------------------------------------------------- 288 + '.crm-chat-compose{padding:10px 12px;border-top:1px solid #eceef2;background:#fff;display:flex;gap:8px;align-items:flex-end;}' 289 + '.crm-chat-compose textarea{flex:1;border:1px solid #e5e7eb;border-radius:10px;padding:11px 15px;font-size:14px;font-family:inherit;box-sizing:border-box;resize:none;height:44px;max-height:96px;background:#f9fafb;outline:none;transition:border-color .15s,box-shadow .15s;}' 290 + '.crm-chat-compose textarea:focus{border-color:' + c + ';background:#fff;box-shadow:0 0 0 3px rgba(0,224,184,.18);}' 291 + '.crm-chat-compose .crm-chat-send{background:' + c + ';color:' + ink + ';border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;line-height:0;transition:filter .15s,transform .12s;}' 292 + '.crm-chat-compose .crm-chat-send:hover{filter:brightness(1.08);}' 293 + '.crm-chat-compose .crm-chat-send:active{transform:scale(.92);}' 294 + '.crm-chat-compose .crm-chat-send[disabled]{opacity:.45;cursor:not-allowed;}' 295 + '.crm-chat-error-inline{color:#b91c1c;font-size:12px;padding:0 14px 4px;}' 296 + '.crm-chat-empty{text-align:center;color:#6b7280;font-size:13px;margin:auto;}' 297 // ---- Quick replies ---------------------------------------------------- 298 + '.crm-chat-quick-replies{display:flex;flex-wrap:wrap;gap:7px;padding:10px 12px 2px;}' 299 + '.crm-chat-quick-replies button{background:#fff;color:' + ink + ';border:1.5px solid ' + c + ';border-radius:8px;padding:7px 13px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s,color .15s;}' 300 + '.crm-chat-quick-replies button:hover{background:' + c + ';color:' + ink + ';}' 301 // ---- Typing ----------------------------------------------------------- 302 + '.crm-chat-typing{background:#fff;border:1px solid #eceef2;border-radius:10px;border-bottom-left-radius:3px;padding:10px 13px;font-size:13px;color:#6b7280;display:flex;align-items:center;box-shadow:0 1px 2px rgba(17,24,39,.06);}' 303 + '.crm-chat-typing .crm-chat-typing-label{font-style:normal;}' 304 + '.crm-chat-typing .crm-chat-typing-dots{display:inline-flex;gap:3px;margin-left:7px;}' 305 + '.crm-chat-typing .crm-chat-typing-dots span{width:6px;height:6px;border-radius:50%;background:#9ca3af;display:inline-block;animation:crm-chat-blink 1.2s infinite ease-in-out;}' 306 + '.crm-chat-typing .crm-chat-typing-dots span:nth-child(2){animation-delay:.2s;}' 307 + '.crm-chat-typing .crm-chat-typing-dots span:nth-child(3){animation-delay:.4s;}' 308 + '@keyframes crm-chat-blink{0%,80%,100%{opacity:.3;}40%{opacity:1;}}'
309 // ---- Responsive (mobile-first messenger feel) ------------------------- 310 + '@media (max-width:480px){' 311 + '.crm-chat-launcher{bottom:16px;right:16px;}' 312 + '.crm-chat-teaser{display:none;}' 313 + '.crm-chat-panel{top:0;left:0;right:0;bottom:0;width:100vw;max-width:100vw;height:100vh;height:100dvh;max-height:none;border-radius:0;animation:none;}' 314 + '.crm-chat-body{padding:14px 12px;gap:11px;}' 315 + '.crm-chat-msg{max-width:84%;font-size:15px;}' 316 + '.crm-chat-flow-options button,.crm-chat-quick-replies button{padding:9px 16px;font-size:14px;}' 317 + '.crm-chat-compose textarea{height:46px;font-size:16px;}' 318 + '.crm-chat-compose .crm-chat-send{width:46px;height:46px;}' 319 + '}'; 320 var style = document.createElement('style'); 321 style.id = 'crm-chat-widget-styles'; 322 style.textContent = css; 323 document.head.appendChild(style); 324 } 325 326 function buildLauncher(state) { 327 var btn = el('button', { 'class': 'crm-chat-launcher', 'aria-label': 'Open chat', 'data-testid': 'button-chat-launcher', html: ICONS.launcher }); 328 var badge = el('span', { 'class': 'crm-chat-badge', 'data-testid': 'badge-unread-count', 'aria-hidden': 'true', style: { display: 'none' } }); 329 btn.appendChild(badge); 330 btn._badge = badge; 331 state._badge = badge; 332 btn.addEventListener('click', function () { state.open(); }); 333 return btn; 334 } 335 336 // Show the unread-count chip on the launcher when there are unseen incoming 337 // messages and the panel is closed. Hidden when count is 0, the panel is 338 // open, or the widget is disabled. 339 function updateBadge(state) { 340 if (!state || !state._badge) return; 341 var count = (state.session && state.session.unreadCount) || 0; 342 var disabled = state.serverDefaults && state.serverDefaults.isEnabled === false; 343 if (count > 0 && !state.isOpen && !disabled) { 344 state._badge.textContent = count > 99 ? '99+' : String(count); 345 state._badge.setAttribute('aria-label', count + ' unread messages'); 346 state._badge.style.display = 'flex'; 347 } else { 348 state._badge.style.display = 'none'; 349 } 350 } 351 352 function buildTeaser(state) { 353 var teaser = el('div', { 'class': 'crm-chat-teaser', 'data-testid': 'chat-teaser', role: 'button', tabindex: '0', style: { display: 'none' } }); 354 var textEl = el('span', { 'class': 'crm-chat-teaser-text', 'data-testid': 'text-chat-teaser' }); 355 var closeBtn = el('button', { 'class': 'crm-chat-teaser-close', 'aria-label': 'Dismiss', 'data-testid': 'button-teaser-close', text: '\u00D7' }); 356 closeBtn.addEventListener('click', function (e) { 357 e.stopPropagation(); 358 state._teaserDismissed = true; 359 teaser.style.display = 'none'; 360 }); 361 teaser.appendChild(textEl); 362 teaser.appendChild(closeBtn); 363 teaser.addEventListener('click', function () { state.open(); }); 364 teaser._textEl = textEl; 365 return teaser; 366 } 367 368 // Show the teaser bubble beside the launcher when configured, the chat is 369 // closed, the widget is enabled, and the visitor has not dismissed it. 370 function updateTeaser(state) { 371 if (!state._teaser) return; 372 var theme = state.serverDefaults && state.serverDefaults.theme; 373 var text = theme && theme.teaserMessage; 374 var disabled = state.serverDefaults && state.serverDefaults.isEnabled === false; 375 if (text && !state.isOpen && !state._teaserDismissed && !disabled) { 376 state._teaser._textEl.textContent = text; 377 state._teaser.style.display = 'block'; 378 } else { 379 state._teaser.style.display = 'none'; 380 } 381 } 382 383 function escapeHtml(s) { 384 return String(s == null ? '' : s) 385 .replace(/&/g, '&') 386 .replace(/</g, '<') 387 .replace(/>/g, '>') 388 .replace(/"/g, '"') 389 .replace(/'/g, '''); 390 } 391 392 // Render a safe subset of markdown (bold, italic, line breaks) for bot/agent 393 // messages. HTML is escaped first, so the markdown conversion is XSS-safe. 394 function renderRichText(text) { 395 var html = escapeHtml(text); 396 html = html.replace(/\*\*\*([\s\S]+?)\*\*\*/g, '<strong><em>$1</em></strong>'); 397 html = html.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>'); 398 html = html.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>'); 399 html = html.replace(/\r\n|\r|\n/g, '<br>'); 400 return html; 401 } 402 403 // Build the small circular avatar that sits beside bot/human message rows. 404 // Uses the configured agent avatar image when present, otherwise an icon. 405 function makeMsgAvatar(state, kind) { 406 var theme = state && state.serverDefaults && state.serverDefaults.theme; 407 var url = theme && theme.agentAvatarUrl; 408 var av = el('div', { 'class': 'crm-chat-avatar crm-chat-avatar-' + kind }); 409 if (kind === 'bot' && url) { 410 av.appendChild(el('img', { src: url, alt: '' })); 411 } else { 412 av.innerHTML = kind === 'agent' ? ICONS.human : ICONS.bot; 413 } 414 return av; 415 } 416 417 // Wrap a bubble in a row with the proper alignment + (optional) avatar, then 418 // append it to the message list. `.crm-chat-msg` stays the bubble element so 419 // existing selectors/tests keep working. 420 function appendBubbleRow(state, bodyEl, kind, bubble) { 421 var rowKind = (kind === 'visitor' || kind === 'option-selected') ? 'visitor' : kind; 422 var row = el('div', { 'class': 'crm-chat-row ' + rowKind }); 423 if (kind === 'bot' || kind === 'agent') { 424 row.appendChild(makeMsgAvatar(state, kind)); 425 } 426 row.appendChild(bubble); 427 bodyEl.appendChild(row); 428 bodyEl.scrollTop = bodyEl.scrollHeight; 429 return row; 430 } 431 432 function addBotBubble(bodyEl, text, options, state) { 433 var wrap = el('div', { 'class': 'crm-chat-msg bot' }); 434 var t = document.createElement('div'); 435 t.innerHTML = renderRichText(text); 436 wrap.appendChild(t); 437 438 if (options && options.length > 0) { 439 var optionsWrap = el('div', { 'class': 'crm-chat-flow-options' }); 440 wrap._flowOptions = optionsWrap; 441 wrap.appendChild(optionsWrap); 442 } 443 444 appendBubbleRow(state || widgetState, bodyEl, 'bot', wrap); 445 return wrap; 446 } 447 448 function addVisitorBubble(bodyEl, text, state) { 449 var wrap = el('div', { 'class': 'crm-chat-msg visitor' }); 450 var t = document.createElement('div'); 451 t.textContent = text; 452 wrap.appendChild(t); 453 appendBubbleRow(state || widgetState, bodyEl, 'visitor', wrap); 454 return wrap; 455 } 456 457 // Render a clickable content card (title / subtitle / bullets / CTA) from a 458 // message's metadata. Reusable component â backend can attach cards later. 459 function buildCard(card) { 460 if (!card || typeof card !== 'object') return null; 461 var wrap = el('div', { 'class': 'crm-chat-card', 'data-testid': 'chat-card' }); 462 if (card.imageUrl) { 463 wrap.appendChild(el('img', { 'class': 'crm-chat-card-img', src: card.imageUrl, alt: '' })); 464 } 465 var body = el('div', { 'class': 'crm-chat-card-body' }); 466 if (card.title) body.appendChild(el('div', { 'class': 'crm-chat-card-title', text: card.title })); 467 if (card.subtitle) body.appendChild(el('div', { 'class': 'crm-chat-card-sub', text: card.subtitle }));
468 if (Array.isArray(card.bullets) && card.bullets.length) { 469 var ul = el('ul', { 'class': 'crm-chat-card-bullets' }); 470 for (var i = 0; i < card.bullets.length; i++) { 471 ul.appendChild(el('li', { text: String(card.bullets[i]) })); 472 } 473 body.appendChild(ul); 474 } 475 var cta = card.cta || (card.ctaLabel ? { label: card.ctaLabel, url: card.ctaUrl } : null); 476 if (cta && cta.label) { 477 var ctaEl; 478 if (cta.action === 'cadastro') { 479 // Reopen the visitor's CURRENT page with ?cadastro=1 added (merging into 480 // any existing query params) so the host site's registration popup opens 481 // wherever the visitor is. 482 ctaEl = el('button', { 'class': 'crm-chat-card-cta', type: 'button', 'data-testid': 'button-card-cta' }); 483 ctaEl.addEventListener('click', function () { 484 try { 485 var u = new URL(window.location.href); 486 u.searchParams.set('cadastro', '1'); 487 window.location.href = u.toString(); 488 } catch (e) { 489 window.location.href = window.location.pathname + '?cadastro=1'; 490 } 491 }); 492 } else if (cta.url) { 493 ctaEl = el('a', { 'class': 'crm-chat-card-cta', href: cta.url, target: '_blank', rel: 'noopener noreferrer', 'data-testid': 'button-card-cta' }); 494 } else { 495 ctaEl = el('button', { 'class': 'crm-chat-card-cta', type: 'button', 'data-testid': 'button-card-cta' }); 496 } 497 ctaEl.appendChild(document.createTextNode(cta.label)); 498 var arrow = el('span', { 'class': 'crm-chat-card-arrow', html: ICONS.arrow }); 499 ctaEl.appendChild(arrow); 500 body.appendChild(ctaEl); 501 } 502 wrap.appendChild(body); 503 return wrap; 504 } 505 506 var IDENTITY_STRINGS = { 507 pt: { 508 name: { question: 'Qual é o seu nome?', placeholder: 'Digite seu nome...', error: 'Por favor, informe seu nome.' }, 509 email: { question: 'Qual é o seu e-mail?', placeholder: 'Digite seu e-mail...', error: 'Por favor, informe um e-mail válido.' }, 510 phone: { question: 'Qual é o seu telefone?', placeholder: 'Digite seu telefone...', error: 'Por favor, informe seu telefone.' }, 511 company: { question: 'De qual empresa você é?', placeholder: 'Digite sua empresa...', error: 'Por favor, informe sua empresa.' }, 512 role: { question: 'Qual é o seu cargo?', placeholder: 'Digite seu cargo...', error: 'Por favor, informe seu cargo.' }, 513 }, 514 en: { 515 name: { question: 'What is your name?', placeholder: 'Enter your name...', error: 'Please enter your name.' }, 516 email: { question: 'What is your email address?', placeholder: 'Enter your email...', error: 'Please enter a valid email address.' }, 517 phone: { question: 'What is your phone number?', placeholder: 'Enter your phone number...', error: 'Please enter your phone number.' }, 518 company: { question: 'What company are you with?', placeholder: 'Enter your company...', error: 'Please enter your company.' }, 519 role: { question: 'What is your role?', placeholder: 'Enter your role...', error: 'Please enter your role.' }, 520 }, 521 es: { 522 name: { question: '¿Cuál es tu nombre?', placeholder: 'Escribe tu nombre...', error: 'Por favor, ingresa tu nombre.' }, 523 email: { question: '¿Cuál es tu correo electrónico?', placeholder: 'Escribe tu correo...', error: 'Por favor, ingresa un correo válido.' }, 524 phone: { question: '¿Cuál es tu teléfono?', placeholder: 'Escribe tu teléfono...', error: 'Por favor, ingresa tu teléfono.' }, 525 company: { question: '¿De qué empresa eres?', placeholder: 'Escribe tu empresa...', error: 'Por favor, ingresa tu empresa.' }, 526 role: { question: '¿Cuál es tu cargo?', placeholder: 'Escribe tu cargo...', error: 'Por favor, ingresa tu cargo.' }, 527 }, 528 }; 529 530 function resolveIdentityLang(serverDefaults) { 531 var lang = (serverDefaults && serverDefaults.agentLanguage) || 'pt'; 532 if (lang === 'auto') { 533 var nav = (typeof navigator !== 'undefined' && (navigator.language || (navigator.languages && navigator.languages[0]))) || 'pt'; 534 nav = String(nav).toLowerCase(); 535 if (nav.indexOf('en') === 0) lang = 'en'; 536 else if (nav.indexOf('es') === 0) lang = 'es'; 537 else lang = 'pt'; 538 } 539 return IDENTITY_STRINGS[lang] ? lang : 'pt'; 540 } 541 542 function buildIdentityQueue(serverDefaults) { 543 var queue = []; 544 var strings = IDENTITY_STRINGS[resolveIdentityLang(serverDefaults)]; 545 if (serverDefaults && serverDefaults.requireName) { 546 queue.push({ field: 'name', question: strings.name.question, placeholder: strings.name.placeholder, validate: function (v) { return v.length > 0 ? null : strings.name.error; } }); 547 } 548 if (serverDefaults && serverDefaults.requireEmail) { 549 queue.push({ field: 'email', question: strings.email.question, placeholder: strings.email.placeholder, validate: function (v) { return isValidEmail(v) ? null : strings.email.error; } }); 550 } 551 if (serverDefaults && serverDefaults.requirePhone) { 552 queue.push({ field: 'phone', question: strings.phone.question, placeholder: strings.phone.placeholder, validate: function (v) { return v.length > 0 ? null : strings.phone.error; } }); 553 } 554 if (serverDefaults && serverDefaults.requireCompany) { 555 queue.push({ field: 'company', question: strings.company.question, placeholder: strings.company.placeholder, validate: function (v) { return v.length > 0 ? null : strings.company.error; } }); 556 } 557 if (serverDefaults && serverDefaults.requireRole) { 558 queue.push({ field: 'role', question: strings.role.question, placeholder: strings.role.placeholder, validate: function (v) { return v.length > 0 ? null : strings.role.error; } }); 559 } 560 return queue; 561 } 562 563 function buildPanel(state) { 564 var closeBtn = el('button', { 'class': 'crm-chat-close', 'aria-label': 'Close chat', 'data-testid': 'button-chat-close', html: ICONS.close }); 565 closeBtn.addEventListener('click', function () { state.close(); }); 566 567 var headerAvatar = el('div', { 'class': 'crm-chat-header-av', 'data-testid': 'img-agent-avatar' }); 568 headerAvatar.innerHTML = ICONS.bot; 569 headerAvatar.appendChild(el('span', { 'class': 'crm-chat-presence' })); 570 571 var headerName = el('h3', { 'class': 'crm-chat-header-name', text: state.config.title || 'Chat with us', 'data-testid': 'text-chat-title' }); 572 var headerSub = el('p', { 'class': 'crm-chat-header-sub', 'data-testid': 'text-agent-subtitle', style: { display: 'none' } }); 573 574 var statusBanner = el('div', { 575 'class': 'crm-chat-status', 576 'data-testid': 'text-chat-status', 577 style: { display: 'none' }, 578 }); 579 580 var headerInfo = el('div', { 'class': 'crm-chat-header-info' }, [headerName, headerSub]); 581 var header = el('div', { 'class': 'crm-chat-header' }, [ 582 headerAvatar, 583 headerInfo, 584 closeBtn, 585 ]); 586 587 // Updates the header persona (name / subtitle / avatar) from server config. 588 function setPersona() { 589 var sd = state.serverDefaults || {}; 590 var theme = sd.theme || {}; 591 var name = theme.title || sd.publicAgentName || sd.chatName || state.config.title || 'Chat with us'; 592 headerName.textContent = name; 593 if (theme.agentSubtitle) { 594 headerSub.textContent = theme.agentSubtitle; 595 headerSub.style.display = 'block'; 596 } else { 597 headerSub.textContent = ''; 598 headerSub.style.display = 'none'; 599 } 600 if (theme.agentAvatarUrl) { 601 headerAvatar.innerHTML = ''; 602 headerAvatar.appendChild(el('img', { src: theme.agentAvatarUrl, alt: name })); 603 } else { 604 headerAvatar.innerHTML = ICONS.bot; 605 } 606 headerAvatar.appendChild(el('span', { 'class': 'crm-chat-presence' })); 607 } 608 609 var body = el('div', { 'class': 'crm-chat-body', 'data-testid': 'list-chat-messages' }); 610 var quickRepliesEl = el('div', { 'class': 'crm-chat-quick-replies', 'data-testid': 'list-chat-quick-replies', style: { display: 'none' } }); 611 var errorBox = el('div', { 'class': 'crm-chat-error-inline', 'data-testid': 'text-chat-error', style: { display: 'none' } }); 612 var msgInput = el('textarea', { placeholder: 'Type your message...', 'data-testid': 'input-chat-message', rows: '1' }); 613 var submitBtn = el('button', { type: 'button', 'class': 'crm-chat-send', 'aria-label': 'Send message', 'data-testid': 'button-chat-send', html: ICONS.send }); 614 615 var compose = el('div', { 'class': 'crm-chat-compose', 'data-testid': 'form-chat' }, [ 616 msgInput, submitBtn, 617 ]); 618 619 var panel = el('div', { 'class': 'crm-chat-panel', role: 'dialog', 'aria-label': 'Chat', 'data-testid': 'panel-chat' }, [ 620 header, statusBanner, body, quickRepliesEl, errorBox, compose, 621 ]); 622 623 var identityQueue = []; 624 var identityIndex = -1; 625 var collectedIdentity = {}; 626 627 function isCollectingIdentity() { 628 return identityIndex >= 0 && identityIndex < identityQueue.length; 629 } 630 631 function identityCollectionDone() { 632 return identityQueue.length === 0 || identityIndex >= identityQueue.length; 633 } 634 635 function startIdentityCollection() { 636 identityQueue = buildIdentityQueue(state.serverDefaults); 637 if (identityQueue.length === 0) { 638 identityIndex = 0; 639 return false;
640 } 641 identityIndex = 0; 642 askCurrentIdentityQuestion(); 643 return true; 644 } 645 646 function askCurrentIdentityQuestion() { 647 if (identityIndex >= identityQueue.length) return; 648 var item = identityQueue[identityIndex]; 649 addBotBubble(body, item.question, null, state); 650 msgInput.placeholder = item.placeholder; 651 msgInput.focus(); 652 } 653 654 function handleIdentityAnswer() { 655 var text = (msgInput.value || '').trim(); 656 if (!text) return; 657 var item = identityQueue[identityIndex]; 658 var validationError = item.validate(text); 659 if (validationError) { 660 showError(validationError); 661 return; 662 } 663 showError(''); 664 addVisitorBubble(body, text, state); 665 collectedIdentity[item.field] = text; 666 msgInput.value = ''; 667 identityIndex++; 668 669 if (!state._identityProvided) state._identityProvided = {}; 670 state._identityProvided[item.field] = true; 671 672 if (identityIndex < identityQueue.length) { 673 askCurrentIdentityQuestion(); 674 } else { 675 onIdentityComplete(); 676 } 677 } 678 679 function onIdentityComplete() { 680 state._collectedIdentity = collectedIdentity; 681 msgInput.placeholder = 'Type your message...'; 682 startServerFlow(); 683 } 684 685 function showError(msg) { 686 if (msg) { 687 errorBox.textContent = msg; 688 errorBox.style.display = 'block'; 689 } else { 690 errorBox.textContent = ''; 691 errorBox.style.display = 'none'; 692 } 693 } 694 695 function isFlowActive() { 696 var fs = state.session && state.session.flowState; 697 return fs && !fs.completed && fs.currentStepKey; 698 } 699 700 function isFlowOptionStep() { 701 if (!isFlowActive()) return false; 702 var fs = state.session.flowState; 703 var flow = (state.serverDefaults && state.serverDefaults.flow) || []; 704 for (var j = 0; j < flow.length; j++) { 705 if (flow[j].key === fs.currentStepKey) { 706 var step = flow[j]; 707 var st = step.type || (step.options && step.options.length > 0 ? 'options' : 'message'); 708 return st === 'options'; 709 } 710 } 711 return false; 712 } 713 714 function isFlowCompleted() { 715 var fs = state.session && state.session.flowState; 716 return fs && fs.completed; 717 } 718 719 function renderQuickReplies() { 720 var replies = (state.serverDefaults && state.serverDefaults.quickReplies) || []; 721 quickRepliesEl.innerHTML = ''; 722 var flowDone = !isFlowActive(); 723 if (!replies.length || !flowDone) { 724 quickRepliesEl.style.display = 'none'; 725 return; 726 } 727 for (var i = 0; i < replies.length; i++) { 728 (function (qr, idx) { 729 var btn = el('button', { type: 'button', 'data-testid': 'button-quick-reply-' + idx, text: qr.label }); 730 btn.addEventListener('click', function () { 731 msgInput.value = qr.body; 732 msgInput.focus(); 733 }); 734 quickRepliesEl.appendChild(btn); 735 })(replies[i], i); 736 } 737 quickRepliesEl.style.display = 'flex'; 738 } 739 740 // When the AI hands a conversation off to a human (status transitions into 741 // waiting_agent), render a one-time announcement with a short summary so the 742 // visitor sees continuity. Gated on aiEnabled so the scripted/no-AI flows 743 // (and the test suite) never show it. 744 function maybeAnnounceHandoff(status) { 745 var aiEnabled = state.serverDefaults && state.serverDefaults.aiEnabled; 746 if (!aiEnabled || state._handoffAnnounced) return; 747 if (status !== 'waiting_agent') return; 748 var prev = state._prevStatus; 749 // Require a real previous status that wasn't already waiting â i.e. an 750 // actual transition (open/AI -> waiting), not the first render. 751 if (!prev || prev === 'waiting_agent') return; 752 state._handoffAnnounced = true; 753 renderHandoffAnnouncement(); 754 } 755 756 function renderHandoffAnnouncement() { 757 var lang = resolveIdentityLang(state.serverDefaults); 758 var T = { 759 pt: { 760 title: 'Conectando voc\u00EA a um especialista', 761 body: 'Resumi sua conversa e encaminhei para a pessoa certa. Em instantes algu\u00E9m assume por aqui.', 762 eta: 'Tempo m\u00E9dio de resposta: poucos minutos', 763 summaryH: 'Resumo enviado', 764 need: 'Necessidade identificada', 765 company: 'Empresa informada', 766 contact: 'Contato informado', 767 }, 768 en: { 769 title: 'Connecting you to a specialist', 770 body: 'I\u2019ve summarized your conversation and routed it to the right person. Someone will take over shortly.', 771 eta: 'Average response time: a few minutes', 772 summaryH: 'Summary sent', 773 need: 'Need identified', 774 company: 'Company provided', 775 contact: 'Contact provided', 776 }, 777 es: { 778 title: 'Conect\u00E1ndote con un especialista', 779 body: 'Resum\u00ED tu conversaci\u00F3n y la envi\u00E9 a la persona correcta. Alguien continuar\u00E1 en breve.', 780 eta: 'Tiempo medio de respuesta: pocos minutos', 781 summaryH: 'Resumen enviado', 782 need: 'Necesidad identificada', 783 company: 'Empresa informada', 784 contact: 'Contacto informado', 785 }, 786 }; 787 var t = T[lang] || T.pt;
788 var provided = state._identityProvided || {}; 789 var wrap = el('div', { 'class': 'crm-chat-handoff', 'data-testid': 'chat-handoff' }); 790 wrap.appendChild(el('div', { 'class': 'crm-chat-handoff-title', text: t.title })); 791 wrap.appendChild(el('div', { 'class': 'crm-chat-handoff-body', text: t.body })); 792 wrap.appendChild(el('div', { 'class': 'crm-chat-handoff-eta', text: t.eta })); 793 794 var summary = el('div', { 'class': 'crm-chat-handoff-summary' }); 795 summary.appendChild(el('div', { 'class': 'crm-chat-handoff-summary-h', text: t.summaryH })); 796 var ul = el('ul'); 797 var items = [t.need]; 798 if (provided.company) items.push(t.company); 799 if (provided.email || provided.phone) items.push(t.contact); 800 for (var i = 0; i < items.length; i++) { 801 var li = el('li'); 802 li.appendChild(el('span', { html: ICONS.check })); 803 li.appendChild(document.createTextNode(items[i])); 804 ul.appendChild(li); 805 } 806 summary.appendChild(ul); 807 wrap.appendChild(summary); 808 809 body.appendChild(wrap); 810 body.scrollTop = body.scrollHeight; 811 } 812 813 // Friendly, messenger-style status copy. Each state has an icon + text. 814 // agent_active (named) MUST include the human's name (relied on by tests 815 // and by the visitor to know who they're talking to). 816 function statusBannerInfo(status) { 817 var lang = resolveIdentityLang(state.serverDefaults); 818 var name = state._lastAgentName; 819 var aiEnabled = state.serverDefaults && state.serverDefaults.aiEnabled; 820 var S = { 821 pt: { 822 ai: 'IA atendendo', 823 waiting: 'Aguardando especialista\u2026', 824 active: 'Especialista conectado', 825 activeNamed: function (n) { return n + ' conectado'; }, 826 closed: 'Atendimento encerrado', 827 }, 828 en: { 829 ai: 'AI assistant online', 830 waiting: 'Waiting for a specialist\u2026', 831 active: 'Specialist connected', 832 activeNamed: function (n) { return n + ' connected'; }, 833 closed: 'Conversation closed', 834 }, 835 es: { 836 ai: 'IA en atenci\u00F3n', 837 waiting: 'Esperando a un especialista\u2026', 838 active: 'Especialista conectado', 839 activeNamed: function (n) { return n + ' conectado'; }, 840 closed: 'Conversaci\u00F3n cerrada', 841 }, 842 }; 843 var t = S[lang] || S.pt; 844 if (status === 'agent_active') return { icon: '\uD83D\uDC64', text: name ? t.activeNamed(name) : t.active }; 845 if (status === 'waiting_agent') return null; 846 if (status === 'closed') return { icon: '\u2705', text: t.closed }; 847 if (status === 'open' && aiEnabled) return { icon: '\uD83E\uDD16', text: t.ai }; 848 return null; 849 } 850
851 function renderStatus() { 852 var status = state.session && state.session.conversationStatus; 853 maybeAnnounceHandoff(status); 854 var info = statusBannerInfo(status); 855 if (info) { 856 statusBanner.innerHTML = ''; 857 statusBanner.appendChild(el('span', { 'class': 'crm-chat-status-ico', text: info.icon })); 858 statusBanner.appendChild(el('span', { 'class': 'crm-chat-status-text', text: info.text })); 859 statusBanner.style.display = 'flex'; 860 statusBanner.setAttribute('data-status', status); 861 } else { 862 statusBanner.textContent = ''; 863 statusBanner.style.display = 'none'; 864 statusBanner.removeAttribute('data-status'); 865 } 866 state._prevStatus = status; 867 var isClosed = status === 'closed'; 868 var optionStep = isFlowOptionStep(); 869 if (isCollectingIdentity()) { 870 msgInput.disabled = false; 871 submitBtn.disabled = false; 872 var currentItem = identityQueue[identityIndex]; 873 msgInput.placeholder = currentItem ? currentItem.placeholder : 'Type your message...'; 874 compose.style.opacity = '1'; 875 } else if (optionStep) { 876 msgInput.disabled = true; 877 submitBtn.disabled = true; 878 msgInput.placeholder = 'Please select an option above\u2026'; 879 compose.style.opacity = '0.5'; 880 } else { 881 msgInput.disabled = false; 882 submitBtn.disabled = false; 883 msgInput.placeholder = isClosed ? 'Send a message to start a new chat\u2026' : 'Type your message...'; 884 compose.style.opacity = '1'; 885 } 886 } 887 888 function disableAllFlowOptionButtons() { 889 var allBtns = body.querySelectorAll('.crm-chat-flow-options button'); 890 for (var i = 0; i < allBtns.length; i++) { 891 allBtns[i].disabled = true; 892 } 893 } 894 895 function selectFlowOption(conversationId, optionKey) { 896 disableAllFlowOptionButtons(); 897 var url = state.config.apiBaseUrl + '/api/v1/chat/conversations/' + encodeURIComponent(conversationId) + '/flow/select'; 898 url = appendQuery(url, 'chatKey', state.config.chatKey); 899 fetch(url, { 900 method: 'POST', 901 headers: buildHeaders(state), 902 body: JSON.stringify({ selectedOptionKey: optionKey }), 903 }) 904 .then(function (res) { 905 if (!res.ok) return res.text().then(function (t) { throw new Error(t || res.status); }); 906 return res.json(); 907 }) 908 .then(function (data) { 909 if (data.conversation) { 910 if (data.conversation.flowState) { 911 state.session.flowState = data.conversation.flowState; 912 } 913 if (data.conversation.status) { 914 state.session.conversationStatus = data.conversation.status; 915 } 916 } 917 if (data.flowCompleted) { 918 state.session.flowState = state.session.flowState || {}; 919 state.session.flowState.completed = true; 920 } 921 saveSession(state.storageKey, state.session); 922 923 if (data.selectionMessage) { 924 renderMessages(state, [data.selectionMessage]); 925 state._lastMessageAt = data.selectionMessage.createdAt; 926 } 927 if (data.nextBotMessage) { 928 renderMessages(state, [data.nextBotMessage]); 929 state._lastMessageAt = data.nextBotMessage.createdAt; 930 } 931 932 renderStatus(); 933 if (data.flowCompleted) { 934 renderQuickReplies(); 935 } 936 }) 937 .catch(function (err) { 938 showError(err && err.message ? err.message : 'Failed to select option'); 939 }); 940 } 941 942 function startServerFlow() { 943 if (state.session && state.session.conversationId) return; 944 945 // AI mode is authoritative by default: when the embed answers with AI, 946 // free-text goes straight to the AI agent and the scripted flow never 947 // starts. flowMode='always' keeps the scripted flow authoritative even 948 // when AI is enabled, matching the backend authority decision. 949 var aiEnabled = state.serverDefaults && state.serverDefaults.aiEnabled; 950 var flowMode = (state.serverDefaults && state.serverDefaults.flowMode) || 'ignore_when_ai'; 951 var flowAuthoritative = !aiEnabled || flowMode === 'always'; 952 var hasFlow = flowAuthoritative && state.serverDefaults && Array.isArray(state.serverDefaults.flow) && state.serverDefaults.flow.length > 0; 953 if (!hasFlow) { 954 var welcomeText = state.config.welcomeMessage || (state.serverDefaults && state.serverDefaults.welcomeMessage) || 'Hi! Send us a message and a teammate will reply shortly.'; 955 addBotBubble(body, welcomeText, null, state); 956 state._welcomeShown = true; 957 renderQuickReplies(); 958 renderStatus(); 959 return; 960 } 961 962 var url = state.config.apiBaseUrl + '/api/v1/chat/conversations'; 963 var startPayload = addGoogleClickIds({ 964 chatKey: state.config.chatKey || undefined, 965 startFlow: true, 966 pageUrl: location.href, 967 pageTitle: document.title, 968 clientIdGa4: readGa4ClientId() || undefined, 969 }); 970 if (state._collectedIdentity) { 971 if (state._collectedIdentity.name) startPayload.visitorName = state._collectedIdentity.name; 972 if (state._collectedIdentity.email) startPayload.visitorEmail = state._collectedIdentity.email; 973 if (state._collectedIdentity.phone) startPayload.visitorPhone = state._collectedIdentity.phone; 974 if (state._collectedIdentity.company) startPayload.visitorCompany = state._collectedIdentity.company; 975 if (state._collectedIdentity.role) startPayload.visitorRole = state._collectedIdentity.role; 976 } 977 fetch(url, { 978 method: 'POST', 979 headers: buildHeaders(state), 980 body: JSON.stringify(startPayload), 981 }) 982 .then(function (res) { 983 if (!res.ok) return res.text().then(function (t) { throw new Error(t || res.status); }); 984 return res.json(); 985 }) 986 .then(function (data) { 987 if (data.sessionToken) { 988 state.session.sessionToken = data.sessionToken; 989 } 990 if (data.visitorId) { 991 state.session.visitorId = data.visitorId; 992 } 993 if (data.conversation) { 994 state.session.conversationId = data.conversation.id; 995 state.session.conversationStatus = data.conversation.status || 'open'; 996 state.session.flowState = data.conversation.flowState || null; 997 } 998 saveSession(state.storageKey, state.session); 999 1000 if (data.messages && data.messages.length) { 1001 renderMessages(state, data.messages); 1002 state._lastMessageAt = data.messages[data.messages.length - 1].createdAt; 1003 } 1004 if (data.settings) { 1005 applyServerDefaults(state, data.settings); 1006 } 1007 renderStatus(); 1008 startPolling(state); 1009 }) 1010 .catch(function (err) { 1011 warn('startServerFlow error:', err); 1012 var welcomeText = state.config.welcomeMessage || (state.serverDefaults && state.serverDefaults.welcomeMessage) || 'Hi! Send us a message and a teammate will reply shortly.'; 1013 addBotBubble(body, welcomeText, null, state); 1014 state._welcomeShown = true; 1015 }); 1016 } 1017 1018 function beginIdentityOrFlow() { 1019 if (state.session && state.session.conversationId) return; 1020 // captureMode 'during' with AI on: skip the up-front questionnaire and let 1021 // the AI agent collect identity naturally in the flow of conversation. 1022 var captureMode = (state.serverDefaults && state.serverDefaults.captureMode) || 'during'; 1023 var aiEnabled = state.serverDefaults && state.serverDefaults.aiEnabled; 1024 if (captureMode === 'during' && aiEnabled) { 1025 startServerFlow(); 1026 return; 1027 } 1028 var collecting = startIdentityCollection(); 1029 if (!collecting) { 1030 startServerFlow(); 1031 } 1032 } 1033 1034 function handleSend() { 1035 if (isCollectingIdentity()) { 1036 handleIdentityAnswer(); 1037 return; 1038 } 1039 if (isFlowOptionStep()) return; 1040 var text = (msgInput.value || '').trim(); 1041 if (!text) return; 1042 1043 var sendInput = { body: text }; 1044 if (state._collectedIdentity) { 1045 if (state._collectedIdentity.name) sendInput.visitorName = state._collectedIdentity.name; 1046 if (state._collectedIdentity.email) sendInput.visitorEmail = state._collectedIdentity.email; 1047 if (state._collectedIdentity.phone) sendInput.visitorPhone = state._collectedIdentity.phone; 1048 if (state._collectedIdentity.company) sendInput.visitorCompany = state._collectedIdentity.company; 1049 if (state._collectedIdentity.role) sendInput.visitorRole = state._collectedIdentity.role; 1050 } 1051 1052 msgInput.value = ''; 1053 showError(''); 1054 1055 // Draw the visitor's own bubble immediately so the chat feels instant; it 1056 // is reconciled with the server's persisted message when the POST returns. 1057 var optimisticTempId = renderOptimisticVisitorMessage(state, text); 1058 1059 submitBtn.disabled = true; 1060 1061 state.sendMessage(sendInput) 1062 .then(function (data) { 1063 if (state._collectedIdentity) { 1064 state._collectedIdentity = null; 1065 } 1066 if (data && data.nextBotMessage) { 1067 renderMessages(state, [data.nextBotMessage]); 1068 state._lastMessageAt = data.nextBotMessage.createdAt; 1069 } 1070 if (data && data.conversation && data.conversation.flowState) { 1071 state.session.flowState = data.conversation.flowState; 1072 saveSession(state.storageKey, state.session); 1073 } 1074 if (data && data.flowCompleted) { 1075 state.session.flowState = state.session.flowState || {}; 1076 state.session.flowState.completed = true; 1077 saveSession(state.storageKey, state.session); 1078 renderQuickReplies(); 1079 } 1080 renderStatus(); 1081 }) 1082 .catch(function (err) { 1083 markOptimisticFailed(state, optimisticTempId); 1084 showError(err && err.message ? err.message : 'Failed to send'); 1085 }) 1086 .then(function () { submitBtn.disabled = false; }); 1087 } 1088 1089 submitBtn.addEventListener('click', handleSend); 1090 msgInput.addEventListener('keydown', function (e) { 1091 if (e.key === 'Enter' && !e.shiftKey) { 1092 e.preventDefault(); 1093 handleSend(); 1094 } 1095 }); 1096 1097 state._dom = { 1098 panel: panel, 1099 body: body, 1100 compose: compose, 1101 msgInput: msgInput, 1102 errorBox: errorBox, 1103 renderQuickReplies: renderQuickReplies,
1104 renderStatus: renderStatus, 1105 beginIdentityOrFlow: beginIdentityOrFlow, 1106 startServerFlow: startServerFlow, 1107 selectFlowOption: selectFlowOption, 1108 setPersona: setPersona, 1109 setTitle: function (t) { if (t) headerName.textContent = t; }, 1110 }; 1111 1112 setPersona(); 1113 1114 renderStatus(); 1115 return panel; 1116 } 1117 1118 function messageKinds(m) { 1119 var msgType = m.messageType || 'text'; 1120 return { 1121 isBot: msgType === 'bot' || m.sender === 'bot', 1122 isOption: msgType === 'option_selected', 1123 isVisitor: m.sender === 'visitor', 1124 }; 1125 } 1126 1127 // Build and append the DOM for a single message bubble. This is the actual 1128 // render; renderMessages decides whether to call it instantly or after a 1129 // simulated typing pause. 1130 function renderSingleMessage(state, m) { 1131 var bodyEl = state._dom && state._dom.body; 1132 if (!bodyEl) return; 1133 var emptyEl = bodyEl.querySelector('.crm-chat-empty'); 1134 if (emptyEl) emptyEl.remove(); 1135 1136 var kinds = messageKinds(m); 1137 var isBotMsg = kinds.isBot; 1138 var isOptionMsg = kinds.isOption; 1139 1140 var cls = 'crm-chat-msg '; 1141 if (isBotMsg) { 1142 cls += 'bot'; 1143 } else if (isOptionMsg) { 1144 cls += 'option-selected'; 1145 } else if (m.sender === 'visitor') { 1146 cls += 'visitor'; 1147 } else { 1148 cls += 'agent'; 1149 } 1150 1151 var wrap = el('div', { 'class': cls, 'data-testid': 'message-' + m.id }); 1152 var bodyText = document.createElement('div'); 1153 bodyText.innerHTML = renderRichText(m.body); 1154 wrap.appendChild(bodyText); 1155 1156 if (isBotMsg && m.metadata && m.metadata.options && Array.isArray(m.metadata.options) && m.metadata.options.length > 0) { 1157 var flowState = state.session && state.session.flowState; 1158 var isCurrentStep = flowState && !flowState.completed && m.metadata.stepKey === flowState.currentStepKey; 1159 if (isCurrentStep) { 1160 var optionsWrap = el('div', { 'class': 'crm-chat-flow-options' }); 1161 for (var j = 0; j < m.metadata.options.length; j++) { 1162 (function (opt) { 1163 var btn = el('button', { 1164 type: 'button', 1165 'data-testid': 'button-flow-option-' + opt.key, 1166 text: opt.label, 1167 }); 1168 btn.addEventListener('click', function () { 1169 var buttons = optionsWrap.querySelectorAll('button'); 1170 for (var b = 0; b < buttons.length; b++) buttons[b].disabled = true; 1171 if (state.session && state.session.conversationId && state._dom && state._dom.selectFlowOption) { 1172 state._dom.selectFlowOption(state.session.conversationId, opt.key); 1173 } 1174 }); 1175 optionsWrap.appendChild(btn); 1176 })(m.metadata.options[j]); 1177 } 1178 wrap.appendChild(optionsWrap); 1179 } 1180 } 1181 1182 // Optional content card(s) attached to a message via metadata. 1183 if (m.metadata) { 1184 var cards = m.metadata.cards || (m.metadata.card ? [m.metadata.card] : null); 1185 if (Array.isArray(cards)) { 1186 for (var ci = 0; ci < cards.length; ci++) { 1187 var cardEl = buildCard(cards[ci]); 1188 if (cardEl) wrap.appendChild(cardEl); 1189 } 1190 } 1191 } 1192 1193 var prevAgentName = state._lastAgentName || null; 1194 var isHuman = !isBotMsg && !isOptionMsg && m.sender !== 'visitor'; 1195 // Track the name of the most recent human agent so the takeover banner 1196 // can show "você está falando com <nome>". 1197 if (isHuman && m.authorDisplayName) { 1198 state._lastAgentName = m.authorDisplayName; 1199 } 1200 1201 var agentName = (state.serverDefaults && state.serverDefaults.publicAgentName) || 'Assistente'; 1202 var meta = document.createElement('span'); 1203 meta.className = 'crm-chat-meta'; 1204 var time = formatTime(m.createdAt); 1205 if (isHuman) { 1206 // Human agent: show name (+ optional role) prominently, time muted. 1207 var who = m.authorDisplayName || 'Support'; 1208 if (m.authorRole) who += ' \u00B7 ' + m.authorRole; 1209 meta.appendChild(document.createTextNode(who + ' ')); 1210 var timeEl = el('span', { 'class': 'crm-chat-meta-time', text: '\u00B7 ' + time }); 1211 meta.appendChild(timeEl); 1212 } else { 1213 var label = isBotMsg ? agentName : 'You'; 1214 meta.textContent = label + ' \u00B7 ' + time; 1215 } 1216 wrap.appendChild(meta); 1217 1218 var kindForRow = isBotMsg ? 'bot' : isOptionMsg ? 'option-selected' : (m.sender === 'visitor' ? 'visitor' : 'agent'); 1219 appendBubbleRow(state, bodyEl, kindForRow, wrap); 1220 1221 // If a human agent's name was learned (e.g. takeover happened with
1221no status 1222 // change in this poll), refresh the status banner so the "você está falando 1223 // com <nome>" cue appears and the attendant name switches from the AI name. 1224 if (state._lastAgentName && state._lastAgentName !== prevAgentName 1225 && state._dom && state._dom.renderStatus) { 1226 state._dom.renderStatus(); 1227 } 1228 } 1229 1230 // The label shown in the typing bubble: the public AI name for bot messages, 1231 // or the human SDR's display name once a person is involved. 1232 function typingLabelFor(state, m) { 1233 var kinds = messageKinds(m); 1234 if (kinds.isBot) { 1235 return (state.serverDefaults && state.serverDefaults.publicAgentName) || 'Assistente'; 1236 } 1237 return m.authorDisplayName || state._lastAgentName || 'Atendente'; 1238 } 1239 1240 function showTypingIndicator(state, label, isHuman) { 1241 var bodyEl = state._dom && state._dom.body; 1242 if (!bodyEl) return; 1243 removeTypingIndicator(state); 1244 var lang = resolveIdentityLang(state.serverDefaults); 1245 var suffix = TYPING_SUFFIX[lang] || TYPING_SUFFIX.pt; 1246 var typingEl = el('div', { 'class': 'crm-chat-typing', 'data-testid': 'typing-indicator' }); 1247 var labelEl = document.createElement('span'); 1248 labelEl.className = 'crm-chat-typing-label'; 1249 labelEl.textContent = label + suffix; 1250 typingEl.appendChild(labelEl); 1251 var dots = el('span', { 'class': 'crm-chat-typing-dots' }); 1252 dots.appendChild(document.createElement('span')); 1253 dots.appendChild(document.createElement('span')); 1254 dots.appendChild(document.createElement('span')); 1255 typingEl.appendChild(dots); 1256 // Wrap with an avatar so the typing bubble mirrors the message layout. 1257 var row = el('div', { 'class': 'crm-chat-row ' + (isHuman ? 'agent' : 'bot') }); 1258 row.appendChild(makeMsgAvatar(state, isHuman ? 'agent' : 'bot')); 1259 row.appendChild(typingEl); 1260 bodyEl.appendChild(row); 1261 bodyEl.scrollTop = bodyEl.scrollHeight; 1262 state._typingEl = row; 1263 } 1264 1265 function removeTypingIndicator(state) { 1266 if (state._typingEl && state._typingEl.parentNode) { 1267 state._typingEl.parentNode.removeChild(state._typingEl); 1268 } 1269 state._typingEl = null; 1270 } 1271 1272 // Break a bot reply body into natural-feeling chunks so a long answer arrives 1273 // as several short bubbles instead of one wall of text. Prefers paragraph 1274 // breaks, then sentence breaks, and keeps very short segments merged together. 1275 // A short overall reply is returned as a single chunk (no artificial split). 1276 // The original markdown (bold/italic/line breaks) is preserved inside each 1277 // chunk because we only cut on paragraph/sentence boundaries. 1278 function splitBotMessageBody(text) { 1279 var trimmed = (text || '').trim(); 1280 if (trimmed.length <= CHUNK_SINGLE_MAX) return [text]; 1281 1282 var paragraphs = trimmed.split(/\n{2,}/); 1283 var segments = []; 1284 for (var p = 0; p < paragraphs.length; p++) { 1285 var para = paragraphs[p].trim(); 1286 if (!para) continue; 1287 if (para.length <= CHUNK_MAX_CHARS) { 1288 segments.push(para); 1289 } else { 1290 var sentences = para.match(/[^.!?]+[.!?]+(?:\s+|$)|[^.!?]+$/g) || [para]; 1291 for (var s = 0; s < sentences.length; s++) { 1292 var sentence = sentences[s].trim(); 1293 if (sentence) segments.push(sentence); 1294 } 1295 } 1296 } 1297 1298 // Greedily merge tiny leading segments so no bubble is trivially short. 1299 var chunks = []; 1300 var buf = ''; 1301 for (var i = 0; i < segments.length; i++) { 1302 var seg = segments[i]; 1303 if (!buf) { buf = seg; continue; } 1304 if (buf.length < CHUNK_MIN_CHARS) { 1305 buf = buf + ' ' + seg; 1306 } else { 1307 chunks.push(buf); 1308 buf = seg; 1309 } 1310 } 1311 if (buf) chunks.push(buf); 1312 return chunks.length ? chunks : [text]; 1313 } 1314 1315 // Enqueue a bot reply for display, splitting a long body into several typed-out 1316 // bubbles. Flow-option buttons and cards (metadata) ride only on the last 1317 // chunk so they are never duplicated across bubbles. Every chunk maps to the 1318 // same server message id in _renderedIds (marked upstream in renderMessages), 1319 // but gets a unique DOM id so polling never redraws or duplicates it. 1320 function enqueueBotMessageChunks(state, m) { 1321 var chunks = splitBotMessageBody(m.body); 1322 if (chunks.length <= 1) { 1323 enqueueTypingMessage(state, m); 1324 return; 1325 } 1326 for (var i = 0; i < chunks.length; i++) { 1327 var isLast = i === chunks.length - 1; 1328 enqueueTypingMessage(state, { 1329 id: m.id + '#c' + i, 1330 sender: m.sender, 1331 messageType: m.messageType, 1332 body: chunks[i], 1333 createdAt: m.createdAt, 1334 authorDisplayName: m.authorDisplayName, 1335 authorRole: m.authorRole, 1336 metadata: isLast ? m.metadata : undefined, 1337 }); 1338 } 1339 } 1340 1341 // Queue a message to be revealed after a brief simulated "digitandoâ¦" pause. 1342 // Messages are processed one at a time so consecutive bot/human replies show 1343 // a typing indicator between each block. 1344 function enqueueTypingMessage(state, m) { 1345 state._typingQueue = state._typingQueue || []; 1346 state._typingQueue.push(m); 1347 processTypingQueue(state); 1348 } 1349 1350 // A typing pause that scales a little with the chunk length (longer text = 1351 // longer "typing"), clamped to the existing MIN/MAX window with light jitter. 1352 function typingDelayForBody(body) { 1353 var len = (body || '').length;
1354 var delay = TYPING_MIN_MS + len * TYPING_MS_PER_CHAR + Math.floor(Math.random() * 150); 1355 if (delay < TYPING_MIN_MS) delay = TYPING_MIN_MS; 1356 if (delay > TYPING_MAX_MS) delay = TYPING_MAX_MS; 1357 return delay; 1358 } 1359 1360 function processTypingQueue(state) { 1361 if (state._typingActive) return; 1362 var queue = state._typingQueue || []; 1363 if (queue.length === 0) return; 1364 state._typingActive = true; 1365 var m = queue.shift(); 1366 var k = messageKinds(m); 1367 var humanTyping = !k.isBot && !k.isOption && m.sender !== 'visitor'; 1368 showTypingIndicator(state, typingLabelFor(state, m), humanTyping); 1369 var delay = typingDelayForBody(m.body); 1370 setTimeout(function () { 1371 removeTypingIndicator(state); 1372 renderSingleMessage(state, m); 1373 state._typingActive = false; 1374 processTypingQueue(state); 1375 }, delay); 1376 } 1377 1378 // Render the visitor's own message immediately on send (before the server 1379 // round-trip) so the chat feels instant. The bubble is tagged with a temp id 1380 // that is later reconciled with the server's persisted message. 1381 function renderOptimisticVisitorMessage(state, text) { 1382 var tempId = 'temp-' + Date.now() + '-' + Math.random().toString(36).slice(2); 1383 // Render the bubble first, then register the pending entry â otherwise the 1384 // reconcile pass inside renderMessages would match the optimistic message 1385 // against its own pending record and skip drawing it. 1386 renderMessages(state, [{ 1387 id: tempId, 1388 sender: 'visitor', 1389 messageType: 'text', 1390 body: text, 1391 createdAt: new Date().toISOString(), 1392 }], { instant: true }); 1393 state._pendingOptimistic = state._pendingOptimistic || []; 1394 state._pendingOptimistic.push({ tempId: tempId, body: text }); 1395 return tempId; 1396 } 1397 1398 // When the server returns a visitor message that matches an optimistic bubble, 1399 // swap the temp node over to the real id (and timestamp) instead of drawing a 1400 // second bubble. Returns true when the incoming message was reconciled. 1401 function reconcileOptimisticMessage(state, m) { 1402 if (!m || m.sender !== 'visitor') return false; 1403 var pending = state._pendingOptimistic; 1404 if (!pending || !pending.length) return false; 1405 for (var i = 0; i < pending.length; i++) { 1406 if (pending[i].body !== m.body) continue; 1407 var tempId = pending[i].tempId; 1408 var bodyEl = state._dom && state._dom.body; 1409 var node = bodyEl && bodyEl.querySelector('[data-testid="message-' + tempId + '"]'); 1410 if (node) { 1411 node.setAttribute('data-testid', 'message-' + m.id); 1412 node.classList.remove('crm-chat-msg-failed'); 1413 var failedNote = node.querySelector('.crm-chat-msg-failed-note'); 1414 if (failedNote) failedNote.parentNode.removeChild(failedNote); 1415 } 1416 state._renderedIds = state._renderedIds || {}; 1417 state._renderedIds[m.id] = true; 1418 if (state._renderedIds[tempId]) delete state._renderedIds[tempId]; 1419 pending.splice(i, 1); 1420 return true; 1421 } 1422 return false; 1423 } 1424 1425 // Mark an optimistic bubble as failed when the send request errors out, so a 1426 // real failure stays visually distinct from a delivered message. 1427 function markOptimisticFailed(state, tempId) { 1428 var pending = state._pendingOptimistic; 1429 if (pending) { 1430 for (var i = 0; i < pending.length; i++) { 1431 if (pending[i].tempId === tempId) { pending.splice(i, 1); break; } 1432 } 1433 } 1434 var bodyEl = state._dom && state._dom.body; 1435 var node = bodyEl && bodyEl.querySelector('[data-testid="message-' + tempId + '"]'); 1436 if (!node) return; 1437 node.classList.add('crm-chat-msg-failed'); 1438 if (!node.querySelector('.crm-chat-msg-failed-note')) { 1439 var lang = resolveIdentityLang(state.serverDefaults); 1440 var note = el('span', { 'class': 'crm-chat-msg-failed-note', text: FAILED_NOTE[lang] || FAILED_NOTE.pt }); 1441 node.appendChild(note); 1442 } 1443 } 1444 1445 function renderMessages(state, messages, opts) { 1446 var bodyEl = state._dom && state._dom.body; 1447 if (!bodyEl) return; 1448 var instant = opts && opts.instant; 1449 var existing = state._renderedIds || {}; 1450 if (Object.keys(existing).length === 0 && !state._welcomeShown) bodyEl.innerHTML = ''; 1451 if (messages.length === 0 && Object.keys(existing).length === 0) { 1452 return; 1453 } 1454 var emptyEl = bodyEl.querySelector('.crm-chat-empty'); 1455 if (emptyEl && messages.length > 0) emptyEl.remove(); 1456 1457 for (var i = 0; i < messages.length; i++) { 1458 var m = messages[i]; 1459 if (existing[m.id]) continue; 1460 // If this is the server's copy of a message we already drew optimistically, 1461 // adopt its real id onto the existing bubble instead of rendering a dupe. 1462 if (reconcileOptimisticMessage(state, m)) { 1463 existing = state._renderedIds || existing; 1464 continue; 1465 } 1466 // Mark as seen immediately so polling/dedup never re-queues the same 1467 // message even though its DOM render may be deferred behind a typing pause. 1468 existing[m.id] = true; 1469 1470 var kinds = messageKinds(m); 1471 // Visitor's own messages and their option selections appear instantly â 1472 // no simulated typing for things the visitor just did. History hydration 1473 // (instant) also renders everything without a pause.
1474 if (instant || kinds.isVisitor || kinds.isOption) { 1475 renderSingleMessage(state, m); 1476 } else if (kinds.isBot) { 1477 // Long bot replies arrive as several typed-out bubbles; short ones stay 1478 // a single bubble. Human agent messages keep their existing behavior. 1479 enqueueBotMessageChunks(state, m); 1480 } else { 1481 enqueueTypingMessage(state, m); 1482 } 1483 } 1484 state._renderedIds = existing; 1485 } 1486 1487 function buildHeaders(state) { 1488 var headers = { 'Content-Type': 'application/json' }; 1489 if (state.session && state.session.sessionToken) headers['X-Chat-Session'] = state.session.sessionToken; 1490 return headers; 1491 } 1492 1493 function applyServerDefaults(state, data) { 1494 state.serverDefaults = { 1495 welcomeMessage: data.welcomeMessage || null, 1496 offlineMessage: data.offlineMessage || null, 1497 requirePhone: !!data.requirePhone, 1498 requireName: data.requireName !== undefined ? !!data.requireName : true, 1499 requireEmail: data.requireEmail !== undefined ? !!data.requireEmail : true, 1500 requireCompany: !!data.requireCompany, 1501 requireRole: !!data.requireRole, 1502 agentLanguage: data.agentLanguage || 'pt', 1503 publicAgentName: data.publicAgentName || null, 1504 useVisitorName: data.useVisitorName !== undefined ? !!data.useVisitorName : true, 1505 captureMode: data.captureMode || 'during', 1506 aiEnabled: !!data.aiEnabled, 1507 flowMode: data.flowMode || 'ignore_when_ai', 1508 leadCaptureEnabled: data.leadCaptureEnabled !== undefined ? !!data.leadCaptureEnabled : true, 1509 allowedActions: Array.isArray(data.allowedActions) ? data.allowedActions : [], 1510 contextKey: data.contextKey || null, 1511 isEnabled: !!data.isEnabled, 1512 chatKey: data.chatKey || state.config.chatKey || null, 1513 chatName: data.chatName || null, 1514 theme: data.theme || null, 1515 flow: Array.isArray(data.flow) ? data.flow : [], 1516 quickReplies: Array.isArray(data.quickReplies) ? data.quickReplies : [], 1517 }; 1518 // Apply server-driven theme: primary color (CSS var) + persona (name /
1519 // subtitle / avatar) read from serverDefaults by setPersona. 1520 if (data.theme && data.theme.primaryColor) setPrimaryColor(data.theme.primaryColor); 1521 if (state._dom && state._dom.setPersona) state._dom.setPersona(); 1522 if (state._dom && state._dom.renderQuickReplies) state._dom.renderQuickReplies(); 1523 applyDisabledState(state); 1524 updateTeaser(state); 1525 } 1526 1527 function applyDisabledState(state) { 1528 if (!state || !state._launcher) return; 1529 var disabled = state.serverDefaults && state.serverDefaults.isEnabled === false; 1530 if (disabled) { 1531 state._launcher.style.display = 'none'; 1532 if (state._panel) state._panel.style.display = 'none'; 1533 if (state._teaser) state._teaser.style.display = 'none'; 1534 stopPolling(state); 1535 stopClosedPolling(state); 1536 updateBadge(state); 1537 state.isOpen = false; 1538 } else if (!state.isOpen) { 1539 state._launcher.style.display = 'flex'; 1540 updateTeaser(state); 1541 updateBadge(state); 1542 } 1543 } 1544 1545 function ensureSession(state) { 1546 var chatKey = state.config.chatKey || null; 1547 if (state.session && state.session.sessionToken) { 1548 var sessionUrl = appendQuery(state.config.apiBaseUrl + '/api/v1/chat/session', 'chatKey', chatKey); 1549 return fetch(sessionUrl, { 1550 method: 'GET', 1551 headers: buildHeaders(state), 1552 }).then(function (res) { 1553 if (res.status === 401) { 1554 clearSession(state.storageKey); 1555 state.session = null; 1556 return ensureSession(state); 1557 } 1558 if (!res.ok) return state.session; 1559 return res.json(); 1560 }).then(function (data) { 1561 if (data) { 1562 if (data.conversationId) state.session.conversationId = data.conversationId; 1563 state.session.conversationStatus = data.status || null; 1564 if (data.flowState !== undefined) { 1565 state.session.flowState = data.flowState || null; 1566 } 1567 saveSession(state.storageKey, state.session); 1568 applyServerDefaults(state, data); 1569 if (state._dom && state._dom.renderStatus) state._dom.renderStatus(); 1570 } 1571 return state.session; 1572 }); 1573 } 1574 return fetch(state.config.apiBaseUrl + '/api/v1/chat/session', { 1575 method: 'POST', 1576 headers: { 'Content-Type': 'application/json' }, 1577 body: JSON.stringify(addGoogleClickIds({ 1578 chatKey: chatKey || undefined, 1579 pageUrl: location.href, 1580 pageTitle: document.title, 1581 referrer: document.referrer, 1582 clientIdGa4: readGa4ClientId() || undefined, 1583 })), 1584 }).then(function (res) { 1585 if (res.status === 503 || res.status === 404) { 1586 return res.json().catch(function () { return null; }).then(function (body) { 1587 applyServerDefaults(state, { isEnabled: false }); 1588 var err = new Error('chat_disabled'); 1589 err.disabled = true; 1590 err.body = body; 1591 throw err; 1592 }); 1593 } 1594 if (!res.ok) throw new Error('Failed to start chat session'); 1595 return res.json(); 1596 }).then(function (data) { 1597 state.session = { sessionToken: data.sessionToken, visitorId: data.visitorId, conversationId: null, flowState: null }; 1598 saveSession(state.storageKey, state.session); 1599 applyServerDefaults(state, data); 1600 return state.session; 1601 }); 1602 } 1603 1604 function poll(state) { 1605 if (!state.isOpen) return; 1606 if (!state.session || !state.session.conversationId) return; 1607 var url = state.config.apiBaseUrl + '/api/v1/chat/conversations/' + encodeURIComponent(state.session.conversationId) + '/messages'; 1608 var qs = []; 1609 if (state._lastMessageAt) qs.push('after=' + encodeURIComponent(state._lastMessageAt)); 1610 if (state.config.chatKey) qs.push('chatKey=' + encodeURIComponent(state.config.chatKey)); 1611 if (qs.length) url += '?' + qs.join('&'); 1612 fetch(url, { method: 'GET', headers: buildHeaders(state) }) 1613 .then(function (res) { 1614 if (res.status === 401) { 1615 clearSession(state.storageKey); 1616 state.session = null; 1617 return null; 1618 } 1619 if (!res.ok) return null; 1620 return res.json(); 1621 }) 1622 .then(function (data) { 1623 if (!data) return; 1624 if (data.flowState) { 1625 state.session.flowState = data.flowState; 1626 saveSession(state.storageKey, state.session); 1627 } 1628 if (data.messages && data.messages.length) { 1629 // The first poll of an existing conversation hydrates the full history 1630 // â render it instantly so we don't replay typing for old messages. 1631 var firstPoll = !state._firstPollDone; 1632 state._firstPollDone = true; 1633 renderMessages(state, data.messages, { instant: firstPoll }); 1634 state._lastMessageAt = data.messages[data.messages.length - 1].createdAt; 1635 // Panel is open, so everything we just rendered is "seen" â advance 1636 // the seen marker and keep the unread count cleared. 1637 if (state.session) { 1638 state.session.lastSeenAt = state._lastMessageAt; 1639 state.session.unreadCount = 0; 1640 saveSession(state.storageKey, state.session); 1641 } 1642 } else { 1643 state._firstPollDone = true; 1644 } 1645 if (data.status && state.session && data.status !== state.session.conversationStatus) { 1646 state.session.conversationStatus = data.status; 1647 saveSession(state.storageKey, state.session); 1648 if (state._dom && state._dom.renderStatus) state._dom.renderStatus(); 1649 } 1650 }) 1651 .catch(function () { /* swallow */ }); 1652 } 1653 1654 function startPolling(state) { 1655 stopPolling(state); 1656 state._pollTimer = setInterval(function () { poll(state); }, state.config.pollIntervalMs || DEFAULT_POLL_MS); 1657 poll(state); 1658 } 1659 1660 function stopPolling(state) { 1661 if (state._pollTimer) { 1662 clearInterval(state._pollTimer); 1663 state._pollTimer = null; 1664 } 1665 } 1666 1667 // Lightweight poll that runs while the panel is CLOSED. It only counts 1668 // incoming (bot/agent) messages the visitor hasn't seen yet and surfaces them 1669 // as the launcher's unread badge â it never renders messages. 1670 function closedPoll(state) { 1671 if (state.isOpen) return; 1672 if (!state.session || !state.session.conversationId) return; 1673 var url = state.config.apiBaseUrl + '/api/v1/chat/conversations/' + encodeURIComponent(state.session.conversationId) + '/messages'; 1674 var since = state.session.lastSeenAt; 1675 // No seen marker yet (e.g. a session created before this feature existed): 1676 // establish a baseline at the latest message instead of counting the whole 1677 // backlog as unread. 1678 var establishingBaseline = !since; 1679 var qs = []; 1680 if (since) qs.push('after=' + encodeURIComponent(since)); 1681 if (state.config.chatKey) qs.push('chatKey=' + encodeURIComponent(state.config.chatKey)); 1682 if (qs.length) url += '?' + qs.join('&'); 1683 fetch(url, { method: 'GET', headers: buildHeaders(state) }) 1684 .then(function (res) { 1685 if (res.status === 401) { 1686 clearSession(state.storageKey); 1687 state.session = null; 1688 stopClosedPolling(state); 1689 return null; 1690 } 1691 if (!res.ok) return null; 1692 return res.json(); 1693 }) 1694 .then(function (data) { 1695 if (!data || !state.session) return; 1696 var msgs = (data.messages && data.messages.length) ? data.messages : []; 1697 if (establishingBaseline) { 1698 if (msgs.length) { 1699 state.session.lastSeenAt = msgs[msgs.length - 1].createdAt; 1700 saveSession(state.storageKey, state.session); 1701 } 1702 return; 1703 } 1704 var count = 0; 1705 for (var i = 0; i < msgs.length; i++) {
1706 var kinds = messageKinds(msgs[i]); 1707 // Only count messages coming TO the visitor â never their own sends. 1708 if (!kinds.isVisitor && !kinds.isOption) count++; 1709 } 1710 if (count !== (state.session.unreadCount || 0)) { 1711 state.session.unreadCount = count; 1712 saveSession(state.storageKey, state.session); 1713 updateBadge(state); 1714 } 1715 }) 1716 .catch(function () { /* swallow */ }); 1717 } 1718 1719 function startClosedPolling(state) { 1720 stopClosedPolling(state); 1721 if (state.isOpen) return; 1722 if (!state.session || !state.session.conversationId) return; 1723 state._closedPollTimer = setInterval(function () { closedPoll(state); }, state.config.closedPollIntervalMs || DEFAULT_CLOSED_POLL_MS); 1724 closedPoll(state); 1725 } 1726 1727 function stopClosedPolling(state) { 1728 if (state._closedPollTimer) { 1729 clearInterval(state._closedPollTimer); 1730 state._closedPollTimer = null; 1731 } 1732 } 1733 1734 function init(config) { 1735 if (typeof window === 'undefined' || typeof document === 'undefined') return null; 1736 if (widgetState) return widgetState; 1737 config = config || {}; 1738 if (!config.apiBaseUrl) { 1739 warn('apiBaseUrl is required'); 1740 return null; 1741 } 1742 var primary = config.primaryColor || '#00e0b8'; 1743 var storageKey = STORAGE_PREFIX + (config.tenantKey || 'default') + '_' + (config.chatKey || 'default'); 1744 1745 var state = { 1746 config: config, 1747 storageKey: storageKey, 1748 session: loadSession(storageKey), 1749 serverDefaults: null, 1750 isOpen: false, 1751 _renderedIds: {}, 1752 _lastMessageAt: null, 1753 _welcomeShown: false, 1754 _collectedIdentity: null, 1755 _identityProvided: null, 1756 open: function () { 1757 state.isOpen = true; 1758 state._panel.style.display = 'flex'; 1759 state._launcher.style.display = 'none'; 1760 if (state._teaser) state._teaser.style.display = 'none'; 1761 stopClosedPolling(state); 1762 // Opening the chat means the visitor is now seeing the messages â clear 1763 // the unread count and hide the badge. 1764 if (state.session) { 1765 state.session.unreadCount = 0; 1766 saveSession(state.storageKey, state.session); 1767 } 1768 updateBadge(state); 1769 ensureSession(state).then(function () { 1770 if (state.session && state.session.conversationId) { 1771 startPolling(state); 1772 if (state._dom && state._dom.renderQuickReplies) state._dom.renderQuickReplies(); 1773 } else { 1774 if (state._dom && state._dom.beginIdentityOrFlow) state._dom.beginIdentityOrFlow(); 1775 } 1776 }).catch(function (err) { warn(err); }); 1777 }, 1778 close: function () { 1779 state.isOpen = false; 1780 state._panel.style.display = 'none'; 1781 state._launcher.style.display = 'flex'; 1782 stopPolling(state); 1783 updateTeaser(state); 1784 updateBadge(state); 1785 // Resume the lightweight closed-panel poll so new messages surface as a 1786 // badge while the visitor is away from the chat. 1787 startClosedPolling(state); 1788 }, 1789 sendMessage: function (input) { 1790 return ensureSession(state).then(function () { 1791 var hasConversation = state.session && state.session.conversationId; 1792 var url = hasConversation 1793 ? state.config.apiBaseUrl + '/api/v1/chat/conversations/' + encodeURIComponent(state.session.conversationId) + '/messages' 1794 : state.config.apiBaseUrl + '/api/v1/chat/conversations'; 1795 return fetch(url, { 1796 method: 'POST', 1797 headers: buildHeaders(state), 1798 body: JSON.stringify(addGoogleClickIds({ 1799 chatKey: state.config.chatKey || undefined, 1800 body: input.body, 1801 visitorName: input.visitorName || undefined, 1802 visitorEmail: input.visitorEmail || undefined, 1803 visitorPhone: input.visitorPhone || undefined, 1804 visitorCompany: input.visitorCompany || undefined, 1805 pageUrl: location.href, 1806 pageTitle: document.title, 1807 clientIdGa4: readGa4ClientId() || undefined, 1808 })), 1809 }); 1810 }).then(function (res) { 1811 if (!res.ok) { 1812 return res.text().then(function (t) { throw new Error('Send failed: ' + (t || res.status)); }); 1813 } 1814 return res.json(); 1815 }).then(function (data) { 1816 if (data && data.conversation && data.conversation.id) { 1817 state.session.conversationId = data.conversation.id; 1818 state.session.conversationStatus = data.conversation.status || 'waiting_agent'; 1819 if (data.conversation.flowState) { 1820 state.session.flowState = data.conversation.flowState; 1821 } 1822 saveSession(state.storageKey, state.session); 1823 if (state._dom && state._dom.renderStatus) state._dom.renderStatus(); 1824 } 1825 if (data && data.messages && data.messages.length) { 1826 renderMessages(state, data.messages); 1827 state._lastMessageAt = data.messages[data.messages.length - 1].createdAt; 1828 } else if (data && data.message) { 1829 renderMessages(state, [data.message]); 1830 state._lastMessageAt = data.message.createdAt; 1831 } 1832 if (!state._pollTimer && state.session && state.session.conversationId) { 1833 startPolling(state); 1834 } 1835 return data; 1836 }); 1837 }, 1838 }; 1839 1840 injectStyles(primary); 1841 state._launcher = buildLauncher(state); 1842 state._teaser = buildTeaser(state); 1843 state._panel = buildPanel(state); 1844 state._panel.style.display = 'none'; 1845 state._launcher.style.display = 'none'; 1846 document.body.appendChild(state._launcher); 1847 document.body.appendChild(state._teaser); 1848 document.body.appendChild(state._panel); 1849 1850 var probeUrl = appendQuery(state.config.apiBaseUrl + '/api/v1/chat/config', 'chatKey', state.config.chatKey); 1851 fetch(probeUrl, { method: 'GET' }) 1852 .then(function (res) { return res.ok ? res.json() : null; }) 1853 .then(function (cfg) { 1854 if (!cfg) return; 1855 applyServerDefaults(state, cfg); 1856 if (cfg.isEnabled) { 1857 state._launcher.style.display = 'flex'; 1858 // Surface any unread count persisted from a previous visit and resume 1859 // watching for new messages while the panel is closed. 1860 updateBadge(state); 1861 if (!state.isOpen && state.session && state.session.conversationId) { 1862 startClosedPolling(state); 1863 } 1864 } 1865 }) 1866 .catch(function () { /* leave launcher hidden on probe failure */ }); 1867 1868 widgetState = state; 1869 return { 1870 open: state.open, 1871 close: state.close, 1872 destroy: function () { 1873 stopPolling(state); 1874 stopClosedPolling(state); 1875 if (state._launcher && state._launcher.parentNode) state._launcher.parentNode.removeChild(state._launcher); 1876 if (state._teaser && state._teaser.parentNode) state._teaser.parentNode.removeChild(state._teaser); 1877 if (state._panel && state._panel.parentNode) state._panel.parentNode.removeChild(state._panel); 1878 widgetState = null; 1879 }, 1880 }; 1881 } 1882 1883 return { 1884 init: init, 1885 open: function () { if (widgetState) widgetState.open(); }, 1886 close: function () { if (widgetState) widgetState.close(); }, 1887 destroy: function () { if (widgetState) { 1888 stopPolling(widgetState); 1889 stopClosedPolling(widgetState); 1890 if (widgetState._launcher && widgetState._launcher.parentNode) widgetState._launcher.parentNode.removeChild(widgetState._launcher); 1891 if (widgetState._teaser && widgetState._teaser.parentNode) widgetState._teaser.parentNode.removeChild(widgetState._teaser); 1892 if (widgetState._panel && widgetState._panel.parentNode) widgetState._panel.parentNode.removeChild(widgetState._panel); 1893 widgetState = null; 1894 } }, 1895 }; 1896}));
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.