PageSourceSearch

https://www.voxplo.ai/widget.js

js voxplo.ai collected 2026-10-05 15:19:12 UTC 39,943 bytes, 690 lines download raw bytes

1/* Voxplo — embeddable chat + click-to-call widget loader.
2 *
3 * A business pastes ONE tag on their site:
4 *   <script src="https://app.voxplo.ai/widget.js"
5 *           data-key="bys_pk_..." data-brain="https://api.voxplo.ai"
6 *           data-agent="" data-name="Voxplo"></script>
7 *
8 * It renders a floating panel in a Shadow DOM (style-isolated) and talks to the
9 * brain's public widget endpoints (embed-key + origin authed):
10 *   POST {brain}/widget/session   -> { sessionToken, businessName, callToken?, scope?, rtcPath? }
11 *   POST {brain}/widget/message   -> { answer }
12 *
13 * Look matches the voxplo.ai site: indigo→blue brand gradient, serif title,
14 * rounded surfaces, soft elevation. Colors are themeable per embed (data-grad-*).
15 */
16(function () {
17  var S = document.currentScript;
18  // Config comes from the <script> tag's data-* attributes (customer embeds) OR,
19  // when the script is injected programmatically (e.g. voxplo.ai's own every-page
20  // embed, where document.currentScript is null), from window.VoxploWidget (window.BYourSideWidget kept for back-compat).
21  var CFG = (typeof window !== 'undefined' && (window.VoxploWidget || window.BYourSideWidget)) || {};
22  // Origin that serves this widget — and the Turnstile iframe page (widget-turnstile.html).
23  // Derived from the <script> src so it follows wherever the widget is hosted; falls back
24  // to the canonical host for the programmatic/own-site embed where currentScript is null.
25  var SELF_ORIGIN = (function () {
26    try { if (S && S.src) return new URL(S.src).origin; } catch (e) {}
27    return 'https://www.voxplo.ai';
28  })();
29  function rd(attr, key) {
30    var v = S ? S.getAttribute(attr) : null;
31    if (v != null && v !== '') return v;
32    return (CFG[key] != null && CFG[key] !== '') ? String(CFG[key]) : null;
33  }
34  var KEY = rd('data-key', 'key') || '';
35  var BRAIN = (rd('data-brain', 'brain') || '').replace(/\/$/, '');
36  var AGENT = rd('data-agent', 'agent') || '';
37  var HAS_NAME = !!rd('data-name', 'name'); // owner set an explicit title
38  var NAME = rd('data-name', 'name') || 'Assistant';
39
40  // ---- Returning-visitor memory (first-party, this device only) -------------
41  // Namespaced per embed key so two BYS widgets never share identity. Stores a
42  // random visitorId (device marker), plus the contact/name the visitor gave.
43  var MEMKEY = 'bys:' + (KEY ? KEY.slice(0, 16) : 'anon') + ':v';
44  function memLoad() {
45    try { return JSON.parse(localStorage.getItem(MEMKEY) || '{}') || {}; } catch (e) { return {}; }
46  }
47  function memSave(patch) {
48    try {
49      var cur = memLoad(); for (var k in patch) cur[k] = patch[k];
50      localStorage.setItem(MEMKEY, JSON.stringify(cur)); return cur;
51    } catch (e) { return {}; }
52  }
53  function memClear() { try { localStorage.removeItem(MEMKEY); } catch (e) {} }
54  var mem = memLoad();
55  if (!mem.visitorId) { mem = memSave({ visitorId: uuid() }); }  // uuid() defined later via hoisted function
56
57  // ---- Brand colors (default to BYS brand) ----------------------------------
58  // Strict hex validation: these values are substituted into a stylesheet, so
59  // anything that isn't a #hex color is dropped to the default — no breaking out
60  // of the CSS context.
61  function hex(v, fallback) {
62    return (typeof v === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.trim())) ? v.trim() : fallback;
63  }
64  // Accept data-color as a convenience alias for the gradient start.
65  var GRAD0 = hex(rd('data-grad-start', 'gradStart') || rd('data-color', 'color'), '#6F6BEA');
66  var GRAD1 = hex(rd('data-grad-end', 'gradEnd'), '#2F73F2');
67  // Optional middle stop. Omitted -> two-stop gradient, exactly as before.
68  var GRADM = hex(rd('data-grad-mid', 'gradMid'), '');
69  var GRAD = 'linear-gradient(135deg, ' + GRAD0 + (GRADM ? ', ' + GRADM : '') + ', ' + GRAD1 + ')';
70  // Accent for text and focus rings. Kept separate from the gradient because a
71  // gradient may legitimately start on a light hue (Voxplo's starts on cyan) that
72  // would be unreadable as 12px text or a focus ring. Defaults to GRAD0, so
73  // embeds that don't set it behave exactly as they did before.
74  var ACCENT = hex(rd('data-accent', 'accent'), GRAD0);
75
76  function hexToRgb(h) {
77    h = (h || '').replace('#', '');
78    if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
79    var n = parseInt(h, 16);
80    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
81  }
82  function rgba(h, a) { var c = hexToRgb(h); return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
83
84  // Clean white chat-bubble + AI-spark glyph for the launcher + header (sits on the gradient).
85  // Speech bubble silhouette (reads as chat) with a 4-point sparkle inside (reads as AI).
86  function chatGlyph(size) {
87    return '<svg viewBox="0 0 24 24" width="' + size + '" height="' + size + '" fill="none" '
88      + 'stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
89      + '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'
90      + '<path d="M9.2 7.4c.3 1.7 1.1 2.5 2.8 2.8-1.7.3-2.5 1.1-2.8 2.8-.3-1.7-1.1-2.5-2.8-2.8 1.7-.3 2.5-1.1 2.8-2.8z" fill="#fff" stroke-width="1.1" stroke-linejoin="round"/>'
91      + '<path d="M14.5 12.6h2.7" /></svg>';
92  }
93  var PHONE = '<svg viewBox="0 0 24 24"><path d="M6.6 10.8c1.4 2.8 3.8 5.2 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.4 0 .8-.3 1l-2.2 2.2z"/></svg>';
94
95  if (!KEY || !BRAIN) { console.error('[voxplo] data-key and data-brain are required'); return; }
96
97  // ---- Shadow host so the page's CSS can't touch us -------------------------
98  var host = document.createElement('div');
99  host.setAttribute('data-voxplo-widget', '');
100  document.body.appendChild(host);
101  var root = host.attachShadow({ mode: 'open' });
102
103  var SANS = '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif';
104  var SERIF = '"Instrument Serif",Georgia,"Times New Roman",serif';
105
106  var style = document.createElement('style');
107  style.textContent = [
108    "@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap');",
109    ':host, * { box-sizing: border-box; }',
110    // Launcher — gradient disc, white chat glyph, brand-tinted lift.
111    '.launch { position: fixed; right: 20px; bottom: 20px; width: 60px; height: 60px; border-radius: 50%; background: ' + GRAD + '; padding: 0; cursor: pointer; box-shadow: 0 10px 26px ' + rgba(GRAD0, .42) + '; border: none; display: flex; align-items: center; justify-content: center; transition: transform .15s ease; }',
112    '.launch:hover { transform: translateY(-2px) scale(1.04); }',
113    '.launch svg { width: 27px; height: 27px; display: block; }',
114    // Panel
115    '.panel { position: fixed; right: 20px; bottom: 92px; width: 384px; max-width: calc(100vw - 40px); height: 560px; max-height: calc(100vh - 120px); background: #fff; border: 1px solid #e6e8f1; border-radius: 18px; box-shadow: 0 18px 50px rgba(22,24,46,.22); display: none; flex-direction: column; overflow: hidden; }',
116    '.panel.open { display: flex; }',
117    // Header — brand gradient, serif title
118    // Header — clean white, serif name, gradient avatar + online status (not a SaaS color bar)
119    '.head { background: #fff; border-bottom: 1px solid #e9eaf2; padding: 13px 14px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }',
120    '.head .title { display: flex; align-items: center; gap: 11px; min-width: 0; }',
121    '.head .ava { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 12px; background: ' + GRAD + '; display: flex; align-items: center; justify-content: center; box-shadow: 0 5px 14px ' + rgba(GRAD0, .38) + '; }',
122    '.head .ava svg { width: 21px; height: 21px; }',
123    '.head .htext { min-width: 0; line-height: 1.1; }',
124    '.head .hname { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 19px/1.15 ' + SERIF + '; color: #15171e; }',
125    '.head .hsub { display: flex; align-items: center; gap: 5px; font: 500 11.5px ' + SANS + '; color: #8a90a0; margin-top: 2px; }',
126    '.head .hsub .dot { width: 6px; height: 6px; border-radius: 50%; background: #34c759; display: inline-block; }',
127    '.head .actions { display: flex; gap: 2px; align-items: center; }',
128    '.head button { background: transparent; border: none; color: #585e6c; cursor: pointer; line-height: 1; padding: 7px; border-radius: 9px; display: flex; align-items: center; transition: background .15s ease; }',
129    '.head button:hover { background: #f1f2fa; }',
130    '.head button.x { font-size: 20px; }',
131    '.head button svg { width: 19px; height: 19px; fill: #585e6c; }',
132    // Editorial welcome (the opening state) — serif headline, prominent voice CTA, quick chips
133    '.welcome { padding: 6px 2px 4px; }',
134    '.welcome .wtitle { font: 400 25px/1.18 ' + SERIF + '; color: #15171e; margin: 2px 0 6px; }',
135    '.welcome .wsub { font: 14px/1.55 ' + SANS + '; color: #585e6c; margin-bottom: 16px; }',
136    '.welcome .wcall { width: 100%; display: flex; align-items: center; justify-content: center; gap: 9px; background: ' + GRAD + '; color: #fff; border: none; border-radius: 13px; padding: 14px; font: 600 15px ' + SANS + '; cursor: pointer; box-shadow: 0 10px 22px ' + rgba(GRAD0, .32) + '; transition: filter .15s ease, transform .15s ease; }',
137    '.welcome .wcall:hover { filter: brightness(1.06); transform: translateY(-1px); }',
138    '.welcome .wcall svg { width: 18px; height: 18px; fill: #fff; }',
139    '.welcome .chiplabel { font: 600 11px ' + SANS + '; letter-spacing: .06em; text-transform: uppercase; color: #969cab; margin: 18px 0 9px; }',
140    '.welcome .chips { display: flex; flex-direction: column; gap: 8px; }',
141    '.welcome .chip { text-align: left; background: #fff; border: 1px solid #e6e8f1; color: #15171e; border-radius: 11px; padding: 11px 14px; font: 500 14px ' + SANS + '; cursor: pointer; box-shadow: 0 1px 2px rgba(22,24,46,.04); transition: border-color .15s ease, transform .15s ease; }',
142    '.welcome .chip:hover { border-color: ' + GRAD0 + '; transform: translateX(2px); }',
143    '.welcome .memnote { margin-top: 14px; font: 12px ' + SANS + '; color: #969cab; }',
144    '.welcome .memnote .memforget { background: none; border: none; padding: 0; color: ' + ACCENT + '; font: 600 12px ' + SANS + '; cursor: pointer; text-decoration: underline; }',
145    // Body + bubbles (mirror the site chat)
146    '.body { flex: 1; overflow-y: auto; padding: 16px; background: #f6f7fb; }',
147    '.msg { margin: 8px 0; max-width: 82%; padding: 10px 13px; border-radius: 14px; font: 14px/1.5 ' + SANS + '; white-space: pre-wrap; word-wrap: break-word; }',
148    '.msg.user { margin-left: auto; background: ' + GRAD + '; color: #fff; border-bottom-right-radius: 5px; }',
149    '.msg.bot { background: #fff; color: #15171e; border: 1px solid #e6e8f1; border-bottom-left-radius: 5px; box-shadow: 0 1px 2px rgba(22,24,46,.05); }',
150    '.msg.err { background: #fde8e8; color: #9b1c1c; border: 1px solid #f6cccc; }',
151    // Footer input
152    '.foot { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #eceef5; background: #fff; }',
153    '.foot input { flex: 1; border: 1px solid #d9dce6; border-radius: 11px; padding: 11px 13px; font: 14px ' + SANS + '; outline: none; transition: border-color .15s ease, box-shadow .15s ease; }',
154    '.foot input:focus { border-color: ' + ACCENT + '; box-shadow: 0 0 0 3px ' + rgba(ACCENT, .16) + '; }',
155    '.foot button { background: ' + GRAD + '; color: #fff; border: none; border-radius: 11px; padding: 0 18px; font: 600 14px ' + SANS + '; cursor: pointer; transition: filter .15s ease; }',
156    '.foot button:hover { filter: brightness(1.05); }',
157    // Call overlay
158    '.callbar { display: none; flex-direction: column; align-items: center; justify-content: center; gap: 14px; flex: 1; background: #f6f7fb; padding: 28px 24px; text-align: center; }',
159    '.callbar.on { display: flex; }',
160    // Pre-call intake anchors to the top so the fields do not float in the middle with large empty space; scrolls if all fields are on.
161    '.callbar.precall { justify-content: flex-start; overflow-y: auto; }',
162    '.callbar .status { font: 600 17px ' + SANS + '; color: #15171e; }',
163    '.callbar .sub { font: 13px/1.5 ' + SANS + '; color: #585e6c; max-width: 260px; }',
164    '.callbar .pulse { width: 72px; height: 72px; border-radius: 50%; background: ' + GRAD + '; display: flex; align-items: center; justify-content: center; box-shadow: 0 10px 26px ' + rgba(GRAD0, .35) + '; }',
165    '.callbar .pulse.live { animation: bys-pulse 1.4s ease-in-out infinite; }',
166    '@keyframes bys-pulse { 0%,100% { box-shadow: 0 0 0 0 ' + rgba(GRAD0, .45) + ';} 50% { box-shadow: 0 0 0 16px ' + rgba(GRAD0, 0) + ';} }',
167    '.callbar .pulse svg { width: 32px; height: 32px; fill: #fff; }',
168    '.callbar .contact, .callbar .f-name, .callbar .f-email, .callbar .f-phone { width: 100%; max-width: 280px; border: 1px solid #d9dce6; border-radius: 11px; padding: 11px 13px; font: 14px ' + SANS + '; outline: none; text-align: center; }',
169    '.callbar .contact:focus, .callbar .f-name:focus, .callbar .f-email:focus, .callbar .f-phone:focus { border-color: ' + ACCENT + '; box-shadow: 0 0 0 3px ' + rgba(ACCENT, .16) + '; }',
170    '.callbar .cerr { color: #9b1c1c; font: 13px ' + SANS + '; min-height: 0; }',
171    '.callbar .start { background: ' + GRAD + '; color: #fff; border: none; border-radius: 999px; padding: 12px 26px; font: 600 15px ' + SANS + '; cursor: pointer; box-shadow: 0 8px 20px ' + rgba(GRAD0, .35) + '; }',
172    '.callbar .end { background: #e02424; color: #fff; border: none; border-radius: 999px; padding: 12px 26px; font: 600 15px ' + SANS + '; cursor: pointer; }',
173    // Cloudflare Turnstile human-check: only shown (during the connecting state) when
174    // the brain challenges a call; hidden otherwise.
175    '.callbar .turnstile { display: none; min-height: 0; margin-top: 6px; }',
176    '.callbar.on .turnstile.show { display: block; }',
177    '.callbar .contact, .callbar .f-name, .callbar .f-email, .callbar .f-phone, .callbar .cerr, .callbar .start { display: none; }',
178    '.callbar.precall .contact { display: block; }',
179    '.callbar.precall .f-name.show, .callbar.precall .f-email.show, .callbar.precall .f-phone.show { display: block; }',
180    '.callbar.precall.intake .contact { display: none; }',
181    '.callbar.precall .cerr { display: block; }',
182    '.callbar.precall .start { display: inline-block; }',
183    '.callbar.precall .end { display: none; }',
184    '.byline { text-align: center; font: 11px ' + SANS + '; color: #969cab; padding: 7px; background: #fff; border-top: 1px solid #f0f1f6; }',
185    '.byline b { font-weight: 600; color: #585e6c; }',
186  ].join('');
187  root.appendChild(style);
188
189  // ---- DOM ------------------------------------------------------------------
190  var launch = document.createElement('button');
191  launch.className = 'launch';
192  launch.setAttribute('aria-label', 'Open chat assistant');
193  launch.innerHTML = chatGlyph(27);
194
195  var panel = document.createElement('div');
196  panel.className = 'panel';
197  panel.innerHTML = [
198    '<div class="head"><div class="title"><span class="ava"></span><span class="htext"><span class="hname"></span><span class="hsub"><span class="dot"></span>AI assistant</span></span></div><div class="actions">',
199    '<button class="call" aria-label="Call" title="Call">' + PHONE + '</button>',
200    '<button class="x" aria-label="Close">×</button>',
201    '</div></div>',
202    '<div class="body"></div>',
203    '<div class="callbar"><div class="pulse">' + PHONE + '</div><div class="status">Connecting…</div><div class="sub"></div>'
204      + '<input class="contact" type="text" placeholder="Your email or phone, so we can call you back" autocomplete="email" />'
205      // data-ph is the placeholder BEFORE any "(optional)" suffix. setField() rebuilds
206      // the placeholder from it on every open, so reopening the panel cannot compound
207      // into "Your phone (optional) (optional)".
208      + '<input class="f-name" type="text" placeholder="Your name" data-ph="Your name" autocomplete="name" />'
209      + '<input class="f-email" type="email" placeholder="Your email" data-ph="Your email" autocomplete="email" />'
210      + '<input class="f-phone" type="tel" placeholder="Your phone" data-ph="Your phone" autocomplete="tel" />'
211      + '<div class="cerr"></div><div class="turnstile"></div><button class="start">Start call</button><button class="end">End call</button></div>',
212    '<div class="foot"><input type="text" placeholder="Type a message…" /><button>Send</button></div>',
213    '<div class="byline">Powered by <b>Voxplo</b></div>',
214  ].join('');
215  panel.querySelector('.head .hname').textContent = NAME;
216  panel.querySelector('.head .ava').innerHTML = chatGlyph(21);
217
218  root.appendChild(launch);
219  root.appendChild(panel);
220
221  var bodyEl = panel.querySelector('.body');
222  var inputEl = panel.querySelector('.foot input');
223  var sendEl = panel.querySelector('.foot button');
224  var footEl = panel.querySelector('.foot');
225  var bylineEl = panel.querySelector('.byline');
226  var closeEl = panel.querySelector('.head button.x');
227  var callBtn = panel.querySelector('.head button.call');
228  var callbar = panel.querySelector('.callbar');
229  var callStatus = callbar.querySelector('.status');
230  var callSub = callbar.querySelector('.sub');
231  var callPulse = callbar.querySelector('.pulse');
232  var contactEl = callbar.querySelector('.contact');
233  var nameEl = callbar.querySelector('.f-name');
234  var emailEl = callbar.querySelector('.f-email');
235  var phoneEl = callbar.querySelector('.f-phone');
236  var cerrEl = callbar.querySelector('.cerr');
237  var startBtn = callbar.querySelector('.start');
238  var endBtn = callbar.querySelector('.end');
239  var turnstileEl = callbar.querySelector('.turnstile');
240
241  // Hidden audio sink for the remote (assistant) voice. playsinline is required so
242  // iOS Safari plays it inline instead of going fullscreen; autoplay + a play() call
243  // inside the tap gesture satisfy Safari's audio-activation rule.
244  var audioEl = document.createElement('audio');
245  audioEl.autoplay = true;
246  audioEl.setAttribute('playsinline', '');
247  root.appendChild(audioEl);
248
249  var sessionToken = null;
250  // Owner-configured intake fields (off|optional|required per field), arrives on the
251  // /widget/session response. Null until the session resolves; the call form then falls
252  // back to the single contact input. The default mirrors the brain's DEFAULT_INTAKE.
253  var intake = null;
254  var DEFAULT_INTAKE = { name: 'optional', email: 'required', phone: 'off' };
255  var capturedContact = '';   // the callback email/phone the visitor gave in the pre-call form
256  var greeted = false;
257  var history = [];   // prior chat turns {role, content} — sent so the bot follows the thread
258
259  // ---- helpers --------------------------------------------------------------
260  function bubble(kind, text) {
261    var d = document.createElement('div');
262    d.className = 'msg ' + kind;
263    d.textContent = text;
264    bodyEl.appendChild(d);
265    bodyEl.scrollTop = bodyEl.scrollHeight;
266    return d;
267  }
268
269  function post(path, body) {
270    return fetch(BRAIN + path, {
271      method: 'POST',
272      headers: { 'content-type': 'application/json' },
273      body: JSON.stringify(body),
274    }).then(function (r) { return r.json().catch(function () { return {}; }); });
275  }
276
277  function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]; }); }
278
279  // ---- session + messaging --------------------------------------------------
280  // The opening view: a short editorial welcome with a prominent voice-call CTA
281  // and a few quick prompts — not an empty chat thread.
282  function renderWelcome() {
283    var w = document.createElement('div');
284    w.className = 'welcome';
285    var returning = !!(mem && mem.name);
286    var title = returning ? ('Welcome back, ' + escapeHtml(mem.name) + '.') : 'How can we help?';
287    w.innerHTML = '<div class="wtitle">' + title + '</div>'
288      + '<div class="wsub">Ask anything below, or talk to the assistant on a quick voice call.</div>'
289      + '<button class="wcall" type="button">' + PHONE + '<span>Start a voice call</span></button>'
290      + '<div class="chiplabel">Or ask</div><div class="chips"></div>'
291      + ((mem && (mem.name || mem.contact)) ? '<div class="memnote">We remember you on this device. <button type="button" class="memforget">Not you?</button></div>' : '');
292    var chips = ['What can you do?', 'How does pricing work?', 'Can I book a call back?'];
293    var cw = w.querySelector('.chips');
294    chips.forEach(function (c) {
295      var b = document.createElement('button');
296      b.className = 'chip';
297      b.type = 'button';
298      b.textContent = c;
299      b.addEventListener('click', function () { inputEl.value = c; send(); });
300      cw.appendChild(b);
301    });
302    w.querySelector('.wcall').addEventListener('click', openCallForm);
303    var forget = w.querySelector('.memforget');
304    if (forget) forget.addEventListener('click', function () {
305      memClear(); mem = memLoad(); if (!mem.visitorId) mem = memSave({ visitorId: uuid() });
306      bodyEl.innerHTML = ''; greeted = false; greet();   // reset to a first-time widget
307    });
308    bodyEl.appendChild(w);
309  }
310
311  function greet() {
312    if (greeted) return;
313    greeted = true;
314    renderWelcome();
315    post('/widget/session', { key: KEY, agentId: AGENT, mode: 'chat', visitorId: mem.visitorId }).then(function (r) {
316      if (r && r.sessionToken) sessionToken = r.sessionToken;
317      if (r && r.intake) intake = r.intake;
318      // The owner's chosen title (data-name) wins; only fall back to the
319      // business name from the brain if no title was set.
320      if (!HAS_NAME && r && r.businessName) panel.querySelector('.head .hname').textContent = r.businessName;
321    }).catch(function () {});
322  }
323
324  function send() {
325    var q = (inputEl.value || '').trim();
326    if (!q) return;
327    inputEl.value = '';
328    bubble('user', q);
329    var typing = bubble('bot', '…');
330    post('/widget/message', { key: KEY, agentId: AGENT, question: q, sessionToken: sessionToken, history: history.slice(-10), visitorId: mem.visitorId, contact: (mem.contact || undefined), name: (mem.name || undefined) })
331      .then(function (r) {
332        typing.remove();
333        var a = (r && r.answer) ? r.answer : 'Sorry, something went wrong.';
334        bubble('bot', a);
335        history.push({ role: 'user', content: q });
336        history.push({ role: 'assistant', content: a });   // remembered for the next turn
337      })
338      .catch(function () { typing.remove(); bubble('err', 'Network error — please try again.'); });
339  }
340
341  // ---- click-to-call (WebRTC) ----------------------------------------------
342  var pc = null, ws = null, localStream = null, inCall = false;
343
344  function uuid() {
345    try { return crypto.randomUUID(); } catch (e) {}
346    return 'web-' + Date.now() + '-' + Math.random().toString(16).slice(2);
347  }
348
349  // Back to the chat view — leave any call / pre-call state and clear the form.
350  function showChatUI() {
351    inCall = false;
352    callbar.classList.remove('on', 'precall', 'intake');
353    bodyEl.style.display = '';
354    footEl.style.display = '';
355    if (bylineEl) bylineEl.style.display = '';
356    callBtn.style.display = '';
357    if (contactEl) contactEl.value = '';
358    if (nameEl) nameEl.value = '';
359    if (emailEl) emailEl.value = '';
360    if (phoneEl) phoneEl.value = '';
361    capturedContact = '';
362    if (cerrEl) cerrEl.textContent = '';
363  }
364
365  // Pre-call: ask for a callback email/phone BEFORE the call starts, so the owner
366  // always has a way to reach the visitor back. The call only begins once they give
367  // a valid one and press Start (which is also the user gesture the mic needs).
368  // The intake config in force (owner's choice, or the default if the session didn't
369  // carry one). At least one contact field is always required (the brain guarantees it).
370  function activeIntake() { return intake || DEFAULT_INTAKE; }
371
372  function openCallForm() {
373    if (inCall) return;
374    callbar.classList.add('on', 'precall');
375    bodyEl.style.display = 'none';
376    footEl.style.display = 'none';
377    if (bylineEl) bylineEl.style.display = 'none';
378    callBtn.style.display = 'none';
379    callStatus.textContent = 'Start a voice call';
380    callPulse.classList.remove('live');
381    if (cerrEl) cerrEl.textContent = '';
382
383    // Fallback: when the session carried no intake config, keep the original single
384    // contact box (email or phone in one field).
385    if (!intake) {
386      callbar.classList.remove('intake');
387      callSub.textContent = 'Leave a callback email or phone so we can reach you.';
388      if (contactEl) { contactEl.value = (mem && mem.contact) ? mem.contact : '';
388 setTimeout(function () { try { contactEl.focus(); } catch (e) {} }, 50); }
389      return;
390    }
391
392    var cfg = activeIntake();
393    callbar.classList.add('intake');
394    function setField(el, state, prefill) {
395      if (!el) return;
396      var on = state === 'optional' || state === 'required';
397      el.classList.toggle('show', on);
398      el.required = (state === 'required');
399      // Say which fields can be skipped. Without this the form enforces `required`
400      // silently: the visitor only discovers a field was mandatory when Start call
401      // rejects them. Mark the optional ones rather than the required ones — owners
402      // typically make most fields required, so tagging the exception is quieter.
403      // Rebuild from data-ph (never append) so reopening cannot double the suffix.
404      var base = el.getAttribute('data-ph');
405      if (base) el.placeholder = base + (state === 'optional' ? ' (optional)' : '');
406      el.value = prefill || '';
407    }
408    setField(nameEl, cfg.name, (mem && mem.name) || '');
409    // Pre-fill whichever contact the visitor gave last time into the matching field.
410    // mem.contact may be a combined "email | phone"; split it back so each field gets
411    // its own value, not the whole combined string.
412    var memEmail = '', memPhone = '';
413    if (mem && mem.contact) {
414      mem.contact.split('|').forEach(function (part) {
415        part = part.trim();
416        if (!part) return;
417        if (part.indexOf('@') !== -1) { if (!memEmail) memEmail = part; }
418        else if (!memPhone) { memPhone = part; }
419      });
420    }
421    setField(emailEl, cfg.email, memEmail);
422    setField(phoneEl, cfg.phone, memPhone);
423
424    var first = (cfg.name !== 'off') ? nameEl : (cfg.email !== 'off') ? emailEl : phoneEl;
425    callSub.textContent = 'Tell us how to reach you back.';
426    setTimeout(function () { try { (first || contactEl).focus(); } catch (e) {} }, 50);
427  }
428
429  // Basic "looks like an email or a phone number" check — enough to stop blanks and
430  // obvious typos without rejecting valid international formats.
431  function validEmail(v) { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test((v || '').trim()); }
432  function validPhone(v) { v = (v || '').trim(); return /^[+]?[\d\s().\-]{6,}$/.test(v) && v.replace(/\D/g, '').length >= 6; }
433  function validContact(v) {
434    v = (v || '').trim();
435    if (v.length < 5) return false;
436    return validEmail(v) || validPhone(v);
437  }
438
439  // Collect + validate the intake fields the owner asked for. Returns
440  // { ok, contact, name, err }. `contact` carries the email AND phone the owner can
441  // reach the visitor back on: when the visitor gave BOTH, they are combined as
442  // "email | phone" (only the parts present) so the owner gets both; a single value
443  // when only one was given. The brain splits this back into clean email/phone parts.
444  function collectIntake() {
445    var cfg = activeIntake();
446    var nm = (nameEl && nameEl.value || '').trim();
447    var em = (emailEl && emailEl.value || '').trim();
448    var ph = (phoneEl && phoneEl.value || '').trim();
449    if (cfg.name === 'required' && !nm) return { ok: false, err: 'Please enter your name.' };
450    if (cfg.email === 'required' && !validEmail(em)) return { ok: false, err: 'Please enter a valid email.' };
451    if (cfg.phone === 'required' && !validPhone(ph)) return { ok: false, err: 'Please enter a valid phone number.' };
452    if (em && !validEmail(em)) return { ok: false, err: 'Please enter a valid email.' };
453    if (ph && !validPhone(ph)) return { ok: false, err: 'Please enter a valid phone number.' };
454    // Carry both when both were given (email first), else the single one; never collapse.
455    var contact = [em, ph].filter(Boolean).join(' | ');
456    if (!contact) return { ok: false, err: 'Please leave an email or phone so we can call you back.' };
457    return { ok: true, contact: contact, name: nm || null };
458  }
459
460  function setCallUI(on, status, sub) {
461    inCall = on;
462    if (!on) { showChatUI(); return; }
463    callbar.classList.add('on');
464    callbar.classList.remove('precall');     // leaving the form → connecting/active
465    bodyEl.style.display = 'none';
466    footEl.style.display = 'none';
467    if (bylineEl) bylineEl.style.display = 'none';
468    callBtn.style.display = 'none';
469    if (status) callStatus.textContent = status;
470    callSub.textContent = sub || '';
471    callPulse.classList.toggle('live', status === 'Connected');
472  }
473
474  function endCall(silent) {
475    try { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'bye' })); } catch (e) {}
476    try { if (ws) ws.close(); } catch (e) {}
477    try { if (pc) pc.close(); } catch (e) {}
478    try { if (localStream) localStream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {}
479    pc = null; ws = null; localStream = null;
480    try { audioEl.srcObject = null; } catch (e) {}
481    if (!silent) setCallUI(false);
482  }
483
484  // Wait for ICE gathering to finish so we send a complete (non-trickle) offer,
485  // which is what rtpengine expects (same as the iOS client).
486  function waitIce(p) {
487    return new Promise(function (resolve) {
488      if (p.iceGatheringState === 'complete') return resolve();
489      var done = false;
490      function check() { if (!done && p.iceGatheringState === 'complete') { done = true; p.removeEventListener('icegatheringstatechange', check); resolve(); } }
491      p.addEventListener('icegatheringstatechange', check);
492      setTimeout(function () { if (!done) { done = true; resolve(); } }, 4000); // fallback cap
493    });
494  }
495
496  // Make the engine's answer SDP acceptable to strict browsers (Chrome/Safari).
497  function patchAnswerSdp(answer, offer) {
498    var sdp = String(answer || '');
499    var mid = '0';
500    if (offer) { var mm = offer.match(/\r?\na=mid:([^\r\n]+)/); if (mm) mid = mm[1]; }
501    if (/\r?\na=mid:/.test(sdp)) {
502      sdp = sdp.replace(/a=mid:[^\r\n]+/, 'a=mid:' + mid);          // align to the offer's mid
503    } else {
504      sdp = sdp.replace(/(m=audio[^\r\n]*\r?\n)/, '$1a=mid:' + mid + '\r\n'); // add one
505    }
506    if (/\r?\na=group:BUNDLE/.test(sdp)) {
507      sdp = sdp.replace(/a=group:BUNDLE[^\r\n]*/, 'a=group:BUNDLE ' + mid);
508    } else {
509      sdp = sdp.replace(/(\r?\nt=[^\r\n]*\r?\n)/, '$1a=group:BUNDLE ' + mid + '\r\n');
510    }
511    return sdp;
512  }
513
514  // ---- Cloudflare Turnstile human-check (Anti-Fraud Phase 0, Task 2) ---------
515  // The brain returns { challengeRequired:true, turnstileSiteKey } when this visitor
516  // has hit the call-velocity band. We render a Turnstile (Managed mode) and re-submit
517  // the call request with the resulting token; the brain verifies it and proceeds. The
518  // check only appears when challenged (a normal first call never sees it). When the
519  // brain runs Turnstile UNCONFIGURED it sends no site key (degraded mode) and we never
520  // get here, so the widget behaves exactly as before.
521  // Render the human-check by loading our OWN-domain Turnstile page in an iframe and
522  // awaiting the token it posts back. A Turnstile site key is locked to its allow-listed
523  // hostnames, so rendering it directly in a customer's page (any embed domain) is
524  // rejected by Cloudflare; hosting the check on our domain means Cloudflare always sees
525  // our allow-listed hostname. The brain still verifies the returned token server-side.
526  function solveTurnstile(siteKey) {
527    return new Promise(function (resolve, reject) {
528      var frame = document.createElement('iframe');
529      frame.src = SELF_ORIGIN + '/widget-turnstile.html?sitekey=' + encodeURIComponent(siteKey);
530      frame.title = 'Verification';
531      frame.setAttribute('scrolling', 'no');
532      frame.style.cssText = 'width:100%;height:72px;border:0;display:block;background:transparent;color-scheme:light;';
533      var settled = false;
534      function finish(fn, arg) {
535        if (settled) return;
536        settled = true;
537        window.removeEventListener('message', onMsg);
538        try { frame.remove(); } catch (e) {}
539        turnstileEl.classList.remove('show');
540        turnstileEl.innerHTML = '';
541        fn(arg);
542      }
543      function onMsg(e) {
544        // Accept only messages from the exact iframe we created (this survives the
545        // apex->www redirect, unlike an origin-string compare) and tagged as ours.
546        if (e.source !== frame.contentWindow || !e.data || e.data.source !== 'bys-turnstile') return;
547        if (e.data.token) finish(resolve, e.data.token);
548        else if (e.data.error) finish(reject, new Error('turnstile_error'));
549        // e.data.ready -> the frame mounted; ignore.
550      }
551      window.addEventListener('message', onMsg);
552      turnstileEl.innerHTML = '';
553      turnstileEl.appendChild(frame);
554      turnstileEl.classList.add('show');
555    });
556  }
557
558  // POST a click-to-call session, transparently solving a Turnstile challenge once if
559  // the brain asks for one. Returns the session response. A degraded challenge (flagged
560  // but no site key) just falls through unchanged.
561  function postCallSession() {
562    return post('/widget/session', { key: KEY, agentId: AGENT, mode: 'call' }).then(function (r) {
563      if (r && r.challengeRequired && r.turnstileSiteKey && (!r.ok || !r.callToken)) {
564        setCallUI(true, 'One quick check…', 'Confirming you’re human');
565        // Watchdog: a CSP-blocked or ad-blocked Turnstile iframe never fires its
566        // callbacks, so without a timeout the "Confirming you're human" screen hangs
567        // forever (no resolve, no reject). Race the challenge against 25s; on timeout
568        // reject with turnstile_error → startCall()'s catch shows the friendly
569        // "human-check didn't complete" message and returns the visitor to chat.
570        return Promise.race([
571          solveTurnstile(r.turnstileSiteKey),
572          new Promise(function (_, reject) {
573            setTimeout(function () { reject(new Error('turnstile_error')); }, 25000);
574          }),
575        ]).then(function (token) {
576          setCallUI(true, 'Connecting…', 'Connecting to assistant…');
577          return post('/widget/session', { key: KEY, agentId: AGENT, mode: 'call', turnstileToken: token });
578        });
579      }
580      return r;
581    });
582  }
583
584  function startCall() {
585    if (inCall) return;
586    var precallContact = (capturedContact || (contactEl && contactEl.value) || '').trim();
587    setCallUI(true, 'Connecting…', 'Asking for microphone…');
588    try { audioEl.play().catch(function () {}); } catch (e) {}
589
590    var callToken = null, rtcPath = '/rtc';
591    navigator.mediaDevices.getUserMedia({ audio: true, video: false })
592      .then(function (stream) {
593        localStream = stream;
594        setCallUI(true, 'Connecting…', 'Connecting to assistant…');
595        return postCallSession();
596      })
597      .then(function (r) {
598        if (!r || !r.ok || !r.callToken) throw new Error(r && r.callError ? r.callError : 'call_unavailable');
599        callToken = r.callToken; rtcPath = r.rtcPath || '/rtc';
600        pc = new RTCPeerConnection({
601          iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
602          bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require',
603        });
604        localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });
605        pc.ontrack = function (e) {
606          audioEl.srcObject = e.streams[0];
607          try { audioEl.play().catch(function () {}); } catch (x) {}
608        };
609        pc.onconnectionstatechange = function () {
610          if (!pc) return;
611          if (pc.connectionState === 'connected') setCallUI(true, 'Connected', 'You can talk now');
612          else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') endCall();
613        };
614        var wsUrl = BRAIN.replace(/^http/, 'ws') + rtcPath;
615        ws = new WebSocket(wsUrl);
616        ws.onopen = function () {
617          ws.send(JSON.stringify({ type: 'hello', role: 'web', handle: 'web-visitor', callId: uuid(), token: callToken, contact: precallContact || undefined, visitorId: mem.visitorId }));
618          pc.createOffer({ offerToReceiveAudio: true }).then(function (offer) {
619            return pc.setLocalDescription(offer);
620          }).then(function () { return waitIce(pc); }).then(function () {
621            if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription.sdp }));
622          });
623        };
624        ws.onmessage = function (ev) {
625          var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
626          if (m.type === 'answer') {
627            var sdp = patchAnswerSdp(m.sdp, pc.localDescription && pc.localDescription.sdp);
628            pc.setRemoteDescription({ type: 'answer', sdp: sdp }).catch(function (e) {
629              bubble('err', 'Call setup failed.'); endCall();
630            });
631          } else if (m.type === 'bye') {
632            endCall();
633          }
634        };
635        ws.onclose = function () { if (inCall) endCall(); };
636        ws.onerror = function () {};
637      })
638      .catch(function (err) {
639        var m = String(err && err.message);
640        var msg = (err && err.name === 'NotAllowedError') ? 'Microphone permission denied.'
641          : (m === 'app_calling_not_ready') ? 'Calling isn’t available yet.'
642          : (m === 'turnstile_load_failed' || m === 'turnstile_error') ? 'The human-check didn’t complete. Please try again.'
643          : 'Could not start the call.';
644        endCall(true); setCallUI(false);
645        bubble('err', msg);
646      });
647  }
648
649  // ---- events ---------------------------------------------------------------
650  launch.addEventListener('click', function () {
651    panel.classList.toggle('open');
652    if (panel.classList.contains('open')) greet();
653  });
654  closeEl.addEventListener('click', function () { if (inCall) endCall(); else showChatUI(); panel.classList.remove('open'); });
655  callBtn.addEventListener('click', openCallForm);
656  function tryStart() {
657    // No intake config → original single-contact behavior.
658    if (!intake) {
659      if (!validContact(contactEl.value)) {
660        if (cerrEl) cerrEl.textContent = 'Please enter a valid email or phone number.';
661        try { contactEl.focus(); } catch (e) {}
662        return;
663      }
664      if (cerrEl) cerrEl.textContent = '';
665      capturedContact = contactEl.value.trim();
666      memSave({ contact: capturedContact });
667      startCall();
668      return;
669    }
670    var res = collectIntake();
671    if (!res.ok) {
672      if (cerrEl) cerrEl.textContent = res.err;
673      return;
674    }
675    if (cerrEl) cerrEl.textContent = '';
676    capturedContact = res.contact;
677    // Remember the contact + name on this device so the next visit pre-fills + greets.
678    var patch = { contact: res.contact };
679    if (res.name) patch.name = res.name;
680    memSave(patch);
681    startCall();   // this click is the user gesture the microphone needs
682  }
683  startBtn.addEventListener('click', tryStart);
684  [contactEl, nameEl, emailEl, phoneEl].forEach(function (el) {
685    if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') tryStart(); });
686  });
687  endBtn.addEventListener('click', function () { endCall(); });
688  sendEl.addEventListener('click', send);
689  inputEl.addEventListener('keydown', function (e) { if (e.key === 'Enter') send(); });
690})();

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.