PageSourceSearch

https://api.mentalforge.ai/widget.js

js mentalforge.ai collected 2026-10-05 12:16:04 UTC 50,625 bytes, 912 lines download raw bytes

1(function () {
2  var scriptTag = document.currentScript;
3  var businessId = scriptTag.getAttribute('data-business');
4  if (!businessId) { console.error('Chat widget: data-business attribute missing'); return; }
5
6  var API_ROOT = "https://api.mentalforge.ai";
7  try {
8    if (scriptTag.src) API_ROOT = new URL(scriptTag.src).origin;
9  } catch (e) {}
10  var API_BASE = API_ROOT + "/api";
11
12  if (window.__mfWidgetLoaded) return; window.__mfWidgetLoaded = true;
13
14  var STORAGE_KEY = "mf_widget_state_" + businessId;
15
16  var widgetHost = document.createElement('div');
17  widgetHost.id = 'mf-widget-host';
18  document.body.appendChild(widgetHost);
19  var shadowRoot = widgetHost.attachShadow ? widgetHost.attachShadow({ mode: 'open' }) : widgetHost;
20
21  var config = { name: "Chat", logoUrl: "", brandColor: "#1B1A18", ctaLinks: [], launcherType: "default", launcherMediaUrl: "", hideBranding: false };
22  var busy = false;
23  var socket = null;
24  var seenMsgIds = {};
25
26  var state = loadState();
27  if (!state.sessionId) { state.sessionId = genSessionId(); saveState(); }
28
29  function botIconSvg(px) {
30    return '<svg viewBox="0 0 24 24" width="' + px + '" height="' + px + '" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
31      '<circle cx="12" cy="5.2" r="1.3" fill="currentColor" stroke="none"/>' +
32      '<path d="M12 6.8v2"/>' +
33      '<rect x="5" y="9.4" width="14" height="11.2" rx="5"/>' +
34      '<circle cx="9.3" cy="14.7" r="1.25" fill="currentColor" stroke="none"/>' +
35      '<circle cx="14.7" cy="14.7" r="1.25" fill="currentColor" stroke="none"/>' +
36      '<path d="M9.5 17.4c.9.65 3.6.65 4.5 0"/>' +
37    '</svg>';
38  }
39  var BOT_ICON_SVG = botIconSvg(15);
40  var USER_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" 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>';
41  var HUMAN_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>';
42  var CLOSE_ICON_SVG = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="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>';
43  var DEFAULT_LAUNCH_SVG = '<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><circle cx="9" cy="10" r="1" fill="#ffffff"/><circle cx="12" cy="10" r="1" fill="#ffffff"/><circle cx="15" cy="10" r="1" fill="#ffffff"/></svg>';
44  var SEND_ICON_SVG = '<svg viewBox="0 0 24 24" width="18" height="18" 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>';
45  var MENU_ICON_SVG = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
46  var DOWNLOAD_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
47  var LEAVE_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
48  var THUMB_UP_SVG = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>';
49  var MAIL_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22 6 12 13 2 6"/></svg>';
50  var THUMB_DOWN_SVG = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z"/><path d="M17 2h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3"/></svg>';
51
52  function genSessionId() {
53    return 's_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
54  }
55
56  function shadeColor(hex, percent) {
57    hex = String(hex || '#1B1A18').replace('#', '');
58    if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
59    var num = parseInt(hex, 16);
60    if (isNaN(num)) return '#1B1A18';
61    var r = Math.min(255, Math.max(0, (num >> 16) + percent));
62    var g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + percent));
63    var b = Math.min(255, Math.max(0, (num & 0x0000FF) + percent));
64    return '#' + (0x1000000 + r * 0x10000 + g * 0x100 + b).toString(16).slice(1);
65  }
66
67  function loadState() {
68    try {
69      var raw = localStorage.getItem(STORAGE_KEY);
70      if (raw) return JSON.parse(raw);
71    } catch (e) {}
72    return { leadCaptured: false, visitor: { name: "", email: "", phone: "" }, messages: [], liveMode: false, chatId: null, sessionId: null };
73  }
74  function saveState() {
75    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
76  }
77
78  fetch(API_BASE + "/public/config/" + businessId)
79    .then(function (r) { return r.json(); })
80    .then(function (data) { config = data; init(); })
81    .catch(function () { init(); });
82
83  function init() {
84    injectStyles();
85    buildDOM();
86  }
87
88  function injectStyles() {
89    var side = config.launcherPosition === 'left' ? 'left' : 'right';
90    var brand = config.brandColor;
91    var brandDeep = shadeColor(brand, -35);
92    var FONT = 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;';
93    var css =
94      '.mf2-launch{position:fixed;' + side + ':22px;bottom:22px;z-index:2147483000;width:68px;height:68px;border:none;border-radius:50%;' +
95      'background:transparent;color:#fff;cursor:pointer;box-shadow:0 14px 34px -6px ' + brand + '77,0 6px 16px -4px rgba(0,0,0,.25);' +
96      'padding:0;transition:transform .2s cubic-bezier(.34,1.56,.64,1), box-shadow .2s ease;' + FONT +
97      'animation:mf2PopIn .35s cubic-bezier(.34,1.56,.64,1);}' +
98      '.mf2-launch:hover{transform:translateY(-4px) scale(1.06);box-shadow:0 18px 40px -6px ' + brand + '90,0 8px 20px -4px rgba(0,0,0,.3);}' +
99      '.mf2-launch:active{transform:translateY(-1px) scale(.97);}' +
100      '.mf2-launch-inner{position:relative;z-index:1;width:100%;height:100%;border-radius:50%;overflow:hidden;background:' + brand + ';display:grid;place-items:center;}' +
101      '.mf2-launch-media{width:100%;height:100%;object-fit:cover;border-radius:50%;display:block;}' +
102      '.mf2-launch-ring{position:absolute;inset:0;border-radius:50%;background:' + brand + ';animation:mf2RingPulse 2.6s cubic-bezier(0,0,.2,1) infinite;pointer-events:none;}' +
103      '@keyframes mf2RingPulse{0%{transform:scale(.9);opacity:.4;}80%,100%{transform:scale(1.7);opacity:0;}}' +
104      '@keyframes mf2PopIn{0%{transform:scale(0);opacity:0;}100%{transform:scale(1);opacity:1;}}' +
105      '.mf2-dot{position:absolute;top:4px;right:4px;width:15px;height:15px;border-radius:50%;background:#EF4444;border:2.5px solid #fff;display:none;box-shadow:0 2px 6px rgba(0,0,0,.25);}' +
106      '.mf2-dot.show{display:block;animation:mf2Pulse 1.8s ease-in-out infinite;}' +
107      '@keyframes mf2Pulse{0%,100%{transform:scale(1);}50%{transform:scale(1.22);}}' +
108
109      '.mf2-teaser{position:fixed;' + side + ':22px;bottom:98px;z-index:2147482999;width:290px;max-width:calc(100vw - 44px);' +
110      'background:#fff;border-radius:18px;box-shadow:0 20px 46px -14px rgba(20,20,30,.28),0 4px 14px -6px rgba(20,20,30,.12);' +
111      'padding:16px;opacity:0;transform:translateY(12px) scale(.95);pointer-events:none;' +
112      'transition:opacity .22s ease, transform .22s ease;box-sizing:border-box;
112cursor:pointer;' + FONT + 'color:#15161F;}' +
113      '.mf2-teaser.show{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;}' +
114      '.mf2-teaser:after{content:"";position:absolute;bottom:-6px;' + side + ':30px;width:14px;height:14px;background:#fff;' +
115      'transform:rotate(45deg);box-shadow:3px 3px 6px -3px rgba(20,20,30,.15);}' +
116      '.mf2-teaser-close{position:absolute;top:8px;right:8px;width:22px;height:22px;border-radius:50%;border:none;background:#F1F1F4;' +
117      'color:#8A8B99;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;line-height:1;padding:0;transition:background .12s ease;}' +
118      '.mf2-teaser-close:hover{background:#E5E5EB;color:#45465A;}' +
119      '.mf2-teaser-row{display:flex;gap:10px;align-items:flex-start;padding-right:14px;}' +
120      '.mf2-teaser-avatar{width:36px;height:36px;border-radius:50%;flex:0 0 auto;background:' + brand + ';color:#fff;' +
121      'display:flex;align-items:center;justify-content:center;overflow:hidden;font-weight:700;font-size:13px;}' +
122      '.mf2-teaser-avatar img{width:100%;height:100%;object-fit:cover;display:block;}' +
123      '.mf2-teaser-name{font-weight:700;font-size:13px;margin-bottom:3px;color:#15161F;}' +
124      '.mf2-teaser-text{font-size:13px;line-height:1.45;color:#4B4C5C;}' +
125      '.mf2-teaser-chips{display:flex;flex-direction:column;gap:6px;margin-top:12px;}' +
126      '.mf2-teaser-chip{border:1px solid ' + brand + '4d;background:' + brand + '0f;color:' + brandDeep + ';text-align:left;' +
127      'padding:8px 12px;border-radius:10px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;' +
128      'transition:background .12s ease,border-color .12s ease;}' +
129      '.mf2-teaser-chip:hover{background:' + brand + '1f;border-color:' + brand + ';}' +
130
131      '.mf2-typing{display:flex;gap:4px;align-items:center;padding:13px 16px;}
131' +
132      '.mf2-typing span{width:6px;height:6px;border-radius:50%;background:#B5B6C4;display:inline-block;animation:mf2Wave 1.1s ease-in-out infinite;}' +
133      '.mf2-typing span:nth-child(2){animation-delay:.15s;}' +
134      '.mf2-typing span:nth-child(3){animation-delay:.3s;}' +
135      '@keyframes mf2Wave{0%,60%,100%{transform:translateY(0);opacity:.5;}30%{transform:translateY(-5px);opacity:1;}}' +
136
137      '.mf2-panel{position:fixed;' + side + ':22px;bottom:98px;z-index:2147483000;width:390px;max-width:calc(100vw - 32px);height:590px;' +
138      'max-height:calc(100vh - 130px);background:#fff;border:1px solid rgba(20,20,30,.08);border-radius:22px;overflow:hidden;display:flex;' +
139      'flex-direction:column;box-shadow:0 30px 70px -24px rgba(20,20,30,.35),0 8px 24px -8px rgba(20,20,30,.15);opacity:0;' +
140      'transform:translateY(16px) scale(.96);pointer-events:none;' +
141      'transition:opacity .18s ease, transform .18s cubic-bezier(.2,.8,.3,1);box-sizing:border-box;' +
142      FONT + 'color:#15161F;}' +
143      '.mf2-panel.open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;}' +
144      '.mf2-panel *{box-sizing:border-box;}' +
145      '.mf2-head{background:linear-gradient(135deg,' + brand + ',' + brandDeep + ');color:#fff;padding:16px 18px;display:flex;align-items:center;gap:12px;flex:0 0 auto;position:relative;}' +
146      '.mf2-head-logo{width:34px;height:34px;border-radius:10px;background:#fff;flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:4px;box-sizing:border-box;box-shadow:0 1px 4px rgba(0,0,0,.18),0 0 0 1px rgba(255,255,255,.55);overflow:hidden;}' +
147      '.mf2-head-logo img{width:100%;height:100%;object-fit:contain;display:block;}' +
148      '.mf2-head-text{min-width:0;}' +
149      '.mf2-title{font-weight:700;font-size:15.5px;letter-spacing:-0.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}' +
150      '.mf2-status{font-size:11.5px;opacity:.9;display:flex;align-items:center;gap:6px;margin-top:2px;}' +
151      '.mf2-status-dot{width:7.5px;height:7.5px;border-radius:50%;background:#4ADE80;box-shadow:0 0 0 2px rgba(255,255,255,.35);flex:0 0 auto;position:relative;}' +
152      '.mf2-status-dot:after{content:"";position:absolute;inset:-3px;border-radius:50%;background:#4ADE80;opacity:.65;animation:mf2Glow 1.8s ease-out infinite;}' +
153      '.mf2-status-dot.away{background:#9CA0AE;}' +
154      '.mf2-status-dot.away:after{display:none;}' +
155      '@keyframes mf2Glow{0%{transform:scale(1);opacity:.65;}100%{transform:scale(2.6);opacity:0;}}' +
156      '.mf2-x{background:none;border:none;color:#fff;cursor:pointer;opacity:.85;display:flex;align-items:center;justify-content:center;padding:6px;border-radius:8px;transition:background .12s ease,opacity .12s ease;flex:0 0 auto;}' +
157      '.mf2-x:hover{opacity:1;background:rgba(255,255,255,.18);}' +
158      '.mf2-menu-btn{margin-left:auto;background:none;border:none;color:#fff;cursor:pointer;padding:6px;opacity:.85;display:grid;place-items:center;border-radius:8px;transition:background .12s ease,opacity .12s ease;flex:0 0 auto;}' +
159      '.mf2-menu-btn:hover{opacity:1;background:rgba(255,255,255,.18);}' +
160      '.mf2-menu-panel{position:absolute;inset:0;background:#fff;z-index:5;display:none;flex-direction:column;}' +
161      '.mf2-menu-panel.open{display:flex;}' +
162      '.mf2-menu-head{background:linear-gradient(135deg,' + brand + ',' + brandDeep + ');color:#fff;padding:16px 16px;display:flex;align-items:center;gap:10px;flex:0 0 auto;}' +
163      '.mf2-menu-back{background:none;border:none;color:#fff;cursor:pointer;font-size:18px;padding:4px;}' +
164      '.mf2-menu-title{font-weight:700;font-size:15px;}' +
165      '.mf2-menu-body{padding:10px;display:flex;flex-direction:column;gap:6px;}' +
166      '.mf2-menu-item{display:flex;align-items:center;gap:12px;padding:13px 12px;border:none;background:none;text-align:left;border-radius:10px;cursor:pointer;font-size:14px;color:#15161F;font-family:inherit;width:100%;transition:background .12s ease;}' +
167      '.mf2-menu-item:hover{background:#F4F4F7;}' +
168      '.mf2-menu-item.danger{color:#DC2626;}' +
169      '.mf2-menu-item svg{width:18px;height:18px;flex:0 0 auto;}' +
170      '.mf2-body{flex:1;overflow-y:auto;padding:18px 16px;display:flex;flex-direction:column;gap:14px;background:#F7F7FA;}' +
171      '.mf2-row{display:flex;gap:8px;max-width:88%;align-items:flex-end;}' +
172      '.mf2-row.user{align-self:flex-end;flex-direction:row-reverse;}' +
173      '.mf2-av{width:28px;height:28px;border-radius:9px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;background:#E9E9EF;color:#6B6C7E;}' +
174      '.mf2-av.rep{background:' + brand + ';color:#fff;}' +
175      '.mf2-bub{padding:11px 15px;border-radius:15px;font-size:14px;line-height:1.52;white-space:pre-wrap;}' +
176      '.mf2-bub.bot{background:#fff;border:1px solid #EAEAF0;border-top-left-radius:4px;box-shadow:0 1px 3px rgba(20,20,30,.04);}' +
177      '.mf2-bub.user{background:' + brand + ';color:#fff;border-top-right-radius:4px;box-shadow:0 2px 8px -2px ' + brand + '55;}' +
178      '.mf2-bub.rep{background:#fff;border:1px solid ' + brand + ';border-top-left-radius:4px;}' +
179      '.mf2-bub.system{background:#ECECF2;color:#5C5D70;font-size:12px;border-radius:10px;}' +
180      '.mf2-cta{margin-top:8px;display:inline-block;text-decoration:none;background:' + brand + ';color:#fff;' +
181      'font-size:13px;font-weight:600;padding:8px 14px;border-radius:10px;transition:opacity .12s ease;}' +
182      '.mf2-cta:hover{opacity:.9;}' +
183      '.mf2-feedback{display:flex;gap:4px;margin-top:4px;}' +
184      '.mf2-fb-btn{width:24px;height:24px;border:none;background:none;border-radius:7px;cursor:pointer;display:flex;' +
185      'align-items:center;justify-content:center;color:#ADAEBE;transition:background .12s ease,color .12s ease;padding:0;}' +
186      '.mf2-fb-btn:hover{background:#EFEFF4;color:#6B6C7E;}' +
187      '.mf2-fb-btn.active.up{color:#0E9F6E;background:#E7F4EC;}' +
188      '.mf2-fb-btn.active.down{color:#DC5B54;background:#FAE7E6;}' +
189      '.mf2-fb-btn[disabled]{cursor:default;}' +
190      '.mf2-fb-btn[disabled]:not(.active):hover{background:none;color:#ADAEBE;}' +
191      '.mf2-foot{border-top:1px solid #EDEDF2;padding:12px 14px;background:#fff;flex:0 0 auto;}' +
192      '.mf2-inrow{display:flex;gap:8px;align-items:center;}' +
193      '.mf2-input{flex:1;resize:none;border:1px solid #E5E5EC;border-radius:12px;padding:11px 14px;font-size:14px;font-family:inherit;outline:none;max-height:100px;background:#FAFAFC;transition:border-color .15s ease, box-shadow .15s ease, background .15s ease;}' +
194      '.mf2-input:focus{border-color:' + brand + ';background:#fff;box-shadow:0 0 0 3px ' + brand + '22;}' +
195      '.mf2-send{width:42px;height:42px;border:none;border-radius:12px;background:' + brand + ';color:#fff;cursor:pointer;display:grid;place-items:center;transition:transform .12s ease, opacity .12s ease;flex:0 0 auto;}' +
196      '.mf2-send:hover{opacity:.92;transform:translateY(-1px);}' +
197      '.mf2-send:active{transform:translateY(0);}' +
198      '.mf2-human-row{text-align:right;margin-top:8px;}' +
199      '.mf2-human{background:none;border:none;color:#8A8B99;cursor:pointer;font-size:12px;font-family:inherit;padding:0;display:inline-flex;align-items:center;gap:6px;transition:color .12s ease;font-weight:500;}' +
200      '.mf2-human span{text-decoration:underline;}' +
201      '.mf2-human:hover{color:' + brand + ';}' +
202      '.mf2-lead{background:#fff;border:1px solid #EAEAF0;border-radius:14px;padding:14px;font-size:13.5px;width:100%;box-shadow:0 1px 3px rgba(20,20,30,.04);}' +
203      '.mf2-lead input{width:100%;box-sizing:border-box;border:1px solid #E5E5EC;border-radius:9px;padding:9px 11px;font-size:13.5px;margin-bottom:8px;font-family:inherit;background:#FAFAFC;transition:border-color .15s ease, box-shadow .15s ease;}' +
204      '.mf2-lead input:focus{outline:none;border-color:' + brand + ';box-shadow:0 0 0 3px ' + brand + '22;background:#fff;}' +
205      '.mf2-lead button{width:100%;border:none;background:' + brand + ';color:#fff;font-weight:600;padding:10px;border-radius:9px;cursor:pointer;transition:opacity .12s ease;}' +
206      '.mf2-lead button:hover{opacity:.9;}' +
207      '.mf2-err{color:#DC2626;font-size:12px;margin:-3px 0 8px;}' +
208
209      '.mf2-gate{flex:1;display:flex;align-items:center;justify-content:center;padding:28px 24px;background:#fff;overflow-y:auto;}' +
210      '.mf2-gate-card{width:100%;max-width:290px;text-align:center;}' +
211      '.mf2-gate-icon{width:58px;height:58px;border-radius:50%;background:linear-gradient(135deg,' + brand + ',' + brandDeep + ');color:#fff;' +
212      'display:flex;align-items:center;justify-content:center;margin:0 auto 16px;box-shadow:0 8px 20px -6px ' + brand + '55;}' +
213      '.mf2-gate-title{font-weight:700;font-size:17.5px;margin-bottom:6px;color:#15161F;letter-spacing:-0.01em;}' +
214      '.mf2-gate-desc{font-size:13px;color:#6B6C7E;margin-bottom:20px;line-height:1.5;}' +
215      '.mf2-gate-card input{width:100%;box-sizing:border-box;border:1px solid #E5E5EC;border-radius:11px;padding:12px 14px;font-size:14px;' +
216      'margin-bottom:10px;font-family:inherit;background:#FAFAFC;transition:border-color .15s ease,box-shadow .15s ease,background .15s ease;}' +
217      '.mf2-gate-card input:focus{outline:none;border-color:' + brand + ';box-shadow:0 0 0 3px ' + brand + '22;background:#fff;}' +
218      '.mf2-gate-card button{width:100%;border:none;background:' + brand + ';color:#fff;font-weight:700;padding:13px;border-radius:11px;' +
219      'cursor:pointer;font-size:14px;transition:opacity .12s ease,transform .12s ease;margin-top:4px;}' +
220      '.mf2-gate-card button:hover{opacity:.92;transform:translateY(-1px);}' +
221      '.mf2-gate-err{color:#DC2626;font-size:12px;margin:8px 0 0;}' +
222      '.mf2-branding{display:flex;align-items:center;justify-content:center;gap:6px;padding:9px 10px;border-top:1px solid #F0F0F4;background:#FBFBFD;font-size:11px;color:#A5A6B3;text-decoration:none;flex:0 0 auto;font-family:inherit;transition:color .12s ease;}' +
223      '.mf2-branding:hover{color:#5C5D70;}' +
224      '.mf2-branding-mark{width:14px;
224height:14px;border-radius:4px;background:#5B5BD6;color:#fff;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex:0 0 auto;}' +
225      // Date dividers
226      '.mf2-date-div{display:flex;align-items:center;gap:8px;padding:2px 0 4px;width:100%;}' +
227      '.mf2-date-line{flex:1;height:1px;background:#E8E8EF;}' +
228      '.mf2-date-label{font-size:10.5px;color:#A5A6B3;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0.03em;}' +
229      // Hover time on messages
230      '.mf2-bub-wrap{position:relative;}' +
231      '.mf2-bub-time{display:none;position:absolute;bottom:-18px;font-size:10px;color:#A5A6B3;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;pointer-events:none;}' +
232      '.mf2-bub-time.left{left:0;}' +
233      '.mf2-bub-time.right{right:0;}' +
234      '.mf2-bub-wrap:hover .mf2-bub-time{display:block;}';
235
236    var style = document.createElement("style");
237    style.textContent = css;
238    shadowRoot.appendChild(style);
239  }
240
241  var body, input, sendBtn, panel, dot, humanBtn, menuBtn, menuPanel, menuBack, downloadBtn, emailBtn, leaveBtn, teaser, teaserTimer, gate, foot;
242  var TEASER_DISMISS_KEY = "mf_teaser_dismissed_" + businessId;
243  var pendingQuickReply = null;
244  var typingRow = null;
245  var typingTimeout = null;
246  var lastBubbleDate = null; // tracks date of last rendered bubble for dividers
247
248  // ─── Date/Time Helpers ──────────────────────────────────────────────────────
249  function mfFormatTime(d) {
250    if (!d) return '';
251    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
252  }
253  function mfDateLabel(d) {
254    if (!d) return '';
255    var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
256    var months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
257    return days[d.getDay()] + ', ' + d.getDate() + ' ' + months[d.getMonth()];
258  }
259  function mfSameDay(a, b) {
260    if (!a || !b) return false;
261    return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
262  }
263  function addDateDividerIfNeeded(ts) {
264    var d = ts ? new Date(ts) : new Date();
265    if (!mfSameDay(d, lastBubbleDate)) {
266      lastBubbleDate = d;
267      var div = document.createElement('div');
268      div.className = 'mf2-date-div';
269      var line1 = document.createElement('span'); line1.className = 'mf2-date-line';
270      var label = document.createElement('span'); label.className = 'mf2-date-label'; label.textContent = mfDateLabel(d);
271      var line2 = document.createElement('span'); line2.className = 'mf2-date-line';
272      div.appendChild(line1); div.appendChild(label); div.appendChild(line2);
273      body.appendChild(div);
274    }
275  }
276
277  function buildLauncherInner() {
278    if (config.launcherType === 'image' && config.launcherMediaUrl) {
279      return '<img class="mf2-launch-media" src="' + API_ROOT + config.launcherMediaUrl + '" alt="">';
280    }
281    if (config.launcherType === 'video' && config.launcherMediaUrl) {
282      return '<video class="mf2-launch-media" src="' + API_ROOT + config.launcherMediaUrl + '" autoplay muted loop playsinline></video>';
283    }
284    return DEFAULT_LAUNCH_SVG;
285  }
286
287  function buildDOM() {
288    var launch = document.createElement('button');
289    launch.className = 'mf2-launch';
290    launch.setAttribute('aria-label', 'Open chat');
291    launch.innerHTML =
292      '<span class="mf2-launch-ring"></span>' +
293      '<span class="mf2-launch-ring" style="animation-delay:1.3s"></span>' +
294      '<span class="mf2-launch-inner">' + buildLauncherInner() + '</span>' +
295      '<span class="mf2-dot" id="mf2Dot"></span>';
296
297    panel = document.createElement('div');
298    panel.className = 'mf2-panel';
299    panel.innerHTML =
300      '<div class="mf2-head">' +
301        (config.logoUrl ? '<div class="mf2-head-logo"><img src="' + API_ROOT + config.logoUrl + '" alt=""></div>' : '') +
302        '<div class="mf2-head-text">' +
303          '<div class="mf2-title">' + escapeHtml(config.name) + '</div>' +
304          '<div class="mf2-status">' +
305            (config.isOpen === false
306              ? '<span class="mf2-status-dot away"></span>Currently away'
307              : '<span class="mf2-status-dot"></span>Online now') +
308          '</div>' +
309        '</div>' +
310        '<button class="mf2-menu-btn" id="mf2MenuBtn" aria-label="More options">' + MENU_ICON_SVG + '</button>' +
311        '<button class="mf2-x" aria-label="Close">' + CLOSE_ICON_SVG + '</button>' +
312      '</div>' +
313      '<div class="mf2-body" id="mf2Body"></div>' +
314      '<div class="mf2-gate" id="mf2Gate" style="display:none;"></div>' +
315      '<div class="mf2-menu-panel" id="mf2MenuPanel">' +
316        '<div class="mf2-menu-head"><button class="mf2-menu-back" id="mf2MenuBack" aria-label="Back">&larr;</button><div class="mf2-menu-title">Options</div></div>' +
317        '<div class="mf2-menu-body">' +
318          '<button class="mf2-menu-item" id="mf2DownloadBtn">' + DOWNLOAD_ICON_SVG + 'Download conversation</button>' +
319          '<button class="mf2-menu-item" id="mf2EmailBtn">' + MAIL_ICON_SVG + 'Email me this conversation</button>' +
320          '<button class="mf2-menu-item danger" id="mf2LeaveBtn">' + LEAVE_ICON_SVG + 'Leave this chat</button>' +
321        '</div>' +
322      '</div>' +
323      '<div class="mf2-foot" id="mf2Foot">' +
324        '<div class="mf2-inrow">' +
325          '<textarea class="mf2-input" id="mf2Input" rows="1" placeholder="Type a message…" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false"></textarea>' +
326          '<button class="mf2-send" id="mf2Send">' + SEND_ICON_SVG + '</button>' +
327        '</div>' +
328        '<div class="mf2-human-row"><button class="mf2-human" id="mf2Human">' + HUMAN_ICON_SVG + '<span>Talk to a Representative →</span></button></div>' +
329      '</div>' +
330      (config.hideBranding ? '' :
331        '<a class="mf2-branding" href="https://mentalforge.ai" target="_blank" rel="noopener">' +
332          '<span class="mf2-branding-mark">M</span>Powered by MentalForge AI' +
333        '</a>');
334
335    var quickReplies = (config.faqs && config.faqs.length)
336      ? config.faqs.map(function (f) { return f.question; }
336).filter(Boolean).slice(0, 3)
337      : ["What can you help me with?", "Tell me about your services", "I have a question"];
338
339    teaser = document.createElement('div');
340    teaser.className = 'mf2-teaser';
341    var teaserAvatar = config.logoUrl
342      ? '<img src="' + API_ROOT + config.logoUrl + '" alt="">'
343      : botIconSvg(18);
344    teaser.innerHTML =
345      '<button class="mf2-teaser-close" id="mf2TeaserClose" aria-label="Dismiss">' + CLOSE_ICON_SVG.replace(/width="18" height="18"/, 'width="11" height="11"').replace(/stroke="#fff"/, 'stroke="currentColor"') + '</button>' +
346      '<div class="mf2-teaser-row">' +
347        '<div class="mf2-teaser-avatar">' + teaserAvatar + '</div>' +
348        '<div>' +
349          '<div class="mf2-teaser-name">' + escapeHtml(config.name) + '</div>' +
350          '<div class="mf2-teaser-text">' + escapeHtml(greetingText()) + '</div>' +
351        '</div>' +
352      '</div>' +
353      (quickReplies.length
354        ? '<div class="mf2-teaser-chips" id="mf2TeaserChips">' +
355            quickReplies.map(function (q, i) {
356              return '<button class="mf2-teaser-chip" data-q="' + i + '">' + escapeHtml(q) + '</button>';
357            }).join('') +
358          '</div>'
359        : '');
360
361    shadowRoot.appendChild(launch);
362    shadowRoot.appendChild(teaser);
363    shadowRoot.appendChild(panel);
364
365    body = panel.querySelector('#mf2Body');
366    gate = panel.querySelector('#mf2Gate');
367    foot = panel.querySelector('#mf2Foot');
368    input = panel.querySelector('#mf2Input');
369    sendBtn = panel.querySelector('#mf2Send');
370    dot = launch.querySelector('#mf2Dot');
371    humanBtn = panel.querySelector('#mf2Human');
372    menuBtn = panel.querySelector('#mf2MenuBtn');
373    menuPanel = panel.querySelector('#mf2MenuPanel');
374    menuBack = panel.querySelector('#mf2MenuBack');
375    downloadBtn = panel.querySelector('#mf2DownloadBtn');
376    emailBtn = panel.querySelector('#mf2EmailBtn');
377    leaveBtn = panel.querySelector('#mf2LeaveBtn');
378
379    var opened = false;
380    function openPanel() {
381      panel.classList.add('open');
382      clearDot();
383      dismissTeaser(true);
384      if (!opened) { opened = true; resumeOrStart(); }
385    }
386    launch.addEventListener('click', function () {
387      if (panel.classList.contains('open')) { panel.classList.remove('open'); return; }
388      openPanel();
389    });
390    teaser.addEventListener('click', openPanel);
391    teaser.querySelector('#mf2TeaserClose').addEventListener('click', function (e) {
392      e.stopPropagation();
393      dismissTeaser(true);
394    });
395    var teaserChips = teaser.querySelectorAll('.mf2-teaser-chip');
396    for (var qi = 0; qi < teaserChips.length; qi++) {
397      teaserChips[qi].addEventListener('click', function (e) {
398        e.stopPropagation();
399        pendingQuickReply = quickReplies[Number(e.currentTarget.getAttribute('data-q'))];
400        dismissTeaser(true);
401        openPanel();
402      });
403    }
404    panel.querySelector('.mf2-x').addEventListener('click', function () { panel.classList.remove('open'); });
405    sendBtn.addEventListener('click', function () { send(); });
406    input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
407    humanBtn.addEventListener('click', function () { startLiveChat(); });
408    menuBtn.addEventListener('click', function () { menuPanel.classList.add('open'); });
409    menuBack.addEventListener('click', function () { menuPanel.classList.remove('open'); });
410    downloadBtn.addEventListener('click', downloadConversation);
411    emailBtn.addEventListener('click', emailTranscript);
412    leaveBtn.addEventListener('click', leaveChat);
413
414    maybeShowTeaser();
415  }
416
417  function maybeShowTeaser() {
418    if (state.messages.length > 0) return;
419    var dismissed = false;
420    try { dismissed = localStorage.getItem(TEASER_DISMISS_KEY) === '1'; } catch (e) {}
421    if (dismissed) return;
422
423    function reveal() {
424      if (panel.classList.contains('open') || teaser.classList.contains('show')) return;
425      teaser.classList.add('show');
426    }
427
428    teaserTimer = setTimeout(reveal, 20000);
429
430    // Exit intent — mouse moving up toward the browser chrome (tab bar / back button)
431    // usually means the visitor is about to leave the page.
432    function onMouseLeave(e) {
433      if (e.clientY > 0) return;
434      reveal();
435      document.removeEventListener('mouseleave', onMouseLeave);
436    }
437    document.addEventListener('mouseleave', onMouseLeave);
438  }
439
440  function dismissTeaser(permanent) {
441    clearTimeout(teaserTimer);
442    if (teaser) teaser.classList.remove('show');
443    if (permanent) {
444      try { localStorage.setItem(TEASER_DISMISS_KEY, '1'); } catch (e) {}
445    }
446  }
447
448  function escapeHtml(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
449  function scrollDown() { body.scrollTop = body.scrollHeight; }
450  function showDot() { if (!panel.classList.contains('open')) { dot.classList.add('show'); playBeep(); } }
451  function clearDot() { dot.classList.remove('show'); }
452
453  function playBeep() {
454    try {
455      var Ctx = window.AudioContext || window.webkitAudioContext;
456      var ctx = new Ctx();
457      var osc = ctx.createOscillator(), gain = ctx.createGain();
458      osc.type = 'sine'; osc.frequency.value = 760;
459      osc.connect(gain); gain.connect(ctx.destination);
460      gain.gain.setValueAtTime(0.001, ctx.currentTime);
461      gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.01);
462      gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
463      osc.start(); osc.stop(ctx.currentTime + 0.17);
464    } catch (e) {}
465  }
466
467  function isSafeCtaUrl(url) {
468    try {
469      var scheme = String(url).trim().split(':')[0].toLowerCase();
470      return scheme === 'http' || scheme === 'https' || scheme === 'mailto' || scheme === 'tel';
471    } catch (e) {
472      return false;
473    }
474  }
475
476  function addBubble(role, text, ctaUrl, ctaLabel, silent, ts, chatLogId) {
477    var cls = role === 'user' ? 'user' : (role === 'rep' ? 'rep' : (role === 'system' ? 'system' : 'bot'));
478    var row = document.createElement('div');
479    row.className = 'mf2-row' + (role === 'user' ? ' user' : '');
480
481    // Inject date divider if this is a new day
482    addDateDividerIfNeeded(ts);
483
484    if (role !== 'system') {
485      var av = document.createElement('div');
486      av.className = 'mf2-av' + (role === 'rep' ? ' rep' : '');
487      if (role === 'user') {
488        av.innerHTML = USER_ICON_SVG;
489      } else if (role === 'rep') {
490        av.innerHTML = HUMAN_ICON_SVG;
491      } else {
492        av.innerHTML = BOT_ICON_SVG;
493      }
494      row.appendChild(av);
495    }
496
497    var col = document.createElement('div');
498    var wrap = document.createElement('div');
499    wrap.className = 'mf2-bub-wrap';
500
501    var bub = document.createElement('div');
502    bub.className = 'mf2-bub ' + cls;
503    bub.textContent = text;
504    wrap.appendChild(bub);
505
506    // Time tooltip on hover
507    var timeStr = mfFormatTime(ts ? new Date(ts) : new Date());
508    if (timeStr) {
509      var timeEl = document.createElement('div');
510      timeEl.className = 'mf2-bub-time ' + (role === 'user' ? 'right' : 'left');
511      timeEl.textContent = timeStr;
512      wrap.appendChild(timeEl);
513    }
514
515    col.appendChild(wrap);
516
517    if (ctaUrl && isSafeCtaUrl(ctaUrl)) {
518      var a = document.createElement('a');
519      a.className = 'mf2-cta'; a.href = ctaUrl; a.target = '_blank'; a.rel = 'noopener';
520      a.textContent = ctaLabel || 'Learn more';
521      col.appendChild(document.createElement('br'));
522      col.appendChild(a);
523    }
524
525    if (role === 'assistant' && chatLogId) {
526      col.appendChild(buildFeedbackRow(chatLogId));
527    }
528
529    row.appendChild(col);
530    body.appendChild(row);
531    if (typingRow) body.appendChild(typingRow);
532    scrollDown();
533    if (!silent && (role === 'assistant' || role === 'rep')) showDot();
534  }
535  function addSystem(text, ts) { addBubble('system', text, null, null, true, ts); }
536
537  function buildFeedbackRow(chatLogId) {
538    var wrap = document.createElement('div');
539    wrap.className = 'mf2-feedback';
540
541    var up = document.createElement('button');
542    up.className = 'mf2-fb-btn'; up.innerHTML = THUMB_UP_SVG; up.setAttribute('aria-label', 'Good reply'); up.type = 'button';
543    var down = document.createElement('button');
544    down.className = 'mf2-fb-btn'; down.innerHTML = THUMB_DOWN_SVG; down.setAttribute('aria-label', 'Bad reply'); down.type = 'button';
545
546    function rate(rating, btn, otherBtn) {
547      if (up.disabled) return;
548      up.disabled = true; down.disabled = true;
549      btn.classList.add('active', rating);
550      fetch(API_BASE + "/public/feedback/" + chatLogId, {
551        method: 'POST', headers: { 'Content-Type': 'application/json' },
552        body: JSON.stringify({ rating: rating })
553      }).catch(function () {});
554    }
555    up.addEventListener('click', function () { rate('up', up, down); });
556    down.addEventListener('click', function () { rate('down', down, up); });
557
558    wrap.appendChild(up);
559    wrap.appendChild(down);
560    return wrap;
561  }
562
563  function showTyping(isLive) {
564    hideTyping();
565    typingRow = document.createElement('div');
566    typingRow.className = 'mf2-row';
567    if (isLive) {
568      typingRow.innerHTML =
569        '<div class="mf2-av rep">' + HUMAN_ICON_SVG + '</div>' +
570        '<div class="mf2-bub rep mf2-typing"><span></span><span></span><span></span></div>';
571    } else {
572      typingRow.innerHTML =
573        '<div class="mf2-av">' + BOT_ICON_SVG + '</div>' +
574        '<div class="mf2-bub bot mf2-typing"><span></span><span></span><span></span></div>';
575    }
576    body.appendChild(typingRow);
577    scrollDown();
578  }
579  function hideTyping() {
580    if (typingRow) { typingRow.remove(); typingRow = null; }
581  }
582
583  // ---------- lead capture ----------
584  var pendingLivePurpose = false;
585
586  function isValidEmail(v) {
587    return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v);
588  }
589  function isValidPhone(v) {
590    return /^[0-9+()\-\s]{6,20}$/.test(v) && /\d{5,}/.test(v.replace(/[^0-9]/g, ''));
591  }
592
593  function submitLead(name, email, phone, onDone) {
594    fetch(API_BASE + "/public/lead/" + businessId, {
595      method: 'POST', headers: { 'Content-Type': 'application/json' },
596      body: JSON.stringify({ name: name, email: email, phone: phone, sessionId: state.sessionId })
597    }).catch(function () {}).finally(function () {
598      state.leadCaptured = true;
599      state.visitor = { name: name, email: email, phone: phone };
600      saveState();
601      onDone();
602    });
603  }
604
605  function validateLeadFields(card, errBox) {
606    var name = card.querySelector('#mf2Name').value.trim();
607    var email = card.querySelector('#mf2Email').value.trim();
608    var phone = card.querySelector('#mf2Phone').value.trim();
609    errBox.style.display = 'none';
610    if (!name) { errBox.textContent = 'Please enter your name.'; errBox.style.display = 'block'; return null; }
611    if (!email) { errBox.textContent = 'Please enter your email address.'; errBox.style.display = 'block'; return null; }
612    if (!isValidEmail(email)) { errBox.textContent = 'Please enter a valid email address.'; errBox.style.display = 'block'; return null; }
613    if (!phone) { errBox.textContent = 'Please enter your phone number.'; errBox.style.display = 'block'; return null; }
614    if (!isValidPhone(phone)) { errBox.textContent = 'Please enter a valid phone number.'; errBox.style.display = 'block'; return null; }
615    return { name: name, email: email, phone: phone };
616  }
617
618  function showLeadForm(purpose) {
619    pendingLivePurpose = purpose === 'live';
620
621    if (pendingLivePurpose) {
622      var row = document.createElement('div'); row.className = 'mf2-row';
623      var card = document.createElement('div'); card.className = 'mf2-lead';
624      card.innerHTML =
625        '<div style="font-weight:600;margin-bottom:6px;">Hi! Who am I speaking with?</div>' +
626        '<div style="font-size:12px;color:#6E6A63;margin-bottom:8px;">So our team can follow up with you directly.</div>' +
627        '<input type="text" id="mf2Name" placeholder="Your name" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false">' +
628        '<input type="email" id="mf2Email" placeholder="Email address" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false">' +
629        '<input type="tel" id="mf2Phone" placeholder="Phone number" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false">' +
630        '<div class="mf2-err" id="mf2Err" style="display:none;"></div>' +
631        '<button id="mf2LeadBtn">Continue</button>';
632      row.appendChild(card); body.appendChild(row); scrollDown();
633
634      card.querySelector('#mf2LeadBtn').addEventListener('click', function () {
635        var fields = validateLeadFields(card, card.querySelector('#mf2Err'));
636        if (!fields) return;
637        submitLead(fields.name, fields.email, fields.phone, function () {
638          row.remove();
639          actuallyStartLiveChat();
640        });
641      });
642      return;
643    }
644
645    // First-time visitor: block the whole chat behind a prominent, centered gate.
646    body.style.display = 'none';
647    foot.style.display = 'none';
648    gate.style.display = 'flex';
649    gate.innerHTML =
650      '<div class="mf2-gate-card">' +
651        '<div class="mf2-gate-icon">' + botIconSvg(30) + '</div>' +
652        '<div class="mf2-gate-title">Hi! Who am I speaking with?</div>' +
653        '<div class="mf2-gate-desc">Please share your details to start chatting with us.</div>' +
654        '<input type="text" id="mf2Name" placeholder="Your name" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false">' +
655        '<input type="email" id="mf2Email" placeholder="Email address" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false">' +
656        '<input type="tel" id="mf2Phone" placeholder="Phone number" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false">' +
657        '<button id="mf2LeadBtn">Continue</button>' +
658        '<div class="mf2-gate-err" id="mf2Err" style="display:none;"></div>' +
659      '</div>';
660
661    gate.querySelector('#mf2LeadBtn').addEventListener('click', function () {
662      var fields = validateLeadFields(gate, gate.querySelector('#mf2Err'));
663      if (!fields) return;
664      submitLead(fields.name, fields.email, fields.phone, function () {
665        gate.style.display = 'none';
666        gate.innerHTML = '';
667        body.style.display = '';
668        foot.style.display = '';
669        startChat();
670      });
671    });
672  }
673
674  function resumeOrStart() {
675    if (state.messages.length > 0) {
676      state.messages.forEach(function (m) { addBubble(m.role, m.content, null, null, true, m.ts); });
677      if (state.liveMode && state.chatId) connectLiveChat(state.chatId);
678      return;
679    }
680    if (!state.leadCaptured) { showLeadForm('start'); return; }
681    startChat();
682  }
683
684  function greetingText() {
685    if (config.isOpen === false && config.awayMessage) return config.awayMessage;
686    return config.welcomeMessage || "Hi! How can I help you today?";
687  }
688
689  function startChat() {
690    var greeting = greetingText();
691    addBubble('assistant', greeting, null, null, true);
692    state.messages.push({ role: 'assistant', content: greeting });
693    saveState();
694    input.focus();
695    if (pendingQuickReply) {
696      var q = pendingQuickReply;
697      pendingQuickReply = null;
698      send(q);
699    }
700  }
701
702  var LIVE_TRIGGER_RE = /(talk|connect|speak|chat).{0,20}(human|agent|representative|person|team member)|human agent|live agent|real person/i;
703
704  async function send(text) {
705    var content = (text !== undefined ? text : input.value).trim();
706    if (!content || busy) return;
707
708    if (state.liveMode) { sendLiveMessage(content); return; }
709
710    if (LIVE_TRIGGER_RE.test(content)) {
711      input.value = ''; input.style.height = 'auto';
712      startLiveChat();
713      return;
714    }
715
716    busy = true; sendBtn.disabled = true;
717    var now = new Date();
718    addBubble('user', content, null, null, true, now);
719    state.messages.push({ role: 'user', content: content, ts: now });
720    saveState();
721    input.value = ''; input.style.height = 'auto';
722    showTyping();
723
724    try {
725      var res = await fetch(API_BASE + "/chat/" + businessId, {
726        method: 'POST', headers: { 'Content-Type': 'application/json' },
727        body: JSON.stringify({
728          messages: state.messages.filter(function (m) { return m.role === 'user' || m.role === 'assistant'; }),
729          sessionId: state.sessionId
730        })
731      });
732      var data = await res.json();
733      hideTyping();
734      var reply = data.reply || "Sorry, something went wrong. Please try again.";
735      var replyTime = new Date();
736      var cta = (config.ctaLinks && config.ctaLinks[0]) ? config.ctaLinks[0] : null;
737      addBubble('assistant', reply, cta ? cta.url : null, cta ? cta.label : null, false, replyTime, data.chatLogId);
738      state.messages.push({ role: 'assistant', content: reply, ts: replyTime });
739      saveState();
740    } catch (e) {
741      hideTyping();
742      addBubble('assistant', "Sorry, something went wrong. Please try again.", null, null, false, new Date());
743    } finally {
744      busy = false; sendBtn.disabled = false; input.focus();
745    }
746  }
747
748  // ---------- hamburger menu actions ----------
749  function buildTranscriptText() {
750    var lines = state.messages.map(function (m) {
751      var who = m.role === 'user' ? 'You' : (m.role === 'rep' ? 'Team member' : (m.role === 'system' ? 'System' : (config.name || 'Assistant')));
752      return who + ': ' + m.content;
753    });
754    return lines.length ? lines.join('\n\n') : 'No conversation yet.';
755  }
756
757  function downloadConversation() {
758    var text = buildTranscriptText();
759    var blob = new Blob([text], { type: 'text/plain' });
760    var url = URL.createObjectURL(blob);
761    var a = document.createElement('a');
762    a.href = url;
763    a.download = 'chat-transcript-' + new Date().toISOString().slice(0, 10) + '.txt';
764    document.body.appendChild(a);
765    a.click();
766    document.body.removeChild(a);
767    URL.revokeObjectURL(url);
768    menuPanel.classList.remove('open');
769  }
770
771  function emailTranscript() {
772    menuPanel.classList.remove('open');
773    var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
774    var email = (state.visitor && state.visitor.email && EMAIL_RE.test(state.visitor.email)) ? state.visitor.email : null;
775    if (!email) {
776      email = window.prompt('Enter your email to receive this conversation:');
777      if (!email || !EMAIL_RE.test(email)) return;
778    }
779    addSystem('Sending the conversation to ' + email + '…');
780    fetch(API_BASE + "/public/transcript/" + businessId, {
781      method: 'POST', headers: { 'Content-Type': 'application/json' },
782      body: JSON.stringify({ email: email, transcript: buildTranscriptText() })
783    }).then(function (r) { return r.json(); }).then(function (data) {
784      addSystem(data.success ? 'Sent! Check your inbox.' : 'Could not send that right now. Please try again.');
785    }).catch(function () {
786      addSystem('Could not send that right now. Please try again.');
787    });
788  }
789
790  function leaveChat() {
791    if (!confirm('Leave this chat? This will clear your conversation and end any active live chat.')) return;
792
793    if (state.liveMode && state.chatId) {
794      fetch(API_BASE + "/livechat/" + state.chatId + "/leave", { method: 'POST' }).catch(function () {});
795      if (socket) socket.disconnect();
796    }
797
798    try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
799    state = { leadCaptured: false, visitor: { name: "", email: "", phone: "" }, messages: [], liveMode: false, chatId: null, sessionId: genSessionId() };
800    saveState();
801    seenMsgIds = {};
802    socket = null;
803    typingRow = null;
804    body.innerHTML = '';
805    menuPanel.classList.remove('open');
806    humanBtn.style.display = 'inline-flex';
807    humanBtn.disabled = false;
808    resumeOrStart();
809  }
810
811  // ================= LIVE AGENT HANDOFF =================
812
813  function loadSocketIO(cb) {
814    if (window.io) { cb(); return; }
815    var s = document.createElement('script');
816    s.src = API_ROOT + "/socket.io/socket.io.js";
817    s.onload = cb;
818    document.head.appendChild(s);
819  }
820
821  function startLiveChat() {
822    if (state.liveMode) { addSystem("You're already connected. Send your message below."); return; }
823    if (!state.leadCaptured) { showLeadForm('live'); return; }
824    actuallyStartLiveChat();
825  }
826
827  function actuallyStartLiveChat() {
828    humanBtn.disabled = true;
829    fetch(API_BASE + "/livechat/create/" + businessId, {
830      method: 'POST', headers: { 'Content-Type': 'application/json' },
831      body: JSON.stringify({ name: state.visitor.name, email: state.visitor.email, phone: state.visitor.phone, sessionId: state.sessionId })
832    }).then(function (r) { return r.json(); })
833      .then(function (data) {
834        if (!data.chatId) {
835          var unavailableMsg = data.message || "Live chat isn't available right now.";
836          addSystem(unavailableMsg);
837          state.messages.push({ role: 'system', content: unavailableMsg });
838          saveState();
839          humanBtn.disabled = false;
840          return;
841        }
842        state.liveMode = true;
843        state.chatId = data.chatId;
844        saveState();
845        var msg = "Connecting you to a team member. Hang tight, someone will join shortly.";
846        addSystem(msg);
847        state.messages.push({ role: 'system', content: msg });
848        saveState();
849        connectLiveChat(data.chatId);
850      });
851  }
852
853  function connectLiveChat(chatId) {
854    humanBtn.style.display = 'none';
855    loadSocketIO(function () {
856      if (!socket) socket = io(API_ROOT);
857      // Re-join on every (re)connect, not just the first — otherwise a
858      // dropped/reconnected socket silently stops receiving replies.
859      socket.off('connect');
860      socket.on('connect', function () { socket.emit('join_chat', chatId); });
861      socket.emit('join_chat', chatId);
862
863      socket.off('new_message');
864      socket.on('new_message', function (m) {
865        var ts = m.timestamp || m.createdAt || new Date();
866        if (m.sender === 'system') {
867          hideTyping();
868          addSystem(m.text, ts);
869          state.messages.push({ role: 'system', content: m.text, ts: ts });
870          saveState();
871          return;
872        }
873        if (m.sender === 'rep') hideTyping();
874        var role = m.sender === 'rep' ? 'rep' : 'user';
875        addBubble(role, m.text, null, null, role === 'user', ts);
876        state.messages.push({ role: role, content: m.text, ts: ts });
877        saveState();
878      });
879
880      socket.off('agent_typing');
881      socket.on('agent_typing', function () {
882        showTyping(true);
883        // Auto-hide after 4 seconds if no message arrives
884        clearTimeout(typingTimeout);
885        typingTimeout = setTimeout(hideTyping, 4000);
886      });
887
888      socket.off('chat_updated');
889      socket.on('chat_updated', function (chat) {
890        if (chat.status === 'closed' && state.liveMode) {
891          hideTyping();
892          var msg = "This chat has been closed. Type a message below to chat with our AI guide again.";
893          addSystem(msg);
894          state.messages.push({ role: 'system', content: msg });
895          state.liveMode = false;
896          saveState();
897          humanBtn.style.display = 'inline-flex';
898          humanBtn.disabled = false;
899        }
900      });
901    });
902  }
903
904  function sendLiveMessage(content) {
905    input.value = ''; input.style.height = 'auto';
906    // Do NOT show typing here — typing indicator is for the AGENT, not the visitor
907    fetch(API_BASE + "/livechat/" + state.chatId + "/message", {
908      method: 'POST', headers: { 'Content-Type': 'application/json' },
909      body: JSON.stringify({ sender: 'visitor', text: content })
910    });
911  }
912})();

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.