PluginProbe
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent / 3.5.3
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent v3.5.3
3.5.4 3.5.3 3.5.2 3.5.1 3.4.9 3.5.0 3.4.8 3.4.7 trunk 2.3.1 3.3.6 3.3.7 3.3.8 3.3.9 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.4.6
supportcandy / asset / js / ai-chatbot / chatbot.js

chatbot.js in SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent 3.5.3, at asset/js/ai-chatbot/chatbot.js

1,739 lines 56.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 window.WPSC_AI_Chatbot = window.WPSC_AI_Chatbot || {};
2
3 // Trace one hop of the send-message flow to the browser console. Silent
4 // unless the server is running with WP_DEBUG on (see wpsc_ai_chatbot.debug,
5 // localized in WPSC_ACB_Admin::enqueue_frontend_scripts()) - mirrors the
6 // server's WP_DEBUG-gated [WPSC ACB] error_log tracing so a full turn (type
7 // -> send -> backend -> render) can be followed from one console.
8 window.WPSC_AI_Chatbot.debugLog =
9
10 function( step, message, data ) {
11 if ( typeof wpsc_ai_chatbot === 'undefined' || ! wpsc_ai_chatbot.debug ) {
12 return;
13 }
14 if ( arguments.length > 2 ) {
15 console.debug( '[WPSC ACB DEBUG] ' + step + ' | ' + message, data );
16 } else {
17 console.debug( '[WPSC ACB DEBUG] ' + step + ' | ' + message );
18 }
19 };
20
21 document.addEventListener(
22 'DOMContentLoaded',
23 function () {
24 window.WPSC_AI_Chatbot.init();
25 }
26 );
27
28 // Get chatbot HTML template.
29 window.WPSC_AI_Chatbot.init =
30
31 function () {
32 const host = document.getElementById( 'wpsc-chatbot-root' );
33 if (!host) {
34 this.debugLog( 'INIT', '#wpsc-chatbot-root not found in DOM; widget will not render' );
35 return;
36 }
37
38 this.isSending = false;
39 this.isLimitReached = false;
40 this.isCreatingTicket = false;
41
42 // One-time cleanup of the pre-cross-tab auto-popup key (per-tab shown COUNT).
43 // It's superseded by wpsc_acb_popup_shown_in_tab (a per-tab boolean flag) and
44 // wpsc_acb_popup_state in localStorage (the cross-tab count) - see scheduleAutoPopup().
45 try {
46 window.sessionStorage.removeItem( 'wpsc_acb_popup_shown_count' );
47 } catch ( error ) {
48 // Ignore storage failures (e.g. private browsing with storage disabled).
49 }
50
51 this.shadowRoot = host.shadowRoot || host.attachShadow( { mode: 'open' } );
52 this.render();
53 this.cacheElements();
54 this.bindEvents();
55 this.scheduleAutoPopup();
56 this.bindMobileKeyboardResize();
57 this.debugLog( 'INIT', 'chatbot widget rendered and events bound' );
58
59 if ( ! this.nonceRefreshStarted ) {
60 this.nonceRefreshStarted = true;
61
62 // Keep chat input disabled until the first nonce refresh resolves, so a
63 // guest can't send a message on a stale, cached-page nonce.
64 this.disableChatInput( 'Preparing chat...' );
65 this.debugLog( 'INIT', 'chat input disabled pending initial nonce refresh' );
66 this.refreshNonce( true );
67 }
68 };
69
70 // Periodically fetch a fresh ajax nonce so guests served a long-lived
71 // full-page-cached copy of this page don't keep an expired nonce forever.
72 window.WPSC_AI_Chatbot.refreshNonce =
73
74 function( isInitial ) {
75 const self = this;
76
77 jQuery.post(
78 wpsc_ai_chatbot.ajax_url, {
79 action: 'wpsc_chatbot_get_nonce',
80 }
81 ).done(
82 function( response ) {
83 if ( response && response.success && response.data && response.data.nonce ) {
84 wpsc_ai_chatbot.nonce = response.data.nonce;
85 }
86 }
87 ).always(
88 function() {
89 if ( isInitial ) {
90 self.enableChatInput();
91 }
92
93 setTimeout(
94 function() {
95 window.WPSC_AI_Chatbot.refreshNonce();
96 },
97 60000
98 );
99 }
100 );
101 };
102
103 // Get chatbot HTML template.
104 window.WPSC_AI_Chatbot.render =
105
106 function () {
107 /*
108 * Inject CSS into Shadow DOM
109 */
110 const style = document.createElement( 'style' );
111 style.textContent = window.WPSC_AI_Chatbot_Config.css + window.WPSC_AI_Chatbot_Config.modal_css + window.WPSC_AI_Chatbot_Config.ticket_form_css;
112 this.shadowRoot.appendChild( style );
113
114 /*
115 * Inject HTML Template
116 */
117 const wrapper = document.createElement( 'div' );
118 wrapper.innerHTML = this.getTemplate() + this.getModalTemplate();
119 this.shadowRoot.appendChild( wrapper );
120 this.setWelcomeTime();
121 };
122
123 // Cache frequently accessed chatbot elements.
124 window.WPSC_AI_Chatbot.cacheElements =
125
126 function() {
127 if ( ! this.shadowRoot ) {
128 return;
129 }
130
131 this.elements = {
132 body: this.shadowRoot.querySelector( '.wpsc-chatbot__body' ),
133 input: this.shadowRoot.querySelector( '#wpsc-chatbot-input' ),
134 sendBtn: this.shadowRoot.querySelector( '.wpsc-chatbot__send' )
135 };
136 };
137
138 // Get chatbot HTML template.
139 window.WPSC_AI_Chatbot.bindEvents =
140
141 function () {
142 const self = this;
143 self.cacheElements();
144 const body = self.elements?.body;
145 const launcher = this.shadowRoot.querySelector( '.wpsc-chatbot-launcher' );
146 const sessionId = launcher?.getAttribute( 'data-sessionid' ) || '';
147 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
148 const closeBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__close' );
149 const expandBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__expand' );
150 const minimizeBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__minimize' );
151 const compressBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__compress' );
152 const dropdownBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__dropdown' );
153 const drawer = this.shadowRoot.querySelector( '.wpsc-chatbot__drawer' );
154 const textarea = this.shadowRoot.querySelector( '.wpsc-chatbot__input' );
155 const sendBtn = self.elements?.sendBtn;
156 const input = self.elements?.input;
157 const inputGroup = this.shadowRoot.querySelector( '.wpsc-chatbot__input-group' );
158 const footer = this.shadowRoot.querySelector( '.wpsc-chatbot__footer' );
159 const modal = this.shadowRoot.querySelector( '.wpsc-chatbot__modal' );
160 const modalCancelBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-cancel' );
161 const modalReactionBtns = this.shadowRoot.querySelectorAll( '.wpsc-chatbot__modal-reaction' );
162 const modalFooterAskMeLater = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-footer-ask-me-later' );
163
164 if (!launcher || !chatbot) {
165 return;
166 }
167
168 chatbot?.classList.add( 'wpsc-chatbot--active' );
169 launcher?.classList.add( 'wpsc-chatbot-launcher--hidden' );
170
171 if ( sessionId ) {
172
173 // Load previous messages if session exists.
174 self.getPreviousMessages();
175 chatbot?.classList.add( 'wpsc-chatbot--active' );
176 launcher?.classList.add( 'wpsc-chatbot-launcher--hidden' );
177 } else {
178 chatbot?.classList.remove( 'wpsc-chatbot--active' );
179 launcher?.classList.remove( 'wpsc-chatbot-launcher--hidden' );
180 }
181
182 // Open chatbot
183 launcher?.addEventListener(
184 'click',
185 function () {
186 if ( self.autoPopupTimer ) {
187 clearTimeout( self.autoPopupTimer );
188 }
189 footer.querySelector( '.wpsc-chatbot__input-conversation-end' )?.remove();
190 chatbot?.classList.add( 'wpsc-chatbot--active' );
191 launcher?.classList.add( 'wpsc-chatbot-launcher--hidden' );
192 inputGroup?.classList.remove( 'wpsc-chatbot__input-group--hidden' );
193 self.enableChatInput();
194 /* self.getPreviousMessages(); */
195 const activeSessionId = launcher?.getAttribute( 'data-sessionid' ) || '';
196 if ( ! activeSessionId ) {
197 body.innerHTML = self.getWelcomeMessageTemplate();
198 self.setWelcomeTime( body );
199 }
200 }
201 );
202
203 // Close chatbot
204 if (closeBtn) {
205
206 closeBtn.addEventListener(
207 'click',
208 function () {
209 const buttonSessionId = closeBtn?.getAttribute( 'data-sessionid' ) || '';
210 if ( ! buttonSessionId ) {
211 chatbot?.classList.remove( 'wpsc-chatbot--active' );
212 launcher?.classList.remove( 'wpsc-chatbot-launcher--hidden' );
213 self.scheduleAutoPopup();
214 } else {
215 self.openModal();
216 }
217 }
218 );
219 }
220
221 // Expand chatbot
222 if (expandBtn) {
223
224 expandBtn.addEventListener(
225 'click',
226 function () {
227 chatbot?.classList.toggle( 'wpsc-chatbot--fullscreen' );
228 chatbot?.classList.add( 'wpsc-chatbot--expanded' );
229 }
230 );
231 }
232
233 // Minimize chatbot
234 if (minimizeBtn) {
235
236 minimizeBtn.addEventListener(
237 'click',
238 function () {
239 chatbot?.classList.remove( 'wpsc-chatbot--active' );
240 launcher?.classList.remove( 'wpsc-chatbot-launcher--hidden' );
241 self.scheduleAutoPopup();
242 }
243 );
244 }
245
246 // On small screens, tapping the conversation area should open it in fullscreen expanded mode.
247 chatbot?.addEventListener(
248 'click',
249 function ( event ) {
250 if ( ! window.matchMedia( '(max-width: 768px)' ).matches ) {
251 return;
252 }
253
254 // Header (expand/minimize/compress/close) and footer (message input/send)
255 // manage their own state or must stay interactive without forcing
256 // fullscreen - tapping to focus the textarea is a click too, and
257 // shouldn't flip the widget into expanded mode along with the keyboard.
258 if ( event.target.closest( '.wpsc-chatbot__header, .wpsc-chatbot__footer' ) ) {
259 return;
260 }
261
262 chatbot?.classList.add( 'wpsc-chatbot--fullscreen' );
263 chatbot?.classList.add( 'wpsc-chatbot--expanded' );
264 }
265 );
266
267 // Compress chatbot
268 if (compressBtn) {
269
270 compressBtn.addEventListener(
271 'click',
272 function () {
273 chatbot?.classList.toggle( 'wpsc-chatbot--fullscreen' );
274 chatbot?.classList.remove( 'wpsc-chatbot--expanded' );
275 }
276 );
277 }
278
279 // Toggle drawer
280 if (dropdownBtn && drawer) {
281
282 dropdownBtn.addEventListener(
283 'click',
284 function () {
285 drawer?.classList.toggle( 'wpsc-chatbot__drawer--active' );
286 }
287 );
288 }
289
290 // Textarea auto-resize
291 if (textarea) {
292
293 textarea.addEventListener(
294 'input',
295 function () {
296 const maxHeight = 120;
297 this.style.height = 'auto';
298 this.style.height = Math.min( this.scrollHeight, maxHeight ) + 'px';
299 this.style.overflowY = this.scrollHeight > maxHeight ? 'auto' : 'hidden';
300 }
301 );
302 }
303
304 // Send message on button click.
305 if ( sendBtn ) {
306
307 sendBtn.addEventListener(
308 'click',
309 function() {
310 self.debugLog( 'STEP 1: USER_ACTION', 'send button clicked' );
311 self.sendMessage();
312 }
313 );
314 }
315
316 // Send message on Enter key press without Shift.
317 if ( input ) {
318
319 input.addEventListener(
320 'keydown',
321 function( e ) {
322 if (
323 e.key === 'Enter' &&
324 ! e.shiftKey
325 ) {
326 e.preventDefault();
327 self.debugLog( 'STEP 1: USER_ACTION', 'Enter key pressed in input (no shift)' );
328 self.sendMessage();
329 }
330 }
331 );
332 }
333
334 // Modal confirm cancel button.
335 if ( modalCancelBtn ) {
336
337 modalCancelBtn.addEventListener(
338 'click',
339 () => {
340 self.closeModal();
341 }
342 );
343 }
344
345 if ( modalFooterAskMeLater ) {
346 modalFooterAskMeLater.addEventListener(
347 'click',
348 () => {
349 const sessionId = modalFooterAskMeLater?.getAttribute( 'data-sessionid' ) || '';
350 self.askMeLater( sessionId );
351 }
352 );
353 }
354
355 // Modal confirm confirm button.
356 if ( modalReactionBtns && modalReactionBtns.length > 0 ) {
357
358 modalReactionBtns.forEach(
359 (button) => {
360 button.addEventListener(
361 'click',
362 (event) => {
363 event.preventDefault();
364 const reaction = event.currentTarget?.dataset?.reaction || '';
365 const sessionId = event.currentTarget?.dataset?.sessionid || '';
366 const ticketCreated = event.currentTarget?.dataset?.ticketcreated === 'true';
367 self.saveChatReaction( reaction, sessionId, ticketCreated );
368 }
369 );
370 }
371 );
372 }
373
374
375
376 // Delegate click event for dynamically added ticket submit button.
377 this.shadowRoot.addEventListener(
378 'click',
379 (event) => {
380
381 const ticketBtn = event.target.closest( '.wpsc-chatbot__ticket-submit' );
382 if ( ticketBtn ) {
383 event.preventDefault();
384 const sessionId = ticketBtn?.dataset?.sessionid || '';
385 this.createTicket( true, ticketBtn?.dataset?.source || '', sessionId );
386 }
387
388 const cancelBtn = event.target.closest( '.wpsc-chatbot__ticket-cancel' );
389 if ( cancelBtn ) {
390 event.preventDefault();
391 const sessionId = cancelBtn?.dataset?.sessionid || '';
392 this.closeTicketModal( 2, cancelBtn?.dataset?.source || '', sessionId );
393 }
394
395 const formCancelBtn = event.target.closest( '.wpsc-chatbot__ticket-form-cancel' );
396 if ( formCancelBtn ) {
397 event.preventDefault();
398 self.openModal();
399 }
400 }
401 );
402 };
403
404 // --- Auto-popup storage helpers -------------------------------------------
405 //
406 // Two independent pieces of state decide whether the auto-popup can show:
407 //
408 // 1. A per-TAB flag (sessionStorage, key below) - "has THIS tab already auto-
409 // shown the popup?". sessionStorage is naturally scoped to one tab and
410 // survives reloads within it, but a brand new tab/window always starts
411 // fresh - exactly the "once per tab" semantics we want.
412 //
413 // 2. A cross-TAB counter with a 24h rolling window (localStorage, key below)
414 // - "how many times has ANY tab shown the popup in the current 24h
415 // period?". localStorage is shared by every same-origin tab, so this is
416 // the single source of truth for the display limit.
417 //
418 // Concurrency note: localStorage has no built-in atomic read-modify-write,
419 // so two tabs could both read count=2 (limit 3) before either writes back
420 // 3, and each would believe it won the last slot - a classic
421 // check-then-act race. Where the browser supports the Web Locks API
422 // (https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API - current
423 // Chrome/Edge/Firefox/Safari), consumeGlobalPopupSlot() uses it to make the
424 // read-check-increment sequence a genuine cross-tab critical section, which
425 // closes this race entirely. On older browsers without Web Locks, the same
426 // function falls back to a best-effort read-then-write - there remains a
427 // small theoretical window where two timers firing at the exact same
428 // instant could both succeed, but this is a client-side popup nicety, not
429 // a security or billing boundary, so the practical risk (occasionally
430 // showing one extra popup out of many tabs closing in the same millisecond)
431 // is an acceptable trade-off for staying purely client-side.
432
433 // Namespaced (not top-level const) so re-including this script never risks a
434 // "duplicate declaration" error - assigning object properties is idempotent.
435 window.WPSC_AI_Chatbot.POPUP_TAB_KEY = 'wpsc_acb_popup_shown_in_tab';
436 window.WPSC_AI_Chatbot.POPUP_STATE_KEY = 'wpsc_acb_popup_state';
437 window.WPSC_AI_Chatbot.POPUP_LOCK_NAME = 'wpsc_acb_popup_lock';
438 window.WPSC_AI_Chatbot.POPUP_PERIOD_MS = 24 * 60 * 60 * 1000;
439
440 // Has this tab already auto-shown the popup once?
441 window.WPSC_AI_Chatbot.hasTabShownPopup =
442
443 function() {
444 try {
445 return window.sessionStorage.getItem( this.POPUP_TAB_KEY ) === '1';
446 } catch ( error ) {
447 // Storage unavailable (e.g. private browsing) - fail open so at least
448 // this tab can still show the popup once.
449 return false;
450 }
451 };
452
453 // Mark this tab as having auto-shown the popup, so it never shows again for
454 // the lifetime of this tab.
455 window.WPSC_AI_Chatbot.markTabShownPopup =
456
457 function() {
458 try {
459 window.sessionStorage.setItem( this.POPUP_TAB_KEY, '1' );
460 } catch ( error ) {
461 // Ignore - worst case this tab could show the popup again later.
462 }
463 };
464
465 // Read the cross-tab popup state from localStorage, resetting it (and
466 // persisting the reset) if the 24h period has elapsed. Never throws - falls
467 // back to a fresh, valid state if storage is unavailable or the stored
468 // value is missing/corrupt, so a bad value can never permanently break the
469 // popup.
470 window.WPSC_AI_Chatbot.readPopupState =
471
472 function() {
473 const freshState = function() {
474 return { count: 0, periodStartedAt: Date.now() };
475 };
476
477 let state;
478 try {
479 const parsed = JSON.parse( window.localStorage.getItem( this.POPUP_STATE_KEY ) );
480 state = ( parsed && typeof parsed.count === 'number' && typeof parsed.periodStartedAt === 'number' )
481 ? parsed
482 : freshState();
483 } catch ( error ) {
484 state = freshState();
485 }
486
487 if ( Date.now() - state.periodStartedAt >= this.POPUP_PERIOD_MS ) {
488 this.debugLog( 'AUTO_POPUP', '24h period elapsed - resetting cross-tab popup count' );
489 state = freshState();
490 }
491
492 try {
493 window.localStorage.setItem( this.POPUP_STATE_KEY, JSON.stringify( state ) );
494 } catch ( error ) {
495 // Ignore storage failures - the in-memory state below is still usable for this call.
496 }
497
498 return state;
499 };
500
501 // Try to consume one of the shared display slots for this 24h period.
502 // Returns a Promise<boolean> - true if a slot was available and has now
503 // been consumed (the caller should show the popup), false if the limit was
504 // already reached (by this tab or another one).
505 window.WPSC_AI_Chatbot.consumeGlobalPopupSlot =
506
507 function( limit ) {
508 const self = this;
509
510 const tryConsume = function() {
511 const state = self.readPopupState();
512 if ( state.count >= limit ) {
513 self.debugLog( 'AUTO_POPUP', 'global display limit already reached for this 24h period', state );
514 return false;
515 }
516
517 state.count += 1;
518 try {
519 window.localStorage.setItem( self.POPUP_STATE_KEY, JSON.stringify( state ) );
520 } catch ( error ) {
521 // Ignore storage failures - proceed as consumed so this visitor still sees
522 // the popup once, even if the count can't be persisted for other tabs.
523 }
524 self.debugLog( 'AUTO_POPUP', 'consumed global display slot', state );
525 return true;
526 };
527
528 if ( window.navigator && navigator.locks && typeof navigator.locks.request === 'function' ) {
529 return navigator.locks.request( self.POPUP_LOCK_NAME, function() {
530 return tryConsume();
531 } );
532 }
533
534 // Web Locks API unavailable - best-effort fallback, see concurrency note above.
535 return Promise.resolve( tryConsume() );
536 };
537
538 // Automatically open the chatbot after the configured delay (if Popup Delay
539 // Status is enabled - otherwise immediately), capped to a display limit
540 // shared across browser tabs within a rolling 24h period, with each tab
541 // showing the popup at most once. See the storage helpers above for the
542 // exact mechanics and their concurrency guarantees.
543 window.WPSC_AI_Chatbot.scheduleAutoPopup =
544
545 function() {
546 const self = this;
547
548 if ( this.autoPopupTimer ) {
549 clearTimeout( this.autoPopupTimer );
550 this.autoPopupTimer = null;
551 }
552
553 const launcher = this.shadowRoot.querySelector( '.wpsc-chatbot-launcher' );
554 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
555
556 // Don't auto-open on top of an already active/ongoing conversation.
557 if ( ! launcher || ! chatbot || launcher.getAttribute( 'data-sessionid' ) ) {
558 self.debugLog( 'AUTO_POPUP', 'skip: missing widget elements or an active session already exists' );
559 return;
560 }
561
562 // Popup Delay Status is the master switch for the whole auto-popup feature -
563 // when it's off, Popup Delay and Popup Display Limit must not be acted on at
564 // all: no timer is armed, and the display-limit counter is never touched, so
565 // turning it back on later starts from a limit that wasn't silently consumed
566 // while it was off. Defaults to enabled (matches pre-existing behavior) when
567 // the setting is missing, e.g. on an older/unmigrated install.
568 const delayStatusEnabled = typeof wpsc_ai_chatbot?.popup_delay_status === 'undefined'
569 ? true
570 : !! parseInt( wpsc_ai_chatbot.popup_delay_status, 10 );
571 if ( ! delayStatusEnabled ) {
572 self.debugLog( 'AUTO_POPUP', 'skip: popup delay status is disabled - auto-popup is off' );
573 return;
574 }
575
576 // A display limit of 0 (or unset) means the popup is disabled entirely.
577 const limit = parseInt( wpsc_ai_chatbot?.popup_display_limit, 10 ) || 0;
578 if ( limit <= 0 ) {
579 self.debugLog( 'AUTO_POPUP', 'skip: popup display limit is 0/unset' );
580 return;
581 }
582
583 // Once this tab has auto-shown the popup, it never shows again in this tab.
584 if ( self.hasTabShownPopup() ) {
585 self.debugLog( 'AUTO_POPUP', 'skip: this tab already auto-showed the popup once' );
586 return;
587 }
588
589 // Cheap up-front check so a timer isn't even armed when the global limit is
590 // already exhausted. The authoritative check happens again right before the
591 // popup is shown (below), since another tab can consume the remaining slots
592 // while this tab is waiting out its delay.
593 if ( self.readPopupState().count >= limit ) {
594 self.debugLog( 'AUTO_POPUP', 'skip: global display limit already reached for this period' );
595 return;
596 }
597
598 const delaySeconds = parseInt( wpsc_ai_chatbot?.popup_delay, 10 ) || 0;
599
600 self.debugLog( 'AUTO_POPUP', 'arming timer', { delaySeconds, delayStatusEnabled, limit } );
601
602 this.autoPopupTimer = setTimeout(
603 function() {
604 self.autoPopupTimer = null;
605
606 // Visitor may have already opened (or otherwise dismissed) the launcher before the timer fired.
607 if ( chatbot.classList.contains( 'wpsc-chatbot--active' ) || launcher.classList.contains( 'wpsc-chatbot-launcher--hidden' ) ) {
608 self.debugLog( 'AUTO_POPUP', 'skip at fire time: chat already opened' );
609 return;
610 }
611
612 if ( self.hasTabShownPopup() ) {
613 self.debugLog( 'AUTO_POPUP', 'skip at fire time: this tab already auto-showed the popup once' );
614 return;
615 }
616
617 // Re-check and consume the shared slot atomically (where supported) right
618 // before displaying - another tab may have used up the remaining slots
619 // while this tab was waiting out its delay.
620 self.consumeGlobalPopupSlot( limit ).then(
621 function( consumed ) {
622 if ( ! consumed ) {
623 self.debugLog( 'AUTO_POPUP', 'skip at fire time: global limit reached by another tab' );
624 return;
625 }
626
627 self.markTabShownPopup();
628
629 chatbot.classList.add( 'wpsc-chatbot--active' );
630 launcher.classList.add( 'wpsc-chatbot-launcher--hidden' );
631 self.enableChatInput();
632 self.debugLog( 'AUTO_POPUP', 'popup shown' );
633 }
634 );
635 },
636 delaySeconds * 1000
637 );
638 };
639
640 // On mobile, the chatbot fills the screen (100dvh) - but `dvh` doesn't reliably shrink
641 // for the on-screen keyboard across mobile browsers, so the footer/input can end up hidden
642 // behind it. Track the real visible area via visualViewport and resize the widget to match,
643 // the same way native chat apps (e.g. WhatsApp) keep header + messages + input all in view.
644 window.WPSC_AI_Chatbot.bindMobileKeyboardResize =
645
646 function() {
647 const self = this;
648 const viewport = window.visualViewport;
649 if ( ! viewport ) {
650 return;
651 }
652
653 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
654 if ( ! chatbot ) {
655 return;
656 }
657
658 const isMobile = () => window.matchMedia( '(max-width: 768px)' ).matches;
659
660 const adjustForViewport = function() {
661 if ( ! isMobile() || ! chatbot.classList.contains( 'wpsc-chatbot--active' ) ) {
662 chatbot.style.height = '';
663 chatbot.style.top = '';
664 return;
665 }
666
667 chatbot.style.top = viewport.offsetTop + 'px';
668 chatbot.style.height = viewport.height + 'px';
669
670 // Keep the latest message in view once the visible area shrinks/grows.
671 const body = self.elements?.body;
672 if ( body ) {
673 body.scrollTop = body.scrollHeight;
674 }
675 };
676
677 viewport.addEventListener( 'resize', adjustForViewport );
678 viewport.addEventListener( 'scroll', adjustForViewport );
679 };
680
681 // Get previous messages if session exists.
682 window.WPSC_AI_Chatbot.getPreviousMessages =
683
684 function( isRetry ) {
685 const self = this;
686 self.cacheElements();
687 const body = self.elements?.body;
688
689 self.debugLog( 'HISTORY_LOAD', 'requesting previous messages for existing session cookie (isRetry=' + !! isRetry + ')' );
690
691 jQuery.post(
692 wpsc_ai_chatbot.ajax_url, {
693 action: 'wpsc_chatbot_get_previous_messages',
694 _ajax_nonce: wpsc_ai_chatbot.nonce,
695 }
696 ).done(
697 function( response ) {
698 if ( ! response.success ) {
699 self.debugLog( 'HISTORY_LOAD', 'response.success false; leaving chat body untouched' );
700 return;
701 }
702 if ( ! body ) {
703 return;
704 }
705 const messages = response.data?.messages || [];
706 self.debugLog( 'HISTORY_LOAD', 'rendering ' + messages.length + ' previous message(s)' );
707 body.innerHTML = self.getWelcomeMessageTemplate();
708 // Anchor the welcome message to when the session actually started, not to this refresh.
709 self.setWelcomeTime( body, response.data?.session_started );
710 messages.forEach( ( message ) => {
711 self.appendMessage( message.role, message.content, message.date_created );
712 } );
713 }
714 ).fail(
715 function( jqXHR ) {
716
717 // This call runs on page load, before the periodic nonce
718 // refresh (see refreshNonce()) has had a chance to run - on
719 // a long-lived full-page-cached copy of the page, the nonce
720 // baked into that cached HTML can already be stale, so the
721 // very first request here gets rejected with 401. Refresh
722 // the nonce once and retry, instead of silently leaving the
723 // chatbox empty even though the session and its history are
724 // still there server-side.
725 if ( isRetry || ! jqXHR || 401 !== jqXHR.status ) {
726 return;
727 }
728
729 jQuery.post(
730 wpsc_ai_chatbot.ajax_url, {
731 action: 'wpsc_chatbot_get_nonce',
732 }
733 ).done(
734 function( nonceResponse ) {
735 if ( nonceResponse && nonceResponse.success && nonceResponse.data && nonceResponse.data.nonce ) {
736 wpsc_ai_chatbot.nonce = nonceResponse.data.nonce;
737 }
738 }
739 ).always(
740 function() {
741 self.getPreviousMessages( true );
742 }
743 );
744 }
745 );
746 };
747
748 // Send message to server and get AI response.
749 window.WPSC_AI_Chatbot.sendMessage =
750
751 function() {
752 const self = this;
753 self.cacheElements();
754
755 const input = self.elements?.input;
756 const sendBtn = self.elements?.sendBtn;
757 if ( this.isSending || this.isLimitReached || ! input || input.disabled ) {
758 self.debugLog(
759 'STEP 2: SEND_MESSAGE_GUARD',
760 'sendMessage() aborted early',
761 { isSending: this.isSending, isLimitReached: this.isLimitReached, hasInput: !! input, inputDisabled: input?.disabled }
762 );
763 return;
764 }
765
766 const message = input.value.trim();
767 if ( ! message ) {
768 self.debugLog( 'STEP 2: SEND_MESSAGE_GUARD', 'sendMessage() aborted: empty message' );
769 return;
770 }
771
772 self.debugLog( 'STEP 2: SEND_MESSAGE_START', 'user message captured, length=' + message.length, message );
773
774 input.value = '';
775 self.appendMessage( 'user', message );
776 self.showTyping();
777 this.isSending = true;
778 input.disabled = true;
779 if ( sendBtn ) {
780 sendBtn.disabled = true;
781 }
782
783 const launcher = this.shadowRoot.querySelector( '.wpsc-chatbot-launcher' );
784 const closeBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__close' );
785 const modalFooterAskMeLater = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-footer-ask-me-later' );
786 const modalReactionBtns = this.shadowRoot.querySelectorAll( '.wpsc-chatbot__modal-reaction' );
787 const submitBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-submit' );
788 const cancelBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-cancel' );
789
790 self.debugLog(
791 'STEP 3: AJAX_REQUEST',
792 'POST wpsc_chatbot_send_message -> ' + wpsc_ai_chatbot.ajax_url,
793 { action: 'wpsc_chatbot_send_message', message_length: message.length }
794 );
795
796 jQuery.post(
797 wpsc_ai_chatbot.ajax_url, {
798 action: 'wpsc_chatbot_send_message',
799 _ajax_nonce: wpsc_ai_chatbot.nonce,
800 message: message
801 }
802 ).done(
803 function( response ) {
804 self.isSending = false;
805 self.debugLog( 'STEP 4: AJAX_RESPONSE_RECEIVED', 'raw response from wpsc_chatbot_send_message', response );
806
807 if ( ! response || ! response.data ) {
808 self.debugLog( 'STEP 4: AJAX_RESPONSE_RECEIVED', 'response missing or has no data payload; aborting render' );
809 self.hideTyping();
810 self.enableChatInput();
811 return;
812 }
813
814 if ( ! response.success ) {
815 self.debugLog( 'STEP 4: AJAX_RESPONSE_RECEIVED', 'response.success is false; aborting render', response.data );
816 self.hideTyping();
817 if ( ! self.isLimitReached ) {
818 self.enableChatInput();
819 }
820 return;
821 }
822
823 if ( response.data.session_id ) {
824 launcher?.setAttribute( 'data-sessionid', response.data.session_id );
825 closeBtn?.setAttribute( 'data-sessionid', response.data.session_id );
826 modalFooterAskMeLater?.setAttribute( 'data-sessionid', response.data.session_id );
827 modalReactionBtns.forEach( ( btn ) => {
828 if ( ! btn ) {
829 return;
830 }
831 // A different session id means a brand-new chat session just started
832 // (e.g. after Skip/Cancel, or the post-ticket-creation auto-reset) - the
833 // previous session's "ticket already created" flag no longer applies here.
834 if ( btn.dataset.ticketcreated === 'true' && btn.getAttribute( 'data-sessionid' ) !== response.data.session_id ) {
835 btn.setAttribute( 'data-ticketcreated', 'false' );
836 }
837 btn.setAttribute( 'data-sessionid', response.data.session_id );
838 } );
839 submitBtn?.setAttribute( 'data-sessionid', response.data.session_id );
840 cancelBtn?.setAttribute( 'data-sessionid', response.data.session_id );
841 }
842
843 const {
844 limit_reached,
845 create_ticket,
846 session_expired,
847 chat_end_message,
848 ai_response,
849 disable_input_message,
850 } = response.data;
851
852 // Handle ticket creation / limit reached / session expiration.
853 if ( limit_reached || create_ticket || session_expired ) {
854
855 self.debugLog(
856 'STEP 5: RESPONSE_BRANCH',
857 'limit_reached/create_ticket/session_expired branch',
858 { limit_reached, create_ticket, session_expired, chat_end_message }
859 );
860
861 if ( chat_end_message ) {
862 self.hideTyping();
863 self.debugLog( 'STEP 6: RENDER', 'chat_end_message present -> handleTicketCreated()', ai_response );
864 self.handleTicketCreated( {
865 chat_end_message,
866 message: ai_response,
867 } );
868 return;
869 }
870
871 self.isLimitReached = true;
872 self.debugLog( 'STEP 6: RENDER', 'appendMessage(assistant) [limit reached/ticket path, no chat_end_message]', ai_response );
873 self.appendMessage( 'assistant', ai_response );
874 // self.showTicketForm( disable_input_message );
875 self.hideTyping();
876
877 return;
878 }
879
880 // Handle chat end.
881 if ( chat_end_message ) {
882 self.hideTyping();
883 self.debugLog( 'STEP 5: RESPONSE_BRANCH', 'chat_end_message branch -> handleTicketCreated()', ai_response );
884 self.handleTicketCreated( {
885 chat_end_message,
886 message: ai_response,
887 } );
888 return;
889 }
890
891 self.debugLog( 'STEP 5: RESPONSE_BRANCH', 'normal assistant reply branch' );
892 self.debugLog( 'STEP 6: RENDER', 'appendMessage(assistant)', response.data.ai_response );
893 self.appendMessage( 'assistant', response.data.ai_response );
894 self.hideTyping();
895 self.enableChatInput();
896 self.debugLog( 'STEP 7: TURN_COMPLETE', 'typing indicator hidden, input re-enabled; user now sees the response' );
897 }
898 ).fail(
899 function( jqXHR, textStatus, errorThrown ) {
900 self.isSending = false;
901 self.debugLog(
902 'STEP 4: AJAX_RESPONSE_FAILED',
903 'wpsc_chatbot_send_message request failed',
904 { status: jqXHR?.status, textStatus, errorThrown }
905 );
906 self.hideTyping();
907 if ( ! self.isLimitReached ) {
908 self.enableChatInput();
909 }
910 }
911 );
912 };
913
914 // Append message to chat body.
915 window.WPSC_AI_Chatbot.appendMessage =
916
917 function( type, message, date_created ) {
918 this.cacheElements();
919 const body = this.elements?.body;
920 if ( ! body ) {
921 this.debugLog( 'APPEND_MESSAGE', 'aborted: .wpsc-chatbot__body not found in shadow DOM', { type } );
922 return;
923 }
924
925 const wrapper = document.createElement( 'div' );
926 // Use the message's own stored date (e.g. when re-rendering history on refresh) so its
927 // displayed time stays fixed, rather than always showing the live current time.
928 // date_created is stored in UTC ('Y-m-d H:i:s'); mark it explicitly as UTC so the browser
929 // converts it to the visitor's local time instead of parsing it as already-local.
930 let messageDate = date_created ? new Date( String( date_created ).replace( ' ', 'T' ) + 'Z' ) : new Date();
931 if ( isNaN( messageDate.getTime() ) ) {
932 messageDate = new Date();
933 }
934 const currentTime = this.formatTime( messageDate );
935
936 let sender = 'Assistant';
937 let className = 'wpsc-chatbot__system__message';
938
939 if ( type === 'user' ) {
940 sender = 'You';
941 className = 'wpsc-chatbot__user__message';
942 }
943
944 wrapper.className = className;
945 // Assistant output is rendered as trusted HTML; sanitize it on the server before returning.
946 const safeMessage = ( type === 'user' ) ? this.escapeHtml( String( message ) ) : String( message );
947 const formattedMessage = safeMessage
948 // Preserve HTML flow by removing line breaks that only separate tags (e.g. </p>\n<ol>).
949 .replace( />\s*\n+\s*</g, '><' )
950 .replace( /\n/g, '<br>' );
951
952 wrapper.innerHTML =
953 `
954 <div class="wpsc-chatbot__message-content">
955 ${formattedMessage}
956 </div>
957 <div class="wpsc-chatbot__message-meta">
958 <span> ${sender} </span>
959 <span> ${currentTime} </span>
960 </div>
961 `;
962
963 body.appendChild( wrapper );
964 body.scrollTop = body.scrollHeight;
965 this.debugLog( 'APPEND_MESSAGE', 'message node appended to DOM, sender=' + sender + ', length=' + formattedMessage.length );
966 };
967
968 // Fill in the static welcome message's time using the visitor's local time.
969 // The PHP templates render this markup once (server-side) with an empty
970 // placeholder, since a static server-rendered time can't reflect each visitor's timezone.
971 // When re-rendering an existing session (e.g. after a page refresh), pass the session's
972 // own start date_created so this stays fixed instead of jumping to the refresh time.
973 window.WPSC_AI_Chatbot.setWelcomeTime =
974
975 function( root, date_created ) {
976 const scope = root || this.shadowRoot;
977 if ( ! scope ) {
978 return;
979 }
980 let time = date_created ? new Date( String( date_created ).replace( ' ', 'T' ) + 'Z' ) : new Date();
981 if ( isNaN( time.getTime() ) ) {
982 time = new Date();
983 }
984 const formattedTime = this.formatTime( time );
985 scope.querySelectorAll( '.wpsc-chatbot__welcome-time' ).forEach( ( el ) => {
986 el.textContent = formattedTime;
987 } );
988 };
989
990 // Format a Date using the site's WordPress "Time Format" setting (Settings > General),
991 // so chat timestamps match the format admins/visitors already see elsewhere on the site.
992 window.WPSC_AI_Chatbot.formatTime =
993
994 function( date ) {
995 const format = ( window.WPSC_AI_Chatbot_Config && window.WPSC_AI_Chatbot_Config.time_format ) || 'g:i a';
996 const hours24 = date.getHours();
997 const hours12 = ( hours24 % 12 ) || 12;
998 const pad = ( n ) => String( n ).padStart( 2, '0' );
999
1000 const tokens = {
1001 g: String( hours12 ),
1002 G: String( hours24 ),
1003 h: pad( hours12 ),
1004 H: pad( hours24 ),
1005 i: pad( date.getMinutes() ),
1006 s: pad( date.getSeconds() ),
1007 a: hours24 < 12 ? 'am' : 'pm',
1008 A: hours24 < 12 ? 'AM' : 'PM',
1009 };
1010
1011 return format.replace( /\\?[a-zA-Z]/g, ( token ) => {
1012 // A backslash escapes the next character as a literal (PHP date() convention).
1013 if ( token.startsWith( '\\' ) ) {
1014 return token.slice( 1 );
1015 }
1016 return token in tokens ? tokens[ token ] : token;
1017 } );
1018 };
1019
1020 // Escape HTML to prevent XSS attacks.
1021 window.WPSC_AI_Chatbot.escapeHtml =
1022
1023 function( text ) {
1024 const div = document.createElement( 'div' );
1025 div.textContent = text;
1026 return div.innerHTML;
1027 };
1028
1029 // Show typing till get response from AI
1030 window.WPSC_AI_Chatbot.showTyping =
1031
1032 function() {
1033 const existing = this.shadowRoot.querySelector( '#wpsc-chatbot-typing' );
1034 if ( existing ) {
1035 return;
1036 }
1037
1038 this.cacheElements();
1039 const body = this.elements?.body;
1040 if ( ! body ) {
1041 return;
1042 }
1043 const typing = document.createElement( 'div' );
1044
1045 typing.className = 'wpsc-chatbot__typing';
1046 typing.id = 'wpsc-chatbot-typing';
1047 typing.innerHTML = '<span></span>\
1048 <span></span>\
1049 <span></span>';
1050
1051 this.debugLog( 'TYPING_INDICATOR', 'typing indicator shown, waiting for AI response' );
1052 body.appendChild( typing );
1053 body.scrollTop = body.scrollHeight;
1054 };
1055
1056 // Hide typing after getting response from AI
1057 window.WPSC_AI_Chatbot.hideTyping =
1058
1059 function() {
1060 const typing = this.shadowRoot.querySelector( '#wpsc-chatbot-typing' );
1061 if ( typing ) {
1062 typing.remove();
1063 this.debugLog( 'TYPING_INDICATOR', 'typing indicator removed' );
1064 }
1065 };
1066
1067 // Open exit chat confirmation modal.
1068 window.WPSC_AI_Chatbot.openModal =
1069
1070 function() {
1071 const modal = this.shadowRoot.querySelector( '.wpsc-chatbot__modal' );
1072 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
1073 const modalDialog = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-dialog' );
1074 const ticketModal = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-modal' );
1075 const negativeReaction = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-reaction--negative' );
1076
1077 negativeReaction?.classList.remove( 'wpsc-chatbot__modal-reaction--negative--selected' );
1078 ticketModal?.remove();
1079
1080 chatbot?.classList.add( 'wpsc-chatbot--active' );
1081 if ( window.matchMedia( '(max-width: 768px)' ).matches ) {
1082 chatbot?.classList.add( 'wpsc-chatbot--fullscreen' );
1083 chatbot?.classList.add( 'wpsc-chatbot--expanded' );
1084 }
1085
1086 chatbot?.classList.add( 'wpsc-chatbot--disabled' );
1087 chatbot?.classList.add( 'wpsc-chatbot__modal__open' );
1088 if ( modal ) {
1089 modal?.classList.add( 'wpsc-chatbot__modal--active' );
1090 }
1091 };
1092
1093 // Close exit chat confirmation modal.
1094 window.WPSC_AI_Chatbot.closeModal =
1095
1096 function() {
1097 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
1098 const modal = this.shadowRoot.querySelector( '.wpsc-chatbot__modal' );
1099 const textarea = this.shadowRoot.querySelector( '.wpsc-chatbot__input' );
1100
1101 if ( modal ) {
1102 modal?.classList.remove( 'wpsc-chatbot__modal--active' );
1103 }
1104
1105 chatbot?.classList.add( 'wpsc-chatbot--active' );
1106 if ( chatbot ) {
1107 chatbot?.classList.remove( 'wpsc-chatbot__modal__open' );
1108 chatbot?.classList.remove( 'wpsc-chatbot--disabled' );
1109 }
1110
1111 // Restore focus to the input field after closing the modal and clear textarea content.
1112 if ( textarea ) {
1113 textarea.value = '';
1114 textarea.disabled = false;
1115 textarea.classList.remove( 'wpsc-chatbot__input--readonly' );
1116 textarea.placeholder = 'Type your message...';
1117 textarea.focus();
1118 }
1119 };
1120
1121 // Close exit chat confirmation modal.
1122 window.WPSC_AI_Chatbot.askMeLater =
1123
1124 function( sessionId ) {
1125 const self = this;
1126 const body = self.elements?.body;
1127 const modal = self.shadowRoot.querySelector( '.wpsc-chatbot__modal' );
1128 const launcher = self.shadowRoot.querySelector( '.wpsc-chatbot-launcher' );
1129 const chatbot = self.shadowRoot.querySelector( '.wpsc-chatbot' );
1130 const textarea = this.shadowRoot.querySelector( '.wpsc-chatbot__input' );
1131
1132 if ( modal ) {
1133 modal?.classList.remove( 'wpsc-chatbot__modal--active' );
1134 }
1135
1136 chatbot?.classList.add( 'wpsc-chatbot--active' );
1137 if ( chatbot ) {
1138 chatbot?.classList.remove( 'wpsc-chatbot__modal__open' );
1139 chatbot?.classList.remove( 'wpsc-chatbot--disabled' );
1140 chatbot?.classList.remove( 'wpsc-chatbot--active' );
1141 }
1142
1143 if ( body ) {
1144 body.innerHTML = self.getWelcomeMessageTemplate();
1145 self.setWelcomeTime( body );
1146 }
1147 launcher?.classList.remove( 'wpsc-chatbot-launcher--hidden' );
1148
1149 // Restore focus to the input field after closing the modal and clear textarea content.
1150 if ( textarea ) {
1151 textarea.value = '';
1152 textarea.disabled = false;
1153 textarea.classList.remove( 'wpsc-chatbot__input--readonly' );
1154 textarea.placeholder = 'Type your message...';
1155 textarea.focus();
1156 }
1157
1158 // mark session closed on the server, then remove cookie from browser.
1159 self.skipFeedback( sessionId );
1160 };
1161
1162 // Mark session as closed (skipped feedback) on server.
1163 window.WPSC_AI_Chatbot.skipFeedback =
1164
1165 function( sessionId ) {
1166 const self = this;
1167
1168 jQuery.post(
1169 wpsc_ai_chatbot.ajax_url, {
1170 action: 'wpsc_chatbot_skip_feedback',
1171 _ajax_nonce: wpsc_ai_chatbot.nonce,
1172 session_id: sessionId
1173 }
1174 ).always(
1175 function() {
1176 self.removeSessionCookie( sessionId );
1177 }
1178 );
1179 };
1180
1181 // Remove chatbot session cookie from server.
1182 window.WPSC_AI_Chatbot.removeSessionCookie =
1183
1184 function( sessionId ) {
1185 const self = this;
1186
1187 // Remove session id from all elements that have it.
1188 const launcher = this.shadowRoot.querySelector( '.wpsc-chatbot-launcher' );
1189 const closeBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__close' );
1190 const modalFooterAskMeLater = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-footer-ask-me-later' );
1191 const modalReactionBtns = this.shadowRoot.querySelectorAll( '.wpsc-chatbot__modal-reaction' );
1192 const submitBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-submit' );
1193 const cancelBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-cancel' );
1194
1195 jQuery.post(
1196 wpsc_ai_chatbot.ajax_url, {
1197 action: 'wpsc_chatbot_remove_session_cookie',
1198 _ajax_nonce: wpsc_ai_chatbot.nonce,
1199 session_id: sessionId
1200 }
1201 ).done(
1202 function( response ) {
1203 if ( ! response.success ) {
1204 return;
1205 }
1206
1207 launcher?.removeAttribute( 'data-sessionid' );
1208 closeBtn?.removeAttribute( 'data-sessionid' );
1209 modalFooterAskMeLater?.removeAttribute( 'data-sessionid' );
1210 modalReactionBtns?.forEach( btn => btn.removeAttribute( 'data-sessionid' ) );
1211 submitBtn?.removeAttribute( 'data-sessionid' );
1212 cancelBtn?.removeAttribute( 'data-sessionid' );
1213
1214 // Session ended without a page reload - the visitor is back to a fresh, pre-conversation
1215 // state, so give the auto-popup a chance to show again (still capped by the display limit).
1216 self.scheduleAutoPopup();
1217 }
1218 ).fail(
1219 function() {
1220 return;
1221 }
1222 );
1223 };
1224
1225 // Close ticket modal.
1226 window.WPSC_AI_Chatbot.closeTicketModal =
1227
1228 function( reaction = null, source = '', sessionId = null ) {
1229 const self = this;
1230 self.isLimitReached = false;
1231 self.isSending = false;
1232 self.enableChatInput();
1233
1234 const modal = this.shadowRoot.querySelector( '.wpsc-chatbot__modal' );
1235 const dialog = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-dialog' );
1236 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
1237 const launcher = this.shadowRoot.querySelector( '.wpsc-chatbot-launcher' );
1238 const negativeReaction = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-reaction--negative' );
1239 const textarea = this.shadowRoot.querySelector( '.wpsc-chatbot__input' );
1240
1241 modal?.classList.remove( 'wpsc-chatbot__modal--active' );
1242 chatbot?.classList.remove( 'wpsc-chatbot--active' );
1243 chatbot?.classList.remove( 'wpsc-chatbot--expanded' );
1244 chatbot?.classList.remove( 'wpsc-chatbot--disabled' );
1245 launcher?.classList.remove( 'wpsc-chatbot-launcher--hidden' );
1246 negativeReaction?.classList.remove( 'wpsc-chatbot__modal-reaction--negative--selected' );
1247
1248 if ( ! sessionId ) {
1249 return;
1250 }
1251
1252 jQuery.post(
1253 wpsc_ai_chatbot.ajax_url, {
1254 action: 'wpsc_chatbot_cancel_ticket_escalation',
1255 _ajax_nonce: wpsc_ai_chatbot.nonce,
1256 reaction: reaction,
1257 session_id: sessionId
1258 }
1259 ).done(
1260 function( response ) {
1261 if ( ! response.success ) {
1262 return;
1263 }
1264 self.isLimitReached = false;
1265 self.cacheElements();
1266 const body = self.elements?.body;
1267 if ( ! body ) {
1268 return;
1269 }
1270 }
1271 ).fail(
1272 function() {
1273 return;
1274 }
1275 ).always(
1276 function() {
1277
1278 // Restore focus to the input field after closing the modal and clear textarea content.
1279 if ( textarea ) {
1280 textarea.value = '';
1281 textarea.disabled = false;
1282 textarea.classList.remove( 'wpsc-chatbot__input--readonly' );
1283 textarea.placeholder = 'Type your message...';
1284 textarea.focus();
1285 }
1286 }
1287 );
1288 };
1289
1290 // Confirm end conversation.
1291 window.WPSC_AI_Chatbot.saveChatReaction =
1292
1293 function( reaction, sessionId, ticketCreated ) {
1294 const self = this;
1295 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
1296 self.cacheElements();
1297 const body = self.elements?.body;
1298 const launcher = this.shadowRoot.querySelector( '.wpsc-chatbot-launcher' );
1299 const textarea = this.shadowRoot.querySelector( '.wpsc-chatbot__input' );
1300 if ( ! sessionId ) {
1301 return;
1302 }
1303
1304 if ( reaction === '2' && ! ticketCreated ) {
1305 self.showTicketEscalation( reaction, sessionId );
1306 return;
1307 }
1308
1309 self.closeModal();
1310 chatbot?.classList.remove( 'wpsc-chatbot--active' );
1311 chatbot?.classList.remove( 'wpsc-chatbot--expanded' );
1312 launcher?.classList.remove( 'wpsc-chatbot-launcher--hidden' );
1313
1314 jQuery.post(
1315 wpsc_ai_chatbot.ajax_url, {
1316 action: 'wpsc_chatbot_end_conversation',
1317 _ajax_nonce: wpsc_ai_chatbot.nonce,
1318 reaction: reaction,
1319 session_id: sessionId
1320 }
1321 ).done(
1322 function( response ) {
1323
1324 if ( ! response || ! response.success ) {
1325 self.debugLog( 'END_CONVERSATION', 'wpsc_chatbot_end_conversation returned success:false; reaction was not saved', response && response.data );
1326 return;
1327 }
1328
1329 self.isLimitReached = false;
1330 if ( ! body ) {
1331 return;
1332 }
1333 self.enableChatInput();
1334 }
1335 ).fail(
1336 function( jqXHR ) {
1337 self.debugLog( 'END_CONVERSATION', 'wpsc_chatbot_end_conversation request failed; reaction was not saved', jqXHR && jqXHR.responseJSON );
1338 }
1339 ).always(
1340 function() {
1341 self.removeSessionCookie( sessionId );
1342
1343 // Restore focus to the input field after closing the modal and clear textarea content.
1344 if ( textarea ) {
1345 textarea.value = '';
1346 textarea.disabled = false;
1347 textarea.classList.remove( 'wpsc-chatbot__input--readonly' );
1348 textarea.placeholder = 'Type your message...';
1349 textarea.focus();
1350 }
1351 }
1352 );
1353 };
1354
1355 window.WPSC_AI_Chatbot.showTicketEscalation =
1356
1357 function( reaction, sessionId ) {
1358
1359 // Check if ticket modal already exists.
1360 const existingTicketModal = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-modal' );
1361 if ( existingTicketModal ) {
1362 return;
1363 }
1364
1365 // Remove ticket form if it exists.
1366 const modalCancelBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-cancel' );
1367 modalCancelBtn?.remove();
1368
1369 // Remove negative reaction selection if it exists.
1370 const negativeReaction = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-reaction--negative' );
1371 negativeReaction?.classList.add( 'wpsc-chatbot__modal-reaction--negative--selected' );
1372
1373 // Append ticket modal to modal dialog.
1374 const modalDialog = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-dialog' );
1375 modalDialog?.insertAdjacentHTML( 'beforeend', this.getTicketModalTemplate() );
1376
1377 // The template markup is rendered server-side at page load and may carry a stale/empty
1378 // session id, so stamp the live session id onto the freshly inserted buttons here.
1379 const ticketSubmitBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-submit' );
1380 const ticketCancelBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-cancel' );
1381 ticketSubmitBtn?.setAttribute( 'data-sessionid', sessionId );
1382 ticketCancelBtn?.setAttribute( 'data-sessionid', sessionId );
1383
1384 // Prefill name/email for a logged-in visitor so they can submit right away.
1385 this.prefillTicketContactFields( '.wpsc-chatbot__ticket-modal-name', '.wpsc-chatbot__ticket-modal-email' );
1386
1387 // Restore focus to the input field after closing the modal and clear textarea content.
1388 const textarea = this.shadowRoot.querySelector( '.wpsc-chatbot__input' );
1389 if ( textarea ) {
1390 textarea.value = '';
1391 textarea.disabled = false;
1392 textarea.classList.remove( 'wpsc-chatbot__input--readonly' );
1393 textarea.placeholder = 'Type your message...';
1394 textarea.focus();
1395 }
1396 };
1397
1398 // Create ticket after chat limit is reached.
1399 window.WPSC_AI_Chatbot.createTicket =
1400
1401 function( ticketEscalation = false, source = '', sessionId = null ) {
1402 const self = this;
1403 if ( this.isCreatingTicket ) {
1404 return;
1405 }
1406
1407 if ( ! sessionId ) {
1408 alert( 'We could not retrieve your session. Please try again.' );
1409 this.isCreatingTicket = false;
1410 return;
1411 }
1412
1413 this.isCreatingTicket = true;
1414 if ( source === 'ticket-form' ) {
1415 var name = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-form-name' );
1416 var email = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-form-email' );
1417 } else if ( source === 'ticket-modal' ) {
1418 var name = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-modal-name' );
1419 var email = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-modal-email' );
1420 }
1421
1422 const ticketName = name ? name.value.trim() : '';
1423 if ( name && ! ticketName ) {
1424 alert( 'Please enter your name.' );
1425 this.isCreatingTicket = false;
1426 return;
1427 }
1428
1429 const ticketEmail = email ? email.value.trim() : '';
1430 if ( email && ( ! ticketEmail || ! /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( ticketEmail ) ) ) {
1431 alert( 'Please enter a valid email.' );
1432 this.isCreatingTicket = false;
1433 return;
1434 }
1435
1436 const submitBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-submit' );
1437 if ( submitBtn ) {
1438 submitBtn.disabled = true;
1439 submitBtn.textContent = 'Creating Ticket...';
1440 this.isCreatingTicket = true;
1441 }
1442
1443 const modal = this.shadowRoot.querySelector( '.wpsc-chatbot__modal' );
1444 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
1445
1446 // Make AJAX request to create ticket.
1447 jQuery.post(
1448 wpsc_ai_chatbot.ajax_url, {
1449 action: 'wpsc_chatbot_create_ticket',
1450 _ajax_nonce: wpsc_ai_chatbot.nonce,
1451 ticketEscalation: ticketEscalation,
1452 user_name: ticketName,
1453 user_email: ticketEmail
1454 }
1455 ).done(
1456 function( response ) {
1457 if ( ! response.success ) {
1458 self.showError( response.data?.message );
1459 return;
1460 }
1461 self.handleTicketCreated( response.data, source, sessionId );
1462 }
1463 )
1464 .fail(
1465 function( jqXHR, textStatus, errorThrown ) {
1466 self.showError( jqXHR.responseJSON?.data?.message );
1467 }
1468 )
1469 .always(
1470 function() {
1471 self.isCreatingTicket = false;
1472 if ( submitBtn ) {
1473 submitBtn.disabled = false;
1474 submitBtn.textContent = 'Create Ticket';
1475 }
1476
1477 if ( modal ) {
1478 modal?.classList.remove( 'wpsc-chatbot__modal--active' );
1479 }
1480
1481 chatbot?.classList.add( 'wpsc-chatbot--active' );
1482 if ( chatbot ) {
1483 chatbot?.classList.remove( 'wpsc-chatbot__modal__open' );
1484 chatbot?.classList.remove( 'wpsc-chatbot--disabled' );
1485 }
1486
1487 // Restore focus to the input field after closing the modal and clear textarea content.
1488 const textarea = self.shadowRoot.querySelector( '.wpsc-chatbot__input' );
1489 if ( textarea ) {
1490 textarea.value = '';
1491 textarea.disabled = false;
1492 textarea.classList.remove( 'wpsc-chatbot__input--readonly' );
1493 textarea.placeholder = 'Type your message...';
1494 textarea.focus();
1495 }
1496 }
1497 );
1498 };
1499
1500 // handle create ticket success response.
1501 window.WPSC_AI_Chatbot.handleTicketCreated =
1502
1503 function( data, source = '', sessionId = null ) {
1504 const self = this;
1505 this.cacheElements();
1506 // Remove ticket form after successful ticket creation.
1507 const form = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-form' );
1508 if ( form ) {
1509 form.remove();
1510 }
1511
1512 // Remove ticket form after successful ticket creation.
1513 const inputGroup = this.shadowRoot.querySelector( '.wpsc-chatbot__input-group' );
1514 if ( inputGroup ) {
1515 inputGroup?.classList.add( 'wpsc-chatbot__input-group--hidden' );
1516 }
1517
1518 // Show success message with ticket link.
1519 this.appendMessage( 'assistant', data.message );
1520
1521 const footer = this.shadowRoot.querySelector( '.wpsc-chatbot__body' );
1522 if ( footer ) {
1523 if ( data.chat_end_message ) {
1524 footer.insertAdjacentHTML( 'beforeend', '<div class="wpsc-chatbot__input-conversation-end">' + data.chat_end_message + '</div>' );
1525 }
1526 }
1527
1528 // Scroll to bottom after appending message.
1529 const body = this.elements?.body;
1530 if ( ! body ) {
1531 return;
1532 }
1533 body.scrollTop = body.scrollHeight;
1534
1535 this.isSending = false;
1536 this.isLimitReached = false;
1537 this.isCreatingTicket = true;
1538
1539 // Keep the negative-reaction button's "ticket already created" flag in
1540 // sync so, if the visitor closes the chat and reacts without a page
1541 // reload, saveChatReaction() skips the ticket-escalation form.
1542 const negativeReactionBtn = this.shadowRoot.querySelector( '.wpsc-chatbot__modal-reaction--negative' );
1543 negativeReactionBtn?.setAttribute( 'data-ticketcreated', 'true' );
1544
1545 if ( source === 'ticket-modal' ) {
1546
1547 // Close the ticket escalation modal now that the ticket has been created.
1548 const ticketModal = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-modal' );
1549 ticketModal?.remove();
1550
1551 // Destroy the now-handed-off session client-side (server already ended it on ticket creation).
1552 if ( sessionId ) {
1553 this.removeSessionCookie( sessionId );
1554 }
1555
1556 // Give the visitor a moment to read the confirmation, then reload the widget for a fresh conversation.
1557 setTimeout(
1558 function() {
1559 self.resetChatbotWidget();
1560 },
1561 5000
1562 );
1563 }
1564 };
1565
1566 // Reset the widget back to its fresh, pre-conversation state (used after a ticket
1567 // is created via the exit-feedback ticket modal, once the session has ended).
1568 window.WPSC_AI_Chatbot.resetChatbotWidget =
1569
1570 function() {
1571 this.cacheElements();
1572
1573 const chatbot = this.shadowRoot.querySelector( '.wpsc-chatbot' );
1574 const launcher = this.shadowRoot.querySelector( '.wpsc-chatbot-launcher' );
1575 const modal = this.shadowRoot.querySelector( '.wpsc-chatbot__modal' );
1576 const ticketModal = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-modal' );
1577 const ticketFormWrapper = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-form-wrapper' );
1578 const textarea = this.shadowRoot.querySelector( '.wpsc-chatbot__input' );
1579 const inputGroup = this.shadowRoot.querySelector( '.wpsc-chatbot__input-group' );
1580 const body = this.elements?.body;
1581
1582 ticketModal?.remove();
1583 ticketFormWrapper?.remove();
1584
1585 if ( modal ) {
1586 modal.classList.remove( 'wpsc-chatbot__modal--active' );
1587 }
1588
1589 if ( chatbot ) {
1590 chatbot.classList.remove( 'wpsc-chatbot--active' );
1591 chatbot.classList.remove( 'wpsc-chatbot--expanded' );
1592 chatbot.classList.remove( 'wpsc-chatbot--fullscreen' );
1593 chatbot.classList.remove( 'wpsc-chatbot--disabled' );
1594 chatbot.classList.remove( 'wpsc-chatbot__modal__open' );
1595 }
1596 launcher?.classList.remove( 'wpsc-chatbot-launcher--hidden' );
1597 inputGroup?.classList.remove( 'wpsc-chatbot__input-group--hidden' );
1598
1599 if ( body ) {
1600 body.innerHTML = this.getWelcomeMessageTemplate();
1601 this.setWelcomeTime( body );
1602 }
1603
1604 this.enableChatInput();
1605 if ( textarea ) {
1606 textarea.value = '';
1607 textarea.disabled = false;
1608 textarea.classList.remove( 'wpsc-chatbot__input--readonly' );
1609 textarea.placeholder = 'Type your message...';
1610 }
1611
1612 this.isSending = false;
1613 this.isLimitReached = false;
1614 this.isCreatingTicket = false;
1615 };
1616
1617 // Show error message in chatbot.
1618 window.WPSC_AI_Chatbot.showError =
1619
1620 function( message ) {
1621 this.cacheElements();
1622 // Remove ticket form if exists.
1623 const form = this.shadowRoot.querySelector( '.wpsc-chatbot__ticket-form' );
1624 if ( form ) {
1625 form.remove();
1626 }
1627
1628 // Append error message.
1629 this.appendMessage( 'assistant', message );
1630
1631 // Show error message.
1632 const body = this.elements?.body;
1633 if ( ! body ) {
1634 return;
1635 }
1636 body.scrollTop = body.scrollHeight;
1637 this.isCreatingTicket = false;
1638 };
1639
1640 // Get chatbot HTML template.
1641 window.WPSC_AI_Chatbot.showTicketForm =
1642
1643 function( disableInputMessage ) {
1644 this.cacheElements();
1645 // Check if ticket form already exists.
1646 const body = this.elements?.body;
1647 if ( ! body ) {
1648 return;
1649 }
1650
1651 if ( body.querySelector( '.wpsc-chatbot__ticket-form-wrapper' ) ) {
1652 return;
1653 }
1654 body.scrollTop = body.scrollHeight;
1655
1656 // Disable chat input when showing ticket form.
1657 this.disableChatInput( disableInputMessage );
1658
1659 // Append ticket form template.
1660 const wrapper = document.createElement( 'div' );
1661 wrapper.className = 'wpsc-chatbot__ticket-form-wrapper';
1662 wrapper.innerHTML = this.getTicketFormTemplate();
1663 body.appendChild( wrapper );
1664
1665 // Prefill name/email for a logged-in visitor so they can submit right away.
1666 this.prefillTicketContactFields( '.wpsc-chatbot__ticket-form-name', '.wpsc-chatbot__ticket-form-email' );
1667 };
1668
1669 // Prefill a ticket contact form's name/email inputs for a logged-in visitor.
1670 window.WPSC_AI_Chatbot.prefillTicketContactFields =
1671
1672 function( nameSelector, emailSelector ) {
1673 const nameInput = this.shadowRoot.querySelector( nameSelector );
1674 const emailInput = this.shadowRoot.querySelector( emailSelector );
1675
1676 if ( nameInput && ! nameInput.value && wpsc_ai_chatbot?.current_user_name ) {
1677 nameInput.value = wpsc_ai_chatbot.current_user_name;
1678 }
1679
1680 if ( emailInput && ! emailInput.value && wpsc_ai_chatbot?.current_user_email ) {
1681 emailInput.value = wpsc_ai_chatbot.current_user_email;
1682 }
1683 };
1684
1685 // Disable chat input when chat limit is reached.
1686 window.WPSC_AI_Chatbot.disableChatInput =
1687
1688 function( disableInputMessage ) {
1689 this.cacheElements();
1690 // Disable input and send button.
1691 const input = this.elements?.input;
1692 const sendBtn = this.elements?.sendBtn;
1693 if ( input ) {
1694 input.disabled = true;
1695 input.placeholder = disableInputMessage;
1696 input.classList.add( 'wpsc-chatbot__input--readonly' );
1697 }
1698 if ( sendBtn ) {
1699 sendBtn.disabled = true;
1700 }
1701 };
1702
1703 // Re-enable chat input after successful non-limit responses.
1704 window.WPSC_AI_Chatbot.enableChatInput =
1705
1706 function() {
1707 this.cacheElements();
1708 // Enable input and send button.
1709 const input = this.elements?.input;
1710 const sendBtn = this.elements?.sendBtn;
1711 if ( input ) {
1712 input.disabled = false;
1713 input.classList.remove( 'wpsc-chatbot__input--readonly' );
1714 input.placeholder = 'Type your message...';
1715
1716 // Keep typing flow smooth by restoring focus after input is re-enabled.
1717 window.requestAnimationFrame(
1718 () => {
1719 if ( input.disabled ) {
1720 return;
1721 }
1722
1723 try {
1724 input.focus( { preventScroll: true } );
1725 } catch ( error ) {
1726 input.focus();
1727 }
1728
1729 const length = input.value ? input.value.length : 0;
1730 if ( typeof input.setSelectionRange === 'function' ) {
1731 input.setSelectionRange( length, length );
1732 }
1733 }
1734 );
1735 }
1736 if ( sendBtn ) {
1737 sendBtn.disabled = false;
1738 }
1739 };