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 +248 -27 3.2.3 → 3.2.23 View file →
@@ -216,15 +216,64 @@
216 216 $('#mxch-select-all').prop('checked', totalItems > 0 && checkedItems === totalItems);
217 217 $('#mxch-select-all').prop('indeterminate', checkedItems > 0 && checkedItems < totalItems);
218 218 }
219 219
220 - // Sort button
221 - $('#mxch-sort-btn').on('click', function() {
222 - currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc';
223 - $(this).find('svg').css('transform', currentSortOrder === 'asc' ? 'rotate(180deg)' : 'rotate(0deg)');
224 - 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();
225 257 });
226 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 +
227 276 // Delete selected button — opens the shared confirm modal with the "also delete lead" checkbox.
228 277 $('#mxch-delete-selected').on('click', function() {
229 278 const count = selectedSessions.size;
230 279 if (count === 0) return;
@@ -366,8 +415,9 @@
366 415 <div class="mxch-chat-name">${escapeHtml(session.display_name)}</div>
367 416 <div class="mxch-chat-preview">${escapeHtml(session.preview)}</div>
368 417 </div>
369 418 <div class="mxch-chat-meta">
419 + ${renderRatingBadge(session)}
370 420 <span class="mxch-chat-time">${escapeHtml(session.time_display)}</span>
371 421 <span class="mxch-chat-count">${session.message_count}</span>
372 422 </div>
373 423 </div>
@@ -520,8 +570,18 @@
520 570 } else {
521 571 $('#mxch-detail-email-row').hide();
522 572 }
523 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 +
524 584 // Clicked links
525 585 if (data.clicked_urls && data.clicked_urls.length > 0) {
526 586 let linksHtml = '';
527 587 data.clicked_urls.forEach(function(url) {
@@ -547,13 +607,19 @@
547 607 <div class="mxch-message-time">${escapeHtml(msg.timestamp)}</div>
548 608 </div>
549 609 `;
550 610 } else {
551 - 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>';
552 618 messagesHtml += `
553 - <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}">
554 620 <div class="mxch-message-header">
555 - <span class="mxch-bot-label">AI Assistant</span>
621 + ${senderLabel}
556 622 ${ragLink}
557 623 </div>
558 624 <div class="mxch-message-row">
559 625 <div class="mxch-message-bubble">
@@ -877,9 +943,12 @@
877 943 renderActionsContext(response.data, $actionsContent);
878 944
879 945 // Update badge counts
880 946 const sourcesCount = response.data.top_matches ? response.data.top_matches.length : 0;
881 - 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;
882 951
883 952 if (sourcesCount > 0) {
884 953 $('#mxch-sources-count').text(sourcesCount).show();
885 954 }
@@ -915,21 +984,43 @@
915 984
916 985 function renderRagContext(data, $container) {
917 986 let html = '';
918 987
919 - // Check if we have any source data
920 - if (!data.top_matches || data.top_matches.length === 0) {
921 - 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>';
922 998 $container.html(html);
923 999 return;
924 1000 }
925 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 +
926 1008 html += '<div class="mxch-rag-summary">';
927 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>';
928 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>';
929 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 + }
930 1015 html += '</div>';
931 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 +
932 1023 const groupedByUrl = {};
933 1024
934 1025 data.top_matches.forEach(function(match) {
935 1026 const url = match.source_display || 'Unknown';
@@ -938,9 +1029,11 @@
938 1029 url: url,
939 1030 isUrl: url.startsWith('http'),
940 1031 bestScore: 0,
941 1032 usedForContext: false,
942 - matchedChunks: []
1033 + matchedChunks: [],
1034 + bestFusedRank: Infinity,
1035 + viaSet: {}
943 1036 };
944 1037 }
945 1038
946 1039 if (match.similarity_percentage > groupedByUrl[url].bestScore) {
@@ -950,8 +1043,15 @@
950 1043 if (match.used_for_context) {
951 1044 groupedByUrl[url].usedForContext = true;
952 1045 }
953 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 +
954 1054 groupedByUrl[url].matchedChunks.push({
955 1055 chunkIndex: match.chunk_index,
956 1056 score: match.similarity_percentage,
957 1057 usedForContext: match.used_for_context
@@ -957,9 +1057,16 @@
957 1057 usedForContext: match.used_for_context
958 1058 });
959 1059 });
960 1060
961 - 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 + });
962 1069 const usedUrlCount = data.sources_used > 0 ? data.sources_used : urlGroups.filter(g => g.usedForContext).length;
963 1070 const chunksInfo = data.total_chunks_used > 0 ? data.total_chunks_used + ' chunks sent to AI' : '';
964 1071
965 1072 html += '<div class="mxch-rag-matches">';
@@ -974,8 +1081,17 @@
974 1081 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
975 1082 html += '<div class="mxch-rag-match-header">';
976 1083 html += '<span class="mxch-rag-match-score">' + group.bestScore + '%</span>';
977 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 +
978 1094 if (group.matchedChunks.length > 1) {
979 1095 html += '<span class="mxch-rag-chunk-badge">' + group.matchedChunks.length + ' chunks</span>';
980 1096 }
981 1097
@@ -995,19 +1111,86 @@
995 1111 html += '</div>';
996 1112 $container.html(html);
997 1113 }
998 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 +
999 1171 function renderActionsContext(data, $container) {
1000 1172 let html = '';
1001 1173
1002 - // Check if we have action analysis data
1003 - if (!data.action_analysis || data.action_analysis.length === 0) {
1004 - 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>';
1005 1181 $container.html(html);
1006 1182 return;
1007 1183 }
1008 1184
1009 - 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 + }
1010 1193 const triggeredAction = actions.find(a => a.triggered);
1011 1194 const actionsAboveThreshold = actions.filter(a => a.above_threshold).length;
1012 1195
1013 1196 // Summary section
@@ -1455,13 +1638,31 @@
1455 1638 });
1456 1639 $wrap.html(html);
1457 1640 }
1458 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 +
1459 1660 function renderLeadsTable(rows) {
1460 1661 const $tbody = $('#mxch-leads-tbody');
1461 1662 if (!rows || rows.length === 0) {
1462 1663 $tbody.html(`
1463 - <tr><td colspan="6" class="mxch-leads-empty">
1664 + <tr><td colspan="7" class="mxch-leads-empty">
1464 1665 <div class="mxch-leads-empty-wrap">
1465 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>
1466 1667 <p>No leads match the current filters.</p>
1467 1668 </div>
@@ -1471,9 +1672,9 @@
1471 1672 }
1472 1673
1473 1674 let html = '';
1474 1675 rows.forEach(function(r) {
1475 - const emailKey = (r.email || '').toLowerCase();
1676 + const emailKey = leadKeyOf(r);
1476 1677 const isChecked = leadsState.selected.has(emailKey) ? ' checked' : '';
1477 1678 // Status: 'active' (has conversations), 'chat_deleted' (admin removed the chat), 'orphan' (no chat ever).
1478 1679 const status = r.status || (r.is_orphan ? 'orphan' : 'active');
1479 1680 const isOrphan = (status === 'orphan');
@@ -1480,12 +1681,21 @@
1480 1681 const isChatDeleted = (status === 'chat_deleted');
1481 1682 const nameLine = r.name
1482 1683 ? `<span class="mxch-leads-lead-name">${escapeHtmlLeads(r.name)}</span>`
1483 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>';
1484 1694 const leadCell = `
1485 1695 <div class="mxch-leads-lead-cell">
1486 - <span class="mxch-leads-lead-email" title="${escapeHtmlLeads(r.email)}">${escapeHtmlLeads(r.email)}</span>
1487 - ${nameLine}
1696 + ${primaryLine}
1697 + ${secondaryLine}
1488 1698 </div>`;
1489 1699 let countCell;
1490 1700 if (isOrphan) {
1491 1701 countCell = `<span class="mxch-leads-pill mxch-leads-pill-orphan">Orphan</span>`;
@@ -1497,8 +1707,18 @@
1497 1707 const lastCell = escapeHtmlLeads(r.last_seen_display || (isOrphan ? 'No conversation yet' : ''));
1498 1708 const pageCell = r.top_page_url
1499 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>`
1500 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 + }
1501 1721 // View Convo only for active leads (orphans and chat_deleted have no viewable session).
1502 1722 const viewBtn = (status === 'active' && r.latest_session_id)
1503 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">
1504 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>
@@ -1504,20 +1724,21 @@
1504 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>
1505 1725 <span>View convo</span>
1506 1726 </button>`
1507 1727 : '';
1508 - 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">
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">
1509 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>
1510 1730 </button>`;
1511 1731
1512 1732 const rowStateClass = isOrphan ? ' is-orphan' : (isChatDeleted ? ' is-chat-deleted' : '');
1513 1733 html += `
1514 - <tr class="mxch-leads-row${rowStateClass}" data-email="${escapeHtmlLeads(r.email)}">
1734 + <tr class="mxch-leads-row${rowStateClass}" data-email="${escapeHtmlLeads(emailKey)}">
1515 1735 <td class="mxch-leads-col-check"><input type="checkbox" class="mxch-leads-rowcheck"${isChecked}></td>
1516 1736 <td class="mxch-leads-col-lead">${leadCell}</td>
1517 1737 <td class="mxch-leads-col-count">${countCell}</td>
1518 1738 <td class="mxch-leads-col-last">${lastCell}</td>
1519 1739 <td class="mxch-leads-col-page">${pageCell}</td>
1740 + <td class="mxch-leads-col-consent">${consentCell}</td>
1520 1741 <td class="mxch-leads-col-actions">${viewBtn}${deleteBtn}</td>
1521 1742 </tr>
1522 1743 `;
1523 1744 });
@@ -1638,9 +1859,9 @@
1638 1859 $('#mxch-leads-select-all').on('change', function() {
1639 1860 const on = $(this).is(':checked');
1640 1861 $('.mxch-leads-rowcheck').prop('checked', on);
1641 1862 $('.mxch-leads-row').each(function() {
1642 - const email = ($(this).data('email') || '').toString().toLowerCase();
1863 + const email = normalizeLeadKey($(this).data('email'));
1643 1864 if (on) {
1644 1865 leadsState.selected.add(email);
1645 1866 } else {
1646 1867 leadsState.selected.delete(email);
@@ -1650,9 +1871,9 @@
1650 1871 });
1651 1872
1652 1873 // Row checkbox
1653 1874 $leads().on('change', '.mxch-leads-rowcheck', function() {
1654 - const email = ($(this).closest('.mxch-leads-row').data('email') || '').toString().toLowerCase();
1875 + const email = normalizeLeadKey($(this).closest('.mxch-leads-row').data('email'));
1655 1876 if ($(this).is(':checked')) {
1656 1877 leadsState.selected.add(email);
1657 1878 } else {
1658 1879 leadsState.selected.delete(email);
@@ -1718,9 +1939,9 @@
1718 1939 success: function(response) {
1719 1940 $btn.prop('disabled', false).text('Delete permanently');
1720 1941 closeLeadsConfirm();
1721 1942 if (response && response.success) {
1722 - emails.forEach(function(e) { leadsState.selected.delete(e.toLowerCase()); });
1943 + emails.forEach(function(e) { leadsState.selected.delete(normalizeLeadKey(e)); });
1723 1944 loadLeads(leadsState.page);
1724 1945 } else {
1725 1946 alert((response && response.data && response.data.message) || 'Failed to delete leads.');
1726 1947 }