PageSourceSearch

https://www.fairfaxcounty.gov/finance/themes/bootstrap-8.x-3.0-rc2/bootstrap/js/ra_chatbot.js?tkrlyi

js fairfaxcounty.gov collected 2026-09-24 08:29:42 UTC 66,396 bytes, 1,619 lines download raw bytes

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.