false, 'position' => 'top', 'offset' => 80, 'delay_ms' => 10000, 'bg' => '#ffffff', 'text' => '#1f2937', 'border_color' => '#e5e7eb', 'border_width' => 0, 'border_radius' => 12, 'icon_color' => '#F46767', 'btn_primary_bg' => '#F46767', 'btn_primary_text' => '#ffffff', 'btn_primary_border' => '#F46767', 'btn_primary_border_w' => 0, 'btn_secondary_bg' => '#f3f4f6', 'btn_secondary_text' => '#4b5563', 'btn_secondary_border' => '#f3f4f6', 'btn_secondary_border_w'=> 0, 'btn_radius' => 8, 'copy_title' => '', 'copy_body' => '', 'copy_accept' => '', 'copy_dismiss' => '', 'copy_thanks_yes' => '', 'copy_thanks_no' => '', 'thanks_autoclose_secs' => 5, )) : self::fill_i18n_defaults(array( 'banner_enabled' => false, 'position' => 'top', 'offset' => 80, 'delay_ms' => 10000, 'bg' => '#ffffff', 'text' => '#1f2937', 'border_color' => '#e5e7eb', 'border_width' => 0, 'border_radius' => 12, 'icon_color' => '#F46767', 'btn_primary_bg' => '#F46767', 'btn_primary_text' => '#ffffff', 'btn_primary_border' => '#F46767', 'btn_primary_border_w' => 0, 'btn_secondary_bg' => '#f3f4f6', 'btn_secondary_text' => '#4b5563', 'btn_secondary_border' => '#f3f4f6', 'btn_secondary_border_w'=> 0, 'btn_radius' => 8, 'copy_title' => '', 'copy_body' => '', 'copy_accept' => '', 'copy_dismiss' => '', 'copy_thanks_yes' => '', 'copy_thanks_no' => '', 'thanks_autoclose_secs' => 5, )); } private static function i18n_labels() { return array( 'copy_title' => __('Stay in the know', 'ai-eshop-optimizer'), 'copy_body' => __('Get notified right away about offers, new arrivals and favourite products back in stock.', 'ai-eshop-optimizer'), 'copy_accept' => __('Yes, please', 'ai-eshop-optimizer'), 'copy_dismiss' => __('Not now', 'ai-eshop-optimizer'), 'copy_thanks_yes' => __('Thank you! Confirm in the browser pop-up to turn notifications on.', 'ai-eshop-optimizer'), 'copy_thanks_no' => __('No problem! You can turn notifications on later from the site menu.', 'ai-eshop-optimizer'), ); } private static function fill_i18n_defaults(array $s) { if (!did_action('init')) { return $s; } foreach (self::i18n_labels() as $k => $v) { if (!isset($s[$k]) || '' === trim((string) $s[$k])) { $s[$k] = $v; } } return $s; } public static function get_banner_settings() { $stored = get_option(self::BANNER_OPTION, array()); if (!is_array($stored)) { $stored = array(); } return array_merge(self::default_banner_settings(), $stored); } const RECOMPUTE_BATCH = 1000; const RECOMPUTE_MAX_PER_REQUEST = 50000; public function get_id() { return 'web-push'; } public function get_icon() { return 'bell'; } public function get_label() { return __('Web Push Subscribers', 'ai-eshop-optimizer'); } public function get_priority() { return 13; } public function get_group() { return 'customer_profiling'; } public function is_pro_locked() { return true; } public function get_pro_promo() { return array( 'title' => __('Web Push Newsletter', 'ai-eshop-optimizer'), 'description' => __('Browser-based newsletter that reaches lapsed customers without depending on email deliverability. Targeted segments, automated reactivation flows, and lookalike audiences powered by your own attribution data.', 'ai-eshop-optimizer'), 'bullets' => array( __('Native browser push — no SMTP, no spam folders, no Mailchimp fees.', 'ai-eshop-optimizer'), __('Behavioural segments (Top customers, Lost cohort, Lookalikes, Abandoned cart).', 'ai-eshop-optimizer'), __('Triggered flows: welcome, reactivation, price-drop, back-in-stock.', 'ai-eshop-optimizer'), __('Full dispatch log with per-subscriber delivery + click attribution.', 'ai-eshop-optimizer'), ), ); } public function register_hooks() { $push_dir = AIEO_DIR . 'includes/dmm/push/'; $push_files = array( 'class-aieo-push-sender.php', 'class-aieo-push-rest.php', 'class-aieo-newsletter-adapter-base.php', 'class-aieo-newsletter-adapter-tnp.php', 'class-aieo-newsletter-adapter-mailchimp.php', 'class-aieo-newsletter-adapter-klaviyo.php', 'class-aieo-newsletter-adapter-webhook.php', 'class-aieo-newsletter-registry.php', 'class-aieo-push-trigger-base.php', 'class-aieo-push-dispatcher.php', ); foreach ($push_files as $f) { if (file_exists($push_dir . $f)) { require_once $push_dir . $f; } } $triggers_dir = $push_dir . 'triggers/'; if (is_dir($triggers_dir)) { foreach ((array) glob($triggers_dir . 'class-aieo-push-trigger-*.php') as $tf) { require_once $tf; $class = $this->trigger_class_from_file(basename($tf)); if ($class !== '' && class_exists($class) && class_exists('AIEO_Push_Trigger_Registry')) { AIEO_Push_Trigger_Registry::instance()->register(new $class()); } } } add_action('plugins_loaded', array($this, 'maybe_install'), 25); add_action('wp_ajax_aieo_dmm_webpush_recompute', array($this, 'ajax_recompute')); add_action('wp_ajax_aieo_dmm_webpush_send_test', array($this, 'ajax_send_test')); add_action('wp_ajax_aieo_dmm_webpush_save_banner', array($this, 'ajax_save_banner')); add_action('wp_ajax_aieo_dmm_webpush_save_newsletter', array($this, 'ajax_save_newsletter')); add_action('wp_ajax_aieo_dmm_webpush_save_template', array($this, 'ajax_save_template')); add_action('wp_ajax_aieo_dmm_webpush_test_newsletter', array($this, 'ajax_test_newsletter')); add_action('wp_ajax_aieo_dmm_webpush_sync_cohort', array($this, 'ajax_sync_cohort')); add_action('wp_ajax_aieo_dmm_webpush_create_campaign', array($this, 'ajax_create_campaign')); add_action('wp_ajax_aieo_dmm_webpush_launch_campaign', array($this, 'ajax_launch_campaign')); add_action('wp_ajax_aieo_dmm_webpush_toggle_engine', array($this, 'ajax_toggle_engine')); add_action('aieo_push_campaign_batch', array($this, 'run_campaign_push_batch'), 10, 2); add_action('admin_post_aieo_dmm_webpush_export_cohort', array($this, 'handle_export_cohort')); add_action('admin_post_aieo_dmm_webpush_export_lookalike', array($this, 'handle_export_lookalike')); add_action(self::CRON_HOOK, array($this, 'cron_nightly_recompute')); if (!wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_event($this->next_athens_0230_utc(), 'daily', self::CRON_HOOK); } add_action(self::RESEGMENT_HOOK, array($this, 'cron_resegment_after_upgrade')); add_action(self::CRON_DISPATCH_HOOK, array($this, 'cron_dispatch_tick')); add_filter('cron_schedules', array($this, 'register_dispatch_interval')); if (!wp_next_scheduled(self::CRON_DISPATCH_HOOK)) { wp_schedule_event(time() + 60, 'aieo_push_5min', self::CRON_DISPATCH_HOOK); } if (class_exists('AIEO_Push_REST')) { AIEO_Push_REST::instance(); } add_action('init', array($this, 'register_sw_rewrite')); add_filter('query_vars', array($this, 'add_sw_query_var')); add_action('template_redirect', array($this, 'maybe_serve_sw'), 1); add_action('wp_enqueue_scripts', array($this, 'enqueue_optin_banner')); add_filter('rocket_delay_js_exclusions', array($this, 'register_rocket_delay_js_exclusions')); } public function register_rocket_delay_js_exclusions($exclusions) { if (!is_array($exclusions)) { $exclusions = array(); } foreach (array('aieo-push-optin', 'aieoPush', 'aieo-push-sw', 'aieo-magic-link-modal') as $token) { if (!in_array($token, $exclusions, true)) { $exclusions[] = $token; } } return $exclusions; } private function next_athens_0230_utc() { try { $tz = new \DateTimeZone('Europe/Athens'); $next = new \DateTime('tomorrow 02:30:00', $tz); return (int) $next->getTimestamp(); } catch (\Throwable $e) { return time() + DAY_IN_SECONDS; } } public function register_dispatch_interval($schedules) { if (!isset($schedules['aieo_push_5min'])) { $schedules['aieo_push_5min'] = array( 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => __('Every 5 minutes (Rooster One push dispatch)', 'ai-eshop-optimizer'), ); } return $schedules; } public function cron_dispatch_tick() { if (!class_exists('AIEO_Push_Dispatcher')) { return; } try { $stats = AIEO_Push_Dispatcher::instance()->process_tick(); update_option('aieo_push_last_dispatch_tick', array( 'at' => time(), 'stats' => $stats, ), false); if (function_exists('aieo_debug_log')) { aieo_debug_log('AIEO_Push dispatch tick: ' . wp_json_encode($stats)); } } catch (\Throwable $e) { if (function_exists('aieo_debug_log')) { aieo_debug_log('AIEO_Push dispatch tick failed: ' . $e->getMessage()); } } } private function trigger_class_from_file($basename) { $stem = preg_replace('/^class-aieo-push-trigger-/', '', $basename); $stem = preg_replace('/\.php$/', '', $stem); if ($stem === '' || $stem === false) { return ''; } $parts = explode('-', $stem); $parts = array_map('ucfirst', $parts); return 'AIEO_Push_Trigger_' . implode('', $parts); } public function enqueue_admin_assets() { wp_enqueue_style('wp-color-picker'); wp_enqueue_script('wp-color-picker'); wp_add_inline_script('wp-color-picker', 'jQuery(function($){$(".aieo-dmm-push-color").wpColorPicker();});' ); } public static function install_sprocs() { global $wpdb; $file = AIEO_DIR . 'includes/dmm/sql/push-sprocs.php'; if ( ! file_exists( $file ) ) { return false; } require_once $file; if ( ! function_exists( 'aieo_push_install_sprocs' ) ) { return false; } aieo_push_install_sprocs(); return 2 === (int) $wpdb->get_var( "SELECT COUNT(*) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() AND ROUTINE_TYPE = 'PROCEDURE' AND ROUTINE_NAME IN ('aieo_sp_recompute_customer_intelligence_batch','aieo_sp_assign_segments')" ); } public function maybe_install() { $push_dir = AIEO_DIR . 'includes/dmm/push/'; if (file_exists($push_dir . 'class-aieo-push-sender.php')) { require_once $push_dir . 'class-aieo-push-sender.php'; } if (file_exists($push_dir . 'class-aieo-push-rest.php')) { require_once $push_dir . 'class-aieo-push-rest.php'; } if ('1' !== get_option('aieo_push_templates_language_repaired')) { add_action('admin_init', static function () { require_once AIEO_DIR . 'includes/dmm/sql/push-schema.php'; if (function_exists('aieo_push_repair_greek_default_templates')) { aieo_push_repair_greek_default_templates(); } }); } if ((string) get_option(self::SCHEMA_OPTION, '') === self::SCHEMA_VERSION) { return; } if (!AIEO_Schema_Lock::acquire(self::SCHEMA_OPTION)) { return; } global $wpdb; $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $base = AIEO_DIR . 'includes/dmm/sql/'; require_once $base . 'push-schema.php'; require_once $base . 'push-sprocs.php'; if (function_exists('aieo_push_install_schema')) { aieo_push_install_schema(); } if (function_exists('aieo_push_install_sprocs')) { aieo_push_install_sprocs(); } update_option(self::SCHEMA_OPTION, self::SCHEMA_VERSION, false); if (!wp_next_scheduled(self::RESEGMENT_HOOK)) { wp_schedule_single_event(time() + 60, self::RESEGMENT_HOOK); } delete_option(self::REWRITE_FLUSHED_OPTION); } public static function force_schema_refresh() { delete_option(self::SCHEMA_OPTION); } public function register_sw_rewrite() { add_rewrite_rule('^aieo-push-sw\.js$', 'index.php?aieo_push_sw=1', 'top'); if ((int) get_option(self::REWRITE_FLUSHED_OPTION, 0) !== 1) { flush_rewrite_rules(false); update_option(self::REWRITE_FLUSHED_OPTION, 1, false); } } public function add_sw_query_var($vars) { $vars[] = 'aieo_push_sw'; return $vars; } public function maybe_serve_sw() { if ((int) get_query_var('aieo_push_sw') !== 1) { return; } $path = AIEO_DIR . 'assets/js/aieo-push-sw.js'; if (!file_exists($path)) { status_header(404); exit; } nocache_headers(); header('Content-Type: application/javascript; charset=utf-8'); header('Service-Worker-Allowed: /'); header('Cache-Control: public, max-age=3600'); readfile($path); exit; } public function enqueue_optin_banner() { if (is_admin()) { return; } $banner_settings = self::get_banner_settings(); if (empty($banner_settings['banner_enabled'])) { return; } if (is_user_logged_in() && function_exists('current_user_can') && current_user_can('manage_options')) { if (defined('AIEO_PUSH_HIDE_FOR_ADMINS') && AIEO_PUSH_HIDE_FOR_ADMINS) { return; } } $css_dir = defined('AIEO_RUNTIME_DIR') ? AIEO_RUNTIME_DIR : ''; $css_uri = defined('AIEO_RUNTIME_URI') ? AIEO_RUNTIME_URI : ''; $css_path = $css_dir . 'assets/css/aieo-push.css'; $js_path = $css_dir . 'assets/js/aieo-push-optin.js'; if (!$css_uri || !file_exists($css_path) || !file_exists($js_path)) { return; } wp_enqueue_style( 'aieo-push-optin', $css_uri . 'assets/css/aieo-push.css', array(), (string) filemtime($css_path) ); wp_enqueue_script( 'aieo-push-optin', $css_uri . 'assets/js/aieo-push-optin.js', array(), (string) filemtime($js_path), true ); $b = self::get_banner_settings(); $inline = sprintf( '.aieo-push-banner{' . '--aieo-pb-bg:%s;--aieo-pb-text:%s;--aieo-pb-muted:%s;' . '--aieo-pb-border-color:%s;--aieo-pb-border-width:%dpx;--aieo-pb-border-radius:%dpx;' . '--aieo-pb-icon-color:%s;' . '--aieo-pb-btn-primary-bg:%s;--aieo-pb-btn-primary-text:%s;' . '--aieo-pb-btn-primary-border:%s;--aieo-pb-btn-primary-border-w:%dpx;' . '--aieo-pb-btn-secondary-bg:%s;--aieo-pb-btn-secondary-text:%s;' . '--aieo-pb-btn-secondary-border:%s;--aieo-pb-btn-secondary-border-w:%dpx;' . '--aieo-pb-btn-radius:%dpx;' . '--aieo-pb-offset:%dpx;}', esc_attr((string) $b['bg']), esc_attr((string) $b['text']), esc_attr((string) $b['text']), esc_attr((string) $b['border_color']), (int) $b['border_width'], (int) $b['border_radius'], esc_attr((string) $b['icon_color']), esc_attr((string) $b['btn_primary_bg']), esc_attr((string) $b['btn_primary_text']), esc_attr((string) $b['btn_primary_border']), (int) $b['btn_primary_border_w'], esc_attr((string) $b['btn_secondary_bg']), esc_attr((string) $b['btn_secondary_text']), esc_attr((string) $b['btn_secondary_border']), (int) $b['btn_secondary_border_w'], (int) $b['btn_radius'], (int) $b['offset'] ); wp_add_inline_style('aieo-push-optin', $inline); $vapid_pub = ''; if (class_exists('AIEO_Push_Sender')) { try { $keys = AIEO_Push_Sender::instance()->get_keys(); $vapid_pub = (string) ($keys['public_b64url'] ?? ''); } catch (\Throwable $e) { $vapid_pub = ''; } } wp_localize_script('aieo-push-optin', 'aieoPush', array( 'vapidPublicKey' => $vapid_pub, 'swUrl' => home_url('/aieo-push-sw.js'), 'restRoot' => esc_url_raw(rest_url()), 'restNonce' => wp_create_nonce('wp_rest'), 'delayMs' => (int) $b['delay_ms'], 'debug' => defined('WP_DEBUG') && WP_DEBUG, 'banner' => array( 'position' => (string) $b['position'], ), 'copy' => array( 'title' => (string) $b['copy_title'], 'body' => (string) $b['copy_body'], 'accept' => (string) $b['copy_accept'], 'dismiss' => (string) $b['copy_dismiss'], 'thanks_yes' => (string) $b['copy_thanks_yes'], 'thanks_no' => (string) $b['copy_thanks_no'], ), 'thanksAutocloseMs' => (int) $b['thanks_autoclose_secs'] * 1000, )); } public function ajax_send_test() { AIEO_DMM::verify_ajax_request(); if (!self::is_engine_live()) { wp_send_json_error(array('message' => self::engine_paused_message())); } global $wpdb; $user_id = get_current_user_id(); if ($user_id <= 0) { wp_send_json_error(array('message' => __('No logged-in user.', 'ai-eshop-optimizer'))); } $tbl = $wpdb->prefix . 'aieo_push_subscriptions'; $rows = $wpdb->get_results($wpdb->prepare( "SELECT subscription_id, endpoint, p256dh, auth_secret FROM {$tbl} WHERE user_id = %d AND status = 'active'", $user_id ), ARRAY_A); if (empty($rows)) { wp_send_json_error(array( 'message' => __('No active subscription found for your account. Subscribe from the storefront first (the banner appears after 8s on the first visit), then come back and click here.', 'ai-eshop-optimizer'), )); } if (!class_exists('AIEO_Push_Sender')) { require_once AIEO_DIR . 'includes/dmm/push/class-aieo-push-sender.php'; } $sender = AIEO_Push_Sender::instance(); $sent = 0; $failed = 0; $details = array(); $tbl_dispatch = $wpdb->prefix . 'aieo_push_dispatch'; $now = current_time('mysql', true); foreach ($rows as $row) { $result = $sender->send_empty(array( 'endpoint' => $row['endpoint'], 'p256dh' => $row['p256dh'], 'auth_secret' => $row['auth_secret'], ), 60); $wpdb->insert($tbl_dispatch, array( 'subscription_id' => (int) $row['subscription_id'], 'user_id' => $user_id, 'trigger_id' => 'admin_test', 'target_url' => home_url('/'), 'state' => $result['ok'] ? 'sent' : 'failed', 'provider_status' => (int) $result['status'], 'provider_error' => substr((string) $result['error'], 0, 200), 'queued_at' => $now, 'sent_at' => $result['ok'] ? $now : null, 'created_at' => $now, ), array('%d','%d','%s','%s','%s','%d','%s','%s','%s','%s')); if ($result['ok']) { $sent++; } else { $failed++; if ((int) $result['status'] === 410 || (int) $result['status'] === 404) { $wpdb->update($tbl, array('status' => 'bounced', 'bounced_at' => $now), array('subscription_id' => (int) $row['subscription_id']), array('%s', '%s'), array('%d')); } } $details[] = array( 'sub' => (int) $row['subscription_id'], 'status' => (int) $result['status'], 'ok' => (bool) $result['ok'], 'error' => (string) $result['error'], ); } wp_send_json_success(array( 'message' => sprintf( __('Test push: %1$d sent, %2$d failed.', 'ai-eshop-optimizer'), $sent, $failed ), 'sent' => $sent, 'failed' => $failed, 'details' => $details, )); } public function find_latest_source_table() { global $wpdb; $temp = $wpdb->prefix . 'aieo_temp_orders'; $temp_has_data = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$temp} WHERE EponymousID IS NOT NULL AND EponymousID > 0 LIMIT 1" ); if ($temp_has_data > 0) { return $temp; } $rows = $wpdb->get_results( "SELECT TABLE_NAME, TABLE_ROWS FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME REGEXP '^{$wpdb->prefix}[0-9]{4}_[0-9]{2}_orders\$' ORDER BY TABLE_NAME DESC LIMIT 1", ARRAY_A ); if (!empty($rows)) { return (string) $rows[0]['TABLE_NAME']; } $rows = $wpdb->get_results( "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME REGEXP '^{$wpdb->prefix}aieo_[0-9]{4}_[0-9]{2}_orders\$' ORDER BY TABLE_NAME DESC LIMIT 1", ARRAY_A ); if (!empty($rows)) { return (string) $rows[0]['TABLE_NAME']; } return null; } private function run_recompute($override_source = null, $start_offset = 0, $max_processed = null) { global $wpdb; $this->maybe_install(); $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $source = $override_source !== null && $override_source !== '' ? (string) $override_source : (string) $this->find_latest_source_table(); if ($source === '' || !$this->is_valid_source_table($source)) { return array( 'ok' => false, 'source' => '', 'message' => __('No valid Rooster One snapshot table found. Run the data-prep pipeline first (Data Prep → "Save Settings & Analyze Data").', 'ai-eshop-optimizer'), ); } $batch = self::RECOMPUTE_BATCH; $max = $max_processed !== null ? (int) $max_processed : self::RECOMPUTE_MAX_PER_REQUEST; $offset = max(0, (int) $start_offset); $started_at = microtime(true); $rows_before = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl_ci}"); $batches_run = 0; $processed_total = 0; $source_total = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT EponymousID) FROM `{$source}` WHERE EponymousID <> 0" ); while ($processed_total < $max) { $rows_at_step_before = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl_ci}"); aieo_call_sproc( $wpdb->prepare( 'CALL aieo_sp_recompute_customer_intelligence_batch(%s, %d, %d)', $source, $offset, $batch ), array( __CLASS__, 'install_sprocs' ) ); $rows_at_step_after = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl_ci}"); $delta = max(0, $rows_at_step_after - $rows_at_step_before); $batches_run += 1; $processed_total += $batch; $offset += $batch; if ($source_total > 0 && $offset >= $source_total) { break; } if ($source_total <= 0 && $delta === 0 && $batches_run > 1) { break; } } aieo_call_sproc( 'CALL aieo_sp_assign_segments()', array( __CLASS__, 'install_sprocs' ) ); $rows_after = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl_ci}"); $duration = round(microtime(true) - $started_at, 2); $complete = ( $source_total <= 0 ) || ( $offset >= $source_total ); return array( 'ok' => true, 'complete' => $complete, 'source' => $source, 'source_total' => $source_total, 'rows_total' => $rows_after, 'rows_added' => max(0, $rows_after - $rows_before), 'batches_run' => $batches_run, 'duration' => $duration, 'next_offset' => $complete ? 0 : $offset, 'message' => $complete ? sprintf( __('Recompute complete from %1$s — %2$d profiles (+%3$d new) in %4$ss. Segments assigned.', 'ai-eshop-optimizer'), $source, $rows_after, max(0, $rows_after - $rows_before), $duration ) : sprintf( __('Recompute IN PROGRESS — %1$d of %2$d customers from %3$s in %4$ss. Run it again to continue.', 'ai-eshop-optimizer'), min($offset, $source_total), $source_total, $source, $duration ), ); } public function ajax_recompute() { AIEO_DMM::verify_ajax_request(); $offset = isset($_POST['offset']) ? (int) wp_unslash($_POST['offset']) : 0; $override = isset($_POST['source_table']) ? sanitize_text_field(wp_unslash((string) $_POST['source_table'])) : null; $result = $this->run_recompute($override, $offset); if (!$result['ok']) { wp_send_json_error(array('message' => $result['message'])); } wp_send_json_success($result); } public function cron_nightly_recompute() { $result = $this->run_recompute(null, 0, 200000); update_option('aieo_push_last_nightly_at', time(), false); update_option('aieo_push_last_nightly_status', $result, false); if (defined('WP_DEBUG') && WP_DEBUG && function_exists('aieo_debug_log')) { aieo_debug_log('AIEO_Push nightly recompute: ' . wp_json_encode($result)); } } public function cron_resegment_after_upgrade() { $started = microtime(true); aieo_call_sproc( 'CALL aieo_sp_assign_segments()', array( __CLASS__, 'install_sprocs' ) ); update_option('aieo_push_last_resegment', array( 'at' => time(), 'schema' => self::SCHEMA_VERSION, 'duration' => round(microtime(true) - $started, 2), ), false); if (defined('WP_DEBUG') && WP_DEBUG && function_exists('aieo_debug_log')) { aieo_debug_log('AIEO_Push post-upgrade re-segmentation ran for schema v' . self::SCHEMA_VERSION); } } private function is_valid_source_table($name) { global $wpdb; $name = (string) $name; if ($name === '' || strlen($name) > 64) { return false; } $temp_name = $wpdb->prefix . 'aieo_temp_orders'; if ($name === $temp_name) { return true; } $regex = '/^' . preg_quote($wpdb->prefix . 'aieo_', '/') . '[0-9]{4}_[0-9]{2}_orders$/'; return (bool) preg_match($regex, $name); } public function render_subtab() { global $wpdb; $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $exists = (string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $tbl_ci)) === $tbl_ci; $this->band_open(array('cols' => 1)); $this->render_engine_status_card(); $this->band_close(); if (!$exists) { $close = $this->card_open(array( 'anchor' => 'web-push-source-data-2', 'icon' => 'database', 'title' => __('Source data', 'ai-eshop-optimizer'), )); $this->note(esc_html__('Intelligence table is being installed. Reload the page once to finish setup.', 'ai-eshop-optimizer')); echo $close; return; } $detected = (string) $this->find_latest_source_table(); $candidates = $this->list_candidate_source_tables(); $this->band_open(array('cols' => 2)); $close = $this->card_open(array( 'anchor' => 'web-push-source-data-2', 'icon' => 'database', 'title' => __('Source data', 'ai-eshop-optimizer'), 'description' => __('Per-customer CDP table sourced from Rooster One\'s pre-built order snapshot (omnichannel: eshop + in-store). Push delivery + newsletter sync + lookalike export all wired to this single source of truth.', 'ai-eshop-optimizer'), 'col' => 1, )); $this->guidance(__('Recompute reads from one of Rooster One\'s order snapshots — either the live working table or a monthly archive. Auto-pick prefers the freshest data.', 'ai-eshop-optimizer')); if ($detected !== '') { echo '

' . esc_html__('Detected:', 'ai-eshop-optimizer') . ' ' . '' . esc_html($detected) . '

'; } else { $this->note(esc_html__('No source snapshot found — run the Rooster One data-prep pipeline first.', 'ai-eshop-optimizer'), 'warning'); } if (!empty($candidates)) { $aieo_wp_list = ''); } $lib_job = class_exists('\Rooster\Admin\States'); echo '
'; echo ''; echo ''; if (!$lib_job) { echo ''; echo ' ' . (class_exists('\Rooster\Admin\States') ? \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-webpush-recompute-status')) : ''); } echo '
'; if ($lib_job) { $job = \Rooster\Admin\States::job(array( 'id' => 'aieo-webpush-recompute-job', 'state' => 'idle', 'run' => __('Recompute now', 'ai-eshop-optimizer'), 'run_attrs' => array('id' => 'aieo-webpush-recompute'), 'disabled' => '' === $detected, 'reason' => '' === $detected ? __('Needs an order snapshot: run the data-prep pipeline first.', 'ai-eshop-optimizer') : '', )); echo $job; } $last_nightly = (int) get_option('aieo_push_last_nightly_at', 0); $next_cron = (int) wp_next_scheduled(self::CRON_HOOK); echo '

'; echo '' . esc_html__('Nightly recompute:', 'ai-eshop-optimizer') . ' '; echo esc_html__('runs at 02:30 Europe/Athens, chains segment assignment.', 'ai-eshop-optimizer'); echo ' · '; if ($last_nightly > 0) { echo esc_html__('Last:', 'ai-eshop-optimizer') . ' ' . esc_html(human_time_diff($last_nightly)) . ' ' . esc_html__('ago', 'ai-eshop-optimizer'); } else { echo esc_html__('Last: never', 'ai-eshop-optimizer'); } if ($next_cron > 0) { echo ' · ' . esc_html__('Next:', 'ai-eshop-optimizer') . ' ' . esc_html(human_time_diff($next_cron)); } echo '

'; echo $this->recompute_inline_js(); $this->note(wp_kses_post(sprintf( __('Looking for segments, dormancy cohorts or your best customers? They moved to %1$sCustomer Intelligence%2$s. The hashed CSV export for Meta and Google is now under %3$sLookalike Audiences%4$s.', 'ai-eshop-optimizer'), '', '', '', '' )), 'mono'); echo $close; $this->render_subscribers_card(); $this->band_close(); $this->band_open(array('cols' => 2)); $this->render_newsletter_settings_card(); $this->render_campaign_launcher_card(); $this->band_close(); $this->band_open(array('cols' => 1)); $this->render_triggers_card(); $this->render_dispatch_log_card(); $this->render_templates_card(); $this->band_close(); } public function render_overview_card($tbl_ci, $intro = '') { global $wpdb; $rows_total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl_ci}"); $with_orders = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl_ci} WHERE orders_count > 0"); $with_wp_user = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl_ci} WHERE wp_user_id > 0"); $sum_ltv = (float) $wpdb->get_var("SELECT IFNULL(SUM(ltv_total),0) FROM {$tbl_ci}"); $median_aov = $this->median_aov($tbl_ci); $row_last_recomp = (string) $wpdb->get_var("SELECT MAX(updated_at) FROM {$tbl_ci}"); $own = __('Counts profiles in wp_aieo_customer_intelligence, how many are registered users, how many have ordered, total LTV, median AOV and time since the 02:30 recompute.', 'ai-eshop-optimizer'); $close = $this->card_open(array( 'anchor' => 'web-push-overview', 'icon' => 'layout-dashboard', 'title' => __('CDP key figures', 'ai-eshop-optimizer'), 'description' => '' !== (string) $intro ? (string) $intro : $own, ), 'overview'); if ('' !== (string) $intro) { $this->guidance($own); } if (0 === $rows_total && class_exists('\Rooster\Admin\States')) { $state = $this->cdp_not_run( __('No customer profiles yet', 'ai-eshop-optimizer'), __('The nightly recompute builds them from Rooster One’s order snapshot at 02:30; Recompute now on Web Push builds them at once.', 'ai-eshop-optimizer') ); echo $state; echo $close; return; } echo '
'; $this->tile(__('Profiles in CDP', 'ai-eshop-optimizer'), number_format_i18n($rows_total), __('Total rows in wp_aieo_customer_intelligence — every customer the CDP knows about. Includes WP users, guest checkouts, and identified anonymous sessions. Built nightly by Recompute.', 'ai-eshop-optimizer')); $this->tile(__('Linked to WP user', 'ai-eshop-optimizer'), number_format_i18n($with_wp_user), __('Subset of profiles that have a wp_users row (registered accounts). Guests / one-time checkouts are NOT included here, even though they ARE in the CDP.', 'ai-eshop-optimizer')); $this->tile(__('With ≥ 1 order', 'ai-eshop-optimizer'), number_format_i18n($with_orders), __('Profiles with orders_count ≥ 1. Excludes profiles built from sessions/wishlist alone with zero purchases — these are the addressable revenue base.', 'ai-eshop-optimizer')); $this->tile(__('Site-wide LTV', 'ai-eshop-optimizer'), $this->fmt_money($sum_ltv), __('Sum of ltv_total across every customer profile. Equivalent to total historical revenue across all known customers (excludes still-anonymous sessions).', 'ai-eshop-optimizer')); $this->tile(__('Median AOV', 'ai-eshop-optimizer'), $this->fmt_money($median_aov), sprintf( __('Median per-customer AOV (middle value, not influenced by outliers like the %s whale). Use this as the typical-customer AOV; mean AOV is skewed high.', 'ai-eshop-optimizer'), $this->fmt_money(5000) )); $this->tile(__('Last recompute', 'ai-eshop-optimizer'), $row_last_recomp ? esc_html(human_time_diff(strtotime($row_last_recomp . ' UTC'))) . ' ' . esc_html__('ago', 'ai-eshop-optimizer') : '—', __('Most recent updated_at in customer_intelligence. Stale = > 24h since the nightly Recompute cron at 02:30 Athens. Click "Recompute now" to refresh manually.', 'ai-eshop-optimizer')); echo '
'; echo $close; } private function render_triggers_card() { global $wpdb; $tbl_d = $wpdb->prefix . 'aieo_push_dispatch'; $triggers = class_exists('AIEO_Push_Trigger_Registry') ? AIEO_Push_Trigger_Registry::instance()->all() : array(); $last_tick = (array) get_option('aieo_push_last_dispatch_tick', array()); $next_tick = (int) wp_next_scheduled(self::CRON_DISPATCH_HOOK); $close = $this->card_open(array( 'anchor' => 'web-push-triggers-phase-4-5', 'icon' => 'zap', 'title' => __('Web push triggers', 'ai-eshop-optimizer'), 'description' => __('Each trigger has its own candidate-producing query; the universal dispatcher (every 5 min) runs caps + quiet hours + 7-day dedup + template pick + AES-128-GCM encrypted send. Click counts come from the SW beacon → push_dispatch update.', 'ai-eshop-optimizer'), 'flush' => true, ), 'triggers'); echo '

'; if (!empty($last_tick['at'])) { echo '' . esc_html__('Last tick:', 'ai-eshop-optimizer') . ' ' . esc_html(human_time_diff((int) $last_tick['at'])) . ' ' . esc_html__('ago', 'ai-eshop-optimizer'); if (!empty($last_tick['stats'])) { $s = $last_tick['stats']; echo ' — ' . sprintf( esc_html__('%1$d processed, %2$d sent, %3$d suppressed, %4$d failed', 'ai-eshop-optimizer'), (int) ($s['processed'] ?? 0), (int) ($s['sent'] ?? 0), (int) ($s['suppressed'] ?? 0), (int) ($s['failed'] ?? 0) ); } } else { echo esc_html__('Last tick: never (cron will run within 5 minutes)', 'ai-eshop-optimizer'); } if ($next_tick > 0) { echo ' · ' . esc_html__('Next:', 'ai-eshop-optimizer') . ' ' . esc_html(human_time_diff($next_tick)); } echo '

'; $agg = $wpdb->get_results( "SELECT trigger_id, SUM(IF(state IN ('sent','clicked','dismissed'), 1, 0)) AS sent, SUM(IF(state = 'clicked', 1, 0)) AS clicked, SUM(IF(state = 'suppressed', 1, 0)) AS suppressed, SUM(IF(state = 'failed', 1, 0)) AS failed, MAX(sent_at) AS last_sent_at FROM {$tbl_d} WHERE channel = 'push' GROUP BY trigger_id", ARRAY_A ); $by_id = array(); foreach ($agg as $r) { $by_id[(string) $r['trigger_id']] = $r; } $trig = array(); foreach ($triggers as $tid => $trigger) { $a = $by_id[$tid] ?? array('sent'=>0,'clicked'=>0,'suppressed'=>0,'failed'=>0,'last_sent_at'=>null); $sent_n = (int) $a['sent']; $click_n = (int) $a['clicked']; $trig[] = array( 'trigger_id' => (string) $tid, 'label' => $trigger->get_label(), 'sent' => $sent_n, 'clicked' => $click_n, 'ctr' => $sent_n > 0 ? round(100 * $click_n / $sent_n, 1) : 0, 'suppressed' => (int) $a['suppressed'], 'failed' => (int) $a['failed'], 'last_sent' => $a['last_sent_at'], 'caps' => sprintf( __('%1$d/d · %2$d/wk · %3$dd dedup', 'ai-eshop-optimizer'), $trigger->cap_per_day(), $trigger->cap_per_week(), $trigger->dedup_days() ), ); } AIEO_Table::render(array( 'page_id' => $this->get_id(), 'row_key' => 'trigger_id', 'density' => 'compact', 'rows' => $trig, 'empty' => __('No triggers registered yet.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'label', 'label' => __('Trigger', 'ai-eshop-optimizer'), 'type' => 'text', 'subline' => 'trigger_id'), array('key' => 'sent', 'label' => __('Sent', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'clicked', 'label' => __('Clicked', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'ctr', 'label' => __('CTR', 'ai-eshop-optimizer'), 'type' => 'percent', 'format' => array('precision' => 1)), array('key' => 'suppressed', 'label' => __('Suppressed', 'ai-eshop-optimizer'), 'type' => 'integer', 'tone' => 'muted'), array( 'key' => 'failed', 'label' => __('Failed', 'ai-eshop-optimizer'), 'type' => 'integer', 'bands' => array( array('min' => 0, 'max' => 0, 'tone' => 'muted'), array('min' => 1, 'tone' => 'danger'), ), ), array( 'key' => 'last_sent', 'label' => __('Last sent', 'ai-eshop-optimizer'), 'type' => 'relative', 'format' => array('tz' => 'utc'), ), array('key' => 'caps', 'label' => __('Caps · dedup', 'ai-eshop-optimizer'), 'type' => 'text', 'tone' => 'secondary'), ), )); echo $close; } public static function push_dispatch_log_rows($limit = 100) { global $wpdb; $tbl_d = $wpdb->prefix . 'aieo_push_dispatch'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT dispatch_id, trigger_id, target_product_id, target_url, state, suppress_reason, provider_status, provider_error, queued_at, sent_at, clicked_at FROM {$tbl_d} WHERE channel = 'push' ORDER BY dispatch_id DESC LIMIT %d", max(1, (int) $limit) ), ARRAY_A ); return is_array($rows) ? $rows : array(); } private function render_dispatch_log_card() { $rows = self::push_dispatch_log_rows(100); $close = $this->card_open(array( 'anchor' => 'web-push-dispatch-log-last-100', 'icon' => 'scroll-text', 'title' => __('Last 100 push dispatches', 'ai-eshop-optimizer'), 'description' => __('Lists the last 100 push dispatches, newest first, with trigger, product and state, plus the reason a push was suppressed or failed.', 'ai-eshop-optimizer'), 'flush' => true, ), 'dispatch-log'); $log = array(); foreach ($rows as $r) { $state = (string) $r['state']; $when = $r['sent_at'] ?: ( $r['clicked_at'] ?: $r['queued_at'] ); if ('suppressed' === $state) { $reason = (string) $r['suppress_reason']; } elseif ('failed' === $state) { $reason = trim((int) $r['provider_status'] . ' ' . (string) $r['provider_error']); } else { $reason = ''; } $log[] = array( 'dispatch_id' => (int) $r['dispatch_id'], 'trigger_id' => (string) $r['trigger_id'], 'product' => '#' . (int) $r['target_product_id'], 'target_url' => (string) $r['target_url'], 'state' => $state, 'reason' => $reason, 'when' => $when, ); } AIEO_Table::render(array( 'page_id' => $this->get_id(), 'row_key' => 'dispatch_id', 'density' => 'compact', 'rows' => $log, 'caps' => array('search' => true), 'filters' => array( array('key' => 'state', 'label' => __('State', 'ai-eshop-optimizer'), 'kind' => 'values', 'control' => 'select', 'options' => array('sent', 'clicked', 'dismissed', 'queued', 'suppressed', 'failed')), array('key' => 'reason', 'label' => __('Reason', 'ai-eshop-optimizer'), 'kind' => 'values', 'control' => 'select'), ), 'per_page' => 0, 'cap' => 100, 'capped' => count($log) >= 100, 'empty' => __('No dispatch rows yet — first push will appear here.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'dispatch_id', 'label' => '#', 'type' => 'id'), array('key' => 'trigger_id', 'label' => __('Trigger', 'ai-eshop-optimizer'), 'type' => 'code'), array( 'key' => 'product', 'label' => __('Product', 'ai-eshop-optimizer'), 'type' => 'link', 'format' => array('href' => 'target_url', 'external' => true), ), array( 'key' => 'state', 'label' => __('State', 'ai-eshop-optimizer'), 'type' => 'badge', 'format' => array( 'map' => array( 'sent' => 'info', 'clicked' => 'success', 'dismissed' => 'neutral', 'queued' => 'info', 'suppressed' => 'muted', 'failed' => 'danger', ), ), ), array('key' => 'reason', 'label' => __('Reason', 'ai-eshop-optimizer'), 'type' => 'text', 'tone' => 'secondary'), array( 'key' => 'when', 'label' => __('When', 'ai-eshop-optimizer'), 'type' => 'relative', 'format' => array('tz' => 'utc'), ), ), )); echo $close; } private function render_templates_card() { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_templates'; $rows = $wpdb->get_results( "SELECT template_id, trigger_id, segment_id, variant_label, weight, title_template, body_template, icon_url, is_active FROM {$tbl} ORDER BY trigger_id, segment_id, variant_label", ARRAY_A ); $close = $this->card_open(array( 'anchor' => 'web-push-templates-phase-6-per-trigger-per-segment-we', 'icon' => 'layout-template', 'title' => __('Web push notification templates', 'ai-eshop-optimizer'), 'description' => __('Tokens like {{product_name}} {{drop_pct}} {{current_price}} get filled from each candidate\'s payload at send time. Multiple variants per (trigger, segment) row are weight-randomised by the dispatcher; segment="*" matches everyone. Edit copy below — AJAX saves on blur.', 'ai-eshop-optimizer'), 'flush' => !empty($rows), )); if (empty($rows)) { if (class_exists('\Rooster\Admin\States')) { echo \Rooster\Admin\States::state('first-use', array('title' => __('No templates yet — first plugin load will seed defaults.', 'ai-eshop-optimizer'), 'compact' => true)); } else { echo '

' . esc_html__('No templates yet — first plugin load will seed defaults.', 'ai-eshop-optimizer') . '

'; } echo $close; return; } AIEO_Table::render(array( 'class' => 'aieo-tpl-table', 'density' => 'compact', 'columns' => array( array( 'key' => 'trigger_id', 'label' => __('Trigger', 'ai-eshop-optimizer'), 'type' => 'code', ), array( 'key' => 'segment_id', 'label' => __('Segment', 'ai-eshop-optimizer'), 'type' => 'code', ), array( 'key' => 'variant_label', 'label' => __('Variant', 'ai-eshop-optimizer'), 'type' => 'text', ), array( 'key' => 'weight', 'label' => __('Weight', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '80px', 'format' => array( 'render' => static function ($row) { return ''; }, ), ), array( 'key' => 'title_template', 'label' => __('Title', 'ai-eshop-optimizer'), 'type' => 'control', 'format' => array( 'render' => static function ($row) { return ''; }, ), ), array( 'key' => 'body_template', 'label' => __('Body', 'ai-eshop-optimizer'), 'type' => 'control', 'format' => array( 'render' => static function ($row) { return ''; }, ), ), array( 'key' => 'is_active', 'label' => __('Active', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '60px', 'align' => 'center', 'format' => array( 'render' => static function ($row) { return ''; }, ), ), ), 'rows' => $rows, 'row_key' => 'template_id', 'row_data' => array('template_id' => 'template_id'), 'page_id' => 'web-push', )); echo \Rooster\Admin\Guidance::text(__('Tip: edits autosave on blur (or change for checkbox). Field name is shown via the data-field attribute.', 'ai-eshop-optimizer'), 'guidance', array('question' => __('How do edits save?', 'ai-eshop-optimizer'))) . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-tpl-status', 'attrs' => array( 'data-aieo-l10n' => wp_json_encode(array( 'saving' => __('Saving…', 'ai-eshop-optimizer'), 'saved' => __('Saved.', 'ai-eshop-optimizer'), 'error' => __('Error: %s', 'ai-eshop-optimizer'), 'unknown' => __('unknown', 'ai-eshop-optimizer'), 'network' => __('Network error.', 'ai-eshop-optimizer'), )), ))); $nonce = wp_create_nonce(class_exists('AIEO_DMM') ? \AIEO_DMM::NONCE_ACTION : 'aieo_dmm_nonce'); echo ''; echo $close; } public function cohort_rollup($tbl_ci) { global $wpdb; $tbl_subs = $wpdb->prefix . 'aieo_push_subscriptions'; $min = (int) AIEO_DMM::REACTIVATION_SCORE_MIN; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ci.dormancy_cohort, COUNT(*) AS cnt, SUM(CASE WHEN ci.email <> '' THEN 1 ELSE 0 END) AS has_email, IFNULL(SUM(ci.ltv_total), 0) AS sum_ltv, IFNULL(AVG(ci.ltv_total), 0) AS avg_ltv, IFNULL(AVG(ci.reactivation_score), 0) AS avg_score, SUM(CASE WHEN ci.reactivation_score >= %d THEN 1 ELSE 0 END) AS high_score, IFNULL(SUM(CASE WHEN ci.reactivation_score >= %d THEN ci.ltv_total ELSE 0 END), 0) AS high_score_ltv, SUM(CASE WHEN ci.reactivation_score >= %d AND ci.wp_user_id > 0 AND EXISTS ( SELECT 1 FROM {$tbl_subs} sub WHERE sub.user_id = ci.wp_user_id AND sub.status = 'active' ) THEN 1 ELSE 0 END) AS push_reachable, SUM(CASE WHEN ci.reactivation_attempts_count > 0 THEN 1 ELSE 0 END) AS attempted, SUM(CASE WHEN ci.reactivated_at IS NOT NULL THEN 1 ELSE 0 END) AS reactivated, SUM(CASE WHEN ci.email <> '' AND (ci.ltv_total >= 100 OR ci.orders_count >= 3) THEN 1 ELSE 0 END) AS worth_chasing, IFNULL(SUM(CASE WHEN ci.email <> '' AND (ci.ltv_total >= 100 OR ci.orders_count >= 3) THEN ci.ltv_total ELSE 0 END), 0) AS worth_chasing_ltv FROM {$tbl_ci} ci GROUP BY ci.dormancy_cohort", $min, $min, $min ), ARRAY_A ); $out = array(); foreach ((array) $rows as $r) { $out[(string) $r['dormancy_cohort']] = $r; } return $out; } private static function load_audience_rules() { if (!class_exists('AIEO_Audience_Rules') && defined('AIEO_DIR')) { $f = AIEO_DIR . 'includes/class-aieo-audience-rules.php'; if (is_readable($f)) { require_once $f; } } return class_exists('AIEO_Audience_Rules'); } private static function rollup_val($rollup, $cohort, $key) { return isset($rollup[$cohort][$key]) ? (float) $rollup[$cohort][$key] : 0.0; } public function render_reactivation_card($tbl_ci, $rollup = null) { $cohorts = self::reactivation_cohort_catalog(); if (!is_array($rollup)) { $rollup = $this->cohort_rollup($tbl_ci); } $stats_by_cohort = $rollup; unset($stats_by_cohort['']); $total_dormant = 0; $high_score_count = 0; $push_reachable = 0; $attempted = 0; $reactivated = 0; $recoverable_ltv = 0.0; $dormant_with_email = 0; foreach ($stats_by_cohort as $r) { $total_dormant += (int) $r['cnt']; $dormant_with_email += (int) $r['has_email']; $high_score_count += (int) $r['high_score']; $push_reachable += (int) $r['push_reachable']; $attempted += (int) $r['attempted']; $reactivated += (int) $r['reactivated']; $recoverable_ltv += (float) $r['high_score_ltv']; } $close = $this->card_open(array( 'anchor' => 'web-push-reactivation-opportunity', 'icon' => 'rotate-ccw', 'title' => __('Reactivation opportunity', 'ai-eshop-optimizer'), 'description' => __('The dormant pool is the biggest single revenue lever in a mature store. We split it into four lifecycle cohorts and score every customer 0–100 by how recoverable they are. The win-back trigger (Phase 5) walks this queue in score order with cohort-specific message strength.', 'ai-eshop-optimizer'), ), 'reactivation'); $score_min = (int) AIEO_DMM::REACTIVATION_SCORE_MIN; echo '
'; $this->tile(__('Total dormant', 'ai-eshop-optimizer'), number_format_i18n($total_dormant), __('Customers with a non-empty dormancy_cohort — at least one past order and no order for 60+ days. The full pool from which the win-back trigger picks targets. This is the ONLY dormancy vocabulary on the page; the old "dormant" segment was retired because it counted a different set under the same word.', 'ai-eshop-optimizer')); $this->tile( sprintf(__('Recoverable LTV (score ≥ %d)', 'ai-eshop-optimizer'), $score_min), $this->fmt_money($recoverable_ltv), sprintf( __('Sum of historical LTV across the dormant customers who clear the reactivation score floor of %d — i.e. the LTV sitting inside the queue we would actually work, not the whole dormant pool. Aspirational ceiling for that queue, not a forecast.', 'ai-eshop-optimizer'), $score_min ) ); $this->tile(__('High-score targets', 'ai-eshop-optimizer'), number_format_i18n($high_score_count), sprintf( __('Dormant customers with reactivation_score ≥ %d, the threshold the win-back trigger works from. Score blends LTV quintile, order-frequency quintile, freshness and an attempt penalty, then decays by cohort.', 'ai-eshop-optimizer'), $score_min )); $this->tile(__('Push-reachable (active sub)', 'ai-eshop-optimizer'), number_format_i18n($push_reachable), sprintf( __('Of the high-score targets (score ≥ %d), those who ALSO have an active push subscription right now. A strict subset of High-score targets, so the funnel below always narrows. The actual addressable count for the next dispatch tick.', 'ai-eshop-optimizer'), $score_min )); $this->tile(__('Dormant with email', 'ai-eshop-optimizer'), number_format_i18n($dormant_with_email), __('Dormant customers with a non-empty email address. Shown here because the lookalike export downstream can only address this subset — it used to print its own has-email counts under the same cohort names as this card, which read as a contradiction.', 'ai-eshop-optimizer')); echo '
'; $this->render_reactivation_funnel($high_score_count, $push_reachable, $attempted, $reactivated); $this->render_cohort_funnel_table($cohorts, $stats_by_cohort); $this->render_reactivation_candidates_table($tbl_ci); echo $close; } private function render_reactivation_funnel($targets, $reachable, $attempted, $reactivated) { $stages = array( array('label' => __('High-score targets', 'ai-eshop-optimizer'), 'value' => $targets, 'color' => '#0891b2'), array('label' => __('Push-reachable', 'ai-eshop-optimizer'), 'value' => $reachable, 'color' => '#7c3aed'), array('label' => __('Attempted', 'ai-eshop-optimizer'), 'value' => $attempted, 'color' => '#f59e0b'), array('label' => __('Reactivated', 'ai-eshop-optimizer'), 'value' => $reactivated, 'color' => '#15803d'), ); $top = max(1, $targets); $attempt_rate = $reachable > 0 ? round(100 * $attempted / $reachable, 1) : 0; $reactivation_rate = $attempted > 0 ? round(100 * $reactivated / $attempted, 1) : 0; $overall_rate = $targets > 0 ? round(100 * $reactivated / $targets, 2) : 0; $this->section(__('Reactivation funnel', 'ai-eshop-optimizer'), __('Live pipeline state. The "Attempted" and "Reactivated" stages stay at 0 until Phase 5 (win-back trigger) runs its first dispatch.', 'ai-eshop-optimizer')); echo \Rooster\Admin\Ui::chart( 'funnel', array(array('values' => array_map(static function ($st) { return (float) $st['value']; }, $stages))), array( 'label' => __('Reactivation funnel', 'ai-eshop-optimizer'), 'labels' => array_map(static function ($st) { return (string) $st['label']; }, $stages), ) ); echo '

' . sprintf( esc_html__('Attempt rate (Attempted ÷ Reachable): %1$s%% · Reactivation rate (Reactivated ÷ Attempted): %2$s%% · Overall conversion (Reactivated ÷ Targets): %3$s%%', 'ai-eshop-optimizer'), esc_html((string) $attempt_rate), esc_html((string) $reactivation_rate), esc_html((string) $overall_rate) ) . '

'; } private function render_cohort_funnel_table($cohorts, $stats_by_cohort) { $rows = array(); foreach ($cohorts as $slug => $info) { if (!isset($stats_by_cohort[$slug])) { continue; } $s = $stats_by_cohort[$slug]; $cnt = (int) ($s['cnt'] ?? 0); $emailable = (int) ($s['has_email'] ?? 0); $sum_ltv = (float) ($s['sum_ltv'] ?? 0); $avg_score = (float) ($s['avg_score'] ?? 0); $high = (int) ($s['high_score'] ?? 0); $reach = (int) ($s['push_reachable'] ?? 0); $attempted = (int) ($s['attempted'] ?? 0); $reacted = (int) ($s['reactivated'] ?? 0); $conv_pct = $attempted > 0 ? round(100 * $reacted / $attempted, 1) : 0; $reach_pct = $high > 0 ? round(100 * $reach / $high, 0) : 0; $email_pct = $cnt > 0 ? round(100 * $emailable / $cnt, 0) : 0; $rows[] = array( 'slug' => $slug, 'cohort' => $info['label'], 'recency' => $info['recency'], 'strategy' => $info['strategy'], 'cnt' => $cnt, 'has_email' => $emailable, 'email_pct' => $email_pct > 0 ? '(' . number_format_i18n($email_pct) . '%)' : '', 'sum_ltv' => $sum_ltv, 'avg_score' => $avg_score, 'high' => $high, 'reach' => $reach, 'reach_pct' => $reach_pct > 0 ? '(' . number_format_i18n($reach_pct) . '%)' : '', 'attempted' => $attempted, 'reacted' => $reacted, 'conv_pct' => $conv_pct, ); } $this->table_section( __('Cohorts — sizing, channel reach, funnel', 'ai-eshop-optimizer'), __('"Total" is every customer in the cohort; "Has email" is the subset the lookalike and ESP exports further down this page can actually address. Both are shown side by side because the export card used to print only the has-email figure under the same cohort name, which read as a contradiction of the Total column here.', 'ai-eshop-optimizer'), sprintf( __('"High-score" = reactivation_score ≥ %d. "Push reach" = those same high-score candidates whose linked WP user has an active push subscription, so it can never exceed High-score; the percentage in parentheses is the share of the high-score pool reachable today. The percentage next to "Has email" is the share of the whole cohort, not of the high-score pool.', 'ai-eshop-optimizer'), (int) AIEO_DMM::REACTIVATION_SCORE_MIN ), array( 'page_id' => $this->get_id(), 'row_key' => 'slug', 'rows' => $rows, 'chooser' => true, 'columns' => array( array( 'key' => 'cohort', 'label' => __('Cohort', 'ai-eshop-optimizer'), 'sort_key' => 'slug', 'subline' => static function ($r) { return $r['slug'] . ' · ' . $r['recency']; }, ), array('key' => 'strategy', 'label' => __('Strategy', 'ai-eshop-optimizer'), 'sortable' => false, 'tone' => 'secondary'), array('key' => 'cnt', 'label' => __('Total', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'has_email', 'label' => __('Has email', 'ai-eshop-optimizer'), 'type' => 'integer', 'suffix' => 'email_pct'), array('key' => 'sum_ltv', 'label' => __('Pool LTV', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'avg_score', 'label' => __('Avg score', 'ai-eshop-optimizer'), 'type' => 'decimal', 'format' => array('precision' => 1)), array('key' => 'high', 'label' => __('High-score', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'reach', 'label' => __('Push reach', 'ai-eshop-optimizer'), 'type' => 'integer', 'suffix' => 'reach_pct'), array('key' => 'attempted', 'label' => __('Attempted', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'reacted', 'label' => __('Reactivated', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'conv_pct', 'label' => __('Conv %', 'ai-eshop-optimizer'), 'type' => 'decimal', 'format' => array('precision' => 1), 'suffix' => '%'), ), )); } private function render_reactivation_candidates_table($tbl_ci) { global $wpdb; $candidates = $wpdb->get_results( "SELECT eponymous_id, wp_user_id, email, ltv_total, orders_count, recency_days, top_brand_id, dormancy_cohort, reactivation_score, reactivation_attempts_count, last_reactivation_attempt_at, reactivated_at, JSON_VALUE(replenishment_json, '$.avg_dbo') AS avg_dbo FROM {$tbl_ci} WHERE dormancy_cohort <> '' AND reactivated_at IS NULL ORDER BY reactivation_score DESC, ltv_total DESC LIMIT 50", ARRAY_A ); $vitals_table = $wpdb->prefix . 'aieo_core_product_vitals'; $cohorts = self::reactivation_cohort_catalog(); $rows = array(); foreach ((array) $candidates as $i => $r) { $epo = (int) $r['eponymous_id']; $wp_uid = (int) $r['wp_user_id']; $email = (string) $r['email']; $cohort = (string) $r['dormancy_cohort']; $brand = (int) $r['top_brand_id'] > 0 ? $this->lookup_brand_name((int) $r['top_brand_id'], $vitals_table) : ''; $rows[] = array( 'rank' => $i + 1, 'epo' => $epo, 'score' => (int) $r['reactivation_score'], 'cohort' => isset($cohorts[$cohort]) ? $cohorts[$cohort]['label'] : $cohort, 'cohort_k' => $cohort, 'customer' => '' !== $email ? $email : ('#' . $epo), 'edit_url' => $wp_uid > 0 ? admin_url('user-edit.php?user_id=' . $wp_uid) : '', 'epo_sub' => 'epo: ' . $epo, 'ltv' => (float) $r['ltv_total'], 'orders' => (int) $r['orders_count'], 'recency' => (int) $r['recency_days'], 'brand' => $brand, 'attempts' => (int) $r['reactivation_attempts_count'], ); } $this->table_section( __('Top reactivation candidates', 'ai-eshop-optimizer'), __('The 50 highest-scoring dormants who have not yet been reactivated. The win-back trigger walks this queue in score order — high-LTV recently-drifted customers first, long-lost low-spenders last.', 'ai-eshop-optimizer'), '', array( 'page_id' => $this->get_id(), 'row_key' => 'epo', 'rows' => $rows, 'caps' => array('search' => true), 'filters' => array(array('key' => 'cohort', 'label' => __('Cohort', 'ai-eshop-optimizer'), 'kind' => 'values', 'control' => 'select')), 'chooser' => true, 'cap' => 50, 'empty' => __('No dormant candidates yet. Run "Recompute now" first.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'rank', 'label' => '#', 'type' => 'id', 'sortable' => false, 'width' => '50px'), array( 'key' => 'score', 'label' => __('Score', 'ai-eshop-optimizer'), 'type' => 'integer', 'bands' => array( array('min' => 75, 'tone' => 'success'), array('min' => 25, 'max' => 49, 'tone' => 'warning'), array('min' => 0, 'max' => 24, 'tone' => 'muted'), ), ), array('key' => 'cohort', 'label' => __('Cohort', 'ai-eshop-optimizer'), 'type' => 'badge', 'sort_key' => 'cohort_k'), array( 'key' => 'customer', 'label' => __('Customer', 'ai-eshop-optimizer'), 'type' => 'link', 'format' => array('href' => 'edit_url'), 'subline' => 'epo_sub', ), array('key' => 'ltv', 'label' => __('LTV', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'orders', 'label' => __('Orders', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'recency', 'label' => __('Recency (d)', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'brand', 'label' => __('Top brand', 'ai-eshop-optimizer')), array('key' => 'attempts', 'label' => __('Attempts', 'ai-eshop-optimizer'), 'type' => 'integer'), ), )); } private function render_newsletter_settings_card() { if (!class_exists('AIEO_Newsletter_Registry')) { return; } $reg = AIEO_Newsletter_Registry::instance(); $cat = $reg->catalogue(); $s = $reg->get_settings(); $current_provider = (string) $s['provider']; $lib = class_exists('\Rooster\Admin\States'); $form_id = 'aieo-card-web-push-newsletter-integration-form'; $no_prov = $current_provider === ''; $footer = ($lib ? \Rooster\Admin\States::save_button($form_id) . ' ' : '') . '' . ' ' . esc_html__('Choose a provider and save it first.', 'ai-eshop-optimizer') . '' . ' '; $close = $this->card_open(array( 'anchor' => 'web-push-newsletter-integration', 'icon' => 'mails', 'title' => __('Newsletter integration', 'ai-eshop-optimizer'), 'description' => __('Connect Rooster One to your email/newsletter platform so cohort syncs and campaigns run through it. Choose a provider, paste the API credentials, and Test connection. Once connected, the "Sync to newsletter" button on each cohort pushes contacts with cohort tags AutomateWoo / your campaign builder can target.', 'ai-eshop-optimizer'), 'col' => 1, 'footer' => $footer, ), 'newsletter-settings'); ob_start(); $providers = array(); foreach ($cat as $slug => $info) { $providers[(string) $slug] = (string) $info['label']; } echo '
' . \Rooster\Admin\Ui::field_row(array( 'id' => 'aieo-newsletter-provider', 'label' => __('Provider', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::select('provider', $providers, $current_provider, array('id' => 'aieo-newsletter-provider', 'medium' => true)), )) . '
'; foreach ($cat as $slug => $info) { if ($slug === '' || empty($info['class']) || !class_exists($info['class'])) { continue; } $instance = new $info['class'](is_array($s[$slug] ?? null) ? $s[$slug] : array()); $rows = ''; foreach ($instance->get_settings_fields() as $field) { $key = (string) $field['key']; $type = (string) $field['type']; $label = (string) $field['label']; $val = (string) ($s[$slug][$key] ?? ''); $fid = 'aieo-nl-' . sanitize_key($slug) . '-' . sanitize_key($key); if ('password' === $type && class_exists('\Rooster\Admin\Fields')) { $control = \Rooster\Admin\Fields::secret($slug . '[' . $key . ']', '' !== $val, array('id' => $fid, 'label' => $label)); } elseif ('password' === $type) { $control = \Rooster\Admin\Ui::input($slug . '[' . $key . ']', '', array('id' => $fid, 'type' => 'password', 'autocomplete' => 'new-password')); } else { $control = \Rooster\Admin\Ui::input($slug . '[' . $key . ']', $val, array('id' => $fid)); } $rows .= \Rooster\Admin\Ui::field_row(array( 'id' => $fid, 'label' => $label, 'control' => $control, 'help' => (string) ($field['description'] ?? ''), )); } list($sub_open, $sub_close) = AIEO_DMM_UI::card(array( 'anchor' => 'web-push-newsletter-provider-' . sanitize_key($slug), 'title' => (string) $info['label'], 'level' => 3, 'class' => 'aieo-newsletter-provider-fields', 'attrs' => array('data-provider' => (string) $slug, 'hidden' => $current_provider !== $slug), )); echo $sub_open . '
' . $rows . '
' . $sub_close; } if (!$lib) { echo '

'; } $fields = (string) ob_get_clean(); if ($lib) { echo AIEO_DMM_UI::card_form('aieo_dmm_webpush_save_newsletter', 'web-push-newsletter', $form_id, $fields); } else { echo '
' . $fields . '
'; } $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); ?> render_newsletter_sync_log_table(); } private function render_newsletter_sync_log_table() { global $wpdb; $tbl = $wpdb->prefix . 'aieo_newsletter_sync_log'; $rows = $wpdb->get_results( "SELECT sync_id, provider, cohort, audience_count, synced_count, failed_count, status, error_text, started_at, completed_at FROM {$tbl} ORDER BY sync_id DESC LIMIT 10", ARRAY_A ); if (empty($rows)) { return; } $close = $this->card_open(array( 'anchor' => 'web-push-recent-syncs', 'icon' => 'refresh-cw', 'title' => __('Recent syncs', 'ai-eshop-optimizer'), 'col' => 1, )); $sync_tone = array('completed' => 'success', 'failed' => 'destructive'); $sync_rows = array(); foreach ($rows as $r) { $status_html = \Rooster\Admin\Ui::badge((string) $r['status'], isset($sync_tone[$r['status']]) ? $sync_tone[$r['status']] : 'mono', array('dot' => true)); if (!empty($r['error_text'])) { $status_html .= '' . esc_html(substr((string) $r['error_text'], 0, 100)) . ''; } $sync_rows[] = array( 'id' => (string) (int) $r['sync_id'], 'provider' => (string) $r['provider'], 'cohort' => (string) $r['cohort'], 'audience' => number_format_i18n((int) $r['audience_count']), 'synced' => number_format_i18n((int) $r['synced_count']), 'failed' => number_format_i18n((int) $r['failed_count']), 'status' => array('html' => $status_html), 'started' => (string) $r['started_at'], ); } echo \Rooster\Admin\Ui::table( array( array('key' => 'id', 'label' => '#', 'type' => 'num'), array('key' => 'provider', 'label' => __('Provider', 'ai-eshop-optimizer'), 'type' => 'code'), array('key' => 'cohort', 'label' => __('Cohort', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'audience', 'label' => __('Audience', 'ai-eshop-optimizer'), 'type' => 'num'), array('key' => 'synced', 'label' => __('Synced', 'ai-eshop-optimizer'), 'type' => 'num'), array('key' => 'failed', 'label' => __('Failed', 'ai-eshop-optimizer'), 'type' => 'num'), array('key' => 'status', 'label' => __('Status', 'ai-eshop-optimizer'), 'type' => 'html'), array('key' => 'started', 'label' => __('Started', 'ai-eshop-optimizer'), 'type' => 'date'), ), $sync_rows, array('id' => 'aieo-webpush-recent-syncs') ); echo $close; } private function render_campaign_launcher_card() { global $wpdb; $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $tbl_camp = $wpdb->prefix . 'aieo_push_campaigns'; $cohorts = self::reactivation_cohort_catalog(); $lib = class_exists('\Rooster\Admin\States'); $form_id = 'aieo-card-web-push-campaign-launcher-form'; $close = $this->card_open(array( 'anchor' => 'web-push-campaign-launcher', 'icon' => 'rocket', 'title' => __('Campaign launcher', 'ai-eshop-optimizer'), 'description' => __('Operator-initiated broadcast for special moments — site relaunch, anniversary, signature offer. Pick the audience by cohort + score + LTV; pick the channel(s) — push (now), newsletter (synced for your campaign builder to send), or both. The push side fires immediately on launch; the newsletter side syncs the audience with a tag your platform can target.', 'ai-eshop-optimizer'), 'col' => 2, 'footer' => $lib ? \Rooster\Admin\States::save_button($form_id, array('label' => __('Create campaign (draft)', 'ai-eshop-optimizer'))) : '', ), 'campaign-launcher'); ob_start(); $cohort_options = array('' => __('— Pick a cohort —', 'ai-eshop-optimizer')); foreach ($cohorts as $slug => $info) { $cohort_options[(string) $slug] = $info['label'] . ' (' . $info['recency'] . ')'; } $row = static function (array $field) { return \Rooster\Admin\Ui::field_row($field); }; echo '
'; echo $row(array( 'id' => 'aieo-camp-name', 'label' => __('Campaign name', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::input('name', '', array('id' => 'aieo-camp-name', 'medium' => true, 'required' => true, 'placeholder' => __('Spring relaunch — Lost cohort win-back', 'ai-eshop-optimizer'))), )); echo $row(array( 'id' => 'aieo-camp-cohort', 'label' => __('Audience cohort', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::select('audience_cohort', $cohort_options, '', array('id' => 'aieo-camp-cohort')), )); echo $row(array( 'id' => 'aieo-camp-min-score', 'label' => __('Min reactivation score', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::input('min_score', '50', array('id' => 'aieo-camp-min-score', 'type' => 'number', 'xs' => true, 'min' => 0, 'max' => 100)), 'help' => __('Skip customers below this score. 50 = "worth attempting" by default.', 'ai-eshop-optimizer'), )); echo $row(array( 'id' => 'aieo-camp-min-ltv', 'label' => sprintf( __('Min LTV (%s)', 'ai-eshop-optimizer'), html_entity_decode(get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8') ), 'control' => \Rooster\Admin\Ui::input('min_ltv', '0', array('id' => 'aieo-camp-min-ltv', 'type' => 'number', 'short' => true, 'min' => 0, 'step' => 'any')), 'help' => __('Skip customers below this LTV. Use 100 to match the lost-cohort "worth chasing" filter.', 'ai-eshop-optimizer'), )); echo $row(array( 'id' => 'aieo-camp-channels', 'group' => true, 'label' => __('Channels', 'ai-eshop-optimizer'), 'control' => '
' . AIEO_Kit::check('channel_push', __('Push (immediate, to active subscribers)', 'ai-eshop-optimizer'), true, array('id' => 'aieo-camp-channel-push')) . AIEO_Kit::check('channel_newsletter', __('Newsletter (sync audience to provider, send via campaign builder)', 'ai-eshop-optimizer'), false, array('id' => 'aieo-camp-channel-newsletter')) . '
', )); echo $row(array( 'id' => 'aieo-camp-title', 'label' => __('Push title', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::input('title', '', array('id' => 'aieo-camp-title', 'maxlength' => 120, 'placeholder' => __('We have prepared something special for you', 'ai-eshop-optimizer'))), )); echo $row(array( 'id' => 'aieo-camp-body', 'label' => __('Push body', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::textarea('body', '', array('id' => 'aieo-camp-body', 'rows' => 2, 'maxlength' => 255, 'placeholder' => __('We are back with a new collection and an exclusive offer for you.', 'ai-eshop-optimizer'))), )); echo $row(array( 'id' => 'aieo-camp-url', 'label' => __('Landing URL', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::input('target_url', esc_url_raw(home_url('/')), array('id' => 'aieo-camp-url', 'type' => 'url')), )); echo $row(array( 'id' => 'aieo-camp-schedule', 'group' => true, 'label' => __('Schedule', 'ai-eshop-optimizer'), 'control' => '
' . '' . '
' . \Rooster\Admin\Ui::input('schedule_at', '', array('id' => 'aieo-camp-schedule-at', 'type' => 'datetime-local', 'aria-label' => __('Schedule for later', 'ai-eshop-optimizer'))), 'help' => __('"Send now" creates the campaign as draft + you click Launch on the next step. Later schedules a cron firing.', 'ai-eshop-optimizer'), )); echo $row(array( 'id' => 'aieo-camp-rate', 'label' => __('Send rate / minute', 'ai-eshop-optimizer'), 'control' => \Rooster\Admin\Ui::input('send_rate', '200', array('id' => 'aieo-camp-rate', 'type' => 'number', 'xs' => true, 'min' => 10, 'max' => 2000)), 'help' => __('Push throttle to avoid simultaneous-fire spikes on the push servers. 200/min handles 12k subscribers in an hour.', 'ai-eshop-optimizer'), )); echo '
'; if (!$lib) { echo '

'; } $fields = (string) ob_get_clean(); if ($lib) { echo AIEO_DMM_UI::card_form('aieo_dmm_webpush_create_campaign', 'web-push-campaign', $form_id, $fields); echo ''; } else { echo '
' . $fields . '
'; } echo $close; $this->render_campaigns_history_table($tbl_camp); } private function render_campaigns_history_table($tbl_camp) { global $wpdb; $rows = $wpdb->get_results( "SELECT campaign_id, name, channels, status, audience_count, sent_count, failed_count, scheduled_at, launched_at, completed_at, created_at, audience_filter_json FROM {$tbl_camp} ORDER BY campaign_id DESC LIMIT 20", ARRAY_A ); if (empty($rows)) { return; } $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); $lib = class_exists('\Rooster\Admin\States'); $close = $this->card_open(array( 'anchor' => 'web-push-recent-campaigns', 'icon' => 'megaphone', 'title' => __('Recent campaigns', 'ai-eshop-optimizer'), 'col' => 2, )); echo '
'; $asks = ''; $camp_tone = array('sent' => 'success', 'failed' => 'destructive', 'running' => 'info'); $camp_rows = array(); foreach ($rows as $r) { $failed = (int) $r['failed_count'] > 0 ? '(' . esc_html(number_format_i18n((int) $r['failed_count'])) . ' ' . esc_html__('fail', 'ai-eshop-optimizer') . ')' : ''; $action = '—'; if ($r['status'] === 'draft' || $r['status'] === 'scheduled') { if ($lib) { $ask = 'aieo-webpush-launch-ask-' . (int) $r['campaign_id']; $action = \Rooster\Admin\Ui::button(__('Launch', 'ai-eshop-optimizer'), array( 'size' => 'sm', 'class' => 'aieo-launch-campaign', 'attrs' => array('data-id' => (string) (int) $r['campaign_id']) + \Rooster\Admin\States::confirm_attrs($ask), )); $asks .= $this->launch_confirmation($ask, $r); } else { $action = ''; } } $camp_rows[] = array( '_id' => (string) (int) $r['campaign_id'], 'id' => (string) (int) $r['campaign_id'], 'name' => (string) $r['name'], 'channels' => (string) $r['channels'], 'audience' => number_format_i18n((int) $r['audience_count']), 'sent' => array('html' => '' . esc_html(number_format_i18n((int) $r['sent_count'])) . '' . $failed), 'status' => array('html' => '' . \Rooster\Admin\Ui::badge((string) $r['status'], isset($camp_tone[$r['status']]) ? $camp_tone[$r['status']] : 'mono', array('dot' => true)) . ''), 'action' => array('html' => $action), ); } echo \Rooster\Admin\Ui::table( array( array('key' => 'id', 'label' => '#', 'type' => 'num'), array('key' => 'name', 'label' => __('Name', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'channels', 'label' => __('Channels', 'ai-eshop-optimizer'), 'type' => 'code'), array('key' => 'audience', 'label' => __('Audience', 'ai-eshop-optimizer'), 'type' => 'num'), array('key' => 'sent', 'label' => __('Sent', 'ai-eshop-optimizer'), 'type' => 'html'), array('key' => 'status', 'label' => __('Status', 'ai-eshop-optimizer'), 'type' => 'html'), array('key' => 'action', 'label' => __('Action', 'ai-eshop-optimizer'), 'type' => 'html'), ), $camp_rows, array('id' => 'aieo-webpush-campaigns') ); echo $asks; ?> ' . esc_html(sprintf( _n( 'It goes to the %1$s customer of its audience, counted when the campaign was created: cohort %2$s, reactivation score %3$s or more, lifetime value %4$s or more.', 'It goes to the %1$s customers of its audience, counted when the campaign was created: cohort %2$s, reactivation score %3$s or more, lifetime value %4$s or more.', $n, 'ai-eshop-optimizer' ), number_format_i18n($n), $cohort, number_format_i18n((int) ($filter['min_score'] ?? 0)), function_exists('wc_price') ? wp_strip_all_tags(wc_price($ltv)) : number_format_i18n($ltv, 2) )) . '

'; if (in_array('push', $channels, true)) { $body .= '

' . esc_html__('Push: each of them who allows notifications gets this one now, at your send rate.', 'ai-eshop-optimizer') . '

'; } if (in_array('newsletter', $channels, true)) { $body .= '

' . esc_html__('Newsletter: they are synced to your newsletter platform with this campaign\'s tag, for your campaign builder to send.', 'ai-eshop-optimizer') . '

'; } $body .= '

' . esc_html__('A message cannot be called back once it is sent.', 'ai-eshop-optimizer') . '

'; return \Rooster\Admin\States::confirm( $id, 'apply', sprintf(__('Launch “%s” now?', 'ai-eshop-optimizer'), (string) ($row['name'] ?? '')), $body, array( 'confirm' => __('Launch', 'ai-eshop-optimizer'), ) ); } public function render_lost_cohort_playbook_card($tbl_ci, $rollup = null) { global $wpdb; if (!is_array($rollup)) { $rollup = $this->cohort_rollup($tbl_ci); } $count = (int) self::rollup_val($rollup, 'lost', 'cnt'); $pool_ltv = self::rollup_val($rollup, 'lost', 'sum_ltv'); $avg_ltv = self::rollup_val($rollup, 'lost', 'avg_ltv'); $has_email = (int) self::rollup_val($rollup, 'lost', 'has_email'); $worth_chasing = (int) self::rollup_val($rollup, 'lost', 'worth_chasing'); $worth_chasing_ltv = self::rollup_val($rollup, 'lost', 'worth_chasing_ltv'); $close = $this->card_open(array( 'anchor' => 'web-push-lost-cohort-playbook-365-days', 'icon' => 'user-x', 'title' => __('Lost-customer playbook (365+ days)', 'ai-eshop-optimizer'), 'description' => __('The lost cohort needs different mechanics than the trigger queue: it\'s a campaign play (site relaunch, anniversary, big sale, identity refresh), not an event-driven push. Push reach is near-zero for these customers — most were never on the site after we shipped push. Email + newsletter is the natural channel; the CSV export below gets you actionable today.', 'ai-eshop-optimizer'), ), 'lost-cohort'); echo '
'; $this->tile(__('Lost customers', 'ai-eshop-optimizer'), number_format_i18n($count), __('Customers in dormancy_cohort = "lost" — no order in 365+ days. Same figure as the "lost" row of the Cohorts table above; both read one shared rollup. Treated separately from the shorter dormancy tiers because they need campaign mechanics, not event-driven nudges.', 'ai-eshop-optimizer')); $this->tile(__('Pool LTV', 'ai-eshop-optimizer'), $this->fmt_money($pool_ltv), __('Total historical LTV across the lost pool. The aspirational ceiling — what we\'d recover if every lost customer came back at their original LTV. Unrealistic, but a useful upper bound.', 'ai-eshop-optimizer')); $this->tile(__('Avg LTV', 'ai-eshop-optimizer'), $this->fmt_money($avg_ltv), __('Mean LTV per lost customer (Pool LTV ÷ Lost customers). Tells you whether this cohort is mostly one-time low-value buyers or a few high-value lapsed regulars.', 'ai-eshop-optimizer')); $this->tile(__('Have email', 'ai-eshop-optimizer'), number_format_i18n($has_email), sprintf( __('Lost customers with a non-empty email address — i.e. addressable via newsletter / TNP — out of %s in the cohort. The remainder is unreachable through any owned channel. This is the same has-email figure the "Addressable" column of the lookalike export below uses; it is stated next to the cohort total rather than quietly replacing it.', 'ai-eshop-optimizer'), number_format_i18n($count) )); $this->tile(__('Worth chasing', 'ai-eshop-optimizer'), number_format_i18n($worth_chasing), sprintf( __('Lost customers with email AND (LTV ≥ %s OR ≥ 3 prior orders). Skips the one-time low-spenders that dominate any long-tail and would dilute campaign metrics.', 'ai-eshop-optimizer'), $this->fmt_money(100) )); $this->tile(__('Chase-pool LTV', 'ai-eshop-optimizer'), $this->fmt_money($worth_chasing_ltv), __('Total historical LTV across just the "Worth chasing" subset. Realistic ceiling for what a well-aimed lost-cohort campaign could surface.', 'ai-eshop-optimizer')); echo '
'; $this->guidance(sprintf( __('"Worth chasing" = lost customers with email AND (LTV ≥ %s OR ≥ 3 prior orders). Skips the one-time low-spenders the long tail gives you.', 'ai-eshop-optimizer'), $this->fmt_money(100) )); $this->section(__('Recommended plays', 'ai-eshop-optimizer')); echo '
    '; echo '
  1. ' . esc_html__('Site-relaunch / brand-refresh blast', 'ai-eshop-optimizer') . ' — ' . esc_html__('“We\'ve redesigned the site for you. New look, same FF.” Single-shot to the chase pool. Highest reactivation lift of any lost-cohort play.', 'ai-eshop-optimizer') . '
  2. '; echo '
  3. ' . esc_html__('Last-chance signature offer', 'ai-eshop-optimizer') . ' — ' . esc_html__('25–30% off + free shipping with a brand-specific framing. Generate a one-time WooCommerce coupon code. After: mute the non-responders permanently.', 'ai-eshop-optimizer') . '
  4. '; echo '
  5. ' . esc_html__('Newsletter re-permission ("are you still with us?")', 'ai-eshop-optimizer') . ' — ' . esc_html__('Two-line check-in with one CTA. Honest framing protects the sender reputation; respondents earn a follow-up offer, non-respondents get muted.', 'ai-eshop-optimizer') . '
  6. '; echo '
  7. ' . esc_html__('Anniversary-of-last-purchase', 'ai-eshop-optimizer') . ' — ' . esc_html__('Annual scheduled trigger keyed to first_order_at + N years. “It\'s been a year.” Lower volume, higher relevance. Phase 6+ once we automate.', 'ai-eshop-optimizer') . '
  8. '; echo '
  9. ' . esc_html__('Lookalike-audience export', 'ai-eshop-optimizer') . ' — ' . esc_html__('Ship the lost-cohort emails to Meta/Google as a custom audience seed. Their lookalike algorithm finds new customers with similar profiles for paid acquisition. Indirect, but the asset earns its keep.', 'ai-eshop-optimizer') . '
  10. '; echo '
'; $this->section( __('Export — drop into MailChimp / Klaviyo / your ESP', 'ai-eshop-optimizer'), __('Each export is a CSV with: email, eponymous_id, wp_user_id, ltv, orders_count, recency_days, top_brand, top_category. Re-importable into any email platform. Use the "Worth chasing" filter to skip noise.', 'ai-eshop-optimizer') ); $base_url = admin_url('admin-post.php?action=aieo_dmm_webpush_export_cohort'); $nonce = wp_create_nonce('aieo_dmm_webpush_export_cohort'); $exports = array( array( 'cohort' => 'lost', 'filter' => 'worth_chasing', 'label' => sprintf( __('Lost — worth chasing (with email, LTV ≥ %s or ≥ 3 orders)', 'ai-eshop-optimizer'), $this->fmt_money(100) ), 'count' => $worth_chasing, ), array( 'cohort' => 'lost', 'filter' => 'all', 'label' => __('Lost — full cohort', 'ai-eshop-optimizer'), 'count' => $count, ), array( 'cohort' => 'long_dormant', 'filter' => 'all', 'label' => __('Long-dormant — full cohort (181–365d)', 'ai-eshop-optimizer'), 'count' => (int) self::rollup_val($rollup, 'long_dormant', 'cnt'), ), array( 'cohort' => 'lapsed', 'filter' => 'all', 'label' => __('Lapsed — full cohort (91–180d)', 'ai-eshop-optimizer'), 'count' => (int) self::rollup_val($rollup, 'lapsed', 'cnt'), ), ); echo '
'; foreach ($exports as $exp) { $url = add_query_arg(array( 'cohort' => $exp['cohort'], 'filter' => $exp['filter'], '_wpnonce' => $nonce, ), $base_url); if (class_exists('\Rooster\Admin\Ui')) { $btn = \Rooster\Admin\Ui::button( $exp['label'] . ' (' . number_format_i18n((int) $exp['count']) . ')', array('href' => $url, 'icon' => 'download', 'variant' => 'outline') ); echo $btn; continue; } echo ''; echo '⬇ ' . esc_html($exp['label']); echo ' (' . esc_html(number_format_i18n((int) $exp['count'])) . ')'; echo ''; } echo '
'; $manual = $wpdb->get_results( "SELECT eponymous_id, email, ltv_total, orders_count, recency_days, top_brand_id FROM {$tbl_ci} WHERE dormancy_cohort = 'lost' AND email <> '' AND (ltv_total >= 100 OR orders_count >= 3) ORDER BY ltv_total DESC LIMIT 20", ARRAY_A ); if (!empty($manual)) { $vitals_table = $wpdb->prefix . 'aieo_core_product_vitals'; $manual_rows = array(); foreach ($manual as $i => $c) { $days = (int) $c['recency_days']; $manual_rows[] = array( 'rank' => $i + 1, 'epo' => (int) $c['eponymous_id'], 'email' => (string) $c['email'], 'epo_sub' => 'epo: ' . (int) $c['eponymous_id'], 'ltv' => (float) $c['ltv_total'], 'orders' => (int) $c['orders_count'], 'days' => $days, 'last_seen' => sprintf( _n('%d day ago', '%d days ago', $days, 'ai-eshop-optimizer'), $days ), 'brand' => (int) $c['top_brand_id'] > 0 ? $this->lookup_brand_name((int) $c['top_brand_id'], $vitals_table) : '', ); } $this->table_section( __('Top 20 lost customers worth a personal touch', 'ai-eshop-optimizer'), __('Customers in the lost cohort with high LTV — worth a hand-crafted email from the brand owner before the automated lost-cohort blast. The personal touch converts ~3-5× better than templated sends on this segment.', 'ai-eshop-optimizer'), '', array( 'page_id' => $this->get_id(), 'row_key' => 'epo', 'rows' => $manual_rows, 'cap' => 20, 'columns' => array( array('key' => 'rank', 'label' => '#', 'type' => 'id', 'sortable' => false, 'width' => '50px'), array('key' => 'email', 'label' => __('Customer', 'ai-eshop-optimizer'), 'subline' => 'epo_sub'), array('key' => 'ltv', 'label' => __('LTV', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'orders', 'label' => __('Orders', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'last_seen', 'label' => __('Last seen', 'ai-eshop-optimizer'), 'sort_key' => 'days', 'nowrap' => true), array('key' => 'brand', 'label' => __('Top brand', 'ai-eshop-optimizer')), ), )); } $roadmap = '' . esc_html__('Phase 6+ automation hooks:', 'ai-eshop-optimizer') . ' ' . esc_html__('campaign launcher UI (audience filter + message + scheduled send), one-time WooCommerce coupon code generation per recipient, email-channel adapter (Mailchimp/Klaviyo/native WP), anniversary-of-last-purchase scheduled trigger, lookalike audience auto-export to Meta/Google.', 'ai-eshop-optimizer'); if ($this->chrome_on()) { $this->section(''); $this->note($roadmap, 'mono'); } else { echo '
' . $roadmap . '
'; } echo $close; } public function ajax_save_template() { AIEO_DMM::verify_ajax_request(); global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_templates'; $tid = isset($_POST['template_id']) ? (int) sanitize_text_field( wp_unslash( $_POST['template_id'] ) ) : 0; $field = isset($_POST['field']) ? sanitize_key((string) wp_unslash($_POST['field'])) : ''; $raw = isset($_POST['value']) ? wp_unslash((string) sanitize_text_field( wp_unslash( $_POST['value'] ) )) : ''; $allowed = array('weight', 'title_template', 'body_template', 'icon_url', 'is_active'); if ($tid <= 0 || !in_array($field, $allowed, true)) { wp_send_json_error(array('message' => __('Bad input.', 'ai-eshop-optimizer'))); } if ($field === 'weight') { $value = max(1, min(100, (int) $raw)); $fmt = '%d'; } elseif ($field === 'is_active') { $value = ((int) $raw) === 1 ? 1 : 0; $fmt = '%d'; } elseif ($field === 'icon_url') { $value = esc_url_raw($raw); $fmt = '%s'; } else { $value = wp_strip_all_tags($raw); $fmt = '%s'; } $rows = (int) $wpdb->update( $tbl, array($field => $value), array('template_id' => $tid), array($fmt), array('%d') ); wp_send_json_success(array('rows' => $rows)); } private static function newsletter_secret_keys() { $keys = array(); if (!class_exists('AIEO_Newsletter_Registry')) { return $keys; } foreach (AIEO_Newsletter_Registry::instance()->catalogue() as $slug => $info) { if ('' === $slug || empty($info['class']) || !class_exists($info['class'])) { continue; } $adapter = new $info['class'](array()); foreach ($adapter->get_settings_fields() as $field) { if ('password' === (string) ($field['type'] ?? '')) { $keys[$slug][] = (string) $field['key']; } } } return $keys; } public function ajax_save_newsletter() { AIEO_DMM::verify_ajax_request(); if (!class_exists('AIEO_Newsletter_Registry')) { wp_send_json_error(array('message' => __('Newsletter framework not loaded.', 'ai-eshop-optimizer'))); } $patch = array(); if (isset($_POST['provider'])) { $patch['provider'] = sanitize_key((string) wp_unslash($_POST['provider'])); } $secret_keys = self::newsletter_secret_keys(); foreach (array('tnp', 'mailchimp', 'klaviyo', 'webhook') as $slug) { if (isset($_POST[$slug]) && is_array($_POST[$slug])) { $patch[$slug] = array_map('sanitize_text_field', wp_unslash($_POST[$slug])); foreach ($secret_keys[$slug] ?? array() as $key) { if (isset($patch[$slug][$key]) && '' === trim(str_replace("\u{2022}", '', (string) $patch[$slug][$key]))) { unset($patch[$slug][$key]); } } } } AIEO_Newsletter_Registry::instance()->save_settings($patch); wp_send_json_success(array( 'message' => __('Newsletter provider settings saved.', 'ai-eshop-optimizer'), )); } public function ajax_test_newsletter() { AIEO_DMM::verify_ajax_request(); if (!class_exists('AIEO_Newsletter_Registry')) { wp_send_json_error(array('message' => __('Newsletter framework not loaded.', 'ai-eshop-optimizer'))); } $adapter = AIEO_Newsletter_Registry::instance()->adapter(); if ($adapter === null) { wp_send_json_error(array('message' => __('No provider configured. Save settings first.', 'ai-eshop-optimizer'))); } $result = $adapter->test_connection(); if ($result['ok']) { $extras = ''; if (!empty($result['details']['account_name'])) { $extras = ' — ' . $result['details']['account_name']; } wp_send_json_success(array( 'message' => sprintf( __('✓ %1$s connection OK%2$s', 'ai-eshop-optimizer'), $adapter->get_label(), $extras ), )); } wp_send_json_error(array( 'message' => sprintf( __('✗ %1$s connection failed: %2$s', 'ai-eshop-optimizer'), $adapter->get_label(), (string) $result['error'] ), )); } public function ajax_sync_cohort() { AIEO_DMM::verify_ajax_request(); if (!self::is_engine_live()) { wp_send_json_error(array('message' => self::engine_paused_message())); } if (!class_exists('AIEO_Newsletter_Registry')) { wp_send_json_error(array('message' => __('Newsletter framework not loaded.', 'ai-eshop-optimizer'))); } $cohort = isset($_POST['cohort']) ? sanitize_key((string) wp_unslash($_POST['cohort'])) : ''; if (!self::load_audience_rules() || !AIEO_Audience_Rules::is_population($cohort)) { wp_send_json_error(array('message' => __('Invalid cohort.', 'ai-eshop-optimizer'))); } $filter = array( 'only_email' => true, 'min_score' => isset($_POST['min_score']) ? (int) $_POST['min_score'] : 0, 'min_ltv' => isset($_POST['min_ltv']) ? (float) $_POST['min_ltv'] : 0, ); $started = microtime(true); $result = AIEO_Newsletter_Registry::instance()->sync_cohort($cohort, $filter); $duration = round(microtime(true) - $started, 2); if (!empty($result['ok']) || (isset($result['synced']) && (int) $result['synced'] > 0)) { wp_send_json_success(array( 'message' => sprintf( __('Synced %1$d / %2$d failed for cohort "%3$s" in %4$ss.', 'ai-eshop-optimizer'), (int) ($result['synced'] ?? 0), (int) ($result['failed'] ?? 0), $cohort, $duration ), 'result' => $result, )); } wp_send_json_error(array( 'message' => sprintf( __('Sync failed for cohort "%1$s": %2$s', 'ai-eshop-optimizer'), $cohort, (string) ($result['error'] ?? 'unknown') ), )); } public function ajax_create_campaign() { AIEO_DMM::verify_ajax_request(); global $wpdb; $name = sanitize_text_field(wp_unslash((string) ($_POST['name'] ?? ''))); $cohort = sanitize_key((string) wp_unslash($_POST['audience_cohort'] ?? '')); $min_score = isset($_POST['min_score']) ? max(0, min(100, (int) $_POST['min_score'])) : 0; $min_ltv = isset($_POST['min_ltv']) ? max(0, (float) $_POST['min_ltv']) : 0; $title = sanitize_text_field(wp_unslash((string) ($_POST['title'] ?? ''))); $body = sanitize_textarea_field(wp_unslash((string) ($_POST['body'] ?? ''))); $target_url = esc_url_raw(wp_unslash((string) ($_POST['target_url'] ?? ''))); $when = sanitize_key((string) wp_unslash($_POST['schedule_when'] ?? 'now')); $sched_at_raw = (string) wp_unslash($_POST['schedule_at'] ?? ''); $send_rate = isset($_POST['send_rate']) ? max(10, min(2000, (int) $_POST['send_rate'])) : 200; $do_push = !empty($_POST['channel_push']); $do_newsletter = !empty($_POST['channel_newsletter']); if ($name === '' || $cohort === '' || (!$do_push && !$do_newsletter)) { wp_send_json_error(array('message' => __('Name, cohort, and at least one channel are required.', 'ai-eshop-optimizer'))); } $channels = array(); if ($do_push) { $channels[] = 'push'; } if ($do_newsletter) { $channels[] = 'newsletter'; } $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $wc = self::cohort_where_clause($cohort); $count_args = array_merge($wc['args'], array($min_score, $min_ltv)); $audience_count = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$tbl_ci} WHERE {$wc['sql']} AND email <> '' AND reactivation_score >= %d AND ltv_total >= %f", $count_args )); $scheduled_at = null; $status = 'draft'; if ($when === 'later' && $sched_at_raw !== '') { $ts = strtotime($sched_at_raw); if ($ts > time()) { $scheduled_at = gmdate('Y-m-d H:i:s', $ts); $status = 'scheduled'; } } $audience_filter = array( 'cohort' => $cohort, 'min_score' => $min_score, 'min_ltv' => $min_ltv, ); $tbl = $wpdb->prefix . 'aieo_push_campaigns'; $wpdb->insert($tbl, array( 'name' => $name, 'audience_filter_json' => wp_json_encode($audience_filter), 'channels' => implode(',', $channels), 'title_template' => $title, 'body_template' => $body, 'cta_label' => '', 'target_url' => $target_url, 'status' => $status, 'audience_count' => $audience_count, 'send_rate_per_minute' => $send_rate, 'scheduled_at' => $scheduled_at, 'created_by' => get_current_user_id(), )); $cid = (int) $wpdb->insert_id; wp_send_json_success(array( 'message' => sprintf( __('Campaign #%1$d created with audience %2$d. Status: %3$s.', 'ai-eshop-optimizer'), $cid, $audience_count, $status ), 'campaign_id' => $cid, )); } public function ajax_launch_campaign() { AIEO_DMM::verify_ajax_request(); if (!self::is_engine_live()) { wp_send_json_error(array('message' => self::engine_paused_message())); } global $wpdb; $cid = isset($_POST['campaign_id']) ? (int) $_POST['campaign_id'] : 0; if ($cid <= 0) { wp_send_json_error(array('message' => __('Invalid campaign id.', 'ai-eshop-optimizer'))); } $tbl = $wpdb->prefix . 'aieo_push_campaigns'; $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $tbl_subs = $wpdb->prefix . 'aieo_push_subscriptions'; $tbl_dispatch = $wpdb->prefix . 'aieo_push_dispatch'; $camp = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$tbl} WHERE campaign_id = %d", $cid), ARRAY_A); if (!$camp) { wp_send_json_error(array('message' => __('Campaign not found.', 'ai-eshop-optimizer'))); } if (!in_array($camp['status'], array('draft', 'scheduled'), true)) { wp_send_json_error(array('message' => __('Campaign already launched or completed.', 'ai-eshop-optimizer'))); } $filter = json_decode((string) $camp['audience_filter_json'], true); $cohort = (string) ($filter['cohort'] ?? ''); $min_score = (int) ($filter['min_score'] ?? 0); $min_ltv = (float) ($filter['min_ltv'] ?? 0); $channels = explode(',', (string) $camp['channels']); $started_at = current_time('mysql', true); $claimed = (int) $wpdb->query($wpdb->prepare( "UPDATE {$tbl} SET status = 'running', launched_at = %s WHERE campaign_id = %d AND status IN ('draft','scheduled')", $started_at, $cid )); if ($claimed < 1) { wp_send_json_error(array('message' => __('Campaign already launched or completed.', 'ai-eshop-optimizer'))); } $sent = 0; $failed = 0; $newsletter_segment_id = ''; $newsletter_provider = ''; $push_queued = false; if (in_array('push', $channels, true)) { $this->schedule_campaign_batch($cid, 0, true); $push_queued = true; } if (in_array('newsletter', $channels, true) && class_exists('AIEO_Newsletter_Registry')) { $sync = AIEO_Newsletter_Registry::instance()->sync_cohort($cohort, array( 'only_email' => true, 'min_score' => $min_score, 'min_ltv' => $min_ltv, )); $sent += (int) ($sync['synced'] ?? 0); $failed += (int) ($sync['failed'] ?? 0); $newsletter_segment_id = (string) ($sync['provider_segment_id'] ?? ''); $adapter = AIEO_Newsletter_Registry::instance()->adapter(); if ($adapter !== null) { $newsletter_provider = $adapter->get_id(); } } $wpdb->update($tbl, array( 'status' => $push_queued ? 'running' : 'sent', 'sent_count' => $sent, 'failed_count' => $failed, 'newsletter_provider' => $newsletter_provider, 'newsletter_segment_id' => $newsletter_segment_id, 'completed_at' => $push_queued ? null : current_time('mysql', true), ), array('campaign_id' => $cid)); wp_send_json_success(array( 'message' => $push_queued ? sprintf( __('Campaign #%d launching — push is sending in the background at your send rate.', 'ai-eshop-optimizer'), $cid ) : sprintf( __('Campaign #%1$d launched: %2$d sent, %3$d failed.', 'ai-eshop-optimizer'), $cid, $sent, $failed ), 'sent' => $sent, 'failed' => $failed, 'more' => $push_queued, )); } private static function campaign_batch_size($camp) { $rate = max(1, (int) ($camp['send_rate_per_minute'] ?? 0)); return min(200, $rate); } private function schedule_campaign_batch($cid, $after, $immediate = false) { if (function_exists('as_enqueue_async_action') && $immediate) { as_enqueue_async_action('aieo_push_campaign_batch', array((int) $cid, (int) $after), 'aieo'); return; } $when = time() + ($immediate ? 0 : MINUTE_IN_SECONDS); if (function_exists('as_schedule_single_action')) { as_schedule_single_action($when, 'aieo_push_campaign_batch', array((int) $cid, (int) $after), 'aieo'); } else { wp_schedule_single_event($when, 'aieo_push_campaign_batch', array((int) $cid, (int) $after)); } } const CAMPAIGN_MAX_HOURS = 24; public function run_campaign_push_batch($cid, $after) { global $wpdb; $cid = (int) $cid; $tbl = $wpdb->prefix . 'aieo_push_campaigns'; $camp = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$tbl} WHERE campaign_id = %d", $cid), ARRAY_A); if (!$camp || 'running' !== (string) $camp['status']) { return; } $launched = strtotime((string) $camp['launched_at'] . ' UTC'); if ($launched > 0 && (time() - $launched) > self::CAMPAIGN_MAX_HOURS * HOUR_IN_SECONDS) { $wpdb->update($tbl, array('status' => 'sent', 'completed_at' => current_time('mysql', true)), array('campaign_id' => $cid)); return; } try { $r = $this->send_campaign_push_batch($cid, (int) $after, self::campaign_batch_size($camp)); } catch (\Throwable $e) { $this->schedule_campaign_batch($cid, (int) $after, false); return; } if (!empty($r['paused'])) { $this->schedule_campaign_batch($cid, (int) $after, false); return; } $wpdb->query($wpdb->prepare( "UPDATE {$tbl} SET sent_count = sent_count + %d, failed_count = failed_count + %d WHERE campaign_id = %d", (int) $r['sent'], (int) $r['failed'], $cid )); if (!empty($r['more'])) { $this->schedule_campaign_batch($cid, (int) $r['last_id'], false); } else { $wpdb->update($tbl, array('status' => 'sent', 'completed_at' => current_time('mysql', true)), array('campaign_id' => $cid)); } } public function send_campaign_push_batch($cid, $after, $limit) { global $wpdb; $cid = (int) $cid; $limit = max(1, (int) $limit); $none = array('sent' => 0, 'failed' => 0, 'processed' => 0, 'last_id' => (int) $after, 'more' => false); if (!self::is_engine_live()) { return array_merge($none, array('paused' => true)); } if (!class_exists('AIEO_Push_Sender')) { return $none; } $tbl = $wpdb->prefix . 'aieo_push_campaigns'; $camp = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$tbl} WHERE campaign_id = %d", $cid), ARRAY_A); if (!$camp) { return $none; } $filter = json_decode((string) $camp['audience_filter_json'], true); $cohort = (string) ($filter['cohort'] ?? ''); $min_score = (int) ($filter['min_score'] ?? 0); $min_ltv = (float) ($filter['min_ltv'] ?? 0); $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $tbl_subs = $wpdb->prefix . 'aieo_push_subscriptions'; $tbl_disp = $wpdb->prefix . 'aieo_push_dispatch'; $wc = self::cohort_where_clause($cohort); $ci_clause = preg_replace('/\b(sale_purchase_cnt|new_product_purchase_cnt|gift_completed_count|monthly_offer_purchase_cnt|dormancy_cohort)\b/', 'ci.$1', $wc['sql']); $args = array_merge($wc['args'], array($min_score, $min_ltv, (int) $after, $limit)); $rows = $wpdb->get_results($wpdb->prepare( "SELECT s.subscription_id, s.endpoint, s.p256dh, s.auth_secret, s.user_id, ci.eponymous_id FROM {$tbl_subs} s JOIN {$tbl_ci} ci ON ci.wp_user_id = s.user_id WHERE s.status = 'active' AND s.user_id > 0 AND {$ci_clause} AND ci.reactivation_score >= %d AND ci.ltv_total >= %f AND s.subscription_id > %d ORDER BY s.subscription_id ASC LIMIT %d", $args ), ARRAY_A); $sender = AIEO_Push_Sender::instance(); $title = (string) $camp['title_template']; $body = (string) $camp['body_template']; $url = (string) $camp['target_url']; if ('' === $url) { $url = home_url('/'); } $sent = 0; $failed = 0; $last = (int) $after; foreach ((array) $rows as $sr) { $last = (int) $sr['subscription_id']; $now = current_time('mysql', true); $wpdb->insert($tbl_disp, array( 'subscription_id' => (int) $sr['subscription_id'], 'user_id' => (int) $sr['user_id'], 'trigger_id' => 'campaign:' . $cid, 'target_url' => $url, 'state' => 'queued', 'queued_at' => $now, 'created_at' => $now, )); $did = (int) $wpdb->insert_id; $tracked = add_query_arg(array( 'aieo_src' => 'push', 'aieo_dispatch_id' => (string) $did, 'utm_source' => 'aieo_push', 'utm_medium' => 'push', 'utm_campaign' => 'campaign:' . $cid, ), $url); $payload = wp_json_encode(array( 'title' => $title, 'body' => $body, 'tag' => 'campaign-' . $cid, 'url' => $tracked, 'dispatchId' => $did, )); $result = $sender->send(array( 'endpoint' => $sr['endpoint'], 'p256dh' => $sr['p256dh'], 'auth_secret' => $sr['auth_secret'], ), (string) $payload, 60); $wpdb->update($tbl_disp, array( 'state' => !empty($result['ok']) ? 'sent' : 'failed', 'provider_status' => (int) ($result['status'] ?? 0), 'provider_error' => substr((string) ($result['error'] ?? ''), 0, 200), 'sent_at' => !empty($result['ok']) ? $now : null, ), array('dispatch_id' => $did)); $wpdb->query($wpdb->prepare( "UPDATE {$tbl_ci} SET reactivation_attempts_count = reactivation_attempts_count + 1, last_reactivation_attempt_at = %s WHERE eponymous_id = %d", $now, (int) $sr['eponymous_id'] )); if (!empty($result['ok'])) { $sent++; } else { $failed++; } } $processed = count((array) $rows); return array('sent' => $sent, 'failed' => $failed, 'processed' => $processed, 'last_id' => $last, 'more' => ($processed >= $limit)); } public function handle_export_lookalike() { if (!current_user_can(AIEO_CAPABILITY)) { wp_die(esc_html__('Insufficient permissions.', 'ai-eshop-optimizer')); } check_admin_referer('aieo_dmm_webpush_export_lookalike'); $cohort = isset($_GET['cohort']) ? sanitize_key((string) wp_unslash($_GET['cohort'])) : ''; if (!self::load_audience_rules()) { wp_die(esc_html__('Audience rules unavailable.', 'ai-eshop-optimizer')); } try { $population = AIEO_Audience_Rules::population_where($cohort, 'ci'); } catch (\InvalidArgumentException $e) { wp_die(esc_html__('Invalid cohort.', 'ai-eshop-optimizer')); } global $wpdb; $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $cohort_clause = ' AND ' . ( empty($population['args']) ? $population['sql'] : $wpdb->prepare($population['sql'], ...$population['args']) ) . ' '; $rows = $wpdb->get_results( "SELECT ci.email FROM {$tbl_ci} ci WHERE ci.email <> '' {$cohort_clause} LIMIT 50000", ARRAY_A ); $filename = sprintf('aieo-lookalike-%s-%s.csv', $cohort, gmdate('Ymd-His')); nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); $out = fopen('php://output', 'w'); fputcsv($out, array('sha256_email')); foreach ((array) $rows as $r) { $email = strtolower(trim((string) $r['email'])); if ($email === '' || !is_email($email)) { continue; } fputcsv($out, array(hash('sha256', $email))); } fclose($out); exit; } public function handle_export_cohort() { if (!current_user_can(AIEO_CAPABILITY)) { wp_die(esc_html__('Insufficient permissions.', 'ai-eshop-optimizer')); } check_admin_referer('aieo_dmm_webpush_export_cohort'); $cohort = isset($_GET['cohort']) ? sanitize_key((string) wp_unslash($_GET['cohort'])) : ''; $filter = isset($_GET['filter']) ? sanitize_key((string) wp_unslash($_GET['filter'])) : 'all'; if (!self::load_audience_rules()) { wp_die(esc_html__('Audience rules unavailable.', 'ai-eshop-optimizer')); } try { $population = AIEO_Audience_Rules::population_where($cohort, 'ci'); } catch (\InvalidArgumentException $e) { wp_die(esc_html__('Invalid cohort.', 'ai-eshop-optimizer')); } global $wpdb; $tbl_ci = $wpdb->prefix . 'aieo_customer_intelligence'; $vitals = $wpdb->prefix . 'aieo_core_product_vitals'; $where = ' WHERE ' . ( empty($population['args']) ? $population['sql'] : $wpdb->prepare($population['sql'], ...$population['args']) ) . ' '; if ($filter === 'worth_chasing') { $where .= " AND ci.email <> '' AND (ci.ltv_total >= 100 OR ci.orders_count >= 3) "; } $sql = "SELECT ci.eponymous_id, ci.wp_user_id, ci.email, ci.ltv_total, ci.orders_count, ci.recency_days, ci.top_brand_id, ci.top_category_id, ci.reactivation_score FROM {$tbl_ci} ci {$where} ORDER BY ci.ltv_total DESC"; $rows = $wpdb->get_results($sql, ARRAY_A); $brand_cache = array(); $cat_cache = array(); $name_for_brand = function ($id) use (&$brand_cache, $wpdb, $vitals) { $id = (int) $id; if ($id <= 0) { return ''; } if (isset($brand_cache[$id])) { return $brand_cache[$id]; } $name = (string) $wpdb->get_var($wpdb->prepare( "SELECT brand FROM {$vitals} WHERE brand_id = %d AND brand <> '' LIMIT 1", $id )); $brand_cache[$id] = $name; return $name; }; $name_for_cat = function ($id) use (&$cat_cache, $wpdb, $vitals) { $id = (int) $id; if ($id <= 0) { return ''; } if (isset($cat_cache[$id])) { return $cat_cache[$id]; } $name = (string) $wpdb->get_var($wpdb->prepare( "SELECT head_category FROM {$vitals} WHERE head_id = %d AND head_category <> '' LIMIT 1", $id )); $cat_cache[$id] = $name; return $name; }; $filename = sprintf( 'aieo-push-cohort-%s-%s-%s.csv', $cohort, $filter, gmdate('Ymd-His') ); nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); echo "\xEF\xBB\xBF"; $out = fopen('php://output', 'w'); fputcsv($out, array( 'email', 'eponymous_id', 'wp_user_id', 'ltv', 'orders_count', 'recency_days', 'top_brand', 'top_category', 'reactivation_score', )); foreach ((array) $rows as $r) { fputcsv($out, array( (string) $r['email'], (int) $r['eponymous_id'], (int) $r['wp_user_id'], (float) $r['ltv_total'], (int) $r['orders_count'], (int) $r['recency_days'], $name_for_brand($r['top_brand_id']), $name_for_cat($r['top_category_id']), (int) $r['reactivation_score'], )); } fclose($out); exit; } public static function cohort_where_clause($cohort_slug) { static $badge_clauses = array( 'sale_buyer' => 'sale_purchase_cnt >= 1', 'new_product_adopter' => 'new_product_purchase_cnt >= 1', 'gift_completer' => 'gift_completed_count >= 1', 'monthly_responder' => 'monthly_offer_purchase_cnt >= 1', ); if (isset($badge_clauses[$cohort_slug])) { return array('sql' => $badge_clauses[$cohort_slug], 'args' => array()); } if ($cohort_slug === 'active') { return array('sql' => "dormancy_cohort = ''", 'args' => array()); } return array('sql' => 'dormancy_cohort = %s', 'args' => array((string) $cohort_slug)); } public static function reactivation_cohort_catalog() { return array( 'drifting' => array( 'label' => __('Drifting', 'ai-eshop-optimizer'), 'recency' => __('60–90 days', 'ai-eshop-optimizer'), 'strategy' => __('Light reminder — new arrivals from their top brand. Push first, no offer.', 'ai-eshop-optimizer'), 'color' => '#0891b2', ), 'lapsed' => array( 'label' => __('Lapsed', 'ai-eshop-optimizer'), 'recency' => __('91–180 days', 'ai-eshop-optimizer'), 'strategy' => __('“We miss you” — personalized brand pull, light offer (e.g. 10% off favorite brand).', 'ai-eshop-optimizer'), 'color' => '#7c3aed', ), 'long_dormant' => array( 'label' => __('Long-dormant', 'ai-eshop-optimizer'), 'recency' => __('181–365 days', 'ai-eshop-optimizer'), 'strategy' => __('Strong offer — 20% off top category + social proof. Email primary, push echo.', 'ai-eshop-optimizer'), 'color' => '#ea580c', ), 'lost' => array( 'label' => __('Lost', 'ai-eshop-optimizer'), 'recency' => __('365+ days', 'ai-eshop-optimizer'), 'strategy' => __('One last best offer (free shipping + 25% / signature product), then mute permanently.', 'ai-eshop-optimizer'), 'color' => '#6b7280', ), 'discount_hunter' => array( 'label' => __('Discount Hunter', 'ai-eshop-optimizer'), 'recency' => __('any recency', 'ai-eshop-optimizer'), 'strategy' => __('Promo-led messaging. Free-shipping-threshold campaigns, percentage-off coupons, clearance-event pushes. ~3.8% of base, behavioural not recency.', 'ai-eshop-optimizer'), 'color' => '#b91c1c', ), 'pure_intent' => array( 'label' => __('Pure-intent shoppers', 'ai-eshop-optimizer'), 'recency' => __('any recency', 'ai-eshop-optimizer'), 'strategy' => __('sole_order_share ≥ 50%. They paid shipping for a single product — they know exactly what they want. Replenishment / new-variant / restock messaging on the products they sole-bought.', 'ai-eshop-optimizer'), 'color' => '#1d4ed8', ), 'vip' => array( 'label' => __('VIP', 'ai-eshop-optimizer'), 'recency' => __('top-quintile LTV', 'ai-eshop-optimizer'), 'strategy' => __('Concierge tier — early-access invites, premium-brand previews, personal-shopper outreach. Coupons NOT recommended (margin leak on already-loyal spenders).', 'ai-eshop-optimizer'), 'color' => '#dc2626', ), 'loyalist' => array( 'label' => __('Loyalist', 'ai-eshop-optimizer'), 'recency' => __('5+ orders, active', 'ai-eshop-optimizer'), 'strategy' => __('Recognition-led messaging — "thank you for X orders", referral asks, tier-graduation perks.', 'ai-eshop-optimizer'), 'color' => '#7c3aed', ), 'brand_devoted' => array( 'label' => __('Brand-Devoted', 'ai-eshop-optimizer'), 'recency' => __('70%+ single-brand', 'ai-eshop-optimizer'), 'strategy' => __('Brand-launch alerts on their concentrated brand, exclusive collaborations, restock-priority signals.', 'ai-eshop-optimizer'), 'color' => '#0891b2', ), 'replenisher' => array( 'label' => __('Replenisher', 'ai-eshop-optimizer'), 'recency' => __('predictable cadence', 'ai-eshop-optimizer'), 'strategy' => __('Time-aware reorder reminders pegged to their personal avg_dbo. Highest-converting cohort for cadence-triggered messaging.', 'ai-eshop-optimizer'), 'color' => '#15803d', ), 'newbie' => array( 'label' => __('Newbie', 'ai-eshop-optimizer'), 'recency' => __('1 order, recent', 'ai-eshop-optimizer'), 'strategy' => __('Onboarding sequence — product education, brand discovery, "complete your routine" cross-sells. NOT discount-led (sets the wrong expectation).', 'ai-eshop-optimizer'), 'color' => '#f59e0b', ), 'sale_buyer' => array( 'label' => __('Sale-responsive buyers', 'ai-eshop-optimizer'), 'recency' => __('any recency', 'ai-eshop-optimizer'), 'strategy' => __('Customers who bought from a SALE-flagged product card. Push them at the START of new sale events (early-access drops, flash promos). High signal: they\'ve proven discount sensitivity.', 'ai-eshop-optimizer'), 'color' => '#dc2626', ), 'new_product_adopter' => array( 'label' => __('NEW-product adopters', 'ai-eshop-optimizer'), 'recency' => __('any recency', 'ai-eshop-optimizer'), 'strategy' => __('Customers who bought from a NEW-flagged card — early-adopter pattern. Push fresh launches the day they go live. Low discount sensitivity — focus on novelty.', 'ai-eshop-optimizer'), 'color' => '#1a1a1a', ), 'gift_completer' => array( 'label' => __('Free-gift completers', 'ai-eshop-optimizer'), 'recency' => __('any recency', 'ai-eshop-optimizer'), 'strategy' => __('Customers who hit the cart-threshold to claim a free gift. They\'re receptive to over-and-above offers — push when a new gift offer launches with a brand they\'ve previously bought.', 'ai-eshop-optimizer'), 'color' => '#0a7d3a', ), 'monthly_responder' => array( 'label' => __('Monthly-offer responders', 'ai-eshop-optimizer'), 'recency' => __('any recency', 'ai-eshop-optimizer'), 'strategy' => __('Customers who bought from the monthly-offer rotation. Push them on the 1st of each month when the new monthly batch goes live — predictable conversion lift.', 'ai-eshop-optimizer'), 'color' => '#F46767', ), ); } public function ajax_save_banner() { AIEO_DMM::verify_ajax_request(); $cur = self::get_banner_settings(); $cur['banner_enabled'] = isset($_POST['banner_enabled']) ? true : false; if (isset($_POST['position'])) { $v = sanitize_key(wp_unslash((string) $_POST['position'])); $cur['position'] = in_array($v, array('top', 'middle', 'bottom'), true) ? $v : 'top'; } $int_fields = array( 'offset' => array(0, 500), 'delay_ms' => array(0, 60000), 'border_width' => array(0, 10), 'border_radius' => array(0, 40), 'btn_primary_border_w' => array(0, 6), 'btn_secondary_border_w' => array(0, 6), 'btn_radius' => array(0, 40), 'thanks_autoclose_secs' => array(0, 30), ); foreach ($int_fields as $key => $bounds) { if (isset($_POST[$key])) { $v = (int) wp_unslash($_POST[$key]); $cur[$key] = max($bounds[0], min($bounds[1], $v)); } } $color_fields = array( 'bg', 'text', 'icon_color', 'border_color', 'btn_primary_bg', 'btn_primary_text', 'btn_primary_border', 'btn_secondary_bg', 'btn_secondary_text', 'btn_secondary_border', ); foreach ($color_fields as $key) { if (isset($_POST[$key])) { $v = sanitize_hex_color(wp_unslash((string) $_POST[$key])); if ($v !== null && $v !== '') { $cur[$key] = $v; } } } foreach (array('copy_title', 'copy_accept', 'copy_dismiss') as $key) { if (isset($_POST[$key])) { $cur[$key] = sanitize_text_field(wp_unslash((string) $_POST[$key])); } } if (isset($_POST['copy_body'])) { $cur['copy_body'] = sanitize_textarea_field(wp_unslash((string) $_POST['copy_body'])); } foreach (array('copy_thanks_yes', 'copy_thanks_no') as $key) { if (isset($_POST[$key])) { $cur[$key] = sanitize_textarea_field(wp_unslash((string) $_POST[$key])); } } update_option(self::BANNER_OPTION, $cur, false); wp_send_json_success(array( 'message' => __('Banner appearance saved. Refresh the storefront to see the change.', 'ai-eshop-optimizer'), )); } private function render_engine_status_card() { $is_live = self::is_engine_live(); $status_label = $is_live ? __('LIVE — outbound actions are real', 'ai-eshop-optimizer') : __('PAUSED — every outbound action is blocked', 'ai-eshop-optimizer'); $bg = $is_live ? '#dcfce7' : '#fef3c7'; $bdr = $is_live ? '#15803d' : '#b45309'; $color = $is_live ? '#14532d' : '#78350f'; $next_action = $is_live ? __('Pause for demo / staging', 'ai-eshop-optimizer') : __('Resume — go live', 'ai-eshop-optimizer'); $btn_color = $is_live ? '#b45309' : '#15803d'; $text = $is_live ? __('Push notifications, campaign launches, and newsletter syncs WILL fire when triggered. Pause before any demo / staging walkthrough.', 'ai-eshop-optimizer') : __('Send Test Push, Launch Campaign, Sync Cohort, and the nightly send-chain are all blocked. Recompute + segment assignment still run (read-only). Suppressed sends are logged with reason="engine_paused".', 'ai-eshop-optimizer'); $toggle = '' . '
'; if (class_exists('AIEO_Chrome') && AIEO_Chrome::on() && class_exists('\Rooster\Admin\Ui')) { $toggle = \Rooster\Admin\Ui::button($next_action, array('variant' => $is_live ? 'outline' : 'primary', 'attrs' => array('id' => 'aieo-engine-toggle'))) . ' ' . (class_exists('\Rooster\Admin\States') ? \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-engine-toggle-status')) : ''); } if (class_exists('AIEO_Chrome') && AIEO_Chrome::on()) { $close = $this->card_open(array( 'anchor' => 'web-push-engine-status', 'icon' => 'activity', 'title' => __('Engine status', 'ai-eshop-optimizer'), 'description' => __('Whether web push messages are being sent to subscribers right now, with the switch that starts or pauses them.', 'ai-eshop-optimizer'), 'movable' => false, 'footer' => $toggle, 'meta' => class_exists('\Rooster\Admin\States') ? \Rooster\Admin\States::status($is_live ? 'on' : 'off') : '', )); echo \Rooster\Admin\Ui::notice(esc_html($text), $is_live ? 'success' : 'warning', array('inline' => true, 'title' => $status_label)); } else { echo '
'; echo '
'; echo '
'; echo '
'; echo '
' . esc_html__('Engine status', 'ai-eshop-optimizer') . '
'; echo '
' . esc_html($status_label) . '
'; echo '

' . esc_html($text) . '

'; echo '
'; echo '
' . $toggle . '
'; echo '
'; } if (isset($close)) { echo $close; } $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); ?>
'; } } public function ajax_toggle_engine() { AIEO_DMM::verify_ajax_request(); $current = self::get_engine_status(); $next = $current === self::ENGINE_STATUS_LIVE ? self::ENGINE_STATUS_PAUSED : self::ENGINE_STATUS_LIVE; self::set_engine_status($next); wp_send_json_success(array( 'status' => $next, 'message' => $next === self::ENGINE_STATUS_LIVE ? __('Engine resumed — outbound actions are LIVE.', 'ai-eshop-optimizer') : __('Engine paused — outbound actions are blocked.', 'ai-eshop-optimizer'), )); } private function render_subscribers_card() { global $wpdb; $tbl = $wpdb->prefix . 'aieo_push_subscriptions'; $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl}"); $active = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl} WHERE status = 'active'"); $logged_in = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl} WHERE status = 'active' AND user_id > 0"); $anonymous = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl} WHERE status = 'active' AND user_id = 0"); $by_device = $wpdb->get_results("SELECT device_kind, COUNT(*) c FROM {$tbl} WHERE status='active' GROUP BY device_kind ORDER BY c DESC", ARRAY_A); $admin_id = get_current_user_id(); $has_admin_sub = $admin_id > 0 ? (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$tbl} WHERE user_id = %d AND status = 'active'", $admin_id )) : 0; $footer = ''; if ($has_admin_sub > 0) { $footer = '' . ' ' . (class_exists('\Rooster\Admin\States') ? \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-webpush-send-test-status')) : '') . $this->send_test_inline_js(); } $close = $this->card_open(array( 'anchor' => 'web-push-push-subscribers', 'icon' => 'users', 'title' => __('Push subscribers', 'ai-eshop-optimizer'), 'description' => __('Browser push subscriptions captured by the first-visit opt-in banner. Anonymous subscribers are scoped by an aieo_push_session cookie until they log in (then bind to their WP user_id).', 'ai-eshop-optimizer'), 'col' => 2, 'footer' => $footer, ), 'subscribers'); echo '
'; $this->tile(__('Active subscriptions', 'ai-eshop-optimizer'), number_format_i18n($active), __('Push subscriptions in status="active" — eligible for the next dispatch tick. Decreases when browsers revoke, when permission is denied, or after 410 Gone bounces.', 'ai-eshop-optimizer')); $this->tile(__('Logged-in', 'ai-eshop-optimizer'), number_format_i18n($logged_in), __('Active subscriptions tied to a wp_users id (subscribed while authenticated, or upgraded from anonymous on later login). These can be targeted by user-level triggers (replenishment, win-back, etc.).', 'ai-eshop-optimizer')); $this->tile(__('Anonymous', 'ai-eshop-optimizer'), number_format_i18n($anonymous), __('Active subscriptions scoped only by aieo_push_session cookie — visitor never logged in. Reachable only by session-level triggers (price-drop on viewed, back-in-stock on viewed). Bind to a user_id at first login.', 'ai-eshop-optimizer')); $this->tile(__('Total ever (incl. revoked)', 'ai-eshop-optimizer'), number_format_i18n($total), __('All subscriptions ever recorded, including those now revoked or bounced. Active ÷ Total ever = retention rate; 60–80% is normal after the first month.', 'ai-eshop-optimizer')); echo '
'; if (!empty($by_device)) { echo '

' . esc_html__('By device:', 'ai-eshop-optimizer') . ' '; $parts = array(); foreach ($by_device as $r) { $parts[] = esc_html($r['device_kind']) . ': ' . esc_html(number_format_i18n((int) $r['c'])); } echo implode(' · ', $parts); echo '

'; } if ($has_admin_sub <= 0) { $this->note( esc_html__('To send a test push, first subscribe from the storefront (the banner appears 8s after page load on a fresh browser), then return here.', 'ai-eshop-optimizer') . ' ' . esc_html__('Open storefront', 'ai-eshop-optimizer') . ' →' ); } $this->note(wp_kses_post(sprintf( __('Looking for the opt-in banner styling (position, delay, colors, copy, thank-you toast)? It now lives under %1$sFrontend → Built-in Features → Web Push%2$s.', 'ai-eshop-optimizer'), '', '' )), 'mono'); echo $close; } private function send_test_inline_js() { $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); ob_start(); ?> get_results( "SELECT segments_json, COUNT(*) AS c FROM {$tbl_ci} GROUP BY segments_json", ARRAY_A ); $total_all = 0; $total_segmented = 0; $rows = array(); foreach ((array) $combo_rows as $cr) { $c = (int) $cr['c']; $slugs = $this->parse_segments($cr['segments_json']); $total_all += $c; if (empty($slugs)) { continue; } $total_segmented += $c; foreach (array_unique($slugs) as $slug) { $rows[$slug] = (isset($rows[$slug]) ? $rows[$slug] : 0) + $c; } } $known = self::segment_catalog(); $segments = array(); foreach ($known as $slug => $info) { if (isset($rows[$slug])) { $segments[$slug] = $info; } } foreach ($rows as $slug => $unused_count) { if (!isset($segments[$slug])) { $segments[$slug] = self::segment_meta($slug); } } $absent = array_diff(array_keys($known), array_keys($segments)); $close = $this->card_open(array( 'anchor' => 'web-push-segments', 'icon' => 'funnel', 'title' => __('Segments', 'ai-eshop-optimizer'), 'description' => __('Customer classifications computed by aieo_sp_assign_segments, listed as they actually appear in segments_json rather than from a fixed list. A customer can belong to multiple segments simultaneously (e.g. VIP + Brand-Devoted), so the shares below sum to more than 100%.', 'ai-eshop-optimizer'), 'flush' => $total_segmented > 0, ), 'segments'); $this->guidance(__('Dormancy is deliberately absent from this table. It lives in dormancy_cohort and is reported by the Reactivation card below — one vocabulary, one number. The retired "dormant" segment used a stricter relative-recency rule than the cohort column, so the two printed counts 2.4× apart under one word.', 'ai-eshop-optimizer')); if ($total_segmented === 0) { if (class_exists('\Rooster\Admin\States')) { $state = $this->cdp_not_run( __('Segments have not been assigned yet', 'ai-eshop-optimizer'), __('The recompute assigns them once it has built the profiles: every night at 02:30, or at once with Recompute now on Web Push.', 'ai-eshop-optimizer') ); echo $state; } else { $this->note(esc_html__('Segments not yet assigned. Click "Recompute now" above to chain segmentation.', 'ai-eshop-optimizer')); } echo $close; return; } $seg_rows = array(); foreach ($segments as $slug => $info) { $count = (int) $rows[$slug]; $seg_rows[] = array( 'slug' => $slug, 'label' => $info['label'], 'rule' => $info['rule'], 'count' => $count, 'pct' => $total_segmented > 0 ? round(100 * $count / $total_segmented, 1) : 0, 'pct_all' => $total_all > 0 ? round(100 * $count / $total_all, 1) : 0, ); } AIEO_Table::card(array( 'wrap' => false, 'page_id' => $this->get_id(), 'row_key' => 'slug', 'rows' => $seg_rows, 'footnote' => sprintf( __('%1$s of %2$s customers carry at least one segment; %3$s match no segment rule at all. The bar column divides by the first number, the last column by the second — the same customer count therefore shows two legitimate percentages.', 'ai-eshop-optimizer'), number_format_i18n($total_segmented), number_format_i18n($total_all), number_format_i18n(max(0, $total_all - $total_segmented)) ), 'columns' => array( array('key' => 'label', 'label' => __('Segment', 'ai-eshop-optimizer'), 'sort_key' => 'slug', 'subline' => 'slug'), array('key' => 'rule', 'label' => __('Definition', 'ai-eshop-optimizer'), 'sortable' => false, 'tone' => 'secondary'), array('key' => 'count', 'label' => __('Customers', 'ai-eshop-optimizer'), 'type' => 'integer'), array( 'key' => 'pct', 'label' => sprintf( __('Share of segmented (n=%s)', 'ai-eshop-optimizer'), number_format_i18n($total_segmented) ), 'type' => 'bar', 'format' => array('max' => 100, 'precision' => 1), 'suffix' => '%', ), array( 'key' => 'pct_all', 'label' => sprintf( __('Share of all customers (n=%s)', 'ai-eshop-optimizer'), number_format_i18n($total_all) ), 'type' => 'percent', ), ), )); if (!empty($absent)) { echo '

' . esc_html(sprintf( __('Documented segments with no customers on this dataset (so they get no row above): %s.', 'ai-eshop-optimizer'), implode(', ', $absent) )) . '

'; } echo $close; } public static function segment_catalog() { return array( 'vip' => array( 'label' => __('VIP', 'ai-eshop-optimizer'), 'color' => '#dc2626', 'rule' => __('Top-quintile LTV (monetary band 5) AND active (recency band ≥ 4).', 'ai-eshop-optimizer'), ), 'loyalist' => array( 'label' => __('Loyalist', 'ai-eshop-optimizer'), 'color' => '#7c3aed', 'rule' => __('5+ orders AND active (recency band ≥ 3) AND not VIP.', 'ai-eshop-optimizer'), ), 'brand_devoted' => array( 'label' => __('Brand-Devoted', 'ai-eshop-optimizer'), 'color' => '#0891b2', 'rule' => __('Top brand spend share > 70% AND ≥ 3 orders.', 'ai-eshop-optimizer'), ), 'replenisher' => array( 'label' => __('Replenisher', 'ai-eshop-optimizer'), 'color' => '#15803d', 'rule' => __('≥ 3 orders AND avg days-between-orders 0.1–30 (predictable cadence).', 'ai-eshop-optimizer'), ), 'overdue' => array( 'label' => __('Overdue', 'ai-eshop-optimizer'), 'color' => '#ea580c', 'rule' => __('Overdue relative to their OWN cadence — recency_days > 2.5 × avg_dbo, floored at 14 absolute days, and ≥ 3 prior orders so the cadence is real. Not a recency tier: a weekly buyer is overdue at 21 days, a quarterly buyer is not. The right target for win-back triggers, and only computable because Rooster One tracks per-customer days-between-orders.', 'ai-eshop-optimizer'), ), 'newbie' => array( 'label' => __('Newbie', 'ai-eshop-optimizer'), 'color' => '#f59e0b', 'rule' => __('Exactly 1 order AND recency under 60 days.', 'ai-eshop-optimizer'), ), 'discount_hunter' => array( 'label' => __('Discount Hunter', 'ai-eshop-optimizer'), 'color' => '#b91c1c', 'rule' => __('discount_share ≥ 0.5 — at least half of their order lines were bought with a coupon applied. Computed from wp_woocommerce_order_itemmeta._line_total < _line_subtotal. Prime audience for promo-led messaging, free-shipping-threshold campaigns, and clearance-event pushes.', 'ai-eshop-optimizer'), ), 'competition_participant' => array( 'label' => __('Competition Participant', 'ai-eshop-optimizer'), 'color' => '#1d4ed8', 'rule' => __('Has at least one row in aieo_competition_participants — entered a giveaway or contest. Engagement signal that is independent of purchase recency, so it cuts across every other segment.', 'ai-eshop-optimizer'), ), ); } public static function segment_meta($slug) { $known = self::segment_catalog(); if (isset($known[$slug])) { return $known[$slug]; } return array( 'label' => ucwords(str_replace('_', ' ', (string) $slug)), 'color' => '#6b7280', 'rule' => __('Written into segments_json by aieo_sp_assign_segments but not yet documented in the segment catalogue. The count is accurate; only the description is missing.', 'ai-eshop-optimizer'), ); } private function list_candidate_source_tables() { global $wpdb; $out = array(); $temp = $wpdb->prefix . 'aieo_temp_orders'; $temp_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$temp} WHERE EponymousID IS NOT NULL AND EponymousID > 0" ); if ($temp_rows > 0) { $out[] = array('name' => $temp, 'rows' => $temp_rows); } $snapshots = $wpdb->get_results( "SELECT TABLE_NAME, TABLE_ROWS FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME REGEXP '^{$wpdb->prefix}aieo_[0-9]{4}_[0-9]{2}_orders\$' ORDER BY TABLE_NAME DESC LIMIT 12", ARRAY_A ); foreach ((array) $snapshots as $r) { $out[] = array('name' => (string) $r['TABLE_NAME'], 'rows' => (int) $r['TABLE_ROWS']); } return $out; } public function render_top_customers_card($tbl_ci) { global $wpdb; $rows = $wpdb->get_results( "SELECT eponymous_id, wp_user_id, email, orders_count, ltv_total, aov, recency_days, top_brand_id, top_brand_share, top_category_id, brand_affinity_json, replenishment_json, segments_json, rfm_cell, last_order_at, source_table FROM {$tbl_ci} ORDER BY ltv_total DESC, orders_count DESC LIMIT 50", ARRAY_A ); $close = $this->card_open(array( 'anchor' => 'web-push-top-50-customers-by-ltv', 'icon' => 'award', 'title' => __('Top 50 customers by LTV', 'ai-eshop-optimizer'), 'description' => __('Sanity check — your highest-value customers. Brand/category affinity is computed from line-level spend; DBO columns come from the Rooster One advanced behavior step. RFM bands and segments populate in Phase 2.', 'ai-eshop-optimizer'), 'flush' => true, ), 'top-customers'); $catalog = self::segment_catalog(); $vitals_table = $wpdb->prefix . 'aieo_core_product_vitals'; $cust_rows = array(); foreach ((array) $rows as $i => $r) { $epo = (int) $r['eponymous_id']; $wp_uid = (int) $r['wp_user_id']; $email = (string) $r['email']; $brand_id = (int) $r['top_brand_id']; $brand_share = (float) $r['top_brand_share']; $brand_name = $brand_id > 0 ? $this->lookup_brand_name($brand_id, $vitals_table) : ''; $chips = array(); foreach ($this->parse_segments($r['segments_json']) as $slug) { $info = isset($catalog[$slug]) ? $catalog[$slug] : self::segment_meta($slug); $chips[] = $info['label']; } $cust_rows[] = array( 'rank' => $i + 1, 'epo' => $epo, 'customer' => '' !== $email ? $email : ('#' . $epo), 'edit_url' => $wp_uid > 0 ? admin_url('user-edit.php?user_id=' . $wp_uid) : '', 'wp_user' => $wp_uid > 0 ? 'WP user: ' . $wp_uid : '', 'rfm' => (string) $r['rfm_cell'], 'orders' => (int) $r['orders_count'], 'ltv' => (float) $r['ltv_total'], 'aov' => (float) $r['aov'], 'recency' => (int) $r['recency_days'], 'avg_dbo' => (float) $this->extract_dbo($r['replenishment_json'], 'avg_dbo'), 'brand' => $brand_name, 'brand_pct' => ('' !== $brand_name && $brand_share > 0) ? number_format_i18n(round($brand_share * 100, 1), 1) . '%' : '', 'segments' => $chips, ); } AIEO_Table::card(array( 'wrap' => false, 'page_id' => $this->get_id(), 'row_key' => 'epo', 'rows' => $cust_rows, 'caps' => array('search' => true), 'chooser' => true, 'cap' => 50, 'empty' => __('No intelligence rows yet. Click "Recompute now" above.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'rank', 'label' => '#', 'type' => 'id', 'sortable' => false, 'width' => '50px'), array('key' => 'epo', 'label' => __('Eponymous', 'ai-eshop-optimizer'), 'type' => 'code'), array( 'key' => 'customer', 'label' => __('Customer', 'ai-eshop-optimizer'), 'type' => 'link', 'format' => array('href' => 'edit_url'), 'subline' => 'wp_user', ), array('key' => 'rfm', 'label' => __('RFM', 'ai-eshop-optimizer'), 'type' => 'code'), array('key' => 'orders', 'label' => __('Orders', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'ltv', 'label' => __('LTV', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'aov', 'label' => __('AOV', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'recency', 'label' => __('Recency (d)', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'avg_dbo', 'label' => __('Avg DBO', 'ai-eshop-optimizer'), 'type' => 'decimal', 'format' => array('precision' => 1)), array('key' => 'brand', 'label' => __('Top brand', 'ai-eshop-optimizer'), 'suffix' => 'brand_pct'), array('key' => 'segments', 'label' => __('Segments', 'ai-eshop-optimizer'), 'type' => 'list', 'sortable' => false), ), )); echo $close; } private function parse_segments($json) { if (!is_string($json) || $json === '' || $json === '[]') { return array(); } $arr = json_decode($json, true); if (!is_array($arr)) { return array(); } $out = array(); foreach ($arr as $v) { if (is_string($v) && $v !== '') { $out[] = $v; } } return $out; } private function card_open(array $c, $legacy = '') { list($open, $close) = AIEO_DMM_UI::card($c); echo $open; if ('' !== (string) $legacy) { echo ''; } return $close; } private function band_open(array $opts) { if (function_exists('rooster_admin_band_open')) { rooster_admin_band_open($opts); } } private function band_close() { if (function_exists('rooster_admin_band_close')) { rooster_admin_band_close(); } } private function chrome_on() { return class_exists('AIEO_Chrome') && AIEO_Chrome::on() && function_exists('rooster_admin_card_section_html'); } private function section($title, $description = '') { if ($this->chrome_on()) { echo rooster_admin_card_section_html((string) $title, '' !== (string) $description ? array('description' => (string) $description) : array()); return; } if ('' !== (string) $title) { echo '

' . esc_html((string) $title) . '

'; } if ('' !== (string) $description) { echo '

' . esc_html((string) $description) . '

'; } } private function table_section($title, $description, $footnote, array $config) { if ($this->chrome_on()) { $this->section($title, $description); AIEO_Table::render($config); if ('' !== (string) $footnote) { echo \Rooster\Admin\Guidance::text((string) $footnote, 'guidance', array('question' => __('What do the columns count?', 'ai-eshop-optimizer'))); } return; } AIEO_Table::card(array_merge($config, array( 'wrap' => false, 'heading' => 'h4', 'title' => $title, 'description' => $description, 'footnote' => $footnote, ))); } private function guidance($text) { if (class_exists('\Rooster\Admin\Guidance')) { echo \Rooster\Admin\Guidance::text((string) $text); return; } echo '

' . esc_html((string) $text) . '

'; } private function cdp_not_run($title, $text) { return \Rooster\Admin\States::state('not-run', array( 'title' => (string) $title, 'text' => (string) $text, 'href' => add_query_arg(array('page' => AIEO_DMM::MENU_SLUG, 'dmm_tab' => 'web-push'), admin_url('admin.php')) . '#aieo-card-web-push-source-data-2', 'label' => __('Open Web Push', 'ai-eshop-optimizer'), )); } private function tiles_class() { return class_exists('\Rooster\Admin\Ui') ? 'rooster-admin-stats' : 'aieo-attr-tiles'; } private function note($html, $tone = 'info') { if (class_exists('\Rooster\Admin\Ui')) { echo \Rooster\Admin\Ui::notice($html, $tone, array('inline' => true)); return; } echo '

' . $html . '

'; } private function tile($label, $value, $help = '') { if (class_exists('\Rooster\Admin\Ui')) { echo \Rooster\Admin\Ui::stat((string) $label, (string) $value, '' !== (string) $help ? array('attrs' => array('data-ra-tip' => (string) $help, 'tabindex' => '0', 'aria-description' => (string) $help)) : array()); return; } echo '
'; echo '
'; echo '' . esc_html($label) . ''; if ($help !== '') { echo 'i'; } echo '
'; echo '
' . esc_html((string) $value) . '
'; if ($help !== '') { echo '
' . esc_html($help) . '
'; } echo '
'; } private function fmt_money($amount) { return AIEO_DMM_Conversions_Registry::money((float) $amount); } private function median_aov($tbl_ci) { global $wpdb; $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tbl_ci} WHERE aov > 0"); if ($count <= 0) { return 0.0; } $offset = (int) floor($count / 2); return (float) $wpdb->get_var( "SELECT aov FROM {$tbl_ci} WHERE aov > 0 ORDER BY aov LIMIT 1 OFFSET {$offset}" ); } private function lookup_brand_name($brand_id, $vitals_table) { global $wpdb; static $cache = array(); $brand_id = (int) $brand_id; if ($brand_id <= 0) { return ''; } if (isset($cache[$brand_id])) { return $cache[$brand_id]; } $name = (string) $wpdb->get_var($wpdb->prepare( "SELECT brand FROM {$vitals_table} WHERE brand_id = %d AND brand <> '' LIMIT 1", $brand_id )); $cache[$brand_id] = $name; return $name; } private function lookup_category_name($head_id, $vitals_table) { global $wpdb; static $cache = array(); $head_id = (int) $head_id; if ($head_id <= 0) { return ''; } if (isset($cache[$head_id])) { return $cache[$head_id]; } $name = (string) $wpdb->get_var($wpdb->prepare( "SELECT head_category FROM {$vitals_table} WHERE head_id = %d AND head_category <> '' LIMIT 1", $head_id )); $cache[$head_id] = $name; return $name; } private function fmt_affinity_top($json, $vitals_table, $kind) { if (!is_string($json) || $json === '' || $json === '{}') { return '—'; } $arr = json_decode($json, true); if (!is_array($arr) || empty($arr)) { return '—'; } arsort($arr); $top = array_slice($arr, 0, 3, true); $parts = array(); foreach ($top as $term_id => $share) { $name = $kind === 'brand' ? $this->lookup_brand_name((int) $term_id, $vitals_table) : $this->lookup_category_name((int) $term_id, $vitals_table); if ($name === '') { $name = '#' . (int) $term_id; } $parts[] = $name . ' ' . round((float) $share * 100, 1) . '%'; } return implode(', ', $parts); } private function extract_dbo($json, $key) { if (!is_string($json) || $json === '') { return 0; } $arr = json_decode($json, true); if (!is_array($arr) || !isset($arr[$key])) { return 0; } return (float) $arr[$key]; } private function recompute_inline_js() { $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); ob_start(); ?>