PageSourceSearch

https://crm.upx.com/embed/crm-chat-widget/crm-chat-widget.js

js upx.com collected 2026-10-01 15:49:22 UTC 88,581 bytes, 1,896 lines download raw bytes

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, '&amp;')
386      .replace(/</g, '&lt;')
387      .replace(/>/g, '&gt;')
388      .replace(/"/g, '&quot;')
389      .replace(/'/g, '&#39;');
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.