PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.19
MxChat – AI Chatbot & Content Generation for WordPress v3.2.19
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / js / mxchat_transcripts.js

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

1,953 lines 82.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 // Check if we have any source data
989 if (!data.top_matches || data.top_matches.length === 0) {
990 html += '<div class="mxch-no-results"><p>No document matches found for this response.</p></div>';
991 $container.html(html);
992 return;
993 }
994
995 // Hybrid keyword boost (38ffa1): rows carry matched_via + fused_rank
996 // when hybrid retrieval was on for this response. Cosine % stays the
997 // anchor; the chip explains WHY a low-% row ranked high.
998 const hybridOn = data.top_matches.some(function(m) { return m.matched_via; });
999
1000 html += '<div class="mxch-rag-summary">';
1001 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>';
1002 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>';
1003 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>';
1004 if (hybridOn) {
1005 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Retrieval:</span> <span class="mxch-rag-value">Hybrid</span></div>';
1006 }
1007 html += '</div>';
1008
1009 const groupedByUrl = {};
1010
1011 data.top_matches.forEach(function(match) {
1012 const url = match.source_display || 'Unknown';
1013 if (!groupedByUrl[url]) {
1014 groupedByUrl[url] = {
1015 url: url,
1016 isUrl: url.startsWith('http'),
1017 bestScore: 0,
1018 usedForContext: false,
1019 matchedChunks: [],
1020 bestFusedRank: Infinity,
1021 viaSet: {}
1022 };
1023 }
1024
1025 if (match.similarity_percentage > groupedByUrl[url].bestScore) {
1026 groupedByUrl[url].bestScore = match.similarity_percentage;
1027 }
1028
1029 if (match.used_for_context) {
1030 groupedByUrl[url].usedForContext = true;
1031 }
1032
1033 if (match.fused_rank && match.fused_rank < groupedByUrl[url].bestFusedRank) {
1034 groupedByUrl[url].bestFusedRank = match.fused_rank;
1035 }
1036 if (match.matched_via) {
1037 groupedByUrl[url].viaSet[match.matched_via] = true;
1038 }
1039
1040 groupedByUrl[url].matchedChunks.push({
1041 chunkIndex: match.chunk_index,
1042 score: match.similarity_percentage,
1043 usedForContext: match.used_for_context
1044 });
1045 });
1046
1047 // Hybrid on: order by fused rank (rows without one sort last);
1048 // otherwise by cosine, exactly as before.
1049 const urlGroups = Object.values(groupedByUrl).sort(function(a, b) {
1050 if (hybridOn && a.bestFusedRank !== b.bestFusedRank) {
1051 return a.bestFusedRank - b.bestFusedRank;
1052 }
1053 return b.bestScore - a.bestScore;
1054 });
1055 const usedUrlCount = data.sources_used > 0 ? data.sources_used : urlGroups.filter(g => g.usedForContext).length;
1056 const chunksInfo = data.total_chunks_used > 0 ? data.total_chunks_used + ' chunks sent to AI' : '';
1057
1058 html += '<div class="mxch-rag-matches">';
1059 html += '<h3>Retrieved Documents</h3>';
1060 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>';
1061
1062 urlGroups.forEach(function(group) {
1063 const cardClass = group.usedForContext ? 'mxch-rag-match-used' : 'mxch-rag-match-below';
1064 const statusIcon = group.usedForContext ? '&#10003;' : '&#10007;';
1065 const statusLabel = group.usedForContext ? 'Used' : 'Not Used';
1066
1067 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
1068 html += '<div class="mxch-rag-match-header">';
1069 html += '<span class="mxch-rag-match-score">' + group.bestScore + '%</span>';
1070
1071 if (hybridOn) {
1072 const vias = Object.keys(group.viaSet);
1073 if (vias.length) {
1074 const viaLabel = (vias.length > 1 || vias[0] === 'both') ? 'Both'
1075 : (vias[0] === 'keyword' ? 'Keyword' : 'Vector');
1076 html += '<span class="mxch-rag-via-chip mxch-rag-via-' + viaLabel.toLowerCase() + '">' + viaLabel + '</span>';
1077 }
1078 }
1079
1080 if (group.matchedChunks.length > 1) {
1081 html += '<span class="mxch-rag-chunk-badge">' + group.matchedChunks.length + ' chunks</span>';
1082 }
1083
1084 html += '<span class="mxch-rag-match-status ' + (group.usedForContext ? 'status-used' : 'status-below') + '">' + statusIcon + ' ' + statusLabel + '</span>';
1085 html += '</div>';
1086
1087 html += '<div class="mxch-rag-match-source">';
1088 if (group.isUrl) {
1089 html += '<a href="' + escapeHtml(group.url) + '" target="_blank">' + escapeHtml(group.url) + '</a>';
1090 } else {
1091 html += escapeHtml(group.url);
1092 }
1093 html += '</div>';
1094 html += '</div>';
1095 });
1096
1097 html += '</div>';
1098 $container.html(html);
1099 }
1100
1101 // AI Tools trace (470f68): one card per tool EXECUTION for this message.
1102 // Rendered ABOVE the trigger-phrase scores — a tool that actually ran
1103 // outranks a phrase that merely scored.
1104 function renderToolCalls(toolCalls) {
1105 const failed = toolCalls.filter(function(t) { return !t.ok; }).length;
1106
1107 let html = '<div class="mxch-rag-summary">';
1108 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Tools Used:</span> <span class="mxch-rag-value">' + toolCalls.length + '</span></div>';
1109 if (failed > 0) {
1110 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>';
1111 }
1112 html += '</div>';
1113
1114 html += '<div class="mxch-rag-matches">';
1115 html += '<h3>AI Tools</h3>';
1116 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>';
1117
1118 toolCalls.forEach(function(tool) {
1119 const ok = !!tool.ok;
1120 const cardClass = ok ? 'mxch-rag-match-used' : 'mxch-tool-failed';
1121 const statusClass = ok ? 'status-used' : 'status-failed';
1122 const statusIcon = ok ? '&#10003;' : '&#10007;';
1123 const statusLabel = ok ? 'Ran' : 'Failed';
1124
1125 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
1126 html += '<div class="mxch-rag-match-header">';
1127 html += '<span class="mxch-rag-match-score">&#9889;</span>';
1128 if (typeof tool.ms === 'number') {
1129 html += '<span class="mxch-tool-duration">' + tool.ms + ' ms</span>';
1130 }
1131 html += '<span class="mxch-rag-match-status ' + statusClass + '">' + statusIcon + ' ' + statusLabel + '</span>';
1132 html += '</div>';
1133
1134 html += '<div class="mxch-action-details">';
1135 html += '<div class="mxch-action-label">' + escapeHtml(tool.label || tool.name) + '</div>';
1136 html += '<div class="mxch-action-callback"><span class="mxch-action-callback-label">Tool:</span> ' + escapeHtml(tool.name) + '</div>';
1137 html += '</div>';
1138
1139 if (!ok && tool.error) {
1140 html += '<div class="mxch-tool-error">' + escapeHtml(tool.error) + '</div>';
1141 }
1142
1143 if (tool.args_redacted === 'sensitive') {
1144 html += '<div class="mxch-tool-redacted">Arguments not recorded &mdash; this tool is marked sensitive.</div>';
1145 } else if (tool.args_excerpt) {
1146 html += '<details class="mxch-tool-args"><summary>Arguments</summary>';
1147 html += '<div class="mxch-tool-args-body">' + escapeHtml(tool.args_excerpt) + '</div></details>';
1148 }
1149
1150 html += '</div>';
1151 });
1152
1153 html += '</div>';
1154 return html;
1155 }
1156
1157 function renderActionsContext(data, $container) {
1158 let html = '';
1159
1160 const toolCalls = (data.tool_calls && data.tool_calls.length) ? data.tool_calls : [];
1161 const actions = (data.action_analysis && data.action_analysis.length) ? data.action_analysis : [];
1162
1163 // 95d79d's empty state now shows only when NEITHER mechanism produced
1164 // anything — the string itself is unchanged.
1165 if (toolCalls.length === 0 && actions.length === 0) {
1166 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>';
1167 $container.html(html);
1168 return;
1169 }
1170
1171 if (toolCalls.length > 0) {
1172 html += renderToolCalls(toolCalls);
1173 }
1174
1175 if (actions.length === 0) {
1176 $container.html(html);
1177 return;
1178 }
1179 const triggeredAction = actions.find(a => a.triggered);
1180 const actionsAboveThreshold = actions.filter(a => a.above_threshold).length;
1181
1182 // Summary section
1183 html += '<div class="mxch-rag-summary">';
1184 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Actions Evaluated:</span> <span class="mxch-rag-value">' + actions.length + '</span></div>';
1185 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Above Threshold:</span> <span class="mxch-rag-value">' + actionsAboveThreshold + '</span></div>';
1186 if (triggeredAction) {
1187 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>';
1188 }
1189 html += '</div>';
1190
1191 // Actions list
1192 html += '<div class="mxch-rag-matches">';
1193 html += '<h3>Action Scores</h3>';
1194 html += '<p style="color: var(--mxch-text-secondary); font-size: 13px; margin-bottom: 16px;">Showing all evaluated actions sorted by similarity score</p>';
1195
1196 actions.forEach(function(action) {
1197 let cardClass = 'mxch-rag-match-below';
1198 let statusIcon = '&#10007;';
1199 let statusLabel = 'Below Threshold';
1200
1201 if (action.triggered) {
1202 cardClass = 'mxch-action-triggered';
1203 statusIcon = '&#9889;';
1204 statusLabel = 'Triggered';
1205 } else if (action.above_threshold) {
1206 cardClass = 'mxch-rag-match-used';
1207 statusIcon = '&#10003;';
1208 statusLabel = 'Above Threshold';
1209 }
1210
1211 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
1212 html += '<div class="mxch-rag-match-header">';
1213 html += '<span class="mxch-rag-match-score">' + action.similarity_percentage + '%</span>';
1214 html += '<span class="mxch-action-threshold-badge">Threshold: ' + action.threshold_percentage + '%</span>';
1215 html += '<span class="mxch-rag-match-status ' + (action.triggered ? 'status-triggered' : (action.above_threshold ? 'status-used' : 'status-below')) + '">' + statusIcon + ' ' + statusLabel + '</span>';
1216 html += '</div>';
1217
1218 html += '<div class="mxch-action-details">';
1219 html += '<div class="mxch-action-label">' + escapeHtml(action.intent_label) + '</div>';
1220 html += '<div class="mxch-action-callback"><span class="mxch-action-callback-label">Callback:</span> ' + escapeHtml(action.callback_function) + '</div>';
1221 html += '</div>';
1222
1223 // Score bar visualization
1224 const scoreBarWidth = Math.min(action.similarity_percentage, 100);
1225 const thresholdPos = Math.min(action.threshold_percentage, 100);
1226 html += '<div class="mxch-action-score-bar">';
1227 html += '<div class="mxch-action-score-fill" style="width: ' + scoreBarWidth + '%;"></div>';
1228 html += '<div class="mxch-action-threshold-marker" style="left: ' + thresholdPos + '%;"></div>';
1229 html += '</div>';
1230
1231 html += '</div>';
1232 });
1233
1234 html += '</div>';
1235 $container.html(html);
1236 }
1237
1238 function escapeHtml(text) {
1239 if (!text) return '';
1240 const div = document.createElement('div');
1241 div.textContent = text;
1242 return div.innerHTML;
1243 }
1244
1245 // Close RAG modal
1246 $('.mxch-modal-close').on('click', function() {
1247 $(this).closest('.mxch-modal-overlay').fadeOut(200);
1248 });
1249
1250 $('.mxch-modal-overlay').on('click', function(e) {
1251 if ($(e.target).is('.mxch-modal-overlay')) {
1252 $(this).fadeOut(200);
1253 }
1254 });
1255
1256 $(document).on('keydown', function(e) {
1257 if (e.key === 'Escape') {
1258 $('.mxch-modal-overlay').fadeOut(200);
1259 }
1260 });
1261
1262 // ==========================================================================
1263 // Activity Chart
1264 // ==========================================================================
1265
1266 // Simple chart implementation (no external dependencies)
1267 class SimpleChart {
1268 constructor(canvas, config) {
1269 this.canvas = canvas;
1270 this.ctx = canvas.getContext('2d');
1271 this.config = config;
1272 this.padding = { top: 20, right: 20, bottom: 40, left: 50 };
1273 this.render();
1274 }
1275
1276 destroy() {
1277 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
1278 }
1279
1280 render() {
1281 const dpr = window.devicePixelRatio || 1;
1282 const rect = this.canvas.getBoundingClientRect();
1283
1284 this.canvas.width = rect.width * dpr;
1285 this.canvas.height = rect.height * dpr;
1286 this.ctx.scale(dpr, dpr);
1287
1288 this.canvas.style.width = rect.width + 'px';
1289 this.canvas.style.height = rect.height + 'px';
1290
1291 const width = rect.width - this.padding.left - this.padding.right;
1292 const height = rect.height - this.padding.top - this.padding.bottom;
1293
1294 // Find max value
1295 let maxValue = 0;
1296 this.config.datasets.forEach(dataset => {
1297 const max = Math.max(...dataset.data);
1298 if (max > maxValue) maxValue = max;
1299 });
1300
1301 // Add some padding to max value
1302 maxValue = Math.ceil(maxValue * 1.1);
1303 if (maxValue === 0) maxValue = 10;
1304
1305 // Draw grid lines
1306 this.ctx.strokeStyle = '#e5e7eb';
1307 this.ctx.lineWidth = 1;
1308 const gridLines = 5;
1309
1310 for (let i = 0; i <= gridLines; i++) {
1311 const y = this.padding.top + (height / gridLines) * i;
1312 this.ctx.beginPath();
1313 this.ctx.moveTo(this.padding.left, y);
1314 this.ctx.lineTo(this.padding.left + width, y);
1315 this.ctx.stroke();
1316
1317 // Draw y-axis labels
1318 const value = maxValue - (maxValue / gridLines) * i;
1319 this.ctx.fillStyle = '#6b7280';
1320 this.ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
1321 this.ctx.textAlign = 'right';
1322 this.ctx.fillText(Math.round(value), this.padding.left - 10, y + 4);
1323 }
1324
1325 // Draw datasets
1326 this.config.datasets.forEach(dataset => {
1327 const points = [];
1328 const xStep = width / (this.config.labels.length - 1 || 1);
1329
1330 dataset.data.forEach((value, index) => {
1331 const x = this.padding.left + (xStep * index);
1332 const y = this.padding.top + height - (value / maxValue * height);
1333 points.push({ x, y, value });
1334 });
1335
1336 // Draw filled area
1337 if (dataset.fill && dataset.backgroundColor) {
1338 this.ctx.fillStyle = dataset.backgroundColor;
1339 this.ctx.beginPath();
1340 this.ctx.moveTo(points[0].x, this.padding.top + height);
1341 points.forEach(point => {
1342 this.ctx.lineTo(point.x, point.y);
1343 });
1344 this.ctx.lineTo(points[points.length - 1].x, this.padding.top + height);
1345 this.ctx.closePath();
1346 this.ctx.fill();
1347 }
1348
1349 // Draw line
1350 this.ctx.strokeStyle = dataset.borderColor;
1351 this.ctx.lineWidth = 3;
1352 this.ctx.lineCap = 'round';
1353 this.ctx.lineJoin = 'round';
1354
1355 this.ctx.beginPath();
1356 points.forEach((point, index) => {
1357 if (index === 0) {
1358 this.ctx.moveTo(point.x, point.y);
1359 } else {
1360 this.ctx.lineTo(point.x, point.y);
1361 }
1362 });
1363 this.ctx.stroke();
1364
1365 // Draw points
1366 points.forEach(point => {
1367 this.ctx.fillStyle = '#ffffff';
1368 this.ctx.beginPath();
1369 this.ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
1370 this.ctx.fill();
1371 this.ctx.strokeStyle = dataset.borderColor;
1372 this.ctx.lineWidth = 2;
1373 this.ctx.stroke();
1374 });
1375 });
1376
1377 // Draw x-axis labels
1378 const xStep = width / (this.config.labels.length - 1 || 1);
1379 this.ctx.fillStyle = '#6b7280';
1380 this.ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
1381 this.ctx.textAlign = 'center';
1382
1383 this.config.labels.forEach((label, index) => {
1384 const x = this.padding.left + (xStep * index);
1385 this.ctx.fillText(label, x, this.padding.top + height + 20);
1386 });
1387 }
1388 }
1389
1390 // Initialize activity chart
1391 function initActivityChart() {
1392 console.log('[MxChat Chart] initActivityChart called');
1393
1394 const canvas = document.getElementById('mxchat-activity-chart');
1395 console.log('[MxChat Chart] Canvas element:', canvas);
1396
1397 if (!canvas) {
1398 console.log('[MxChat Chart] Canvas not found, aborting');
1399 return;
1400 }
1401
1402 console.log('[MxChat Chart] mxchatChartData exists:', typeof mxchatChartData !== 'undefined');
1403 if (typeof mxchatChartData === 'undefined') {
1404 console.log('[MxChat Chart] mxchatChartData is undefined, aborting');
1405 return;
1406 }
1407
1408 console.log('[MxChat Chart] Raw mxchatChartData:', mxchatChartData);
1409
1410 // Check if chart already exists and destroy it
1411 if (canvas.chartInstance) {
1412 canvas.chartInstance.destroy();
1413 }
1414
1415 const ctx = canvas.getContext('2d');
1416 console.log('[MxChat Chart] Canvas context:', ctx);
1417 console.log('[MxChat Chart] Canvas dimensions:', canvas.getBoundingClientRect());
1418
1419 // Create gradient for chats line
1420 const chatsGradient = ctx.createLinearGradient(0, 0, 0, 300);
1421 chatsGradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
1422 chatsGradient.addColorStop(1, 'rgba(102, 126, 234, 0.05)');
1423
1424 // Create gradient for messages line
1425 const messagesGradient = ctx.createLinearGradient(0, 0, 0, 300);
1426 messagesGradient.addColorStop(0, 'rgba(118, 75, 162, 0.3)');
1427 messagesGradient.addColorStop(1, 'rgba(118, 75, 162, 0.05)');
1428
1429 // Convert wp_localize_script objects to arrays (WordPress converts indexed arrays to objects)
1430 const labels = Object.values(mxchatChartData.labels);
1431 const chatsData = Object.values(mxchatChartData.chats).map(Number);
1432 const messagesData = Object.values(mxchatChartData.messages).map(Number);
1433
1434 console.log('[MxChat Chart] Processed labels:', labels);
1435 console.log('[MxChat Chart] Processed chatsData:', chatsData);
1436 console.log('[MxChat Chart] Processed messagesData:', messagesData);
1437
1438 // Create chart
1439 try {
1440 canvas.chartInstance = new SimpleChart(canvas, {
1441 labels: labels,
1442 datasets: [
1443 {
1444 label: 'Chats',
1445 data: chatsData,
1446 borderColor: '#667eea',
1447 backgroundColor: chatsGradient,
1448 fill: true
1449 },
1450 {
1451 label: 'Messages',
1452 data: messagesData,
1453 borderColor: '#764ba2',
1454 backgroundColor: messagesGradient,
1455 fill: true
1456 }
1457 ]
1458 });
1459 console.log('[MxChat Chart] Chart created successfully');
1460 } catch (error) {
1461 console.error('[MxChat Chart] Error creating chart:', error);
1462 }
1463 }
1464
1465 // Initialize chart on page load (dashboard is shown by default)
1466 setTimeout(function() {
1467 initActivityChart();
1468 }, 100);
1469
1470 // Reinitialize chart on window resize
1471 let resizeTimeout;
1472 $(window).on('resize', function() {
1473 clearTimeout(resizeTimeout);
1474 resizeTimeout = setTimeout(function() {
1475 initActivityChart();
1476 }, 250);
1477 });
1478
1479 // ==========================================================================
1480 // Leads Tab
1481 // ==========================================================================
1482
1483 const leadsState = {
1484 loaded: false,
1485 page: 1,
1486 perPage: 25,
1487 totalPages: 1,
1488 totalCount: 0,
1489 selected: new Set(),
1490 filters: {
1491 search: '',
1492 dateRange: 'all',
1493 status: 'all',
1494 pageUrl: '',
1495 pageTitle: ''
1496 },
1497 pendingDelete: [],
1498 leadsRows: [] // last-rendered rows for quick lookup
1499 };
1500
1501 function $leads() { return $('#leads'); }
1502
1503 // Called after a transcript delete from the All Chats side. Marks the Leads tab
1504 // data stale so the next tab visit re-fetches, and refreshes immediately if the
1505 // Leads tab happens to already be visible.
1506 function invalidateLeadsData() {
1507 leadsState.loaded = false;
1508 if ($('#leads').hasClass('active')) {
1509 loadLeads(1);
1510 }
1511 }
1512
1513 function escapeHtmlLeads(s) {
1514 if (s === null || typeof s === 'undefined') return '';
1515 return String(s)
1516 .replace(/&/g, '&amp;')
1517 .replace(/</g, '&lt;')
1518 .replace(/>/g, '&gt;')
1519 .replace(/"/g, '&quot;')
1520 .replace(/'/g, '&#039;');
1521 }
1522
1523 function leadsFiltersActive() {
1524 const f = leadsState.filters;
1525 return f.search !== '' || f.dateRange !== 'all' || f.status !== 'all' || f.pageUrl !== '';
1526 }
1527
1528 function updateClearFiltersButton() {
1529 if (leadsFiltersActive()) {
1530 $('#mxch-leads-clear-filters').show();
1531 } else {
1532 $('#mxch-leads-clear-filters').hide();
1533 }
1534 }
1535
1536 function setPageFilterChip(url, title) {
1537 leadsState.filters.pageUrl = url || '';
1538 leadsState.filters.pageTitle = title || url || '';
1539 const $chip = $('#mxch-leads-active-page-filter');
1540 if (url) {
1541 $chip.find('.mxch-leads-page-chip-label').text('Page: ' + (title || url));
1542 $chip.show();
1543 } else {
1544 $chip.hide();
1545 }
1546 updateClearFiltersButton();
1547 }
1548
1549 function loadLeads(page) {
1550 if (typeof page === 'number') leadsState.page = page;
1551
1552 const $tbody = $('#mxch-leads-tbody');
1553 $tbody.html('<tr><td colspan="6" class="mxch-leads-loading"><span class="spinner is-active"></span></td></tr>');
1554
1555 $.ajax({
1556 url: ajaxurl,
1557 type: 'POST',
1558 data: {
1559 action: 'mxchat_fetch_leads',
1560 page: leadsState.page,
1561 per_page: leadsState.perPage,
1562 search: leadsState.filters.search,
1563 date_range: leadsState.filters.dateRange,
1564 status: leadsState.filters.status,
1565 page_url: leadsState.filters.pageUrl
1566 },
1567 success: function(response) {
1568 leadsState.loaded = true;
1569 if (!response || !response.success) {
1570 $tbody.html('<tr><td colspan="7" class="mxch-leads-empty">Error loading leads</td></tr>');
1571 return;
1572 }
1573 leadsState.totalPages = response.total_pages || 1;
1574 leadsState.totalCount = response.total_count || 0;
1575 leadsState.leadsRows = response.leads || [];
1576
1577 renderLeadsStats(response.stats || {});
1578 renderLeadsTopPages(response.top_pages || []);
1579 renderLeadsTable(response.leads || []);
1580 renderLeadsCount(response.showing_start, response.showing_end, response.total_count);
1581 renderLeadsPagination(response.page, response.total_pages);
1582
1583 // Nav badge
1584 if (response.stats && typeof response.stats.total_leads === 'number') {
1585 const $badge = $('#mxch-leads-nav-badge');
1586 if (response.stats.total_leads > 0) {
1587 $badge.text(response.stats.total_leads).show();
1588 } else {
1589 $badge.hide();
1590 }
1591 }
1592 },
1593 error: function() {
1594 $tbody.html('<tr><td colspan="7" class="mxch-leads-empty">Error loading leads</td></tr>');
1595 }
1596 });
1597 }
1598
1599 function renderLeadsStats(stats) {
1600 $('#mxch-leads-stat-total').text(stats.total_leads || 0);
1601 $('#mxch-leads-stat-new').text(stats.new_this_week || 0);
1602 $('#mxch-leads-stat-avg').text(stats.avg_convos || 0);
1603 const pct = stats.orphan_pct || 0;
1604 $('#mxch-leads-stat-orphan').text(pct + '%');
1605 const orphanCount = stats.orphan_count || 0;
1606 $('#mxch-leads-stat-orphan-sub').text(orphanCount + (orphanCount === 1 ? ' lead captured but never chatted' : ' leads captured but never chatted'));
1607 }
1608
1609 function renderLeadsTopPages(pages) {
1610 const $wrap = $('#mxch-leads-toppages-list');
1611 if (!pages || pages.length === 0) {
1612 $wrap.html('<div class="mxch-leads-empty-mini">No page data yet.</div>');
1613 return;
1614 }
1615 let html = '';
1616 pages.forEach(function(p) {
1617 const isActive = leadsState.filters.pageUrl === p.url ? ' is-active' : '';
1618 html += `
1619 <button type="button" class="mxch-leads-toppage-row${isActive}" data-url="${escapeHtmlLeads(p.url)}" data-title="${escapeHtmlLeads(p.title)}">
1620 <span class="mxch-leads-toppage-title">${escapeHtmlLeads(p.title || p.url)}</span>
1621 <span class="mxch-leads-toppage-count">${p.lead_count}</span>
1622 </button>
1623 `;
1624 });
1625 $wrap.html(html);
1626 }
1627
1628 function renderLeadsTable(rows) {
1629 const $tbody = $('#mxch-leads-tbody');
1630 if (!rows || rows.length === 0) {
1631 $tbody.html(`
1632 <tr><td colspan="6" class="mxch-leads-empty">
1633 <div class="mxch-leads-empty-wrap">
1634 <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>
1635 <p>No leads match the current filters.</p>
1636 </div>
1637 </td></tr>
1638 `);
1639 return;
1640 }
1641
1642 let html = '';
1643 rows.forEach(function(r) {
1644 const emailKey = (r.email || '').toLowerCase();
1645 const isChecked = leadsState.selected.has(emailKey) ? ' checked' : '';
1646 // Status: 'active' (has conversations), 'chat_deleted' (admin removed the chat), 'orphan' (no chat ever).
1647 const status = r.status || (r.is_orphan ? 'orphan' : 'active');
1648 const isOrphan = (status === 'orphan');
1649 const isChatDeleted = (status === 'chat_deleted');
1650 const nameLine = r.name
1651 ? `<span class="mxch-leads-lead-name">${escapeHtmlLeads(r.name)}</span>`
1652 : '';
1653 const leadCell = `
1654 <div class="mxch-leads-lead-cell">
1655 <span class="mxch-leads-lead-email" title="${escapeHtmlLeads(r.email)}">${escapeHtmlLeads(r.email)}</span>
1656 ${nameLine}
1657 </div>`;
1658 let countCell;
1659 if (isOrphan) {
1660 countCell = `<span class="mxch-leads-pill mxch-leads-pill-orphan">Orphan</span>`;
1661 } else if (isChatDeleted) {
1662 countCell = `<span class="mxch-leads-pill mxch-leads-pill-deleted" title="Chat was deleted by an admin">Chat deleted</span>`;
1663 } else {
1664 countCell = `<span class="mxch-leads-pill">${r.conversation_count}</span>`;
1665 }
1666 const lastCell = escapeHtmlLeads(r.last_seen_display || (isOrphan ? 'No conversation yet' : ''));
1667 const pageCell = r.top_page_url
1668 ? `<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>`
1669 : '<span class="mxch-leads-muted">—</span>';
1670 // View Convo only for active leads (orphans and chat_deleted have no viewable session).
1671 const viewBtn = (status === 'active' && r.latest_session_id)
1672 ? `<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">
1673 <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>
1674 <span>View convo</span>
1675 </button>`
1676 : '';
1677 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(r.email)}" title="Delete lead">
1678 <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>
1679 </button>`;
1680
1681 const rowStateClass = isOrphan ? ' is-orphan' : (isChatDeleted ? ' is-chat-deleted' : '');
1682 html += `
1683 <tr class="mxch-leads-row${rowStateClass}" data-email="${escapeHtmlLeads(r.email)}">
1684 <td class="mxch-leads-col-check"><input type="checkbox" class="mxch-leads-rowcheck"${isChecked}></td>
1685 <td class="mxch-leads-col-lead">${leadCell}</td>
1686 <td class="mxch-leads-col-count">${countCell}</td>
1687 <td class="mxch-leads-col-last">${lastCell}</td>
1688 <td class="mxch-leads-col-page">${pageCell}</td>
1689 <td class="mxch-leads-col-actions">${viewBtn}${deleteBtn}</td>
1690 </tr>
1691 `;
1692 });
1693
1694 $tbody.html(html);
1695 updateLeadsSelectionUI();
1696 }
1697
1698 function renderLeadsCount(start, end, total) {
1699 if (!total) {
1700 $('#mxch-leads-count').text('0 leads');
1701 } else {
1702 $('#mxch-leads-count').text(start + '-' + end + ' / ' + total + ' leads');
1703 }
1704 }
1705
1706 function renderLeadsPagination(currentPage, totalPages) {
1707 const $c = $('#mxch-leads-pagination');
1708 if (!totalPages || totalPages <= 1) { $c.html(''); return; }
1709 let html = '<div class="mxch-pagination-btns">';
1710 if (currentPage > 1) {
1711 html += `<button class="mxch-page-btn" data-page="${currentPage - 1}">&laquo;</button>`;
1712 }
1713 html += `<span class="mxch-page-info">${currentPage} / ${totalPages}</span>`;
1714 if (currentPage < totalPages) {
1715 html += `<button class="mxch-page-btn" data-page="${currentPage + 1}">&raquo;</button>`;
1716 }
1717 html += '</div>';
1718 $c.html(html);
1719 }
1720
1721 function updateLeadsSelectionUI() {
1722 const count = leadsState.selected.size;
1723 const $countEl = $('#mxch-leads-selected-count');
1724 const $del = $('#mxch-leads-delete-selected');
1725 if (count > 0) {
1726 $countEl.text(count + ' selected').addClass('has-selection');
1727 $del.prop('disabled', false);
1728 } else {
1729 $countEl.text('0').removeClass('has-selection');
1730 $del.prop('disabled', true);
1731 }
1732 // Selected-scope export menu items
1733 $('#mxch-leads-export-menu button[data-scope="selected"]').prop('disabled', count === 0);
1734
1735 // Select-all checkbox state
1736 const $checks = $('.mxch-leads-rowcheck');
1737 const checked = $checks.filter(':checked').length;
1738 const total = $checks.length;
1739 $('#mxch-leads-select-all').prop('checked', total > 0 && checked === total);
1740 $('#mxch-leads-select-all').prop('indeterminate', checked > 0 && checked < total);
1741 }
1742
1743 // Trigger leads load when switching to the tab (works alongside the main nav handler above).
1744 $('.mxch-nav-link[data-target="leads"], .mxch-mobile-nav-link[data-target="leads"]').on('click', function() {
1745 if (!leadsState.loaded) {
1746 loadLeads(1);
1747 }
1748 });
1749
1750 // Filter: search (debounced)
1751 let leadsSearchTimer;
1752 $('#mxch-leads-search').on('input', function() {
1753 clearTimeout(leadsSearchTimer);
1754 const val = $(this).val();
1755 leadsSearchTimer = setTimeout(function() {
1756 leadsState.filters.search = (val || '').trim();
1757 updateClearFiltersButton();
1758 loadLeads(1);
1759 }, 300);
1760 });
1761
1762 // Filter: date range
1763 $('#mxch-leads-date-range').on('change', function() {
1764 leadsState.filters.dateRange = $(this).val();
1765 updateClearFiltersButton();
1766 loadLeads(1);
1767 });
1768
1769 // Filter: status
1770 $('#mxch-leads-status').on('change', function() {
1771 leadsState.filters.status = $(this).val();
1772 updateClearFiltersButton();
1773 loadLeads(1);
1774 });
1775
1776 // Clear filters
1777 $('#mxch-leads-clear-filters').on('click', function() {
1778 leadsState.filters = { search: '', dateRange: 'all', status: 'all', pageUrl: '', pageTitle: '' };
1779 $('#mxch-leads-search').val('');
1780 $('#mxch-leads-date-range').val('all');
1781 $('#mxch-leads-status').val('all');
1782 setPageFilterChip('', '');
1783 loadLeads(1);
1784 });
1785
1786 // Remove page chip
1787 $leads().on('click', '.mxch-leads-page-chip-remove', function() {
1788 setPageFilterChip('', '');
1789 loadLeads(1);
1790 });
1791
1792 // Top Pages click -> set filter
1793 $leads().on('click', '.mxch-leads-toppage-row', function() {
1794 const url = $(this).data('url') || '';
1795 const title = $(this).data('title') || '';
1796 setPageFilterChip(url, title);
1797 loadLeads(1);
1798 });
1799
1800 // Pagination click
1801 $leads().on('click', '#mxch-leads-pagination .mxch-page-btn', function() {
1802 const p = parseInt($(this).data('page'), 10);
1803 if (p > 0) loadLeads(p);
1804 });
1805
1806 // Select-all
1807 $('#mxch-leads-select-all').on('change', function() {
1808 const on = $(this).is(':checked');
1809 $('.mxch-leads-rowcheck').prop('checked', on);
1810 $('.mxch-leads-row').each(function() {
1811 const email = ($(this).data('email') || '').toString().toLowerCase();
1812 if (on) {
1813 leadsState.selected.add(email);
1814 } else {
1815 leadsState.selected.delete(email);
1816 }
1817 });
1818 updateLeadsSelectionUI();
1819 });
1820
1821 // Row checkbox
1822 $leads().on('change', '.mxch-leads-rowcheck', function() {
1823 const email = ($(this).closest('.mxch-leads-row').data('email') || '').toString().toLowerCase();
1824 if ($(this).is(':checked')) {
1825 leadsState.selected.add(email);
1826 } else {
1827 leadsState.selected.delete(email);
1828 }
1829 updateLeadsSelectionUI();
1830 });
1831
1832 // View convo -> jump to All Chats tab and open the session
1833 $leads().on('click', '.mxch-leads-view', function() {
1834 const sid = $(this).attr('data-session-id');
1835 if (!sid) return;
1836 $('.mxch-nav-link[data-target="all-chats"]').trigger('click');
1837 // selectChat is defined earlier in this closure
1838 if (typeof selectChat === 'function') {
1839 setTimeout(function() { selectChat(sid); }, 30);
1840 }
1841 });
1842
1843 // Row delete -> confirm for one
1844 $leads().on('click', '.mxch-leads-delete-row', function() {
1845 const email = $(this).data('email');
1846 if (!email) return;
1847 openLeadsConfirm([String(email)]);
1848 });
1849
1850 // Bulk delete -> confirm for N
1851 $('#mxch-leads-delete-selected').on('click', function() {
1852 if (leadsState.selected.size === 0) return;
1853 openLeadsConfirm(Array.from(leadsState.selected));
1854 });
1855
1856 function openLeadsConfirm(emails) {
1857 leadsState.pendingDelete = emails;
1858 const count = emails.length;
1859 const msg = count === 1
1860 ? 'Delete lead "' + emails[0] + '" and all of their conversations?'
1861 : 'Delete ' + count + ' leads and all of their conversations?';
1862 $('#mxch-leads-confirm-body').text(msg);
1863 $('#mxch-leads-confirm').fadeIn(120);
1864 }
1865
1866 function closeLeadsConfirm() {
1867 $('#mxch-leads-confirm').fadeOut(120);
1868 leadsState.pendingDelete = [];
1869 }
1870
1871 $leads().on('click', '[data-mxch-leads-close]', closeLeadsConfirm);
1872
1873 $('#mxch-leads-confirm-go').on('click', function() {
1874 const emails = leadsState.pendingDelete.slice();
1875 if (!emails.length) { closeLeadsConfirm(); return; }
1876
1877 const $btn = $(this).prop('disabled', true).text('Deleting...');
1878
1879 $.ajax({
1880 url: ajaxurl,
1881 type: 'POST',
1882 data: {
1883 action: 'mxchat_delete_leads',
1884 security: $('#mxchat_leads_delete_nonce').val(),
1885 emails: emails
1886 },
1887 success: function(response) {
1888 $btn.prop('disabled', false).text('Delete permanently');
1889 closeLeadsConfirm();
1890 if (response && response.success) {
1891 emails.forEach(function(e) { leadsState.selected.delete(e.toLowerCase()); });
1892 loadLeads(leadsState.page);
1893 } else {
1894 alert((response && response.data && response.data.message) || 'Failed to delete leads.');
1895 }
1896 },
1897 error: function() {
1898 $btn.prop('disabled', false).text('Delete permanently');
1899 alert('Network error while deleting.');
1900 }
1901 });
1902 });
1903
1904 // Export dropdown
1905 $('#mxch-leads-export-btn').on('click', function(e) {
1906 e.stopPropagation();
1907 $('#mxch-leads-export-menu').toggleClass('is-open');
1908 });
1909
1910 $(document).on('click', function() {
1911 $('#mxch-leads-export-menu').removeClass('is-open');
1912 });
1913
1914 $('#mxch-leads-export-menu').on('click', function(e) { e.stopPropagation(); });
1915
1916 $('#mxch-leads-export-menu button').on('click', function() {
1917 if ($(this).prop('disabled')) return;
1918 const scope = $(this).data('scope') || 'all';
1919 const fields = $(this).data('fields') || 'email_and_name';
1920 submitLeadsExport(scope, fields);
1921 $('#mxch-leads-export-menu').removeClass('is-open');
1922 });
1923
1924 function submitLeadsExport(scope, fields) {
1925 const $form = $('<form>', { method: 'POST', action: ajaxurl, style: 'display:none;' });
1926 $form.append($('<input>', { type: 'hidden', name: 'action', value: 'mxchat_export_leads' }));
1927 $form.append($('<input>', { type: 'hidden', name: 'security', value: $('#mxchat_leads_export_nonce').val() }));
1928 $form.append($('<input>', { type: 'hidden', name: 'scope', value: scope }));
1929 $form.append($('<input>', { type: 'hidden', name: 'fields', value: fields }));
1930 if (scope === 'selected') {
1931 Array.from(leadsState.selected).forEach(function(e) {
1932 $form.append($('<input>', { type: 'hidden', name: 'emails[]', value: e }));
1933 });
1934 }
1935 $form.appendTo('body').submit().remove();
1936 }
1937
1938 // Preload leads metadata on page load (for the nav badge count only) without rendering.
1939 // We keep this light — the full fetch only runs when the tab is clicked.
1940 $.ajax({
1941 url: ajaxurl,
1942 type: 'POST',
1943 data: { action: 'mxchat_fetch_leads', page: 1, per_page: 1 },
1944 success: function(response) {
1945 if (response && response.success && response.stats) {
1946 const total = response.stats.total_leads || 0;
1947 const $badge = $('#mxch-leads-nav-badge');
1948 if (total > 0) $badge.text(total).show();
1949 }
1950 }
1951 });
1952 });
1953