1// Pava chat widget â single-file embed. 2// 3// Everything (styles, markup, logic) self-injects from this one script: 4// 5// <script src="https://<host>/assets/pava-widget.js" async></script> 6// 7// placed before </body> on any page. No other markup, CSS, or build step. 8// The API host defaults to the origin this script was loaded from; override 9// with data-api="https://other-host" on the script tag. Requires the 10// /api/pava/* chat endpoints (src/pava-chat.js) on that host â note those 11// are currently session-authed, so embeds outside Valet need the public 12// endpoint variant before launch. 13// 14// Behavior: 15// - Floating Pearl-navy bubble, bottom-right; click = open/minimize 16// (minimizing keeps the conversation) 17// - Header X = END the chat: marks the Retell session ended server-side 18// and clears the stored conversation 19// - Conversation persists across page navigations (sessionStorage), so 20// the widget can sit on every page of the site 21// - Auto-opens after 5s of user idle, at most once per browser session; 22// any manual open/close disarms it 23// - Esc minimizes; 16px Lato matches pearlscore.com body type; phone 24// (<=599px, the site's breakpoint) gets a full-width bottom sheet with 25// safe-area padding 26(function () { 27 'use strict'; 28 if (window.__pavaWidgetLoaded) return; 29 window.__pavaWidgetLoaded = true; 30 31 var script = document.currentScript; 32 var API = (script && script.getAttribute('data-api')) 33 || (script && script.src ? new URL(script.src).origin : location.origin); 34 // data-public â use the anonymous public endpoints and send NO cookies (for 35 // embedding on pearlscore.com). Default (internal Valet embed) uses the 36 // session-gated endpoints with credentials. 37 var PUBLIC = !!(script && script.hasAttribute('data-public')); 38 var PATH = PUBLIC ? '/api/pava/public' : '/api/pava'; 39 var CREDS = PUBLIC ? 'omit' : 'include'; 40 var STORE_KEYS = { chat: 'pava_chat_id', log: 'pava_transcript', auto: 'pava_auto_done', open: 'pava_open' }; 41 var LOG_CAP = 50; 42 43 var css = [ 44 '#pava-fab{position:fixed;right:22px;bottom:22px;z-index:2147483001;width:62px;height:62px;border-radius:50%;border:none;cursor:pointer;background:#0c3860;box-shadow:0 6px 24px rgba(12,56,96,.4);display:flex;align-items:center;justify-content:center;transition:transform .15s ease,box-shadow .15s ease}', 45 '#pava-fab:hover{transform:scale(1.07);box-shadow:0 8px 28px rgba(12,56,96,.5)}', 46 '#pava-fab svg{width:30px;height:30px;fill:#fff}', 47 '#pava-fab .pava-fab-dot{position:absolute;top:4px;right:4px;width:13px;height:13px;border-radius:50%;background:#0ba884;border:2px solid #fff}', 48 '#pava-chat{position:fixed;right:22px;bottom:96px;z-index:2147483001;width:372px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100vh - 130px);background:#fff;border-radius:16px;box-shadow:0 12px 48px rgba(12,56,96,.3);display:none;flex-direction:column;overflow:hidden;font:16px/1.5 Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#0c3860}', 49 '#pava-chat.open{display:flex}', 50 '.pava-chat-head{background:#0c3860;color:#fff;padding:14px 16px;display:flex;align-items:center;gap:10px}', 51 '.pava-chat-head .pava-avatar{width:34px;height:34px;border-radius:50%;background:#0ba884;display:flex;align-items:center;justify-content:center;font-weight:700}', 52 '.pava-chat-head h3{margin:0;font-size:16px;font-weight:700;letter-spacing:.01em}', 53 '.pava-head-btns{margin-left:auto;display:flex;gap:2px}', 54 '.pava-head-btns button{background:none;border:none;color:#fff;font-size:20px;cursor:pointer;line-height:1;padding:4px 7px;border-radius:6px}', 55 '.pava-head-btns button:hover{background:rgba(255,255,255,.14)}', 56 '#pava-msgs{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:8px;background:#f9fbff}', 57 '.pava-msg{max-width:82%;padding:10px 14px;border-radius:12px;white-space:pre-wrap;word-wrap:break-word}', 58 '.pava-msg.agent{background:#fff;border:1px solid #dfe5f2;color:#0c3860;align-self:flex-start;border-bottom-left-radius:4px}', 59 '.pava-msg.user{background:#0ba884;color:#fff;align-self:flex-end;border-bottom-right-radius:4px}', 60 '.pava-msg.typing{color:#5c7b96;font-style:italic}', 61 '.pava-msg.notice{background:transparent;border:none;color:#5c7b96;font-size:13px;align-self:center;text-align:center}', 62 '#pava-confirm{display:none;gap:10px;align-items:center;justify-content:center;flex-wrap:wrap;padding:12px 14px;border-top:1px solid #dfe5f2;background:#fff8ec;font-size:14px}', 63 '#pava-confirm.show{display:flex}', 64 '#pava-confirm button{border-radius:10px;font:inherit;font-weight:700;padding:7px 14px;cursor:pointer}', 65 '#pava-confirm [data-pava-confirm-end]{border:none;background:#df1642;color:#fff}', 66 '#pava-confirm [data-pava-confirm-keep]{border:1px solid #c4d3e3;background:#fff;color:#0c3860}', 67 '.pava-chat-foot{display:flex;gap:8px;padding:10px;border-top:1px solid #dfe5f2;background:#fff}', 68 '#pava-input{flex:1;border:1px solid #c4d3e3;border-radius:12px;padding:9px 12px;font:inherit;color:#0c3860;outline:none}', 69 '#pava-input::placeholder{color:#5c7b96}', 70 '#pava-input:focus{border-color:#0ba884;box-shadow:0 0 0 3px rgba(11,168,132,.15)}', 71 '#pava-send{border:none;border-radius:12px;background:#0ba884;color:#fff;font:inherit;font-weight:700;padding:0 18px;
71cursor:pointer;transition:background .15s ease}', 72 '#pava-send:hover:not(:disabled){background:#04b290}', 73 '#pava-send:disabled{background:#9db8cd;cursor:default}', 74 '.pava-qrs{display:flex;flex-direction:column;align-items:flex-start;gap:6px;margin-top:2px}', 75 '.pava-qr{max-width:100%;padding:8px 13px;border:1px solid #c4d3e3;border-radius:16px;font:inherit;font-size:13px;line-height:1.3;color:#0c3860;background:#fff;cursor:pointer;text-align:left}', 76 '.pava-qr:hover{border-color:#0ba884;color:#0ba884;background:rgba(11,168,132,.06)}', 77 '.pava-qr:active{background:rgba(11,168,132,.12)}', 78 '#pava-backdrop{display:none;position:fixed;inset:0;z-index:2147483000;background:rgba(12,56,96,.38)}', 79 '@media (max-width:599px){', 80 // Full-width bottom sheet. When open, the height + top are set inline from 81 // window.visualViewport (keyboard-aware â see syncViewport). This rule is 82 // the no-JS / older-browser fallback: dvh accounts for the mobile browser 83 // chrome that plain vh ignores (the old 60vh hid content under the URL bar). 84 '#pava-chat{left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:90vh;height:90dvh;max-height:none;border-radius:18px 18px 0 0}', 85 '.pava-chat-foot{padding-bottom:calc(10px + env(safe-area-inset-bottom,0px))}', 86 '#pava-input{font-size:16px}', 87 '#pava-fab{right:14px;bottom:calc(14px + env(safe-area-inset-bottom,0px))}', 88 '#pava-backdrop.show{display:block}', 89 '}', 90 ].join('\n'); 91 92 var html = 93 '<div id="pava-backdrop"></div>' + 94 '<button id="pava-fab" type="button" aria-label="Chat with Pava">' + 95 '<svg viewBox="0 0 24 24"><path d="M12 3C6.48 3 2 6.94 2 11.8c0 2.42 1.12 4.6 2.94 6.18-.1.86-.42 2.02-1.32 3.02-.18.2-.02.52.24.5 1.7-.14 3.1-.78 4.06-1.4 1.24.42 2.62.66 4.08.66 5.52 0 10-3.94 10-8.96S17.52 3 12 3z"/></svg>' + 96 '<span class="pava-fab-dot"></span></button>' + 97 '<div id="pava-chat" role="dialog" aria-label="Pava chat">' + 98 '<div class="pava-chat-head">' + 99 '<div class="pava-avatar">P</div>' + 100 '<h3>Pava</h3>' + 101 '<div class="pava-head-btns">' + 102 '<button type="button" data-pava-min aria-label="Minimize chat" title="Minimize">–</button>' + 103 '<button type="button" data-pava-end aria-label="End chat" title="End chat">×</button>' + 104 '</div></div>' + 105 '<div id="pava-msgs"></div>' + 106 '<div id="pava-confirm">' + 107 '<span>End this chat?</span>' + 108 '<button type="button" data-pava-confirm-end>End chat</button>' + 109 '<button type="button" data-pava-confirm-keep>Keep chatting</button>' + 110 '</div>' + 111 '<div class="pava-chat-foot">' + 112 '<input id="pava-input" type="text" placeholder="Message Pava" autocomplete="off">' + 113 '<button id="pava-send" type="button">Send</button>' + 114 '</div></div>'; 115 116 function mount(cfg) { 117 cfg = cfg || {}; 118 var style = document.createElement('style'); 119 style.textContent = css; 120 document.head.appendChild(style); 121 var root = document.createElement('div'); 122 root.id = 'pava-widget-root'; 123 root.innerHTML = html; 124 document.body.appendChild(root); 125 init(cfg); 126 } 127 128 // sessionStorage can throw (private mode, storage disabled) â degrade to 129 // per-page memory rather than breaking the host page. 130 function sget(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } } 131 function sset(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { /* no-op */ } } 132 function lget(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } 133 function lset(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* no-op */ } } 134 function sdel(k) { try { sessionStorage.removeItem(k); } catch (e) { /* no-op */ } } 135 function ldel(k) { try { localStorage.removeItem(k); } catch (e) { /* no-op */ } } 136 137 function init(cfg) { 138 // ?pava-fresh=1 on the host page simulates a first-time visitor: wipe the 139 // stored conversation and the auto-open flag so the idle popup always 140 // demos. Used by the Valet Pava panel iframe; harmless elsewhere. 141 // The conversation keys live in sessionStorage, but the auto-open flag 142 // (pava_auto_done) is in localStorage â so clear BOTH stores, or the flag 143 // stays '1' after the first pop and the idle popup never demos again. 144 try { 145 if (new URLSearchParams(location.search).has('pava-fresh')) {
146 Object.keys(STORE_KEYS).forEach(function (k) { sdel(STORE_KEYS[k]); ldel(STORE_KEYS[k]); }); 147 } 148 } catch (e) { /* ignore */ } 149 150 var fab = document.getElementById('pava-fab'); 151 var box = document.getElementById('pava-chat'); 152 var msgs = document.getElementById('pava-msgs'); 153 var input = document.getElementById('pava-input'); 154 var send = document.getElementById('pava-send'); 155 var backdrop = document.getElementById('pava-backdrop'); 156 var chatId = sget(STORE_KEYS.chat); 157 var log = []; 158 try { log = JSON.parse(sget(STORE_KEYS.log) || '[]'); } catch (e) { log = []; } 159 var busy = false; 160 var greeted = log.length > 0; 161 162 function persistLog() { 163 if (log.length > LOG_CAP) log = log.slice(log.length - LOG_CAP); 164 sset(STORE_KEYS.log, JSON.stringify(log)); 165 } 166 167 // Escape (model output is untrusted) then linkify markdown links + bare URLs. 168 // No regex lookbehind â this runs on any consumer browser (incl. older Safari), 169 // where a lookbehind literal would be a syntax error and break the whole widget. 170 function esc(s) { 171 return String(s == null ? '' : s) 172 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 173 } 174 // Trademark the brand in anything the widget shows the client: "Pearl SCORE" 175 // -> "Pearl SCORE(TM)" (case-normalized). \b keeps plurals ("SCOREs") intact and 176 // the negative lookahead avoids a double (TM). Deterministic â never relies on 177 // the model to type the symbol. Doesn't touch the "pearlscore.com" URL (no space). 178 function tm(s) { 179 // Emit (TM) via a \u2122 escape (ASCII source) so the symbol is correct 180 // no matter what charset the file is served with. 181 return String(s == null ? '' : s).replace(/Pearl SCORE\b(?!\u2122)/gi, 'Pearl SCORE\u2122'); 182 } 183 function fmt(s) { 184 var h = esc(tm(s)).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 185 h = h.replace(/\[([^\]]+)\]\((https?:\/\/[^\s")]+)\)/g, 186 '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'); 187 // Bare URLs, but only in segments BETWEEN existing anchors so a markdown 188 // link isn't double-wrapped. Match to the first whitespace/quote/bracket 189 // (dots and commas ARE valid inside a URL), then peel off trailing sentence 190 // punctuation so "â¦pearlscore.com." doesn't swallow the peri
190od. The char 191 // class excludes " < > ' ) ] so the href can't be broken out of. 192 h = h.split(/(<a\b[^>]*>[\s\S]*?<\/a>)/).map(function (part) { 193 if (part.indexOf('<a ') === 0) return part; 194 return part.replace(/https?:\/\/[^\s<>"')\]]+/g, function (url) { 195 var trail = (url.match(/[.,;:!?]+$/) || [''])[0]; 196 if (trail) url = url.slice(0, url.length - trail.length); 197 return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>' + trail; 198 }); 199 }).join(''); 200 return h; 201 } 202 203 function add(role, text, transient) { 204 var el = document.createElement('div'); 205 el.className = 'pava-msg ' + role; 206 if (role.indexOf('agent') === 0 && !transient) el.innerHTML = fmt(text); 207 else el.textContent = text; // user, notice, and the transient "typingâ¦" stay plain 208 msgs.appendChild(el); 209 if (role.indexOf('agent') === 0 && !transient) { 210 // Long answers: align the START of the reply to the top of the panel so 211 // it reads from the beginning, instead of "scroll to newest" pinning the 212 // bottom and burying the opening lines. Short replies clamp to max 213 // scroll and still show fully. 214 // 215 // Applied to every agent message, not only the first after a user turn. 216 // One turn can carry several messages (the send handler renders 217 // d.messages as a list), so a ticket confirmation arrives as a second 218 // message behind "one moment while I create that ticket". Anchoring only 219 // the first left every later message rendered below the fold with no 220 // scroll at all, which is what made a created ticket look like nothing 221 // had happened until the reader scrolled by hand. 222 msgs.scrollTop += el.getBoundingClientRect().top - msgs.getBoundingClientRect().top - 8; 223 } else { 224 msgs.scrollTop = msgs.scrollHeight; 225 } 226 if (!transient) { log.push({ role: role, text: text }); persistLog(); } 227 return el; 228 } 229 230 // Rehydrate a conversation carried over from a previous page. 231 log.forEach(function (m) { 232 var el = document.createElement('div'); 233 el.className = 'pava-msg ' + m.role; 234 if ((m.role || '').indexOf('agent') === 0) el.innerHTML = fmt(m.text); 235 else el.textContent = m.text; 236 msgs.appendChild(el); 237 }); 238 msgs.scrollTop = msgs.scrollHeight; 239 240 function isMobile() { 241 return !!(window.matchMedia && window.matchMedia('(max-width:599px)').matches); 242 } 243 // Keyboard-aware mobile sheet: size + anchor the panel to the VISUAL 244 // viewport (not the layout viewport) so the on-screen keyboard never covers 245 // the input or leaves a dead gap. When the keyboard is down we leave an 8% 246 // top gap so the page shows through the backdrop (doesn't "take over the 247 // whole screen"); when it's up we use the full visible height. 248 function syncViewport() { 249 if (!box.classList.contains('open')) return; 250 if (!isMobile() || !window.visualViewport) { 251 box.style.height = ''; box.style.top = ''; box.style.bottom = ''; 252 return; 253 } 254 var vv = window.visualViewport; 255 // Keyboard detection: an open keyboard shrinks visualViewport.height by 256 // ~250px+, so anything within 80px of the layout height means "no 257 // keyboard" (the 80px absorbs minor browser-UI/zoom differences). 258 var noKeyboard = vv.height >= (window.innerHeight - 80); 259 var gap = noKeyboard ? Math.round(vv.height * 0.08) : 0; 260 box.style.height = (vv.height - gap) + 'px'; 261 box.style.top = (vv.offsetTop + gap) + 'px'; 262 box.style.bottom = 'auto'; 263 msgs.scrollTop = msgs.scrollHeight; 264 } 265 266 function openBox(focus) { 267 box.classList.add('open'); 268 sset(STORE_KEYS.open, '1'); 269 greet(); 270 if (isMobile()) { 271 document.body.style.overflow = 'hidden'; // lock the page behind the sheet 272 backdrop.classList.add('show'); 273 syncViewport(); 274 if (window.visualViewport) { 275 window.visualViewport.addEventListener('resize', syncViewport);
276 window.visualViewport.addEventListener('scroll', syncViewport); 277 } 278 // No auto-focus on mobile â it slams the keyboard up before the user 279 // has even read the greeting. They tap the field when ready. 280 } else if (focus) { 281 input.focus(); 282 } 283 msgs.scrollTop = msgs.scrollHeight; // show the latest on open 284 } 285 function minimizeBox() { 286 box.classList.remove('open'); 287 sdel(STORE_KEYS.open); 288 backdrop.classList.remove('show'); 289 document.body.style.overflow = ''; 290 box.style.height = ''; box.style.top = ''; box.style.bottom = ''; // drop inline mobile sizing 291 if (window.visualViewport) { 292 window.visualViewport.removeEventListener('resize', syncViewport); 293 window.visualViewport.removeEventListener('scroll', syncViewport); 294 } 295 } 296 297 // Clickable starter questions under the greeting. One row at a time; a click 298 // sends the question immediately and removes the row (same UX as the Agent 299 // Guide). Shows at most 4. Empty/absent list -> nothing renders. 300 function showReplies(list) { 301 if (!Array.isArray(list) || !list.length) return; 302 var row = document.createElement('div'); 303 row.className = 'pava-qrs'; 304 list.slice(0, 4).forEach(function (q) { 305 q = (q == null ? '' : String(q)).trim(); 306 if (!q) return; 307 var label = tm(q); // show the trademark on the chip, but SEND the plain 308 // question so the symbol never lands in the transcript. 309 var b = document.createElement('button'); 310 b.type = 'button'; 311 b.className = 'pava-qr'; 312 b.textContent = label; 313 b.addEventListener('click', function () { if (busy) return; row.remove(); sendMsg(q); }); 314 row.appendChild(b); 315 }); 316 if (row.childNodes.length) { msgs.appendChild(row); msgs.scrollTop = msgs.scrollHeight; } 317 } 318 319 async function greet() { 320 if (greeted) return; 321 greeted = true; 322 var fallback = 'Hi! Iâm Pava. How can I help with your home?'; 323 try { 324 var r = await fetch(API + PATH + '/greeting', { credentials: CREDS }); 325 var d = await r.json(); 326 add('agent', d.begin_message || fallback); 327 } catch (e) { 328 add('agent', fallback); 329 } 330 showReplies(cfg.suggestedQuestions); // starter-question chips on first open 331 } 332 333 async function ensureChat() { 334 if (chatId) return chatId; 335 var r = await fetch(API + PATH + '/start', { 336 method: 'POST', credentials: CREDS, 337 headers: { 'Content-Type': 'application/json' }, 338 body: JSON.stringify({ previous_chat_id: null }) 339 }); 340 if (!r.ok) throw new Error('start failed (' + r.status + ')'); 341 var d = await r.json(); 342 chatId = d.chat_id; 343 sset(STORE_KEYS.chat, chatId); 344 return chatId; 345 } 346 347 function clearChat() { 348 chatId = null; 349 sdel(STORE_KEYS.chat); 350 } 351 352 // End = tell the server the session is over, wipe the stored 353 // conversation, and close. Next open starts fresh. 354 function endChat() { 355 var ending = chatId; 356 clearChat(); 357 log = []; 358 sdel(STORE_KEYS.log); 359 msgs.innerHTML = ''; 360 greeted = false; 361 minimizeBox(); 362 if (ending) { 363 fetch(API + PATH + '/end/' + encodeURIComponent(ending), { method: 'POST', credentials: CREDS }) 364 .catch(function () { /* best-effort */ }); 365 } 366 } 367 368 // `text` set = a starter-question chip was clicked; otherwise read the input. 369 // (The click listener passes a MouseEvent, which is not a string, so the 370 // typed-input path still works.) 371 async function sendMsg(text) { 372 var content = (typeof text === 'string' ? text : input.value).trim(); 373 if (!content || busy) return; 374 var qr = msgs.querySelector('.pava-qrs'); 375 if (qr) qr.remove(); // clear starter chips once a question is asked 376 busy = true; 377 send.disabled = true; 378 input.value = ''; 379 add('user', content); 380 var typing = add('agent typing', 'Pava is typingâ¦', true);
381 try { 382 await ensureChat(); 383 var r = await fetch(API + PATH + '/message', { 384 method: 'POST', credentials: CREDS, 385 headers: { 'Content-Type': 'application/json' }, 386 body: JSON.stringify({ chat_id: chatId, content: content }) 387 }); 388 var d = await r.json(); 389 typing.remove(); 390 if (!r.ok) { 391 if (d && d.error === 'session_expired') { 392 clearChat(); 393 add('agent', 'That session timed out â send your message again and Iâll start a fresh one.'); 394 } else { 395 add('agent', 'Sorry â something went wrong sending that. Please try again.'); 396 } 397 return; 398 } 399 (d.messages || []).forEach(function (m) { add('agent', m); }); 400 if (d.chat_ended) { 401 clearChat(); 402 add('notice', 'Chat ended. Send a message to start a new one.'); 403 } 404 } catch (e) { 405 typing.remove(); 406 add('agent', 'Sorry â I couldnât reach the chat service. Please try again.'); 407 } finally { 408 busy = false; 409 send.disabled = false; 410 input.focus(); 411 } 412 } 413 414 var confirmBar = document.getElementById('pava-confirm'); 415 function hideConfirm() { confirmBar.classList.remove('show'); } 416 417 fab.addEventListener('click', function () { 418 autoOpenDone = true; 419 lset(STORE_KEYS.auto, '1'); 420 hideConfirm(); 421 if (box.classList.contains('open')) minimizeBox(); 422 else openBox(true); 423 }); 424 box.querySelector('[data-pava-min]').addEventListener('click', function () { 425 hideConfirm(); 426 minimizeBox(); 427 }); 428 // Tapping the dimmed backdrop (mobile) closes the sheet. 429 backdrop.addEventListener('click', function () { 430 hideConfirm(); 431 minimizeBox(); 432 }); 433 // X asks before ending â a mis-click shouldn't destroy the conversation. 434 // With nothing to lose (no session and no transcript) it just minimizes. 435 box.querySelector('[data-pava-end]').addEventListener('click', function () { 436 if (!chatId && log.length === 0) { minimizeBox(); return; } 437 confirmBar.classList.add('show'); 438 }); 439 confirmBar.querySelector('[data-pava-confirm-end]').addEventListener('click', function () { 440 hideConfirm(); 441 endChat(); 442 }); 443 confirmBar.querySelector('[data-pava-confirm-keep]').addEventListener('click', function () { 444 hideConfirm(); 445 input.focus(); 446 }); 447 document.addEventListener('keydown', function (ev) { 448 if (ev.key === 'Escape' && box.classList.contains('open')) { 449 hideConfirm(); 450 minimizeBox(); 451 } 452 }); 453 send.addEventListener('click', sendMsg); 454 input.addEventListener('keydown', function (ev) { 455 if (ev.key === 'Enter') sendMsg(); 456 }); 457 458 // Auto-open after 5s of idle â at most once per browser session 459 // (sessionStorage), so a site-wide embed doesn't pop on every page. 460 // Any user activity resets the timer; manual open/close disarms it. 461 // No focus() on auto-open: grabbing the keyboard mid-read is hostile, 462 // especially on phones. 463 // Once-per-BROWSER (localStorage), not per-tab â a fresh page load must 464 // not re-trigger the auto-open nudge on every navigation. 465 var autoOpenEnabled = cfg.autoOpen !== false; 466 var autoOpenDelayMs = Math.min(600000, Math.max(1000, (Number(cfg.autoOpenDelaySec) > 0 ? Number(cfg.autoOpenDelaySec) : 5) * 1000)); 467 var autoOpenDone = lget(STORE_KEYS.auto) === '1'; 468 var idleTimer = null; 469 function armIdleTimer() { 470 if (!autoOpenEnabled || autoOpenDone) return; 471 clearTimeout(idleTimer); 472 idleTimer = setTimeout(function () { 473 if (autoOpenDone || box.classList.contains('open')) return; 474 autoOpenDone = true; 475 lset(STORE_KEYS.auto, '1'); 476 openBox(false); 477 }, autoOpenDelayMs); 478 } 479 if (autoOpenEnabled) { 480 ['scroll', 'mousemove', 'keydown', 'touchstart', 'click'].forEach(function (ev) { 481 window.addEventListener(ev, armIdleTimer, { passive: true }); 482 }); 483 armIdleTimer(); 484 } 485 486 // A conversation that was open on the previous page stays open here. 487 if (sget(STORE_KEYS.open) === '1' && log.length > 0) { 488 box.classList.add('open'); 489 } 490 } 491 492 // Runtime kill-switch check: CDNs cache this script for hours, so the 493 // Pearl-side enable flag is consulted at load time against an uncacheable 494 // API endpoint. Disabled -> render nothing. Any error (endpoint missing on 495 // an older server, network blip) fails OPEN so a transient API issue can 496 // never take the widget down by accident. 497 // True when the current page path matches an admin-configured exclusion. 498 // Patterns: exact ('/pricing'), subtree ('/lp/*' -> /lp and everything 499 // under it), or prefix ('/lp*'). Full URLs are reduced to their pathname. 500 function pathExcluded(patterns) { 501 if (!Array.isArray(patterns) || !patterns.length) return false;
502 var here = (location.pathname || '/').toLowerCase().replace(/\/+$/, '') || '/'; 503 return patterns.some(function (raw) { 504 var p = String(raw || '').trim().toLowerCase(); 505 if (!p) return false; 506 if (p.indexOf('http://') === 0 || p.indexOf('https://') === 0) { try { p = new URL(p).pathname; } catch (e) { /* keep */ } } 507 if (p.slice(-2) === '/*') { var base = p.slice(0, -2).replace(/\/+$/, ''); return here === base || here.indexOf(base + '/') === 0; } 508 if (p.slice(-1) === '*') { return here.indexOf(p.slice(0, -1)) === 0; } 509 return here === (p.replace(/\/+$/, '') || '/'); 510 }); 511 } 512 513 // Runtime config check: CDNs cache this script for hours, so enablement, 514 // auto-open behavior, and page exclusions are read at load time from an 515 // uncacheable API endpoint. Disabled or excluded path -> render nothing. 516 // Any error fails OPEN (with defaults) so a transient API issue can never 517 // take the widget down by accident. 518 function gatedMount() { 519 try { 520 fetch(API + PATH + '/widget-config', { credentials: CREDS }) 521 .then(function (r) { return r.ok ? r.json() : {}; }) 522 .then(function (cfg) { 523 cfg = cfg || {}; 524 if (cfg.enabled === false) return; 525 if (pathExcluded(cfg.disabledPaths)) return; 526 mount(cfg); 527 }) 528 .catch(function () { mount({}); }); 529 } catch (e) { 530 mount({}); 531 } 532 } 533 534 if (document.readyState === 'loading') { 535 document.addEventListener('DOMContentLoaded', gatedMount); 536 } else { 537 gatedMount(); 538 } 539})();
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.