PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.22
MxChat – AI Chatbot & Content Generation for WordPress v3.2.22
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / js / mxchat_transcripts.js

mxchat_transcripts.js in MxChat – AI Chatbot & Content Generation for WordPress 3.2.22, at js/mxchat_transcripts.js

2,005 lines 85.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * MxChat Transcripts Page JavaScript - v3.0
3 * Split-panel layout with chat list and conversation view
4 */
5 jQuery(document).ready(function($) {
6 // ==========================================================================
7 // Sidebar Navigation
8 // ==========================================================================
9
10 // Desktop sidebar navigation
11 $('.mxch-nav-link').on('click', function(e) {
12 e.preventDefault();
13 const target = $(this).data('target');
14
15 // Update active states
16 $('.mxch-nav-link').removeClass('active');
17 $(this).addClass('active');
18
19 // Show target section
20 $('.mxch-section').removeClass('active');
21 $('#' + target).addClass('active');
22
23 // Reset scroll position of content area
24 $('.mxch-content').scrollTop(0);
25
26 // Also update mobile nav if open
27 $('.mxch-mobile-nav-link').removeClass('active');
28 $('.mxch-mobile-nav-link[data-target="' + target + '"]').addClass('active');
29
30 // Load transcripts when switching to all-chats
31 if (target === 'all-chats' && !transcriptsLoaded) {
32 loadChatList(1, '');
33 }
34 });
35
36 // Mobile menu toggle
37 $('.mxch-mobile-menu-btn').on('click', function() {
38 $('.mxch-mobile-menu').addClass('open');
39 $('.mxch-mobile-overlay').addClass('open');
40 });
41
42 // Close mobile menu
43 $('.mxch-mobile-menu-close, .mxch-mobile-overlay').on('click', function() {
44 $('.mxch-mobile-menu').removeClass('open');
45 $('.mxch-mobile-overlay').removeClass('open');
46 });
47
48 // Mobile navigation
49 $('.mxch-mobile-nav-link').on('click', function(e) {
50 e.preventDefault();
51 const target = $(this).data('target');
52
53 $('.mxch-mobile-nav-link').removeClass('active');
54 $(this).addClass('active');
55
56 $('.mxch-section').removeClass('active');
57 $('#' + target).addClass('active');
58
59 // Reset scroll position of content area
60 $('.mxch-content').scrollTop(0);
61
62 $('.mxch-nav-link').removeClass('active');
63 $('.mxch-nav-link[data-target="' + target + '"]').addClass('active');
64
65 $('.mxch-mobile-menu').removeClass('open');
66 $('.mxch-mobile-overlay').removeClass('open');
67 });
68
69 // Quick action buttons
70 $('.mxch-quick-action-btn[data-action="view-chats"]').on('click', function() {
71 $('.mxch-nav-link[data-target="all-chats"]').trigger('click');
72 });
73
74 $('.mxch-quick-action-btn[data-action="settings"]').on('click', function() {
75 $('.mxch-nav-link[data-target="notifications"]').trigger('click');
76 });
77
78 // ==========================================================================
79 // Mobile Panel Management
80 // ==========================================================================
81
82 function isMobile() {
83 return window.innerWidth <= 782;
84 }
85
86 function showMobileConversationPanel() {
87 if (isMobile()) {
88 $('.mxch-chat-list-panel').addClass('panel-hidden');
89 $('#mxch-conversation-panel').addClass('panel-active');
90 $('#mxch-transcript-back-btn').show();
91 }
92 }
93
94 function hideMobileConversationPanel() {
95 if (isMobile()) {
96 $('#mxch-conversation-panel').removeClass('panel-active');
97 $('.mxch-chat-list-panel').removeClass('panel-hidden');
98 $('#mxch-transcript-back-btn').hide();
99 }
100 }
101
102 // Mobile back button handler
103 $('#mxch-transcript-back-btn').on('click', function(e) {
104 e.preventDefault();
105 hideMobileConversationPanel();
106 $('.mxch-chat-item').removeClass('active');
107 currentSessionId = null;
108 });
109
110 // Handle window resize
111 $(window).on('resize', function() {
112 if (!isMobile()) {
113 // Reset panel states when switching to desktop
114 $('.mxch-chat-list-panel').removeClass('panel-hidden');
115 $('#mxch-conversation-panel').removeClass('panel-active');
116 $('#mxch-transcript-back-btn').hide();
117 }
118 updateMobileViewportHeight();
119 });
120
121 // Fix for mobile browser address bar - sets CSS custom property for accurate viewport height
122 function updateMobileViewportHeight() {
123 if (isMobile()) {
124 // Use visualViewport if available (most reliable for mobile)
125 const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
126 document.documentElement.style.setProperty('--mxch-mobile-vh', vh + 'px');
127 }
128 }
129
130 // Update on load and viewport changes
131 updateMobileViewportHeight();
132 if (window.visualViewport) {
133 window.visualViewport.addEventListener('resize', updateMobileViewportHeight);
134 }
135
136 // ==========================================================================
137 // Chat List - Split Panel
138 // ==========================================================================
139
140 let currentPage = 1;
141 const perPage = 50;
142 let totalPages = 1;
143 let currentSessionId = null;
144 let transcriptsLoaded = false;
145 let selectedSessions = new Set();
146 let currentSortOrder = 'desc'; // newest first
147
148 // Load chat list on page load
149 loadChatList(1, '');
150
151 // Search functionality with debounce
152 let searchTimeout;
153 $('#mxch-search-transcripts').on('input', function() {
154 clearTimeout(searchTimeout);
155 const searchTerm = $(this).val().toLowerCase();
156
157 searchTimeout = setTimeout(function() {
158 currentPage = 1;
159 loadChatList(currentPage, searchTerm);
160 }, 300);
161 });
162
163 // Refresh button
164 $('#mxch-refresh-list').on('click', function() {
165 const $btn = $(this);
166 $btn.addClass('spinning');
167 loadChatList(currentPage, $('#mxch-search-transcripts').val());
168 setTimeout(() => $btn.removeClass('spinning'), 500);
169 });
170
171 // ==========================================================================
172 // Bulk Selection & Actions
173 // ==========================================================================
174
175 // Select all checkbox
176 $('#mxch-select-all').on('change', function() {
177 const isChecked = $(this).is(':checked');
178 $('.mxch-chat-checkbox').prop('checked', isChecked);
179
180 if (isChecked) {
181 $('.mxch-chat-item').each(function() {
182 // Use .attr() — jQuery's .data() coerces "null"/"true"/numeric strings to JS types,
183 // which causes fetch/delete of those sessions to silently fail.
184 selectedSessions.add($(this).attr('data-session-id'));
185 $(this).addClass('selected');
186 });
187 $('#mxch-chat-list').addClass('selection-mode');
188 } else {
189 selectedSessions.clear();
190 $('.mxch-chat-item').removeClass('selected');
191 $('#mxch-chat-list').removeClass('selection-mode');
192 }
193
194 updateSelectionUI();
195 });
196
197 // Update selection UI
198 function updateSelectionUI() {
199 const count = selectedSessions.size;
200 const $countEl = $('#mxch-selected-count');
201 const $deleteBtn = $('#mxch-delete-selected');
202
203 if (count > 0) {
204 $countEl.text(count + ' selected').addClass('has-selection');
205 $deleteBtn.prop('disabled', false);
206 $('#mxch-chat-list').addClass('selection-mode');
207 } else {
208 $countEl.removeClass('has-selection');
209 $deleteBtn.prop('disabled', true);
210 $('#mxch-chat-list').removeClass('selection-mode');
211 }
212
213 // Update select all checkbox state
214 const totalItems = $('.mxch-chat-checkbox').length;
215 const checkedItems = $('.mxch-chat-checkbox:checked').length;
216 $('#mxch-select-all').prop('checked', totalItems > 0 && checkedItems === totalItems);
217 $('#mxch-select-all').prop('indeterminate', checkedItems > 0 && checkedItems < totalItems);
218 }
219
220 // Sort button — opens a small menu with 4 sort modes (plan-a5b006 adds rating sorts).
221 $('#mxch-sort-btn').on('click', function(e) {
222 e.stopPropagation();
223 const $btn = $(this);
224 let $menu = $('#mxch-sort-menu');
225 if (!$menu.length) {
226 $menu = $(
227 '<div id="mxch-sort-menu" class="mxch-sort-menu" role="menu">' +
228 ' <button type="button" class="mxch-sort-option" data-sort="desc" role="menuitem">Newest first</button>' +
229 ' <button type="button" class="mxch-sort-option" data-sort="asc" role="menuitem">Oldest first</button>' +
230 ' <button type="button" class="mxch-sort-option" data-sort="rating_positive" role="menuitem"><span class="mxch-sort-option-icon mxch-rating-thumb-up"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H7"/><path d="M3 10h4"/></svg></span>Positive ratings first</button>' +
231 ' <button type="button" class="mxch-sort-option" data-sort="rating_negative" role="menuitem"><span class="mxch-sort-option-icon mxch-rating-thumb-down"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H17"/><path d="M21 14h-4"/></svg></span>Negative ratings first</button>' +
232 '</div>'
233 );
234 $('body').append($menu);
235 $menu.on('click', '.mxch-sort-option', function() {
236 currentSortOrder = $(this).data('sort');
237 $menu.find('.mxch-sort-option').removeClass('active');
238 $(this).addClass('active');
239 $menu.hide();
240 loadChatList(1, $('#mxch-search-transcripts').val());
241 });
242 $(document).on('click.mxchSortMenu', function(ev) {
243 if (!$(ev.target).closest('#mxch-sort-menu, #mxch-sort-btn').length) {
244 $menu.hide();
245 }
246 });
247 }
248 $menu.find('.mxch-sort-option').removeClass('active');
249 $menu.find('[data-sort="' + currentSortOrder + '"]').addClass('active');
250 const offset = $btn.offset();
251 const btnHeight = $btn.outerHeight();
252 $menu.css({
253 position: 'absolute',
254 top: (offset.top + btnHeight + 4) + 'px',
255 left: offset.left + 'px'
256 }).toggle();
257 });
258
259 // Render a rating badge for a session row. Uses inline SVG so the glyph
260 // renders the same across OSes (emoji fonts vary). Tooltip surfaces the
261 // optional feedback text.
262 function renderRatingBadge(session) {
263 const value = (session && typeof session.rating_value === 'number') ? session.rating_value : null;
264 const feedback = (session && session.rating_feedback) ? session.rating_feedback : '';
265 if (value === 1) {
266 const title = feedback ? ('Visitor: ' + feedback) : 'Visitor rated this chat positively';
267 return '<span class="mxch-chat-rating mxch-chat-rating-up" title="' + escapeHtml(title) + '" aria-label="' + escapeHtml(title) + '"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H7"/><path d="M3 10h4"/></svg></span>';
268 }
269 if (value === -1) {
270 const title = feedback ? ('Visitor: ' + feedback) : 'Visitor rated this chat negatively';
271 return '<span class="mxch-chat-rating mxch-chat-rating-down" title="' + escapeHtml(title) + '" aria-label="' + escapeHtml(title) + '"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H17"/><path d="M21 14h-4"/></svg></span>';
272 }
273 return '<span class="mxch-chat-rating mxch-chat-rating-empty" title="No rating yet" aria-label="No rating yet">—</span>';
274 }
275
276 // Delete selected button — opens the shared confirm modal with the "also delete lead" checkbox.
277 $('#mxch-delete-selected').on('click', function() {
278 const count = selectedSessions.size;
279 if (count === 0) return;
280 openTranscriptConfirm(Array.from(selectedSessions), count);
281 });
282
283 // Transcript delete confirm (shared by bulk + individual) ---------------------------
284 let transcriptConfirmSessionIds = [];
285
286 function openTranscriptConfirm(sessionIds, count) {
287 transcriptConfirmSessionIds = sessionIds.slice();
288 const n = count || sessionIds.length;
289 $('#mxch-transcript-confirm-title').text(n === 1 ? 'Delete conversation?' : 'Delete ' + n + ' conversations?');
290 $('#mxch-transcript-confirm-body').text(
291 n === 1
292 ? 'This removes the conversation and its messages.'
293 : 'This removes ' + n + ' conversations and their messages.'
294 );
295 $('#mxch-transcript-also-delete-lead').prop('checked', false);
296 $('#mxch-transcript-confirm').fadeIn(120);
297 }
298
299 function closeTranscriptConfirm() {
300 $('#mxch-transcript-confirm').fadeOut(120);
301 transcriptConfirmSessionIds = [];
302 }
303
304 $(document).on('click', '[data-mxch-transcript-close]', closeTranscriptConfirm);
305
306 $('#mxch-transcript-confirm-go').on('click', function() {
307 const ids = transcriptConfirmSessionIds.slice();
308 if (!ids.length) { closeTranscriptConfirm(); return; }
309 const alsoDeleteLead = $('#mxch-transcript-also-delete-lead').is(':checked');
310 closeTranscriptConfirm();
311 deleteMultipleSessions(ids, alsoDeleteLead);
312 });
313
314 // Delete multiple sessions
315 function deleteMultipleSessions(sessionIds, alsoDeleteLead) {
316 $.ajax({
317 url: ajaxurl,
318 type: 'POST',
319 data: {
320 action: 'mxchat_delete_chat_history',
321 delete_session_ids: sessionIds,
322 also_delete_lead: alsoDeleteLead ? '1' : '0',
323 security: $('#mxchat_delete_chat_nonce').val()
324 },
325 success: function(response) {
326 try {
327 const jsonResponse = typeof response === 'object' ? response : JSON.parse(response);
328
329 if (jsonResponse.success) {
330 // Clear selection
331 selectedSessions.clear();
332 $('#mxch-select-all').prop('checked', false);
333 updateSelectionUI();
334
335 // If current conversation was deleted, reset panel
336 if (sessionIds.includes(currentSessionId)) {
337 currentSessionId = null;
338 $('#mxch-conversation-content').hide();
339 $('#mxch-conversation-empty').show();
340 $('#mxch-details-drawer').hide();
341 }
342
343 // Reload list
344 loadChatList(currentPage, $('#mxch-search-transcripts').val());
345
346 // The Leads tab shares this data (Chat deleted pill, nav badge,
347 // stats) — invalidate it so switching tabs re-fetches instead of
348 // showing stale "active lead" rows.
349 invalidateLeadsData();
350 } else if (jsonResponse.error) {
351 alert('Error: ' + jsonResponse.error);
352 }
353 } catch (e) {
354 alert('An error occurred while processing the response.');
355 }
356 },
357 error: function() {
358 alert('An error occurred while deleting conversations.');
359 }
360 });
361 }
362
363 // Load chat list function
364 function loadChatList(page, searchTerm) {
365 const $container = $('#mxch-chat-list');
366 $container.html('<div class="mxch-list-loading"><span class="spinner is-active"></span></div>');
367
368 $.ajax({
369 url: ajaxurl,
370 type: 'POST',
371 data: {
372 action: 'mxchat_fetch_chat_history',
373 page: page,
374 per_page: perPage,
375 search: searchTerm,
376 sort_order: currentSortOrder
377 },
378 success: function(response) {
379 transcriptsLoaded = true;
380
381 if (response.success && response.sessions && response.sessions.length > 0) {
382 renderChatList(response.sessions);
383 currentPage = response.page;
384 totalPages = response.total_pages;
385 updateChatCount(response.showing_start, response.showing_end, response.total_sessions);
386 renderPagination(response.page, response.total_pages, searchTerm);
387 } else {
388 $container.html('<div class="mxch-list-empty"><p>No chats found</p></div>');
389 updateChatCount(0, 0, 0);
390 $('#mxch-pagination').html('');
391 }
392 },
393 error: function() {
394 $container.html('<div class="mxch-list-empty"><p>Error loading chats</p></div>');
395 }
396 });
397 }
398
399 // Render chat list items
400 function renderChatList(sessions) {
401 const $container = $('#mxch-chat-list');
402 let html = '';
403
404 sessions.forEach(function(session) {
405 const isActive = session.session_id === currentSessionId ? ' active' : '';
406 const isSelected = selectedSessions.has(session.session_id) ? ' selected' : '';
407 const isChecked = selectedSessions.has(session.session_id) ? ' checked' : '';
408 html += `
409 <div class="mxch-chat-item${isActive}${isSelected}" data-session-id="${escapeHtml(session.session_id)}">
410 <input type="checkbox" class="mxch-chat-checkbox"${isChecked}>
411 <div class="mxch-chat-avatar">
412 <span>${escapeHtml(session.initials)}</span>
413 </div>
414 <div class="mxch-chat-info">
415 <div class="mxch-chat-name">${escapeHtml(session.display_name)}</div>
416 <div class="mxch-chat-preview">${escapeHtml(session.preview)}</div>
417 </div>
418 <div class="mxch-chat-meta">
419 ${renderRatingBadge(session)}
420 <span class="mxch-chat-time">${escapeHtml(session.time_display)}</span>
421 <span class="mxch-chat-count">${session.message_count}</span>
422 </div>
423 </div>
424 `;
425 });
426
427 $container.html(html);
428
429 // Attach checkbox handlers
430 $('.mxch-chat-checkbox').on('click', function(e) {
431 e.stopPropagation(); // Prevent triggering chat item click
432 const $item = $(this).closest('.mxch-chat-item');
433 const sessionId = $item.attr('data-session-id');
434
435 if ($(this).is(':checked')) {
436 selectedSessions.add(sessionId);
437 $item.addClass('selected');
438 } else {
439 selectedSessions.delete(sessionId);
440 $item.removeClass('selected');
441 }
442
443 updateSelectionUI();
444 });
445
446 // Attach click handlers for selecting chat
447 $('.mxch-chat-item').on('click', function(e) {
448 // Don't trigger if clicking on checkbox
449 if ($(e.target).is('.mxch-chat-checkbox')) return;
450
451 const sessionId = $(this).attr('data-session-id');
452 selectChat(sessionId);
453
454 // Update active state
455 $('.mxch-chat-item').removeClass('active');
456 $(this).addClass('active');
457
458 // Show conversation panel on mobile
459 showMobileConversationPanel();
460 });
461
462 // Update selection UI after render
463 updateSelectionUI();
464 }
465
466 // Update chat count display
467 function updateChatCount(start, end, total) {
468 if (total === 0) {
469 $('#mxch-chat-count').text('0 chats');
470 } else {
471 $('#mxch-chat-count').text(`${start}-${end} / ${total} chats`);
472 }
473 }
474
475 // Render pagination
476 function renderPagination(currentPage, totalPages, searchTerm) {
477 const $container = $('#mxch-pagination');
478
479 if (totalPages <= 1) {
480 $container.html('');
481 return;
482 }
483
484 let html = '<div class="mxch-pagination-btns">';
485
486 if (currentPage > 1) {
487 html += `<button class="mxch-page-btn" data-page="${currentPage - 1}">&laquo;</button>`;
488 }
489
490 html += `<span class="mxch-page-info">${currentPage} / ${totalPages}</span>`;
491
492 if (currentPage < totalPages) {
493 html += `<button class="mxch-page-btn" data-page="${currentPage + 1}">&raquo;</button>`;
494 }
495
496 html += '</div>';
497 $container.html(html);
498
499 // Pagination click handlers
500 $('.mxch-page-btn').on('click', function() {
501 const pageNum = $(this).data('page');
502 loadChatList(pageNum, searchTerm);
503 });
504 }
505
506 // ==========================================================================
507 // Conversation Panel
508 // ==========================================================================
509
510 // Select and load a chat conversation
511 function selectChat(sessionId) {
512 currentSessionId = sessionId;
513
514 // Reset translation state when selecting new chat
515 if (typeof resetTranslationState === 'function') {
516 resetTranslationState();
517 }
518
519 // Show loading in conversation panel
520 $('#mxch-conversation-empty').hide();
521 $('#mxch-conversation-content').show();
522 $('#mxch-messages-area').html('<div class="mxch-messages-loading"><span class="spinner is-active"></span> Loading conversation...</div>');
523
524 $.ajax({
525 url: ajaxurl,
526 type: 'POST',
527 data: {
528 action: 'mxchat_fetch_conversation',
529 session_id: sessionId
530 },
531 success: function(response) {
532 if (response.success) {
533 renderConversation(response);
534 // Load saved translation after rendering
535 if (typeof loadSavedTranslation === 'function') {
536 setTimeout(function() {
537 loadSavedTranslation(sessionId);
538 }, 100);
539 }
540 } else {
541 $('#mxch-messages-area').html('<div class="mxch-messages-error">Failed to load conversation</div>');
542 }
543 },
544 error: function() {
545 $('#mxch-messages-area').html('<div class="mxch-messages-error">Error loading conversation</div>');
546 }
547 });
548 }
549
550 // Render conversation content
551 function renderConversation(data) {
552 // Update header
553 $('#mxch-user-avatar span').text(data.user.initials);
554 $('#mxch-user-name').text(data.user.name);
555 $('#mxch-user-meta').text(data.user.sub);
556
557 // Update details drawer
558 $('#mxch-detail-messages').text(data.message_count);
559 $('#mxch-detail-started').text(data.started);
560
561 if (data.page.url) {
562 $('#mxch-detail-page').html(`<a href="${escapeHtml(data.page.url)}" target="_blank">${escapeHtml(data.page.title || data.page.url)}</a>`);
563 } else {
564 $('#mxch-detail-page').text('-');
565 }
566
567 if (data.user.email) {
568 $('#mxch-detail-email').text(data.user.email);
569 $('#mxch-detail-email-row').show();
570 } else {
571 $('#mxch-detail-email-row').hide();
572 }
573
574 // Feedback row — .text() (not .html()) for XSS-safe display of user-submitted text.
575 // rating_feedback is already sanitize_text_field()'d + mb_substr(200) on save.
576 var feedback = (data && data.rating_feedback) ? String(data.rating_feedback).trim() : '';
577 if (feedback) {
578 $('#mxch-detail-feedback').text(feedback);
579 $('#mxch-detail-feedback-row').show();
580 } else {
581 $('#mxch-detail-feedback-row').hide();
582 }
583
584 // Clicked links
585 if (data.clicked_urls && data.clicked_urls.length > 0) {
586 let linksHtml = '';
587 data.clicked_urls.forEach(function(url) {
588 linksHtml += `<a href="${escapeHtml(url)}" target="_blank" class="mxch-clicked-link">${escapeHtml(url)}</a>`;
589 });
590 $('#mxch-clicked-links').html(linksHtml);
591 $('#mxch-clicked-section').show();
592 } else {
593 $('#mxch-clicked-section').hide();
594 }
595
596 // Render messages
597 let messagesHtml = '';
598 data.messages.forEach(function(msg) {
599 if (msg.is_user) {
600 messagesHtml += `
601 <div class="mxch-message mxch-message-user" data-message-id="${msg.id}">
602 <div class="mxch-message-row">
603 <div class="mxch-message-bubble">
604 ${msg.content}
605 </div>
606 </div>
607 <div class="mxch-message-time">${escapeHtml(msg.timestamp)}</div>
608 </div>
609 `;
610 } else {
611 // Live-agent replies (role 'agent') get their own label and never
612 // carry a Sources link; bot/assistant rows render exactly as before.
613 const isAgent = !!msg.is_agent;
614 const ragLink = (!isAgent && msg.has_rag) ? `<a href="#" class="mxch-rag-link" data-message-id="${msg.id}">Sources</a>` : '';
615 const senderLabel = isAgent
616 ? '<span class="mxch-agent-label">Live Agent</span>'
617 : '<span class="mxch-bot-label">AI Assistant</span>';
618 messagesHtml += `
619 <div class="mxch-message mxch-message-bot${isAgent ? ' mxch-message-agent' : ''}" data-message-id="${msg.id}">
620 <div class="mxch-message-header">
621 ${senderLabel}
622 ${ragLink}
623 </div>
624 <div class="mxch-message-row">
625 <div class="mxch-message-bubble">
626 ${msg.content}
627 </div>
628 </div>
629 <div class="mxch-message-time">${escapeHtml(msg.timestamp)}</div>
630 </div>
631 `;
632 }
633 });
634
635 $('#mxch-messages-area').html(messagesHtml);
636
637 // Scroll to bottom
638 const $area = $('#mxch-messages-area');
639 $area.scrollTop($area[0].scrollHeight);
640
641 // Attach RAG link handlers
642 $('.mxch-rag-link').on('click', function(e) {
643 e.preventDefault();
644 const messageId = $(this).data('message-id');
645 if (messageId) {
646 openRagContextModal(messageId);
647 }
648 });
649 }
650
651 // Toggle details drawer
652 $('#mxch-toggle-details').on('click', function() {
653 const $drawer = $('#mxch-details-drawer');
654 const $btn = $(this);
655
656 if ($drawer.is(':visible')) {
657 $drawer.slideUp(200);
658 $btn.removeClass('active');
659 } else {
660 $drawer.slideDown(200);
661 $btn.addClass('active');
662 }
663 });
664
665 // Delete current chat — opens the shared confirm modal.
666 $('#mxch-delete-current').on('click', function() {
667 if (!currentSessionId) return;
668 openTranscriptConfirm([currentSessionId], 1);
669 });
670
671 // Delete session function
672 function deleteSession(sessionId, alsoDeleteLead) {
673 $.ajax({
674 url: ajaxurl,
675 type: 'POST',
676 data: {
677 action: 'mxchat_delete_chat_history',
678 delete_session_ids: [sessionId],
679 also_delete_lead: alsoDeleteLead ? '1' : '0',
680 security: $('#mxchat_delete_chat_nonce').val()
681 },
682 success: function(response) {
683 try {
684 const jsonResponse = typeof response === 'object' ? response : JSON.parse(response);
685
686 if (jsonResponse.success) {
687 // Reset conversation panel
688 currentSessionId = null;
689 $('#mxch-conversation-content').hide();
690 $('#mxch-conversation-empty').show();
691 $('#mxch-details-drawer').hide();
692
693 // Reload list
694 loadChatList(currentPage, $('#mxch-search-transcripts').val());
695 } else if (jsonResponse.error) {
696 alert('Error: ' + jsonResponse.error);
697 }
698 } catch (e) {
699 alert('An error occurred while processing the response.');
700 }
701 },
702 error: function() {
703 alert('An error occurred while deleting the conversation.');
704 }
705 });
706 }
707
708 // ==========================================================================
709 // Export Functionality
710 // ==========================================================================
711
712 $('#mxch-export-btn, #mxch-export-current').on('click', function() {
713 const $button = $(this);
714 $button.prop('disabled', true).addClass('loading');
715
716 const $form = $('<form>', {
717 method: 'post',
718 action: ajaxurl
719 });
720
721 $form.append($('<input>', {
722 type: 'hidden',
723 name: 'action',
724 value: 'mxchat_export_transcripts'
725 }));
726
727 $form.append($('<input>', {
728 type: 'hidden',
729 name: 'security',
730 value: mxchatAdmin.export_nonce
731 }));
732
733 $form.appendTo('body').submit();
734
735 setTimeout(function() {
736 $button.prop('disabled', false).removeClass('loading');
737 }, 2000);
738 });
739
740 // ==========================================================================
741 // Translation Functionality
742 // ==========================================================================
743
744 // Store original messages for reverting
745 let originalMessages = null;
746 let isTranslated = false;
747 let currentTranslationLang = null;
748
749 // Load saved language preference from localStorage
750 const savedLang = localStorage.getItem('mxch_translate_lang');
751 if (savedLang) {
752 $('#mxch-translate-lang').val(savedLang);
753 }
754
755 // Save language preference when changed
756 $('#mxch-translate-lang').on('change', function() {
757 localStorage.setItem('mxch_translate_lang', $(this).val());
758 });
759
760 // Apply translations to messages
761 function applyTranslations(translations) {
762 // Store original messages if not already stored
763 if (!originalMessages) {
764 originalMessages = [];
765 $('#mxch-messages-area .mxch-message-bubble').each(function() {
766 originalMessages.push($(this).html());
767 });
768 }
769
770 // Apply translations
771 translations.forEach(function(item) {
772 const $bubble = $('#mxch-messages-area .mxch-message-bubble').eq(item.index);
773 if ($bubble.length) {
774 $bubble.html(item.translated);
775 $bubble.addClass('translated');
776 }
777 });
778
779 isTranslated = true;
780 $('#mxch-show-original-btn').show();
781 }
782
783 // Load saved translation for current session
784 function loadSavedTranslation(sessionId) {
785 $.ajax({
786 url: ajaxurl,
787 type: 'POST',
788 data: {
789 action: 'mxchat_get_transcript_translation',
790 session_id: sessionId
791 },
792 success: function(response) {
793 if (response.success && response.has_translation) {
794 currentTranslationLang = response.language;
795 applyTranslations(response.translations);
796 // Update language selector to show saved language
797 $('#mxch-translate-lang').val(response.language);
798 }
799 }
800 });
801 }
802
803 // Translate button click handler
804 $('#mxch-translate-btn').on('click', function() {
805 if (!currentSessionId) return;
806
807 const $btn = $(this);
808 const targetLang = $('#mxch-translate-lang').val();
809
810 // Disable button and show loading state
811 $btn.prop('disabled', true);
812 $btn.find('.mxch-translate-text').text('Translating...');
813 $btn.find('svg').addClass('mxch-translate-spinner');
814
815 // Store original messages before translation
816 if (!originalMessages) {
817 originalMessages = [];
818 $('#mxch-messages-area .mxch-message-bubble').each(function() {
819 originalMessages.push($(this).html());
820 });
821 }
822
823 // If already translated, restore originals first before re-translating
824 if (isTranslated) {
825 $('#mxch-messages-area .mxch-message-bubble').each(function(index) {
826 if (originalMessages[index]) {
827 $(this).html(originalMessages[index]);
828 $(this).removeClass('translated');
829 }
830 });
831 }
832
833 // Collect all message content (from originals)
834 const messages = [];
835 originalMessages.forEach(function(html, index) {
836 // Create temp element to get text content
837 const $temp = $('<div>').html(html);
838 messages.push({
839 index: index,
840 content: $temp.text().trim()
841 });
842 });
843
844 // Send translation request
845 $.ajax({
846 url: ajaxurl,
847 type: 'POST',
848 data: {
849 action: 'mxchat_translate_messages',
850 session_id: currentSessionId,
851 target_lang: targetLang,
852 messages: JSON.stringify(messages),
853 security: mxchatAdmin.translate_nonce || ''
854 },
855 success: function(response) {
856 if (response.success && response.translations) {
857 currentTranslationLang = response.language;
858 applyTranslations(response.translations);
859 $btn.find('.mxch-translate-text').text('Translate');
860 } else {
861 alert(response.error || 'Translation failed. Please try again.');
862 $btn.find('.mxch-translate-text').text('Translate');
863 }
864 },
865 error: function() {
866 alert('Translation request failed. Please try again.');
867 $btn.find('.mxch-translate-text').text('Translate');
868 },
869 complete: function() {
870 $btn.prop('disabled', false);
871 $btn.find('svg').removeClass('mxch-translate-spinner');
872 }
873 });
874 });
875
876 // Show original button click handler
877 $('#mxch-show-original-btn').on('click', function() {
878 if (!originalMessages) return;
879
880 // Restore original messages
881 $('#mxch-messages-area .mxch-message-bubble').each(function(index) {
882 if (originalMessages[index]) {
883 $(this).html(originalMessages[index]);
884 $(this).removeClass('translated');
885 }
886 });
887
888 isTranslated = false;
889 $(this).hide();
890 });
891
892 // Reset translation state (called when selecting new chat)
893 function resetTranslationState() {
894 originalMessages = null;
895 isTranslated = false;
896 currentTranslationLang = null;
897 $('#mxch-show-original-btn').hide();
898 }
899
900 // Make functions available to selectChat
901 window.resetTranslationState = resetTranslationState;
902 window.loadSavedTranslation = loadSavedTranslation;
903
904 // ==========================================================================
905 // RAG Context Modal (Sources & Actions Tabs)
906 // ==========================================================================
907
908 function openRagContextModal(messageId) {
909 const $modal = $('#mxch-rag-modal');
910 const $loading = $modal.find('.mxch-rag-loading');
911 const $sourcesContent = $modal.find('.mxch-rag-content');
912 const $actionsContent = $modal.find('.mxch-actions-content');
913
914 // Reset to Sources tab
915 $modal.find('.mxch-context-tab').removeClass('active');
916 $modal.find('.mxch-context-tab[data-tab="sources"]').addClass('active');
917 $('#mxch-tab-sources').show();
918 $('#mxch-tab-actions').hide();
919
920 // Reset badge counts
921 $('#mxch-sources-count, #mxch-actions-count').hide().text('0');
922
923 $modal.fadeIn(200);
924 $loading.show();
925 $sourcesContent.html('');
926 $actionsContent.html('');
927
928 $.ajax({
929 url: ajaxurl,
930 type: 'POST',
931 data: {
932 action: 'mxchat_get_rag_context',
933 message_id: messageId
934 },
935 success: function(response) {
936 $loading.hide();
937
938 if (response.success && response.data) {
939 // Render sources tab
940 renderRagContext(response.data, $sourcesContent);
941
942 // Render actions tab
943 renderActionsContext(response.data, $actionsContent);
944
945 // Update badge counts
946 const sourcesCount = response.data.top_matches ? response.data.top_matches.length : 0;
947 // 470f68: the Actions tab now carries two mechanisms — tools
948 // that ran and trigger phrases that scored. Badge counts both.
949 const toolCallsCount = response.data.tool_calls ? response.data.tool_calls.length : 0;
950 const actionsCount = (response.data.action_analysis ? response.data.action_analysis.length : 0) + toolCallsCount;
951
952 if (sourcesCount > 0) {
953 $('#mxch-sources-count').text(sourcesCount).show();
954 }
955 if (actionsCount > 0) {
956 $('#mxch-actions-count').text(actionsCount).show();
957 }
958 } else {
959 $sourcesContent.html('<div class="mxch-rag-error">Unable to load document context.</div>');
960 $actionsContent.html('<div class="mxch-rag-error">No action data available.</div>');
961 }
962 },
963 error: function() {
964 $loading.hide();
965 $sourcesContent.html('<div class="mxch-rag-error">Error loading context. Please try again.</div>');
966 $actionsContent.html('<div class="mxch-rag-error">Error loading context. Please try again.</div>');
967 }
968 });
969 }
970
971 // Tab switching
972 $(document).on('click', '.mxch-context-tab', function() {
973 const $tab = $(this);
974 const tabName = $tab.data('tab');
975
976 // Update active tab
977 $('.mxch-context-tab').removeClass('active');
978 $tab.addClass('active');
979
980 // Show/hide content
981 $('.mxch-tab-content').hide();
982 $('#mxch-tab-' + tabName).show();
983 });
984
985 function renderRagContext(data, $container) {
986 let html = '';
987
988 // 67fc92: "the KB was searched and nothing matched" and "nothing was
989 // recorded for this row" are different facts. Retrieval keys present
990 // means the search ran and was recorded (top_matches may be empty);
991 // no retrieval keys means there is simply no record (older rows, or
992 // turns that never consulted the knowledge base).
993 const retrievalRecorded = typeof data.total_documents_checked !== 'undefined'
994 || (data.top_matches && data.top_matches.length > 0);
995
996 if (!retrievalRecorded) {
997 html += '<div class="mxch-no-results"><p>No retrieval data was recorded for this response.</p></div>';
998 $container.html(html);
999 return;
1000 }
1001
1002 // Hybrid keyword boost (38ffa1): rows carry matched_via + fused_rank
1003 // when hybrid retrieval was on for this response. Cosine % stays the
1004 // anchor; the chip explains WHY a low-% row ranked high.
1005 const topMatches = data.top_matches || [];
1006 const hybridOn = topMatches.some(function(m) { return m.matched_via; });
1007
1008 html += '<div class="mxch-rag-summary">';
1009 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Knowledge Base:</span> <span class="mxch-rag-value">' + escapeHtml(data.knowledge_base_type || 'WordPress Database') + '</span></div>';
1010 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Similarity Threshold:</span> <span class="mxch-rag-value">' + Math.round((data.similarity_threshold || 0.35) * 100) + '%</span></div>';
1011 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Documents Checked:</span> <span class="mxch-rag-value">' + (data.total_documents_checked || 0) + '</span></div>';
1012 if (hybridOn) {
1013 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Retrieval:</span> <span class="mxch-rag-value">Hybrid</span></div>';
1014 }
1015 html += '</div>';
1016
1017 if (topMatches.length === 0) {
1018 html += '<div class="mxch-no-results"><p>The knowledge base was searched &mdash; no documents matched this response.</p></div>';
1019 $container.html(html);
1020 return;
1021 }
1022
1023 const groupedByUrl = {};
1024
1025 data.top_matches.forEach(function(match) {
1026 const url = match.source_display || 'Unknown';
1027 if (!groupedByUrl[url]) {
1028 groupedByUrl[url] = {
1029 url: url,
1030 isUrl: url.startsWith('http'),
1031 bestScore: 0,
1032 usedForContext: false,
1033 matchedChunks: [],
1034 bestFusedRank: Infinity,
1035 viaSet: {}
1036 };
1037 }
1038
1039 if (match.similarity_percentage > groupedByUrl[url].bestScore) {
1040 groupedByUrl[url].bestScore = match.similarity_percentage;
1041 }
1042
1043 if (match.used_for_context) {
1044 groupedByUrl[url].usedForContext = true;
1045 }
1046
1047 if (match.fused_rank && match.fused_rank < groupedByUrl[url].bestFusedRank) {
1048 groupedByUrl[url].bestFusedRank = match.fused_rank;
1049 }
1050 if (match.matched_via) {
1051 groupedByUrl[url].viaSet[match.matched_via] = true;
1052 }
1053
1054 groupedByUrl[url].matchedChunks.push({
1055 chunkIndex: match.chunk_index,
1056 score: match.similarity_percentage,
1057 usedForContext: match.used_for_context
1058 });
1059 });
1060
1061 // Hybrid on: order by fused rank (rows without one sort last);
1062 // otherwise by cosine, exactly as before.
1063 const urlGroups = Object.values(groupedByUrl).sort(function(a, b) {
1064 if (hybridOn && a.bestFusedRank !== b.bestFusedRank) {
1065 return a.bestFusedRank - b.bestFusedRank;
1066 }
1067 return b.bestScore - a.bestScore;
1068 });
1069 const usedUrlCount = data.sources_used > 0 ? data.sources_used : urlGroups.filter(g => g.usedForContext).length;
1070 const chunksInfo = data.total_chunks_used > 0 ? data.total_chunks_used + ' chunks sent to AI' : '';
1071
1072 html += '<div class="mxch-rag-matches">';
1073 html += '<h3>Retrieved Documents</h3>';
1074 html += '<p style="color: var(--mxch-text-secondary); font-size: 13px; margin-bottom: 16px;">' + usedUrlCount + ' source' + (usedUrlCount === 1 ? '' : 's') + ' used for response' + (chunksInfo ? ' &middot; ' + chunksInfo : '') + '</p>';
1075
1076 urlGroups.forEach(function(group) {
1077 const cardClass = group.usedForContext ? 'mxch-rag-match-used' : 'mxch-rag-match-below';
1078 const statusIcon = group.usedForContext ? '&#10003;' : '&#10007;';
1079 const statusLabel = group.usedForContext ? 'Used' : 'Not Used';
1080
1081 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
1082 html += '<div class="mxch-rag-match-header">';
1083 html += '<span class="mxch-rag-match-score">' + group.bestScore + '%</span>';
1084
1085 if (hybridOn) {
1086 const vias = Object.keys(group.viaSet);
1087 if (vias.length) {
1088 const viaLabel = (vias.length > 1 || vias[0] === 'both') ? 'Both'
1089 : (vias[0] === 'keyword' ? 'Keyword' : 'Vector');
1090 html += '<span class="mxch-rag-via-chip mxch-rag-via-' + viaLabel.toLowerCase() + '">' + viaLabel + '</span>';
1091 }
1092 }
1093
1094 if (group.matchedChunks.length > 1) {
1095 html += '<span class="mxch-rag-chunk-badge">' + group.matchedChunks.length + ' chunks</span>';
1096 }
1097
1098 html += '<span class="mxch-rag-match-status ' + (group.usedForContext ? 'status-used' : 'status-below') + '">' + statusIcon + ' ' + statusLabel + '</span>';
1099 html += '</div>';
1100
1101 html += '<div class="mxch-rag-match-source">';
1102 if (group.isUrl) {
1103 html += '<a href="' + escapeHtml(group.url) + '" target="_blank">' + escapeHtml(group.url) + '</a>';
1104 } else {
1105 html += escapeHtml(group.url);
1106 }
1107 html += '</div>';
1108 html += '</div>';
1109 });
1110
1111 html += '</div>';
1112 $container.html(html);
1113 }
1114
1115 // AI Tools trace (470f68): one card per tool EXECUTION for this message.
1116 // Rendered ABOVE the trigger-phrase scores — a tool that actually ran
1117 // outranks a phrase that merely scored.
1118 function renderToolCalls(toolCalls) {
1119 const failed = toolCalls.filter(function(t) { return !t.ok; }).length;
1120
1121 let html = '<div class="mxch-rag-summary">';
1122 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Tools Used:</span> <span class="mxch-rag-value">' + toolCalls.length + '</span></div>';
1123 if (failed > 0) {
1124 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Failed:</span> <span class="mxch-rag-value" style="color: #ef4444; font-weight: 600;">' + failed + '</span></div>';
1125 }
1126 html += '</div>';
1127
1128 html += '<div class="mxch-rag-matches">';
1129 html += '<h3>AI Tools</h3>';
1130 html += '<p style="color: var(--mxch-text-secondary); font-size: 13px; margin-bottom: 16px;">Tools the assistant chose and ran for this message, in order.</p>';
1131
1132 toolCalls.forEach(function(tool) {
1133 const ok = !!tool.ok;
1134 const cardClass = ok ? 'mxch-rag-match-used' : 'mxch-tool-failed';
1135 const statusClass = ok ? 'status-used' : 'status-failed';
1136 const statusIcon = ok ? '&#10003;' : '&#10007;';
1137 const statusLabel = ok ? 'Ran' : 'Failed';
1138
1139 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
1140 html += '<div class="mxch-rag-match-header">';
1141 html += '<span class="mxch-rag-match-score">&#9889;</span>';
1142 if (typeof tool.ms === 'number') {
1143 html += '<span class="mxch-tool-duration">' + tool.ms + ' ms</span>';
1144 }
1145 html += '<span class="mxch-rag-match-status ' + statusClass + '">' + statusIcon + ' ' + statusLabel + '</span>';
1146 html += '</div>';
1147
1148 html += '<div class="mxch-action-details">';
1149 html += '<div class="mxch-action-label">' + escapeHtml(tool.label || tool.name) + '</div>';
1150 html += '<div class="mxch-action-callback"><span class="mxch-action-callback-label">Tool:</span> ' + escapeHtml(tool.name) + '</div>';
1151 html += '</div>';
1152
1153 if (!ok && tool.error) {
1154 html += '<div class="mxch-tool-error">' + escapeHtml(tool.error) + '</div>';
1155 }
1156
1157 if (tool.args_redacted === 'sensitive') {
1158 html += '<div class="mxch-tool-redacted">Arguments not recorded &mdash; this tool is marked sensitive.</div>';
1159 } else if (tool.args_excerpt) {
1160 html += '<details class="mxch-tool-args"><summary>Arguments</summary>';
1161 html += '<div class="mxch-tool-args-body">' + escapeHtml(tool.args_excerpt) + '</div></details>';
1162 }
1163
1164 html += '</div>';
1165 });
1166
1167 html += '</div>';
1168 return html;
1169 }
1170
1171 function renderActionsContext(data, $container) {
1172 let html = '';
1173
1174 const toolCalls = (data.tool_calls && data.tool_calls.length) ? data.tool_calls : [];
1175 const actions = (data.action_analysis && data.action_analysis.length) ? data.action_analysis : [];
1176
1177 // 95d79d's empty state now shows only when NEITHER mechanism produced
1178 // anything — the string itself is unchanged.
1179 if (toolCalls.length === 0 && actions.length === 0) {
1180 html += '<div class="mxch-no-results"><p>No trigger-phrase analysis for this message.</p><p style="color: var(--mxch-text-secondary); font-size: 13px; margin-top: 8px;">This panel shows how your <strong>Trigger Phrases</strong> scored &mdash; it stays empty if you have none enabled for this bot, or if the answer came from <strong>AI Tools</strong>, which don\'t produce similarity scores. It\'s recorded when the answer is generated, so it won\'t appear on older conversations.</p></div>';
1181 $container.html(html);
1182 return;
1183 }
1184
1185 if (toolCalls.length > 0) {
1186 html += renderToolCalls(toolCalls);
1187 }
1188
1189 if (actions.length === 0) {
1190 $container.html(html);
1191 return;
1192 }
1193 const triggeredAction = actions.find(a => a.triggered);
1194 const actionsAboveThreshold = actions.filter(a => a.above_threshold).length;
1195
1196 // Summary section
1197 html += '<div class="mxch-rag-summary">';
1198 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Actions Evaluated:</span> <span class="mxch-rag-value">' + actions.length + '</span></div>';
1199 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Above Threshold:</span> <span class="mxch-rag-value">' + actionsAboveThreshold + '</span></div>';
1200 if (triggeredAction) {
1201 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Triggered:</span> <span class="mxch-rag-value" style="color: #10b981; font-weight: 600;">' + escapeHtml(triggeredAction.intent_label) + '</span></div>';
1202 }
1203 html += '</div>';
1204
1205 // Actions list
1206 html += '<div class="mxch-rag-matches">';
1207 html += '<h3>Action Scores</h3>';
1208 html += '<p style="color: var(--mxch-text-secondary); font-size: 13px; margin-bottom: 16px;">Showing all evaluated actions sorted by similarity score</p>';
1209
1210 actions.forEach(function(action) {
1211 let cardClass = 'mxch-rag-match-below';
1212 let statusIcon = '&#10007;';
1213 let statusLabel = 'Below Threshold';
1214
1215 if (action.triggered) {
1216 cardClass = 'mxch-action-triggered';
1217 statusIcon = '&#9889;';
1218 statusLabel = 'Triggered';
1219 } else if (action.above_threshold) {
1220 cardClass = 'mxch-rag-match-used';
1221 statusIcon = '&#10003;';
1222 statusLabel = 'Above Threshold';
1223 }
1224
1225 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
1226 html += '<div class="mxch-rag-match-header">';
1227 html += '<span class="mxch-rag-match-score">' + action.similarity_percentage + '%</span>';
1228 html += '<span class="mxch-action-threshold-badge">Threshold: ' + action.threshold_percentage + '%</span>';
1229 html += '<span class="mxch-rag-match-status ' + (action.triggered ? 'status-triggered' : (action.above_threshold ? 'status-used' : 'status-below')) + '">' + statusIcon + ' ' + statusLabel + '</span>';
1230 html += '</div>';
1231
1232 html += '<div class="mxch-action-details">';
1233 html += '<div class="mxch-action-label">' + escapeHtml(action.intent_label) + '</div>';
1234 html += '<div class="mxch-action-callback"><span class="mxch-action-callback-label">Callback:</span> ' + escapeHtml(action.callback_function) + '</div>';
1235 html += '</div>';
1236
1237 // Score bar visualization
1238 const scoreBarWidth = Math.min(action.similarity_percentage, 100);
1239 const thresholdPos = Math.min(action.threshold_percentage, 100);
1240 html += '<div class="mxch-action-score-bar">';
1241 html += '<div class="mxch-action-score-fill" style="width: ' + scoreBarWidth + '%;"></div>';
1242 html += '<div class="mxch-action-threshold-marker" style="left: ' + thresholdPos + '%;"></div>';
1243 html += '</div>';
1244
1245 html += '</div>';
1246 });
1247
1248 html += '</div>';
1249 $container.html(html);
1250 }
1251
1252 function escapeHtml(text) {
1253 if (!text) return '';
1254 const div = document.createElement('div');
1255 div.textContent = text;
1256 return div.innerHTML;
1257 }
1258
1259 // Close RAG modal
1260 $('.mxch-modal-close').on('click', function() {
1261 $(this).closest('.mxch-modal-overlay').fadeOut(200);
1262 });
1263
1264 $('.mxch-modal-overlay').on('click', function(e) {
1265 if ($(e.target).is('.mxch-modal-overlay')) {
1266 $(this).fadeOut(200);
1267 }
1268 });
1269
1270 $(document).on('keydown', function(e) {
1271 if (e.key === 'Escape') {
1272 $('.mxch-modal-overlay').fadeOut(200);
1273 }
1274 });
1275
1276 // ==========================================================================
1277 // Activity Chart
1278 // ==========================================================================
1279
1280 // Simple chart implementation (no external dependencies)
1281 class SimpleChart {
1282 constructor(canvas, config) {
1283 this.canvas = canvas;
1284 this.ctx = canvas.getContext('2d');
1285 this.config = config;
1286 this.padding = { top: 20, right: 20, bottom: 40, left: 50 };
1287 this.render();
1288 }
1289
1290 destroy() {
1291 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
1292 }
1293
1294 render() {
1295 const dpr = window.devicePixelRatio || 1;
1296 const rect = this.canvas.getBoundingClientRect();
1297
1298 this.canvas.width = rect.width * dpr;
1299 this.canvas.height = rect.height * dpr;
1300 this.ctx.scale(dpr, dpr);
1301
1302 this.canvas.style.width = rect.width + 'px';
1303 this.canvas.style.height = rect.height + 'px';
1304
1305 const width = rect.width - this.padding.left - this.padding.right;
1306 const height = rect.height - this.padding.top - this.padding.bottom;
1307
1308 // Find max value
1309 let maxValue = 0;
1310 this.config.datasets.forEach(dataset => {
1311 const max = Math.max(...dataset.data);
1312 if (max > maxValue) maxValue = max;
1313 });
1314
1315 // Add some padding to max value
1316 maxValue = Math.ceil(maxValue * 1.1);
1317 if (maxValue === 0) maxValue = 10;
1318
1319 // Draw grid lines
1320 this.ctx.strokeStyle = '#e5e7eb';
1321 this.ctx.lineWidth = 1;
1322 const gridLines = 5;
1323
1324 for (let i = 0; i <= gridLines; i++) {
1325 const y = this.padding.top + (height / gridLines) * i;
1326 this.ctx.beginPath();
1327 this.ctx.moveTo(this.padding.left, y);
1328 this.ctx.lineTo(this.padding.left + width, y);
1329 this.ctx.stroke();
1330
1331 // Draw y-axis labels
1332 const value = maxValue - (maxValue / gridLines) * i;
1333 this.ctx.fillStyle = '#6b7280';
1334 this.ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
1335 this.ctx.textAlign = 'right';
1336 this.ctx.fillText(Math.round(value), this.padding.left - 10, y + 4);
1337 }
1338
1339 // Draw datasets
1340 this.config.datasets.forEach(dataset => {
1341 const points = [];
1342 const xStep = width / (this.config.labels.length - 1 || 1);
1343
1344 dataset.data.forEach((value, index) => {
1345 const x = this.padding.left + (xStep * index);
1346 const y = this.padding.top + height - (value / maxValue * height);
1347 points.push({ x, y, value });
1348 });
1349
1350 // Draw filled area
1351 if (dataset.fill && dataset.backgroundColor) {
1352 this.ctx.fillStyle = dataset.backgroundColor;
1353 this.ctx.beginPath();
1354 this.ctx.moveTo(points[0].x, this.padding.top + height);
1355 points.forEach(point => {
1356 this.ctx.lineTo(point.x, point.y);
1357 });
1358 this.ctx.lineTo(points[points.length - 1].x, this.padding.top + height);
1359 this.ctx.closePath();
1360 this.ctx.fill();
1361 }
1362
1363 // Draw line
1364 this.ctx.strokeStyle = dataset.borderColor;
1365 this.ctx.lineWidth = 3;
1366 this.ctx.lineCap = 'round';
1367 this.ctx.lineJoin = 'round';
1368
1369 this.ctx.beginPath();
1370 points.forEach((point, index) => {
1371 if (index === 0) {
1372 this.ctx.moveTo(point.x, point.y);
1373 } else {
1374 this.ctx.lineTo(point.x, point.y);
1375 }
1376 });
1377 this.ctx.stroke();
1378
1379 // Draw points
1380 points.forEach(point => {
1381 this.ctx.fillStyle = '#ffffff';
1382 this.ctx.beginPath();
1383 this.ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
1384 this.ctx.fill();
1385 this.ctx.strokeStyle = dataset.borderColor;
1386 this.ctx.lineWidth = 2;
1387 this.ctx.stroke();
1388 });
1389 });
1390
1391 // Draw x-axis labels
1392 const xStep = width / (this.config.labels.length - 1 || 1);
1393 this.ctx.fillStyle = '#6b7280';
1394 this.ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
1395 this.ctx.textAlign = 'center';
1396
1397 this.config.labels.forEach((label, index) => {
1398 const x = this.padding.left + (xStep * index);
1399 this.ctx.fillText(label, x, this.padding.top + height + 20);
1400 });
1401 }
1402 }
1403
1404 // Initialize activity chart
1405 function initActivityChart() {
1406 console.log('[MxChat Chart] initActivityChart called');
1407
1408 const canvas = document.getElementById('mxchat-activity-chart');
1409 console.log('[MxChat Chart] Canvas element:', canvas);
1410
1411 if (!canvas) {
1412 console.log('[MxChat Chart] Canvas not found, aborting');
1413 return;
1414 }
1415
1416 console.log('[MxChat Chart] mxchatChartData exists:', typeof mxchatChartData !== 'undefined');
1417 if (typeof mxchatChartData === 'undefined') {
1418 console.log('[MxChat Chart] mxchatChartData is undefined, aborting');
1419 return;
1420 }
1421
1422 console.log('[MxChat Chart] Raw mxchatChartData:', mxchatChartData);
1423
1424 // Check if chart already exists and destroy it
1425 if (canvas.chartInstance) {
1426 canvas.chartInstance.destroy();
1427 }
1428
1429 const ctx = canvas.getContext('2d');
1430 console.log('[MxChat Chart] Canvas context:', ctx);
1431 console.log('[MxChat Chart] Canvas dimensions:', canvas.getBoundingClientRect());
1432
1433 // Create gradient for chats line
1434 const chatsGradient = ctx.createLinearGradient(0, 0, 0, 300);
1435 chatsGradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
1436 chatsGradient.addColorStop(1, 'rgba(102, 126, 234, 0.05)');
1437
1438 // Create gradient for messages line
1439 const messagesGradient = ctx.createLinearGradient(0, 0, 0, 300);
1440 messagesGradient.addColorStop(0, 'rgba(118, 75, 162, 0.3)');
1441 messagesGradient.addColorStop(1, 'rgba(118, 75, 162, 0.05)');
1442
1443 // Convert wp_localize_script objects to arrays (WordPress converts indexed arrays to objects)
1444 const labels = Object.values(mxchatChartData.labels);
1445 const chatsData = Object.values(mxchatChartData.chats).map(Number);
1446 const messagesData = Object.values(mxchatChartData.messages).map(Number);
1447
1448 console.log('[MxChat Chart] Processed labels:', labels);
1449 console.log('[MxChat Chart] Processed chatsData:', chatsData);
1450 console.log('[MxChat Chart] Processed messagesData:', messagesData);
1451
1452 // Create chart
1453 try {
1454 canvas.chartInstance = new SimpleChart(canvas, {
1455 labels: labels,
1456 datasets: [
1457 {
1458 label: 'Chats',
1459 data: chatsData,
1460 borderColor: '#667eea',
1461 backgroundColor: chatsGradient,
1462 fill: true
1463 },
1464 {
1465 label: 'Messages',
1466 data: messagesData,
1467 borderColor: '#764ba2',
1468 backgroundColor: messagesGradient,
1469 fill: true
1470 }
1471 ]
1472 });
1473 console.log('[MxChat Chart] Chart created successfully');
1474 } catch (error) {
1475 console.error('[MxChat Chart] Error creating chart:', error);
1476 }
1477 }
1478
1479 // Initialize chart on page load (dashboard is shown by default)
1480 setTimeout(function() {
1481 initActivityChart();
1482 }, 100);
1483
1484 // Reinitialize chart on window resize
1485 let resizeTimeout;
1486 $(window).on('resize', function() {
1487 clearTimeout(resizeTimeout);
1488 resizeTimeout = setTimeout(function() {
1489 initActivityChart();
1490 }, 250);
1491 });
1492
1493 // ==========================================================================
1494 // Leads Tab
1495 // ==========================================================================
1496
1497 const leadsState = {
1498 loaded: false,
1499 page: 1,
1500 perPage: 25,
1501 totalPages: 1,
1502 totalCount: 0,
1503 selected: new Set(),
1504 filters: {
1505 search: '',
1506 dateRange: 'all',
1507 status: 'all',
1508 pageUrl: '',
1509 pageTitle: ''
1510 },
1511 pendingDelete: [],
1512 leadsRows: [] // last-rendered rows for quick lookup
1513 };
1514
1515 function $leads() { return $('#leads'); }
1516
1517 // Called after a transcript delete from the All Chats side. Marks the Leads tab
1518 // data stale so the next tab visit re-fetches, and refreshes immediately if the
1519 // Leads tab happens to already be visible.
1520 function invalidateLeadsData() {
1521 leadsState.loaded = false;
1522 if ($('#leads').hasClass('active')) {
1523 loadLeads(1);
1524 }
1525 }
1526
1527 function escapeHtmlLeads(s) {
1528 if (s === null || typeof s === 'undefined') return '';
1529 return String(s)
1530 .replace(/&/g, '&amp;')
1531 .replace(/</g, '&lt;')
1532 .replace(/>/g, '&gt;')
1533 .replace(/"/g, '&quot;')
1534 .replace(/'/g, '&#039;');
1535 }
1536
1537 function leadsFiltersActive() {
1538 const f = leadsState.filters;
1539 return f.search !== '' || f.dateRange !== 'all' || f.status !== 'all' || f.pageUrl !== '';
1540 }
1541
1542 function updateClearFiltersButton() {
1543 if (leadsFiltersActive()) {
1544 $('#mxch-leads-clear-filters').show();
1545 } else {
1546 $('#mxch-leads-clear-filters').hide();
1547 }
1548 }
1549
1550 function setPageFilterChip(url, title) {
1551 leadsState.filters.pageUrl = url || '';
1552 leadsState.filters.pageTitle = title || url || '';
1553 const $chip = $('#mxch-leads-active-page-filter');
1554 if (url) {
1555 $chip.find('.mxch-leads-page-chip-label').text('Page: ' + (title || url));
1556 $chip.show();
1557 } else {
1558 $chip.hide();
1559 }
1560 updateClearFiltersButton();
1561 }
1562
1563 function loadLeads(page) {
1564 if (typeof page === 'number') leadsState.page = page;
1565
1566 const $tbody = $('#mxch-leads-tbody');
1567 $tbody.html('<tr><td colspan="6" class="mxch-leads-loading"><span class="spinner is-active"></span></td></tr>');
1568
1569 $.ajax({
1570 url: ajaxurl,
1571 type: 'POST',
1572 data: {
1573 action: 'mxchat_fetch_leads',
1574 page: leadsState.page,
1575 per_page: leadsState.perPage,
1576 search: leadsState.filters.search,
1577 date_range: leadsState.filters.dateRange,
1578 status: leadsState.filters.status,
1579 page_url: leadsState.filters.pageUrl
1580 },
1581 success: function(response) {
1582 leadsState.loaded = true;
1583 if (!response || !response.success) {
1584 $tbody.html('<tr><td colspan="7" class="mxch-leads-empty">Error loading leads</td></tr>');
1585 return;
1586 }
1587 leadsState.totalPages = response.total_pages || 1;
1588 leadsState.totalCount = response.total_count || 0;
1589 leadsState.leadsRows = response.leads || [];
1590
1591 renderLeadsStats(response.stats || {});
1592 renderLeadsTopPages(response.top_pages || []);
1593 renderLeadsTable(response.leads || []);
1594 renderLeadsCount(response.showing_start, response.showing_end, response.total_count);
1595 renderLeadsPagination(response.page, response.total_pages);
1596
1597 // Nav badge
1598 if (response.stats && typeof response.stats.total_leads === 'number') {
1599 const $badge = $('#mxch-leads-nav-badge');
1600 if (response.stats.total_leads > 0) {
1601 $badge.text(response.stats.total_leads).show();
1602 } else {
1603 $badge.hide();
1604 }
1605 }
1606 },
1607 error: function() {
1608 $tbody.html('<tr><td colspan="7" class="mxch-leads-empty">Error loading leads</td></tr>');
1609 }
1610 });
1611 }
1612
1613 function renderLeadsStats(stats) {
1614 $('#mxch-leads-stat-total').text(stats.total_leads || 0);
1615 $('#mxch-leads-stat-new').text(stats.new_this_week || 0);
1616 $('#mxch-leads-stat-avg').text(stats.avg_convos || 0);
1617 const pct = stats.orphan_pct || 0;
1618 $('#mxch-leads-stat-orphan').text(pct + '%');
1619 const orphanCount = stats.orphan_count || 0;
1620 $('#mxch-leads-stat-orphan-sub').text(orphanCount + (orphanCount === 1 ? ' lead captured but never chatted' : ' leads captured but never chatted'));
1621 }
1622
1623 function renderLeadsTopPages(pages) {
1624 const $wrap = $('#mxch-leads-toppages-list');
1625 if (!pages || pages.length === 0) {
1626 $wrap.html('<div class="mxch-leads-empty-mini">No page data yet.</div>');
1627 return;
1628 }
1629 let html = '';
1630 pages.forEach(function(p) {
1631 const isActive = leadsState.filters.pageUrl === p.url ? ' is-active' : '';
1632 html += `
1633 <button type="button" class="mxch-leads-toppage-row${isActive}" data-url="${escapeHtmlLeads(p.url)}" data-title="${escapeHtmlLeads(p.title)}">
1634 <span class="mxch-leads-toppage-title">${escapeHtmlLeads(p.title || p.url)}</span>
1635 <span class="mxch-leads-toppage-count">${p.lead_count}</span>
1636 </button>
1637 `;
1638 });
1639 $wrap.html(html);
1640 }
1641
1642 // A lead's key is its email address, or "session:<id>" when the capture
1643 // had no address (c0cfaf). Email keys are lowercased exactly as before;
1644 // session keys pass through untouched, because the server resolves them
1645 // back to a real session id and case matters there. Both shapes travel on
1646 // the same wire field the tab has always used, so the selection set, the
1647 // delete call and the export call need no new parameter.
1648 function leadKeyOf(r) {
1649 const email = (r && r.email ? String(r.email) : '').trim();
1650 if (email) return email.toLowerCase();
1651 if (r && r.lead_key) return String(r.lead_key);
1652 return 'session:' + (r && r.latest_session_id ? r.latest_session_id : '');
1653 }
1654
1655 function normalizeLeadKey(value) {
1656 const key = (value === undefined || value === null) ? '' : String(value);
1657 return key.indexOf('session:') === 0 ? key : key.toLowerCase();
1658 }
1659
1660 function renderLeadsTable(rows) {
1661 const $tbody = $('#mxch-leads-tbody');
1662 if (!rows || rows.length === 0) {
1663 $tbody.html(`
1664 <tr><td colspan="7" class="mxch-leads-empty">
1665 <div class="mxch-leads-empty-wrap">
1666 <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
1667 <p>No leads match the current filters.</p>
1668 </div>
1669 </td></tr>
1670 `);
1671 return;
1672 }
1673
1674 let html = '';
1675 rows.forEach(function(r) {
1676 const emailKey = leadKeyOf(r);
1677 const isChecked = leadsState.selected.has(emailKey) ? ' checked' : '';
1678 // Status: 'active' (has conversations), 'chat_deleted' (admin removed the chat), 'orphan' (no chat ever).
1679 const status = r.status || (r.is_orphan ? 'orphan' : 'active');
1680 const isOrphan = (status === 'orphan');
1681 const isChatDeleted = (status === 'chat_deleted');
1682 const nameLine = r.name
1683 ? `<span class="mxch-leads-lead-name">${escapeHtmlLeads(r.name)}</span>`
1684 : '';
1685 // A lead captured with no email address (c0cfaf) still has a
1686 // name; show that on the primary line rather than an empty cell,
1687 // and say plainly that there is no address.
1688 const primaryLine = r.email
1689 ? `<span class="mxch-leads-lead-email" title="${escapeHtmlLeads(r.email)}">${escapeHtmlLeads(r.email)}</span>`
1690 : `<span class="mxch-leads-lead-email">${escapeHtmlLeads(r.name || 'Unnamed lead')}</span>`;
1691 const secondaryLine = r.email
1692 ? nameLine
1693 : '<span class="mxch-leads-muted">No email address</span>';
1694 const leadCell = `
1695 <div class="mxch-leads-lead-cell">
1696 ${primaryLine}
1697 ${secondaryLine}
1698 </div>`;
1699 let countCell;
1700 if (isOrphan) {
1701 countCell = `<span class="mxch-leads-pill mxch-leads-pill-orphan">Orphan</span>`;
1702 } else if (isChatDeleted) {
1703 countCell = `<span class="mxch-leads-pill mxch-leads-pill-deleted" title="Chat was deleted by an admin">Chat deleted</span>`;
1704 } else {
1705 countCell = `<span class="mxch-leads-pill">${r.conversation_count}</span>`;
1706 }
1707 const lastCell = escapeHtmlLeads(r.last_seen_display || (isOrphan ? 'No conversation yet' : ''));
1708 const pageCell = r.top_page_url
1709 ? `<a href="${escapeHtmlLeads(r.top_page_url)}" target="_blank" rel="noopener" class="mxch-leads-page-link" title="${escapeHtmlLeads(r.top_page_url)}">${escapeHtmlLeads(r.top_page_title || r.top_page_url)}</a>`
1710 : '<span class="mxch-leads-muted">—</span>';
1711 // Consent (b062c4): 'yes'/'no' are recorded decisions; '' means the
1712 // capture predates the checkbox — "Not recorded", never "No".
1713 let consentCell;
1714 if (r.consent === 'yes' || r.consent === 'no') {
1715 const consentTitle = (r.consent_at ? 'Recorded ' + r.consent_at : '') +
1716 (r.consent_label ? (r.consent_at ? ' — ' : '') + '"' + r.consent_label + '"' : '');
1717 consentCell = `<span class="mxch-leads-pill${r.consent === 'yes' ? ' mxch-leads-pill-consent-yes' : ' mxch-leads-pill-consent-no'}" title="${escapeHtmlLeads(consentTitle)}">${r.consent === 'yes' ? 'Yes' : 'No'}</span>`;
1718 } else {
1719 consentCell = '<span class="mxch-leads-muted" title="Captured before the consent checkbox was enabled">Not recorded</span>';
1720 }
1721 // View Convo only for active leads (orphans and chat_deleted have no viewable session).
1722 const viewBtn = (status === 'active' && r.latest_session_id)
1723 ? `<button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm mxch-leads-view" data-session-id="${escapeHtmlLeads(r.latest_session_id)}" title="View latest conversation">
1724 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
1725 <span>View convo</span>
1726 </button>`
1727 : '';
1728 const deleteBtn = `<button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm mxch-btn-danger-ghost mxch-leads-delete-row" data-email="${escapeHtmlLeads(emailKey)}" title="Delete lead">
1729 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
1730 </button>`;
1731
1732 const rowStateClass = isOrphan ? ' is-orphan' : (isChatDeleted ? ' is-chat-deleted' : '');
1733 html += `
1734 <tr class="mxch-leads-row${rowStateClass}" data-email="${escapeHtmlLeads(emailKey)}">
1735 <td class="mxch-leads-col-check"><input type="checkbox" class="mxch-leads-rowcheck"${isChecked}></td>
1736 <td class="mxch-leads-col-lead">${leadCell}</td>
1737 <td class="mxch-leads-col-count">${countCell}</td>
1738 <td class="mxch-leads-col-last">${lastCell}</td>
1739 <td class="mxch-leads-col-page">${pageCell}</td>
1740 <td class="mxch-leads-col-consent">${consentCell}</td>
1741 <td class="mxch-leads-col-actions">${viewBtn}${deleteBtn}</td>
1742 </tr>
1743 `;
1744 });
1745
1746 $tbody.html(html);
1747 updateLeadsSelectionUI();
1748 }
1749
1750 function renderLeadsCount(start, end, total) {
1751 if (!total) {
1752 $('#mxch-leads-count').text('0 leads');
1753 } else {
1754 $('#mxch-leads-count').text(start + '-' + end + ' / ' + total + ' leads');
1755 }
1756 }
1757
1758 function renderLeadsPagination(currentPage, totalPages) {
1759 const $c = $('#mxch-leads-pagination');
1760 if (!totalPages || totalPages <= 1) { $c.html(''); return; }
1761 let html = '<div class="mxch-pagination-btns">';
1762 if (currentPage > 1) {
1763 html += `<button class="mxch-page-btn" data-page="${currentPage - 1}">&laquo;</button>`;
1764 }
1765 html += `<span class="mxch-page-info">${currentPage} / ${totalPages}</span>`;
1766 if (currentPage < totalPages) {
1767 html += `<button class="mxch-page-btn" data-page="${currentPage + 1}">&raquo;</button>`;
1768 }
1769 html += '</div>';
1770 $c.html(html);
1771 }
1772
1773 function updateLeadsSelectionUI() {
1774 const count = leadsState.selected.size;
1775 const $countEl = $('#mxch-leads-selected-count');
1776 const $del = $('#mxch-leads-delete-selected');
1777 if (count > 0) {
1778 $countEl.text(count + ' selected').addClass('has-selection');
1779 $del.prop('disabled', false);
1780 } else {
1781 $countEl.text('0').removeClass('has-selection');
1782 $del.prop('disabled', true);
1783 }
1784 // Selected-scope export menu items
1785 $('#mxch-leads-export-menu button[data-scope="selected"]').prop('disabled', count === 0);
1786
1787 // Select-all checkbox state
1788 const $checks = $('.mxch-leads-rowcheck');
1789 const checked = $checks.filter(':checked').length;
1790 const total = $checks.length;
1791 $('#mxch-leads-select-all').prop('checked', total > 0 && checked === total);
1792 $('#mxch-leads-select-all').prop('indeterminate', checked > 0 && checked < total);
1793 }
1794
1795 // Trigger leads load when switching to the tab (works alongside the main nav handler above).
1796 $('.mxch-nav-link[data-target="leads"], .mxch-mobile-nav-link[data-target="leads"]').on('click', function() {
1797 if (!leadsState.loaded) {
1798 loadLeads(1);
1799 }
1800 });
1801
1802 // Filter: search (debounced)
1803 let leadsSearchTimer;
1804 $('#mxch-leads-search').on('input', function() {
1805 clearTimeout(leadsSearchTimer);
1806 const val = $(this).val();
1807 leadsSearchTimer = setTimeout(function() {
1808 leadsState.filters.search = (val || '').trim();
1809 updateClearFiltersButton();
1810 loadLeads(1);
1811 }, 300);
1812 });
1813
1814 // Filter: date range
1815 $('#mxch-leads-date-range').on('change', function() {
1816 leadsState.filters.dateRange = $(this).val();
1817 updateClearFiltersButton();
1818 loadLeads(1);
1819 });
1820
1821 // Filter: status
1822 $('#mxch-leads-status').on('change', function() {
1823 leadsState.filters.status = $(this).val();
1824 updateClearFiltersButton();
1825 loadLeads(1);
1826 });
1827
1828 // Clear filters
1829 $('#mxch-leads-clear-filters').on('click', function() {
1830 leadsState.filters = { search: '', dateRange: 'all', status: 'all', pageUrl: '', pageTitle: '' };
1831 $('#mxch-leads-search').val('');
1832 $('#mxch-leads-date-range').val('all');
1833 $('#mxch-leads-status').val('all');
1834 setPageFilterChip('', '');
1835 loadLeads(1);
1836 });
1837
1838 // Remove page chip
1839 $leads().on('click', '.mxch-leads-page-chip-remove', function() {
1840 setPageFilterChip('', '');
1841 loadLeads(1);
1842 });
1843
1844 // Top Pages click -> set filter
1845 $leads().on('click', '.mxch-leads-toppage-row', function() {
1846 const url = $(this).data('url') || '';
1847 const title = $(this).data('title') || '';
1848 setPageFilterChip(url, title);
1849 loadLeads(1);
1850 });
1851
1852 // Pagination click
1853 $leads().on('click', '#mxch-leads-pagination .mxch-page-btn', function() {
1854 const p = parseInt($(this).data('page'), 10);
1855 if (p > 0) loadLeads(p);
1856 });
1857
1858 // Select-all
1859 $('#mxch-leads-select-all').on('change', function() {
1860 const on = $(this).is(':checked');
1861 $('.mxch-leads-rowcheck').prop('checked', on);
1862 $('.mxch-leads-row').each(function() {
1863 const email = normalizeLeadKey($(this).data('email'));
1864 if (on) {
1865 leadsState.selected.add(email);
1866 } else {
1867 leadsState.selected.delete(email);
1868 }
1869 });
1870 updateLeadsSelectionUI();
1871 });
1872
1873 // Row checkbox
1874 $leads().on('change', '.mxch-leads-rowcheck', function() {
1875 const email = normalizeLeadKey($(this).closest('.mxch-leads-row').data('email'));
1876 if ($(this).is(':checked')) {
1877 leadsState.selected.add(email);
1878 } else {
1879 leadsState.selected.delete(email);
1880 }
1881 updateLeadsSelectionUI();
1882 });
1883
1884 // View convo -> jump to All Chats tab and open the session
1885 $leads().on('click', '.mxch-leads-view', function() {
1886 const sid = $(this).attr('data-session-id');
1887 if (!sid) return;
1888 $('.mxch-nav-link[data-target="all-chats"]').trigger('click');
1889 // selectChat is defined earlier in this closure
1890 if (typeof selectChat === 'function') {
1891 setTimeout(function() { selectChat(sid); }, 30);
1892 }
1893 });
1894
1895 // Row delete -> confirm for one
1896 $leads().on('click', '.mxch-leads-delete-row', function() {
1897 const email = $(this).data('email');
1898 if (!email) return;
1899 openLeadsConfirm([String(email)]);
1900 });
1901
1902 // Bulk delete -> confirm for N
1903 $('#mxch-leads-delete-selected').on('click', function() {
1904 if (leadsState.selected.size === 0) return;
1905 openLeadsConfirm(Array.from(leadsState.selected));
1906 });
1907
1908 function openLeadsConfirm(emails) {
1909 leadsState.pendingDelete = emails;
1910 const count = emails.length;
1911 const msg = count === 1
1912 ? 'Delete lead "' + emails[0] + '" and all of their conversations?'
1913 : 'Delete ' + count + ' leads and all of their conversations?';
1914 $('#mxch-leads-confirm-body').text(msg);
1915 $('#mxch-leads-confirm').fadeIn(120);
1916 }
1917
1918 function closeLeadsConfirm() {
1919 $('#mxch-leads-confirm').fadeOut(120);
1920 leadsState.pendingDelete = [];
1921 }
1922
1923 $leads().on('click', '[data-mxch-leads-close]', closeLeadsConfirm);
1924
1925 $('#mxch-leads-confirm-go').on('click', function() {
1926 const emails = leadsState.pendingDelete.slice();
1927 if (!emails.length) { closeLeadsConfirm(); return; }
1928
1929 const $btn = $(this).prop('disabled', true).text('Deleting...');
1930
1931 $.ajax({
1932 url: ajaxurl,
1933 type: 'POST',
1934 data: {
1935 action: 'mxchat_delete_leads',
1936 security: $('#mxchat_leads_delete_nonce').val(),
1937 emails: emails
1938 },
1939 success: function(response) {
1940 $btn.prop('disabled', false).text('Delete permanently');
1941 closeLeadsConfirm();
1942 if (response && response.success) {
1943 emails.forEach(function(e) { leadsState.selected.delete(normalizeLeadKey(e)); });
1944 loadLeads(leadsState.page);
1945 } else {
1946 alert((response && response.data && response.data.message) || 'Failed to delete leads.');
1947 }
1948 },
1949 error: function() {
1950 $btn.prop('disabled', false).text('Delete permanently');
1951 alert('Network error while deleting.');
1952 }
1953 });
1954 });
1955
1956 // Export dropdown
1957 $('#mxch-leads-export-btn').on('click', function(e) {
1958 e.stopPropagation();
1959 $('#mxch-leads-export-menu').toggleClass('is-open');
1960 });
1961
1962 $(document).on('click', function() {
1963 $('#mxch-leads-export-menu').removeClass('is-open');
1964 });
1965
1966 $('#mxch-leads-export-menu').on('click', function(e) { e.stopPropagation(); });
1967
1968 $('#mxch-leads-export-menu button').on('click', function() {
1969 if ($(this).prop('disabled')) return;
1970 const scope = $(this).data('scope') || 'all';
1971 const fields = $(this).data('fields') || 'email_and_name';
1972 submitLeadsExport(scope, fields);
1973 $('#mxch-leads-export-menu').removeClass('is-open');
1974 });
1975
1976 function submitLeadsExport(scope, fields) {
1977 const $form = $('<form>', { method: 'POST', action: ajaxurl, style: 'display:none;' });
1978 $form.append($('<input>', { type: 'hidden', name: 'action', value: 'mxchat_export_leads' }));
1979 $form.append($('<input>', { type: 'hidden', name: 'security', value: $('#mxchat_leads_export_nonce').val() }));
1980 $form.append($('<input>', { type: 'hidden', name: 'scope', value: scope }));
1981 $form.append($('<input>', { type: 'hidden', name: 'fields', value: fields }));
1982 if (scope === 'selected') {
1983 Array.from(leadsState.selected).forEach(function(e) {
1984 $form.append($('<input>', { type: 'hidden', name: 'emails[]', value: e }));
1985 });
1986 }
1987 $form.appendTo('body').submit().remove();
1988 }
1989
1990 // Preload leads metadata on page load (for the nav badge count only) without rendering.
1991 // We keep this light — the full fetch only runs when the tab is clicked.
1992 $.ajax({
1993 url: ajaxurl,
1994 type: 'POST',
1995 data: { action: 'mxchat_fetch_leads', page: 1, per_page: 1 },
1996 success: function(response) {
1997 if (response && response.success && response.stats) {
1998 const total = response.stats.total_leads || 0;
1999 const $badge = $('#mxch-leads-nav-badge');
2000 if (total > 0) $badge.text(total).show();
2001 }
2002 }
2003 });
2004 });
2005