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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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.