https://www.fairfaxcounty.gov/finance/themes/bootstrap-8.x-3.0-rc2/bootstrap/js/ra_chatbot.js?tkrlyi
1/** 2 * FFX.gov AI Chatbot (ra_chatbot.js) 3 * 4 * Manages the "Ask Fairfax" AI chatbot overlay including: 5 * - Modal and sidebar display modes with toggle between them 6 * - Voice search via Web Speech API 7 * - Chat history persistence via cookies 8 * - Terms of Service agreement flow 9 * - Live chat (PHPLive) integration and operator status detection 10 * - iOS keyboard restoration workarounds for iPad sidebar mode 11 * - Responsive layout adjustments via ResizeObserver 12 * 13 * Dependencies: jQuery, Socrata chatbot embed script 14 * Related files: chatbot.js (sidebar toggle/animation), chatbot.css, ra_chatbot.css 15 */ 16 17jQuery(function($) { 18 var host = window.location.hostname; 19 var isProd = host === 'www.fairfaxcounty.gov'|| host === 'fairfaxcounty.gov' || host === 'webwcmacc.fairfaxcounty.gov'; 20 var tenant = isProd ? 'https://fairfaxcounty-va-ra.chatbot.socrata.com' : 'https://fairfaxcounty-va.chatbot.socrata.com'; 21 const CONFIG = { 22 chatbotDomain: tenant, 23 cookieName: 'ffx_ai_chat_history', 24 cookieExpiryDays: 7, 25 maxHistoryItems: 50, 26 showAIHistory: false, 27 selectors: { 28 requestSenderTextarea: '.request-sender textarea', 29 homeAiText: '#homeAiText', 30 webchat: '#webchat', 31 aiToggle: '#aiToggle', 32 aiOverlay: '#aiOverlay', 33 historyList: '#aiHistoryList', 34 historyClear: '#aiHistoryClear', 35 sendIcon: '.send-icon', 36 inputArea: '#ai-chatbot-start .input-area textarea', 37 floatChat: '#webchat.floatchat', 38 feedbackIcons: '.feedback-icons>span', 39 aiCards: '.aiCard', 40 aiOverlayClose: '#aiOverlayClose' 41 }, 42 placeholders: { 43 input: 'How can FFX.gov AI help you today?', 44 voiceSearch: 'Speak now...' 45 } 46 }; 47 48 /** 49 * The Socrata chat now renders inside the #webchatFrame iframe (ra_chatbot.html) 50 * instead of inline in this page â this isolates the iOS keyboard so it can't 51 * push the fixed sidebar around. Because the iframe is same-origin, we still 52 * reach the chat DOM directly; every query that used to hit the inline chat 53 * now runs against this document. Falls back to the parent document if the 54 * frame isn't present/loaded yet so nothing throws. 55 */ 56 function getChatDoc() { 57 const f = document.getElementById('webchatFrame'); 58 return (f && f.contentDocument) || document; 59 } 60 61 /** 62 * ChatbotManager handles all AI chatbot functionality within the page. 63 * Instantiated once per page load, exposed globally as window.chatbotManagerInstance 64 * so the ToS agreement handler can access it. 65 */ 66 class ChatbotManager { 67 constructor(config) { 68 this.config = config; 69 this.chatHistory = []; 70 this.voiceSearchActive = false; 71 this.voiceTimer = null; 72 this.pendingQuestion = null; 73 this.tosAgreed = false; 74 } 75 76 init() { 77 // Parent-side setup runs immediately. 78 this.setupFloatChat(); 79 this.showMenuButton(); 80 this.initChatHistory(); 81 this.showChatHistory(); 82 this.setupEventHandlers(); 83 84 // The Socrata chat mounts asynchronously inside the #webchatFrame iframe, 85 // so anything that reaches into the chat DOM (placeholder + observers) 86 // must wait until that frame has loaded. 87 this.whenChatFrameReady(() => { 88 this.setupWebchat(); 89 this.setupObservers(); 90 }); 91 } 92 93 /** 94 * Runs cb once the #webchatFrame iframe has loaded (its document is ready 95 * for the embed to mount into). Falls back to running immediately if the 96 * frame is missing or already loaded. 97 * 98 * NOT once-only: moving #aiOverlay between modal and sidebar reparents the 99 * iframe (appendTo), which RELOADS it and re-mounts the embed in a fresh 100 * document. cb (setupWebchat + setupObservers, incl. the voice-button 101 * injection) must re-run on every load or the mic button vanishes after a 102 * mode switch. The observers it creates target the fresh document each time, 103 * so re-running doesn't accumulate stale observers on the live doc. 104 */ 105 whenChatFrameReady(cb) { 106 const frame = document.getElementById('webchatFrame'); 107 108 if (!frame) { 109 cb(); 110 return; 111 } 112 113 // Re-run on every (re)load caused by reparenting the iframe. 114 frame.addEventListener('load', cb); 115 116 // Run once now if the real chat document already loaded before this 117 // listener was attached. Guard against the transient about:blank a fresh 118 // iframe holds first: it reports readyState 'complete' with a body, but 119 // getChatDoc().body there is the dead doc and reparents away â observing 120 // it throws / wires to nothing. The load listener above still fires for 121 // the real ra_chatbot.html navigation, so skipping this pass is safe. 122 const doc = frame.contentDocument; 123 if (doc && doc.readyState === 'complete' && doc.body && doc.location.href !== 'about:blank') { 124 cb(); 125 } 126 } 127 128 setupWebchat() { 129 // The embed script now loads inside the #webchatFrame iframe 130 // (ra_chatbot.html), not here. Just set the input placeholder once the 131 // chat has rendered inside the frame. 132 $('input.webchat__send-box-text-box__input', getChatDoc()) 133 .attr('placeholder', this.config.placeholders.input); 134 } 135 136 showChatHistory() { 137 if (CONFIG.showAIHistory) { 138 $('#aiHistory').show(); 139 $('#aiDialog').css('padding-left', '2rem'); 140 } 141 } 142 143 showMenuButton() { 144 $(document).ready(() => { 145 $('li#aiToggle').css('display', 'flex'); 146 }); 147 } 148 149 setupFloatChat() { 150 const { floatChat } = this.config.selectors; 151 152 if ($(floatChat).length) { 153 $('<div id="floatchat-btn"><span class="fa-light fa-message-bot"></span>IN.gov AI</div>') 154 .insertBefore(floatChat); 155 156 $(floatChat).prepend(` 157 <div id="floatchat-header">IN.gov AI
158 <span id="floatchat-close"><i class="fa-regular fa-rectangle-xmark"></i> Close</span> 159 </div> 160 `); 161 } 162 } 163 164 setupEventHandlers() { 165 this.setupFloatChatHandlers(); 166 this.setupAiToggleHandlers(); 167 this.setupAiCardsHandlers(); 168 this.setupHomeAiSubmitHandlers(); 169 this.setupAiOverlayCloseHandlers(); 170 this.setupHistoryItemClickHandlers(); 171 this.setupHistoryClearHandlers(); 172 this.setupTextareaFocusHandlers(); 173 this.setupHeaderVoiceMic(); 174 175 document.addEventListener('keydown', (event) => { 176 if (event.key === 'Escape') { 177 const overlay = document.getElementById(this.config.selectors.aiOverlay.substring(1)); 178 if (window.getComputedStyle(overlay).display !== 'none') { 179 this.toggleAiOverlay(); 180 } 181 } 182 }); 183 } 184 185 setupFloatChatHandlers() { 186 const { floatChat } = this.config.selectors; 187 188 if ($(floatChat).length) { 189 $(document).on("click", "#floatchat-btn", function() { 190 $(this).hide(); 191 $(floatChat).show(); 192 }); 193 194 $(document).on("click", "#floatchat-close", function() { 195 $(floatChat).hide(); 196 $('#floatchat-btn').show(); 197 }); 198 } 199 } 200 201 setupAiToggleHandlers() { 202 const t = document.getElementById(this.config.selectors.aiToggle.substring(1)); 203 t && t.addEventListener("click", (t => this.toggleAiOverlay(t))) 204 } 205 206 /** 207 * Suggested prompt cards on the homepage â clicking one opens the overlay 208 * and submits that card's text as a question. 209 */ 210 setupAiCardsHandlers() { 211 const initHandler = () => { 212 const aiCards = document.querySelectorAll(this.config.selectors.aiCards); 213 214 aiCards.forEach(card => { 215 card.addEventListener('click', (e) => { 216 const paragraph = card.querySelector('p'); 217 if (!paragraph) { 218 return; 219 } 220 221 const cardText = paragraph.textContent.trim(); 222 if (!cardText) { 223 return; 224 } 225 226 setTimeout(() => { 227 this.toggleAiOverlay(null, cardText, 'card'); 228 }, 200); 229 }); 230 }); 231 }; 232 233 if (document.readyState === 'loading') { 234 document.addEventListener('DOMContentLoaded', initHandler); 235 } else { 236 initHandler(); 237 } 238 } 239 240 /** 241 * Homepage AI input field â submits question via button click or Enter key. 242 */ 243 setupHomeAiSubmitHandlers() { 244 const initHandler = () => { 245 const submitButton = document.querySelector('#homeAiInputSubmit'); 246 const homeInput = document.querySelector(this.config.selectors.homeAiText); 247 248 if (submitButton) { 249 submitButton.addEventListener('click', (e) => { 250 e.preventDefault(); 251 this.handleHomeAiSubmission(); 252 }); 253 } 254 255 if (homeInput) { 256 homeInput.addEventListener('keydown', (e) => { 257 if (e.key === 'Enter') { 258 e.preventDefault(); 259 this.handleHomeAiSubmission(); 260 } 261 }); 262 } 263 }; 264 265 if (document.readyState === 'loading') { 266 document.addEventListener('DOMContentLoaded', initHandler); 267 } else { 268 initHandler(); 269 } 270 } 271 272 handleHomeAiSubmission() { 273 const homeInput = document.querySelector(this.config.selectors.homeAiText); 274 if (!homeInput) return; 275 276 const inputText = homeInput.value.trim(); 277 if (inputText) { 278 // Store in case ToS modal blocks submission 279 this.pendingQuestion = inputText; 280 this.toggleAiOverlay(null, inputText, 'input'); 281 homeInput.value = ''; 282 } 283 } 284 285 /** 286 * Close button on #aiOverlay. If in sidebar mode, also moves overlay 287 * back to #site-header and closes the sidebar panel. 288 */ 289 setupAiOverlayCloseHandlers() { 290 const initHandler = () => { 291 const closeButton = document.getElementById(this.config.selectors.aiOverlayClose.substring(1));
292 const overlay = document.getElementById(this.config.selectors.aiOverlay.substring(1)); 293 294 if (closeButton) { 295 closeButton.addEventListener('click', (e) => { 296 e.preventDefault(); 297 overlay.style.opacity = '0'; 298 document.body.style.overflow = ''; 299 document.documentElement.style.overflow = ''; 300 301 setTimeout(() => { 302 overlay.style.display = 'none'; 303 }, 300); 304 305 // If in sidebar mode, move overlay back to full-page position and close sidebar 306 if ($('.overlayPositionIcon').hasClass('fa-arrow-left-from-bracket')) { 307 $(this).attr('title', 'Move to sidebar'); 308 $('.overlayPositionIcon').removeClass('fa-arrow-left-from-bracket'); 309 $('.overlayPositionIcon').addClass('fa-arrow-right-from-bracket'); 310 $('#aiOverlay').appendTo($('#site-header')); 311 toggleChat(); 312 } 313 }); 314 } 315 }; 316 317 if (document.readyState === 'loading') { 318 document.addEventListener('DOMContentLoaded', initHandler); 319 } else { 320 initHandler(); 321 } 322 } 323 324 /** 325 * Clicking a history item re-submits that question to the chatbot. 326 * Uses event delegation on the list container. 327 */ 328 setupHistoryItemClickHandlers() { 329 const initHandler = () => { 330 const historyList = document.getElementById(this.config.selectors.historyList.substring(1)); 331 332 if (historyList) { 333 historyList.addEventListener('click', (e) => { 334 const historyItem = e.target.closest('.history-item'); 335 336 if (historyItem) { 337 const textElement = historyItem.querySelector('.history-text'); 338 339 if (textElement) { 340 const text = textElement.textContent; 341 this.simulateInput(text, 'history'); 342 } 343 } 344 }); 345 } 346 }; 347 348 if (document.readyState === 'loading') { 349 document.addEventListener('DOMContentLoaded', initHandler); 350 } else { 351 initHandler(); 352 } 353 } 354 355 setupHistoryClearHandlers() { 356 const clearButton = document.getElementById(this.config.selectors.historyClear.substring(1)); 357 358 if (clearButton) { 359 clearButton.addEventListener('click', (e) => { 360 e.preventDefault(); 361 this.clearChatHistory(); 362 this.displayChatHistory(); 363 }); 364 } 365 } 366 367 /** 368 * On mobile, scrolls to top when the chatbot textarea gets focus 369 * to prevent the page from being in a weird scroll position behind the overlay. 370 */ 371 setupTextareaFocusHandlers() { 372 const initHandler = () => { 373 const textarea = document.querySelector(this.config.selectors.inputArea); 374 375 if (!textarea) { 376 return; 377 } 378 379 textarea.addEventListener('focus', () => { 380 window.scrollTo({ 381 top: 0, 382 behavior: 'smooth' 383 }); 384 }); 385 }; 386 387 if (document.readyState === 'loading') { 388 document.addEventListener('DOMContentLoaded', initHandler); 389 } else { 390 initHandler(); 391 } 392 } 393 394 /** 395 * MutationObservers wait for dynamically-loaded chatbot elements 396 * (the Socrata embed loads asynchronously). 397 */ 398 setupObservers() { 399 setTimeout(() => { 400 this.setupInputObserver(); 401 this.setupFeedbackObserver(); 402 this.setupLoadingObserver(); 403 }, 100); 404 } 405 406 /** 407 * Watches for the Socrata chatbot textarea to appear in the DOM,
408 * then attaches input handlers and voice search. 409 */ 410 setupInputObserver() { 411 const self = this; 412 const inputObserver = new MutationObserver(function() { 413 if (getChatDoc().querySelector(self.config.selectors.requestSenderTextarea)) { 414 inputObserver.disconnect(); 415 self.setupTextareaHandlers(); 416 self.initVoiceSearch(); 417 } 418 }); 419 420 inputObserver.observe(getChatDoc().body, { childList: true, subtree: true }); 421 } 422 423 /** 424 * Attaches Enter key and send button handlers to the chatbot textarea 425 * for tracking history and analytics. 426 */ 427 setupTextareaHandlers() { 428 const aiInput = getChatDoc().querySelector(this.config.selectors.requestSenderTextarea); 429 const self = this; 430 let aiInputData = aiInput.value; 431 432 aiInput.addEventListener("keydown", function(event) { 433 aiInputData = getChatDoc().querySelector(self.config.selectors.requestSenderTextarea).value; 434 435 if (event.key === "Enter" && aiInputData.length !== 0) { 436 self.addToChatHistory(aiInputData); 437 self.sendInputAnalytics(); 438 } 439 }); 440 441 const aiButton = getChatDoc().querySelector(self.config.selectors.sendIcon); 442 if (aiButton) { 443 aiButton.addEventListener("click", function(event) { 444 aiInputData = getChatDoc().querySelector(self.config.selectors.requestSenderTextarea).value; 445 if (aiInputData.length !== 0) { 446 self.addToChatHistory(aiInputData); 447 self.sendInputAnalytics(); 448 } 449 }); 450 } 451 452 // Socrata uses shadow DOM for the clear button â override its styling 453 $('forge-button.clear-chat-button', getChatDoc()).html('<span style="color: #091F30; font-weight: 700; font-size: 1rem;">Clear Chat</span>'); 454 } 455 456 /** 457 * Event delegation for thumbs up/down feedback icons which are 458 * dynamically injected by the Socrata chatbot after each response. 459 */ 460 setupFeedbackObserver() { 461 const self = this; 462 463 getChatDoc().addEventListener('click', function(event) { 464 const iconElement = event.target.closest('.feedback-icons > span'); 465 466 if (iconElement) { 467 const aiResponse = iconElement.closest('.ai-response'); 468 const aiResponseLayout = iconElement.closest('.ai-response-layout'); 469 470 if (aiResponse && aiResponseLayout) { 471 const aiRatingThumb = iconElement.className || ''; 472 const active = aiRatingThumb.includes('clicked'); 473 474 if (aiRatingThumb.includes('thumbsup')) { 475 self.sendFeedbackAnalytics('thumbsup', active); 476 } else if (aiRatingThumb.includes('thumbsdown')) { 477 self.sendFeedbackAnalytics('thumbsdown', active); 478 } 479 } 480 } 481 }); 482 } 483 484 /** 485 * Disables the input area while the chatbot is generating a response 486 * (detected by presence of .loading-spinner). 487 */ 488 setupLoadingObserver() { 489 const self = this; 490 let wasLoading = false; 491 const loadingObserver = new MutationObserver(function() { 492 const chatDoc = getChatDoc(); 493 const isLoading = $('.aiResponse:last .loading-spinner', chatDoc).length > 0; 494 $('#ai-chatbot-start .input-area textarea', chatDoc).prop('disabled', isLoading); 495 $('#ai-chatbot-start .input-area button', chatDoc).prop('disabled', isLoading); 496 497 if (wasLoading && !isLoading) { 498 self.focusRequestSenderIfIdle(chatDoc); 499 } 500 wasLoading = isLoading; 501 }); 502 503 loadingObserver.observe(getChatDoc().body, { childList: true, subtree: true }); 504 } 505 506 /** 507 * Returns focus to the chat textarea once a response finishes loading, 508 * so the user can keep typing without clicking back in. Skipped on touch 509 * devices (would reopen the iOS keyboard and risk the sidebar-positioning 510 * bug), and skipped if the user has focused something else in the chat 511 * doc since sending (e.g. a link in the response) so we don't steal focus. 512 */ 513 focusRequestSenderIfIdle(chatDoc) { 514 const isTouchDevice = window.matchMedia('(pointer: coarse)').matches; 515 if (isTouchDevice) { 516 return; 517 } 518 519 const active = chatDoc.activeElement; 520 const nothingElseFocused = !active || active === chatDoc.body || active === chatDoc.documentElement; 521 if (!nothingElseFocused) { 522 return; 523 } 524 525 const textarea = chatDoc.querySelector(this.config.selectors.requestSenderTextarea); 526 if (textarea) { 527 textarea.focus(); 528 } 529 } 530 531 /** 532 * Main toggle for #aiOverlay visibility. 533 * 534 * Mobile (< 768px): Always opens in sidebar mode inside #live_chat. 535 * Desktop (>= 768px): Opens as a full-page modal overlay, positioning 536 * based on whether the sticky header is active. 537 * 538 * @param {Event} event - The triggering event (optional) 539 * @param {string} aiText - Text to auto-submit after opening (optional) 540 * @param {string} source - Where the text came from: 'input', 'card', 'history', etc. 541 * @param {string} inputfield - If set, reads value from searchtextinput element 542 */ 543 toggleAiOverlay(event = null, aiText = null, source = null, inputfield = null) { 544
545 const overlay = document.getElementById(this.config.selectors.aiOverlay.substring(1)); 546 const toggleButton = document.getElementById(this.config.selectors.aiToggle.substring(1)); 547 const currentState = window.getComputedStyle(overlay).display; 548 549 // If ToS already agreed and pending question exists, submit immediately 550 if (this.tosAgreed && this.pendingQuestion && currentState === 'flex') { 551 setTimeout(() => this.submitPendingQuestion(), 300); 552 } 553 554 if (inputfield !== null) { 555 aiText = searchtextinput.value; 556 } 557 558 // MOBILE: force sidebar mode 559 if (window.innerWidth < 768) { 560 $('toggleOverlayPosition').attr('title', 'Return to full page view'); 561 $('.overlayPositionIcon').removeClass('fa-arrow-right-from-bracket'); 562 $('.overlayPositionIcon').addClass('fa-arrow-left-from-bracket'); 563 564 // Only move the overlay into the sidebar if it isn't already there. Re-appending 565 // #aiOverlay (which holds the #webchatFrame iframe) reloads the iframe and destroys 566 // the chat textarea, so a just-submitted question can't be delivered. 567 if ($('#live_chat').children('#aiOverlay').length !== 1) { 568 $('#aiOverlay').appendTo($('#live_chat')); 569 } 570 571 // Open the sidebar if it's closed (never toggle an open one shut here). 572 if (!$('.chatbot').hasClass('open')) { 573 toggleChat(); 574 } 575 576 // Must explicitly set overlay styles â toggleChat() only handles .chatbot classes 577 $('#aiOverlay').css('opacity', 1); 578 $('#aiOverlay').css('display', 'flex'); 579 $('#aiOverlay').css('top', '84px'); 580 $('#aiOverlay').css('height', 'calc(-84px + 100dvh)'); 581 $('#aiOverlay').css('width', 'calc(-4rem + 100vw)'); 582 583 this.displayChatHistory(); 584 585 if (event && event.type !== "click" || (aiText && source !== 'input')) { 586 this.simulateInput(aiText || event, source || 'unknown'); 587 } else if (aiText && source === 'input') { 588 // If the overlay was already open (currentState 'flex') and ToS is agreed, the 589 // check at the top of this method already scheduled submitPendingQuestion â don't 590 // submit again here, or the two chains race the shared retry counter and the 591 // question is dropped. 592 if (!(this.tosAgreed && this.pendingQuestion && currentState === 'flex')) { 593 this.simulateInput(aiText, 'home_input_direct'); 594 } 595 } 596 } else { 597 // DESKTOP: modal overlay mode 598 // Keeps overlay open if already visible â allows continued prompts without re-toggling 599 if ((currentState === 'none') || (currentState === 'flex')) { 600 601 if (overlay.classList.contains('sidebar-ai')) { 602 toggleChat(); 603 } 604 605 toggleButton.setAttribute('aria-expanded', 'true'); 606 607 setTimeout(() => { 608 const topHeader = document.getElementById('site-header'); 609 610 if (topHeader && overlay) { 611 // Position overlay below header, accounting for sticky header state 612 if (topHeader.classList.contains('header-stuck')) { 613 if (window.innerWidth < 500) { 614 aiOverlay.style.cssText = 'display: flex; top: 4.5rem; height: calc(100dvh - 4.5rem);'; 615 } else if (window.innerWidth < 1024) { 616 aiOverlay.style.cssText = 'display: flex; top: 4.5rem; height: calc(100dvh - 8.5rem);'; 617 } else { 618 aiOverlay.style.cssText = 'display: flex; top: 4.5rem; height: calc(100dvh - 8.5rem);'; 619 } 620 } else { 621 if (window.innerWidth < 500) { 622 aiOverlay.style.cssText = 'display: flex; top: 6.75rem; height: calc(100dvh - 6.75rem);'; 623 } else if (window.innerWidth < 1024) { 624 aiOverlay.style.cssText = 'display: flex; top: 84px; height: calc(100dvh - 10.75rem);'; 625 } else { 626 aiOverlay.style.cssText = 'display: flex; top: 64px; height: calc(-84px + 100dvdh);'; 627 } 628 } 629 630 overlay.style.opacity = '1'; 631 document.body.style.overflow = 'hidden'; 632 document.documentElement.style.overflow = 'hidden'; 633 $('#aiOverlay').css('width', 'calc(100vw - 4rem)'); 634 635 this.displayChatHistory(); 636 637 if (event && event.type !== "click" || (aiText && source !== '
637input')) { 638 this.simulateInput(aiText || event, source || 'unknown'); 639 } else if (aiText && source === 'input') { 640 this.simulateInput(aiText, 'home_input_direct'); 641 if (($('#live_chat').children('#aiOverlay').length == 1) && (currentState === 'flex')) { 642 toggleChat(); 643 } 644 } 645 } 646 }, 10); 647 648 } else { 649 // Close the overlay with fade-out 650 overlay.style.opacity = '0'; 651 document.body.style.overflow = ''; 652 document.documentElement.style.overflow = ''; 653 toggleButton.setAttribute('aria-expanded', 'false'); 654 655 setTimeout(() => { 656 overlay.style.display = 'none'; 657 }, 300); 658 } 659 } 660 } 661 662 initVoiceSearch() { 663 const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; 664 665 if (!SpeechRecognition) { 666 return; 667 } 668 669 this.setupVoiceSearchUI(); 670 this.setupSpeechRecognition(SpeechRecognition); 671 } 672 673 setupVoiceSearchUI() { 674 const { requestSenderTextarea, homeAiText } = this.config.selectors; 675 const chatDoc = getChatDoc(); 676 const searchField = $(requestSenderTextarea, chatDoc); 677 const textChatInput = $(requestSenderTextarea, chatDoc); 678 679 // Guard against double-injection: the frame can reload (mode switch) and 680 // re-run this, and MutationObservers can fire more than once per document. 681 if ($('.button_ai-voice-search', chatDoc).length) { 682 return; 683 } 684 685 searchField.attr('data-placeholder', searchField.attr('placeholder')); 686 687 const btnVoiceSearchHTML = ` 688 <button type="button" class="button_ai-voice-search" title="Search AI by voice"> 689 <span class="fas fa-microphone"></span> 690 <span class="show-for-sr">Start voice input</span> 691 </button> 692 `; 693 694 textChatInput.before(btnVoiceSearchHTML); 695 696 // The in-chat mic only exists once the chat textarea has rendered (i.e. 697 // after ToS agreement), so voice is always ready here. The header mic is 698 // bound separately in setupHeaderVoiceMic (it must work BEFORE ToS). 699 $('.button_ai-voice-search', chatDoc).click((e) => this.onVoiceMicClick(e)); 700 } 701 702 /** 703 * Shared handler for the mic buttons. Before ToS agreement the Socrata chat 704 * shows only the disclaimer (no textarea) and voice recognition isn't set up, 705 * so dictation would silently fail â instead open the overlay so the user sees 706 * the ToS. Once agreed (recognition ready, disclaimer gone) it toggles voice. 707 */ 708 onVoiceMicClick(e) { 709 e.preventDefault(); 710 711 if ((!this.tosAgreed && getChatDoc().querySelector('.disclaimer-action-buttons')) || !this.recognition) { 712 // If the overlay is docked in the sidebar, reveal it by opening the 713 // sidebar rather than the modal. (#aiOverlayCloseSidebar just closes the 714 // sidebar and leaves the overlay docked + display:flex, so toggleAiOverlay 715 // would read the stale 'flex' and wrongly treat it as already open.) 716 if ($('.overlayPositionIcon').hasClass('fa-arrow-left-from-bracket')) { 717 if (!$('.chatbot').hasClass('open')) { 718 toggleChat(); 719 } 720 $('#aiOverlay').css({ opacity: 1, display: 'flex' }); 721 } else { 722 this.toggleAiOverlay(); 723 } 724 return; 725 } 726 727 if (!this.voiceSearchActive) { 728 this.toggleVoiceState('on'); 729 } else { 730 this.toggleVoiceState('off'); 731 } 732 } 733 734 /** 735 * The header search mics (globaltopnav.twig .voice-search-visible) live in the 736 * PARENT document and must respond BEFORE ToS agreement â but setupVoiceSearchUI 737 * (which used to bind them) only runs after the chat textarea appears, i.e. post- 738 * agreement. So bind them here at init instead. Namespaced off/on avoids stacking 739 * handlers if init runs more than once. 740 */ 741 setupHeaderVoiceMic() { 742 $('.button_ai-voice-search.voice-search-visible') 743 .off('click.voiceHeader') 744 .on('click.voiceHeader', (e) => this.onVoiceMicClick(e)); 745 } 746 747 setupSpeechRecognition(SpeechRecognition) { 748 const { requestSenderTextarea, homeAiText } = this.config.selectors; 749 750 this.recognition = new SpeechRecognition(); 751 this.recognition.continuous = false;
752 this.recognition.lang = 'en-US'; 753 this.recognition.interimResults = false; 754 this.recognition.maxAlternatives = 1; 755 756 this.recognition.onresult = (event) => { 757 const keyword = event.results[0][0].transcript; 758 759 if (document.getElementById('aiOverlay')?.style.display === 'flex') { 760 // If the overlay is docked in the sidebar but the sidebar is closed, 761 // open it so the submitted question + response are visible (otherwise 762 // it submits off-screen). #aiOverlayCloseSidebar leaves the overlay 763 // docked + display:flex, so this branch is reached while it's hidden. 764 if ($('.overlayPositionIcon').hasClass('fa-arrow-left-from-bracket') && !$('.chatbot').hasClass('open')) { 765 toggleChat(); 766 $('#aiOverlay').css({ opacity: 1, display: 'flex' }); 767 } 768 this.simulateInput(keyword, 'voice_direct'); 769 } else { 770 $(homeAiText).val(keyword); 771 } 772 773 this.toggleVoiceState('off'); 774 }; 775 } 776 777 toggleVoiceState(state) { 778 const { requestSenderTextarea } = this.config.selectors; 779 const chatDoc = getChatDoc(); 780 const searchField = $(requestSenderTextarea, chatDoc); 781 // Include the parent-side header mic so its listening state animates too. 782 const btnVoiceSearch = $('.button_ai-voice-search', chatDoc) 783 .add($('.button_ai-voice-search.voice-search-visible')); 784 785 switch (state) { 786 case 'on': 787 $('i', btnVoiceSearch).addClass('listening'); 788 searchField.attr('placeholder', this.config.placeholders.voiceSearch); 789 this.voiceSearchActive = true; 790 this.recognition.start(); 791 792 // Auto-cancel after 4 seconds of silence 793 this.voiceTimer = setTimeout(() => { 794 this.toggleVoiceState('off'); 795 }, 4000); 796 break; 797 798 case 'off': 799 clearTimeout(this.voiceTimer); 800 this.recognition.stop(); 801 $('i', btnVoiceSearch).removeClass('listening'); 802 searchField.attr('placeholder', searchField.data('placeholder')); 803 this.voiceSearchActive = false; 804 break; 805 } 806 } 807 808 /** 809 * Programmatically sets text in the chatbot textarea and submits it. 810 * Uses native property descriptors to trigger React-style change detection 811 * in the Socrata embed. 812 * 813 * On touch devices, simulates a tap on the send icon instead of Enter key 814 * because some mobile browsers don't propagate synthetic keyboard events. 815 */ 816 simulateInput(text, source) { 817 const chatDoc = getChatDoc(); 818 const textarea = chatDoc.querySelector(this.config.selectors.inputArea) || chatDoc.querySelector('.ai-text-input'); 819 820 // The chat renders asynchronously inside the iframe â if a question is 821 // submitted before the textarea exists, stash it as pending so it fires 822 // once the chat is ready instead of throwing in setInputValue. 823 if (!textarea) { 824 this.pendingQuestion = text; 825 // Only retry-submit if ToS is already agreed. Before agreement the 826 // textarea is hidden behind the disclaimer, and submitPendingQuestion's 827 // retry loop would clear the stashed question (~1.2s) before the user 828 // ever clicks Agree â leave it stashed for the Agree handler instead. 829 if (this.tosAgreed) { 830 setTimeout(() => this.submitPendingQuestion(), 300); 831 } 832 return; 833 } 834 835 this.setInputValue(textarea, text); 836 837 textarea.dispatchEvent(new Event('input', { bubbles: true })); 838 839 const isTouchDevice = window.matchMedia('(pointer: coarse)').matches; 840 841 if (isTouchDevice) { 842 this.sendIconTap(); 843 } else { 844 // These sources already handle their own history/analytics tracking 845 const specialSources = ['input', 'home_input_direct', 'voice_direct', 'card', 'history', 'pending_question']; 846 if (!specialSources.includes(source)) { 847 this.addToChatHistory(text); 848 this.sendInputAnalytics(); 849 } 850 851 this.simulateEnterPress(); 852 } 853 } 854 855 /** 856 * Sets input value via property descriptor to bypass React's synthetic event system. 857 * Direct .value assignment doesn't trigger React's onChange handlers. 858 */ 859 setInputValue(element, value) { 860 const { set: valueSetter } = Object.getOwnPropertyDescriptor(element, 'value') || {}; 861 const prototype = Object.getPrototypeOf(element); 862 const { set: prototypeValueSetter } = Object.getOwnPropertyDescriptor(prototype, 'value') || {}; 863
864 if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { 865 prototypeValueSetter.call(element, value); 866 } else if (valueSetter) { 867 valueSetter.call(element, value); 868 } else { 869 throw new Error('The given element does not have a value setter'); 870 } 871 } 872 873 simulateEnterPress() { 874 const chatTextarea = getChatDoc().querySelector(this.config.selectors.inputArea); 875 876 if (chatTextarea) { 877 chatTextarea.dispatchEvent(new Event('input', { bubbles: true })); 878 chatTextarea.focus(); 879 chatTextarea.dispatchEvent(new KeyboardEvent('keydown', { 880 key: 'Enter', 881 code: 'Enter', 882 keyCode: 13, 883 which: 13, 884 bubbles: true, 885 cancelable: true 886 })); 887 } 888 } 889 890 /** 891 * For touch devices, simulates mousedown/mouseup/click on the send icon 892 * since synthetic Enter key events are unreliable on mobile browsers. 893 */ 894 sendIconTap() { 895 const sendIcon = getChatDoc().querySelector(this.config.selectors.sendIcon); 896 897 if (!sendIcon) { 898 return; 899 } 900 901 try { 902 const mouseDown = new MouseEvent('mousedown', { 903 bubbles: true, 904 cancelable: true, 905 view: window 906 }); 907 908 const mouseUp = new MouseEvent('mouseup', { 909 bubbles: true, 910 cancelable: true, 911 view: window 912 }); 913 914 sendIcon.dispatchEvent(mouseDown); 915 916 setTimeout(() => { 917 sendIcon.dispatchEvent(mouseUp); 918 sendIcon.click(); 919 }, 50); 920 } catch (error) { 921 sendIcon.click(); 922 } 923 } 924 925 /** 926 * After the user agrees to ToS, submits whatever question was pending. 927 * Retries up to 3 times in case the textarea hasn't rendered yet. 928 */ 929 submitPendingQuestion() { 930 if (!this.pendingQuestion) { 931 return; 932 } 933 934 const questionToSubmit = this.pendingQuestion; 935 936 const chatDoc = getChatDoc(); 937 const textarea = chatDoc.querySelector(this.config.selectors.inputArea) || 938 chatDoc.querySelector('.ai-text-input'); 939 940 if (!textarea) { 941 if (!this.submitRetryCount) this.submitRetryCount = 0; 942 943 if (this.submitRetryCount < 3) { 944 this.submitRetryCount++; 945 setTimeout(() => this.submitPendingQuestion(), 300); 946 } else { 947 this.clearPendingQuestion(); 948 } 949 return; 950 } 951 952 this.submitRetryCount = 0; 953 954 // Clear BEFORE submission to prevent double-submission 955 this.clearPendingQuestion(); 956 957 this.simulateInput(questionToSubmit, 'pending_question'); 958 } 959 960 clearPendingQuestion() { 961 this.pendingQuestion = null; 962 this.submitRetryCount = 0; 963 } 964 965 clearTextarea() { 966 const textarea = getChatDoc().querySelector(this.config.selectors.inputArea); 967 968 if (textarea) { 969 textarea.innerHTML = ''; 970 textarea.value = ''; 971 textarea.dispatchEvent(new Event('input', { bubbles: true })); 972 } 973 } 974 975 initChatHistory() { 976 try { 977 const cookieData = this.getCookie(this.config.cookieName); 978 if (cookieData) { 979 this.chatHistory = JSON.parse(cookieData); 980 } 981 } catch (error) { 982 this.chatHistory = []; 983 } 984 } 985 986 addToChatHistory(text) { 987 const entry = { 988 text: text, 989 timestamp: new Date().toISOString() 990 }; 991 992 this.chatHistory.push(entry); 993 994 if (this.chatHistory.length > this.config.maxHistoryItems) { 995 this.chatHistory = this.chatHistory.slice(-this.config.maxHistoryItems); 996 } 997 998 this.updateChatHistoryCookie(); 999 this.displayChatHistory(); 1000 1001 return entry; 1002 } 1003 1004 clearChatHistory() { 1005 this.chatHistory = []; 1006 document.cookie = `${this.config.cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; 1007 1008 const clearButton = document.getElementById(this.config.selectors.historyClear.substring(1)); 1009 if (clearButton) { 1010 clearButton.style.display = 'none'; 1011 } 1012 } 1013 1014 updateChatHistoryCookie() { 1015 const expiryDate = new Date(); 1016 expiryDate.setDate(expiryDate.getDate() + this.config.cookieExpiryDays); 1017 1018 try { 1019 const historyString = JSON.stringify(this.chatHistory); 1020 document.cookie = `${this.config.cookieName}=${encodeURIComponent(historyString)}; expires=${expiryDate.toUTCString()}; path=/`; 1021 } catch (error) {} 1022 } 1023 1024 getCookie(name) { 1025 const cookieName = name + "="; 1026 const decodedCookie = decodeURIComponent(document.cookie); 1027 const cookieArray = decodedCookie.split(';'); 1028 1029 for (let i = 0; i < cookieArray.length; i++) { 1030 let cookie = cookieArray[i]; 1031 while (cookie.charAt(0) === ' ') { 1032 cookie = cookie.substring(1); 1033 } 1034 if (cookie.indexOf(cookieName) === 0) { 1035 return cookie.substring(cookieName.length, cookie.length); 1036 } 1037 } 1038 return ""; 1039 } 1040 1041 displayChatHistory() { 1042 const historyList = document.getElementById(this.config.selectors.historyList.substring(1)); 1043 if (!historyList) return; 1044 1045 historyList.innerHTML = ''; 1046 1047 const clearButton = document.getElementById(this.config.selectors.historyClear.substring(1)); 1048 1049 if (this.chatHistory.length === 0) { 1050 const emptyMessage = document.createElement('li'); 1051 emptyMessage.className = 'history-empty'; 1052 emptyMessage.textContent = 'No chat history yet. Start a conversation!'; 1053 historyList.appendChild(emptyMessage); 1054 1055 if (clearButton) { 1056 clearButton.style.display = 'none'; 1057 } 1058 return; 1059 } else if (clearButton) { 1060 clearButton.style.display = 'block'; 1061 } 1062
1063 this.chatHistory.forEach(entry => { 1064 const li = document.createElement('li'); 1065 li.className = 'history-item'; 1066 1067 const textSpan = document.createElement('span'); 1068 textSpan.className = 'history-text'; 1069 textSpan.textContent = entry.text; 1070 1071 const timeSpan = document.createElement('span'); 1072 timeSpan.className = 'history-time'; 1073 const date = new Date(entry.timestamp); 1074 timeSpan.textContent = date.toLocaleString(); 1075 1076 li.appendChild(textSpan); 1077 li.appendChild(timeSpan); 1078 historyList.appendChild(li); 1079 }); 1080 } 1081 1082 sendInputAnalytics() { 1083 window.dataLayer = window.dataLayer || []; 1084 window.dataLayer.push({ 1085 'event': 'aichatbot-input' 1086 }); 1087 } 1088 1089 sendFeedbackAnalytics(rating, active) { 1090 window.dataLayer = window.dataLayer || []; 1091 window.dataLayer.push({ 1092 'event': 'aichatbot-rate', 1093 'rating': rating, 1094 'rating-active': active 1095 }); 1096 } 1097 } 1098 1099 /** 1100 * Bootstrap: instantiates ChatbotManager. The Socrata embed and #aiLinks now 1101 * live in the #webchatFrame iframe (ra_chatbot.html) and chatbot.twig 1102 * respectively, so this no longer injects either. 1103 */ 1104 function initChatbot() { 1105 window.socrataChatDomain = CONFIG.chatbotDomain; 1106 1107 if (window.chatbotInitialized) { 1108 return; 1109 } 1110 1111 if ($('#ai-chatbot-start', getChatDoc()).length > 1) { 1112 $('#ai-chatbot-start', getChatDoc()).not(':first').remove(); 1113 } 1114 1115 const chatbot = new ChatbotManager(CONFIG); 1116 chatbot.init(); 1117 1118 window.chatbotManagerInstance = chatbot; 1119 window.chatbotInitialized = true; 1120 } 1121 1122 let liveChatEmbed = false; 1123 1124 $(document).ready(function() { 1125 1126 // NOTE: The former iOS keyboard-restoration machinery (body lock/unlock, 1127 // focus preventScroll, and the RAF blur re-anchoring) was removed once the 1128 // Ask Fairfax chat moved into the #webchatFrame iframe. The keyboard is now 1129 // isolated inside that iframe's document, so it no longer pushes the fixed 1130 // .chatbot sidebar and there is nothing to restore. See ra_chatbot.html. 1131 1132 // âââ GOOGLE CUSTOM SEARCH FOCUS MANAGEMENT ââââââââââââââââââââââââââââââ 1133 // Prevents the in-page Google search input from auto-stealing focus 1134 // when the AI chatbot search field is being used. 1135 $('#gsc-i-id1').blur(function(e) { 1136 if (!$(this).data('user-clicked')) { 1137 e.preventDefault(); 1138 } 1139 }); 1140 1141 $('#gsc-i-id1').mousedown(function() { 1142 $(this).data('user-clicked', true); 1143 }); 1144 1145 $('#gsc-i-id0').focus(function() { 1146 $('#gsc-i-id1').data('user-clicked', false); 1147 }); 1148 1149 // âââ INIT CHATBOT âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1150 if (!window.chatbotInitStarted) { 1151 window.chatbotInitStarted = true; 1152 initChatbot(); 1153 } 1154 1155 // âââ LIVE CHAT STATUS DETECTION âââââââââââââââââââââââââââââââââââââââââ 1156 // PHPLive chat button loads asynchronously; watch for it and update 1157 // the "Live chat available/unavailable" link text accordingly. 1158 watchForLiveChatStatus((status, buttonElement) => { 1159 if (status === 'online') { 1160 $('.live-chat-available').show(); 1161 } else if (status === 'offline') { 1162 $('.live-chat-available').html('<span class="fa fa-circle-user show-for-medium" aria-hidden="true"></span>Live chat<span class="isLive"> unavailable</span>'); 1163 $('.online-chat').addClass('chat-not-available'); 1164 $('.online-chat').text('Live Chat Unavailable'); 1165 } 1166 }); 1167 1168 // âââ LIVE CHAT CLICK HANDLER ââââââââââââââââââââââââââââââââââââââââââââ 1169 // Hides #aiOverlay, triggers the PHPLive button, and moves the live chat 1170 // iframe into the sidebar. When live chat is closed (iframe removed from DOM), 1171 // restores #aiOverlay visibility. 1172 $('.live-chat-available').on('click', function(event) { 1173 event.preventDefault(); 1174 $('#aiOverlay').hide(); 1175 1176 const hostname = window.location.hostname; 1177 const isStaging = hostname === 'stg-fairfax-d8-redesign.virginiainteractive.org' || hostname === 'webwcmdev.fairfaxcounty.gov'; 1178 1179 if (isStaging) { 1180 $('#phplive_btn_1779217493').trigger('click'); 1181 } else { 1182 $('#phplive_btn_1671029454').trigger('click'); 1183 } 1184 1185 // Move to sidebar if currently in full-page modal mode 1186 if ($('.overlayPositionIcon').hasClass('fa-arrow-right-from-bracket')) { 1187 toggleChat(); 1188 $('toggleOverlayPosition').attr('title', 'Return to full page view'); 1189 $('.overlayPositionIcon').removeClass('fa-arrow-right-from-bracket'); 1190 $('.overlayPositionIcon').addClass('fa-arrow-left-from-bracket'); 1191 $('#aiOverlay').appendTo($('#live_chat')); 1192 } else { 1193 $('#aiOverlay').appendTo($('#live_chat')); 1194 $('.chatbot').addClass('open'); 1195 } 1196 1197 // Reposition the PHPLive iframe to fill the sidebar content area 1198 if ($('#live_chat').children($('#phplive_iframe_chat_embed_wrapper')).length > 0) { 1199 $('#phplive_iframe_chat_embed_wrapper').appendTo($('.chatFrame')); 1200 $('#phplive_iframe_chat_embed_wrapper').css({ 1201 'position': 'absolute', 1202 'top': '30px', 1203 'right': '0', 1204 'bottom': '0', 1205 'left': '0', 1206 'overflow': 'unset', 1207 'width': '100%', 1208 'max-width': '100%', 1209 'height': '100%', 1210 'max-height': '100%', 1211 'box-shadow': 'none' 1212 }); 1213 } 1214 1215 // When live chat iframe is removed (user closes it), restore #aiOverlay 1216 const myElement = document.querySelector('#phplive_iframe_chat_embed_wrapper');
1217 const observer = watchForRemoval(myElement, (removedElement) => { 1218 $('#aiOverlay').css('opacity', '1'); 1219 $('#aiOverlay').show(); 1220 $('.chatbot').addClass('open'); 1221 }); 1222 }) 1223 1224 // âââ SIDEBAR/MODAL POSITION TOGGLE ââââââââââââââââââââââââââââââââââââââ 1225 // The arrow icon lets users move #aiOverlay between full-page modal 1226 // and sidebar mode. 1227 $('.toggleOverlayPosition').on('click', function(event) { 1228 event.preventDefault(); 1229 1230 if ($('.overlayPositionIcon').hasClass('fa-arrow-right-from-bracket')) { 1231 // Move to sidebar 1232 $(this).attr('title', 'Return to full page view'); 1233 $('.overlayPositionIcon').removeClass('fa-arrow-right-from-bracket'); 1234 $('.overlayPositionIcon').addClass('fa-arrow-left-from-bracket'); 1235 $('#aiOverlay').appendTo($('#live_chat')); 1236 $('#aiOverlay').css('top', '84px'); 1237 $('#aiOverlay').css('height', 'calc(-84px + 100dvh)'); 1238 toggleChat(); 1239 } else { 1240 // Move to full-page modal 1241 $(this).attr('title', 'Move to sidebar'); 1242 $('.overlayPositionIcon').removeClass('fa-arrow-left-from-bracket'); 1243 $('.overlayPositionIcon').addClass('fa-arrow-right-from-bracket'); 1244 $('#aiOverlay').appendTo($('body')); 1245 $('#aiOverlay').css('top', '64px'); 1246 $('#aiOverlay').css('height', 'calc(-104px + 100dvh)'); 1247 toggleChat(); 1248 } 1249 1250 $('#aiOverlay').css('opacity', 1); 1251 $('#aiOverlay').css('display', 'flex'); 1252 }); 1253 1254 // âââ RESIZE OBSERVER ââââââââââââââââââââââââââââââââââââââââââââââââââââ 1255 // Handles responsive layout adjustments: 1256 // - Interior page column widths at 1300px breakpoint 1257 // - Force sidebar mode below 768px (if overlay was visible) 1258 // - Disable transitions during resize to prevent visual flash 1259 let resizeTimer; 1260 1261 const observer = new ResizeObserver(entries => { 1262 const width = window.innerWidth; 1263 1264 const chatbot = document.querySelector('.chatbot'); 1265 if (chatbot) { 1266 chatbot.style.transition = 'none'; 1267 } 1268 1269 clearTimeout(resizeTimer); 1270 1271 // Interior page layout adjustments 1272 if (width < 1300) { 1273 if ($('.interior-page').length > 0) { 1274 $('.ai-input-row').removeClass('col-lg-8'); 1275 $('.ai-input-row').addClass('col-lg-6'); 1276 $('.search-chat-container').removeClass('col-lg-4'); 1277 $('.search-chat-container').addClass('col-lg-6'); 1278 } 1279 } else { 1280 if ($('.interior-page').length > 0) { 1281 $('.ai-input-row').addClass('col-lg-8'); 1282 $('.ai-input-row').removeClass('col-lg-6'); 1283 $('.search-chat-container').addClass('col-lg-4'); 1284 $('.search-chat-container').removeClass('col-lg-6'); 1285 } 1286 } 1287 1288 // Force sidebar mode on mobile â but only if overlay was actively visible 1289 if (width < 768) { 1290 if ($('#live_chat').children('#aiOverlay').length == 0) { 1291 var overlayHidden = $('#aiOverlay').css('display') === 'none'; 1292 1293 $('toggleOverlayPosition').attr('title', 'Return to full page view'); 1294 $('.overlayPositionIcon').removeClass('fa-arrow-right-from-bracket'); 1295 $('.overlayPositionIcon').addClass('fa-arrow-left-from-bracket'); 1296 $('#aiOverlay').appendTo($('#live_chat')); 1297 1298 if (!overlayHidden) { 1299 toggleChat(); 1300 } 1301 } 1302 } 1303 1304 // Re-enable transitions after resize settles (250ms debounce) 1305 resizeTimer = setTimeout(() => { 1306 if (chatbot) { 1307 chatbot.style.transition = ''; 1308 } 1309 }, 250); 1310 }); 1311 1312 observer.observe(document.body); 1313 1314 // Watch for ToS "Agree" button to submit pending questions after agreement. 1315 // The disclaimer lives inside the #webchatFrame iframe, which navigates to 1316 // ra_chatbot.html asynchronously â wait for its load so getAgreeChatDoc() 1317 // returns the real chat document (not the transient about:blank) before 1318 // observing. 1319 // 1320 // ALWAYS attach a (non-once) load listener: a fresh iframe first holds a 1321 // throwaway about:blank that reports readyState 'complete', so an either/or 1322 // "run now if complete, else wait for load" wires the observer to that dead 1323 // document and never sees the disclaimer. Attaching on every load (like 1324 // whenChatFrameReady does) covers both the real navigation and mode-switch 1325 // reloads. Also run once now to cover the case where the real doc is already 1326 // loaded before this listener is attached.
1327 const webchatFrame = document.getElementById('webchatFrame'); 1328 if (webchatFrame) { 1329 webchatFrame.addEventListener('load', function() { 1330 waitForContainerAndButton((agreeButton) => {}); 1331 }); 1332 1333 const doc = webchatFrame.contentDocument; 1334 if (doc && doc.readyState === 'complete' && doc.querySelector('body') && doc.location.href !== 'about:blank') { 1335 waitForContainerAndButton((agreeButton) => {}); 1336 } 1337 } else { 1338 waitForContainerAndButton((agreeButton) => {}); 1339 } 1340 1341 }); 1342}); 1343 1344// âââ TERMS OF SERVICE AGREEMENT HANDLER âââââââââââââââââââââââââââââââââââââ 1345// The Socrata chatbot shows a ToS disclaimer on first use. This observer 1346// watches for the "Agree" button and wires up a click handler that marks 1347// ToS as agreed and submits any pending question. 1348 1349// The ToS disclaimer + "Agree" button render inside the Socrata chat, which now 1350// lives in the #webchatFrame iframe. These functions are at global scope (outside 1351// the jQuery module closure that defines getChatDoc), so resolve the chat document 1352// inline here rather than calling getChatDoc(). 1353function getAgreeChatDoc() { 1354 const f = document.getElementById('webchatFrame'); 1355 return (f && f.contentDocument) || document; 1356} 1357 1358function observeForAgreeButton(callback) { 1359 const chatDoc = getAgreeChatDoc(); 1360 const container = chatDoc.querySelector('.disclaimer-action-buttons'); 1361 1362 if (!container) { 1363 return; 1364 } 1365 1366 const checkForButton = () => { 1367 const buttons = container.querySelectorAll('forge-button'); 1368 for (const button of buttons) { 1369 if (button.textContent.trim() === 'Agree') { 1370 1371 // Guard against wiring the same button twice (this can run on more 1372 // than one iframe load) â a duplicate handler would double-submit. 1373 if (button.dataset.ffxAgreeWired) { 1374 return button; 1375 } 1376 button.dataset.ffxAgreeWired = '1'; 1377 1378 button.addEventListener('click', function() { 1379 const chatbotManager = window.chatbotManagerInstance; 1380 if (!chatbotManager) { 1381 return; 1382 } 1383 1384 chatbotManager.tosAgreed = true; 1385 1386 if (chatbotManager.pendingQuestion) { 1387 setTimeout(() => { 1388 chatbotManager.submitPendingQuestion(); 1389 }, 500); 1390 } 1391 $('#aiLinks').css('font-size', '16px'); 1392 callback(button); 1393 }); 1394 1395 return button; 1396 } 1397 } 1398 return null; 1399 }; 1400 1401 const existingButton = checkForButton(); 1402 if (existingButton) { 1403 callback(existingButton); 1404 return; 1405 } 1406 1407 const observer = new MutationObserver(() => { 1408 const button = checkForButton(); 1409 if (button) { 1410 callback(button); 1411 observer.disconnect(); 1412 } 1413 }); 1414 1415 observer.observe(container, { 1416 childList: true, 1417 subtree: true, 1418 characterData: true 1419 }); 1420} 1421 1422function waitForContainerAndButton(callback) { 1423 // Same as above: the disclaimer container mounts async inside the iframe chat, 1424 // so both the lookup and the MutationObserver must target the iframe document. 1425 const chatDoc = getAgreeChatDoc(); 1426 1427 const checkContainer = () => { 1428 const container = chatDoc.querySelector('.disclaimer-action-buttons'); 1429 if (container) { 1430 observeForAgreeButton(callback); 1431 return true; 1432 } 1433 return false; 1434 }; 1435 1436 if (checkContainer()) return; 1437 1438 const observer = new MutationObserver(() => { 1439 if (checkContainer()) { 1440 observer.disconnect(); 1441 } 1442 }); 1443 1444 observer.observe(chatDoc.body, { 1445 childList: true, 1446 subtree: true 1447 }); 1448} 1449 1450// âââ DOM REMOVAL WATCHER ââââââââââââââââââââââââââââââââââââââââââââââââââââ 1451// Used to detect when the PHPLive chat iframe is removed (user closes live chat),
1452// so we can restore #aiOverlay visibility in the sidebar. 1453 1454function watchForRemoval(targetElement, callback) { 1455 const observer = new MutationObserver((mutations) => { 1456 mutations.forEach((mutation) => { 1457 mutation.removedNodes.forEach((node) => { 1458 if (node === targetElement || node.contains?.(targetElement)) { 1459 callback(targetElement); 1460 observer.disconnect(); 1461 } 1462 }); 1463 }); 1464 }); 1465 1466 const parent = targetElement.parentElement || document.body; 1467 observer.observe(parent, { 1468 childList: true, 1469 subtree: true 1470 }); 1471 1472 return observer; 1473} 1474 1475// âââ LIVE CHAT STATUS DETECTION âââââââââââââââââââââââââââââââââââââââââââââ 1476// PHPLive renders a button with an img or svg indicating online/offline status. 1477// Since it loads asynchronously, we poll with exponential backoff (up to 20 attempts) 1478// after the button appears, waiting for the status indicator to render. 1479 1480function watchForLiveChatStatus(callback) { 1481 const checkButton = () => { 1482 const hostname = window.location.hostname; 1483 const isStaging = hostname === 'stg-fairfax-d8-redesign.virginiainteractive.org' || hostname === 'webwcmdev.fairfaxcounty.gov'; 1484 1485 const buttonId = isStaging ? 'phplive_btn_1779217493' : 'phplive_btn_1671029454'; 1486 const button = document.getElementById(buttonId); 1487 1488 if (button) { 1489 const checkStatus = (attempt = 1, maxAttempts = 20) => { 1490 const img = button.querySelector('img'); 1491 const svg = button.querySelector('svg'); 1492 const element = img || svg; 1493 1494 if (element) { 1495 let status = 'unknown'; 1496 1497 if (img && img.src) { 1498 const src = img.src.toLowerCase(); 1499 1500 if (src.includes('online')) { 1501 status = 'online'; 1502 } else if (src.includes('offline')) { 1503 status = 'offline'; 1504 } 1505 } else if (svg) { 1506 const svgId = svg.id ? svg.id.toLowerCase() : ''; 1507 const svgClass = svg.className ? String(svg.className).toLowerCase() : ''; 1508 1509 if (svgId.includes('online') || svgClass.includes('online')) { 1510 status = 'online'; 1511 } else if (svgId.includes('offline') || svgClass.includes('offline')) { 1512 status = 'offline'; 1513 } 1514 } 1515 1516 callback(status, button); 1517 } else if (attempt < maxAttempts) { 1518 const delay = Math.min(100 * attempt, 1000); 1519 setTimeout(() => checkStatus(attempt + 1, maxAttempts), delay); 1520 } else { 1521 callback('unknown', button); 1522 } 1523 }; 1524 1525 checkStatus(); 1526 return true; 1527 } 1528 return false; 1529 }; 1530 1531 if (checkButton()) { 1532 return null; 1533 } 1534 1535 if (!document.body) { 1536 if (document.readyState === 'loading') { 1537 document.addEventListener('DOMContentLoaded', () => { 1538 watchForLiveChatStatus(callback); 1539 }); 1540 return null; 1541 } 1542 } 1543 1544 const observer = new MutationObserver(() => { 1545 if (checkButton()) { 1546 observer.disconnect(); 1547 } 1548 }); 1549 1550 observer.observe(document.body, { 1551 childList: true, 1552 subtree: true 1553 }); 1554 1555 return observer; 1556} 1557 1558// âââ FOOTER OBSERVER ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1559// Moves #aiLinks after the Socrata chatbot's .customer-footer element 1560// once it renders (it loads dynamically with the embed). 1561 1562function checkForFooter() { 1563 return document.querySelector('.customer-footer'); 1564} 1565 1566function onFooterFound(element) { 1567 jQuery('#aiLinks').insertAfter('.customer-footer'); 1568} 1569 1570const observer = new MutationObserver((mutations) => { 1571 const footer = checkForFooter(); 1572 1573 if (footer) { 1574 onFooterFound(footer); 1575 observer.disconnect(); 1576 } 1577}); 1578 1579function startObserving() { 1580 const existingFooter = checkForFooter(); 1581 1582 if (existingFooter) { 1583 onFooterFound(existingFooter); 1584 return; 1585 } 1586 1587 observer.observe(document.body, { 1588 childList: true, 1589 subtree: true, 1590 attributes: true, 1591 attributeFilter: ['class'] 1592 }); 1593} 1594 1595if (document.readyState === 'loading') { 1596 document.addEventListener('DOMContentLoaded', startObserving); 1597} else { 1598 startObserving(); 1599} 1600 1601// âââ SIDEBAR TOGGLE âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1602// Slides the .chatbot sidebar panel open/closed via CSS class toggling. 1603// Note: this function only manages .chatbot classes â it does NOT set 1604// #aiOverlay display/opacity. Callers must handle overlay visibility separately. 1605 1606function toggleChat() { 1607 origScrollY = window.scrollY; 1608 if (jQuery('.chatbot').hasClass('open')) { 1609 jQuery('.chatbot').addClass('slide-right', 1000); 1610 jQuery('.chatbot').removeClass('slide-left'); 1611 jQuery('.chatbot').removeClass("slide-left-close"); 1612 } else { 1613 jQuery('.chatbot').removeClass('slide-right'); 1614 jQuery('.chatbot').addClass('slide-left', 1000); 1615 jQuery('.chatbot').addClass('slide-left-close'); 1616 } 1617 1618 jQuery('.chatbot').toggleClass('open'); 1619};
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.