PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
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
← All changes | js/mxchat_transcripts.js +760 -35 3.2.1 → 3.2.23 View file →
@@ -178,9 +178,11 @@
178 178 $('.mxch-chat-checkbox').prop('checked', isChecked);
179 179
180 180 if (isChecked) {
181 181 $('.mxch-chat-item').each(function() {
182 - selectedSessions.add($(this).data('session-id'));
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'));
183 185 $(this).addClass('selected');
184 186 });
185 187 $('#mxch-chat-list').addClass('selection-mode');
186 188 } else {
@@ -214,29 +216,104 @@
214 216 $('#mxch-select-all').prop('checked', totalItems > 0 && checkedItems === totalItems);
215 217 $('#mxch-select-all').prop('indeterminate', checkedItems > 0 && checkedItems < totalItems);
216 218 }
217 219
218 - // Sort button
219 - $('#mxch-sort-btn').on('click', function() {
220 - currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc';
221 - $(this).find('svg').css('transform', currentSortOrder === 'asc' ? 'rotate(180deg)' : 'rotate(0deg)');
222 - loadChatList(currentPage, $('#mxch-search-transcripts').val());
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();
223 257 });
224 258
225 - // Delete selected button
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.
226 277 $('#mxch-delete-selected').on('click', function() {
227 278 const count = selectedSessions.size;
228 279 if (count === 0) return;
280 + openTranscriptConfirm(Array.from(selectedSessions), count);
281 + });
229 282
230 - if (!confirm('Are you sure you want to delete ' + count + ' conversation(s)? This action cannot be undone.')) {
231 - return;
232 - }
283 + // Transcript delete confirm (shared by bulk + individual) ---------------------------
284 + let transcriptConfirmSessionIds = [];
233 285
234 - deleteMultipleSessions(Array.from(selectedSessions));
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);
235 312 });
236 313
237 314 // Delete multiple sessions
238 - function deleteMultipleSessions(sessionIds) {
315 + function deleteMultipleSessions(sessionIds, alsoDeleteLead) {
239 316 $.ajax({
240 317 url: ajaxurl,
241 318 type: 'POST',
242 319 data: {
@@ -241,8 +318,9 @@
241 318 type: 'POST',
242 319 data: {
243 320 action: 'mxchat_delete_chat_history',
244 321 delete_session_ids: sessionIds,
322 + also_delete_lead: alsoDeleteLead ? '1' : '0',
245 323 security: $('#mxchat_delete_chat_nonce').val()
246 324 },
247 325 success: function(response) {
248 326 try {
@@ -263,8 +341,13 @@
263 341 }
264 342
265 343 // Reload list
266 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();
267 350 } else if (jsonResponse.error) {
268 351 alert('Error: ' + jsonResponse.error);
269 352 }
270 353 } catch (e) {
@@ -332,8 +415,9 @@
332 415 <div class="mxch-chat-name">${escapeHtml(session.display_name)}</div>
333 416 <div class="mxch-chat-preview">${escapeHtml(session.preview)}</div>
334 417 </div>
335 418 <div class="mxch-chat-meta">
419 + ${renderRatingBadge(session)}
336 420 <span class="mxch-chat-time">${escapeHtml(session.time_display)}</span>
337 421 <span class="mxch-chat-count">${session.message_count}</span>
338 422 </div>
339 423 </div>
@@ -345,9 +429,9 @@
345 429 // Attach checkbox handlers
346 430 $('.mxch-chat-checkbox').on('click', function(e) {
347 431 e.stopPropagation(); // Prevent triggering chat item click
348 432 const $item = $(this).closest('.mxch-chat-item');
349 - const sessionId = $item.data('session-id');
433 + const sessionId = $item.attr('data-session-id');
350 434
351 435 if ($(this).is(':checked')) {
352 436 selectedSessions.add(sessionId);
353 437 $item.addClass('selected');
@@ -363,9 +447,9 @@
363 447 $('.mxch-chat-item').on('click', function(e) {
364 448 // Don't trigger if clicking on checkbox
365 449 if ($(e.target).is('.mxch-chat-checkbox')) return;
366 450
367 - const sessionId = $(this).data('session-id');
451 + const sessionId = $(this).attr('data-session-id');
368 452 selectChat(sessionId);
369 453
370 454 // Update active state
371 455 $('.mxch-chat-item').removeClass('active');
@@ -486,8 +570,18 @@
486 570 } else {
487 571 $('#mxch-detail-email-row').hide();
488 572 }
489 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 +
490 584 // Clicked links
491 585 if (data.clicked_urls && data.clicked_urls.length > 0) {
492 586 let linksHtml = '';
493 587 data.clicked_urls.forEach(function(url) {
@@ -513,13 +607,19 @@
513 607 <div class="mxch-message-time">${escapeHtml(msg.timestamp)}</div>
514 608 </div>
515 609 `;
516 610 } else {
517 - const ragLink = msg.has_rag ? `<a href="#" class="mxch-rag-link" data-message-id="${msg.id}">Sources</a>` : '';
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>';
518 618 messagesHtml += `
519 - <div class="mxch-message mxch-message-bot" data-message-id="${msg.id}">
619 + <div class="mxch-message mxch-message-bot${isAgent ? ' mxch-message-agent' : ''}" data-message-id="${msg.id}">
520 620 <div class="mxch-message-header">
521 - <span class="mxch-bot-label">AI Assistant</span>
621 + ${senderLabel}
522 622 ${ragLink}
523 623 </div>
524 624 <div class="mxch-message-row">
525 625 <div class="mxch-message-bubble">
@@ -561,21 +661,16 @@
561 661 $btn.addClass('active');
562 662 }
563 663 });
564 664
565 - // Delete current chat
665 + // Delete current chat — opens the shared confirm modal.
566 666 $('#mxch-delete-current').on('click', function() {
567 667 if (!currentSessionId) return;
568 -
569 - if (!confirm('Are you sure you want to delete this conversation? This action cannot be undone.')) {
570 - return;
571 - }
572 -
573 - deleteSession(currentSessionId);
668 + openTranscriptConfirm([currentSessionId], 1);
574 669 });
575 670
576 671 // Delete session function
577 - function deleteSession(sessionId) {
672 + function deleteSession(sessionId, alsoDeleteLead) {
578 673 $.ajax({
579 674 url: ajaxurl,
580 675 type: 'POST',
581 676 data: {
@@ -580,8 +675,9 @@
580 675 type: 'POST',
581 676 data: {
582 677 action: 'mxchat_delete_chat_history',
583 678 delete_session_ids: [sessionId],
679 + also_delete_lead: alsoDeleteLead ? '1' : '0',
584 680 security: $('#mxchat_delete_chat_nonce').val()
585 681 },
586 682 success: function(response) {
587 683 try {
@@ -847,9 +943,12 @@
847 943 renderActionsContext(response.data, $actionsContent);
848 944
849 945 // Update badge counts
850 946 const sourcesCount = response.data.top_matches ? response.data.top_matches.length : 0;
851 - const actionsCount = response.data.action_analysis ? response.data.action_analysis.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;
852 951
853 952 if (sourcesCount > 0) {
854 953 $('#mxch-sources-count').text(sourcesCount).show();
855 954 }
@@ -885,21 +984,43 @@
885 984
886 985 function renderRagContext(data, $container) {
887 986 let html = '';
888 987
889 - // Check if we have any source data
890 - if (!data.top_matches || data.top_matches.length === 0) {
891 - html += '<div class="mxch-no-results"><p>No document matches found for this response.</p></div>';
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>';
892 998 $container.html(html);
893 999 return;
894 1000 }
895 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 +
896 1008 html += '<div class="mxch-rag-summary">';
897 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>';
898 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>';
899 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 + }
900 1015 html += '</div>';
901 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 +
902 1023 const groupedByUrl = {};
903 1024
904 1025 data.top_matches.forEach(function(match) {
905 1026 const url = match.source_display || 'Unknown';
@@ -908,9 +1029,11 @@
908 1029 url: url,
909 1030 isUrl: url.startsWith('http'),
910 1031 bestScore: 0,
911 1032 usedForContext: false,
912 - matchedChunks: []
1033 + matchedChunks: [],
1034 + bestFusedRank: Infinity,
1035 + viaSet: {}
913 1036 };
914 1037 }
915 1038
916 1039 if (match.similarity_percentage > groupedByUrl[url].bestScore) {
@@ -920,8 +1043,15 @@
920 1043 if (match.used_for_context) {
921 1044 groupedByUrl[url].usedForContext = true;
922 1045 }
923 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 +
924 1054 groupedByUrl[url].matchedChunks.push({
925 1055 chunkIndex: match.chunk_index,
926 1056 score: match.similarity_percentage,
927 1057 usedForContext: match.used_for_context
@@ -927,9 +1057,16 @@
927 1057 usedForContext: match.used_for_context
928 1058 });
929 1059 });
930 1060
931 - const urlGroups = Object.values(groupedByUrl).sort((a, b) => b.bestScore - a.bestScore);
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 + });
932 1069 const usedUrlCount = data.sources_used > 0 ? data.sources_used : urlGroups.filter(g => g.usedForContext).length;
933 1070 const chunksInfo = data.total_chunks_used > 0 ? data.total_chunks_used + ' chunks sent to AI' : '';
934 1071
935 1072 html += '<div class="mxch-rag-matches">';
@@ -944,8 +1081,17 @@
944 1081 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
945 1082 html += '<div class="mxch-rag-match-header">';
946 1083 html += '<span class="mxch-rag-match-score">' + group.bestScore + '%</span>';
947 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 +
948 1094 if (group.matchedChunks.length > 1) {
949 1095 html += '<span class="mxch-rag-chunk-badge">' + group.matchedChunks.length + ' chunks</span>';
950 1096 }
951 1097
@@ -965,19 +1111,86 @@
965 1111 html += '</div>';
966 1112 $container.html(html);
967 1113 }
968 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 +
969 1171 function renderActionsContext(data, $container) {
970 1172 let html = '';
971 1173
972 - // Check if we have action analysis data
973 - if (!data.action_analysis || data.action_analysis.length === 0) {
974 - html += '<div class="mxch-no-results"><p>No action analysis available for this message.</p><p style="color: var(--mxch-text-secondary); font-size: 13px; margin-top: 8px;">Actions are only evaluated when enabled in your bot configuration.</p></div>';
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>';
975 1181 $container.html(html);
976 1182 return;
977 1183 }
978 1184
979 - const actions = data.action_analysis;
1185 + if (toolCalls.length > 0) {
1186 + html += renderToolCalls(toolCalls);
1187 + }
1188 +
1189 + if (actions.length === 0) {
1190 + $container.html(html);
1191 + return;
1192 + }
980 1193 const triggeredAction = actions.find(a => a.triggered);
981 1194 const actionsAboveThreshold = actions.filter(a => a.above_threshold).length;
982 1195
983 1196 // Summary section
@@ -1274,6 +1487,518 @@
1274 1487 clearTimeout(resizeTimeout);
1275 1488 resizeTimeout = setTimeout(function() {
1276 1489 initActivityChart();
1277 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 + }
1278 2003 });
1279 2004 });