get_column_count() : 3; printf( '

%2$s

', (int) $cols, esc_html__( 'Element tests: ImproveAB hides the versions a visitor should not see. While it is deactivated, your pages show every version at once, so remove the extra versions from your pages first.', 'improveab-ab-testing' ) ); } /** * "Tests" and "Settings" links on the Plugins screen row, where people * look first after activating. Settings stays administrator-only, like * the menu entry it points at. * * @param string[] $links Existing row links. * @return string[] */ public function action_links( $links ) { $mine = array(); if ( improveab_can_manage() ) { $mine[] = '' . esc_html__( 'Tests', 'improveab-ab-testing' ) . ''; } if ( current_user_can( 'manage_options' ) ) { $mine[] = '' . esc_html__( 'Settings', 'improveab-ab-testing' ) . ''; } return array_merge( $mine, $links ); } /** * Add the top-level menu and its submenus. Each submenu opens the same * single-page app at a specific view via a data-route attribute. */ public function menu() { // Test screens open to anyone with the manage-tests capability // (administrators always; any role a site grants it to). Settings // stay administrator-only. $cap = IMPROVEAB_CAP; $this->page_hooks[] = add_menu_page( __( 'ImproveAB', 'improveab-ab-testing' ), __( 'A/B Testing', 'improveab-ab-testing' ), $cap, self::SLUG, array( $this, 'page_tests' ), // The brand mark (two bars) as a data URI; wp-admin paints it in the colour scheme. 'data:image/svg+xml;base64,' . base64_encode( '' ), 58 ); // Rename the auto-created first submenu to "All tests". $this->page_hooks[] = add_submenu_page( self::SLUG, __( 'All tests', 'improveab-ab-testing' ), __( 'All tests', 'improveab-ab-testing' ), $cap, self::SLUG, array( $this, 'page_tests' ) ); $this->page_hooks[] = add_submenu_page( self::SLUG, __( 'New test', 'improveab-ab-testing' ), __( 'New test', 'improveab-ab-testing' ), $cap, self::SLUG . '-new', array( $this, 'page_new' ) ); // The sample-size calculator is intentionally not a menu item: it lives // inside the Guide (and at the #/guide route), where it has context. $this->page_hooks[] = add_submenu_page( self::SLUG, __( 'Guide', 'improveab-ab-testing' ), __( 'Guide', 'improveab-ab-testing' ), $cap, self::SLUG . '-guide', array( $this, 'page_guide' ) ); $this->page_hooks[] = add_submenu_page( self::SLUG, __( 'Settings', 'improveab-ab-testing' ), __( 'Settings', 'improveab-ab-testing' ), 'manage_options', self::SLUG . '-settings', array( $this, 'page_settings' ) ); // Self-heal on the plugin's own screens. A database cleaner, a // partial restore or a botched migration can drop the tables while // improveab_db_version still says "installed", and nothing would // ever recreate them. One SHOW TABLES per plugin screen view is // cheap and puts an administrator one page load away from a working // install instead of a deactivate/reactivate cycle. foreach ( array_unique( array_filter( $this->page_hooks ) ) as $hook ) { add_action( 'load-' . $hook, array( 'ImproveAB_Install', 'ensure_tables' ) ); } } /** * Output the app mount point with an initial route. * * @param string $route Hash route the SPA should open on load. */ private function mount( $route ) { echo '
'; } public function page_tests() { $this->mount( '#/' ); } public function page_new() { $this->mount( '#/new' ); } public function page_guide() { $this->mount( '#/guide' ); } /** * Register the "A/B tests" dashboard widget for admins, with its own * stylesheet. wp_dashboard_setup runs before the admin header prints the * styles, so the sheet is queued only on the dashboard and only for * someone who gets the widget. */ public function dashboard_widget() { if ( ! improveab_can_manage() ) { return; } wp_add_dashboard_widget( 'improveab_dashboard', __( 'A/B tests', 'improveab-ab-testing' ), array( $this, 'render_dashboard_widget' ) ); wp_enqueue_style( 'improveab-dashboard', IMPROVEAB_URL . 'assets/css/dashboard.css', array(), improveab_asset_ver( 'assets/css/dashboard.css' ) ); } /** * Running tests at a glance, in the tests list's words: a summary line * (running, and paused so they are not forgotten), then one row per * running test that links to its results. Each rate stands alone, with * Collecting under it until the test has the Settings minimum of * visitors per variant (the list's rule) or the red Split off tag when * its traffic split is off; a test nobody has reached yet says so once. * Drawn by assets/css/dashboard.css (.improveab-dash, .dw-*), and with no * headings: wp-admin styles #dashboard-widgets h3 by id, which would * outrank the widget's own rules. */ public function render_dashboard_widget() { // The counts by status, the five newest running tests and their // totals alone: the widget used to load every test with every // variant's markup and count every event of every test to show five // rows. $by = ImproveAB_Tests::count_by_status(); $count = isset( $by['running'] ) ? (int) $by['running'] : 0; $paused = isset( $by['paused'] ) ? (int) $by['paused'] : 0; $running = $count > 0 ? ImproveAB_Tests::running_glance( 5 ) : array(); $list_url = admin_url( 'admin.php?page=' . self::SLUG ); echo '
'; if ( $count > 0 || $paused > 0 ) { echo '

'; if ( $count > 0 ) { /* translators: %s: number of running tests on this site. */ echo '' . esc_html( sprintf( _n( '%s running', '%s running', $count, 'improveab-ab-testing' ), number_format_i18n( $count ) ) ) . ''; } if ( $paused > 0 ) { /* translators: %s: number of paused tests. */ echo '' . esc_html( sprintf( _n( '%s paused', '%s paused', $paused, 'improveab-ab-testing' ), number_format_i18n( $paused ) ) ) . ''; } echo '

'; } // Nothing running: what to do next, rather than an empty box. The // menu path is built from the admin bar's own strings, so the two // always name it the same way. if ( 0 === $count ) { echo '
'; echo '

' . esc_html__( 'No tests running', 'improveab-ab-testing' ) . '

'; echo '

' . esc_html( sprintf( /* translators: 1: the admin-bar menu's name, "A/B Tests"; 2: that menu's first item, "Start a test on this page". */ __( 'Start one from any page of your site with %1$s › %2$s, or set one up here.', 'improveab-ab-testing' ), __( 'A/B Tests', 'improveab-ab-testing' ), __( 'Start a test on this page', 'improveab-ab-testing' ) ) ) . '

'; echo '' . esc_html__( 'New test', 'improveab-ab-testing' ) . ''; echo '
'; // Paused tests are resumed from their results, one list away. if ( $paused > 0 ) { echo '
' . esc_html__( 'All tests', 'improveab-ab-testing' ) . '
'; } echo '
'; return; } $totals = ImproveAB_Tests::totals( wp_list_pluck( $running, 'id' ) ); $min = max( 1, (int) $this->setting( 'min_visitors', 100 ) ); $rated = false; echo '
'; foreach ( $running as $t ) { $tid = (int) $t['id']; $imps = isset( $totals[ $tid ] ) ? (int) $totals[ $tid ][0] : 0; $conv = isset( $totals[ $tid ] ) ? (int) $totals[ $tid ][1] : 0; echo ''; echo '' . esc_html( $t['name'] ) . ''; // A running test nobody has reached yet is the one row worth acting // on, and the commonest first-run mistake (the classes are not on // the page). It says so once, where the counts would be, instead // of zeroes or a rate that does not exist yet. if ( 0 === $imps ) { echo '' . esc_html__( 'No visitors yet', 'improveab-ab-testing' ) . ''; continue; } echo '' . esc_html( sprintf( /* translators: 1: a number of visitors, e.g. "25 visitors"; 2: a number of conversions, e.g. "3 conversions". */ _x( '%1$s · %2$s', 'visitors and conversions', 'improveab-ab-testing' ), /* translators: %s: number of visitors. */ sprintf( _n( '%s visitor', '%s visitors', $imps, 'improveab-ab-testing' ), number_format_i18n( $imps ) ), /* translators: %s: number of conversions. */ sprintf( _n( '%s conversion', '%s conversions', $conv, 'improveab-ab-testing' ), number_format_i18n( $conv ) ) ) ) . ''; // The list's rule: every variant needs the minimum, and the list // only has the test's total, so the total is held against the // minimum times the variants (never marks too much). A broken // split outweighs it: those numbers are not worth waiting on. $variants = isset( $t['variants'] ) ? (int) $t['variants'] : 0; echo '' . esc_html( number_format_i18n( 100 * $conv / $imps, 1 ) . '%' ); if ( ImproveAB_Stats::split_off( $t ) ) { echo '' . esc_html__( 'Split off', 'improveab-ab-testing' ) . ''; } elseif ( $imps < $min * ( $variants > 0 ? $variants : 2 ) ) { echo '' . esc_html__( 'Collecting', 'improveab-ab-testing' ) . ''; } echo ''; $rated = true; } echo '
'; // The foot says once what the rate is, and leads to the full list. echo '
'; echo $rated ? '' . esc_html__( 'Rate is conversions per visitor', 'improveab-ab-testing' ) . '' : ''; if ( $count > 5 ) { /* translators: %s: number of running tests the widget does not list. */ echo '' . esc_html( sprintf( _n( '%s more running', '%s more running', $count - 5, 'improveab-ab-testing' ), number_format_i18n( $count - 5 ) ) ) . ''; } else { echo '' . esc_html__( 'All tests', 'improveab-ab-testing' ) . ''; } echo '
'; } /** * Handle the welcome-notice dismiss link. */ public function maybe_dismiss_welcome() { if ( ! isset( $_GET['improveab-welcome-dismiss'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'improveab_welcome' ) ) { return; } update_option( 'improveab_welcome_done', 1, false ); wp_safe_redirect( remove_query_arg( array( 'improveab-welcome-dismiss', '_wpnonce' ) ) ); exit; } /** * One-time onboarding notice. Shows on the Dashboard and Plugins screens * only (our own screens have their own empty-state CTA), and retires * itself permanently once dismissed or once a first test exists. */ public function welcome_notice() { // The screen first: the option is not autoloaded, so reading it on // every admin page would cost a query where the notice never shows. $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! in_array( $screen->id, array( 'dashboard', 'plugins' ), true ) ) { return; } if ( get_option( 'improveab_welcome_done' ) || ! current_user_can( 'manage_options' ) ) { return; } if ( array_sum( ImproveAB_Tests::count_by_status() ) > 0 ) { update_option( 'improveab_welcome_done', 1, false ); return; } $new_url = admin_url( 'admin.php?page=' . self::SLUG . '-new' ); $guide_url = admin_url( 'admin.php?page=' . self::SLUG . '-guide' ); $dismiss_url = wp_nonce_url( add_query_arg( 'improveab-welcome-dismiss', '1' ), 'improveab_welcome' ); echo '
'; echo '

' . esc_html__( 'ImproveAB is ready.', 'improveab-ab-testing' ) . '

'; echo '

' . esc_html__( 'Create your first A/B test in a couple of minutes, or skim the built-in guide first. No traffic limits, no cookies, works with any builder.', 'improveab-ab-testing' ) . '

'; echo '

'; echo '' . esc_html__( 'New test', 'improveab-ab-testing' ) . ' '; echo '' . esc_html__( 'Read the guide', 'improveab-ab-testing' ) . ' '; echo '' . esc_html__( 'Dismiss', 'improveab-ab-testing' ) . ''; echo '

'; } public function page_settings() { $this->mount( '#/settings' ); } /** * The admin app's translations under the name WP-CLI's make-json gives * them. Its ".min.js" pattern has unescaped dots, so "admin.js" ends in * a match ("dmin.js") and the file is named for "assets/js/a.js" while * WordPress looks for the one named for "assets/js/admin.js": a site * whose translation was built that way kept the whole app in English. * * @param string|false $file Translation file WordPress is about to read. * @param string $handle Script handle. * @param string $domain Text domain. * @return string|false */ public function admin_translation_file( $file, $handle, $domain ) { if ( 'improveab-admin' !== $handle || 'improveab-ab-testing' !== $domain || ! is_string( $file ) || '' === $file || is_readable( $file ) ) { return $file; } $alt = str_replace( md5( 'assets/js/admin.js' ), md5( 'assets/js/a.js' ), $file ); return ( $alt !== $file && is_readable( $alt ) ) ? $alt : $file; } /** * Enqueue admin assets only on the plugin's own screens. * * @param string $hook Current admin page hook. */ public function assets( $hook ) { if ( ! in_array( $hook, $this->page_hooks, true ) ) { return; } wp_enqueue_style( 'improveab-admin', IMPROVEAB_URL . 'assets/css/admin.css', array(), improveab_asset_ver( 'assets/css/admin.css' ) ); wp_enqueue_script( 'improveab-admin', IMPROVEAB_URL . 'assets/js/admin.js', array( 'wp-i18n' ), improveab_asset_ver( 'assets/js/admin.js' ), true ); // Without this, the __() calls inside admin.js never receive a // catalog and the whole admin SPA renders untranslated. wp_set_script_translations( 'improveab-admin', 'improveab-ab-testing' ); add_filter( 'load_script_translation_file', array( $this, 'admin_translation_file' ), 10, 3 ); wp_localize_script( 'improveab-admin', 'ImproveABAdmin', array( 'root' => esc_url_raw( rest_url( ImproveAB_REST::NS . '/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), // Carried by every preview link the app builds: the server // shows a draft, or keeps a test whole outside its pages, only // for a preview that holds it (improveab_preview_request()). 'previewNonce' => wp_create_nonce( 'improveab_preview' ), 'upgradeUrl' => 'https://improveab.com/pricing/', 'homeUrl' => esc_url_raw( home_url( '/' ) ), // Plain permalinks: every page's path is the home path and only // its query names it, which the Pages help and its check say. 'plainLinks' => '' === (string) get_option( 'permalink_structure' ), // The screen name WordPress put at the head of the tab's title // for the page that loaded; the app names each of its screens // in its place and keeps the rest ("‹ Site — WordPress"). 'pageTitle' => html_entity_decode( wp_strip_all_tags( (string) get_admin_page_title() ), ENT_QUOTES, 'UTF-8' ), // The list marks a rate "Collecting" below this many visitors // per variant, the same Settings value the results use. 'minVisitors' => (int) $this->setting( 'min_visitors', 100 ), // The top bar's running count until the tests list arrives. 'running' => (int) ( ImproveAB_Tests::count_by_status()['running'] ?? 0 ), // This user's one-time guides and results view (the /prefs route). 'prefs' => improveab_user_prefs(), // The site's timezone (a zone name, or an offset such as // +05:30) and its offset now, in seconds: dates and times are // the site's clock, not the browser's. 'timezone' => wp_timezone_string(), 'tzOffset' => (int) wp_timezone()->getOffset( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) ), // The app is shown in the person's own language; when it is // not the site's, the app asks the REST API for it too. 'userLocale' => get_user_locale() !== get_locale(), 'caps' => array( // Whether this user may open Settings (administrators only). 'settings' => current_user_can( 'manage_options' ), 'maxVariants' => improveab_max_variants(), 'maxActiveTests' => improveab_max_active_tests(), 'goalTypes' => array_values( improveab_goal_types() ), ), ) ); } /** * One stored setting, or its default when the settings class is absent. * * @param string $key Setting key. * @param mixed $fallback Value when it is not set. * @return mixed */ private function setting( $key, $fallback ) { $all = class_exists( 'ImproveAB_Settings' ) ? ImproveAB_Settings::get() : array(); return isset( $all[ $key ] ) ? $all[ $key ] : $fallback; } /** * Suggest privacy-policy text describing what the plugin stores. */ public function privacy_policy() { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $content = wp_kses_post( __( 'This site runs A/B tests with ImproveAB. To keep each visitor on a consistent version and to measure results, a random, pseudonymous identifier is stored in the visitor\'s browser (localStorage). This site\'s own database records which version that identifier saw, whether it converted, the device type (desktop, tablet or mobile), the time and, for a conversion, the path of the page it happened on (never its query string, and for a purchase without the order\'s number) and, for a purchase, the order total less any refund; once a test has ended, these records are reduced to daily totals after the retention period. These records hold no name, email address or IP address, and no cookies are set. A WooCommerce order that a test counts, or that waits for its payment before it is counted, also carries a note linking that identifier, the test and the version to the order, which holds the buyer\'s details; the note is removed with the order, or with the rest of the plugin\'s data when the plugin is deleted with its setting to delete all data switched on. These records stay on this site: the plugin sends none of them to its developer or to any third party. To limit abuse, requests are counted per minute under a salted one-way hash of the visitor\'s IP address; the counts are deleted by the plugin\'s nightly clean-up at the latest, and the address itself is never stored.', 'improveab-ab-testing' ) ); $more = ''; wp_add_privacy_policy_content( 'ImproveAB', '

' . $content . '

' . $more ); } }