';
} 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 = '
';
foreach ($candidates as $c) {
$aieo_wp_list .= '
';
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 '
'
. 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 '
' . 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 '
' . 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')
. '
';
echo '
' . 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')
. '
';
echo '
' . 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')
. '
';
echo '
' . 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')
. '
';
echo '
' . 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')
. '
';
}
}
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 ($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 '