PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.8
MxChat – AI Chatbot & Content Generation for WordPress v3.1.8
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
← All changes | includes/class-mxchat-admin.php +203 -1818 3.2.93.1.8 View file →
@@ -37,22 +37,11 @@
37 37 }
38 38
39 39 // Add admin menu and initialize settings
40 40 add_action('admin_menu', array($this, 'mxchat_add_plugin_page'));
41 - // Pro & Extensions registers at priority 30 so it always lands last in
42 - // the sidebar — below API Access (priority 20) and below any other
43 - // submenu that hooks at default priority 10.
44 - add_action('admin_menu', array($this, 'mxchat_add_pro_extensions_page'), 30);
45 - // Onboarding visibility — runs LAST so it can remove the submenu after every
46 - // other add_submenu_page() call. The page stays reachable by direct URL.
47 - add_action('admin_menu', array($this, 'mxchat_apply_onboarding_visibility'), 999);
48 41 add_action('admin_init', array($this, 'mxchat_page_init'));
49 42 add_action('admin_init', array($this, 'mxchat_prompts_page_init'));
50 43 add_action('admin_enqueue_scripts', array($this, 'mxchat_enqueue_admin_assets'));
51 - // Add body class for the Onboarding wizard (plan-905439) so the
52 - // chrome-surgery CSS in admin-onboarding-wizard.css can scope its
53 - // WP-sidebar collapse to this page only.
54 - add_filter('admin_body_class', array($this, 'mxchat_add_onboarding_body_class'));
55 44 add_action('wp_ajax_mxchat_delete_chat_history', array($this, 'mxchat_delete_chat_history'));
56 45 add_action('admin_post_mxchat_delete_prompt', array($this, 'mxchat_handle_delete_prompt'));
57 46 add_action('wp_ajax_mxchat_fetch_chat_history', array($this, 'mxchat_fetch_chat_history'));
58 47 add_action('wp_ajax_nopriv_mxchat_fetch_chat_history', array($this, 'mxchat_fetch_chat_history'));
@@ -65,14 +54,8 @@
65 54 add_action('admin_post_mxchat_add_intent', array($this, 'mxchat_handle_add_intent'));
66 55 add_action('admin_post_mxchat_delete_intent', array($this, 'mxchat_handle_delete_intent'));
67 56 add_action('admin_post_mxchat_edit_intent', array($this, 'mxchat_handle_edit_intent'));
68 57 add_action('wp_ajax_mxchat_export_transcripts', array($this, 'export_chat_transcripts'));
69 -
70 - // Leads tab (inside Transcripts)
71 - add_action('wp_ajax_mxchat_fetch_leads', array($this, 'mxchat_fetch_leads'));
72 - add_action('wp_ajax_mxchat_delete_leads', array($this, 'mxchat_delete_leads'));
73 - add_action('wp_ajax_mxchat_export_leads', array($this, 'mxchat_export_leads'));
74 -
75 58 add_action('admin_init', array($this, 'mxchat_transcripts_page_init'));
76 59 add_action('wp_ajax_dismiss_live_agent_notice', array($this, 'dismiss_live_agent_notice'));
77 60 add_action('wp_ajax_dismiss_theme_migration_notice', array($this, 'dismiss_theme_migration_notice'));
78 61 add_action('mxchat_cleanup_old_transcripts', array($this, 'cleanup_old_transcripts'));
@@ -107,146 +90,10 @@
107 90
108 91 // Translation handlers
109 92 add_action('wp_ajax_mxchat_translate_messages', array($this, 'mxchat_translate_messages'));
110 93 add_action('wp_ajax_mxchat_get_transcript_translation', array($this, 'mxchat_get_transcript_translation'));
111 -
112 - // 3.2.3: Embedding model switch protection
113 - add_action('wp_ajax_mxchat_check_embedding_switch', array($this, 'mxchat_check_embedding_switch_ajax'));
114 - add_action('wp_ajax_mxchat_dismiss_embedding_mismatch', array($this, 'mxchat_dismiss_embedding_mismatch_ajax'));
115 - add_action('admin_notices', array($this, 'mxchat_embedding_mismatch_notice'));
116 94 }
117 95
118 - /**
119 - * 3.2.3: Preflight check before allowing the embedding model dropdown to
120 - * switch. Pure option comparison — no counts, no DB queries beyond the
121 - * cached options. The dialog is shown whenever the user has previously
122 - * embedded with a different model than the one they're switching to.
123 - */
124 - public function mxchat_check_embedding_switch_ajax() {
125 - check_ajax_referer('mxchat_admin_nonce', 'security');
126 - if (!current_user_can('manage_options')) {
127 - wp_send_json_error(__('Unauthorized', 'mxchat'));
128 - }
129 -
130 - $new_model = isset($_POST['new_model']) ? sanitize_text_field(wp_unslash($_POST['new_model'])) : '';
131 - $active_model = MxChat_Utils::get_active_embedding_model();
132 -
133 - $is_mismatch = !empty($active_model) && !empty($new_model) && $active_model !== $new_model;
134 -
135 - $active_dims = MxChat_Utils::embedding_model_dimensions($active_model);
136 - $new_dims = MxChat_Utils::embedding_model_dimensions($new_model);
137 -
138 - wp_send_json_success(array(
139 - 'is_mismatch' => $is_mismatch,
140 - 'active_model' => $active_model,
141 - 'active_label' => MxChat_Utils::embedding_model_label($active_model),
142 - 'new_model' => $new_model,
143 - 'new_label' => MxChat_Utils::embedding_model_label($new_model),
144 - 'dims_differ' => ($active_dims > 0 && $new_dims > 0 && $active_dims !== $new_dims),
145 - 'active_dims' => $active_dims,
146 - 'new_dims' => $new_dims,
147 - ));
148 - }
149 -
150 - /**
151 - * 3.2.3: Dismiss the persistent mismatch banner. Tied to the active+selected
152 - * pair so the banner reappears on the next switch event.
153 - */
154 - public function mxchat_dismiss_embedding_mismatch_ajax() {
155 - check_ajax_referer('mxchat_admin_nonce', 'security');
156 - if (!current_user_can('manage_options')) {
157 - wp_send_json_error(__('Unauthorized', 'mxchat'));
158 - }
159 -
160 - $options = get_option('mxchat_options', array());
161 - $selected = $options['embedding_model'] ?? '';
162 - $active = MxChat_Utils::get_active_embedding_model();
163 - update_option('mxchat_dismissed_embedding_mismatch', $active . '|' . $selected, false);
164 - wp_send_json_success();
165 - }
166 -
167 - /**
168 - * 3.2.3: Persistent admin banner shown whenever the active embedding model
169 - * (last used to actually embed something) differs from the currently
170 - * selected model. Pure option comparison — no DB queries on every page
171 - * load. The banner auto-clears once both match again, i.e. after a delete
172 - * + re-embed cycle.
173 - */
174 - public function mxchat_embedding_mismatch_notice() {
175 - if (!current_user_can('manage_options')) {
176 - return;
177 - }
178 -
179 - $options = get_option('mxchat_options', array());
180 - $selected = $options['embedding_model'] ?? '';
181 - $active = MxChat_Utils::get_active_embedding_model();
182 -
183 - if (empty($active) || empty($selected) || $active === $selected) {
184 - return;
185 - }
186 -
187 - $dismissed = get_option('mxchat_dismissed_embedding_mismatch', '');
188 - if ($dismissed === $active . '|' . $selected) {
189 - return;
190 - }
191 -
192 - $active_label = MxChat_Utils::embedding_model_label($active);
193 - $selected_label = MxChat_Utils::embedding_model_label($selected);
194 - $active_dims = MxChat_Utils::embedding_model_dimensions($active);
195 - $selected_dims = MxChat_Utils::embedding_model_dimensions($selected);
196 - $dims_differ = ($active_dims > 0 && $selected_dims > 0 && $active_dims !== $selected_dims);
197 -
198 - $kb_url = admin_url('admin.php?page=mxchat-prompts');
199 - $actions_url = admin_url('admin.php?page=mxchat-actions');
200 -
201 - ?>
202 - <div class="notice notice-error is-dismissible mxchat-embedding-mismatch-notice"
203 - data-active="<?php echo esc_attr($active); ?>"
204 - data-selected="<?php echo esc_attr($selected); ?>">
205 - <p><strong><?php esc_html_e('MxChat: Embedding model mismatch detected', 'mxchat'); ?></strong></p>
206 - <p>
207 - <?php
208 - printf(
209 - /* translators: 1: previously-used model name, 2: currently-selected model name */
210 - esc_html__('Your knowledge base and actions were embedded with %1$s, but %2$s is now selected. Similarity matching will return inaccurate or empty results until you delete all existing embeddings and re-embed your content with the new model.', 'mxchat'),
211 - '<code>' . esc_html($active_label) . '</code>',
212 - '<code>' . esc_html($selected_label) . '</code>'
213 - );
214 - ?>
215 - </p>
216 - <?php if ($dims_differ) : ?>
217 - <p>
218 - <strong><?php esc_html_e('Dimension mismatch:', 'mxchat'); ?></strong>
219 - <?php
220 - printf(
221 - /* translators: 1: old dim count, 2: new dim count */
222 - esc_html__('Existing vectors are %1$d-dimensional but the new model produces %2$d-dimensional vectors. If you use Pinecone, your index will reject queries entirely until re-embedded.', 'mxchat'),
223 - (int) $active_dims,
224 - (int) $selected_dims
225 - );
226 - ?>
227 - </p>
228 - <?php endif; ?>
229 - <p>
230 - <?php esc_html_e('To fix this:', 'mxchat'); ?>
231 - <a href="<?php echo esc_url($kb_url); ?>"><?php esc_html_e('Delete all knowledge base entries', 'mxchat'); ?></a> ·
232 - <a href="<?php echo esc_url($actions_url); ?>"><?php esc_html_e('Delete all actions', 'mxchat'); ?></a> ·
233 - <?php esc_html_e('then re-import / re-add them with the new model selected.', 'mxchat'); ?>
234 - </p>
235 - </div>
236 - <script>
237 - (function($){
238 - $(document).on('click', '.mxchat-embedding-mismatch-notice .notice-dismiss', function(){
239 - $.post(ajaxurl, {
240 - action: 'mxchat_dismiss_embedding_mismatch',
241 - security: '<?php echo esc_js(wp_create_nonce('mxchat_admin_nonce')); ?>'
242 - });
243 - });
244 - })(jQuery);
245 - </script>
246 - <?php
247 - }
248 -
249 96 private function is_license_active() {
250 97 $license_status = get_option('mxchat_license_status', 'inactive');
251 98 return ($license_status === 'active');
252 99 }
@@ -298,14 +145,8 @@
298 145 'post_type_visibility_mode' => 'all', // 'all', 'include', 'exclude'
299 146 'post_type_visibility_list' => array(), // Array of post type slugs
300 147 'contextual_awareness_toggle' => 'off',
301 148 'citation_links_toggle' => 'on',
302 - 'satisfaction_rating_enabled' => 'off',
303 - 'satisfaction_rating_idle_seconds' => 60,
304 - 'satisfaction_rating_question' => '',
305 - 'satisfaction_rating_thanks' => '',
306 - 'satisfaction_rating_placeholder' => '',
307 - 'satisfaction_rating_saved' => '',
308 149 'close_button_color' => esc_html__('#fff', 'mxchat'),
309 150 'chatbot_bg_color' => esc_html__('#fff', 'mxchat'),
310 151 'user_message_bg_color' => esc_html__('#fff', 'mxchat'),
311 152 'user_message_font_color' => esc_html__('#212121', 'mxchat'),
@@ -384,55 +225,26 @@
384 225 $this->options = $merged_options;
385 226 }
386 227
387 228 public function mxchat_add_plugin_page() {
388 - // Onboarding lifecycle helpers (admin_init redirect + ajax handlers live in the file).
389 - require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
390 -
391 - // Main menu page — `mxchat-max` remains the parent slug for every MxChat submenu
392 - // (Settings, Knowledge, Transcripts, …). Hitting `?page=mxchat-max` directly now
393 - // dispatches to the Onboarding page (or Settings if the user has dismissed onboarding).
229 + // Main menu page
394 230 add_menu_page(
231 + esc_html__('MxChat Settings', 'mxchat'),
395 232 esc_html__('MxChat', 'mxchat'),
396 - esc_html__('MxChat', 'mxchat'),
397 233 'manage_options',
398 234 'mxchat-max',
399 - array($this, 'mxchat_create_dashboard_page'),
235 + array($this, 'mxchat_create_admin_page'),
400 236 'dashicons-testimonial',
401 237 6
402 238 );
403 239
404 - // Onboarding submenu — first child under MxChat (plan-d14e89).
405 - // First registration uses menu_slug === parent slug 'mxchat-max' →
406 - // WP-canonical override of the auto-duplicate "MxChat" entry. Result: the
407 - // first child shows as "Onboarding" instead of a redundant pair.
240 + // Rename the first submenu item from "MxChat" to "Settings"
408 241 add_submenu_page(
409 242 'mxchat-max',
410 - esc_html__('MxChat Onboarding', 'mxchat'),
411 - esc_html__('Onboarding', 'mxchat'),
412 - 'manage_options',
413 - 'mxchat-max',
414 - array($this, 'mxchat_create_dashboard_page')
415 - );
416 - // Hidden route for `?page=mxchat-onboarding` (Settings "Show again" link
417 - // + legacy redirects still target this slug). Parent === null keeps it
418 - // out of the menu while remaining accessible by URL.
419 - add_submenu_page(
420 - null,
421 - esc_html__('MxChat Onboarding', 'mxchat'),
422 - esc_html__('Onboarding', 'mxchat'),
423 - 'manage_options',
424 - 'mxchat-onboarding',
425 - array($this, 'mxchat_create_dashboard_page')
426 - );
427 -
428 - // Settings submenu — same callback as before, just at a new slug.
429 - add_submenu_page(
430 - 'mxchat-max',
431 243 esc_html__('MxChat Settings', 'mxchat'),
432 244 esc_html__('Settings', 'mxchat'),
433 245 'manage_options',
434 - 'mxchat-settings',
246 + 'mxchat-max',
435 247 array($this, 'mxchat_create_admin_page')
436 248 );
437 249
438 250 // Submenu page for Knowledge
@@ -472,16 +284,9 @@
472 284 'mxchat-content',
473 285 array($this, 'mxchat_create_content_page')
474 286 );
475 287
476 -}
477 -
478 -/**
479 - * Register the Pro & Extensions submenu on a later admin_menu priority so it
480 - * always renders as the bottom-most item in the MxChat sidebar — below
481 - * configuration pages like API Access (priority 20).
482 - */
483 -public function mxchat_add_pro_extensions_page() {
288 + // Consolidated Pro & Extensions page (replaces separate Add Ons and Pro Upgrade pages)
484 289 add_submenu_page(
485 290 'mxchat-max',
486 291 esc_html__('Pro & Extensions', 'mxchat'),
487 292 esc_html__('Pro & Extensions', 'mxchat'),
@@ -1069,54 +874,8 @@
1069 874 </div>
1070 875 <?php
1071 876 }
1072 877 }
1073 -/**
1074 - * Render the Onboarding page. Delegates to the procedural renderer in
1075 - * includes/admin-onboarding-page.php. Wired to both `?page=mxchat-max`
1076 - * (legacy top-level URL) and `?page=mxchat-onboarding` (the canonical
1077 - * Onboarding submenu).
1078 - *
1079 - * When the user has dismissed onboarding and lands on `mxchat-max` (the
1080 - * legacy URL, since the Onboarding submenu has been removed), redirect to
1081 - * Settings instead — the page is "graduated" and we shouldn't dump them
1082 - * back onto it. They can still navigate here directly via the unhide link.
1083 - */
1084 -public function mxchat_create_dashboard_page() {
1085 - require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
1086 -
1087 - $current = isset($_GET['page']) ? sanitize_key($_GET['page']) : '';
1088 - if ($current === 'mxchat-max' && function_exists('mxchat_onboarding_is_dismissed') && mxchat_onboarding_is_dismissed()) {
1089 - wp_safe_redirect(admin_url('admin.php?page=mxchat-settings'));
1090 - exit;
1091 - }
1092 -
1093 - if (function_exists('mxchat_render_onboarding_page')) {
1094 - mxchat_render_onboarding_page();
1095 - return;
1096 - }
1097 - // Defensive: if the include failed to load, fall back to the old Settings page
1098 - // so the top-level menu never lands on an empty screen.
1099 - $this->mxchat_create_admin_page();
1100 -}
1101 -
1102 -/**
1103 - * Hide the Onboarding submenu when the user has dismissed it (either
1104 - * manually or via auto-graduation). The page itself remains routable so
1105 - * the Settings "Show MxChat Onboarding again" link can navigate back to it.
1106 - */
1107 -public function mxchat_apply_onboarding_visibility() {
1108 - if (!function_exists('mxchat_onboarding_is_dismissed')) {
1109 - return;
1110 - }
1111 - if (mxchat_onboarding_is_dismissed()) {
1112 - // The first MxChat child is the same-slug-as-parent registration
1113 - // (slug 'mxchat-max', labelled "Onboarding") added in plan-d14e89.
1114 - // Remove it so the menu opens straight to Settings after dismiss.
1115 - remove_submenu_page('mxchat-max', 'mxchat-max');
1116 - }
1117 -}
1118 -
1119 878 public function mxchat_create_admin_page() {
1120 879 $this->add_live_agent_nonce();
1121 880 $this->add_theme_migration_nonce();
1122 881
@@ -1358,11 +1117,8 @@
1358 1117 $chart_messages[] = 0;
1359 1118 }
1360 1119 }
1361 1120
1362 - // Satisfaction rating rollup — last 30 days, grouped by bot (plan-a5b006).
1363 - $satisfaction_stats = $this->get_satisfaction_rating_stats(30);
1364 -
1365 1121 // Prepare page data for the template
1366 1122 $page_data = array(
1367 1123 'total_chats' => $total_chats,
1368 1124 'total_messages' => $total_messages,
@@ -1377,9 +1133,8 @@
1377 1133 'busiest_hour' => $busiest_hour,
1378 1134 'chart_labels' => $chart_labels,
1379 1135 'chart_chats' => $chart_chats,
1380 1136 'chart_messages' => $chart_messages,
1381 - 'satisfaction_stats' => $satisfaction_stats,
1382 1137 );
1383 1138
1384 1139 // Include and render the new template
1385 1140 require_once plugin_dir_path(__FILE__) . 'admin-transcripts-page.php';
@@ -1386,50 +1141,8 @@
1386 1141 mxchat_render_transcripts_page($this, $page_data);
1387 1142 }
1388 1143
1389 1144 /**
1390 - * Per-bot satisfaction rating rollup over the last $days days. Used by the
1391 - * Satisfaction card on the Transcripts dashboard (plan-a5b006).
1392 - *
1393 - * @param int $days Window in days.
1394 - * @return array Each entry: ['bot_id', 'total', 'positive', 'negative', 'positive_pct', 'negative_pct'].
1395 - */
1396 -public function get_satisfaction_rating_stats($days = 30) {
1397 - global $wpdb;
1398 - $table = $wpdb->prefix . 'mxchat_session_ratings';
1399 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) {
1400 - return array();
1401 - }
1402 - $days = max(1, (int) $days);
1403 - $rows = $wpdb->get_results($wpdb->prepare(
1404 - "SELECT bot_id,
1405 - COUNT(*) AS total,
1406 - SUM(CASE WHEN rating_value = 1 THEN 1 ELSE 0 END) AS positive,
1407 - SUM(CASE WHEN rating_value = -1 THEN 1 ELSE 0 END) AS negative
1408 - FROM {$table}
1409 - WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
1410 - GROUP BY bot_id
1411 - ORDER BY total DESC",
1412 - $days
1413 - ));
1414 - $out = array();
1415 - foreach ((array) $rows as $row) {
1416 - $total = (int) $row->total;
1417 - $positive = (int) $row->positive;
1418 - $negative = (int) $row->negative;
1419 - $out[] = array(
1420 - 'bot_id' => $row->bot_id ?: 'default',
1421 - 'total' => $total,
1422 - 'positive' => $positive,
1423 - 'negative' => $negative,
1424 - 'positive_pct' => $total > 0 ? (int) round(($positive / $total) * 100) : 0,
1425 - 'negative_pct' => $total > 0 ? (int) round(($negative / $total) * 100) : 0,
1426 - );
1427 - }
1428 - return $out;
1429 -}
1430 -
1431 -/**
1432 1145 * Get chart data for transcripts page
1433 1146 * Used by both page render and script localization
1434 1147 *
1435 1148 * @return array Chart data with labels, chats, and messages arrays
@@ -1541,39 +1254,8 @@
1541 1254 </p>
1542 1255 <?php
1543 1256 }
1544 1257
1545 -/**
1546 - * Custom retention-days input. When > 0 it overrides the bucket dropdown above
1547 - * and deletes transcripts older than the given number of days. Set to 0 to fall
1548 - * back to the dropdown (or "Never" if the dropdown is also Never).
1549 - *
1550 - * Devs can override the final day count via the `mxchat_transcript_retention_days`
1551 - * filter — runs in `cleanup_old_transcripts()` after this option is read.
1552 - *
1553 - * (plan-mxchat-20260509-9b80b1)
1554 - */
1555 -public function mxchat_retention_days_callback() {
1556 - $options = get_option('mxchat_transcripts_options', array());
1557 - $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1558 - ?>
1559 - <input type="number"
1560 - id="mxchat_retention_days"
1561 - name="mxchat_transcripts_options[mxchat_retention_days]"
1562 - value="<?php echo esc_attr($days); ?>"
1563 - min="0"
1564 - max="3650"
1565 - step="1"
1566 - style="width: 90px;" />
1567 - <p class="description">
1568 - <?php esc_html_e('Number of days to retain transcripts. When set to a value greater than 0, this overrides the dropdown above. Set to 0 to use the dropdown. Maximum 3650 (10 years). A daily wp-cron task removes anything older, cascading to translations and click-tracking rows.', 'mxchat'); ?>
1569 - <br>
1570 - <code>apply_filters( 'mxchat_transcript_retention_days', $days )</code>
1571 - <?php esc_html_e('lets developers override the final day count programmatically.', 'mxchat'); ?>
1572 - </p>
1573 - <?php
1574 -}
1575 -
1576 1258 public function mxchat_auto_email_transcript_callback() {
1577 1259 $options = get_option('mxchat_transcripts_options', array());
1578 1260 $enabled = isset($options['mxchat_auto_email_transcript_enabled']) ? $options['mxchat_auto_email_transcript_enabled'] : 0;
1579 1261 $delay = isset($options['mxchat_auto_email_transcript_delay']) ? $options['mxchat_auto_email_transcript_delay'] : '30';
@@ -1649,29 +1331,15 @@
1649 1331 } else {
1650 1332 $sanitized['mxchat_auto_delete_transcripts'] = 'never';
1651 1333 }
1652 1334
1653 - // Sanitize custom retention-days override (plan-9b80b1).
1654 - if (isset($input['mxchat_retention_days'])) {
1655 - $days = (int) $input['mxchat_retention_days'];
1656 - $sanitized['mxchat_retention_days'] = max(0, min(3650, $days));
1657 - } else {
1658 - $sanitized['mxchat_retention_days'] = 0;
1659 - }
1660 -
1661 - // Get old values to check if auto-delete or retention-days changed.
1335 + // Get old value to check if auto-delete setting changed
1662 1336 $old_options = get_option('mxchat_transcripts_options');
1663 1337 $old_interval = isset($old_options['mxchat_auto_delete_transcripts']) ? $old_options['mxchat_auto_delete_transcripts'] : 'never';
1664 - $old_retention = isset($old_options['mxchat_retention_days']) ? (int) $old_options['mxchat_retention_days'] : 0;
1665 -
1666 - // If either setting changed, reschedule the cron job. The schedule_transcript_cleanup
1667 - // helper now treats "any active retention" (dropdown != never OR custom days > 0) as a
1668 - // reason to keep the daily cron registered.
1669 - $interval_changed = ($old_interval !== $sanitized['mxchat_auto_delete_transcripts']);
1670 - $retention_changed = ($old_retention !== $sanitized['mxchat_retention_days']);
1671 - if ($interval_changed || $retention_changed) {
1672 - $any_active = ($sanitized['mxchat_auto_delete_transcripts'] !== 'never') || ($sanitized['mxchat_retention_days'] > 0);
1673 - $this->schedule_transcript_cleanup($any_active ? 'active' : 'never');
1338 +
1339 + // If the auto-delete setting changed, reschedule the cron job
1340 + if ($old_interval !== $sanitized['mxchat_auto_delete_transcripts']) {
1341 + $this->schedule_transcript_cleanup($sanitized['mxchat_auto_delete_transcripts']);
1674 1342 }
1675 1343
1676 1344 // Sanitize auto-email transcript settings
1677 1345 $sanitized['mxchat_auto_email_transcript_enabled'] = isset($input['mxchat_auto_email_transcript_enabled']) ? 1 : 0;
@@ -1699,13 +1367,10 @@
1699 1367 $timestamp = wp_next_scheduled('mxchat_cleanup_old_transcripts');
1700 1368 if ($timestamp) {
1701 1369 wp_unschedule_event($timestamp, 'mxchat_cleanup_old_transcripts');
1702 1370 }
1703 -
1704 - // Schedule new event whenever retention is active. "active" is the canonical
1705 - // value passed by sanitize_transcripts_options when either the dropdown != never
1706 - // OR the custom retention-days > 0; "never" turns the cron off. Any other value
1707 - // (the legacy "1week" / "2weeks" / "1month" strings) is also treated as active.
1371 +
1372 + // Schedule new event if not set to "never"
1708 1373 if ($interval !== 'never') {
1709 1374 // Schedule to run daily at 3 AM
1710 1375 $next_run = strtotime('tomorrow 3:00 AM');
1711 1376 wp_schedule_event($next_run, 'daily', 'mxchat_cleanup_old_transcripts');
@@ -1715,74 +1380,58 @@
1715 1380 /**
1716 1381 * Delete old transcripts based on the configured interval
1717 1382 */
1718 1383 public function cleanup_old_transcripts() {
1719 - $options = get_option('mxchat_transcripts_options', array());
1720 - $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1721 - $custom_days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1722 -
1723 - // Custom retention-days (plan-9b80b1) takes precedence over the bucket dropdown.
1384 + $options = get_option('mxchat_transcripts_options', array());
1385 + $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1386 +
1387 + // If set to never, don't delete anything
1388 + if ($interval === 'never') {
1389 + return;
1390 + }
1391 +
1392 + // Calculate the cutoff date
1724 1393 $days = 0;
1725 - if ($custom_days > 0) {
1726 - $days = $custom_days;
1727 - } else {
1728 - switch ($interval) {
1729 - case '1week': $days = 7; break;
1730 - case '2weeks': $days = 14; break;
1731 - case '1month': $days = 30; break;
1732 - case 'never':
1733 - default:
1734 - $days = 0;
1735 - }
1394 + switch ($interval) {
1395 + case '1week':
1396 + $days = 7;
1397 + break;
1398 + case '2weeks':
1399 + $days = 14;
1400 + break;
1401 + case '1month':
1402 + $days = 30;
1403 + break;
1404 + default:
1405 + return; // Invalid interval, don't delete anything
1736 1406 }
1737 -
1738 - // Devs can override the final day count programmatically.
1739 - $days = (int) apply_filters('mxchat_transcript_retention_days', $days);
1740 -
1741 - if ($days <= 0) {
1742 - return; // Retention disabled — bail.
1743 - }
1744 -
1407 +
1745 1408 global $wpdb;
1746 - $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts';
1409 + $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1747 1410 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
1748 - $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
1749 1411
1750 - // Defensive: if the main table doesn't exist (fresh-ish install), bail.
1751 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) {
1752 - return;
1753 - }
1412 + // Calculate the cutoff timestamp
1413 + $cutoff_date = date('Y-m-d H:i:s', strtotime("-{$days} days"));
1754 1414
1755 - $cutoff_date = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
1756 -
1757 - // Cap at 5000 session_ids per run so a large unattended site doesn't OOM —
1758 - // the cron will pick up where it left off on the next tick (within hours).
1759 - $batch_cap = (int) apply_filters('mxchat_transcript_retention_batch_cap', 5000);
1760 -
1415 + // First, get the session IDs that will be deleted (to clean up translations too)
1761 1416 $sessions_to_delete = $wpdb->get_col(
1762 1417 $wpdb->prepare(
1763 - "SELECT DISTINCT session_id FROM {$transcripts_table} WHERE timestamp IS NOT NULL AND timestamp < %s LIMIT %d",
1764 - $cutoff_date,
1765 - $batch_cap
1418 + "SELECT DISTINCT session_id FROM {$table_name} WHERE timestamp < %s",
1419 + $cutoff_date
1766 1420 )
1767 1421 );
1768 1422
1769 - if (empty($sessions_to_delete)) {
1770 - return;
1771 - }
1772 -
1773 - $placeholders = implode(',', array_fill(0, count($sessions_to_delete), '%s'));
1774 -
1775 - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1776 - // $placeholders is a server-built list of literal "%s" tokens.
1777 - $deleted_transcripts = (int) $wpdb->query(
1423 + // Delete transcripts older than the cutoff date
1424 + $deleted = $wpdb->query(
1778 1425 $wpdb->prepare(
1779 - "DELETE FROM {$transcripts_table} WHERE session_id IN ($placeholders)",
1780 - $sessions_to_delete
1426 + "DELETE FROM {$table_name} WHERE timestamp < %s",
1427 + $cutoff_date
1781 1428 )
1782 1429 );
1783 1430
1784 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) {
1431 + // Also delete any translations for the deleted sessions
1432 + if (!empty($sessions_to_delete) && $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table) {
1433 + $placeholders = implode(',', array_fill(0, count($sessions_to_delete), '%s'));
1785 1434 $wpdb->query(
1786 1435 $wpdb->prepare(
1787 1436 "DELETE FROM {$translations_table} WHERE session_id IN ($placeholders)",
1788 1437 $sessions_to_delete
@@ -1789,20 +1438,12 @@
1789 1438 )
1790 1439 );
1791 1440 }
1792 1441
1793 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) {
1794 - $wpdb->query(
1795 - $wpdb->prepare(
1796 - "DELETE FROM {$url_clicks_table} WHERE session_id IN ($placeholders)",
1797 - $sessions_to_delete
1798 - )
1799 - );
1442 + // Log the cleanup action
1443 + if ($deleted !== false && $deleted > 0) {
1444 + //error_log(sprintf('MXChat: Auto-deleted %d transcripts older than %d days', $deleted, $days));
1800 1445 }
1801 - // phpcs:enable
1802 -
1803 - update_option('mxchat_retention_last_swept_at', time(), false);
1804 - update_option('mxchat_retention_rows_last_deleted', $deleted_transcripts, false);
1805 1446 }
1806 1447
1807 1448 public function export_chat_transcripts() {
1808 1449 if (!current_user_can('manage_options')) {
@@ -1863,763 +1504,9 @@
1863 1504 fclose($output);
1864 1505 wp_die();
1865 1506 }
1866 1507
1867 -// ============================================================================
1868 -// Leads tab (inside Transcripts)
1869 -//
1870 -// Leads are derived from existing data — no dedicated table. Primary source:
1871 -// wp_mxchat_chat_transcripts rows where user_email is populated. Secondary
1872 -// source: wp_options entries `mxchat_email_{session_id}` / `mxchat_name_{sid}`
1873 -// for "orphan" leads who submitted the pre-chat form but never chatted.
1874 -// ============================================================================
1875 -
1876 1508 /**
1877 - * Fetch leads: dedup-by-email rows, stats strip, and top pages in one call.
1878 - */
1879 -public function mxchat_fetch_leads() {
1880 - if (!current_user_can('manage_options')) {
1881 - wp_send_json_error(['message' => 'Insufficient permissions']);
1882 - wp_die();
1883 - }
1884 -
1885 - global $wpdb;
1886 - $table = $wpdb->prefix . 'mxchat_chat_transcripts';
1887 -
1888 - $page = isset($_POST['page']) ? max(1, absint($_POST['page'])) : 1;
1889 - $per_page = isset($_POST['per_page']) ? min(100, max(10, absint($_POST['per_page']))) : 25;
1890 - $offset = ($page - 1) * $per_page;
1891 - $search = isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '';
1892 - $date_range = isset($_POST['date_range']) ? sanitize_key($_POST['date_range']) : 'all';
1893 - $status = isset($_POST['status']) ? sanitize_key($_POST['status']) : 'all';
1894 - $page_filter = isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '';
1895 - $sort = isset($_POST['sort']) ? sanitize_key($_POST['sort']) : 'last_seen';
1896 - $sort_dir = (isset($_POST['sort_dir']) && $_POST['sort_dir'] === 'asc') ? 'ASC' : 'DESC';
1897 -
1898 - $date_cutoff = self::mxchat_leads_date_cutoff($date_range);
1899 - $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
1900 -
1901 - // Base WHERE for transcripts leads.
1902 - $where_clauses = ["user_email IS NOT NULL", "user_email != ''"];
1903 - $where_params = [];
1904 -
1905 - if ($date_cutoff) {
1906 - $where_clauses[] = 'timestamp >= %s';
1907 - $where_params[] = $date_cutoff;
1908 - }
1909 - if ($page_filter && $has_page_url_column) {
1910 - $where_clauses[] = 'originating_page_url = %s';
1911 - $where_params[] = $page_filter;
1912 - }
1913 - if ($search !== '') {
1914 - $like = '%' . $wpdb->esc_like($search) . '%';
1915 - $where_clauses[] = '(user_email LIKE %s OR user_name LIKE %s)';
1916 - $where_params[] = $like;
1917 - $where_params[] = $like;
1918 - }
1919 - $where_sql = 'WHERE ' . implode(' AND ', $where_clauses);
1920 -
1921 - // Aggregate query grouped by email.
1922 - $select_sql = $has_page_url_column
1923 - ? "SELECT user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
1924 - COUNT(DISTINCT session_id) AS conversation_count"
1925 - : "SELECT user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
1926 - COUNT(DISTINCT session_id) AS conversation_count";
1927 -
1928 - $order_column = in_array($sort, ['last_seen', 'conversation_count', 'first_seen'], true) ? $sort : 'last_seen';
1929 - $group_order_limit = " GROUP BY user_email ORDER BY {$order_column} {$sort_dir} LIMIT %d OFFSET %d";
1930 -
1931 - $transcripts_sql = $wpdb->prepare(
1932 - "{$select_sql} FROM {$table} {$where_sql}{$group_order_limit}",
1933 - array_merge($where_params, [$per_page, $offset])
1934 - );
1935 - $transcript_rows = $wpdb->get_results($transcripts_sql);
1936 -
1937 - // Count of unique transcript-based leads under the same filters.
1938 - $count_sql = $wpdb->prepare(
1939 - "SELECT COUNT(DISTINCT user_email) FROM {$table} {$where_sql}",
1940 - $where_params
1941 - );
1942 - $transcripts_lead_count = (int) $wpdb->get_var($count_sql);
1943 -
1944 - // Hydrate each row: name, latest_session_id, top page.
1945 - $leads = [];
1946 - foreach ($transcript_rows as $row) {
1947 - $detail = $has_page_url_column
1948 - ? $wpdb->get_row($wpdb->prepare(
1949 - "SELECT session_id, user_name, originating_page_url, originating_page_title
1950 - FROM {$table} WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
1951 - $row->user_email
1952 - ))
1953 - : $wpdb->get_row($wpdb->prepare(
1954 - "SELECT session_id, user_name FROM {$table}
1955 - WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
1956 - $row->user_email
1957 - ));
1958 -
1959 - $leads[] = [
1960 - 'email' => $row->user_email,
1961 - 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
1962 - 'conversation_count' => (int) $row->conversation_count,
1963 - 'last_seen' => $row->last_seen,
1964 - 'last_seen_display' => self::mxchat_leads_format_relative($row->last_seen),
1965 - 'first_seen' => $row->first_seen,
1966 - 'latest_session_id' => isset($detail->session_id) ? $detail->session_id : '',
1967 - 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
1968 - 'top_page_title' => isset($detail->originating_page_title) ? $detail->originating_page_title : '',
1969 - 'is_orphan' => false,
1970 - 'status' => 'active',
1971 - ];
1972 - }
1973 -
1974 - // Non-transcript lead sources. Built once here, then filtered/merged based on the
1975 - // status filter below. Deduplication priority when the same email appears in multiple
1976 - // sources: transcripts > chat_deleted > orphan.
1977 - $transcripts_emails_seen = array_flip(array_map(
1978 - function ($r) { return strtolower($r['email']); },
1979 - $leads
1980 - ));
1981 -
1982 - // Chat-deleted leads: had a conversation that an admin removed. Preserved via
1983 - // mxchat_lead_del_* options, with timestamps so they respect date filters.
1984 - $chat_deleted_leads_all = [];
1985 - if ($status === 'all' || $status === 'chat_deleted') {
1986 - $chat_deleted_leads_all = self::mxchat_collect_chat_deleted_leads($search, $date_cutoff);
1987 - // Dedup: drop any chat_deleted row whose email is already in the transcripts set.
1988 - $chat_deleted_leads_all = array_values(array_filter(
1989 - $chat_deleted_leads_all,
1990 - function ($row) use ($transcripts_emails_seen) {
1991 - return !isset($transcripts_emails_seen[strtolower($row['email'])]);
1992 - }
1993 - ));
1994 - foreach ($chat_deleted_leads_all as $row) {
1995 - $transcripts_emails_seen[strtolower($row['email'])] = true;
1996 - }
1997 - }
1998 -
1999 - // Orphans: pre-chat form captures with no conversation. No timestamp, so skipped
2000 - // when a date filter is active.
2001 - $orphan_leads_all = [];
2002 - if (($status === 'all' || $status === 'orphan') && !$date_cutoff && !$page_filter) {
2003 - $orphan_leads_all = self::mxchat_collect_orphan_leads($search);
2004 - $orphan_leads_all = array_values(array_filter(
2005 - $orphan_leads_all,
2006 - function ($row) use ($transcripts_emails_seen) {
2007 - return !isset($transcripts_emails_seen[strtolower($row['email'])]);
2008 - }
2009 - ));
2010 - }
2011 -
2012 - // Apply status filter to the transcripts-derived list.
2013 - if ($status === 'orphan' || $status === 'chat_deleted') {
2014 - $leads = [];
2015 - $transcripts_lead_count = 0;
2016 - }
2017 -
2018 - // Stitch the current page from the three buckets in priority order.
2019 - $total_count = $transcripts_lead_count + count($chat_deleted_leads_all) + count($orphan_leads_all);
2020 - $remaining_slots = $per_page - count($leads);
2021 -
2022 - if ($remaining_slots > 0 && !empty($chat_deleted_leads_all)) {
2023 - $start = max(0, ($page - 1) * $per_page - $transcripts_lead_count);
2024 - if ($start < count($chat_deleted_leads_all)) {
2025 - $leads = array_merge($leads, array_slice($chat_deleted_leads_all, $start, $remaining_slots));
2026 - $remaining_slots = $per_page - count($leads);
2027 - }
2028 - }
2029 -
2030 - if ($remaining_slots > 0 && !empty($orphan_leads_all)) {
2031 - $before = $transcripts_lead_count + count($chat_deleted_leads_all);
2032 - $start = max(0, ($page - 1) * $per_page - $before);
2033 - if ($start < count($orphan_leads_all)) {
2034 - $leads = array_merge($leads, array_slice($orphan_leads_all, $start, $remaining_slots));
2035 - }
2036 - }
2037 -
2038 - $total_pages = $per_page > 0 ? (int) ceil($total_count / $per_page) : 1;
2039 -
2040 - // Stats strip: always computed over full dataset, unaffected by filters.
2041 - $stats = self::mxchat_leads_stats($table, $has_page_url_column);
2042 -
2043 - // Top pages: top 5 by distinct emails captured.
2044 - $top_pages = [];
2045 - if ($has_page_url_column) {
2046 - $top_pages_rows = $wpdb->get_results(
2047 - "SELECT originating_page_url AS url,
2048 - MAX(originating_page_title) AS title,
2049 - COUNT(DISTINCT user_email) AS lead_count
2050 - FROM {$table}
2051 - WHERE user_email IS NOT NULL AND user_email != ''
2052 - AND originating_page_url IS NOT NULL AND originating_page_url != ''
2053 - GROUP BY originating_page_url
2054 - ORDER BY lead_count DESC, url ASC
2055 - LIMIT 5"
2056 - );
2057 - foreach ($top_pages_rows as $p) {
2058 - $top_pages[] = [
2059 - 'url' => $p->url,
2060 - 'title' => $p->title ?: $p->url,
2061 - 'lead_count' => (int) $p->lead_count,
2062 - ];
2063 - }
2064 - }
2065 -
2066 - wp_send_json([
2067 - 'success' => true,
2068 - 'leads' => $leads,
2069 - 'page' => $page,
2070 - 'per_page' => $per_page,
2071 - 'total_count' => $total_count,
2072 - 'total_pages' => $total_pages,
2073 - 'showing_start' => $total_count === 0 ? 0 : ($offset + 1),
2074 - 'showing_end' => min($offset + $per_page, $total_count),
2075 - 'stats' => $stats,
2076 - 'top_pages' => $top_pages,
2077 - ]);
2078 - wp_die();
2079 -}
2080 -
2081 -/**
2082 - * Delete one or more leads by email. Removes every transcripts row for that
2083 - * email and cleans up related wp_options (mxchat_email_{sid}, mxchat_name_{sid},
2084 - * mxchat_history_{sid}) and any orphan option entries matching the email.
2085 - */
2086 -public function mxchat_delete_leads() {
2087 - if (!current_user_can('manage_options')) {
2088 - wp_send_json_error(['message' => 'Insufficient permissions']);
2089 - wp_die();
2090 - }
2091 - check_ajax_referer('mxchat_delete_leads', 'security');
2092 -
2093 - $emails_raw = isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [];
2094 - $emails = [];
2095 - foreach ($emails_raw as $e) {
2096 - $clean = sanitize_email((string) $e);
2097 - if ($clean) {
2098 - $emails[] = $clean;
2099 - }
2100 - }
2101 - if (empty($emails)) {
2102 - wp_send_json_error(['message' => 'No emails provided']);
2103 - wp_die();
2104 - }
2105 -
2106 - $summary = self::mxchat_wipe_leads_by_email($emails);
2107 -
2108 - wp_send_json([
2109 - 'success' => true,
2110 - 'deleted_leads' => count($emails),
2111 - 'deleted_sessions' => $summary['deleted_sessions'],
2112 - 'deleted_rows' => $summary['deleted_rows'],
2113 - ]);
2114 - wp_die();
2115 -}
2116 -
2117 -/**
2118 - * Fully wipe one or more leads by email: every transcripts row, every related wp_options
2119 - * entry (history, pre-chat capture, chat_deleted preservation, agent name, translations).
2120 - *
2121 - * Shared between the Leads-tab Delete button and the transcript-delete opt-in checkbox.
2122 - * Input emails must already be sanitized with sanitize_email().
2123 - */
2124 -private static function mxchat_wipe_leads_by_email(array $emails) {
2125 - global $wpdb;
2126 - $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2127 - $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2128 - $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
2129 -
2130 - $deleted_sessions = 0;
2131 - $deleted_rows = 0;
2132 - $emails_lc = array_map('strtolower', $emails);
2133 -
2134 - foreach ($emails as $email) {
2135 - $session_ids = $wpdb->get_col($wpdb->prepare(
2136 - "SELECT DISTINCT session_id FROM {$table} WHERE user_email = %s",
2137 - $email
2138 - ));
2139 -
2140 - $rows_removed = $wpdb->delete($table, ['user_email' => $email], ['%s']);
2141 - if ($rows_removed !== false) {
2142 - $deleted_rows += (int) $rows_removed;
2143 - }
2144 -
2145 - foreach ($session_ids as $sid) {
2146 - $deleted_sessions++;
2147 - wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
2148 - delete_option('mxchat_history_' . $sid);
2149 - delete_option('mxchat_email_' . $sid);
2150 - delete_option('mxchat_name_' . $sid);
2151 - delete_option('mxchat_agent_name_' . $sid);
2152 - delete_option('mxchat_lead_del_email_' . $sid);
2153 - delete_option('mxchat_lead_del_name_' . $sid);
2154 - delete_option('mxchat_lead_del_ts_' . $sid);
2155 - if ($has_translations) {
2156 - $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']);
2157 - }
2158 - }
2159 - }
2160 -
2161 - // Clean up any lingering option entries (orphan pre-chat captures + chat_deleted
2162 - // preservations) whose stored value matches one of the emails being wiped.
2163 - $lingering = $wpdb->get_results(
2164 - "SELECT option_name, option_value FROM {$wpdb->options}
2165 - WHERE option_name LIKE 'mxchat_email_%' OR option_name LIKE 'mxchat_lead_del_email_%'"
2166 - );
2167 - foreach ($lingering as $opt) {
2168 - if (!in_array(strtolower(trim($opt->option_value)), $emails_lc, true)) {
2169 - continue;
2170 - }
2171 - if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) {
2172 - $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2173 - delete_option('mxchat_lead_del_email_' . $sid);
2174 - delete_option('mxchat_lead_del_name_' . $sid);
2175 - delete_option('mxchat_lead_del_ts_' . $sid);
2176 - } else {
2177 - $sid = substr($opt->option_name, strlen('mxchat_email_'));
2178 - delete_option('mxchat_email_' . $sid);
2179 - delete_option('mxchat_name_' . $sid);
2180 - }
2181 - }
2182 -
2183 - wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
2184 -
2185 - return [
2186 - 'deleted_sessions' => $deleted_sessions,
2187 - 'deleted_rows' => $deleted_rows,
2188 - ];
2189 -}
2190 -
2191 -/**
2192 - * Stream a leads CSV. scope=all exports every lead under current filters is not
2193 - * supported to keep semantics simple; caller either exports all leads or a
2194 - * specific set of selected emails.
2195 - */
2196 -public function mxchat_export_leads() {
2197 - if (!current_user_can('manage_options')) {
2198 - wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2199 - }
2200 - check_ajax_referer('mxchat_export_leads', 'security');
2201 -
2202 - $scope = isset($_POST['scope']) ? sanitize_key($_POST['scope']) : 'all';
2203 - $fields_mode = isset($_POST['fields']) ? sanitize_key($_POST['fields']) : 'email_and_name';
2204 - $emails_in = isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [];
2205 -
2206 - $emails_in_clean = [];
2207 - foreach ($emails_in as $e) {
2208 - $clean = sanitize_email((string) $e);
2209 - if ($clean) {
2210 - $emails_in_clean[] = $clean;
2211 - }
2212 - }
2213 -
2214 - global $wpdb;
2215 - $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2216 - $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2217 -
2218 - // Collect leads from transcripts.
2219 - $transcripts_sql = "SELECT user_email AS email,
2220 - MAX(timestamp) AS last_seen,
2221 - COUNT(DISTINCT session_id) AS conversation_count
2222 - FROM {$table}
2223 - WHERE user_email IS NOT NULL AND user_email != ''";
2224 - $params = [];
2225 - if ($scope === 'selected' && !empty($emails_in_clean)) {
2226 - $placeholders = implode(',', array_fill(0, count($emails_in_clean), '%s'));
2227 - $transcripts_sql .= " AND user_email IN ({$placeholders})";
2228 - $params = $emails_in_clean;
2229 - }
2230 - $transcripts_sql .= " GROUP BY user_email ORDER BY last_seen DESC";
2231 -
2232 - $rows = !empty($params)
2233 - ? $wpdb->get_results($wpdb->prepare($transcripts_sql, $params))
2234 - : $wpdb->get_results($transcripts_sql);
2235 -
2236 - // Hydrate each row with name + top page.
2237 - $export_rows = [];
2238 - foreach ($rows as $row) {
2239 - $detail = $has_page_url_column
2240 - ? $wpdb->get_row($wpdb->prepare(
2241 - "SELECT user_name, originating_page_url FROM {$table}
2242 - WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2243 - $row->email
2244 - ))
2245 - : $wpdb->get_row($wpdb->prepare(
2246 - "SELECT user_name FROM {$table}
2247 - WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2248 - $row->email
2249 - ));
2250 - $export_rows[] = [
2251 - 'email' => $row->email,
2252 - 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2253 - 'conversation_count' => (int) $row->conversation_count,
2254 - 'last_seen' => $row->last_seen,
2255 - 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2256 - ];
2257 - }
2258 -
2259 - // Include orphan + chat_deleted leads when exporting all.
2260 - if ($scope === 'all') {
2261 - $transcripts_emails_lc = array_flip(array_map(
2262 - function ($r) { return strtolower($r['email']); },
2263 - $export_rows
2264 - ));
2265 - foreach (self::mxchat_collect_chat_deleted_leads('') as $cd) {
2266 - if (isset($transcripts_emails_lc[strtolower($cd['email'])])) continue;
2267 - $transcripts_emails_lc[strtolower($cd['email'])] = true;
2268 - $export_rows[] = [
2269 - 'email' => $cd['email'],
2270 - 'name' => $cd['name'],
2271 - 'conversation_count' => 0,
2272 - 'last_seen' => $cd['last_seen'],
2273 - 'top_page_url' => '',
2274 - ];
2275 - }
2276 - foreach (self::mxchat_collect_orphan_leads('') as $orphan) {
2277 - if (isset($transcripts_emails_lc[strtolower($orphan['email'])])) continue;
2278 - $transcripts_emails_lc[strtolower($orphan['email'])] = true;
2279 - $export_rows[] = [
2280 - 'email' => $orphan['email'],
2281 - 'name' => $orphan['name'],
2282 - 'conversation_count' => 0,
2283 - 'last_seen' => '',
2284 - 'top_page_url' => '',
2285 - ];
2286 - }
2287 - }
2288 -
2289 - if (empty($export_rows)) {
2290 - wp_send_json_error(['message' => 'No leads to export.']);
2291 - wp_die();
2292 - }
2293 -
2294 - $filename = 'mxchat-leads-' . date('Y-m-d') . '.csv';
2295 - header('Content-Type: text/csv');
2296 - header('Content-Disposition: attachment; filename="' . $filename . '"');
2297 - header('Pragma: no-cache');
2298 - header('Expires: 0');
2299 -
2300 - $output = fopen('php://output', 'w');
2301 - fputs($output, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel
2302 -
2303 - if ($fields_mode === 'email_only') {
2304 - fputcsv($output, ['Email']);
2305 - foreach ($export_rows as $r) {
2306 - fputcsv($output, [$r['email']]);
2307 - }
2308 - } else {
2309 - fputcsv($output, ['Email', 'Name', 'Conversations', 'Last seen', 'Top page']);
2310 - foreach ($export_rows as $r) {
2311 - fputcsv($output, [
2312 - $r['email'],
2313 - $r['name'],
2314 - $r['conversation_count'],
2315 - $r['last_seen'],
2316 - $r['top_page_url'],
2317 - ]);
2318 - }
2319 - }
2320 -
2321 - fclose($output);
2322 - wp_die();
2323 -}
2324 -
2325 -/**
2326 - * Stats strip payload (independent of filters).
2327 - */
2328 -private static function mxchat_leads_stats($table, $has_page_url_column) {
2329 - global $wpdb;
2330 -
2331 - $total_transcripts_emails = (int) $wpdb->get_var(
2332 - "SELECT COUNT(DISTINCT user_email) FROM {$table}
2333 - WHERE user_email IS NOT NULL AND user_email != ''"
2334 - );
2335 -
2336 - $new_this_week = (int) $wpdb->get_var($wpdb->prepare(
2337 - "SELECT COUNT(*) FROM (
2338 - SELECT user_email FROM {$table}
2339 - WHERE user_email IS NOT NULL AND user_email != ''
2340 - GROUP BY user_email
2341 - HAVING MIN(timestamp) >= %s
2342 - ) AS new_leads",
2343 - gmdate('Y-m-d H:i:s', strtotime('-7 days'))
2344 - ));
2345 -
2346 - $total_convos = (int) $wpdb->get_var(
2347 - "SELECT COUNT(DISTINCT session_id) FROM {$table}
2348 - WHERE user_email IS NOT NULL AND user_email != ''"
2349 - );
2350 -
2351 - $orphan_count = count(self::mxchat_collect_orphan_leads(''));
2352 - $chat_deleted_count = self::mxchat_count_chat_deleted_leads();
2353 -
2354 - // Total leads = unique emails across all three sources (dedup priority: transcripts > chat_deleted > orphan
2355 - // is already enforced at collection time in mxchat_fetch_leads; stats re-apply it here).
2356 - $total_leads = $total_transcripts_emails + $chat_deleted_count + $orphan_count;
2357 -
2358 - $avg = $total_transcripts_emails > 0
2359 - ? round($total_convos / $total_transcripts_emails, 1)
2360 - : 0;
2361 -
2362 - // Orphan % reflects *true* orphans only (pre-chat dropoffs). Chat-deleted leads are
2363 - // excluded so the metric stays meaningful — admins shouldn't see their cleanups
2364 - // inflate this number.
2365 - $orphan_pct = $total_leads > 0
2366 - ? (int) round(($orphan_count / $total_leads) * 100)
2367 - : 0;
2368 -
2369 - return [
2370 - 'total_leads' => $total_leads,
2371 - 'new_this_week' => $new_this_week,
2372 - 'avg_convos' => $avg,
2373 - 'orphan_pct' => $orphan_pct,
2374 - 'orphan_count' => $orphan_count,
2375 - 'chat_deleted_count' => $chat_deleted_count,
2376 - ];
2377 -}
2378 -
2379 -/**
2380 - * Collect leads who had a conversation that an admin later deleted (preserved via
2381 - * mxchat_lead_del_* options). Returns rows tagged status='chat_deleted' with the
2382 - * original last-seen timestamp so they still sort and filter sensibly.
2383 - *
2384 - * @param string $search Optional email/name substring filter.
2385 - * @param string $date_cutoff Optional 'Y-m-d H:i:s' cutoff — only rows with last_ts >= cutoff.
2386 - * @return array
2387 - */
2388 -private static function mxchat_collect_chat_deleted_leads($search = '', $date_cutoff = '') {
2389 - global $wpdb;
2390 - $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2391 -
2392 - $rows = $wpdb->get_results(
2393 - "SELECT option_name, option_value FROM {$wpdb->options}
2394 - WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2395 - );
2396 - if (empty($rows)) {
2397 - return [];
2398 - }
2399 -
2400 - // Emails that currently have transcripts rows should not appear as chat_deleted —
2401 - // they've come back and chatted, so they're active leads again.
2402 - $emails_in_transcripts = array_map(
2403 - 'strtolower',
2404 - (array) $wpdb->get_col(
2405 - "SELECT DISTINCT user_email FROM {$table}
2406 - WHERE user_email IS NOT NULL AND user_email != ''"
2407 - )
2408 - );
2409 - $emails_in_transcripts = array_flip($emails_in_transcripts);
2410 -
2411 - $needle = strtolower(trim((string) $search));
2412 - $by_email = [];
2413 -
2414 - foreach ($rows as $opt) {
2415 - $email = sanitize_email(trim((string) $opt->option_value));
2416 - if (!$email) {
2417 - continue;
2418 - }
2419 - if (isset($emails_in_transcripts[strtolower($email)])) {
2420 - continue;
2421 - }
2422 - $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2423 - if (!$sid) {
2424 - continue;
2425 - }
2426 - $name = (string) get_option('mxchat_lead_del_name_' . $sid, '');
2427 - $ts = (string) get_option('mxchat_lead_del_ts_' . $sid, '');
2428 -
2429 - if ($date_cutoff !== '' && ($ts === '' || $ts < $date_cutoff)) {
2430 - continue;
2431 - }
2432 - if ($needle !== '') {
2433 - $hay = strtolower($email . ' ' . $name);
2434 - if (strpos($hay, $needle) === false) {
2435 - continue;
2436 - }
2437 - }
2438 -
2439 - $key = strtolower($email);
2440 - if (!isset($by_email[$key]) || (isset($by_email[$key]['last_seen']) && $ts > $by_email[$key]['last_seen'])) {
2441 - $by_email[$key] = [
2442 - 'email' => $email,
2443 - 'name' => $name,
2444 - 'conversation_count' => 0,
2445 - 'last_seen' => $ts,
2446 - 'last_seen_display' => $ts ? self::mxchat_leads_format_relative($ts) : __('Chat deleted', 'mxchat'),
2447 - 'first_seen' => $ts,
2448 - 'latest_session_id' => '',
2449 - 'top_page_url' => '',
2450 - 'top_page_title' => '',
2451 - 'is_orphan' => false,
2452 - 'status' => 'chat_deleted',
2453 - ];
2454 - }
2455 - }
2456 -
2457 - // Newest chat_deleted first.
2458 - usort($by_email, function ($a, $b) {
2459 - return strcmp((string) $b['last_seen'], (string) $a['last_seen']);
2460 - });
2461 - return array_values($by_email);
2462 -}
2463 -
2464 -/**
2465 - * Count unique emails preserved as "chat deleted" (for the stats strip).
2466 - */
2467 -private static function mxchat_count_chat_deleted_leads() {
2468 - global $wpdb;
2469 - $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2470 -
2471 - $emails = $wpdb->get_col(
2472 - "SELECT DISTINCT option_value FROM {$wpdb->options}
2473 - WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2474 - );
2475 - if (empty($emails)) {
2476 - return 0;
2477 - }
2478 -
2479 - $transcripts_emails = array_map(
2480 - 'strtolower',
2481 - (array) $wpdb->get_col(
2482 - "SELECT DISTINCT user_email FROM {$table}
2483 - WHERE user_email IS NOT NULL AND user_email != ''"
2484 - )
2485 - );
2486 - $transcripts_emails = array_flip($transcripts_emails);
2487 -
2488 - $count = 0;
2489 - $seen = [];
2490 - foreach ($emails as $raw) {
2491 - $email = strtolower(trim((string) $raw));
2492 - if (!$email || isset($seen[$email]) || isset($transcripts_emails[$email])) {
2493 - continue;
2494 - }
2495 - $seen[$email] = true;
2496 - $count++;
2497 - }
2498 - return $count;
2499 -}
2500 -
2501 -/**
2502 - * Find leads who submitted the pre-chat form but never produced a transcripts row.
2503 - * Returned rows have no conversation_count, no timestamp.
2504 - *
2505 - * @param string $search Optional email/name substring filter.
2506 - * @return array
2507 - */
2508 -private static function mxchat_collect_orphan_leads($search = '') {
2509 - global $wpdb;
2510 - $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2511 -
2512 - $option_rows = $wpdb->get_results(
2513 - "SELECT option_name, option_value FROM {$wpdb->options}
2514 - WHERE option_name LIKE 'mxchat_email_%'"
2515 - );
2516 - if (empty($option_rows)) {
2517 - return [];
2518 - }
2519 -
2520 - // Collect all session_ids that have real transcripts rows so we can exclude them.
2521 - $session_ids_with_rows = $wpdb->get_col(
2522 - "SELECT DISTINCT session_id FROM {$table}
2523 - WHERE user_email IS NOT NULL AND user_email != ''"
2524 - );
2525 - $session_ids_with_rows = array_flip($session_ids_with_rows);
2526 -
2527 - // Seen emails in transcripts (so orphans only include truly never-chatted leads).
2528 - $emails_in_transcripts = array_map(
2529 - 'strtolower',
2530 - (array) $wpdb->get_col(
2531 - "SELECT DISTINCT user_email FROM {$table}
2532 - WHERE user_email IS NOT NULL AND user_email != ''"
2533 - )
2534 - );
2535 - $emails_in_transcripts = array_flip($emails_in_transcripts);
2536 -
2537 - $orphans_by_email = [];
2538 - $needle = strtolower(trim((string) $search));
2539 -
2540 - foreach ($option_rows as $opt) {
2541 - $email = sanitize_email(trim((string) $opt->option_value));
2542 - if (!$email) {
2543 - continue;
2544 - }
2545 - $sid = substr($opt->option_name, strlen('mxchat_email_'));
2546 - if (!$sid) {
2547 - continue;
2548 - }
2549 - // Exclude leads who have any transcripts rows (they appear in the main list).
2550 - if (isset($emails_in_transcripts[strtolower($email)])) {
2551 - continue;
2552 - }
2553 - if (isset($session_ids_with_rows[$sid])) {
2554 - continue;
2555 - }
2556 -
2557 - $name_option = get_option('mxchat_name_' . $sid, '');
2558 - $name = is_string($name_option) ? trim($name_option) : '';
2559 -
2560 - if ($needle !== '') {
2561 - $hay = strtolower($email . ' ' . $name);
2562 - if (strpos($hay, $needle) === false) {
2563 - continue;
2564 - }
2565 - }
2566 -
2567 - $key = strtolower($email);
2568 - if (!isset($orphans_by_email[$key])) {
2569 - $orphans_by_email[$key] = [
2570 - 'email' => $email,
2571 - 'name' => $name,
2572 - 'conversation_count' => 0,
2573 - 'last_seen' => '',
2574 - 'last_seen_display' => __('No conversation yet', 'mxchat'),
2575 - 'first_seen' => '',
2576 - 'latest_session_id' => '',
2577 - 'top_page_url' => '',
2578 - 'top_page_title' => '',
2579 - 'is_orphan' => true,
2580 - 'status' => 'orphan',
2581 - ];
2582 - }
2583 - }
2584 -
2585 - return array_values($orphans_by_email);
2586 -}
2587 -
2588 -/**
2589 - * Map a date_range key to a SQL-comparable cutoff string, or '' for all-time.
2590 - */
2591 -private static function mxchat_leads_date_cutoff($date_range) {
2592 - switch ($date_range) {
2593 - case 'today': return gmdate('Y-m-d H:i:s', strtotime('-24 hours'));
2594 - case '7d': return gmdate('Y-m-d H:i:s', strtotime('-7 days'));
2595 - case '30d': return gmdate('Y-m-d H:i:s', strtotime('-30 days'));
2596 - case '90d': return gmdate('Y-m-d H:i:s', strtotime('-90 days'));
2597 - case 'all':
2598 - default: return '';
2599 - }
2600 -}
2601 -
2602 -/**
2603 - * Turn a UTC timestamp into a short relative display like "2h ago" or "Apr 12".
2604 - */
2605 -private static function mxchat_leads_format_relative($timestamp) {
2606 - if (!$timestamp) {
2607 - return '';
2608 - }
2609 - $ts = strtotime($timestamp . ' UTC');
2610 - if (!$ts) {
2611 - return '';
2612 - }
2613 - $diff = time() - $ts;
2614 - if ($diff < 60) return __('just now', 'mxchat');
2615 - if ($diff < 3600) return floor($diff / 60) . __('m ago', 'mxchat');
2616 - if ($diff < 86400) return floor($diff / 3600) . __('h ago', 'mxchat');
2617 - if ($diff < 604800) return floor($diff / 86400) . __('d ago', 'mxchat');
2618 - return wp_date('M j', $ts);
2619 -}
2620 -
2621 -/**
2622 1509 * Handle translation of chat messages via AJAX
2623 1510 */
2624 1511 public function mxchat_translate_messages() {
2625 1512 if (!current_user_can('manage_options')) {
@@ -3014,11 +1901,8 @@
3014 1901 /**
3015 1902 * Translate text using Google Gemini API
3016 1903 */
3017 1904 private function translate_with_gemini($api_key, $model, $system_prompt, $text) {
3018 - if ($model === 'gemini-3-pro-preview') {
3019 - $model = 'gemini-3.1-pro-preview';
3020 - }
3021 1905 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;
3022 1906
3023 1907 $response = wp_remote_post($url, [
3024 1908 'timeout' => 60,
@@ -3108,20 +1992,9 @@
3108 1992 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3109 1993 $per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : 50;
3110 1994 $offset = ($page - 1) * $per_page;
3111 1995 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3112 - $sort_raw = isset($_POST['sort_order']) ? sanitize_key($_POST['sort_order']) : 'desc';
3113 - $allowed_sorts = array('asc', 'desc', 'rating_positive', 'rating_negative');
3114 - if (!in_array($sort_raw, $allowed_sorts, true)) { $sort_raw = 'desc'; }
3115 - $sort_order = ($sort_raw === 'asc') ? 'ASC' : 'DESC';
3116 - $ratings_table = $wpdb->prefix . 'mxchat_session_ratings';
3117 - $ratings_join = '';
3118 - $rating_table_exists = ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $ratings_table)) === $ratings_table);
3119 - if ($rating_table_exists && ($sort_raw === 'rating_positive' || $sort_raw === 'rating_negative')) {
3120 - // We sort by rating then recency. Wrap rating_value so NULL sorts last.
3121 - $rating_dir = ($sort_raw === 'rating_positive') ? 'DESC' : 'ASC';
3122 - $ratings_join = " LEFT JOIN {$ratings_table} r ON r.session_id = t.session_id ";
3123 - }
1996 + $sort_order = isset($_POST['sort_order']) && $_POST['sort_order'] === 'asc' ? 'ASC' : 'DESC';
3124 1997
3125 1998 // Build search condition
3126 1999 $search_condition = '';
3127 2000 $search_params = [];
@@ -3141,24 +2014,18 @@
3141 2014 ? $wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table_name} {$search_condition}", $search_params)
3142 2015 : "SELECT COUNT(DISTINCT session_id) FROM {$table_name}";
3143 2016 $total_sessions = (int) $wpdb->get_var($count_query);
3144 2017
3145 - // Get session IDs for current page. Default sort is recency; rating sorts join the ratings table
3146 - // and order by rating value (NULLs last) with recency as tiebreaker.
3147 - if ($ratings_join) {
3148 - $order_by = "ORDER BY (r.rating_value IS NULL), r.rating_value {$rating_dir}, MAX(t.timestamp) DESC";
3149 - } else {
3150 - $order_by = "ORDER BY MAX(t.timestamp) {$sort_order}";
3151 - }
2018 + // Get session IDs for current page (sorted by newest or oldest)
3152 2019 $session_query = !empty($search)
3153 2020 ? $wpdb->prepare(
3154 - "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join} {$search_condition}
3155 - GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
2021 + "SELECT DISTINCT t.session_id FROM {$table_name} t {$search_condition}
2022 + GROUP BY t.session_id ORDER BY MAX(t.timestamp) {$sort_order} LIMIT %d OFFSET %d",
3156 2023 array_merge($search_params, [$per_page, $offset])
3157 2024 )
3158 2025 : $wpdb->prepare(
3159 - "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join}
3160 - GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
2026 + "SELECT DISTINCT session_id FROM {$table_name}
2027 + GROUP BY session_id ORDER BY MAX(timestamp) {$sort_order} LIMIT %d OFFSET %d",
3161 2028 $per_page, $offset
3162 2029 );
3163 2030 $session_ids = $wpdb->get_col($session_query);
3164 2031
@@ -3180,24 +2047,8 @@
3180 2047 // Check for optional columns/tables
3181 2048 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3182 2049 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3183 2050
3184 - // Batch-fetch session ratings for this page (plan-a5b006).
3185 - $ratings_map = array();
3186 - if ($rating_table_exists && !empty($session_ids)) {
3187 - $placeholders = implode(',', array_fill(0, count($session_ids), '%s'));
3188 - $rating_rows = $wpdb->get_results($wpdb->prepare(
3189 - "SELECT session_id, rating_value, rating_feedback FROM {$ratings_table} WHERE session_id IN ($placeholders)",
3190 - $session_ids
3191 - ));
3192 - foreach ($rating_rows as $row) {
3193 - $ratings_map[$row->session_id] = array(
3194 - 'value' => (int) $row->rating_value,
3195 - 'feedback' => (string) $row->rating_feedback,
3196 - );
3197 - }
3198 - }
3199 -
3200 2051 // Build session list data
3201 2052 $sessions = [];
3202 2053 foreach ($session_ids as $session_id) {
3203 2054 // Get session metadata
@@ -3254,12 +2105,8 @@
3254 2105 $parts = explode(' ', $display_name);
3255 2106 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3256 2107 }
3257 2108
3258 - $rating_entry = isset($ratings_map[$session_id]) ? $ratings_map[$session_id] : null;
3259 - $rating_value = $rating_entry ? $rating_entry['value'] : null;
3260 - $rating_feedback = $rating_entry ? $rating_entry['feedback'] : '';
3261 -
3262 2109 $sessions[] = [
3263 2110 'session_id' => $session_id,
3264 2111 'display_name' => $display_name,
3265 2112 'display_sub' => $display_sub,
@@ -3266,11 +2113,9 @@
3266 2113 'initials' => $initials,
3267 2114 'preview' => $preview,
3268 2115 'message_count' => (int) $message_stats->count,
3269 2116 'time_display' => $time_display,
3270 - 'timestamp' => $message_stats->latest,
3271 - 'rating_value' => $rating_value,
3272 - 'rating_feedback' => $rating_feedback,
2117 + 'timestamp' => $message_stats->latest
3273 2118 ];
3274 2119 }
3275 2120
3276 2121 wp_send_json([
@@ -3408,18 +2253,8 @@
3408 2253
3409 2254 // First message timestamp for "started" display
3410 2255 $started = !empty($messages) ? wp_date('M j, Y g:i A', strtotime($messages[0]->timestamp . ' UTC')) : '-';
3411 2256
3412 - // Pull rating_feedback for this session (if any) so the details drawer can show it.
3413 - $rating_feedback = '';
3414 - $ratings_table_det = $wpdb->prefix . 'mxchat_session_ratings';
3415 - if ($wpdb->get_var("SHOW TABLES LIKE '$ratings_table_det'") === $ratings_table_det) {
3416 - $rating_feedback = (string) $wpdb->get_var($wpdb->prepare(
3417 - "SELECT rating_feedback FROM {$ratings_table_det} WHERE session_id = %s LIMIT 1",
3418 - $session_id
3419 - ));
3420 - }
3421 -
3422 2257 wp_send_json([
3423 2258 'success' => true,
3424 2259 'session_id' => $session_id,
3425 2260 'user' => [
@@ -3435,10 +2270,9 @@
3435 2270 ],
3436 2271 'clicked_urls' => $clicked_urls,
3437 2272 'messages' => $formatted_messages,
3438 2273 'message_count' => count($messages),
3439 - 'started' => $started,
3440 - 'rating_feedback' => $rating_feedback
2274 + 'started' => $started
3441 2275 ]);
3442 2276 wp_die();
3443 2277 }
3444 2278
@@ -3891,103 +2725,53 @@
3891 2725 echo wp_json_encode(['error' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
3892 2726 wp_die();
3893 2727 }
3894 2728 check_ajax_referer('mxchat_delete_chat_history', 'security');
3895 -
3896 - if (!isset($_POST['delete_session_ids']) || !is_array($_POST['delete_session_ids'])) {
3897 - echo wp_json_encode(['error' => esc_html__('No chat sessions selected for deletion.', 'mxchat')]);
3898 - wp_die();
3899 - }
3900 -
3901 2729 global $wpdb;
3902 2730 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3903 - $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
3904 - $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
3905 2731
3906 - // When true, any lead attached to these sessions is fully wiped (all their sessions,
3907 - // across the whole table). Default false: the chat rows go away but the lead is
3908 - // preserved as a separate "chat deleted" lead in the Leads tab.
3909 - $also_delete_lead = !empty($_POST['also_delete_lead']) && $_POST['also_delete_lead'] !== 'false';
2732 + if (isset($_POST['delete_session_ids']) && is_array($_POST['delete_session_ids'])) {
2733 + $deleted_count = 0;
2734 + $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
3910 2735
3911 - $deleted_count = 0;
3912 - $preserved_as_deleted_leads = 0;
3913 - $emails_to_fully_wipe = [];
2736 + foreach ($_POST['delete_session_ids'] as $session_id) {
2737 + $session_id_sanitized = sanitize_text_field($session_id);
3914 2738
3915 - foreach ((array) $_POST['delete_session_ids'] as $session_id) {
3916 - $session_id_sanitized = sanitize_text_field($session_id);
3917 - if ($session_id_sanitized === '') {
3918 - continue;
3919 - }
2739 + // Clear relevant cache before deletion
2740 + $cache_key = 'chat_session_' . $session_id_sanitized;
2741 + wp_cache_delete($cache_key, 'mxchat_chat_sessions');
3920 2742
3921 - // Capture the lead info attached to this session *before* we delete the rows.
3922 - $lead_row = $wpdb->get_row($wpdb->prepare(
3923 - "SELECT user_email, user_name, MAX(timestamp) AS last_ts
3924 - FROM {$table_name}
3925 - WHERE session_id = %s AND user_email IS NOT NULL AND user_email != ''
3926 - GROUP BY user_email, user_name
3927 - ORDER BY last_ts DESC LIMIT 1",
3928 - $session_id_sanitized
3929 - ));
2743 + // Perform the deletion from the database table
2744 + $wpdb->delete($table_name, ['session_id' => $session_id_sanitized]);
3930 2745
3931 - wp_cache_delete('chat_session_' . $session_id_sanitized, 'mxchat_chat_sessions');
3932 - $wpdb->delete($table_name, ['session_id' => $session_id_sanitized]);
2746 + // Delete any saved translations for this session
2747 + if ($wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table) {
2748 + $wpdb->delete($translations_table, ['session_id' => $session_id_sanitized]);
2749 + }
3933 2750
3934 - if ($has_translations) {
3935 - $wpdb->delete($translations_table, ['session_id' => $session_id_sanitized]);
3936 - }
2751 + // Delete the corresponding option entry from wp_options table
2752 + delete_option("mxchat_history_" . $session_id_sanitized);
3937 2753
3938 - delete_option('mxchat_history_' . $session_id_sanitized);
3939 - delete_option('mxchat_agent_name_' . $session_id_sanitized);
2754 + // Delete any associated metadata options
2755 + delete_option("mxchat_email_" . $session_id_sanitized);
2756 + delete_option("mxchat_agent_name_" . $session_id_sanitized);
3940 2757
3941 - if ($lead_row && !empty($lead_row->user_email)) {
3942 - if ($also_delete_lead) {
3943 - // Full-wipe requested — queue the email so that all their sessions and
3944 - // related options get swept below. Also clear this session's pre-chat
3945 - // capture options (they're no longer meaningful).
3946 - $emails_to_fully_wipe[strtolower($lead_row->user_email)] = $lead_row->user_email;
3947 - delete_option('mxchat_email_' . $session_id_sanitized);
3948 - delete_option('mxchat_name_' . $session_id_sanitized);
3949 - } else {
3950 - // Preserve the lead in a "Chat deleted" state via distinct option keys so
3951 - // they stay out of the orphan bucket (orphan = pre-chat form dropoff).
3952 - update_option('mxchat_lead_del_email_' . $session_id_sanitized, $lead_row->user_email, false);
3953 - if (!empty($lead_row->user_name)) {
3954 - update_option('mxchat_lead_del_name_' . $session_id_sanitized, $lead_row->user_name, false);
3955 - }
3956 - if (!empty($lead_row->last_ts)) {
3957 - update_option('mxchat_lead_del_ts_' . $session_id_sanitized, $lead_row->last_ts, false);
3958 - }
3959 - // Clean up pre-chat capture options for this session — chat_deleted supersedes.
3960 - delete_option('mxchat_email_' . $session_id_sanitized);
3961 - delete_option('mxchat_name_' . $session_id_sanitized);
3962 - $preserved_as_deleted_leads++;
3963 - }
3964 - } else {
3965 - // No lead attached — nothing to preserve. Clean up any orphan options anyway.
3966 - delete_option('mxchat_email_' . $session_id_sanitized);
3967 - delete_option('mxchat_name_' . $session_id_sanitized);
2758 + $deleted_count++;
3968 2759 }
3969 2760
3970 - $deleted_count++;
3971 - }
2761 + // Optionally, clear a general cache if you have one
2762 + wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
3972 2763
3973 - // Opt-in full-lead wipe: sweep every remaining row + every option key (including
3974 - // chat_deleted preservation) for each affected email. Reuses the same internal
3975 - // helper as the Leads-tab Delete button for consistency.
3976 - if (!empty($emails_to_fully_wipe)) {
3977 - self::mxchat_wipe_leads_by_email(array_values($emails_to_fully_wipe));
2764 + echo wp_json_encode([
2765 + 'success' => sprintf(
2766 + esc_html__('%d chat session(s) have been deleted from all storage locations.', 'mxchat'),
2767 + $deleted_count
2768 + )
2769 + ]);
2770 + } else {
2771 + echo wp_json_encode(['error' => esc_html__('No chat sessions selected for deletion.', 'mxchat')]);
3978 2772 }
3979 2773
3980 - wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
3981 -
3982 - echo wp_json_encode([
3983 - 'success' => sprintf(
3984 - esc_html__('%d chat session(s) have been deleted.', 'mxchat'),
3985 - $deleted_count
3986 - ),
3987 - 'preserved_as_deleted_leads' => $preserved_as_deleted_leads,
3988 - 'leads_fully_wiped' => count($emails_to_fully_wipe),
3989 - ]);
3990 2774 wp_die();
3991 2775 }
3992 2776
3993 2777 /**
@@ -4863,9 +3647,9 @@
4863 3647 'intent_label' => $intent_label,
4864 3648 'phrases' => implode(', ', $phrases_array),
4865 3649 'embedding_vector' => $serialized_vector,
4866 3650 'similarity_threshold' => $similarity_threshold,
4867 - 'enabled_bots' => $enabled_bots_json, // Include enabled_bots in update
3651 + 'enabled_bots' => $enabled_bots_json // Include enabled_bots in update
4868 3652 ),
4869 3653 array('id' => $intent_id),
4870 3654 array('%s', '%s', '%s', '%f', '%s'), // Format: string, string, string, float, string
4871 3655 array('%d') // Where format: integer
@@ -4982,9 +3766,9 @@
4982 3766 'phrases' => implode(', ', $phrases_array),
4983 3767 'embedding_vector' => $serialized_vector,
4984 3768 'callback_function' => $callback_function,
4985 3769 'similarity_threshold' => $similarity_threshold,
4986 - 'enabled_bots' => $enabled_bots_json, // NEW field
3770 + 'enabled_bots' => $enabled_bots_json // NEW field
4987 3771 ]);
4988 3772
4989 3773 if ($result === false) {
4990 3774 $this->handle_embedding_error(__('Database error: ', 'mxchat') . $wpdb->last_error);
@@ -6056,17 +4840,8 @@
6056 4840 'mxchat-api-keys',
6057 4841 'mxchat_api_keys_section'
6058 4842 );
6059 4843
6060 - // Custom (OpenAI-compatible) Provider — for Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
6061 - add_settings_field(
6062 - 'custom_provider',
6063 - esc_html__('Custom Provider (OpenAI-compatible)', 'mxchat'),
6064 - array($this, 'custom_provider_callback'),
6065 - 'mxchat-api-keys',
6066 - 'mxchat_api_keys_section'
6067 - );
6068 -
6069 4844 // Loops API Key
6070 4845 add_settings_field(
6071 4846 'loops_api_key',
6072 4847 esc_html__('Loops API Key', 'mxchat'),
@@ -6134,22 +4909,9 @@
6134 4909 'mxchat-chatbot',
6135 4910 'mxchat_chatbot_section'
6136 4911 );
6137 4912
6138 - // Satisfaction rating toggle (plan-a5b006).
6139 4913 add_settings_field(
6140 - 'satisfaction_rating_enabled',
6141 - esc_html__('Satisfaction Rating Prompt', 'mxchat'),
6142 - array($this, 'mxchat_satisfaction_rating_toggle_callback'),
6143 - 'mxchat-chatbot',
6144 - 'mxchat_chatbot_section'
6145 - );
6146 -
6147 - // Satisfaction rating customization (plan-141a12, plan-29caac):
6148 - // the 5 customization fields are now inline-rendered inside
6149 - // mxchat_satisfaction_rating_toggle_callback's sub-options wrapper.
6150 -
6151 - add_settings_field(
6152 4914 'enable_streaming_toggle',
6153 4915 esc_html__('Enable Streaming', 'mxchat'),
6154 4916 array($this, 'enable_streaming_toggle_callback'),
6155 4917 'mxchat-chatbot',
@@ -6298,16 +5060,8 @@
6298 5060 'mxchat_chatbot_section'
6299 5061 );
6300 5062
6301 5063 add_settings_field(
6302 - 'print_button_enabled',
6303 - esc_html__('Show Download Transcript Button', 'mxchat'),
6304 - array($this, 'mxchat_print_button_toggle_callback'),
6305 - 'mxchat-chatbot',
6306 - 'mxchat_chatbot_section'
6307 - );
6308 -
6309 - add_settings_field(
6310 5064 'popular_question_1',
6311 5065 esc_html__('Quick Question 1', 'mxchat'),
6312 5066 array($this, 'mxchat_popular_question_1_callback'),
6313 5067 'mxchat-chatbot',
@@ -6697,16 +5451,8 @@
6697 5451 'mxchat_transcripts_notification_section'
6698 5452 );
6699 5453
6700 5454 add_settings_field(
6701 - 'mxchat_retention_days',
6702 - esc_html__('Custom Retention (Days)', 'mxchat'),
6703 - array($this, 'mxchat_retention_days_callback'),
6704 - 'mxchat-transcripts',
6705 - 'mxchat_transcripts_notification_section'
6706 - );
6707 -
6708 - add_settings_field(
6709 5455 'mxchat_auto_email_transcript',
6710 5456 esc_html__('Auto-Email Full Transcript', 'mxchat'),
6711 5457 array($this, 'mxchat_auto_email_transcript_callback'),
6712 5458 'mxchat-transcripts',
@@ -7006,136 +5752,8 @@
7006 5752 echo '<p class="description">' . esc_html__('Required for OpenRouter models. Get your API key from OpenRouter.ai', 'mxchat') . '</p>';
7007 5753 echo '</div>';
7008 5754 }
7009 5755
7010 -// Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
7011 -public function custom_provider_callback() {
7012 - $base_url = isset($this->options['custom_provider_base_url']) ? esc_attr($this->options['custom_provider_base_url']) : '';
7013 - $api_key = isset($this->options['custom_provider_api_key']) ? esc_attr($this->options['custom_provider_api_key']) : '';
7014 - $model_name = isset($this->options['custom_provider_model']) ? esc_attr($this->options['custom_provider_model']) : '';
7015 - $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? esc_attr($this->options['custom_provider_auth_scheme']) : 'bearer';
7016 - $api_version = isset($this->options['custom_provider_api_version']) ? esc_attr($this->options['custom_provider_api_version']) : '';
7017 - $use_embed = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
7018 - $use_images = !empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on';
7019 - $embed_model = isset($this->options['custom_provider_embedding_model']) ? esc_attr($this->options['custom_provider_embedding_model']) : '';
7020 - $nonce = wp_create_nonce('mxchat_autosave_nonce');
7021 - $test_nonce = wp_create_nonce('mxchat_test_custom_provider');
7022 -
7023 - echo '<style>
7024 - .mxchat-cp { max-width: 680px; }
7025 - .mxchat-cp .mxchat-cp-intro { margin: 0 0 16px; color: #50575e; font-size: 13px; line-height: 1.5; }
7026 - .mxchat-cp .mxchat-cp-row { display: block; margin: 0 0 18px; }
7027 - .mxchat-cp .mxchat-cp-row > label { display: block; font-weight: 600; margin: 0 0 6px; color: #1d2327; font-size: 13px; }
7028 - .mxchat-cp .mxchat-cp-row > input[type="text"],
7029 - .mxchat-cp .mxchat-cp-row > input[type="password"],
7030 - .mxchat-cp .mxchat-cp-row > select { display: block; width: 100%; max-width: 480px; margin: 0; }
7031 - .mxchat-cp .mxchat-cp-row > .description { display: block; margin: 6px 0 0; color: #646970; font-size: 12px; line-height: 1.5; max-width: 480px; }
7032 - .mxchat-cp .mxchat-cp-test { margin-top: 4px; padding-top: 14px; border-top: 1px solid #e5e7eb; }
7033 - .mxchat-cp .mxchat-cp-test .mxchat-cp-test-status { display: inline-block; margin-left: 10px; vertical-align: middle; font-size: 13px; }
7034 - .mxchat-cp .mxchat-cp-azure { margin: 0 0 18px; padding: 12px 14px; background: #f6f7ff; border: 1px solid #dfe1f5; border-left: 3px solid #7873f5; border-radius: 6px; max-width: 480px; }
7035 - .mxchat-cp .mxchat-cp-azure > .mxchat-cp-azure-title { display: block; font-weight: 600; color: #1d2327; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; margin: 0 0 8px; }
7036 - .mxchat-cp .mxchat-cp-azure ol { margin: 0; padding: 0 0 0 18px; color: #50575e; font-size: 12px; line-height: 1.7; }
7037 - .mxchat-cp .mxchat-cp-azure code { background: #eceefb; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
7038 - </style>';
7039 -
7040 - echo '<div class="api-key-wrapper mxchat-cp">';
7041 - echo '<p class="mxchat-cp-intro">' . esc_html__('Point MxChat at any OpenAI-compatible /v1/chat/completions endpoint: Ollama, LM Studio, vLLM, llama.cpp, LocalAI, Azure OpenAI, etc. Then select "Custom (OpenAI-compatible)" in the model picker.', 'mxchat') . '</p>';
7042 -
7043 - // Azure OpenAI quick start — consolidates the 4-field Azure recipe in one scannable callout
7044 - // so admins can configure Azure without piecing it together from each field's hint.
7045 - echo '<div class="mxchat-cp-azure">';
7046 - echo '<span class="mxchat-cp-azure-title">' . esc_html__('Azure OpenAI quick start', 'mxchat') . '</span>';
7047 - echo '<ol>';
7048 - echo '<li>' . wp_kses(__('<strong>Base URL</strong> → <code>https://&lt;resource&gt;.openai.azure.com/openai/deployments/&lt;deployment&gt;</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7049 - echo '<li>' . wp_kses(__('<strong>API Key</strong> → your Azure OpenAI key (required)', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7050 - echo '<li>' . wp_kses(__('<strong>Auth Scheme</strong> → <code>api-key header (Azure OpenAI)</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7051 - echo '<li>' . wp_kses(__('<strong>API Version</strong> → required for Azure, e.g. <code>2024-08-01-preview</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7052 - echo '</ol>';
7053 - echo '</div>';
7054 -
7055 - echo '<div class="mxchat-cp-row">';
7056 - echo '<label for="custom_provider_base_url">' . esc_html__('Base URL', 'mxchat') . '</label>';
7057 - echo '<input type="text" id="custom_provider_base_url" name="custom_provider_base_url" value="' . $base_url . '" class="regular-text mxchat-autosave-field" placeholder="http://localhost:11434/v1" autocomplete="off" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7058 - echo '<p class="description">' . esc_html__('Examples: Ollama http://localhost:11434/v1 · LM Studio http://localhost:1234/v1 · vLLM http://gpu:8000/v1 · Azure https://<resource>.openai.azure.com/openai/deployments/<deployment>', 'mxchat') . '</p>';
7059 - echo '</div>';
7060 -
7061 - echo '<div class="mxchat-cp-row">';
7062 - echo '<label for="custom_provider_api_key">' . esc_html__('API Key (optional)', 'mxchat') . '</label>';
7063 - echo '<input type="password" id="custom_provider_api_key" name="custom_provider_api_key" value="' . $api_key . '" class="regular-text mxchat-autosave-field mxchat-api-key-field" autocomplete="new-password" data-lpignore="true" data-form-type="other" data-nonce="' . $nonce . '" />';
7064 - echo '<p class="description">' . esc_html__('Leave empty for unauthenticated local servers. Required for Azure / vLLM / hosted endpoints.', 'mxchat') . '</p>';
7065 - echo '</div>';
7066 -
7067 - echo '<div class="mxchat-cp-row">';
7068 - echo '<label for="custom_provider_model">' . esc_html__('Model Name', 'mxchat') . '</label>';
7069 - echo '<input type="text" id="custom_provider_model" name="custom_provider_model" value="' . $model_name . '" class="regular-text mxchat-autosave-field" placeholder="llama3.2" autocomplete="off" data-lpignore="true" data-nonce="' . $nonce . '" />';
7070 - echo '<p class="description">' . esc_html__('The model identifier the upstream server expects (e.g. llama3.2, mistral, gpt-oss). For Azure this is the deployment ID — leave empty if the Base URL already includes /deployments/<deployment>.', 'mxchat') . '</p>';
7071 - echo '</div>';
7072 -
7073 - echo '<div class="mxchat-cp-row">';
7074 - echo '<label for="custom_provider_auth_scheme">' . esc_html__('Auth Scheme', 'mxchat') . '</label>';
7075 - echo '<select id="custom_provider_auth_scheme" name="custom_provider_auth_scheme" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
7076 - echo '<option value="bearer"' . selected($auth_scheme, 'bearer', false) . '>' . esc_html__('Authorization: Bearer (OpenAI / Ollama / vLLM / LM Studio)', 'mxchat') . '</option>';
7077 - echo '<option value="api-key"' . selected($auth_scheme, 'api-key', false) . '>' . esc_html__('api-key header (Azure OpenAI)', 'mxchat') . '</option>';
7078 - echo '</select>';
7079 - echo '<p class="description">' . esc_html__('Most OpenAI-compatible servers use Bearer. Azure OpenAI uses the api-key header.', 'mxchat') . '</p>';
7080 - echo '</div>';
7081 -
7082 - echo '<div class="mxchat-cp-row">';
7083 - echo '<label for="custom_provider_api_version">' . esc_html__('API Version (Azure only)', 'mxchat') . '</label>';
7084 - echo '<input type="text" id="custom_provider_api_version" name="custom_provider_api_version" value="' . $api_version . '" class="regular-text mxchat-autosave-field" placeholder="2024-08-01-preview" autocomplete="off" data-lpignore="true" data-nonce="' . $nonce . '" />';
7085 - echo '<p class="description">' . esc_html__('Appended as ?api-version=... on the request URL. Required for Azure OpenAI; leave empty for non-Azure providers.', 'mxchat') . '</p>';
7086 - echo '</div>';
7087 -
7088 - // Extended-use checkboxes — opt-in routing of other dispatcher paths through the custom provider.
7089 - echo '<div class="mxchat-cp-row">';
7090 - echo '<label style="font-weight:600; display:block; margin:0 0 6px; color:#1d2327; font-size:13px;">' . esc_html__('Extended routing (opt-in)', 'mxchat') . '</label>';
7091 - echo '<label style="display:block; margin:0 0 6px; font-weight:400;"><input type="checkbox" id="custom_provider_for_embeddings" name="custom_provider_for_embeddings" value="on"' . checked($use_embed, true, false) . ' class="mxchat-autosave-field" data-nonce="' . $nonce . '" /> ' . esc_html__('Use custom provider for embeddings', 'mxchat') . '</label>';
7092 - echo '<label style="display:block; margin:0 0 0; font-weight:400;"><input type="checkbox" id="custom_provider_for_images" name="custom_provider_for_images" value="on"' . checked($use_images, true, false) . ' class="mxchat-autosave-field" data-nonce="' . $nonce . '" /> ' . esc_html__('Use custom provider for image generation', 'mxchat') . '</label>';
7093 - echo '<p class="description">' . esc_html__('When off, embeddings and image generation continue to use OpenAI (current behavior). Turn on only if your endpoint exposes OpenAI-compatible /embeddings or /images/generations routes (e.g. Ollama, vLLM, LocalAI).', 'mxchat') . '</p>';
7094 - echo '</div>';
7095 -
7096 - echo '<div class="mxchat-cp-row">';
7097 - echo '<label for="custom_provider_embedding_model">' . esc_html__('Custom Embedding Model', 'mxchat') . '</label>';
7098 - echo '<input type="text" id="custom_provider_embedding_model" name="custom_provider_embedding_model" value="' . $embed_model . '" class="regular-text mxchat-autosave-field" placeholder="nomic-embed-text" autocomplete="off" data-lpignore="true" data-nonce="' . $nonce . '" />';
7099 - echo '<p class="description">' . esc_html__('Only used when "Use custom provider for embeddings" is on. The embedding model name is separate from the chat model name above (e.g. Ollama embedding models: nomic-embed-text, mxbai-embed-large). Leave blank to fall back to the chat model name.', 'mxchat') . '</p>';
7100 - echo '</div>';
7101 -
7102 - echo '<div class="mxchat-cp-test">';
7103 - echo '<button type="button" class="button" id="mxchat-test-custom-provider" data-nonce="' . $test_nonce . '">' . esc_html__('Test Connection', 'mxchat') . '</button>';
7104 - echo '<span id="mxchat-test-custom-provider-result" class="mxchat-cp-test-status"></span>';
7105 - echo '</div>';
7106 -
7107 - echo '<script>(function(){
7108 - var btn = document.getElementById("mxchat-test-custom-provider");
7109 - if (!btn || btn._wired) { return; } btn._wired = true;
7110 - btn.addEventListener("click", function(){
7111 - var out = document.getElementById("mxchat-test-custom-provider-result");
7112 - out.textContent = "' . esc_js(__('Testing...', 'mxchat')) . '";
7113 - out.style.color = "#646970";
7114 - var fd = new FormData();
7115 - fd.append("action", "mxchat_test_custom_provider");
7116 - fd.append("_wpnonce", btn.getAttribute("data-nonce"));
7117 - fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })
7118 - .then(function(r){ return r.json(); })
7119 - .then(function(j){
7120 - if (j && j.success) {
7121 - out.textContent = "✓ " + (j.data && j.data.message ? j.data.message : "' . esc_js(__('OK', 'mxchat')) . '");
7122 - out.style.color = "#00a32a";
7123 - } else {
7124 - out.textContent = "⚠ " + (j && j.data && j.data.message ? j.data.message : "' . esc_js(__('Failed', 'mxchat')) . '");
7125 - out.style.color = "#d63638";
7126 - }
7127 - })
7128 - .catch(function(){
7129 - out.textContent = "⚠ ' . esc_js(__('Request failed', 'mxchat')) . '";
7130 - out.style.color = "#d63638";
7131 - });
7132 - });
7133 - })();</script>';
7134 -
7135 - echo '</div>';
7136 -}
7137 -
7138 5756 // Voyage API Key
7139 5757 public function voyage_api_key_callback() {
7140 5758 $apiKey = isset($this->options['voyage_api_key']) ? esc_attr($this->options['voyage_api_key']) : '';
7141 5759 $nonce = wp_create_nonce('mxchat_autosave_nonce');
@@ -7344,15 +5962,60 @@
7344 5962 }
7345 5963
7346 5964
7347 5965 public function mxchat_model_callback() {
7348 - // Catalog refactor (plan-d14e89): single source of truth lives in
7349 - // includes/class-mxchat-model-catalog.php. Dropdown groups are the
7350 - // provider labels; each group maps model_id => "Label" strings.
7351 - if (!class_exists('MxChat_Model_Catalog')) {
7352 - require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
7353 - }
7354 - $models = MxChat_Model_Catalog::settings_dropdown_groups();
5966 + // Define available models grouped by provider
5967 + $models = array(
5968 + esc_html__('OpenRouter (100+ Models)', 'mxchat') => array(
5969 + 'openrouter' => esc_html__('OpenRouter - Select after entering API key', 'mxchat'),
5970 + ),
5971 + esc_html__('Google Gemini Models', 'mxchat') => array(
5972 + 'gemini-3-pro-preview' => esc_html__('Gemini 3 Pro (Most Intelligent, Multimodal)', 'mxchat'),
5973 + 'gemini-3-flash-preview' => esc_html__('Gemini 3 Flash (Balanced Speed & Scale)', 'mxchat'),
5974 + 'gemini-2.5-pro' => esc_html__('Gemini 2.5 Pro (Advanced Thinking)', 'mxchat'),
5975 + 'gemini-2.5-flash' => esc_html__('Gemini 2.5 Flash (Best Price-Performance)', 'mxchat'),
5976 + 'gemini-2.5-flash-lite' => esc_html__('Gemini 2.5 Flash-Lite (Ultra Fast)', 'mxchat'),
5977 + 'gemini-2.0-flash' => esc_html__('Gemini 2.0 Flash (Deprecated Mar 2026)', 'mxchat'),
5978 + 'gemini-2.0-flash-lite' => esc_html__('Gemini 2.0 Flash-Lite (Deprecated Mar 2026)', 'mxchat'),
5979 + 'gemini-1.5-pro' => esc_html__('Gemini 1.5 Pro (Deprecated Sep 2025)', 'mxchat'),
5980 + 'gemini-1.5-flash' => esc_html__('Gemini 1.5 Flash (Deprecated Sep 2025)', 'mxchat'),
5981 + ),
5982 + esc_html__('X.AI Models', 'mxchat') => array(
5983 + 'grok-3-beta' => esc_html__('Grok-3 (Powerful)', 'mxchat'),
5984 + 'grok-3-fast-beta' => esc_html__('Grok-3 Fast (High Performance)', 'mxchat'),
5985 + 'grok-3-mini-beta' => esc_html__('Grok-3 Mini (Affordable)', 'mxchat'),
5986 + 'grok-3-mini-fast-beta' => esc_html__('Grok-3 Mini Fast (Quick Response)', 'mxchat'),
5987 + 'grok-2' => esc_html__('Grok 2', 'mxchat'),
5988 + 'grok-4-0709' => esc_html__('Grok 4 (Latest Flagship)', 'mxchat'),
5989 + 'grok-4-1-fast-reasoning' => esc_html__('Grok 4.1 Fast (Reasoning)', 'mxchat'),
5990 + 'grok-4-1-fast-non-reasoning' => esc_html__('Grok 4.1 Fast (Non-Reasoning)', 'mxchat'),
5991 + ),
5992 + esc_html__('DeepSeek Models', 'mxchat') => array(
5993 + 'deepseek-chat' => esc_html__('DeepSeek-V3', 'mxchat'),
5994 + ),
5995 + esc_html__('Claude Models', 'mxchat') => array(
5996 + 'claude-opus-4-6' => esc_html__('Claude Opus 4.6 (Most Capable - Recommended)', 'mxchat'),
5997 + 'claude-sonnet-4-6' => esc_html__('Claude Sonnet 4.6 (Latest Sonnet - Fast & Capable)', 'mxchat'),
5998 + 'claude-opus-4-5' => esc_html__('Claude Opus 4.5 (Highly Capable)', 'mxchat'),
5999 + 'claude-sonnet-4-5-20250929' => esc_html__('Claude Sonnet 4.5 (Best for Agents & Coding)', 'mxchat'),
6000 + 'claude-opus-4-1-20250805' => esc_html__('Claude Opus 4.1 (Exceptional for Complex Tasks)', 'mxchat'),
6001 + 'claude-haiku-4-5-20251001' => esc_html__('Claude Haiku 4.5 (Fastest & Most Intelligent)', 'mxchat'),
6002 + 'claude-opus-4-20250514' => esc_html__('Claude 4 Opus (Complex Tasks)', 'mxchat'),
6003 + 'claude-sonnet-4-20250514' => esc_html__('Claude 4 Sonnet (High Performance)', 'mxchat'),
6004 + ),
6005 + esc_html__('OpenAI Models', 'mxchat') => array(
6006 + 'gpt-5.4' => esc_html__('GPT-5.4 (Flagship Reasoning & Coding)', 'mxchat'),
6007 + 'gpt-5.4-mini' => esc_html__('GPT-5.4 Mini (Fast, 400K Context)', 'mxchat'),
6008 + 'gpt-5.4-nano' => esc_html__('GPT-5.4 Nano (Fastest & Cheapest)', 'mxchat'),
6009 + 'gpt-5.3-chat-latest' => esc_html__('GPT-5.3 Chat (Conversational)', 'mxchat'),
6010 + 'gpt-5.2' => esc_html__('GPT-5.2 (Best General-Purpose & Agentic Model)', 'mxchat'),
6011 + 'gpt-5.1-chat-latest' => esc_html__('GPT-5.1 Chat Latest (Recommended)', 'mxchat'),
6012 + 'gpt-5.1-2025-11-13' => esc_html__('GPT-5.1 (Flagship for Coding & Agentic Tasks)', 'mxchat'),
6013 + 'gpt-5' => esc_html__('GPT-5 (Flagship for Coding, Reasoning & Agents)', 'mxchat'),
6014 + 'gpt-5-mini' => esc_html__('GPT-5 Mini (Fast and Lightweight)', 'mxchat'),
6015 + 'gpt-5-nano' => esc_html__('GPT-5 Nano (Fastest & Cheapest for Summarization/Classification)', 'mxchat'),
6016 + ),
6017 + );
7355 6018
7356 6019 // Retrieve the currently selected model from saved options
7357 6020 $selected_model = isset($this->options['model']) ? esc_attr($this->options['model']) : 'gpt-5.1-chat-latest';
7358 6021
@@ -7461,15 +6124,15 @@
7461 6124 );
7462 6125 echo '<span class="slider"></span>';
7463 6126 echo '</label>';
7464 6127
7465 - // Test button — branded .mxch-btn with inline SVG (IDs preserved for AJAX binding)
7466 - echo '<div class="mxch-streaming-test-row">';
7467 - echo '<button type="button" id="mxchat-test-streaming-btn" class="mxch-btn mxch-btn-secondary">';
7468 - echo '<svg class="mxch-streaming-test-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
6128 + // Test button with better styling
6129 + echo '<div style="margin-top: 15px;">';
6130 + echo '<button type="button" id="mxchat-test-streaming-btn" class="button button-secondary">';
6131 + echo '<span class="dashicons dashicons-admin-tools" style="vertical-align: middle; margin-right: 5px;"></span>';
7469 6132 echo esc_html__('Test Streaming Compatibility', 'mxchat');
7470 6133 echo '</button>';
7471 - echo '<p id="mxchat-test-streaming-result" class="mxch-streaming-test-result"></p>';
6134 + echo '<p id="mxchat-test-streaming-result" style="margin-top:10px; font-weight: bold;"></p>';
7472 6135 echo '</div>';
7473 6136 }
7474 6137
7475 6138 // Web Search toggle callback
@@ -7480,23 +6143,17 @@
7480 6143
7481 6144 // Get current model to determine if we should show/enable the toggle
7482 6145 $current_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
7483 6146
7484 - // Models that DON'T support web search — OpenAI-docs-driven exception list.
7485 - // Keep hardcoded; the catalog can't infer "supports web search" per-model, so any
7486 - // future OpenAI model that lacks Responses-API web_search support is added here.
6147 + // Models that DON'T support web search (per OpenAI docs)
6148 + // gpt-5 with minimal reasoning is handled at API level, gpt-4.1-nano doesn't support it
7487 6149 $unsupported_models = array('gpt-4.1-nano');
7488 6150
7489 - // OpenAI chat-model allowlist for the Web Search toggle, derived from the central
7490 - // model catalog (class-mxchat-model-catalog.php). When a new OpenAI chat model is
7491 - // added there, the Web Search toggle picks it up automatically — no edit here.
7492 - if (!class_exists('MxChat_Model_Catalog')) {
7493 - require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
7494 - }
7495 - $chat_catalog = MxChat_Model_Catalog::chat_models();
7496 - $openai_models = (isset($chat_catalog['openai']['models']) && is_array($chat_catalog['openai']['models']))
7497 - ? array_keys($chat_catalog['openai']['models'])
7498 - : array();
6151 + // Check if current model is an OpenAI model that supports web search
6152 + $openai_models = array(
6153 + 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-5.3-chat-latest',
6154 + 'gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.1-2025-11-13', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano'
6155 + );
7499 6156
7500 6157 $is_openai = in_array($current_model, $openai_models);
7501 6158 $is_supported = $is_openai && !in_array($current_model, $unsupported_models);
7502 6159
@@ -7622,9 +6279,8 @@
7622 6279 } else {
7623 6280 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
7624 6281 }
7625 6282 echo '</p>';
7626 -
7627 6283 }
7628 6284
7629 6285
7630 6286 public function mxchat_top_bar_title_callback() {
@@ -7859,125 +6515,8 @@
7859 6515 echo '<span class="slider"></span>';
7860 6516 echo '</label>';
7861 6517 }
7862 6518
7863 -/**
7864 - * Toggle for the end-of-session satisfaction rating prompt (plan-a5b006).
7865 - * Default ON. The widget reads this through the localized object; the
7866 - * mxchat_satisfaction_rating_enabled filter still lets developers force
7867 - * the value site-wide.
7868 - *
7869 - * The 5 customization fields (idle/question/thanks/placeholder/saved) are
7870 - * rendered inline here inside a single wrapper div whose initial display
7871 - * is set server-side from the toggle value (plan-29caac). Mirrors the
7872 - * auto-display chatbot pattern at mxchat_append_to_body_callback — no
7873 - * DOMContentLoaded race because rows exist as direct children of this
7874 - * callback's output, and the wrapper's display: none is inline at render
7875 - * time so refresh shows the correct state with no flash.
7876 - */
7877 -public function mxchat_satisfaction_rating_toggle_callback() {
7878 - $options = $this->options;
7879 - $value = isset($options['satisfaction_rating_enabled']) ? $options['satisfaction_rating_enabled'] : 'off';
7880 - $checked = ($value === 'on') ? 'checked' : '';
7881 -
7882 - $idle = isset($options['satisfaction_rating_idle_seconds']) ? intval($options['satisfaction_rating_idle_seconds']) : 60;
7883 - $idle = max(5, min(600, $idle));
7884 - $question = isset($options['satisfaction_rating_question']) ? $options['satisfaction_rating_question'] : '';
7885 - $thanks = isset($options['satisfaction_rating_thanks']) ? $options['satisfaction_rating_thanks'] : '';
7886 - $placeholder = isset($options['satisfaction_rating_placeholder']) ? $options['satisfaction_rating_placeholder'] : '';
7887 - $saved = isset($options['satisfaction_rating_saved']) ? $options['satisfaction_rating_saved'] : '';
7888 -
7889 - echo '<div class="mxchat-autosave-section">';
7890 -
7891 - echo '<label class="toggle-switch">';
7892 - echo sprintf(
7893 - '<input type="checkbox" id="satisfaction_rating_enabled" name="satisfaction_rating_enabled" value="on" %s />',
7894 - esc_attr($checked)
7895 - );
7896 - echo '<span class="slider"></span>';
7897 - echo '</label>';
7898 -
7899 - ?>
7900 - <style>
7901 - .mxchat-sub-options-field { margin: 12px 0; }
7902 - .mxchat-sub-options-field label { display: inline-block; margin-bottom: 4px; }
7903 - #satisfaction-rating-sub-options h4 { margin: 16px 0 8px; }
7904 - </style>
7905 - <?php
7906 -
7907 - $display_style = ($value === 'on') ? '' : 'display: none;';
7908 - echo '<div id="satisfaction-rating-sub-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
7909 -
7910 - echo '<h4>' . esc_html__('Customize the prompt (optional)', 'mxchat') . '</h4>';
7911 -
7912 - echo '<div class="mxchat-sub-options-field">';
7913 - echo '<label for="satisfaction_rating_idle_seconds"><strong>' . esc_html__('Idle Timeout', 'mxchat') . '</strong></label><br />';
7914 - printf(
7915 - '<input type="number" id="satisfaction_rating_idle_seconds" name="satisfaction_rating_idle_seconds" value="%d" min="5" max="600" step="1" class="small-text" /> <span class="description">%s</span>',
7916 - (int) $idle,
7917 - esc_html__('seconds of user inactivity before the prompt appears (5-600)', 'mxchat')
7918 - );
7919 - echo '</div>';
7920 -
7921 - echo '<div class="mxchat-sub-options-field">';
7922 - echo '<label for="satisfaction_rating_question"><strong>' . esc_html__('Prompt Question', 'mxchat') . '</strong></label><br />';
7923 - printf(
7924 - '<input type="text" id="satisfaction_rating_question" name="satisfaction_rating_question" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
7925 - esc_attr($question),
7926 - esc_attr__('Was this helpful?', 'mxchat')
7927 - );
7928 - echo '<p class="description">' . esc_html__('Leave blank for the default. Shown above the thumbs up/down.', 'mxchat') . '</p>';
7929 - echo '</div>';
7930 -
7931 - echo '<div class="mxchat-sub-options-field">';
7932 - echo '<label for="satisfaction_rating_thanks"><strong>' . esc_html__('Thank-You Message', 'mxchat') . '</strong></label><br />';
7933 - printf(
7934 - '<input type="text" id="satisfaction_rating_thanks" name="satisfaction_rating_thanks" value="%s" maxlength="300" class="regular-text" placeholder="%s" />',
7935 - esc_attr($thanks),
7936 - esc_attr__('Thanks! Anything we should improve? (optional)', 'mxchat')
7937 - );
7938 - echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the user clicks a thumb.', 'mxchat') . '</p>';
7939 - echo '</div>';
7940 -
7941 - echo '<div class="mxchat-sub-options-field">';
7942 - echo '<label for="satisfaction_rating_placeholder"><strong>' . esc_html__('Feedback Placeholder', 'mxchat') . '</strong></label><br />';
7943 - printf(
7944 - '<input type="text" id="satisfaction_rating_placeholder" name="satisfaction_rating_placeholder" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
7945 - esc_attr($placeholder),
7946 - esc_attr__('Tell us what could be better…', 'mxchat')
7947 - );
7948 - echo '<p class="description">' . esc_html__('Leave blank for the default. Placeholder text inside the feedback textarea.', 'mxchat') . '</p>';
7949 - echo '</div>';
7950 -
7951 - echo '<div class="mxchat-sub-options-field">';
7952 - echo '<label for="satisfaction_rating_saved"><strong>' . esc_html__('Saved Confirmation', 'mxchat') . '</strong></label><br />';
7953 - printf(
7954 - '<input type="text" id="satisfaction_rating_saved" name="satisfaction_rating_saved" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
7955 - esc_attr($saved),
7956 - esc_attr__('Thanks for the feedback.', 'mxchat')
7957 - );
7958 - echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the feedback is sent.', 'mxchat') . '</p>';
7959 - echo '</div>';
7960 -
7961 - echo '</div>'; // #satisfaction-rating-sub-options
7962 - echo '</div>'; // .mxchat-autosave-section
7963 -
7964 - ?>
7965 - <script>
7966 - (function() {
7967 - document.addEventListener('DOMContentLoaded', function() {
7968 - var toggle = document.getElementById('satisfaction_rating_enabled');
7969 - var subOptions = document.getElementById('satisfaction-rating-sub-options');
7970 - if (!toggle || !subOptions) return;
7971 - toggle.addEventListener('change', function() {
7972 - subOptions.style.display = toggle.checked ? '' : 'none';
7973 - });
7974 - });
7975 - })();
7976 - </script>
7977 - <?php
7978 -}
7979 -
7980 6519 public function mxchat_privacy_toggle_callback() {
7981 6520 // Load from mxchat_options array
7982 6521 $options = get_option('mxchat_options', []);
7983 6522
@@ -8060,26 +6599,8 @@
8060 6599 echo '<span class="slider"></span>';
8061 6600 echo '</label>';
8062 6601 }
8063 6602
8064 -public function mxchat_print_button_toggle_callback() {
8065 - // Load from mxchat_options array
8066 - $options = get_option('mxchat_options', []);
8067 -
8068 - // Default ON — the option was previously unexposed and the button always showed.
8069 - $print_button_enabled = isset($options['print_button_enabled']) ? $options['print_button_enabled'] : 'on';
8070 - $checked = ($print_button_enabled === 'on') ? 'checked' : '';
8071 -
8072 - // Output the toggle switch
8073 - echo '<label class="toggle-switch">';
8074 - echo sprintf(
8075 - '<input type="checkbox" id="print_button_enabled" name="print_button_enabled" value="on" %s />',
8076 - esc_attr($checked)
8077 - );
8078 - echo '<span class="slider"></span>';
8079 - echo '</label>';
8080 -}
8081 -
8082 6603 public function mxchat_popular_question_1_callback() {
8083 6604 // Load the full plugin options array
8084 6605 $all_options = get_option('mxchat_options', []);
8085 6606
@@ -8663,20 +7184,8 @@
8663 7184 );
8664 7185 echo '</div>';
8665 7186 }
8666 7187
8667 -/**
8668 - * Add body class on the Onboarding wizard page so CSS-only chrome surgery
8669 - * (collapsing the WP admin sidebar) only applies on this page.
8670 - * Plan: plan-mxchat-20260527-905439.
8671 - */
8672 -public function mxchat_add_onboarding_body_class($classes) {
8673 - if (isset($_GET['page']) && $_GET['page'] === 'mxchat-onboarding') {
8674 - $classes .= ' mxchat-onboarding-focused';
8675 - }
8676 - return $classes;
8677 -}
8678 -
8679 7188 public function mxchat_enqueue_admin_assets() {
8680 7189 // Get plugin version
8681 7190 $version = MXCHAT_VERSION;
8682 7191
@@ -8778,38 +7287,8 @@
8778 7287 wp_enqueue_style('mxchat-content-css', $plugin_url . 'css/admin-content.css', array('mxchat-admin-sidebar-css'), $version);
8779 7288 // Load content page JavaScript
8780 7289 wp_enqueue_script('mxchat-content-js', $plugin_url . 'js/mxchat-content.js', array('jquery'), $version, true);
8781 7290 break;
8782 -
8783 - case 'mxchat-api-access':
8784 - // Shared sidebar shell (CSS + JS for tab switching / mobile menu / copy buttons).
8785 - wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
8786 - wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
8787 - wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
8788 - 'copied' => __('Copied', 'mxchat'),
8789 - ));
8790 - break;
8791 -
8792 - case 'mxchat-max':
8793 - case 'mxchat-onboarding':
8794 - // Onboarding page — uses the shared admin shell PLUS the wizard
8795 - // overlay (plan-905439). admin-onboarding-wizard.css scopes the
8796 - // WP-chrome surgery to body.mxchat-onboarding-focused so it only
8797 - // applies on THIS page. The body class is added below via the
8798 - // admin_body_class filter.
8799 - wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
8800 - wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
8801 - wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
8802 - 'copied' => __('Copied', 'mxchat'),
8803 - ));
8804 - // admin-style.css provides the .mxchat-instructions-modal-* classes
8805 - // the new Behavior step's "View Sample Instructions" modal needs.
8806 - // Enqueued AFTER admin-sidebar.css but BEFORE the wizard overlay
8807 - // so the wizard's own rules win where they collide. plan-a2e4d6.
8808 - wp_enqueue_style('mxchat-admin-style-css', $plugin_url . 'css/admin-style.css', array('mxchat-admin-sidebar-css'), $version);
8809 - wp_enqueue_style('mxchat-admin-onboarding-wizard-css', $plugin_url . 'css/admin-onboarding-wizard.css', array('mxchat-admin-sidebar-css', 'mxchat-admin-style-css'), $version);
8810 - wp_enqueue_script('mxchat-admin-onboarding-wizard-js', $plugin_url . 'js/admin-onboarding-wizard.js', array(), $version, true);
8811 - break;
8812 7291 default:
8813 7292 wp_enqueue_script(
8814 7293 'mxchat-test-streaming-js',
8815 7294 $plugin_url . 'js/mxchat-test-streaming.js',
@@ -8922,16 +7401,8 @@
8922 7401
8923 7402 // Localize main admin script with base data
8924 7403 wp_localize_script('mxchat-admin-js', 'mxchatAdmin', $base_data);
8925 7404
8926 - // Canonical chat-model catalog for the modal picker grid
8927 - // (plan-d14e89). Adding a model in class-mxchat-model-catalog.php
8928 - // automatically appears here.
8929 - if (!class_exists('MxChat_Model_Catalog')) {
8930 - require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
8931 - }
8932 - wp_localize_script('mxchat-admin-js', 'mxchatChatModelCatalog', MxChat_Model_Catalog::js_picker_shape());
8933 -
8934 7405 // Page-specific localizations
8935 7406 $this->localize_page_specific_scripts($current_page);
8936 7407 }
8937 7408 private function localize_page_specific_scripts($current_page) {
@@ -8943,22 +7414,17 @@
8943 7414 'nonce' => wp_create_nonce('mxchat_status_nonce')
8944 7415 ));
8945 7416
8946 7417 // Content selector localization
8947 - $mxchat_options_for_selector = get_option('mxchat_options', array());
8948 - $acf_pdf_extract_default = !empty($mxchat_options_for_selector['acf_pdf_extract_default']);
8949 7418 wp_localize_script('mxchat-content-selector-js', 'mxchatSelector', array(
8950 7419 'ajaxurl' => admin_url('admin-ajax.php'),
8951 7420 'nonce' => wp_create_nonce('mxchat_content_selector_nonce'),
8952 - 'acfPdfExtractDefault' => $acf_pdf_extract_default ? 1 : 0,
8953 7421 'i18n' => array(
8954 7422 'searchPlaceholder' => __('Search posts and pages...', 'mxchat'),
8955 7423 'selectAll' => __('Select All', 'mxchat'),
8956 7424 'process' => __('Process Selected', 'mxchat'),
8957 7425 'cancel' => __('Cancel', 'mxchat'),
8958 - 'noResults' => __('No content found.', 'mxchat'),
8959 - 'extractingPdfs' => __('extracting %d PDF(s)...', 'mxchat'),
8960 - 'pdfExtractedSuffix' => __(' (%d PDF(s) extracted)', 'mxchat')
7426 + 'noResults' => __('No content found.', 'mxchat')
8961 7427 )
8962 7428 ));
8963 7429
8964 7430 wp_localize_script('mxchat-knowledge-processing', 'mxchatAdmin', array(
@@ -9192,35 +7658,8 @@
9192 7658 if (isset($input['citation_links_toggle'])) {
9193 7659 $new_input['citation_links_toggle'] = $input['citation_links_toggle'] === 'on' ? 'on' : 'off';
9194 7660 }
9195 7661
9196 - // Satisfaction rating prompt — defaults ON when unchecked (so first save
9197 - // doesn't accidentally disable it). The form posts 'on' when checked;
9198 - // unchecked checkboxes don't post a value at all, so we infer 'off' only
9199 - // when the autosave/PHP request explicitly clears it via empty string.
9200 - if (array_key_exists('satisfaction_rating_enabled', $input)) {
9201 - $new_input['satisfaction_rating_enabled'] = $input['satisfaction_rating_enabled'] === 'on' ? 'on' : 'off';
9202 - }
9203 -
9204 - // Satisfaction rating customization (plan-141a12). Idle is clamped 5-600;
9205 - // the 4 text strings are sanitized + capped server-side. Blank values are
9206 - // preserved so the integrator falls back to translated defaults.
9207 - if (array_key_exists('satisfaction_rating_idle_seconds', $input)) {
9208 - $new_input['satisfaction_rating_idle_seconds'] = max(5, min(600, intval($input['satisfaction_rating_idle_seconds'])));
9209 - }
9210 - if (array_key_exists('satisfaction_rating_question', $input)) {
9211 - $new_input['satisfaction_rating_question'] = mb_substr(sanitize_text_field($input['satisfaction_rating_question']), 0, 200);
9212 - }
9213 - if (array_key_exists('satisfaction_rating_thanks', $input)) {
9214 - $new_input['satisfaction_rating_thanks'] = mb_substr(sanitize_text_field($input['satisfaction_rating_thanks']), 0, 300);
9215 - }
9216 - if (array_key_exists('satisfaction_rating_placeholder', $input)) {
9217 - $new_input['satisfaction_rating_placeholder'] = mb_substr(sanitize_text_field($input['satisfaction_rating_placeholder']), 0, 200);
9218 - }
9219 - if (array_key_exists('satisfaction_rating_saved', $input)) {
9220 - $new_input['satisfaction_rating_saved'] = mb_substr(sanitize_text_field($input['satisfaction_rating_saved']), 0, 200);
9221 - }
9222 -
9223 7662 if (isset($input['top_bar_title'])) {
9224 7663 $new_input['top_bar_title'] = sanitize_text_field($input['top_bar_title']);
9225 7664 }
9226 7665
@@ -9272,13 +7711,9 @@
9272 7711
9273 7712 // Sanitize limit
9274 7713 if (isset($settings['limit'])) {
9275 7714 $limit = sanitize_text_field($settings['limit']);
9276 - // Accept presets, 'unlimited', the '__custom__' sentinel, OR any positive
9277 - // integer (custom value) — mirrors the global branch (plan-2c02ea). Without
9278 - // the custom path the per-role custom input was dropped and reset to the role
9279 - // default on every save (plan-7e23e7).
9280 - if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
7715 + if (in_array($limit, $allowed_limits, true)) {
9281 7716 $new_input['rate_limits'][$role_id]['limit'] = $limit;
9282 7717 } else {
9283 7718 $new_input['rate_limits'][$role_id]['limit'] = ($role_id === 'logged_out') ? '10' : '100'; // Default
9284 7719 }
@@ -9283,13 +7718,8 @@
9283 7718 $new_input['rate_limits'][$role_id]['limit'] = ($role_id === 'logged_out') ? '10' : '100'; // Default
9284 7719 }
9285 7720 }
9286 7721
9287 - // Preserve the per-role custom value (mirrors the global branch's limit_custom).
9288 - if (isset($settings['limit_custom'])) {
9289 - $new_input['rate_limits'][$role_id]['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $settings['limit_custom']);
9290 - }
9291 -
9292 7722 // Sanitize timeframe
9293 7723 if (isset($settings['timeframe'])) {
9294 7724 $timeframe = sanitize_text_field($settings['timeframe']);
9295 7725 if (in_array($timeframe, $allowed_timeframes, true)) {
@@ -9305,46 +7735,8 @@
9305 7735 }
9306 7736 }
9307 7737 }
9308 7738
9309 -// Handle the whole-chatbot global rate limit (plan-mxchat-20260603-2c02ea).
9310 -// Mirrors the per-role block above, adapted to the single global shape. Without
9311 -// this branch the whitelist-rebuild dropped rate_limits_global entirely on every
9312 -// save, so the global cap silently fell back to its 'unlimited' default.
9313 -// MUST accept arbitrary positive integers so the custom-value path (d55f65) is not regressed.
9314 -if (isset($input['rate_limits_global']) && is_array($input['rate_limits_global'])) {
9315 - $g = $input['rate_limits_global'];
9316 - $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
9317 - $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
9318 - $global_out = array();
9319 -
9320 - if (isset($g['limit'])) {
9321 - $limit = sanitize_text_field($g['limit']);
9322 - // Accept presets, 'unlimited', the '__custom__' sentinel (resolved by the
9323 - // autosave handler / renderer), OR any positive integer (custom value).
9324 - if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
9325 - $global_out['limit'] = $limit;
9326 - } else {
9327 - $global_out['limit'] = 'unlimited';
9328 - }
9329 - }
9330 -
9331 - if (isset($g['limit_custom'])) {
9332 - $global_out['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $g['limit_custom']);
9333 - }
9334 -
9335 - if (isset($g['timeframe'])) {
9336 - $timeframe = sanitize_text_field($g['timeframe']);
9337 - $global_out['timeframe'] = in_array($timeframe, $allowed_timeframes, true) ? $timeframe : 'daily';
9338 - }
9339 -
9340 - if (isset($g['message'])) {
9341 - $global_out['message'] = sanitize_textarea_field($g['message']);
9342 - }
9343 -
9344 - $new_input['rate_limits_global'] = $global_out;
9345 -}
9346 -
9347 7739 if (isset($input['pre_chat_message'])) {
9348 7740 $new_input['pre_chat_message'] = sanitize_textarea_field($input['pre_chat_message']);
9349 7741 }
9350 7742
@@ -9353,14 +7745,15 @@
9353 7745 }
9354 7746
9355 7747 // Add to your sanitize function
9356 7748 if (isset($input['embedding_model'])) {
9357 - // Catalog refactor (plan-d14e89): allowlist derived from the canonical
9358 - // catalog in includes/class-mxchat-model-catalog.php.
9359 - if (!class_exists('MxChat_Model_Catalog')) {
9360 - require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
9361 - }
9362 - $allowed_models = MxChat_Model_Catalog::embedding_model_ids();
7749 + $allowed_models = array(
7750 + 'text-embedding-ada-002',
7751 + 'text-embedding-3-small',
7752 + 'text-embedding-3-large',
7753 + 'voyage-3-large',
7754 + 'gemini-embedding-001'
7755 + );
9363 7756 if (in_array($input['embedding_model'], $allowed_models)) {
9364 7757 $new_input['embedding_model'] = sanitize_text_field($input['embedding_model']);
9365 7758 }
9366 7759 }
@@ -9368,12 +7761,46 @@
9368 7761 if (isset($input['model'])) {
9369 7762 if ($input['model'] === 'openrouter') {
9370 7763 $new_input['model'] = 'openrouter';
9371 7764 } else {
9372 - if (!class_exists('MxChat_Model_Catalog')) {
9373 - require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
9374 - }
9375 - $allowed_models = MxChat_Model_Catalog::chat_model_ids();
7765 + $allowed_models = array(
7766 + 'gemini-3-pro-preview',
7767 + 'gemini-3-flash-preview',
7768 + 'gemini-2.5-pro',
7769 + 'gemini-2.5-flash',
7770 + 'gemini-2.5-flash-lite',
7771 + 'gemini-2.0-flash',
7772 + 'gemini-2.0-flash-lite',
7773 + 'gemini-1.5-pro',
7774 + 'gemini-1.5-flash',
7775 + 'grok-4-0709',
7776 + 'grok-4-1-fast-reasoning',
7777 + 'grok-4-1-fast-non-reasoning',
7778 + 'grok-3-beta',
7779 + 'grok-3-fast-beta',
7780 + 'grok-3-mini-beta',
7781 + 'grok-3-mini-fast-beta',
7782 + 'grok-2',
7783 + 'deepseek-chat',
7784 + 'claude-opus-4-6',
7785 + 'claude-sonnet-4-6',
7786 + 'claude-opus-4-5',
7787 + 'claude-sonnet-4-5-20250929',
7788 + 'claude-opus-4-1-20250805',
7789 + 'claude-haiku-4-5-20251001',
7790 + 'claude-opus-4-20250514',
7791 + 'claude-sonnet-4-20250514',
7792 + 'gpt-5.2',
7793 + 'gpt-5.1-chat-latest',
7794 + 'gpt-5.1-2025-11-13',
7795 + 'gpt-5',
7796 + 'gpt-5-mini',
7797 + 'gpt-5-nano',
7798 + 'gpt-5.4',
7799 + 'gpt-5.4-mini',
7800 + 'gpt-5.4-nano',
7801 + 'gpt-5.3-chat-latest',
7802 + );
9376 7803
9377 7804 if (in_array($input['model'], $allowed_models)) {
9378 7805 $new_input['model'] = sanitize_text_field($input['model']);
9379 7806 } else {
@@ -9396,37 +7823,10 @@
9396 7823 if (isset($input['openrouter_api_key'])) {
9397 7824 $new_input['openrouter_api_key'] = sanitize_text_field($input['openrouter_api_key']);
9398 7825 }
9399 7826
9400 - // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, Azure OpenAI, etc.
9401 - if (isset($input['custom_provider_base_url'])) {
9402 - $new_input['custom_provider_base_url'] = esc_url_raw(rtrim(trim((string) $input['custom_provider_base_url']), '/'));
9403 - }
9404 - if (isset($input['custom_provider_api_key'])) {
9405 - $new_input['custom_provider_api_key'] = sanitize_text_field($input['custom_provider_api_key']);
9406 - }
9407 - if (isset($input['custom_provider_model'])) {
9408 - $new_input['custom_provider_model'] = sanitize_text_field($input['custom_provider_model']);
9409 - }
9410 - if (isset($input['custom_provider_auth_scheme'])) {
9411 - $scheme = sanitize_text_field($input['custom_provider_auth_scheme']);
9412 - $new_input['custom_provider_auth_scheme'] = in_array($scheme, array('bearer', 'api-key'), true) ? $scheme : 'bearer';
9413 - }
9414 - if (isset($input['custom_provider_for_embeddings'])) {
9415 - $new_input['custom_provider_for_embeddings'] = ($input['custom_provider_for_embeddings'] === 'on') ? 'on' : 'off';
9416 - }
9417 - if (isset($input['custom_provider_for_images'])) {
9418 - $new_input['custom_provider_for_images'] = ($input['custom_provider_for_images'] === 'on') ? 'on' : 'off';
9419 - }
9420 - if (isset($input['custom_provider_embedding_model'])) {
9421 - $new_input['custom_provider_embedding_model'] = sanitize_text_field($input['custom_provider_embedding_model']);
9422 - }
9423 - if (isset($input['custom_provider_api_version'])) {
9424 - $new_input['custom_provider_api_version'] = sanitize_text_field($input['custom_provider_api_version']);
9425 - }
9426 7827
9427 7828
9428 -
9429 7829 if (isset($input['woocommerce_consumer_key'])) {
9430 7830 $new_input['woocommerce_consumer_key'] = sanitize_text_field($input['woocommerce_consumer_key']);
9431 7831 }
9432 7832
@@ -9448,14 +7848,8 @@
9448 7848 if (isset($input['chat_persistence_toggle'])) {
9449 7849 $new_input['chat_persistence_toggle'] = $input['chat_persistence_toggle'] === 'on' ? 'on' : 'off';
9450 7850 }
9451 7851
9452 - // No else clause: an absent key stays absent, so the front-end default ('on') applies
9453 - // and the rebuild never strips a saved 'off' (autosave passes the full options array back through here).
9454 - if (isset($input['print_button_enabled'])) {
9455 - $new_input['print_button_enabled'] = $input['print_button_enabled'] === 'on' ? 'on' : 'off';
9456 - }
9457 -
9458 7852 if (isset($input['popular_question_1'])) {
9459 7853 $new_input['popular_question_1'] = sanitize_text_field($input['popular_question_1']);
9460 7854 }
9461 7855
@@ -9614,12 +8008,8 @@
9614 8008 }
9615 8009 if (isset($input['content_image_model'])) {
9616 8010 $new_input['content_image_model'] = sanitize_text_field($input['content_image_model']);
9617 8011 }
9618 - if (isset($input['content_image_quality'])) {
9619 - $q = sanitize_text_field($input['content_image_quality']);
9620 - $new_input['content_image_quality'] = in_array($q, array('auto', 'low', 'medium', 'high'), true) ? $q : 'auto';
9621 - }
9622 8012 if (isset($input['content_enable_images'])) {
9623 8013 $new_input['content_enable_images'] = ($input['content_enable_images'] === 'on') ? 'on' : 'off';
9624 8014 }
9625 8015 if (isset($input['content_use_placeholders'])) {
@@ -9630,11 +8020,8 @@
9630 8020 }
9631 8021 if (isset($input['content_tool_use'])) {
9632 8022 $new_input['content_tool_use'] = ($input['content_tool_use'] === 'on') ? 'on' : 'off';
9633 8023 }
9634 - if (isset($input['content_image_count'])) {
9635 - $new_input['content_image_count'] = (string) max(1, min(5, (int) $input['content_image_count']));
9636 - }
9637 8024
9638 8025 // SEO Optimize toggle fields
9639 8026 foreach (array('seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img') as $seo_key) {
9640 8027 if (isset($input[$seo_key])) {
@@ -9644,9 +8031,9 @@
9644 8031
9645 8032 // Preserve content generator settings when saving from main settings page
9646 8033 // (where content fields are not in the form submission)
9647 8034 $existing = get_option('mxchat_options', array());
9648 - foreach (array('content_model', 'content_image_model', 'content_image_quality', 'content_image_count', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img') as $key) {
8035 + foreach (array('content_model', 'content_image_model', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img') as $key) {
9649 8036 if (!isset($new_input[$key]) && isset($existing[$key])) {
9650 8037 $new_input[$key] = $existing[$key];
9651 8038 }
9652 8039 }
@@ -10233,9 +8620,8 @@
10233 8620 if ($embedding_dimensions !== 1536) {
10234 8621 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
10235 8622 }
10236 8623
10237 - MxChat_Utils::stamp_active_embedding_model($selected_model);
10238 8624 return $response_data['embedding']['values'];
10239 8625 } else {
10240 8626 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
10241 8627 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
@@ -10261,9 +8647,8 @@
10261 8647 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
10262 8648 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
10263 8649 }
10264 8650
10265 - MxChat_Utils::stamp_active_embedding_model($selected_model);
10266 8651 return $response_data['data'][0]['embedding'];
10267 8652 } else {
10268 8653 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
10269 8654 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));