PluginProbe
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent / 3.5.4
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent v3.5.4
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.4, at asset/js/ai-chatbot/chatbot.js

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