1/** 2 * DentaBot floating chat widget â Smile Connections (Ivanhoe VIC) build. 3 * ("24/7 Receptionist" launcher â "Speak To Reception" panel.) 4 * 5 * Usage: 6 * DentaBotWidget.init(optionalMountEl, { apiBase: "https://..." }); 7 */ 8(function () { 9 "use strict"; 10 11 var DEFAULTS = { 12 apiBase: "", 13 title: "Speak To Reception", 14 launcherLabel: "24/7 Receptionist", 15 subtitle: "Please allow me to answer any questions you may have ð", 16 poweredBy: "Powered By Digital Smiles", 17 // Smile Connections icon logo (new branding), self-hosted from this 18 // repo's public/ folder so it survives their site moves/redesigns. 19 // Resolved against apiBase at init so localhost and production each 20 // load their own copy. 21 logo: "/logo.png", 22 }; 23 24 var SVG = { 25 chevronDown: 26 '<svg class="db-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>', 27 reset: 28 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>', 29 close: 30 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"></line><line x1="18" y1="6" x2="6" y2="18"></line></svg>', 31 send: 32 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg>', 33 }; 34 35 function el(tag, className, html) { 36 var node = document.createElement(tag); 37 if (className) node.className = className; 38 if (html !== undefined) node.innerHTML = html; 39 return node; 40 } 41 42 // Conversation state survives page navigation within the same tab. 43 var STORAGE_KEY = "dentabot-state-v1"; 44 function loadStoredState() { 45 try { 46 var raw = window.sessionStorage.getItem(STORAGE_KEY); 47 var state = raw ? JSON.parse(raw) : null; 48 // A stored session with no rendered messages means the first open never 49 // completed (e.g. the API was unreachable). Restoring it would mark the 50 // conversation as started and leave the widget blank â discard instead 51 // so the next open starts fresh. 52 if (state && (!state.uiLog || state.uiLog.length === 0)) return null; 53 return state; 54 } catch (e) { 55 return null; 56 } 57 } 58 function writeStoredState(state) { 59 try { 60 window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 61 } catch (e) { 62 /* private browsing / storage full â persistence is best-effort */ 63 } 64 } 65 66 function escapeHtml(text) { 67 var d = document.createElement("div"); 68 d.textContent = text; 69 return d.innerHTML; 70 } 71 72 // Australian landline/mobile numbers become tap-to-call links. 73 var PHONE_RE = /\b(0[2-9]\s?\d{4}\s?\d{4}|04\d{2}\s?\d{3}\s?\d{3})\b/g; 74 function phoneLinkedHtml(text) { 75 var html = escapeHtml(text); 76 // The AI sometimes emphasises with markdown â render **bold** and 77 // *italic* instead of showing raw asterisks. 78 html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>"); 79 html = html.replace(/(^|[\s(>])\*(\S(?:[^*\n]*\S)?)\*(?=$|[\s<).,!?:;])/gm, "$1<em>$2</em>"); 80 return html.replace(PHONE_RE, function (m) { 81 return '<a href="tel:' + m.replace(/\s+/g, "") + '">' + m + "</a>"; 82 }); 83 } 84 85 function createWidget(options) { 86 var opts = {}; 87 for (var k in DEFAULTS) opts[k] = DEFAULTS[k]; 88 for (var k2 in options) if (options[k2] != null) opts[k2] = options[k2]; 89 90 var apiBase = opts.apiBase.replace(/\/$/, ""); 91 // A root-relative logo path is served by the bot, not the host page. 92 if (opts.logo.charAt(0) === "/") opts.logo = apiBase + opts.logo; 93 94 // ---------- state ---------- 95 var stored = loadStoredState(); 96 var isOpen = false; 97 var started = !!stored; 98 var busy = false;
99 var restoring = false; // true while re-rendering a stored conversation 100 var captureNode = stored ? stored.capture || null : null; 101 var capturedFields = stored ? stored.fields || {} : {}; 102 var history = stored ? stored.history || [] : []; // free-text history for the AI fallback 103 var uiLog = stored ? stored.uiLog || [] : []; // rendered turns, for restore 104 var lastButtons = stored ? stored.buttons || null : null; 105 // Session id ties the conversation together server-side (and across page 106 // navigations) so exactly one transcript is emailed per visit. 107 var sessionId = 108 stored && stored.sessionId 109 ? stored.sessionId 110 : window.crypto && window.crypto.randomUUID 111 ? window.crypto.randomUUID() 112 : "s-" + Date.now() + "-" + Math.random().toString(36).slice(2); 113 114 function saveState() { 115 writeStoredState({ 116 sessionId: sessionId, 117 uiLog: uiLog.slice(-80), 118 buttons: lastButtons, 119 history: history.slice(-20), 120 capture: captureNode, 121 fields: capturedFields, 122 open: isOpen, 123 }); 124 } 125 var activeChips = null; 126 127 // ---------- launcher ---------- 128 var launcher = el("button", "db-launcher"); 129 launcher.type = "button"; 130 launcher.setAttribute("aria-label", "Open chat"); 131 launcher.innerHTML = 132 '<img alt="" src="' + 133 opts.logo + 134 '" />' + 135 SVG.chevronDown + 136 '<span class="db-launcher-label">' + 137 opts.launcherLabel + 138 "</span>"; 139 140 // ---------- panel ---------- 141 var panel = el("div", "db-widget db-panel"); 142 143 var header = el("div", "db-header"); 144 header.innerHTML = '<img alt="" src="' + opts.logo + '" />'; 145 var headerTitle = el("div", "db-header-title"); 146 headerTitle.textContent = opts.title; 147 header.appendChild(headerTitle); 148 var resetBtn = el("button", null, SVG.reset); 149 resetBtn.type = "button"; 150 resetBtn.setAttribute("aria-label", "Restart conversation"); 151 var closeBtn = el("button", null, SVG.close); 152 closeBtn.type = "button"; 153 closeBtn.setAttribute("aria-label", "Close chat"); 154 header.appendChild(resetBtn); 155 header.appendChild(closeBtn); 156 157 var body = el("div", "db-body"); 158 159 var hero = el("div", "db-hero"); 160 hero.innerHTML = 161 '<div class="db-hero-logo"><img alt="" src="' + 162 opts.logo + 163 '" /></div><div class="db-hero-title">' + 164 opts.title + 165 '</div><div class="db-hero-sub"></div>'; 166 hero.querySelector(".db-hero-sub").textContent = opts.subtitle; 167 body.appendChild(hero); 168 169 var inputRow = el("div", "db-input-row"); 170 var inputWrap = el("div", "db-input-wrap"); 171 var input = el("input", "db-input"); 172 input.type = "text"; 173 input.placeholder = "Message..."; 174 input.autocomplete = "off"; 175 var sendBtn = el("button", "db-send", SVG.send); 176 sendBtn.type = "button"; 177 sendBtn.setAttribute("aria-label", "Send"); 178 inputWrap.appendChild(input); 179 inputWrap.appendChild(sendBtn); 180 inputRow.appendChild(inputWrap); 181 182 var footer = el("div", "db-footer"); 183 var poweredLink = el("a"); 184 poweredLink.textContent = opts.poweredBy; 185 poweredLink.href = "https://www.digitalsmiles.com.au"; 186 poweredLink.target = "_blank"; 187 poweredLink.rel = "noopener"; 188 footer.appendChild(poweredLink); 189 var disclaimer = el("div", "db-disclaimer"); 190 disclaimer.textContent = 191 "DentaBot provides general information only â not dental or medical advice."; 192 193 panel.appendChild(header); 194 panel.appendChild(body); 195 panel.appendChild(inputRow); 196 panel.appendChild(footer); 197 panel.appendChild(disclaimer); 198 199 document.body.appendChild(panel); 200 document.body.appendChild(launcher); 201 202 // ---------- rendering ---------- 203 function scrollDown() { 204 body.scrollTop = body.scrollHeight; 205 } 206 207 /** 208 * Adds a bot bubble. When animate is true the text "types" out 209 * progressively (duration scales with length, capped so long messages 210 * don't drag). Calls onDone when finished. 211 */ 212 function addBotBubble(text, animate, onDone) { 213 if (!restoring) uiLog.push({ who: "bot", text: text }); 214 var row = el("div", "db-row"); 215 row.innerHTML = 216 '<div class="db-avatar"><img alt="" src="' + opts.logo + '" /></div>'; 217 var bubble = el("div", "db-bubble db-bubble--bot"); 218 row.appendChild(bubble); 219 body.appendChild(row); 220 221 if (!animate) { 222 bubble.innerHTML = phoneLinkedHtml(text); 223 scrollDown(); 224 if (onDone) onDone(); 225 return; 226 } 227 228 bubble.classList.add("db-bubble--typing"); 229 var total = text.length; 230 var duration = Math.max(500, Math.min(total * 12, 2400)); 231 var start = null; 232 function frame(ts) { 233 if (start === null) start = ts; 234 var progress = Math.min((ts - start) / duration, 1); 235 bubble.textContent = text.slice(0, Math.floor(progress * total)); 236 scrollDown(); 237 if (progress < 1) { 238 requestAnimationFrame(frame); 239 } else { 240 bubble.classList.remove("db-bubble--typing"); 241 bubble.innerHTML = phoneLinkedHtml(text); 242 scrollDown(); 243 if (onDone) onDone(); 244 } 245 } 246 requestAnimationFrame(frame); 247 } 248 249 function addUserBubble(text) { 250 if (!restoring) uiLog.push({ who: "user", text: text }); 251 var row = el("div", "db-row db-row--user"); 252 var bubble = el("div", "db-bubble db-bubble--user"); 253 bubble.textContent = text; 254 row.appendChild(bubble); 255 body.appendChild(row); 256 scrollDown(); 257 } 258 259 function addTyping() { 260 var row = el("div", "db-row"); 261 row.innerHTML = 262 '<div class="db-avatar"><img alt="" src="' + 263 opts.logo + 264 '" /></div><div class="db-bubble db-bubble--bot db-typing"><span></span><span></span><span></span></div>'; 265 body.appendChild(row); 266 scrollDown(); 267 return row; 268 } 269 270 function clearChips() { 271 if (activeChips) { 272 activeChips.remove(); 273 activeChips = null; 274 } 275 } 276 277 function addChips(buttons) { 278 clearChips(); 279 if (!buttons || buttons.length === 0) return; 280 var wrap = el("div", "db-chips"); 281 buttons.forEach(function (btn) { 282 var chip = el("button", "db-chip"); 283 chip.type = "button"; 284 chip.textContent = btn.label; 285 chip.addEventListener("click", function () { 286 if (busy) return; 287 clearChips(); 288 addUserBubble(btn.label); 289 if (btn.url) { 290 window.open(btn.url, "_blank", "noopener"); 291 if (btn.then) { 292 sendAction(btn.then, btn.label); 293 } 294 } else if (btn.action) { 295 sendAction(btn.action, btn.label); 296 } 297 }); 298 wrap.appendChild(chip); 299 }); 300 body.appendChild(wrap); 301 activeChips = wrap; 302 scrollDown(); 303 } 304 305 function renderResponse(data, onDone) { 306 var queue = (data.messages || []).slice(); 307 queue.forEach(function (m) { 308 history.push({ role: "assistant", content: m }); 309 }); 310 // Type messages out one after another, then show the chips. 311 (function next() { 312 if (queue.length === 0) { 313 captureNode = data.capture || null;
314 capturedFields = data.fields || {}; 315 lastButtons = 316 data.buttons && data.buttons.length ? data.buttons : null; 317 addChips(data.buttons); 318 saveState(); 319 if (onDone) onDone(); 320 return; 321 } 322 addBotBubble(queue.shift(), true, next); 323 })(); 324 } 325 326 // ---------- networking ---------- 327 function post(payload, onDone) { 328 busy = true; 329 payload.session = sessionId; 330 var typing = addTyping(); 331 fetch(apiBase + "/api/chat", { 332 method: "POST", 333 headers: { "Content-Type": "application/json" }, 334 body: JSON.stringify(payload), 335 }) 336 .then(function (res) { 337 if (!res.ok) throw new Error("HTTP " + res.status); 338 return res.json(); 339 }) 340 .then(function (data) { 341 typing.remove(); 342 // busy stays true until the typing animation finishes, so the 343 // patient can't interleave a second message mid-reply. 344 renderResponse(data, function () { 345 busy = false; 346 if (onDone) onDone(); 347 }); 348 }) 349 .catch(function () { 350 typing.remove(); 351 addBotBubble( 352 "Sorry, I'm having trouble connecting right now. Please try again in a moment.", 353 true, 354 function () { 355 busy = false; 356 if (onDone) onDone(); 357 } 358 ); 359 }); 360 } 361 362 function sendAction(nodeId, label) { 363 var payload = { action: nodeId }; 364 // Only real chip clicks carry a label â it's what shows as the 365 // patient's side of the emailed transcript. 366 if (label) payload.label = label; 367 post(payload); 368 } 369 370 function sendText() { 371 var text = input.value.trim(); 372 if (!text || busy) return; 373 input.value = ""; 374 updateSendState(); 375 clearChips(); 376 lastButtons = null; 377 addUserBubble(text); 378 saveState(); 379 if (captureNode) { 380 var node = captureNode; 381 captureNode = null; 382 post({ text: text, capture: node, fields: capturedFields }); 383 } else { 384 var payload = { text: text, messages: history.slice(-20) }; 385 history.push({ role: "user", content: text }); 386 post(payload); 387 } 388 } 389 390 // ---------- open/close ---------- 391 function open() { 392 isOpen = true; 393 panel.classList.add("db-panel--open"); 394 launcher.classList.add("db-launcher--open"); 395 launcher.setAttribute("aria-label", "Close chat"); 396 if (!started) { 397 started = true; 398 sendAction("home"); 399 } 400 saveState(); 401 scrollDown(); 402 // Desktop only: focusing on mobile pops the keyboard over the chat 403 // before the patient has read anything. 404 if (!window.matchMedia("(max-width: 480px)").matches) { 405 input.focus(); 406 } 407 } 408 409 function close() { 410 isOpen = false; 411 panel.classList.remove("db-panel--open"); 412 launcher.classList.remove("db-launcher--open"); 413 launcher.setAttribute("aria-label", "Open chat"); 414 saveState(); 415 } 416 417 function reset() { 418 // wipe conversation and start fresh (same session â it's the same visit) 419 var rows = body.querySelectorAll(".db-row, .db-chips"); 420 rows.forEach(function (r) { 421 r.remove(); 422 }); 423 history = []; 424 uiLog = []; 425 lastButtons = null; 426 captureNode = null; 427 capturedFields = {}; 428 activeChips = null; 429 saveState(); 430 sendAction("home"); 431 } 432 433 launcher.addEventListener("click", function () { 434 if (isOpen) close(); 435 else open(); 436 }); 437 closeBtn.addEventListener("click", close); 438 resetBtn.addEventListener("click", function () { 439 if (!busy) reset(); 440 }); 441 sendBtn.addEventListener("click", sendText); 442 input.addEventListener("keydown", function (e) { 443 if (e.key === "Enter") sendText(); 444 }); 445 446 function updateSendState() { 447 if (input.value.trim()) sendBtn.classList.add("db-send--active"); 448 else sendBtn.classList.remove("db-send--active"); 449 } 450 input.addEventListener("input", updateSendState); 451 452 // Tell the server the patient is leaving so the transcript can be 453 // emailed promptly (also covers tab close / navigation). 454 window.addEventListener("pagehide", function () { 455 if (started && navigator.sendBeacon) { 456 navigator.sendBeacon(apiBase + "/api/chat-end", sessionId); 457 } 458 }); 459 460 // ---------- restore a conversation carried over from another page ---------- 461 if (stored && uiLog.length > 0) { 462 restoring = true; 463 uiLog.forEach(function (turn) { 464 if (turn.who === "user") addUserBubble(turn.text); 465 else addBotBubble(turn.text, false); 466 }); 467 restoring = false;
468 if (lastButtons) addChips(lastButtons); 469 // Cancel the "left the site" mark from the previous page's beacon so 470 // the transcript isn't emailed mid-visit. 471 fetch(apiBase + "/api/chat-resume", { 472 method: "POST", 473 headers: { "Content-Type": "text/plain" }, 474 body: sessionId, 475 }).catch(function () {}); 476 if (stored.open) open(); 477 } 478 } 479 480 window.DentaBotWidget = { 481 /** 482 * init(options) or init(mountEl, options) â mountEl is accepted for 483 * backwards compatibility but the widget always floats over the page. 484 */ 485 init: function (a, b) { 486 var options = b || (a && a.nodeType ? {} : a) || {}; 487 if (b && a && a.nodeType) options = b; 488 if (document.readyState === "loading") { 489 document.addEventListener("DOMContentLoaded", function () { 490 createWidget(options); 491 }); 492 } else { 493 createWidget(options); 494 } 495 }, 496 }; 497})();
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.