at, change_id, metric, before, after. */ if (!defined('ABSPATH')) { exit; } class AIFLT_Fixes { const OPTION = 'aiflt_fixes'; const MEASURE_DAYS = 14; /** Fewer shoppers than this on either side and the result is not shown as a number. */ const MIN_SAMPLE = 10; public static function init() { add_action('admin_post_aiflt_fix', [__CLASS__, 'handle']); add_action('admin_post_aiflt_reassurance_text', [__CLASS__, 'handle_reassurance_text']); if (function_exists('aiflt_is_woocommerce_active') && aiflt_is_woocommerce_active()) { add_action('woocommerce_before_cart', [__CLASS__, 'print_free_shipping_notice']); add_action('woocommerce_before_checkout_form', [__CLASS__, 'print_free_shipping_notice'], 5); add_filter('render_block', [__CLASS__, 'block_free_shipping_notice'], 10, 2); // A short reassurance line under the Place order button (classic and block checkout). add_action('woocommerce_review_order_after_submit', [__CLASS__, 'print_reassurance']); add_filter('render_block_woocommerce/checkout-actions-block', [__CLASS__, 'block_reassurance'], 10, 1); } } /** * Every fix the dashboard knows. * * @return array key => stage (cart|checkout), metric, todo, done, options (setting fixes) or url (link fixes) */ public static function all() { $fields = class_exists('AIFLT_Funnel_Analyzer_Logic') ? (int) (AIFLT_Funnel_Analyzer_Logic::get_checkout_field_data()['count'] ?? 0) : 0; return [ 'shipping_calc' => [ 'stage' => 'cart', 'metric' => 'cart_to_checkout', 'todo' => __('Show the delivery cost on the cart page', 'ai-flash-tune'), 'done' => __('The cart page shows the delivery cost', 'ai-flash-tune'), 'options' => ['woocommerce_enable_shipping_calc' => 'yes', 'woocommerce_shipping_cost_requires_address' => 'no'], ], 'free_shipping_setup' => [ 'stage' => 'cart', 'metric' => 'cart_to_checkout', 'todo' => __('Offer free shipping above an order value', 'ai-flash-tune'), 'done' => __('Free shipping is set up', 'ai-flash-tune'), 'url' => admin_url('admin.php?page=wc-settings&tab=shipping'), ], 'save_box' => [ 'stage' => 'cart', 'metric' => null, 'todo' => __('Ask for an email on the cart page ("Save my cart")', 'ai-flash-tune'), 'done' => __('The cart page offers to email the cart', 'ai-flash-tune'), 'url' => aiflt_admin_url('carts') . '#aiflt-catch-emails', ], 'free_shipping_notice' => [ 'stage' => 'cart', 'metric' => 'cart_to_checkout', 'todo' => __('Tell shoppers how close they are to free shipping', 'ai-flash-tune'), 'done' => __('Shoppers see how close they are to free shipping', 'ai-flash-tune'), 'options' => ['aiflt_free_shipping_notice' => 'yes'], ], 'checkout_fields' => [ 'stage' => 'checkout', 'metric' => 'checkout_to_order', 'todo' => $fields > 15 /* translators: %d: number of checkout fields */ ? sprintf(__('Hide checkout fields you don\'t need (%d now)', 'ai-flash-tune'), $fields) : __('Keep the checkout short', 'ai-flash-tune'), 'done' => __('Checkout has a reasonable number of fields', 'ai-flash-tune'), 'url' => aiflt_admin_url('checkout'), 'fields' => $fields, ], 'guest_checkout' => [ 'stage' => 'checkout', 'metric' => 'checkout_to_order', 'todo' => __('Let guests check out without an account', 'ai-flash-tune'), 'done' => __('Guests can check out without an account', 'ai-flash-tune'), 'options' => ['woocommerce_enable_guest_checkout' => 'yes'], ], // The next three show on the Checkout page only (page_only), not in the dashboard list. 'hide_company' => [ 'stage' => 'checkout', 'page_only' => true, 'metric' => 'checkout_to_order', 'todo' => __('Hide the Company field', 'ai-flash-tune'), 'done' => __('The Company field is hidden', 'ai-flash-tune'), 'options' => ['woocommerce_checkout_company_field' => 'hidden'], ], 'phone_optional' => [ 'stage' => 'checkout', 'page_only' => true, 'metric' => 'checkout_to_order', 'todo' => __('Make the Phone field optional', 'ai-flash-tune'), 'done' => __('The Phone field is optional', 'ai-flash-tune'), 'options' => ['woocommerce_checkout_phone_field' => 'optional'], ], 'reassurance' => [ 'stage' => 'checkout', 'page_only' => true, 'metric' => 'checkout_to_order', 'todo' => __('Add a reassurance line under the Place order button', 'ai-flash-tune'), 'done' => __('A reassurance line shows under the Place order button', 'ai-flash-tune'), 'options' => ['aiflt_checkout_reassurance' => 'yes'], ], 'reminders' => [ 'stage' => 'any', 'metric' => null, 'todo' => __('Remind shoppers who gave their email', 'ai-flash-tune'), 'done' => __('Cart reminders are on', 'ai-flash-tune'), 'url' => aiflt_admin_url('carts') . '#aiflt-reminders', ], ]; } /** Whether a fix is in place now, whoever made the change. */ public static function is_done($key, array $fix) { switch ($key) { case 'free_shipping_setup': return self::has_free_shipping(); case 'free_shipping_notice': return get_option('aiflt_free_shipping_notice') === 'yes'; case 'checkout_fields': return (int) ($fix['fields'] ?? 0) > 0 && (int) $fix['fields'] <= 15; case 'reminders': return class_exists('AIFLT_Cart_Recovery') && AIFLT_Cart_Recovery::is_enabled(); case 'save_box': return class_exists('AIFLT_Cart_Recovery') && AIFLT_Cart_Recovery::save_cart_enabled('box'); } foreach ((array) ($fix['options'] ?? []) as $name => $value) { if ((string) get_option($name, '') !== (string) $value) { return false; } } return !empty($fix['options']); } /** Whether a fix makes sense on this store (the free shipping note needs a free shipping minimum). */ private static function applies($key) { if ($key === 'free_shipping_notice') { return self::free_shipping_min() > 0 || get_option('aiflt_free_shipping_notice') === 'yes'; } if ($key === 'shipping_calc') { return function_exists('wc_shipping_enabled') ? wc_shipping_enabled() : true; } return true; } public static function records() { $records = get_option(self::OPTION, []); return is_array($records) ? $records : []; } private static function save_records(array $records) { update_option(self::OPTION, $records, false); } /** * The fixes for one leak, plus any fix that is being measured, ready for the * dashboard. * * @return array list of key, text, url, external, done, kind (setting|link), record, result, actions */ public static function for_stage($stage, array $only = []) { $records = self::records(); $out = []; foreach (self::all() as $key => $fix) { $wanted = $only ? in_array($key, $only, true) : ($fix['stage'] === $stage || $fix['stage'] === 'any') && empty($fix['page_only']); if ((!$wanted && !isset($records[$key])) || !self::applies($key)) { continue; } $done = self::is_done($key, $fix); $record = $records[$key] ?? null; $is_setting = !empty($fix['options']); if ($record && !empty($record['change_id']) && !$done) { // Undone from the change log: nothing left to measure. unset($records[$key]); self::save_records($records); $record = null; if (!$wanted) { continue; } } $actions = []; if ($is_setting && !$done) { $actions[] = 'apply'; } if ($record && !empty($record['change_id']) && $done) { $actions[] = 'undo'; } if ($done && !$record && !empty($fix['metric'])) { $actions[] = 'mark'; } if ($record && empty($record['change_id'])) { $actions[] = 'forget'; } $out[] = [ 'key' => $key, 'text' => $done ? $fix['done'] : $fix['todo'], 'url' => $fix['url'] ?? '', 'external' => false, 'done' => $done, 'kind' => $is_setting ? 'setting' : 'link', 'record' => $record, 'result' => $record ? self::result($key, $record) : '', 'actions' => $actions, ]; } return $out; } // ------------------------------------------------------------------ // Measuring // ------------------------------------------------------------------ /** * The rate a fix should move, between two site-local dates. * * @return array{rate: float|null, base: int} */ public static function rate($metric, $start, $end) { $f = AIFLT_Funnel_Analyzer_Logic::get_funnel_data('all', $start, $end); if ($metric === 'checkout_to_order') { $base = (int) ($f['checkout_starts'] ?? 0); $hits = (int) ($f['sales'] ?? $f['completed_sessions'] ?? 0); } else { $base = (int) ($f['cart_entries'] ?? 0); $hits = (int) ($f['checkout_starts'] ?? 0); } return ['rate' => $base > 0 ? round(100 * min($hits, $base) / $base, 1) : null, 'base' => $base]; } /** Before and after in words, or how far the measuring has got. */ public static function result($key, array &$record) { $metric = (string) ($record['metric'] ?? ''); if ($metric === '') { return ''; } $at = strtotime((string) $record['at']); $days = (int) floor((current_time('timestamp') - $at) / DAY_IN_SECONDS); if ($days < self::MEASURE_DAYS) { /* translators: 1: days so far, 2: days in total */ return sprintf(__('Measuring the effect: day %1$s of %2$s.', 'ai-flash-tune'), number_format_i18n(max(0, $days)), number_format_i18n(self::MEASURE_DAYS)); } if (empty($record['after'])) { $record['after'] = self::rate($metric, wp_date('Y-m-d', $at + DAY_IN_SECONDS, new DateTimeZone('UTC')), wp_date('Y-m-d', $at + self::MEASURE_DAYS * DAY_IN_SECONDS, new DateTimeZone('UTC'))); $records = self::records(); if (isset($records[$key])) { $records[$key]['after'] = $record['after']; self::save_records($records); } } $before = (array) ($record['before'] ?? []); $after = (array) $record['after']; if ((int) ($before['base'] ?? 0) < self::MIN_SAMPLE || (int) ($after['base'] ?? 0) < self::MIN_SAMPLE || ($before['rate'] ?? null) === null || ($after['rate'] ?? null) === null) { return __('Too few shoppers in the two weeks before and after to tell a difference.', 'ai-flash-tune'); } $diff = (float) $after['rate'] - (float) $before['rate']; $what = $metric === 'checkout_to_order' ? __('of checkouts became orders', 'ai-flash-tune') : __('of carts reached checkout', 'ai-flash-tune'); /* translators: 1: rate before, 2: rate after, 3: what was measured, 4: change in points such as "+4.2" */ return sprintf(__('Two weeks before: %1$s%%. Two weeks after: %2$s%% %3$s (%4$s points).', 'ai-flash-tune'), number_format_i18n((float) $before['rate'], 1), number_format_i18n((float) $after['rate'], 1), $what, ($diff >= 0 ? '+' : '') . number_format_i18n($diff, 1)); } private static function start_record($key, array $fix, $change_id = 0) { $now = current_time('timestamp'); $records = self::records(); $records[$key] = [ 'at' => current_time('mysql'), 'change_id' => (int) $change_id, 'metric' => (string) ($fix['metric'] ?? ''), 'before' => !empty($fix['metric']) ? self::rate($fix['metric'], wp_date('Y-m-d', $now - self::MEASURE_DAYS * DAY_IN_SECONDS, new DateTimeZone('UTC')), wp_date('Y-m-d', $now - DAY_IN_SECONDS, new DateTimeZone('UTC'))) : [], 'by' => get_current_user_id(), ]; self::save_records($records); } // ------------------------------------------------------------------ // Actions from the dashboard // ------------------------------------------------------------------ public static function handle() { if (!current_user_can('manage_woocommerce') && !current_user_can('manage_options')) { wp_die(esc_html__('Permission denied.', 'ai-flash-tune')); } check_admin_referer('aiflt_fix'); $key = sanitize_key(wp_unslash($_POST['fix'] ?? '')); $do = sanitize_key(wp_unslash($_POST['do'] ?? '')); $all = self::all(); $flag = 'none'; if (isset($all[$key])) { $fix = $all[$key]; if ($do === 'apply') { $flag = self::apply($key, $fix) ? 'applied' : 'none'; } elseif ($do === 'undo') { $flag = self::undo($key) ? 'undone' : 'none'; } elseif ($do === 'mark' && self::is_done($key, $fix)) { self::start_record($key, $fix); $flag = 'measuring'; } elseif ($do === 'forget') { $records = self::records(); unset($records[$key]); self::save_records($records); $flag = 'forgotten'; } } $back = isset($_POST['redirect_to']) ? wp_validate_redirect(esc_url_raw(wp_unslash($_POST['redirect_to'])), '') : ''; if ($back === '' || strpos($back, admin_url()) !== 0) { $back = aiflt_admin_url('leak-scan') . '#aiflt-dash-leak'; } $anchor = strpos($back, '#') !== false ? substr($back, strpos($back, '#')) : ''; $back = $anchor !== '' ? substr($back, 0, strpos($back, '#')) : $back; wp_safe_redirect(add_query_arg(['aiflt_fix' => $flag, 'aiflt_fix_key' => $key], $back) . $anchor); exit; } /** Change the settings, log the change for Undo, and start measuring. */ public static function apply($key, array $fix) { if (empty($fix['options']) || self::is_done($key, $fix)) { return false; } $before = []; foreach ($fix['options'] as $name => $value) { $old = get_option($name, null); $before[$name] = $old === false ? null : $old; update_option($name, $value); } $change_id = 0; if (class_exists('AIFLT_World_Class_Tools')) { $change_id = (int) AIFLT_World_Class_Tools::log_change('fix_' . $key, [ 'object_type' => 'option', 'label' => $fix['todo'], 'before_data' => ['options' => $before], 'after_data' => ['options' => $fix['options']], 'source' => 'fix', ]); } self::start_record($key, $fix, $change_id); return true; } public static function undo($key) { $records = self::records(); $record = $records[$key] ?? null; if (!$record || empty($record['change_id']) || !class_exists('AIFLT_World_Class_Tools')) { return false; } $ok = AIFLT_World_Class_Tools::rollback_change((int) $record['change_id']); if ($ok) { unset($records[$key]); self::save_records($records); } return $ok; } /** Settings a fix may change, so the change log's Undo can put them back. */ public static function undoable_options() { $names = []; foreach (self::all() as $fix) { $names = array_merge($names, array_keys((array) ($fix['options'] ?? []))); } return array_values(array_unique($names)); } // ------------------------------------------------------------------ // Free shipping // ------------------------------------------------------------------ public static function has_free_shipping() { if (!class_exists('WC_Shipping_Zones')) { return false; } $zones = WC_Shipping_Zones::get_zones(); $zones[] = ['shipping_methods' => (new WC_Shipping_Zone(0))->get_shipping_methods(true)]; foreach ($zones as $zone) { foreach ((array) ($zone['shipping_methods'] ?? []) as $method) { if ($method->id === 'free_shipping' && $method->is_enabled()) { return true; } } } return false; } /** * The order value for free shipping in the shopper's zone (or the store's * own country before an address is known); 0 when there is none. */ public static function free_shipping_min() { if (!class_exists('WC_Shipping_Zones') || !function_exists('WC')) { return 0.0; } $customer = WC()->customer; $country = $customer && $customer->get_shipping_country() ? $customer->get_shipping_country() : WC()->countries->get_base_country(); $zone = WC_Shipping_Zones::get_zone_matching_package([ 'destination' => [ 'country' => $country, 'state' => $customer ? $customer->get_shipping_state() : '', 'postcode' => $customer ? $customer->get_shipping_postcode() : '', ], ]); $min = 0.0; foreach ($zone->get_shipping_methods(true) as $method) { if ($method->id === 'free_shipping' && in_array($method->get_option('requires'), ['min_amount', 'either', 'both'], true)) { $amount = (float) $method->get_option('min_amount'); if ($amount > 0 && ($min <= 0 || $amount < $min)) { $min = $amount; } } } return $min; } public static function handle_reassurance_text() { if (!current_user_can('manage_woocommerce') && !current_user_can('manage_options')) { wp_die(esc_html__('Permission denied.', 'ai-flash-tune')); } check_admin_referer('aiflt_reassurance_text'); $text = isset($_POST['text']) ? sanitize_text_field(wp_unslash($_POST['text'])) : ''; update_option('aiflt_checkout_reassurance_text', mb_substr($text, 0, 200), false); wp_safe_redirect(aiflt_admin_url('checkout') . '#aiflt-checkout-fixes'); exit; } /** The reassurance line: the owner's own text, or a plain default. */ public static function reassurance_html() { if (get_option('aiflt_checkout_reassurance') !== 'yes') { return ''; } $text = trim((string) get_option('aiflt_checkout_reassurance_text', '')); if ($text === '') { $text = __('Secure checkout. Your payment details are handled by the payment provider you choose.', 'ai-flash-tune'); } return '
' . esc_html($text) . '
'; } public static function print_reassurance() { echo self::reassurance_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped in reassurance_html(). } public static function block_reassurance($content) { return $content . self::reassurance_html(); } /** "Add ₨500 more for free shipping" on the classic cart and checkout. */ public static function print_free_shipping_notice() { $message = self::free_shipping_message(true); if ($message !== '') { wc_print_notice(esc_html($message), 'notice'); } } /** * On the block cart and checkout the totals change without a page load, so * the note says the threshold instead of the amount still missing. */ public static function block_free_shipping_notice($content, $block) { $name = (string) ($block['blockName'] ?? ''); if (($name !== 'woocommerce/cart' && $name !== 'woocommerce/checkout') || is_admin()) { return $content; } $message = self::free_shipping_message(false); if ($message === '') { return $content; } return '' . $content; } private static function free_shipping_message($exact) { if (get_option('aiflt_free_shipping_notice') !== 'yes' || !function_exists('WC') || !WC()->cart || WC()->cart->is_empty()) { return ''; } $min = self::free_shipping_min(); if ($min <= 0) { return ''; } $price = static function ($amount) { return html_entity_decode(wp_strip_all_tags(wc_price($amount)), ENT_QUOTES, 'UTF-8'); }; if (!$exact) { /* translators: %s: order value */ return sprintf(__('Free shipping on orders over %s.', 'ai-flash-tune'), $price($min)); } $left = $min - ((float) WC()->cart->get_displayed_subtotal() - (float) WC()->cart->get_discount_total()); return $left > 0 /* translators: %s: amount still needed */ ? sprintf(__('Add %s more to your cart for free shipping.', 'ai-flash-tune'), $price($left)) : __('Your order qualifies for free shipping.', 'ai-flash-tune'); } }