array('label' => __('Split tests', 'ai-eshop-optimizer'), 'icon' => 'list'), 'surfaces' => array('label' => __('Promotional surfaces', 'ai-eshop-optimizer'), 'icon' => 'layout-template'), ); } public function register_hooks() { add_action('admin_post_aieo_display_exp_save', array($this, 'handle_save')); add_action('admin_post_aieo_display_exp_state', array($this, 'handle_state')); add_action('admin_post_aieo_display_exp_delete', array($this, 'handle_delete')); } private function guard($action) { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to change split tests.', 'ai-eshop-optimizer'), '', array('response' => 403)); } check_admin_referer($action); } private function bounce($msg, $exp_id = '', $detail = '') { $args = array( 'page' => AIEO_DMM::MENU_SLUG, 'dmm_tab' => $this->get_id(), self::ARG_MSG => $msg, ); if ('' !== $exp_id) { $args[self::ARG_EXP] = $exp_id; } if ('' !== $detail) { $args[self::ARG_DETAIL] = rawurlencode($detail); } wp_safe_redirect(add_query_arg($args, admin_url('admin.php'))); exit; } public function handle_save() { $this->guard(self::NONCE . '_save'); if (!class_exists('AIEO_Display_Experiment') || !class_exists('AIEO_Display_Conditions') || !class_exists('AIEO_Display_Registry')) { $this->bounce('unavailable'); } $existing_id = isset($_POST['exp_id']) ? sanitize_key(wp_unslash($_POST['exp_id'])) : ''; $label = isset($_POST['label']) ? sanitize_text_field(wp_unslash($_POST['label'])) : ''; $known = array(); foreach (AIEO_Display_Registry::surfaces() as $s) { $known[(string) $s['key']] = true; } $surfaces = array(); foreach ((array) (isset($_POST['surfaces']) ? wp_unslash($_POST['surfaces']) : array()) as $s) { $s = is_scalar($s) ? (string) $s : ''; if ('' !== $s && isset($known[$s])) { $surfaces[$s] = true; } } $surfaces = array_keys($surfaces); $vary = array(); foreach ((array) (isset($_POST['vary']) ? wp_unslash($_POST['vary']) : array()) as $k) { $k = is_scalar($k) ? (string) $k : ''; if (in_array($k, self::VARIABLE_KEYS, true) && in_array($k, AIEO_Display_Conditions::OVERRIDABLE, true)) { $vary[] = $k; } } $raw_names = (array) (isset($_POST['arm']) ? wp_unslash($_POST['arm']) : array()); $names = array(); foreach ($raw_names as $i => $n) { $n = sanitize_key((string) $n); if ('' === $n) { continue; } if (isset($names[$n])) { $this->bounce('arm_duplicate', $existing_id); } $names[$n] = (int) $i; } $posted = isset($_POST['v']) && is_array($_POST['v']) ? wp_unslash($_POST['v']) : array(); $arms = array(); $baseline = true; foreach ($names as $name => $slot) { $overrides = $baseline ? array() : self::collect_overrides($vary, $posted, $slot); $per_surface = array(); foreach ($surfaces as $s) { $per_surface[$s] = $overrides; } $arms[$name] = $per_surface; $baseline = false; } $errors = AIEO_Display_Experiment::validate_payload(array('id' => $existing_id, 'arms' => $arms)); if (!empty($errors)) { $labels = $this->surface_labels(); $named = array(); foreach ($errors as $row) { if ('' === (string) $row['surface']) { continue; } $named[$row['surface']] = isset($labels[$row['surface']]) ? $labels[$row['surface']] : $row['surface']; } $detail = implode(', ', $named); switch ((string) $errors[0]['code']) { case 'timing_on_painted': case 'async_on_painted': $this->bounce('timing_on_painted', $existing_id, $detail); case 'arm_too_long': $this->bounce('arm_too_long', $existing_id); case 'arm_numeric': $this->bounce('arm_numeric', $existing_id); case 'arm_duplicate': $this->bounce('arm_duplicate', $existing_id); case 'too_few_arms': $this->bounce('too_few_arms', $existing_id); case 'too_many_arms': $this->bounce('too_many_arms', $existing_id); case 'no_surfaces': case 'arm_no_surface': $this->bounce('no_surfaces', $existing_id); case 'nothing_varied': $this->bounce('no_vary', $existing_id); } $this->bounce('rejected', $existing_id, $detail); } $list = AIEO_Display_Experiment::all(); $found = null; foreach ($list as $i => $e) { if ($e['id'] === $existing_id) { $found = $i; break; } } if (null !== $found && !empty($list[$found]['enabled'])) { $cand = AIEO_Display_Experiment::sanitize_one(array( 'id' => $list[$found]['id'], 'salt' => $list[$found]['salt'], 'arms' => $arms, )); if (!is_array($cand) || $cand['arms'] !== $list[$found]['arms']) { $this->bounce('running_locked', $existing_id); } } foreach ($list as $i => $e) { if ($i === $found || empty($e['enabled'])) { continue; } if (array_intersect($surfaces, AIEO_Display_Experiment::surfaces_of($e))) { $this->bounce('surface_taken', $existing_id); } } if (null === $found) { $mint = AIEO_Display_Experiment::mint(); $entry = array( 'id' => sanitize_key('exp_' . wp_generate_password(8, false, false)), 'salt' => $mint['salt'], 'started_at' => '', 'enabled' => false, ); $list[] = $entry; $found = count($list) - 1; } $list[$found]['label'] = $label; $list[$found]['arms'] = $arms; $saved = AIEO_Display_Experiment::save($list); $id = $list[$found]['id']; foreach ($saved as $e) { if ($e['id'] === $id) { $this->bounce('saved', $id); } } $this->bounce('rejected', ''); } public function handle_state() { $this->guard(self::NONCE . '_state'); if (!class_exists('AIEO_Display_Experiment')) { $this->bounce('unavailable'); } $id = isset($_POST['exp_id']) ? sanitize_key(wp_unslash($_POST['exp_id'])) : ''; $state = isset($_POST['state']) ? sanitize_key(wp_unslash($_POST['state'])) : ''; $list = AIEO_Display_Experiment::all(); $found = null; foreach ($list as $i => $e) { if ($e['id'] === $id) { $found = $i; break; } } if (null === $found) { $this->bounce('missing'); } switch ($state) { case 'start': foreach ($list as $i => $e) { if ($i === $found || empty($e['enabled'])) { continue; } if (array_intersect(AIEO_Display_Experiment::surfaces_of($list[$found]), AIEO_Display_Experiment::surfaces_of($e))) { $this->bounce('surface_taken', $id); } } $list[$found]['enabled'] = true; if ('' === (string) $list[$found]['started_at']) { $list[$found]['started_at'] = gmdate('Y-m-d H:i:s'); } break; case 'stop': $list[$found]['enabled'] = false; break; case 'restart': $mint = AIEO_Display_Experiment::mint(); $list[$found]['salt'] = $mint['salt']; $list[$found]['started_at'] = $mint['started_at']; $list[$found]['enabled'] = true; break; default: $this->bounce('missing'); } AIEO_Display_Experiment::save($list); $this->bounce('state_' . $state, $id); } public function handle_delete() { $this->guard(self::NONCE . '_delete'); if (!class_exists('AIEO_Display_Experiment')) { $this->bounce('unavailable'); } $id = isset($_POST['exp_id']) ? sanitize_key(wp_unslash($_POST['exp_id'])) : ''; $keep = array(); foreach (AIEO_Display_Experiment::all() as $e) { if ($e['id'] !== $id) { $keep[] = $e; } } AIEO_Display_Experiment::save($keep); $this->bounce('deleted'); } public function render_subtab() { if (!class_exists('AIEO_Display_Experiment') || !class_exists('AIEO_Display_Registry')) { echo '
' . esc_html__('The display framework is not available on this install.', 'ai-eshop-optimizer') . '
' . esc_html($opts['title']) . '
' : '') . '' . $html . '
' . (!empty($opts['actions']) ? '' . esc_html($text) . '
'; } private function render_list_card() { $labels = $this->surface_labels(); $rows = array(); foreach (AIEO_Display_Experiment::all() as $e) { $surfaces = AIEO_Display_Experiment::surfaces_of($e); $named = array(); foreach ($surfaces as $s) { $named[] = isset($labels[$s]) ? $labels[$s] : $s; } $rows[] = array( 'id' => $e['id'], 'label' => '' !== $e['label'] ? $e['label'] : $e['id'], 'state' => !empty($e['enabled']) ? __('Running', 'ai-eshop-optimizer') : ('' !== (string) $e['started_at'] ? __('Stopped', 'ai-eshop-optimizer') : __('Not started', 'ai-eshop-optimizer')), 'versions' => implode(' vs ', array_keys($e['arms'])), 'where' => implode(', ', $named), 'started' => '' !== (string) $e['started_at'] ? substr((string) $e['started_at'], 0, 10) : '—', 'edit_url' => $this->self_url(array(self::ARG_EXP => $e['id'])), ); } AIEO_Table::card(array( 'anchor' => 'display-experiments-list', 'icon' => 'list', 'heading' => 'h2', 'title' => __('Your split tests', 'ai-eshop-optimizer'), 'description' => __('Show two versions of the same promotion to two halves of your visitors and measure which does better. Because popups, recommendation rails, custom slots and promo surfaces are all described in the same words now, a test can cover any of them — and the results can be compared across them.', 'ai-eshop-optimizer'), 'page_id' => $this->get_id(), 'row_key' => 'id', 'rows' => $rows, 'empty' => __('No split tests yet. Use the form below to set one up.', 'ai-eshop-optimizer'), 'caps' => array('search' => true), 'filters' => array(array('key' => 'state', 'label' => __('Status', 'ai-eshop-optimizer'), 'control' => 'segmented')), 'columns' => array( array('key' => 'label', 'label' => __('Name', 'ai-eshop-optimizer'), 'subline' => 'id'), array('key' => 'state', 'label' => __('Status', 'ai-eshop-optimizer'), 'nowrap' => true), array('key' => 'versions', 'label' => __('Versions', 'ai-eshop-optimizer')), array('key' => 'where', 'label' => __('Where it runs', 'ai-eshop-optimizer')), array('key' => 'started', 'label' => __('Started', 'ai-eshop-optimizer'), 'nowrap' => true), ), 'actions' => array( array( 'id' => 'edit', 'label' => __('Open', 'ai-eshop-optimizer'), 'href' => static function ($r) { return $r['edit_url']; }, ), ), )); } private function render_editor_card() { $exp = $this->current_experiment(); $is_new = (null === $exp); $surfaces = $this->eligible_surfaces(); $chosen = $is_new ? array() : AIEO_Display_Experiment::surfaces_of($exp); $arm_names = $is_new ? array('control', 'variant') : array_keys($exp['arms']); $varied = $is_new ? array('maxPerSession') : $this->varied_keys($exp); $has_form = !empty($surfaces) && ($is_new || !self::is_storage_authored($exp)); $state = (!empty($surfaces) && !$is_new) ? $this->state_controls($exp, !$has_form) : null; $footer = ''; if ($has_form) { $footer .= AIEO_Kit::button($is_new ? __('Create split test', 'ai-eshop-optimizer') : __('Save changes', 'ai-eshop-optimizer'), array( 'variant' => 'primary', 'type' => 'submit', 'attrs' => array('form' => 'aieo-dx-editor-form'), )); } if (null !== $state) { $footer .= $state['actions']; } list($aieo_dx_open, $aieo_dx_close) = AIEO_DMM_UI::card(array( 'anchor' => 'display-experiments-editor', 'icon' => 'pencil-line', 'title' => $is_new ? __('Set up a split test', 'ai-eshop-optimizer') : sprintf( __('Editing “%s”', 'ai-eshop-optimizer'), '' !== $exp['label'] ? $exp['label'] : $exp['id']), 'description' => __('Choose where the test runs and the versions to compare; visitors are shared between them at random.', 'ai-eshop-optimizer'), 'footer' => $footer, )); echo $aieo_dx_open; $this->render_notice(); if (empty($surfaces)) { echo $this->state_html('not-set-up', __('There is nothing to test yet. Create a popup, a custom slot or a promo surface first, or switch on a recommendation rail.', 'ai-eshop-optimizer')); echo $aieo_dx_close; return; } if (!$is_new) { echo '' . esc_html__('← Start a new one instead', 'ai-eshop-optimizer') . '
'; echo $state['notice']; if (!$has_form) { $this->render_readonly_grid($exp); echo $aieo_dx_close; return; } } echo ''; echo $aieo_dx_close; } private function render_variation_grid($arm_names, array $varied, $exp, array $chosen) { if (!class_exists('AIEO_Display_Conditions_UI')) { return; } $baseline = isset($arm_names[0]) ? $arm_names[0] : 'control'; $others = array(); for ($i = 1; $i < self::ARM_SLOTS; $i++) { $others[$i] = isset($arm_names[$i]) ? $arm_names[$i] : ''; } $current = array(); if (1 === count($chosen) && class_exists('AIEO_Display_Registry')) { $s = AIEO_Display_Registry::surface($chosen[0]); if ($s) { $current = $s['conditions']; } } echo $this->section_html(__('What is different between the versions', 'ai-eshop-optimizer')); $aieo_dx_how = __('Tick a setting to vary it, then give each version its value. Anything left unticked stays exactly as the promotion is configured — a test should change one thing at a time.', 'ai-eshop-optimizer'); echo class_exists('\Rooster\Admin\Guidance') && self::library_card() ? \Rooster\Admin\Guidance::text($aieo_dx_how) : '' . esc_html($aieo_dx_how) . '
'; $dx_cols = array( array('key' => 'setting', 'label' => __('Setting', 'ai-eshop-optimizer'), 'type' => 'html'), array('key' => 'arm0', 'label' => sprintf('%1$s (%2$s)', $baseline, __('as configured now', 'ai-eshop-optimizer')), 'type' => 'text', 'group' => __('Versions', 'ai-eshop-optimizer')), ); foreach ($others as $i => $name) { $dx_cols[] = array('key' => 'arm' . (int) $i, 'label' => '' !== $name ? (string) $name : sprintf('#%d', $i + 1), 'type' => 'html', 'group' => __('Versions', 'ai-eshop-optimizer')); } $dx_rows = array(); $timing_open = false; foreach (self::VARIABLE_KEYS as $key) { $timing = in_array($key, AIEO_Display_Conditions::CLIENT_PAINTED_ONLY, true); if ($timing && !$timing_open) { $timing_open = true; $dx_rows[] = array( '_id' => 'timing', '_span' => '' . esc_html__('Timing — only for blocks the browser puts on the page', 'ai-eshop-optimizer') . ' ' . '' . esc_html__('Popups and custom slots only. A recommendation rail or a promo surface is drawn by the server and is already there, so there is no "when" left to change.', 'ai-eshop-optimizer') . '', '_span_kind' => 'label', ); } $label = AIEO_Display_Conditions_UI::label_for($key); $row = array( '_id' => $key, '_name' => $label, 'setting' => array('html' => AIEO_Kit::check('vary[]', $label, in_array($key, $varied, true), array('value' => $key, 'id' => 'aieo-dx-vary-' . $key))), 'arm0' => $this->current_value_text($key, $current, count($chosen)), ); foreach ($others as $i => $name) { if ('' === $name) { $row['arm' . (int) $i] = array('html' => '—'); continue; } $stored = $this->arm_value($exp, $name, $key); ob_start(); AIEO_Display_Conditions_UI::render_control_cell($key, 'v[' . $key . '][' . (int) $i . ']', null === $stored ? array() : array($key => $stored)); $row['arm' . (int) $i] = array('html' => self::named_cell((string) ob_get_clean(), $label . ', ' . $name)); } $dx_rows[] = $row; } \Rooster\Admin\Markup::out( \Rooster\Admin\Ui::table($dx_cols, $dx_rows, array('id' => 'aieo-dx-versions-grid', 'caption' => __('Versions of this test', 'ai-eshop-optimizer'), 'sticky_first' => true)) ); } private static function named_cell($html, $name) { if (1 !== preg_match('/<(?:input(?![^>]*\btype="(?:hidden|checkbox|radio)")|select|textarea)\b[^>]*>/', $html, $m, PREG_OFFSET_CAPTURE)) { return $html; } $tag = (string) $m[0][0]; if (1 === preg_match('/\saria-label(?:ledby)?="/', $tag)) { return $html; } $named = (string) preg_replace('/^<(input|select|textarea)\b/', '<$1 aria-label="' . esc_attr($name) . '"', $tag); return substr_replace($html, $named, (int) $m[0][1], strlen($tag)); } public static function collect_overrides(array $vary, array $posted, $slot) { $overrides = array(); foreach ($vary as $key) { if (!isset($posted[$key][$slot])) { continue; } $val = is_scalar($posted[$key][$slot]) ? sanitize_text_field((string) $posted[$key][$slot]) : ''; if ('' === $val) { continue; } $overrides[$key] = $val; } return $overrides; } public static function is_storage_authored(array $exp) { if (empty($exp['arms']) || !is_array($exp['arms'])) { return false; } $first = true; foreach ($exp['arms'] as $by_surface) { if (!is_array($by_surface)) { continue; } if ($first) { $first = false; foreach ($by_surface as $overrides) { if (!empty($overrides)) { return true; } } continue; } $seen = null; foreach ($by_surface as $overrides) { $overrides = (array) $overrides; ksort($overrides); if (null === $seen) { $seen = $overrides; continue; } if ($overrides !== $seen) { return true; } } } return false; } private function render_readonly_grid(array $exp) { echo $this->notice_html( esc_html__('This test was authored programmatically with per-surface values; edit it via the API or delete and recreate. Start, stop and delete still work.', 'ai-eshop-optimizer'), 'info' ); $labels = $this->surface_labels(); $arms = array_keys($exp['arms']); $columns = array( array('key' => 'surface', 'label' => __('Where it runs', 'ai-eshop-optimizer')), array('key' => 'setting', 'label' => __('Setting', 'ai-eshop-optimizer')), ); foreach ($arms as $arm) { $columns[] = array('key' => 'arm_' . $arm, 'label' => (string) $arm, 'group' => __('Arm', 'ai-eshop-optimizer')); } $rows = array(); foreach (AIEO_Display_Experiment::surfaces_of($exp) as $s) { $keys = array(); foreach ($exp['arms'] as $by_surface) { if (!isset($by_surface[$s]) || !is_array($by_surface[$s])) { continue; } foreach (array_keys($by_surface[$s]) as $k) { $keys[$k] = true; } } if (empty($keys)) { continue; } foreach (array_keys($keys) as $k) { $row = array( '_id' => $s . ':' . $k, 'surface' => isset($labels[$s]) ? $labels[$s] : $s, 'setting' => class_exists('AIEO_Display_Conditions_UI') ? AIEO_Display_Conditions_UI::label_for($k) : $k, ); foreach ($arms as $arm) { $v = isset($exp['arms'][$arm][$s][$k]) ? $exp['arms'][$arm][$s][$k] : null; if (null === $v) { $cell = __('as configured', 'ai-eshop-optimizer'); } elseif (is_bool($v)) { $cell = $v ? __('on', 'ai-eshop-optimizer') : __('off', 'ai-eshop-optimizer'); } else { $cell = (string) $v; } $row['arm_' . $arm] = $cell; } $rows[] = $row; } } \Rooster\Admin\Markup::out( \Rooster\Admin\Ui::table( $columns, $rows, array( 'id' => 'aieo-dx-values', 'caption' => sprintf( __('Values of “%s” by arm', 'ai-eshop-optimizer'), '' !== $exp['label'] ? $exp['label'] : $exp['id']), 'sticky_first' => true, ) ) ); } private function state_controls(array $exp, $primary) { $running = !empty($exp['enabled']); $started = '' !== (string) $exp['started_at']; $asks = class_exists('\Rooster\Admin\States') && self::library_card(); $restart = __('Restarting reassigns every visitor and starts the results again from today. The measurements so far stay in your reports but will no longer be part of this test. Continue?', 'ai-eshop-optimizer'); $delete = __('Delete this split test? The measurements it produced are kept.', 'ai-eshop-optimizer'); $ask = static function ($dialog, $text) use ($asks) { if ($asks) { return (array) \Rooster\Admin\States::confirm_attrs($dialog); } return array('onclick' => 'return confirm(' . wp_json_encode($text) . ');'); }; $actions = ''; $actions .= ''; if ($asks) { $name = '' !== $exp['label'] ? $exp['label'] : $exp['id']; if ($started) { $actions .= \Rooster\Admin\States::confirm( 'aieo-dx-restart-dialog', 'reset', __('Restart from scratch', 'ai-eshop-optimizer'), '' . esc_html($restart) . '
', array('confirm' => __('Restart from scratch', 'ai-eshop-optimizer')) ); } $actions .= \Rooster\Admin\States::confirm( 'aieo-dx-delete-dialog', 'replace', $delete, '' . esc_html($name) . '
', array('confirm' => __('Delete', 'ai-eshop-optimizer')) ); } if ($running) { $text = __('Running. Each visitor is placed in one version by a calculation on their own browser, so the same person always sees the same one — and no page needs to be uncached for it.', 'ai-eshop-optimizer'); } elseif ($started) { $text = __('Stopped. Resuming keeps the same people in the same versions and continues the results; restarting reassigns everyone and begins again.', 'ai-eshop-optimizer'); } else { $text = __('Not started yet. Nothing changes on your shop until you press Start.', 'ai-eshop-optimizer'); } return array( 'notice' => $this->notice_html(esc_html($text), $running ? 'success' : 'info'), 'actions' => $actions, ); } private function render_results_card() { $exp = $this->current_experiment(); if (null === $exp) { return; } list($aieo_dx_open, $aieo_dx_close) = AIEO_DMM_UI::card(array( 'anchor' => 'display-experiments-results', 'icon' => 'chart-column', 'title' => __('Results', 'ai-eshop-optimizer'), )); echo $aieo_dx_open; if ('' === (string) $exp['started_at']) { echo $this->state_html('not-run', __('Nothing to report yet — this test has not been started.', 'ai-eshop-optimizer')); echo $aieo_dx_close; return; } if (!class_exists('AIEO_Display_Experiment_Report')) { echo $this->notice_html(esc_html__('The reporting component is not available on this install.', 'ai-eshop-optimizer'), 'warning'); echo $aieo_dx_close; return; } $report = AIEO_Display_Experiment_Report::build($exp, 60); $labels = $this->surface_labels(); echo '' . esc_html(sprintf( __('Counted from %s, when this test last started.', 'ai-eshop-optimizer'), substr((string) $report['since'], 0, 10) )); if ('live' === $report['source']) { echo ' ' . esc_html__('These figures are being read straight from recent activity because the nightly summary has not run since the test started. They will settle into the summarised numbers overnight.', 'ai-eshop-optimizer'); } echo '
'; foreach ($report['surfaces'] as $s) { $name = isset($labels[$s['surface']]) ? $labels[$s['surface']] : $s['surface']; echo $this->section_html($name); $this->render_arm_table($s['arms'], $s['engaged'], $report['arms'], $report['baseline']); } if (count($report['surfaces']) > 1) { echo $this->section_html(__('All of them together', 'ai-eshop-optimizer')); $this->render_arm_table($report['totals'], $report['engaged'], $report['arms'], $report['baseline']); } if (empty($report['surfaces'])) { echo $this->state_html('first-use', __('No activity recorded for this test yet.', 'ai-eshop-optimizer')); } $this->render_reasons_table($report); $this->render_origins_table($report); echo $aieo_dx_close; } private function render_arm_table(array $per_arm, array $compare, array $arms, $baseline) { $rows = array(); foreach ($arms as $arm) { $m = isset($per_arm[$arm]) ? $per_arm[$arm] : array( 'impressions' => 0, 'clicks' => 0, 'suppressions' => 0, 'sessions' => 0, 'click_sessions' => 0, ); $rate = ((int) $m['sessions'] > 0) ? ((int) $m['click_sessions']) / ((int) $m['sessions']) : null; $rows[] = array( 'arm' => $arm . ($arm === $baseline ? ' ' . __('(baseline)', 'ai-eshop-optimizer') : ''), 'impressions' => (int) $m['impressions'], 'clicks' => (int) $m['clicks'], 'suppressions' => (int) $m['suppressions'], 'sessions' => (int) $m['sessions'], 'rate' => null === $rate ? null : $rate * 100, 'verdict' => $this->verdict_text(isset($compare[$arm]) ? $compare[$arm] : null, $arm === $baseline), ); } AIEO_Table::card(array( 'wrap' => false, 'page_id' => $this->get_id(), 'row_key' => 'arm', 'density' => 'compact', 'rows' => $rows, 'empty' => __('No activity yet.', 'ai-eshop-optimizer'), 'footnote' => __('“Shown” counts every appearance; “Visitors” counts people, and the rate is the share of those people who interacted at least once. The verdict uses visitors rather than appearances, because two appearances to the same person are not two independent pieces of evidence.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'arm', 'label' => __('Version', 'ai-eshop-optimizer'), 'nowrap' => true), array('key' => 'impressions', 'label' => __('Shown', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'clicks', 'label' => __('Interactions', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'suppressions', 'label' => __('Held back', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'sessions', 'label' => __('Visitors', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'rate', 'label' => __('Interacted', 'ai-eshop-optimizer'), 'type' => 'percent', 'format' => array('precision' => 2)), array('key' => 'verdict', 'label' => __('Against the baseline', 'ai-eshop-optimizer')), ), )); } private function verdict_text($cmp, $is_baseline) { if ($is_baseline) { return '—'; } if (!is_array($cmp) || 'no_data' === $cmp['verdict']) { return __('no data yet', 'ai-eshop-optimizer'); } $pp = null !== $cmp['diff'] ? sprintf('%+.2f pp', $cmp['diff'] * 100) : ''; switch ($cmp['verdict']) { case 'other_wins': return sprintf(__('better, %s', 'ai-eshop-optimizer'), $pp); case 'base_wins': return sprintf(__('worse, %s', 'ai-eshop-optimizer'), $pp); case 'underpowered': return null !== $cmp['mde'] ? sprintf(__('not enough traffic yet — it could only spot a gap of %s', 'ai-eshop-optimizer'), sprintf('%.2f pp', $cmp['mde'] * 100)) : __('not enough traffic yet', 'ai-eshop-optimizer'); } return sprintf(__('no clear difference, %s', 'ai-eshop-optimizer'), $pp); } private function render_reasons_table(array $report) { if (empty($report['reasons'])) { return; } $words = array( 'cap' => __('the per-visitor limit was used up', 'ai-eshop-optimizer'), 'schedule' => __('outside its dates', 'ai-eshop-optimizer'), 'device' => __('wrong device', 'ai-eshop-optimizer'), 'visitor' => __('wrong kind of visitor', 'ai-eshop-optimizer'), 'cart' => __('the cart rule', 'ai-eshop-optimizer'), 'audience' => __('not in the chosen audience', 'ai-eshop-optimizer'), 'placement' => __('not allowed on that page', 'ai-eshop-optimizer'), 'instigator' => __('came from somewhere else', 'ai-eshop-optimizer'), 'arbitration' => __('something else had the screen', 'ai-eshop-optimizer'), 'unrecorded' => __('not recorded', 'ai-eshop-optimizer'), ); $rows = array(); foreach ($report['reasons'] as $r) { $rows[] = array( 'key' => $r['arm'] . '|' . $r['reason'], 'arm' => $r['arm'], 'reason' => isset($words[$r['reason']]) ? $words[$r['reason']] : $r['reason'], 'n' => $r['n'], ); } echo $this->section_html(__('When it was ready but not shown', 'ai-eshop-optimizer')); AIEO_Table::card(array( 'wrap' => false, 'page_id' => $this->get_id(), 'row_key' => 'key', 'density' => 'compact', 'rows' => $rows, 'empty' => __('Nothing was held back.', 'ai-eshop-optimizer'), 'footnote' => __('"Nobody engaged" and "hardly anyone ever saw it" look identical in most reports, and they need opposite fixes. This is the difference. Read from recent activity rather than the nightly summary, so it covers the last few weeks only.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'arm', 'label' => __('Version', 'ai-eshop-optimizer'), 'nowrap' => true), array('key' => 'reason', 'label' => __('Why', 'ai-eshop-optimizer')), array('key' => 'n', 'label' => __('Times', 'ai-eshop-optimizer'), 'type' => 'integer'), ), )); } private function render_origins_table(array $report) { if (empty($report['origins'])) { return; } $rows = array(); foreach ($report['origins'] as $o) { foreach ($report['arms'] as $arm) { if (!isset($o['arms'][$arm])) { continue; } $m = $o['arms'][$arm]; $rate = ((int) $m['sessions'] > 0) ? ((int) $m['click_sessions']) / ((int) $m['sessions']) : null; $rows[] = array( 'key' => $o['origin_id'] . '|' . $arm, 'origin' => $o['label'], 'arm' => $arm, 'impressions' => (int) $m['impressions'], 'sessions' => (int) $m['sessions'], 'rate' => null === $rate ? null : $rate * 100, ); } } if (empty($rows)) { return; } echo $this->section_html(__('By where the visitor came from', 'ai-eshop-optimizer')); AIEO_Table::card(array( 'wrap' => false, 'page_id' => $this->get_id(), 'row_key' => 'key', 'density' => 'compact', 'rows' => $rows, 'empty' => __('No breakdown available yet.', 'ai-eshop-optimizer'), 'footnote' => __('A version that wins overall can lose badly on the one source somebody is being paid to send you. "Direct or unknown" is kept in the list on purpose — its size tells you how much of your traffic the rest of this breakdown actually covers.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'origin', 'label' => __('Came from', 'ai-eshop-optimizer')), array('key' => 'arm', 'label' => __('Version', 'ai-eshop-optimizer'), 'nowrap' => true), array('key' => 'impressions', 'label' => __('Shown', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'sessions', 'label' => __('Visitors', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'rate', 'label' => __('Interacted', 'ai-eshop-optimizer'), 'type' => 'percent', 'format' => array('precision' => 2)), ), )); } private function render_surfaces_card() { $type_labels = array( 'popup' => __('Popup', 'ai-eshop-optimizer'), 'slot' => __('Custom slot', 'ai-eshop-optimizer'), 'rail' => __('Recommendation rail', 'ai-eshop-optimizer'), 'promo' => __('Promo surface', 'ai-eshop-optimizer'), ); $class_labels = array( 'overlay' => __('Takes over the screen', 'ai-eshop-optimizer'), 'inline' => __('Sits in the page', 'ai-eshop-optimizer'), 'region' => __('Owns a band of the layout', 'ai-eshop-optimizer'), ); $rows = array(); foreach (AIEO_Display_Registry::surfaces() as $s) { $summary = class_exists('AIEO_DMM_Product_Display') ? AIEO_DMM_Product_Display::conditions_summary($s['conditions']) : ''; $rows[] = array( 'key' => $s['key'], 'label' => $s['label'], 'type' => isset($type_labels[$s['type']]) ? $type_labels[$s['type']] : $s['type'], 'kind' => isset($class_labels[$s['class']]) ? $class_labels[$s['class']] : $s['class'], 'enabled' => (bool) $s['enabled'], 'rules' => '' === $summary ? __('no rules', 'ai-eshop-optimizer') : $summary, ); } list($aieo_dx_open, $aieo_dx_close) = AIEO_DMM_UI::card(array( 'anchor' => 'display-experiments-surfaces', 'icon' => 'layout-template', 'title' => __('Every promotional surface on this site', 'ai-eshop-optimizer'), 'description' => __('Popups, recommendation rails, custom slots and promo surfaces all use the same display rules now, which is what lets you compare them to each other rather than each being described in its own language. Edit each on its own screen; this list is the overview.', 'ai-eshop-optimizer'), 'flush' => true, 'footer' => $this->parity_check_html(), )); echo $aieo_dx_open; AIEO_Table::render(array( 'page_id' => $this->get_id(), 'row_key' => 'key', 'density' => 'compact', 'rows' => $rows, 'empty' => __('No promotional surfaces are configured yet.', 'ai-eshop-optimizer'), 'per_page' => 0, 'columns' => array( array('key' => 'label', 'label' => __('Name', 'ai-eshop-optimizer'), 'subline' => 'key'), array('key' => 'type', 'label' => __('Kind', 'ai-eshop-optimizer'), 'nowrap' => true), array('key' => 'kind', 'label' => __('Behaviour', 'ai-eshop-optimizer')), array('key' => 'enabled', 'label' => __('On', 'ai-eshop-optimizer'), 'type' => 'boolean'), array('key' => 'rules', 'label' => __('Display rules', 'ai-eshop-optimizer')), ), )); echo $aieo_dx_close; } private function parity_check_html() { if (!class_exists('AIEO_Display_Conditions')) { return ''; } $drift = AIEO_Display_Conditions::arm_drift(); $n = (int) $drift['n']; if ($n > 0) { $text = esc_html(sprintf( __('%1$d disagreement(s) recorded, most recently on %2$s for “%3$s”. The browser and the server worked out different halves of a split test for the same visitor, which makes that test\'s results unreliable. Report this — it is not something to fix from this screen.', 'ai-eshop-optimizer'), $n, $drift['last'], isset($drift['sample']['surface']) ? $drift['sample']['surface'] : '?' )); } else { $text = esc_html__('No disagreements recorded. While a split test is running, every measurement carries the version the visitor actually saw and the server checks it against its own answer; anything that did not match would be counted here.', 'ai-eshop-optimizer'); } return $this->notice_html($text, $n > 0 ? 'warning' : 'mono', array('title' => __('A/B split check', 'ai-eshop-optimizer'))); } private function self_url(array $extra = array()) { return add_query_arg(array_merge(array( 'page' => AIEO_DMM::MENU_SLUG, 'dmm_tab' => $this->get_id(), ), $extra), admin_url('admin.php')); } private function current_experiment() { $id = isset($_GET[self::ARG_EXP]) ? sanitize_key(wp_unslash($_GET[self::ARG_EXP])) : ''; if ('' === $id) { return null; } foreach (AIEO_Display_Experiment::all() as $e) { if ($e['id'] === $id) { return $e; } } return null; } private function eligible_surfaces() { $kinds = array( 'popup' => __('popup', 'ai-eshop-optimizer'), 'slot' => __('custom slot', 'ai-eshop-optimizer'), 'rail' => __('recommendation rail', 'ai-eshop-optimizer'), 'promo' => __('promo surface', 'ai-eshop-optimizer'), ); $out = array(); foreach (AIEO_Display_Registry::surfaces() as $s) { if (empty($s['enabled'])) { continue; } $out[] = array( 'key' => $s['key'], 'label' => $s['label'], 'kind' => isset($kinds[$s['type']]) ? $kinds[$s['type']] : $s['type'], ); } return $out; } private function surface_labels() { $out = array(); foreach (AIEO_Display_Registry::surfaces() as $s) { $out[$s['key']] = $s['label']; } return $out; } private function surfaces_taken_by_others($except_id) { $out = array(); foreach (AIEO_Display_Experiment::all() as $e) { if ($e['id'] === $except_id || empty($e['enabled'])) { continue; } $name = '' !== $e['label'] ? $e['label'] : $e['id']; foreach (AIEO_Display_Experiment::surfaces_of($e) as $s) { $out[$s] = $name; } } return $out; } private function varied_keys(array $exp) { $keys = array(); foreach ($exp['arms'] as $by_surface) { foreach ($by_surface as $overrides) { foreach (array_keys((array) $overrides) as $k) { $keys[$k] = true; } } } return array_keys($keys); } private function arm_value($exp, $arm, $key) { if (!is_array($exp) || !isset($exp['arms'][$arm])) { return null; } foreach ($exp['arms'][$arm] as $overrides) { if (isset($overrides[$key])) { return $overrides[$key]; } } return null; } private function current_value_text($key, array $current, $surface_count) { if (1 !== (int) $surface_count || !isset($current[$key])) { return __('as configured', 'ai-eshop-optimizer'); } $v = $current[$key]; if (is_bool($v)) { return $v ? __('on', 'ai-eshop-optimizer') : __('off', 'ai-eshop-optimizer'); } if ('' === (string) $v) { return __('not set', 'ai-eshop-optimizer'); } return (string) $v; } }