PageSourceSearch

https://dentabot-smileconnections.onrender.com/widget.js

js dentabot-smileconnections.onrender.com collected 2026-10-03 11:39:48 UTC 17,085 bytes, 497 lines download raw bytes

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.