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') . '

'; return; } $sections = $this->get_tabs(); $section = isset($_GET[self::ARG_SECTION]) ? sanitize_key(wp_unslash($_GET[self::ARG_SECTION])) : 'tests'; if (!isset($sections[$section])) { $section = 'tests'; } AIEO_DMM_UI::tab_strip($this->get_id(), $sections, $section, array( 'arg' => $this->get_tab_arg(), 'base' => $this->self_url(), )); if ('surfaces' === $section) { $this->render_surfaces_card(); return; } $this->render_list_card(); $this->render_editor_card(); $this->render_results_card(); } private function render_notice() { $msg = isset($_GET[self::ARG_MSG]) ? sanitize_key(wp_unslash($_GET[self::ARG_MSG])) : ''; if ('' === $msg) { return; } $good = array( 'saved' => __('Split test saved. It is not running yet — press Start when you are ready.', 'ai-eshop-optimizer'), 'state_start' => __('Split test started. From now on each visitor is put in one version and stays there.', 'ai-eshop-optimizer'), 'state_stop' => __('Split test stopped. Everyone is back on the surface\'s own settings, and the results so far are kept.', 'ai-eshop-optimizer'), 'state_restart' => __('Split test restarted with a fresh draw. Everyone has been reassigned, and the results now begin from today.', 'ai-eshop-optimizer'), 'deleted' => __('Split test deleted. The measurements it produced are kept.', 'ai-eshop-optimizer'), ); $bad = array( 'no_surfaces' => __('Pick at least one thing to test on.', 'ai-eshop-optimizer'), 'no_vary' => __('Tick at least one setting to vary — a test where both versions are identical measures nothing.', 'ai-eshop-optimizer'), 'too_few_arms' => __('A split test needs at least two versions.', 'ai-eshop-optimizer'), 'too_many_arms' => __('Four versions is the maximum. More than that and none of them gets enough traffic to conclude anything.', 'ai-eshop-optimizer'), 'arm_too_long' => __('A version name can be at most 16 characters. Longer names are refused rather than shortened, because a shortened name would silently merge two versions into one.', 'ai-eshop-optimizer'), 'arm_duplicate' => __('Two versions cannot share a name.', 'ai-eshop-optimizer'), 'arm_numeric' => __('A version name cannot be just a number. Add at least one letter, so reports and browsers cannot mistake it for a count.', 'ai-eshop-optimizer'), 'running_locked' => __('This test is running, so its versions and their values are locked. Stop it first, then make your changes and resume, or use Restart for a clean start that reassigns every visitor. Only the name can be changed while it runs.', 'ai-eshop-optimizer'), 'surface_taken' => __('Something you picked is already in another running test. Stop that one first — two tests on the same thing at once produce a result neither of them can explain.', 'ai-eshop-optimizer'), 'timing_on_painted' => __('You are varying WHEN something appears, but at least one of the things you picked is drawn by the server and is already on the page before anything can decide when to put it there. Both versions would behave identically. Vary something else, or run this test on a popup or a custom slot.', 'ai-eshop-optimizer'), 'missing' => __('That split test no longer exists.', 'ai-eshop-optimizer'), 'rejected' => __('That split test could not be saved. Check the version names and the settings you chose to vary.', 'ai-eshop-optimizer'), 'unavailable' => __('The display framework is not available on this install.', 'ai-eshop-optimizer'), ); if (isset($good[$msg])) { echo $this->notice_html(esc_html($good[$msg]), 'success', array('dismissible' => true)); } elseif (isset($bad[$msg])) { $text = $bad[$msg]; $detail = isset($_GET[self::ARG_DETAIL]) ? sanitize_text_field(rawurldecode(wp_unslash($_GET[self::ARG_DETAIL]))) : ''; if ('' !== $detail) { $text .= ' ' . sprintf(__('Affected: %s.', 'ai-eshop-optimizer'), $detail); } echo $this->notice_html(esc_html($text), 'destructive', array('dismissible' => true)); } } private static function library_card() { return class_exists('AIEO_Chrome') && AIEO_Chrome::on() && function_exists('rooster_admin_card_section_html'); } private function notice_html($html, $tone, array $opts = array()) { if (self::library_card() && class_exists('\Rooster\Admin\Ui')) { return \Rooster\Admin\Ui::notice($html, $tone, array('inline' => true) + $opts); } $wp = array('success' => 'notice-success', 'warning' => 'notice-warning', 'destructive' => 'notice-error', 'info' => 'notice-info'); return '
' . (!empty($opts['title']) ? '

' . esc_html($opts['title']) . '

' : '') . '

' . $html . '

' . (!empty($opts['actions']) ? '
' . $opts['actions'] . '
' : '') . '
'; } private function section_html($title) { if (self::library_card()) { return rooster_admin_card_section_html($title); } return '

' . esc_html($title) . '

'; } private function state_html($kind, $text) { if (self::library_card() && class_exists('\Rooster\Admin\States')) { return \Rooster\Admin\States::state($kind, array('text' => $text)); } return '

' . 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 '
'; wp_nonce_field(self::NONCE . '_save'); echo ''; echo ''; echo '
'; echo AIEO_Kit::row( __('Name', 'ai-eshop-optimizer'), \Rooster\Admin\Ui::input('label', $is_new ? '' : (string) $exp['label'], array('id' => 'aieo-dx-label', 'medium' => true, 'placeholder' => __('e.g. Does capping the video rail help?', 'ai-eshop-optimizer'))), __('For your own reference. It is never shown to visitors.', 'ai-eshop-optimizer'), array('id' => 'aieo-dx-label') ); $taken = $this->surfaces_taken_by_others($is_new ? '' : $exp['id']); $checks = '
'; foreach ($surfaces as $n => $s) { $busy = isset($taken[$s['key']]); $cid = 'aieo-dx-surface-' . (int) $n; $checks .= ''; } $checks .= '
'; echo AIEO_Kit::row(__('What to test', 'ai-eshop-optimizer'), $checks, __('You can test the same offer in more than one place at once. Each version gets the same settings everywhere it runs.', 'ai-eshop-optimizer'), array('id' => 'aieo-dx-surfaces', 'group' => true)); $parts = array(); for ($i = 0; $i < self::ARM_SLOTS; $i++) { $val = isset($arm_names[$i]) ? $arm_names[$i] : ''; $control = \Rooster\Admin\Ui::input('arm[' . (int) $i . ']', (string) $val, array( 'id' => 'aieo-dx-arm-' . (int) $i, 'medium' => true, 'code' => true, 'maxlength' => (int) AIEO_Display_Experiment::ARM_MAX_LEN, 'placeholder' => 0 === $i ? __('control — leave as it is today', 'ai-eshop-optimizer') : sprintf( __('version %d (leave blank if unused)', 'ai-eshop-optimizer'), $i + 1), 'attrs' => 0 === $i ? array('aria-describedby' => 'aieo-dx-arm-0-help') : array(), )); if (0 === $i) { $control .= ' ' . esc_html__('the one everything is measured against', 'ai-eshop-optimizer') . ''; } $parts[] = array( 'label' => sprintf(__('Version %d', 'ai-eshop-optimizer'), $i + 1), 'id' => 'aieo-dx-arm-' . (int) $i, 'control' => $control, ); } echo AIEO_Kit::row( __('Versions', 'ai-eshop-optimizer'), \Rooster\Admin\Fields::compound($parts, array('label' => __('Versions', 'ai-eshop-optimizer'))), __('Lower-case letters, numbers and underscores, up to 16 characters. Traffic is split equally between them. Two to four versions.', 'ai-eshop-optimizer'), array('id' => 'aieo-dx-arms', 'group' => true) ); echo '
'; $this->render_variation_grid($arm_names, $varied, $exp, $chosen); 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 .= wp_nonce_field(self::NONCE . '_state', '_wpnonce', true, false); $actions .= ''; $actions .= ''; if ($running) { $actions .= AIEO_Kit::button(__('Stop', 'ai-eshop-optimizer'), array('type' => 'submit', 'attrs' => array('name' => 'state', 'value' => 'stop'))); } else { $actions .= AIEO_Kit::button($started ? __('Resume', 'ai-eshop-optimizer') : __('Start', 'ai-eshop-optimizer'), array('variant' => $primary ? 'primary' : 'outline', 'type' => 'submit', 'attrs' => array('name' => 'state', 'value' => 'start'))); } if ($started) { $actions .= AIEO_Kit::button(__('Restart from scratch', 'ai-eshop-optimizer'), array('type' => 'submit', 'attrs' => array('name' => 'state', 'value' => 'restart') + $ask('aieo-dx-restart-dialog', $restart))); } $actions .= '
'; $actions .= '
'; $actions .= wp_nonce_field(self::NONCE . '_delete', '_wpnonce', true, false); $actions .= ''; $actions .= ''; $actions .= AIEO_Kit::button(__('Delete', 'ai-eshop-optimizer'), array('variant' => 'destructive', 'type' => 'submit', 'attrs' => $ask('aieo-dx-delete-dialog', $delete))); $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; } }