prefix . 'abtestkit_events' );
}
//ini_set('log_errors', 1);
//ini_set('error_log', __DIR__ . '/my-abtestkit-debug.log');
// ─────────────────────────────────────────────────────────────────────────────
// Explicit free-text feedback transport. Connected-service operational events
// use the separate authenticated platform boundary.
// ─────────────────────────────────────────────────────────────────────────────
if ( ! defined( 'ABTESTKIT_TELEMETRY_ENDPOINT' ) ) {
define( 'ABTESTKIT_TELEMETRY_ENDPOINT', '' );
}
function abtestkit_build_telemetry_base(): array {
return [
'plugin' => 'abtestkit',
'version' => '1.7.2',
'site' => md5( home_url() ), // anonymous hash
'wp' => get_bloginfo( 'version' ),
'php' => PHP_VERSION,
'env' => ( wp_get_environment_type() ?: 'production' ),
];
}
/**
* Explicit feedback sender.
*
* Set ABTESTKIT_TELEMETRY_ENDPOINT
* add_filter('abtestkit_telemetry_endpoint')
*/
if ( ! defined( 'ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK' ) ) {
define( 'ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK', 'abtestkit_telemetry_heartbeat' );
}
if ( ! defined( 'ABTESTKIT_REVIEW_PROMPT_OPTION' ) ) {
define( 'ABTESTKIT_REVIEW_PROMPT_OPTION', 'abtestkit_review_prompt_state' );
}
if ( ! defined( 'ABTESTKIT_PREVIEW_HEALTH_OPTION' ) ) {
define( 'ABTESTKIT_PREVIEW_HEALTH_OPTION', 'abtestkit_preview_health_failures' );
}
if ( ! defined( 'ABTESTKIT_HTML_RUNTIME_HEALTH_OPTION' ) ) {
define( 'ABTESTKIT_HTML_RUNTIME_HEALTH_OPTION', 'abtestkit_html_runtime_health' );
}
if ( ! defined( 'ABTESTKIT_REVIEW_URL' ) ) {
define( 'ABTESTKIT_REVIEW_URL', 'https://wordpress.org/support/plugin/abtestkit/reviews/' );
}
function abtestkit_review_prompt_get_state(): array {
$stored = get_option( ABTESTKIT_REVIEW_PROMPT_OPTION, null );
$default_tests_created = 0;
if ( function_exists( 'abtestkit_pt_all' ) ) {
$tests = abtestkit_pt_all();
if ( is_array( $tests ) ) {
$default_tests_created = count( $tests );
}
}
if ( ! is_array( $stored ) ) {
$stored = [];
}
return wp_parse_args(
$stored,
[
'tests_created' => $default_tests_created,
'snooze_until' => 0,
'dismissed' => 0,
]
);
}
function abtestkit_review_prompt_save_state( array $state ): void {
$clean = [
'tests_created' => max( 0, (int) ( $state['tests_created'] ?? 0 ) ),
'snooze_until' => max( 0, (int) ( $state['snooze_until'] ?? 0 ) ),
'dismissed' => ! empty( $state['dismissed'] ) ? 1 : 0,
];
update_option( ABTESTKIT_REVIEW_PROMPT_OPTION, $clean, false );
}
function abtestkit_review_prompt_note_test_created(): void {
$state = abtestkit_review_prompt_get_state();
$state['tests_created'] = max( 0, (int) $state['tests_created'] ) + 1;
abtestkit_review_prompt_save_state( $state );
}
function abtestkit_preview_health_get_failures(): array {
$stored = get_option( ABTESTKIT_PREVIEW_HEALTH_OPTION, [] );
return is_array( $stored ) ? $stored : [];
}
function abtestkit_preview_health_record_failure( int $post_id, string $url, string $label = '', string $context = '' ): void {
$post_id = absint( $post_id );
$url = esc_url_raw( trim( $url ) );
if ( $post_id <= 0 && $url === '' ) {
return;
}
$label = substr( sanitize_text_field( $label ), 0, 120 );
$context = sanitize_key( $context );
$now = time();
$key = md5( $post_id . '|' . $url . '|' . $context );
$failures = abtestkit_preview_health_get_failures();
$failures[ $key ] = [
'post_id' => $post_id,
'url' => $url,
'label' => $label,
'context' => $context,
'last_failed_at' => $now,
'last_failed_gmt' => gmdate( 'Y-m-d H:i:s', $now ),
];
uasort(
$failures,
static function( $a, $b ) {
return (int) ( $b['last_failed_at'] ?? 0 ) <=> (int) ( $a['last_failed_at'] ?? 0 );
}
);
$failures = array_slice( $failures, 0, 50, true );
update_option( ABTESTKIT_PREVIEW_HEALTH_OPTION, $failures, false );
}
function abtestkit_preview_health_latest_for_test( array $test ): array {
$ids = [];
foreach ( [ 'control_id', 'variant_id' ] as $key ) {
if ( ! empty( $test[ $key ] ) ) {
$ids[] = (int) $test[ $key ];
}
}
$ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
if ( empty( $ids ) ) {
return [];
}
$cutoff = time() - ( 7 * DAY_IN_SECONDS );
$latest = [];
$failures = abtestkit_preview_health_get_failures();
foreach ( $failures as $failure ) {
if ( ! is_array( $failure ) ) {
continue;
}
$post_id = isset( $failure['post_id'] ) ? (int) $failure['post_id'] : 0;
$last_failed_at = isset( $failure['last_failed_at'] ) ? (int) $failure['last_failed_at'] : 0;
if ( ! in_array( $post_id, $ids, true ) || $last_failed_at < $cutoff ) {
continue;
}
if ( empty( $latest ) || $last_failed_at > (int) ( $latest['last_failed_at'] ?? 0 ) ) {
$latest = $failure;
}
}
return is_array( $latest ) ? $latest : [];
}
function abtestkit_html_runtime_health_get_all(): array {
$stored = get_option( ABTESTKIT_HTML_RUNTIME_HEALTH_OPTION, [] );
return is_array( $stored ) ? $stored : [];
}
function abtestkit_html_runtime_health_get( string $test_id ): array {
$test_id = abtestkit_sanitize_test_id( $test_id );
if ( $test_id === '' ) {
return [];
}
$stored = abtestkit_html_runtime_health_get_all();
$record = isset( $stored[ $test_id ] ) && is_array( $stored[ $test_id ] )
? $stored[ $test_id ]
: [];
return $record;
}
function abtestkit_html_runtime_health_delete( string $test_id ): void {
$test_id = abtestkit_sanitize_test_id( $test_id );
if ( $test_id === '' ) {
return;
}
$stored = abtestkit_html_runtime_health_get_all();
if ( ! isset( $stored[ $test_id ] ) ) {
return;
}
unset( $stored[ $test_id ] );
update_option( ABTESTKIT_HTML_RUNTIME_HEALTH_OPTION, $stored, false );
}
function abtestkit_html_runtime_health_record( array $test, array $counts ): array {
$test_id = isset( $test['id'] ) ? abtestkit_sanitize_test_id( $test['id'] ) : '';
$control_id = isset( $test['control_id'] ) ? absint( $test['control_id'] ) : 0;
$changes = isset( $test['html_changes'] )
? abtestkit_sanitize_custom_html_changes( $test['html_changes'] )
: [];
$total = count( $changes );
if ( $test_id === '' || $control_id <= 0 || $total <= 0 ) {
return [];
}
$matched = isset( $counts['matched'] ) ? max( 0, (int) $counts['matched'] ) : 0;
$missing = isset( $counts['missing'] ) ? max( 0, (int) $counts['missing'] ) : 0;
$invalid = isset( $counts['invalid'] ) ? max( 0, (int) $counts['invalid'] ) : 0;
if ( $matched + $missing + $invalid !== $total ) {
return [];
}
$stored = abtestkit_html_runtime_health_get_all();
$previous = isset( $stored[ $test_id ] ) && is_array( $stored[ $test_id ] )
? $stored[ $test_id ]
: [];
$now = time();
$invalid_streak = $invalid > 0
? max( 0, (int) ( $previous['invalid_streak'] ?? 0 ) ) + 1
: 0;
$status = 'good';
if ( $invalid_streak >= 3 ) {
$status = 'broken';
} elseif ( $missing > 0 || $invalid > 0 ) {
$status = 'attention';
}
$record = [
'test_id' => $test_id,
'control_id' => $control_id,
'status' => $status,
'matched' => $matched,
'missing' => $missing,
'invalid' => $invalid,
'total' => $total,
'reports' => max( 0, (int) ( $previous['reports'] ?? 0 ) ) + 1,
'invalid_streak' => $invalid_streak,
'last_reported_at' => $now,
'last_reported_gmt' => gmdate( 'Y-m-d H:i:s', $now ),
];
$stored[ $test_id ] = $record;
uasort(
$stored,
static function( $a, $b ) {
return (int) ( $b['last_reported_at'] ?? 0 ) <=> (int) ( $a['last_reported_at'] ?? 0 );
}
);
$stored = array_slice( $stored, 0, 100, true );
update_option( ABTESTKIT_HTML_RUNTIME_HEALTH_OPTION, $stored, false );
return $record;
}
function abtestkit_admin_return_url_from_request(): string {
$dashboard_url = admin_url( 'admin.php?page=abtestkit-dashboard' );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only current admin screen routing.
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
if ( $page === 'abtestkit-test-performance' ) {
$url = admin_url( 'admin.php?page=abtestkit-test-performance' );
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only return URL preservation.
if ( isset( $_GET['test_id'] ) ) {
$request_test_id = sanitize_text_field( wp_unslash( $_GET['test_id'] ) );
if ( $request_test_id !== '' ) {
$url = add_query_arg(
'test_id',
$request_test_id,
$url
);
}
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return $url;
}
if ( $page === 'abtestkit-dashboard' ) {
return $dashboard_url;
}
return $dashboard_url;
}
function abtestkit_admin_get_safe_return_url( string $fallback = '' ): string {
$fallback = $fallback !== '' ? $fallback : admin_url( 'admin.php?page=abtestkit-dashboard' );
$redirect = '';
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- admin-post callers verify their nonce before this helper runs; value is validated with wp_validate_redirect before use.
if ( isset( $_REQUEST['return_url'] ) && $_REQUEST['return_url'] !== '' ) {
$redirect = esc_url_raw( wp_unslash( $_REQUEST['return_url'] ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( $redirect === '' ) {
$redirect = wp_get_referer() ?: '';
}
$redirect = wp_validate_redirect( $redirect, $fallback );
return remove_query_arg(
[ 'action', 'do', 'v', '_wpnonce', 'return_url' ],
$redirect
);
}
function abtestkit_review_prompt_should_show(): bool {
if ( ! current_user_can( 'manage_options' ) ) {
return false;
}
$state = abtestkit_review_prompt_get_state();
if ( ! empty( $state['dismissed'] ) ) {
return false;
}
if ( (int) $state['tests_created'] < 2 ) {
return false;
}
$snooze_until = isset( $state['snooze_until'] ) ? (int) $state['snooze_until'] : 0;
if ( $snooze_until > time() ) {
return false;
}
return true;
}
function abtestkit_review_prompt_is_dashboard_screen(): bool {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen ) {
return false;
}
return in_array(
(string) $screen->id,
[
'toplevel_page_abtestkit-dashboard',
'abtestkit-dashboard_page_abtestkit-dashboard',
],
true
);
}
function abtestkit_telemetry_endpoint(): string {
// If the constant is defined but empty (your current default), fall back.
$endpoint = defined( 'ABTESTKIT_TELEMETRY_ENDPOINT' ) ? (string) ABTESTKIT_TELEMETRY_ENDPOINT : '';
$endpoint = trim( $endpoint );
if ( $endpoint === '' ) {
// Default collector
$endpoint = 'https://www.abtestkit.io/wp-json/abtestkit-telemetry/v1/collect';
}
$endpoint = (string) apply_filters( 'abtestkit_telemetry_endpoint', $endpoint );
$endpoint = esc_url_raw( trim( $endpoint ) );
return $endpoint;
}
/**
* Internal transport for explicit user-action feedback only.
*/
function abtestkit_send_explicit_feedback(
string $event,
array $data = [],
bool $blocking = false,
int $timeout = 2
): void {
$event = sanitize_key( $event );
if ( ! in_array( $event, [ 'plugin_deactivated', 'compatibility_help_request' ], true ) ) {
return;
}
$endpoint = abtestkit_telemetry_endpoint();
if ( $endpoint === '' ) {
return;
}
// Keep the shared envelope small. Account identity stays on the separate
// authenticated hosted-service boundary.
$payload = array_merge(
abtestkit_build_telemetry_base(),
[
'event' => $event,
't' => time(),
'data' => is_array( $data ) ? $data : [],
]
);
$args = [
'timeout' => max( 1, (int) $timeout ),
'blocking' => (bool) $blocking,
'headers' => [
'Content-Type' => 'application/json; charset=utf-8',
],
'body' => wp_json_encode( $payload ),
];
// Best-effort; never break UX.
try {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_post_wp_remote_post
wp_remote_post( $endpoint, $args );
} catch ( \Throwable $e ) {
// no-op
}
}
/**
* Sender for explicit user actions where submitting authorises that one event.
* Keep this whitelist tight.
*/
function abtestkit_send_explicit_feedback_event( string $event, array $data = [], bool $blocking = true ): void {
$event = sanitize_key( $event );
// Tight allow-list to prevent accidental “forced” telemetry expansion.
$allowed = [ 'plugin_deactivated', 'compatibility_help_request' ];
if ( ! in_array( $event, $allowed, true ) ) {
return;
}
// For deactivation feedback, use blocking=true so it actually leaves the site
// before the user completes deactivation.
abtestkit_send_explicit_feedback( $event, $data, $blocking, 3 );
}
/** Remove schedules left by releases that offered anonymous telemetry. */
function abtestkit_telemetry_unschedule_heartbeat(): void {
$ts = wp_next_scheduled( ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK );
while ( $ts ) {
wp_unschedule_event( $ts, ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK );
$ts = wp_next_scheduled( ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK );
}
}
add_action( 'admin_init', 'abtestkit_telemetry_unschedule_heartbeat', 1 );
/**
* Build a small, anonymous technical snapshot for deactivation feedback.
* No URLs, site name, user details, order details, post titles or content are sent.
*/
function abtestkit_telemetry_detect_plugin_state( array $plugin_files ): array {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$all_plugins = function_exists( 'get_plugins' ) ? get_plugins() : [];
$state = [
'installed' => 0,
'active' => 0,
'network_active' => 0,
'version' => '',
];
foreach ( $plugin_files as $plugin_file ) {
$plugin_file = plugin_basename( (string) $plugin_file );
if ( isset( $all_plugins[ $plugin_file ] ) ) {
$state['installed'] = 1;
if ( empty( $state['version'] ) && ! empty( $all_plugins[ $plugin_file ]['Version'] ) ) {
$state['version'] = sanitize_text_field( (string) $all_plugins[ $plugin_file ]['Version'] );
}
}
if ( function_exists( 'is_plugin_active' ) && is_plugin_active( $plugin_file ) ) {
$state['active'] = 1;
}
if ( is_multisite() && function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $plugin_file ) ) {
$state['active'] = 1;
$state['network_active'] = 1;
}
}
return $state;
}
function abtestkit_telemetry_count_post_type( string $post_type ): array {
if ( ! post_type_exists( $post_type ) ) {
return [
'publish' => 0,
'draft' => 0,
'private' => 0,
'total' => 0,
];
}
$counts = wp_count_posts( $post_type );
$publish = isset( $counts->publish ) ? (int) $counts->publish : 0;
$draft = isset( $counts->draft ) ? (int) $counts->draft : 0;
$private = isset( $counts->private ) ? (int) $counts->private : 0;
$total = 0;
foreach ( (array) $counts as $count ) {
$total += (int) $count;
}
return [
'publish' => $publish,
'draft' => $draft,
'private' => $private,
'total' => $total,
];
}
function abtestkit_telemetry_theme_family( string $stylesheet, string $template, string $name ): string {
$haystack = strtolower( $stylesheet . ' ' . $template . ' ' . $name );
$families = [
'hello-elementor' => 'hello_elementor',
'astra' => 'astra',
'generatepress' => 'generatepress',
'kadence' => 'kadence',
'blocksy' => 'blocksy',
'oceanwp' => 'oceanwp',
'storefront' => 'storefront',
'flatsome' => 'flatsome',
'woodmart' => 'woodmart',
'divi' => 'divi',
'bricks' => 'bricks',
'avada' => 'avada',
'betheme' => 'betheme',
'enfold' => 'enfold',
];
foreach ( $families as $needle => $family ) {
if ( strpos( $haystack, $needle ) !== false ) {
return $family;
}
}
return 'other_or_custom';
}
/**
* Return broad target categories without transmitting selectors or URLs.
*/
function abtestkit_telemetry_target_types( array $targets ): array {
$types = [];
foreach ( $targets as $target ) {
$target = trim( (string) $target );
if ( $target === '' ) {
continue;
}
if ( preg_match( '#^https?://#i', $target ) || strpos( $target, '/' ) === 0 || strpos( $target, '?' ) === 0 ) {
$types[] = 'url';
} elseif ( strpos( $target, '#' ) === 0 ) {
$types[] = 'id_selector';
} elseif ( strpos( $target, '.' ) !== false ) {
$types[] = 'class_selector';
} else {
$types[] = 'selector';
}
}
return array_values( array_unique( $types ) );
}
function abtestkit_telemetry_product_type( int $post_id ): string {
if ( $post_id <= 0 || get_post_type( $post_id ) !== 'product' || ! taxonomy_exists( 'product_type' ) ) {
return '';
}
$types = wp_get_object_terms(
$post_id,
'product_type',
[ 'fields' => 'slugs' ]
);
if ( is_wp_error( $types ) || empty( $types ) ) {
return '';
}
return sanitize_key( (string) reset( $types ) );
}
/**
* Summarise saved tests for an explicitly submitted deactivation report.
*
* Deliberately omit titles, post IDs, selectors, URLs, custom code/content,
* event rows, order/customer data and exact timestamps.
*/
function abtestkit_build_deactivation_tests_snapshot(): array {
if ( ! function_exists( 'abtestkit_pt_all' ) ) {
return [
'included' => 0,
'omitted' => 0,
'items' => [],
];
}
$tests = array_values(
array_filter(
abtestkit_pt_all(),
static function( $test ): bool {
return is_array( $test );
}
)
);
$total = count( $tests );
$tests = array_slice( array_reverse( $tests ), 0, 25 );
$items = [];
$now = time();
foreach ( $tests as $test ) {
$test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
$control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
$targets = ! empty( $test['links'] ) && is_array( $test['links'] )
? array_values( $test['links'] )
: [];
$kind = isset( $test['kind'] ) ? sanitize_key( (string) $test['kind'] ) : '';
$status = isset( $test['status'] ) ? sanitize_key( (string) $test['status'] ) : '';
$version_b_mode = '';
if ( in_array( $kind, [ 'custom_css', 'custom_html' ], true ) ) {
$version_b_mode = $kind;
} elseif ( $variant_id > 0 ) {
$is_owned_shadow = (
function_exists( 'abtestkit_is_shadow_product' )
&& abtestkit_is_shadow_product( $variant_id )
) || (
function_exists( 'abtestkit_is_shadow_variant' )
&& abtestkit_is_shadow_variant( $variant_id )
);
$version_b_mode = $is_owned_shadow ? 'abtestkit_shadow' : 'existing_content';
}
$html_operations = [];
foreach ( (array) ( $test['html_changes'] ?? [] ) as $change ) {
if ( ! is_array( $change ) ) {
continue;
}
$operation = sanitize_key( (string) ( $change['operation'] ?? '' ) );
if ( in_array( $operation, [ 'replace_contents', 'insert_before', 'insert_after', 'prepend_inside', 'append_inside' ], true ) ) {
$html_operations[] = $operation;
}
}
$started_at = max( 0, (int) ( $test['started_at'] ?? 0 ) );
$finished_at = max( 0, (int) ( $test['finished_at'] ?? 0 ) );
$items[] = [
'ref' => $test_id !== '' ? substr( hash_hmac( 'sha256', $test_id, wp_salt( 'auth' ) ), 0, 12 ) : '',
'kind' => $kind,
'status' => $status,
'goal' => isset( $test['goal'] ) ? sanitize_key( (string) $test['goal'] ) : '',
'decision_mode' => isset( $test['decision_mode'] ) ? sanitize_key( (string) $test['decision_mode'] ) : '',
'decision_rule' => isset( $test['decision_rule'] ) ? sanitize_key( (string) $test['decision_rule'] ) : '',
'split' => max( 0, min( 100, (int) ( $test['split'] ?? 50 ) ) ),
'min_impressions' => max( 0, (int) ( $test['min_impressions'] ?? 0 ) ),
'min_conversions' => max( 0, (int) ( $test['min_conversions'] ?? 0 ) ),
'control_type' => $control_id > 0 ? sanitize_key( (string) get_post_type( $control_id ) ) : '',
'control_product_type' => abtestkit_telemetry_product_type( $control_id ),
'control_exists' => $control_id > 0 && get_post( $control_id ) ? 1 : 0,
'variant_type' => $variant_id > 0 ? sanitize_key( (string) get_post_type( $variant_id ) ) : '',
'variant_product_type' => abtestkit_telemetry_product_type( $variant_id ),
'variant_exists' => $variant_id > 0 && get_post( $variant_id ) ? 1 : 0,
'version_b_mode' => $version_b_mode,
'target_count' => count( $targets ),
'target_types' => abtestkit_telemetry_target_types( $targets ),
'scroll_depth' => isset( $test['scroll_depth'] ) ? max( 0, min( 100, (int) $test['scroll_depth'] ) ) : 0,
'css_scope' => isset( $test['css_scope'] ) ? sanitize_key( (string) $test['css_scope'] ) : '',
'custom_css_length' => isset( $test['custom_css'] ) ? strlen( (string) $test['custom_css'] ) : 0,
'css_marker_count' => isset( $test['css_markers'] ) && is_array( $test['css_markers'] ) ? count( $test['css_markers'] ) : 0,
'html_scope' => isset( $test['html_scope'] ) ? sanitize_key( (string) $test['html_scope'] ) : '',
'html_change_count' => isset( $test['html_changes'] ) && is_array( $test['html_changes'] ) ? count( $test['html_changes'] ) : 0,
'html_operation_types' => array_values( array_unique( $html_operations ) ),
'started' => $started_at > 0 ? 1 : 0,
'started_age_days' => $started_at > 0 ? min( 36500, (int) floor( max( 0, $now - $started_at ) / DAY_IN_SECONDS ) ) : 0,
'finished' => $finished_at > 0 ? 1 : 0,
'finished_age_days' => $finished_at > 0 ? min( 36500, (int) floor( max( 0, $now - $finished_at ) / DAY_IN_SECONDS ) ) : 0,
'auto_paused_broken' => ! empty( $test['auto_paused_broken'] ) ? 1 : 0,
'has_final_stats' => ! empty( $test['final_stats'] ) && is_array( $test['final_stats'] ) ? 1 : 0,
'has_completed_snapshot' => ! empty( $test['snapshot_versions'] ) && is_array( $test['snapshot_versions'] ) ? 1 : 0,
];
}
return [
'included' => count( $items ),
'omitted' => max( 0, $total - count( $items ) ),
'items' => $items,
];
}
/**
* Accept only non-content wizard progress fields supplied by the admin browser.
*/
function abtestkit_sanitize_deactivation_wizard_snapshot( $snapshot ): array {
if ( ! is_array( $snapshot ) ) {
return [];
}
$out = [];
$allowed_strings = [
'ui' => [ 'pt-wizard-1.2.0' ],
'step' => [ 'select_type', 'select_custom_code_type', 'select_control', 'version_b_source', 'review_versions', 'choose_conversion_type', 'set_destination_url', 'set_scroll_depth', 'select_click_targets', 'summary' ],
'furthest_step' => [ 'select_type', 'select_custom_code_type', 'select_control', 'version_b_source', 'review_versions', 'choose_conversion_type', 'set_destination_url', 'set_scroll_depth', 'select_click_targets', 'summary' ],
'kind' => [ 'page', 'post', 'product', 'reusable_section', 'custom_code' ],
'custom_code_type' => [ 'custom_css', 'custom_html' ],
'scope' => [ 'page', 'post', 'product' ],
'b_mode' => [ 'duplicate', 'existing', 'custom_css', 'custom_html' ],
'goal' => [ 'clicks', 'form', 'add_to_cart', 'purchase', 'destination_url', 'scroll_depth' ],
'click_scope' => [ 'on_test_pages', 'other_page' ],
'decision_mode' => [ 'auto', 'manual' ],
'decision_rule' => [ 'fast', 'balanced', 'precise', 'manual' ],
'result' => [ 'completed', 'abandoned' ],
];
foreach ( $allowed_strings as $key => $allowed_values ) {
$value = $key === 'ui'
? sanitize_text_field( (string) ( $snapshot[ $key ] ?? '' ) )
: sanitize_key( (string) ( $snapshot[ $key ] ?? '' ) );
if ( in_array( $value, $allowed_values, true ) ) {
$out[ $key ] = $value;
}
}
$bounded_ints = [
'step_index' => [ 0, 50 ],
'furthest_step_index' => [ 0, 50 ],
'ms' => [ 0, DAY_IN_SECONDS * 1000 ],
'age_seconds' => [ 0, YEAR_IN_SECONDS ],
'links_count' => [ 0, 100 ],
'scroll_depth' => [ 0, 100 ],
'custom_css_length' => [ 0, 1000000 ],
'css_marker_count' => [ 0, 1000 ],
'html_change_count' => [ 0, 1000 ],
];
foreach ( $bounded_ints as $key => $bounds ) {
if ( ! array_key_exists( $key, $snapshot ) || ! is_numeric( $snapshot[ $key ] ) ) {
continue;
}
$out[ $key ] = max( $bounds[0], min( $bounds[1], (int) $snapshot[ $key ] ) );
}
$boolean_fields = [
'completed',
'has_control',
'has_variant',
'has_temp_variant',
'edited_variant',
'seo_safe_existing_b',
'conversion_chosen',
'has_error',
'product_title_changed',
'product_price_changed',
'product_sale_price_changed',
'product_short_description_changed',
'product_long_description_changed',
'product_image_changed',
'product_gallery_changed',
];
foreach ( $boolean_fields as $key ) {
if ( array_key_exists( $key, $snapshot ) ) {
$out[ $key ] = in_array( $snapshot[ $key ], [ 1, '1', true ], true ) ? 1 : 0;
}
}
return $out;
}
function abtestkit_build_deactivation_snapshot( array $wizard_snapshot = [], bool $include_test_state = true ): array {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$all_plugins = function_exists( 'get_plugins' ) ? get_plugins() : [];
$active_plugins = (array) get_option( 'active_plugins', [] );
$network_active_plugins = is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) : [];
$active_plugins = array_values( array_unique( array_merge( $active_plugins, $network_active_plugins ) ) );
$theme = wp_get_theme();
$stylesheet = sanitize_key( (string) $theme->get_stylesheet() );
$template = sanitize_key( (string) $theme->get_template() );
$theme_name = sanitize_text_field( (string) $theme->get( 'Name' ) );
$builders = [
'elementor' => abtestkit_telemetry_detect_plugin_state( [ 'elementor/elementor.php' ] ),
'elementor_pro' => abtestkit_telemetry_detect_plugin_state( [ 'elementor-pro/elementor-pro.php' ] ),
'beaver_builder' => abtestkit_telemetry_detect_plugin_state( [ 'bb-plugin/fl-builder.php' ] ),
'divi_builder' => abtestkit_telemetry_detect_plugin_state( [ 'divi-builder/divi-builder.php' ] ),
'oxygen' => abtestkit_telemetry_detect_plugin_state( [ 'oxygen/functions.php' ] ),
'brizy' => abtestkit_telemetry_detect_plugin_state( [ 'brizy/brizy.php' ] ),
'breakdance' => abtestkit_telemetry_detect_plugin_state( [ 'breakdance/plugin.php' ] ),
'wpbakery' => abtestkit_telemetry_detect_plugin_state( [ 'js_composer/js_composer.php' ] ),
'visual_composer' => abtestkit_telemetry_detect_plugin_state( [ 'visualcomposer/plugin-wordpress.php', 'visualcomposer/visualcomposer.php' ] ),
'siteorigin' => abtestkit_telemetry_detect_plugin_state( [ 'siteorigin-panels/siteorigin-panels.php' ] ),
'thrive_architect' => abtestkit_telemetry_detect_plugin_state( [ 'thrive-visual-editor/thrive-visual-editor.php' ] ),
'avada_builder' => abtestkit_telemetry_detect_plugin_state( [ 'fusion-builder/fusion-builder.php' ] ),
'kadence_blocks' => abtestkit_telemetry_detect_plugin_state( [ 'kadence-blocks/kadence-blocks.php' ] ),
'spectra' => abtestkit_telemetry_detect_plugin_state( [ 'ultimate-addons-for-gutenberg/ultimate-addons-for-gutenberg.php' ] ),
'generateblocks' => abtestkit_telemetry_detect_plugin_state( [ 'generateblocks/plugin.php' ] ),
'stackable' => abtestkit_telemetry_detect_plugin_state( [ 'stackable-ultimate-gutenberg-blocks/plugin.php' ] ),
];
// Bricks is commonly a theme, not a plugin.
$builders['bricks_theme'] = [
'installed' => ( strpos( $stylesheet . ' ' . $template, 'bricks' ) !== false ) ? 1 : 0,
'active' => ( strpos( $stylesheet . ' ' . $template, 'bricks' ) !== false ) ? 1 : 0,
'network_active' => 0,
'version' => '',
];
$tests_total = 0;
$tests_running = 0;
if ( function_exists( 'abtestkit_pt_all' ) ) {
$tests = abtestkit_pt_all();
if ( is_array( $tests ) ) {
$tests_total = count( $tests );
foreach ( $tests as $test ) {
if ( is_array( $test ) && ( $test['status'] ?? '' ) === 'running' ) {
$tests_running++;
}
}
}
}
$tests_created = $tests_total;
if ( function_exists( 'abtestkit_review_prompt_get_state' ) ) {
$review_prompt_state = abtestkit_review_prompt_get_state();
$tests_created = max(
$tests_created,
(int) ( $review_prompt_state['tests_created'] ?? 0 )
);
}
$snapshot = [
'snapshot_version' => $include_test_state ? 2 : 1,
'wp_version' => get_bloginfo( 'version' ),
'php_version' => PHP_VERSION,
'environment' => ( wp_get_environment_type() ?: 'production' ),
'is_multisite' => is_multisite() ? 1 : 0,
'site_language' => sanitize_text_field( (string) get_locale() ),
'memory_limit' => sanitize_text_field( (string) WP_MEMORY_LIMIT ),
'wp_debug' => ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ? 1 : 0,
'theme' => [
'name' => $theme_name,
'stylesheet' => $stylesheet,
'template' => $template,
'family' => abtestkit_telemetry_theme_family( $stylesheet, $template, $theme_name ),
'version' => sanitize_text_field( (string) $theme->get( 'Version' ) ),
'is_child' => ( $stylesheet !== $template ) ? 1 : 0,
],
'plugins' => [
'installed_count' => count( $all_plugins ),
'active_count' => count( $active_plugins ),
'woocommerce' => abtestkit_telemetry_detect_plugin_state( [ 'woocommerce/woocommerce.php' ] ),
'subscriptions' => abtestkit_telemetry_detect_plugin_state( [ 'woocommerce-subscriptions/woocommerce-subscriptions.php' ] ),
'acf' => abtestkit_telemetry_detect_plugin_state( [ 'advanced-custom-fields/acf.php', 'advanced-custom-fields-pro/acf.php' ] ),
],
'builders' => $builders,
'content_counts' => [
'page' => abtestkit_telemetry_count_post_type( 'page' ),
'post' => abtestkit_telemetry_count_post_type( 'post' ),
'product' => abtestkit_telemetry_count_post_type( 'product' ),
'product_variation' => abtestkit_telemetry_count_post_type( 'product_variation' ),
],
'abtestkit_usage' => [
'tests_total' => (int) $tests_total,
'tests_running' => (int) $tests_running,
'tests_created' => (int) $tests_created,
],
];
if ( $include_test_state ) {
$snapshot['tests'] = abtestkit_build_deactivation_tests_snapshot();
$snapshot['wizard'] = abtestkit_sanitize_deactivation_wizard_snapshot( $wizard_snapshot );
}
return $snapshot;
}
/**
* Event: plugin deactivated feedback from plugins.php.
* This is NOT full telemetry opt-in.
* We only send when the user explicitly submits feedback (not skip/empty).
*/
function abtestkit_telemetry_track_plugin_delete_reason( string $reason, string $detail = '', string $detail_tag = '', string $area = '', array $wizard_snapshot = [] ): void {
$reason = sanitize_key( (string) $reason );
$detail = substr( sanitize_textarea_field( (string) $detail ), 0, 1000 );
$detail_tag = sanitize_key( (string) $detail_tag );
$area = sanitize_key( (string) $area );
// Only treat "Send feedback" as opt-in for this one event.
if ( $reason === '' || $reason === 'skip' ) {
return;
}
$deactivation = function_exists( 'abtestkit_connected_deactivation_prepare' )
? abtestkit_connected_deactivation_prepare()
: null;
$feedback_data = [
'kind' => 'deactivate',
'reason' => $reason,
'detail_tag' => $detail_tag,
'area' => $area,
'detail' => $detail,
'snapshot' => abtestkit_build_deactivation_snapshot( $wizard_snapshot ),
];
if ( is_array( $deactivation ) ) {
$feedback_data['deactivation_id'] = (string) $deactivation['event']['event_id'];
}
abtestkit_send_explicit_feedback_event( 'plugin_deactivated', $feedback_data, true );
}
function abtestkit_build_compatibility_help_snapshot(): array {
$snapshot = function_exists( 'abtestkit_build_deactivation_snapshot' )
? abtestkit_build_deactivation_snapshot( [], false )
: [];
$snapshot['cache_plugins'] = [
'litespeed_cache' => abtestkit_telemetry_detect_plugin_state( [ 'litespeed-cache/litespeed-cache.php' ] ),
'wp_rocket' => abtestkit_telemetry_detect_plugin_state( [ 'wp-rocket/wp-rocket.php' ] ),
'w3_total_cache' => abtestkit_telemetry_detect_plugin_state( [ 'w3-total-cache/w3-total-cache.php' ] ),
'wp_super_cache' => abtestkit_telemetry_detect_plugin_state( [ 'wp-super-cache/wp-cache.php' ] ),
'autoptimize' => abtestkit_telemetry_detect_plugin_state( [ 'autoptimize/autoptimize.php' ] ),
'perfmatters' => abtestkit_telemetry_detect_plugin_state( [ 'perfmatters/perfmatters.php' ] ),
'sg_optimizer' => abtestkit_telemetry_detect_plugin_state( [ 'sg-cachepress/sg-cachepress.php' ] ),
'breeze' => abtestkit_telemetry_detect_plugin_state( [ 'breeze/breeze.php' ] ),
'cache_enabler' => abtestkit_telemetry_detect_plugin_state( [ 'cache-enabler/cache-enabler.php' ] ),
'wp_fastest_cache' => abtestkit_telemetry_detect_plugin_state( [ 'wp-fastest-cache/wpFastestCache.php' ] ),
'nitropack' => abtestkit_telemetry_detect_plugin_state( [ 'nitropack/main.php' ] ),
];
return $snapshot;
}
function abtestkit_telemetry_track_compatibility_help_request( string $test_id, string $message = '', string $source = 'performance_page' ): bool {
$test_id = sanitize_text_field( (string) $test_id );
$message = substr( sanitize_textarea_field( (string) $message ), 0, 1500 );
$source = sanitize_key( (string) $source );
if ( $source === '' ) {
$source = 'performance_page';
}
if ( $test_id === '' || ! function_exists( 'abtestkit_pt_get' ) ) {
return false;
}
$test = abtestkit_pt_get( $test_id );
if ( ! is_array( $test ) ) {
return false;
}
$support_report = function_exists( 'abtestkit_hosted_submit_support_report' )
? abtestkit_hosted_submit_support_report( $test_id, $message, $source )
: new WP_Error( 'abtestkit_hosted_support_unavailable' );
if ( is_wp_error( $support_report ) ) {
return false;
}
$control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
$targets = function_exists( 'abtestkit_pt_click_targets_for_test' ) ? abtestkit_pt_click_targets_for_test( $test ) : [];
$stats = function_exists( 'abtestkit_pt_stats_for_snapshot' ) ? abtestkit_pt_stats_for_snapshot( $test_id ) : [];
$target_types = [];
foreach ( (array) $targets as $target ) {
$target = (string) $target;
if ( preg_match( '#^https?://#i', $target ) || strpos( $target, '/' ) === 0 || strpos( $target, '?' ) === 0 ) {
$target_types[] = 'url';
} elseif ( strpos( $target, '#' ) === 0 ) {
$target_types[] = 'id_selector';
} elseif ( strpos( $target, '.' ) !== false ) {
$target_types[] = 'class_selector';
} else {
$target_types[] = 'selector';
}
}
$target_types = array_values( array_unique( $target_types ) );
abtestkit_send_explicit_feedback_event(
'compatibility_help_request',
[
'kind' => 'compatibility_help',
'source' => $source,
'message' => $message,
'support_report_ref' => (string) $support_report['report_ref'],
'test' => [
'id' => $test_id,
'kind' => isset( $test['kind'] ) ? sanitize_key( (string) $test['kind'] ) : '',
'status' => isset( $test['status'] ) ? sanitize_key( (string) $test['status'] ) : '',
'goal' => isset( $test['goal'] ) ? sanitize_key( (string) $test['goal'] ) : '',
'decision_mode' => isset( $test['decision_mode'] ) ? sanitize_key( (string) $test['decision_mode'] ) : '',
'decision_rule' => isset( $test['decision_rule'] ) ? sanitize_key( (string) $test['decision_rule'] ) : '',
'split' => isset( $test['split'] ) ? (int) $test['split'] : 50,
'control_post_type' => $control_id > 0 ? sanitize_key( (string) get_post_type( $control_id ) ) : '',
'variant_post_type' => $variant_id > 0 ? sanitize_key( (string) get_post_type( $variant_id ) ) : '',
'has_control' => $control_id > 0 ? 1 : 0,
'has_variant' => $variant_id > 0 ? 1 : 0,
'click_target_count' => count( (array) $targets ),
'click_target_types' => $target_types,
'stats' => $stats,
],
'snapshot' => abtestkit_build_compatibility_help_snapshot(),
],
true
);
return true;
}
// ───────────────────────────────────────────────────────────
// One-time redirect + "onboarding complete" flag (hardened)
// ───────────────────────────────────────────────────────────
register_activation_hook( __FILE__, 'abtestkit_activate_onboarding' );
function abtestkit_activate_onboarding() {
if ( function_exists( 'abtestkit_connected_deactivation_reset' ) ) {
abtestkit_connected_deactivation_reset();
}
// Create the "done" flag if missing
if ( get_option( 'abtestkit_onboarding_done', null ) === null ) {
add_option( 'abtestkit_onboarding_done', '0' );
}
// Set the one-time redirect flag
update_option( 'abtestkit_do_activation_redirect', '1', true );
}
/**
* Some activation paths don’t reliably run the activation callback in the
* same request pipeline that lands you back in wp-admin. This ensures the
* flag is still set right after the specific plugin is activated.
*/
add_action( 'activated_plugin', function( $plugin ) {
if ( $plugin === plugin_basename( __FILE__ ) ) {
update_option( 'abtestkit_do_activation_redirect', '1', true );
}
}, 10, 1 );
/**
* Perform the one-time redirect as soon as we’re safely in admin.
*/
add_action( 'admin_init', function () {
// Only once
if ( '1' !== get_option( 'abtestkit_do_activation_redirect' ) ) {
return;
}
// Clear the flag immediately to avoid loops
delete_option( 'abtestkit_do_activation_redirect' );
// Don’t redirect in these contexts
if ( wp_doing_ajax() ) return;
if ( is_network_admin() ) return; // Multisite network screens
// If we're on the bulk activate action, bail early. Core has already handled nonce checks.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check of a core GET param.
if ( isset( $_GET['activate-multi'] ) ) {
return;
}
// Only redirect admins who can actually view the page
if ( ! current_user_can( 'manage_options' ) ) return;
// If onboarding is already complete (or tests already exist), skip it.
$done = ( '1' === get_option( 'abtestkit_onboarding_done' ) );
$tests = abtestkit_pt_all();
$has_tests = is_array( $tests ) && ! empty( $tests );
if ( $done || $has_tests ) {
wp_safe_redirect( admin_url( 'admin.php?page=abtestkit-dashboard' ) );
exit;
}
// Otherwise, show onboarding once.
wp_safe_redirect( admin_url( 'admin.php?page=abtestkit-get-started' ) );
exit;
} );
// ───────────────────────────────────────────────────────────
// Hidden admin pages
// ───────────────────────────────────────────────────────────
add_action( 'admin_menu', function () {
// "Create new test +" item under main abtestkit menu → points to the PT wizard
add_submenu_page(
'abtestkit-dashboard', // parent = main abtestkit menu
__( 'Create New Test – abtestkit', 'abtestkit' ),
__( 'Create new test +', 'abtestkit' ),
'manage_options',
'abtestkit-pt-wizard',
'abtestkit_render_pt_wizard_page'
);
// Page Test Wizard (hidden – direct URL only)
add_submenu_page(
null, // hidden from menus
__( 'Create Page Test', 'abtestkit' ),
__( 'Create Page Test', 'abtestkit' ),
'manage_options',
'abtestkit-pt-wizard',
'abtestkit_render_pt_wizard_page'
);
// Onboarding "Get started" screen (hidden – direct URL only)
add_submenu_page(
null,
__( 'Welcome to abtestkit', 'abtestkit' ),
__( 'Welcome to abtestkit', 'abtestkit' ),
'manage_options',
'abtestkit-get-started',
'abtestkit_render_onboarding_page'
);
// Test Performance page (hidden – direct URL only)
add_submenu_page(
null,
__( 'Test Performance', 'abtestkit' ),
__( 'Test Performance', 'abtestkit' ),
'manage_options',
'abtestkit-test-performance',
'abtestkit_render_test_performance_page'
);
} );
function abtestkit_render_onboarding_page() {
echo '
';
}
// ───────────────────────────────────────────────────────────
// Enqueue wizard assets on that page
// ───────────────────────────────────────────────────────────
add_action( 'admin_enqueue_scripts', function ( $hook ) {
if ( 'admin_page_abtestkit-get-started' !== $hook ) {
return;
}
// Core deps
wp_enqueue_script( 'wp-element' );
wp_enqueue_script( 'wp-components' );
wp_enqueue_script( 'wp-api-fetch' );
wp_enqueue_style( 'wp-components' );
// Onboarding wizard JS
wp_enqueue_script(
'abtestkit-onboarding',
plugins_url( 'assets/js/onboarding.js', __FILE__ ),
array( 'wp-element', 'wp-components', 'wp-api-fetch' ),
'1.7.2',
true
);
wp_localize_script(
'abtestkit-onboarding',
'ABTESTKIT_ONBOARDING',
array(
'nonce' => wp_create_nonce( 'wp_rest' ),
'rest' => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
'done' => (bool) get_option( 'abtestkit_onboarding_done' ),
'site' => get_bloginfo( 'name' ),
'canCreateOrLaunch' => abtestkit_connected_platform_can_create_or_launch(),
'links' => array(
'dashboard' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
'createUrl' => abtestkit_connected_platform_can_create_or_launch() ? admin_url( 'admin.php?page=abtestkit-pt-wizard' ) : abtestkit_hosted_admin_url(),
'scheduler' => esc_url_raw( 'https://calendar.app.google/rpkHjB8Q9aCqS5rz7' ),
'new_post' => admin_url( 'post-new.php' ),
'blocks' => admin_url( 'edit.php?post_type=page' ),
'settings' => admin_url( 'options-general.php?page=abtestkit-settings' ),
'plugins' => admin_url( 'plugins.php' ),
),
// Image assets for the 7-step onboarding wizard
'assets' => array(
// Page 1
'getStarted' => plugins_url( 'assets/img/onboarding-get-started.png', __FILE__ ),
// Page 2
'selectTestType' => plugins_url( 'assets/img/onboarding-select-test-type.png', __FILE__ ),
// Page 3 – Pages
'pagesVersionBSource' => plugins_url( 'assets/img/onboarding-page-version-b-source.png', __FILE__ ),
'pagesReviewVersions' => plugins_url( 'assets/img/onboarding-review-versions.png', __FILE__ ),
'pagesReviewVersions2' => plugins_url( 'assets/img/onboarding-review-versions-2.png', __FILE__ ),
// Page 4 – Products
'productsSelectProduct' => plugins_url( 'assets/img/onboarding-select-product.png', __FILE__ ),
'productsReviewVersions' => plugins_url( 'assets/img/onboarding-product-review-versions.png', __FILE__ ),
// Page 5 – Conversions & Clicks
'conversionType' => plugins_url( 'assets/img/onboarding-choose-conversion-type.png', __FILE__ ),
'clicksSelectTarget' => plugins_url( 'assets/img/onboarding-clicks-select-target-in-preview.png', __FILE__ ),
'clicksClickTarget' => plugins_url( 'assets/img/onboarding-clicks-click-a-target.png', __FILE__ ),
'clicksSelectAnother' => plugins_url( 'assets/img/onboarding-clicks-select-another.png', __FILE__ ),
'clicksOnAnotherPage' => plugins_url( 'assets/img/onboarding-clicks-on-another-page.png', __FILE__ ),
'clicksSelectTargetDifferentPage' => plugins_url( 'assets/img/onboarding-select-target-different-page.png', __FILE__ ),
// Page 6 – Summary
'summary' => plugins_url( 'assets/img/onboarding-summary.png', __FILE__ ),
// Page 7 – Dashboard / How tests work
'dashboard' => plugins_url( 'assets/img/onboarding-dashboard.png', __FILE__ ),
),
)
);
} );
// --- Custom CSS test helpers ---
// These must be available globally because the dashboard, health checks,
// frontend rendering, and REST endpoints can all read Custom CSS test data.
if ( ! function_exists( 'abtestkit_sanitize_custom_css_input' ) ) {
function abtestkit_sanitize_custom_css_input( $css ) : string {
$css = is_string( $css ) ? wp_unslash( $css ) : '';
$css = str_replace( "\0", '', $css );
// Remove HTML/script/style wrappers. The saved value should be stylesheet rules only.
$css = preg_replace( '##is', '', $css );
$css = preg_replace( '#?style\b[^>]*>#i', '', $css );
$css = preg_replace( '/<\?(?:php)?/i', '', $css );
// Remove high-risk CSS features while keeping normal override CSS intact.
$css = preg_replace( '/@import\b[^;]+;?/i', '', $css );
$css = preg_replace( '/expression\s*\(/i', '', $css );
$css = preg_replace( '/behavior\s*:/i', 'x-abtestkit-removed:', $css );
$css = preg_replace( '/-moz-binding\s*:/i', 'x-abtestkit-removed:', $css );
$css = preg_replace( '/url\s*\(\s*([\'"]?)\s*(?:javascript|vbscript|data):[^)]*\)/i', 'url($1#)', $css );
// Keep tabs/newlines, remove other control characters.
$css = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $css );
return trim( (string) $css );
}
}
if ( ! function_exists( 'abtestkit_custom_css_marker_class_from_label' ) ) {
function abtestkit_custom_css_marker_class_from_label( string $label ) : string {
$slug = sanitize_title( $label );
if ( $slug === '' ) {
$slug = 'marker';
}
return sanitize_html_class( 'abtestkit-marker-' . $slug );
}
}
if ( ! function_exists( 'abtestkit_sanitize_custom_css_markers' ) ) {
function abtestkit_sanitize_custom_css_markers( $markers ) : array {
if ( ! is_array( $markers ) ) {
return [];
}
$clean = [];
foreach ( $markers as $marker ) {
if ( ! is_array( $marker ) ) {
continue;
}
$label = isset( $marker['label'] ) ? sanitize_text_field( wp_unslash( (string) $marker['label'] ) ) : '';
$selector = isset( $marker['selector'] ) ? sanitize_text_field( wp_unslash( (string) $marker['selector'] ) ) : '';
$selector = trim( str_replace( [ '<', '>', '`' ], '', $selector ) );
if ( $label === '' || $selector === '' ) {
continue;
}
$class_name = isset( $marker['class_name'] )
? sanitize_html_class( wp_unslash( (string) $marker['class_name'] ) )
: '';
if ( $class_name === '' || strpos( $class_name, 'abtestkit-marker-' ) !== 0 ) {
$class_name = abtestkit_custom_css_marker_class_from_label( $label );
}
$clean[] = [
'label' => $label,
'selector' => $selector,
'class_name' => $class_name,
];
}
return array_values( $clean );
}
}
if ( ! function_exists( 'abtestkit_custom_css_data_for_test' ) ) {
function abtestkit_custom_css_data_for_test( array $test ) : array {
return [
'css_scope' => isset( $test['css_scope'] ) ? sanitize_key( (string) $test['css_scope'] ) : '',
'custom_css' => isset( $test['custom_css'] ) ? abtestkit_sanitize_custom_css_input( $test['custom_css'] ) : '',
'css_markers' => isset( $test['css_markers'] ) ? abtestkit_sanitize_custom_css_markers( $test['css_markers'] ) : [],
];
}
}
// --- Custom HTML test helpers ---
if ( ! function_exists( 'abtestkit_sanitize_custom_html_changes' ) ) {
function abtestkit_sanitize_custom_html_changes( $changes ) : array {
if ( ! is_array( $changes ) ) {
return [];
}
$allowed_html = wp_kses_allowed_html( 'post' );
$extra_tags = [
'section', 'article', 'header', 'footer', 'main', 'nav',
'button', 'picture', 'source', 'small', 'mark', 'time',
'svg', 'g', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon',
];
$common_attributes = [
'id' => true,
'class' => true,
'style' => true,
'title' => true,
'role' => true,
'tabindex' => true,
'aria-label' => true,
'aria-hidden' => true,
'aria-live' => true,
'aria-*' => true,
'data-*' => true,
];
foreach ( $allowed_html as $tag => $attributes ) {
$allowed_html[ $tag ] = array_merge(
is_array( $attributes ) ? $attributes : [],
$common_attributes
);
}
foreach ( $extra_tags as $tag ) {
if ( ! isset( $allowed_html[ $tag ] ) || ! is_array( $allowed_html[ $tag ] ) ) {
$allowed_html[ $tag ] = [];
}
$allowed_html[ $tag ] = array_merge( $allowed_html[ $tag ], $common_attributes );
}
$allowed_html['button'] = array_merge(
$allowed_html['button'],
[
'type' => true,
'name' => true,
'value' => true,
'disabled' => true,
]
);
$allowed_html['source'] = array_merge(
$allowed_html['source'],
[
'src' => true,
'srcset' => true,
'sizes' => true,
'type' => true,
'media' => true,
]
);
$allowed_html['svg'] = array_merge(
$allowed_html['svg'],
[
'xmlns' => true,
'viewbox' => true,
'width' => true,
'height' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'focusable' => true,
]
);
$svg_shape_attributes = [
'd' => true,
'x' => true,
'y' => true,
'x1' => true,
'x2' => true,
'y1' => true,
'y2' => true,
'cx' => true,
'cy' => true,
'r' => true,
'rx' => true,
'ry' => true,
'width' => true,
'height' => true,
'points' => true,
'fill' => true,
'fill-rule' => true,
'clip-rule' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'transform' => true,
];
foreach ( [ 'g', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon' ] as $svg_tag ) {
$allowed_html[ $svg_tag ] = array_merge(
$allowed_html[ $svg_tag ],
$svg_shape_attributes
);
}
$clean = [];
foreach ( array_slice( $changes, 0, 20 ) as $change ) {
if ( ! is_array( $change ) ) {
continue;
}
$label = isset( $change['label'] )
? substr( sanitize_text_field( wp_unslash( (string) $change['label'] ) ), 0, 120 )
: '';
$selector = isset( $change['selector'] )
? sanitize_text_field( wp_unslash( (string) $change['selector'] ) )
: '';
$selector = trim( str_replace( [ "\0", '<', '`' ], '', $selector ) );
$selector = substr( $selector, 0, 1000 );
$operation = isset( $change['operation'] )
? sanitize_key( wp_unslash( (string) $change['operation'] ) )
: 'replace_contents';
if ( ! in_array( $operation, [ 'replace_contents', 'insert_before', 'insert_after', 'prepend_inside', 'append_inside' ], true ) ) {
$operation = 'replace_contents';
}
$match_mode = isset( $change['match_mode'] )
? sanitize_key( wp_unslash( (string) $change['match_mode'] ) )
: 'all';
if ( ! in_array( $match_mode, [ 'first', 'all' ], true ) ) {
$match_mode = 'all';
}
$html = isset( $change['html'] ) && is_string( $change['html'] )
? wp_unslash( $change['html'] )
: '';
$html = str_replace( "\0", '', $html );
$intentionally_empty = trim( $html ) === '';
$html = preg_replace( '##is', '', $html );
$html = preg_replace( '##is', '', $html );
$html = preg_replace( '#<(?:iframe|object|embed)\b[^>]*>.*?(?:iframe|object|embed)>#is', '', $html );
$html = preg_replace( '#<(?:iframe|object|embed)\b[^>]*/?>#is', '', $html );
if ( preg_match_all( '#<\s*([a-z][a-z0-9-]*-[a-z0-9-]+)\b#i', $html, $custom_tag_matches ) ) {
foreach ( array_unique( array_map( 'strtolower', $custom_tag_matches[1] ) ) as $custom_tag ) {
if ( in_array( $custom_tag, [ 'script', 'style', 'iframe', 'object', 'embed' ], true ) ) {
continue;
}
$allowed_html[ $custom_tag ] = $common_attributes;
}
}
$html = wp_kses( $html, $allowed_html );
$html = trim( (string) $html );
if ( $selector === '' || ( ! $intentionally_empty && $html === '' ) ) {
continue;
}
if ( $label === '' ) {
$label = __( 'Selected element', 'abtestkit' );
}
$clean[] = [
'label' => $label,
'selector' => $selector,
'operation' => $operation,
'match_mode' => $match_mode,
'html' => $html,
];
}
return array_values( $clean );
}
}
if ( ! function_exists( 'abtestkit_custom_html_data_for_test' ) ) {
function abtestkit_custom_html_data_for_test( array $test ) : array {
return [
'html_scope' => isset( $test['html_scope'] ) ? sanitize_key( (string) $test['html_scope'] ) : '',
'html_changes' => isset( $test['html_changes'] ) ? abtestkit_sanitize_custom_html_changes( $test['html_changes'] ) : [],
];
}
}
/** Return only the saved setup needed to refill the administrator's wizard. */
function abtestkit_pt_edit_setup( array $test ) {
$kind = (string) ( $test['kind'] ?? 'page' );
if (
! in_array( $kind, [ 'page', 'post', 'product', 'reusable_section', 'custom_css', 'custom_html', 'custom_javascript' ], true )
|| ! in_array( (string) ( $test['status'] ?? '' ), [ 'running', 'paused', 'draft' ], true )
|| ! empty( $test['winner'] )
|| ! empty( $test['winner_applied_at'] )
) {
return new WP_Error( 'edit_unavailable', __( 'This test can no longer be edited.', 'abtestkit' ) );
}
$control_id = (int) ( $test['control_id'] ?? 0 );
$control = $control_id ? get_post( $control_id ) : null;
$expected_type = in_array( $kind, [ 'custom_css', 'custom_html', 'custom_javascript' ], true )
? (string) ( $test[ $kind === 'custom_css' ? 'css_scope' : ( $kind === 'custom_html' ? 'html_scope' : 'javascript_scope' ) ] ?? '' )
: ( $kind === 'reusable_section' ? 'page' : $kind );
if ( $expected_type === '' && $control ) {
$expected_type = $control->post_type;
}
if ( ! $control || $control->post_type !== $expected_type ) {
return new WP_Error( 'edit_missing_control', __( 'Version A is no longer available.', 'abtestkit' ) );
}
$entity = static function ( WP_Post $post ) : array {
$id = (int) $post->ID;
$url = (string) get_permalink( $id );
$item = [
'id' => $id,
'title' => get_the_title( $id ),
'post_type' => $post->post_type,
'preview_url' => $url ? add_query_arg( 'abtestkit_preview', '1', $url ) : '',
'permalink' => $url,
];
if ( $post->post_type === 'product' && function_exists( 'wc_get_product' ) ) {
$product = wc_get_product( $id );
if ( $product ) {
$image_id = (int) $product->get_image_id();
$gallery_ids = $product->get_gallery_image_ids();
$item['product'] = [
'price' => $product->get_price(),
'regular_price' => $product->get_regular_price(),
'sale_price' => $product->get_sale_price(),
'short_description' => $product->get_short_description(),
'description' => $product->get_description(),
'image_id' => $image_id,
'image_url' => $image_id ? wp_get_attachment_image_url( $image_id, 'full' ) : '',
'gallery_ids' => $gallery_ids,
'gallery_urls' => array_values( array_filter( array_map( static function ( $image ) {
return wp_get_attachment_image_url( $image, 'full' );
}, $gallery_ids ) ) ),
];
}
}
return $item;
};
$variant_id = (int) ( $test['variant_id'] ?? 0 );
$variant = $variant_id ? get_post( $variant_id ) : null;
if ( ! in_array( $kind, [ 'custom_css', 'custom_html', 'custom_javascript' ], true ) ) {
if ( ! $variant || $variant->post_type !== $expected_type ) {
return new WP_Error( 'edit_missing_variant', __( 'Version B is no longer available.', 'abtestkit' ) );
}
if ( $kind === 'product' && ( ! abtestkit_is_shadow_product( $variant_id ) || (int) get_post_meta( $variant_id, '_abtestkit_shadow_of', true ) !== $control_id ) ) {
return new WP_Error( 'edit_invalid_variant', __( 'Version B does not belong to this product test.', 'abtestkit' ) );
}
}
$javascript = '';
if ( $kind === 'custom_javascript' ) {
$record = abtestkit_custom_javascript_code_get( $test['javascript_code_ref'] ?? '', $test['javascript_code_hash'] ?? '' );
if ( is_wp_error( $record ) ) {
return new WP_Error( 'edit_missing_javascript', __( 'The saved JavaScript is unavailable.', 'abtestkit' ) );
}
$javascript = (string) $record['source'];
}
$is_shadow = $variant && abtestkit_is_shadow_variant( $variant_id );
return [
'test_id' => (string) $test['id'],
'kind' => $kind,
'scope' => $expected_type,
'control' => $entity( $control ),
'variant' => $variant ? $entity( $variant ) : null,
'b_mode' => $is_shadow ? 'duplicate' : 'existing',
'seo_safe_existing_b' => $variant && ! $is_shadow ? (string) get_post_meta( $variant_id, '_abtestkit_variant_test_id', true ) === (string) $test['id'] : true,
'title' => (string) ( $test['title'] ?? '' ),
'goal' => (string) ( $test['goal'] ?? '' ),
'links' => array_values( (array) ( $test['links'] ?? [] ) ),
'scroll_depth' => (int) ( $test['scroll_depth'] ?? 50 ),
'decision_rule' => (string) ( $test['decision_rule'] ?? 'balanced' ),
'commerce' => (array) ( $test['commerce'] ?? [] ),
'custom_css' => (string) ( $test['custom_css'] ?? '' ),
'css_markers' => (array) ( $test['css_markers'] ?? [] ),
'html_changes' => (array) ( $test['html_changes'] ?? [] ),
'custom_javascript' => $javascript,
];
}
/** Complete a revised test only after its replacement has been saved. */
function abtestkit_pt_finish_edit( array $previous, array $replacement ) : bool {
$saved = abtestkit_pt_get( (string) $replacement['id'] );
if (
! is_array( $saved )
|| (int) ( $saved['control_id'] ?? 0 ) !== (int) $replacement['control_id']
|| (string) ( $saved['kind'] ?? '' ) !== (string) $replacement['kind']
) {
return false;
}
global $wpdb;
$previous_id = (string) $previous['id'];
$old_variant = (int) ( $previous['variant_id'] ?? 0 );
$new_variant = (int) ( $replacement['variant_id'] ?? 0 );
abtestkit_pt_delete( $previous_id );
// An already-started visitor request may have recorded one last old-run event.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Remove only this retired run's events, then invalidate every affected cache below.
$wpdb->delete( ABTESTKIT_EVENTS_TABLE, [ 'ab_test_id' => $previous_id ], [ '%s' ] );
$post_ids = [ (int) ( $previous['control_id'] ?? 0 ) ];
if ( (string) ( $previous['kind'] ?? 'page' ) !== 'product' && $old_variant > 0 ) {
$post_ids[] = $old_variant;
}
$post_ids = array_values( array_unique( array_filter( array_map( 'absint', $post_ids ) ) ) );
foreach ( $post_ids as $post_id ) {
wp_cache_delete( "stats:$post_id:$previous_id", 'abtestkit_stats' );
wp_cache_delete( "stats_multi:$post_id", 'abtestkit_stats' );
}
abtestkit_pt_flush_test_caches( $previous_id );
if (
$old_variant > 0 && $old_variant !== $new_variant
&& abtestkit_is_shadow_variant( $old_variant )
&& (int) get_post_meta( $old_variant, '_abtestkit_shadow_of', true ) === (int) ( $previous['control_id'] ?? 0 )
) {
abtestkit_delete_owned_shadow_variant( $old_variant );
}
return true;
}
// ───────────────────────────────────────────────────────────
// Minimal REST endpoint to mark onboarding complete.
// ───────────────────────────────────────────────────────────
add_action( 'rest_api_init', function () {
register_rest_route(
'abtestkit/v1',
'/onboarding',
array(
array(
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
$done = (bool) $req->get_param( 'done' );
update_option( 'abtestkit_onboarding_done', $done ? '1' : '0' );
return rest_ensure_response(
array(
'ok' => true,
'done' => $done,
)
);
},
),
)
);
// --- Page Test Wizard: search pages/products ---
register_rest_route(
'abtestkit/v1',
'/pt/pages',
[
'methods' => 'GET',
'permission_callback' => function () { return current_user_can( 'manage_options' ); },
'callback' => function ( WP_REST_Request $req ) {
$q = sanitize_text_field( (string) $req->get_param( 'q' ) );
$type = sanitize_key( (string) $req->get_param( 'type' ) );
// Allow pages, posts, WooCommerce products, or reusable sections.
// Reusable Section Test v1 targets a WP/Elementor page rendered through a shortcode.
$post_type = in_array( $type, [ 'page', 'post', 'product' ], true ) ? $type : 'page';
if ( $type === 'reusable_section' ) {
$post_type = 'page';
}
// If we're searching products, exclude any Version B posts used in tests
$post__not_in = [];
if ( $post_type === 'product' ) {
foreach ( abtestkit_pt_all() as $t ) {
if ( ! empty( $t['variant_id'] ) ) {
$post__not_in[] = (int) $t['variant_id'];
}
}
$post__not_in = array_values( array_unique( array_filter( $post__not_in ) ) );
}
$query_args = [
'post_type' => $post_type,
'posts_per_page' => 200,
'post_status' => [ 'publish', 'draft', 'pending', 'future', 'private' ],
'no_found_rows' => true,
'fields' => 'ids',
'post__not_in' => $post__not_in,
'ignore_sticky_posts' => true,
'orderby' => 'date',
'order' => 'DESC',
];
// Only apply a search query when the user has actually typed one.
// On some large/live sites, passing an empty `s` can produce odd results.
if ( $q !== '' ) {
$query_args['s'] = $q;
}
$query = new WP_Query( $query_args );
$items = [];
foreach ( (array) $query->posts as $pid ) {
$actual_post_type = get_post_type( $pid );
// Hard guard against anything leaking into the results from filters/plugins.
if ( $actual_post_type !== $post_type ) {
continue;
}
$preview_url = '';
$permalink = (string) get_permalink( $pid );
if ( $post_type === 'product' ) {
if ( $permalink !== '' ) {
$preview_url = add_query_arg( 'abtestkit_preview', '1', $permalink );
}
} else {
if ( function_exists( 'get_preview_post_link' ) ) {
$preview_url = (string) get_preview_post_link( $pid );
}
if ( $preview_url === '' ) {
$preview_url = $permalink;
}
if ( $preview_url !== '' ) {
$preview_url = add_query_arg( 'abtestkit_preview', '1', $preview_url );
}
}
$item = [
'id' => (int) $pid,
'title' => get_the_title( $pid ),
'status' => get_post_status( $pid ),
'post_type' => (string) $actual_post_type,
'preview_url' => $preview_url,
'permalink' => $permalink,
// display-ready localised date (matches WP list tables)
'date' => get_the_date( 'Y/m/d', $pid ),
// raw ISO for any future sorting/formatting if you want it
'date_iso' => get_post_field( 'post_date', $pid ),
];
// Check if this page/product is already part of a running test.
// For reusable-section selection, also block source pages that are already
// embedded inside another running page/product/post test.
$conflict_kind = ( $type === 'reusable_section' ) ? 'reusable_section' : $post_type;
$conflicts = abtestkit_pt_conflicts_for_pages( (int) $pid, 0, '', $conflict_kind );
$in_running_test = ! empty( $conflicts );
$item['in_running_test'] = $in_running_test;
$item['disabled'] = $in_running_test;
$item['disabled_reason'] = $in_running_test ? 'conflict_running' : '';
$item['conflicts'] = $conflicts;
// If listing WooCommerce products, include basic product meta
if ( $post_type === 'product' && function_exists( 'wc_get_product' ) ) {
$product = wc_get_product( $pid );
if ( $product ) {
$image_id = $product->get_image_id();
$gallery_ids = $product->get_gallery_image_ids();
$image_url = $image_id ? wp_get_attachment_image_url( $image_id, 'full' ) : '';
$gallery_urls = [];
if ( is_array( $gallery_ids ) && $gallery_ids ) {
foreach ( $gallery_ids as $gid ) {
$url = wp_get_attachment_image_url( $gid, 'full' );
if ( $url ) {
$gallery_urls[] = $url;
}
}
}
$item['product'] = [
'price' => $product->get_price(),
'regular_price' => $product->get_regular_price(),
'sale_price' => $product->get_sale_price(),
'short_description' => $product->get_short_description(),
'description' => $product->get_description(),
'image_id' => $image_id,
'image_url' => $image_url,
'gallery_ids' => $gallery_ids,
'gallery_urls' => $gallery_urls,
];
// Also expose a simple Category label for the wizard list
$cats = get_the_terms( $pid, 'product_cat' );
$cat_names = [];
if ( is_array( $cats ) ) {
foreach ( $cats as $cat ) {
if ( isset( $cat->name ) && $cat->name !== '' ) {
$cat_names[] = $cat->name;
}
}
}
if ( $cat_names ) {
// Top-level field so JS can use p.category directly
$item['category'] = implode( ', ', $cat_names );
}
}
}
$items[] = $item;
}
return rest_ensure_response( [ 'ok' => true, 'pages' => $items ] );
},
]
);
// --- Commerce goals: search products and lazily load variation choices ---
register_rest_route(
'abtestkit/v1',
'/pt/commerce-products',
[
'methods' => 'GET',
'permission_callback' => function () { return current_user_can( 'manage_options' ); },
'callback' => function ( WP_REST_Request $req ) {
if ( ! function_exists( 'wc_get_product' ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'woocommerce_unavailable',
'products' => [],
]
);
}
$product_id = absint( $req->get_param( 'product_id' ) );
$q = sanitize_text_field( (string) $req->get_param( 'q' ) );
$ids = [];
if ( $product_id > 0 ) {
$ids = [ $product_id ];
} else {
$query_args = [
'post_type' => 'product',
'posts_per_page' => 50,
'post_status' => [ 'publish', 'draft', 'pending', 'future', 'private' ],
'no_found_rows' => true,
'fields' => 'ids',
'ignore_sticky_posts' => true,
'orderby' => 'date',
'order' => 'DESC',
];
if ( $q !== '' ) {
$query_args['s'] = $q;
}
$query = new WP_Query( $query_args );
$ids = (array) $query->posts;
}
$products = [];
foreach ( $ids as $id ) {
$id = absint( $id );
if (
$id <= 0
|| get_post_type( $id ) !== 'product'
|| abtestkit_commerce_canonical_product_id( $id ) !== $id
) {
continue;
}
$product = wc_get_product( $id );
if ( ! $product ) {
continue;
}
$item = [
'id' => $id,
'title' => get_the_title( $id ),
'status' => (string) get_post_status( $id ),
'type' => method_exists( $product, 'get_type' )
? sanitize_key( (string) $product->get_type() )
: '',
'variation_count' => 0,
'variations' => [],
];
if ( method_exists( $product, 'get_children' ) ) {
$children = array_values(
array_filter(
array_map( 'absint', (array) $product->get_children() )
)
);
$item['variation_count'] = count( $children );
// Variation data is only needed after the user selects a product.
if ( $product_id > 0 ) {
foreach ( array_slice( $children, 0, ABTESTKIT_COMMERCE_MAX_VARIATIONS_PER_TARGET ) as $variation_id ) {
if (
get_post_type( $variation_id ) !== 'product_variation'
|| abtestkit_commerce_canonical_variation_id( $variation_id ) !== $variation_id
) {
continue;
}
$variation = wc_get_product( $variation_id );
if ( ! $variation ) {
continue;
}
$label = method_exists( $variation, 'get_name' )
? wp_strip_all_tags( (string) $variation->get_name() )
: '';
if ( $label === '' || $label === (string) $item['title'] ) {
$label = sprintf(
/* translators: %d: WooCommerce variation ID. */
__( 'Variation #%d', 'abtestkit' ),
$variation_id
);
}
$item['variations'][] = [
'id' => $variation_id,
'label' => $label,
'status' => (string) get_post_status( $variation_id ),
];
}
}
}
$products[] = $item;
}
return rest_ensure_response(
[
'ok' => true,
'products' => $products,
]
);
},
]
);
// --- Page Test Wizard: create a temporary preview token for WooCommerce product overrides ---
register_rest_route(
'abtestkit/v1',
'/pt/product-preview',
[
'methods' => 'POST',
'permission_callback' => function () { return current_user_can( 'manage_options' ); },
'callback' => function ( WP_REST_Request $req ) {
$control_id = absint( $req->get_param( 'control_id' ) );
$overrides = $req->get_param( 'product_overrides' );
if ( $control_id <= 0 ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
}
if ( ! is_array( $overrides ) ) {
$overrides = [];
}
// Whitelist keys to store
$allowed = [
'title',
'price',
'regular_price',
'sale_price',
'short_description',
'description',
'image_url',
'image_id',
'gallery_urls',
'gallery_ids',
];
$clean = [];
foreach ( $allowed as $k ) {
if ( ! array_key_exists( $k, $overrides ) ) continue;
$v = $overrides[ $k ];
if ( is_array( $v ) ) {
// Arrays are only allowed for gallery urls/ids
if ( $k === 'gallery_urls' ) {
$urls = array_map( 'trim', array_map( 'sanitize_text_field', array_map( 'wp_unslash', $v ) ) );
$urls = array_values( array_filter( $urls ) );
if ( $urls ) $clean[ $k ] = $urls;
} elseif ( $k === 'gallery_ids' ) {
$ids = array_map( 'absint', $v );
$ids = array_values( array_filter( $ids ) );
if ( $ids ) $clean[ $k ] = $ids;
}
continue;
}
// Scalars
$sv = sanitize_text_field( wp_unslash( (string) $v ) );
if ( $sv !== '' ) {
$clean[ $k ] = $sv;
}
}
// Must have at least one override to be meaningful
if ( empty( $clean ) ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'no_overrides' ] );
}
$token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : wp_hash( microtime( true ) . wp_rand() );
$key = 'abtestkit_product_preview_' . $token;
// 10 minutes is plenty for a wizard preview
set_transient( $key, [
'control_id' => $control_id,
'overrides' => $clean,
'user_id' => get_current_user_id(),
'created' => time(),
], 10 * MINUTE_IN_SECONDS );
return rest_ensure_response( [ 'ok' => true, 'token' => $token ] );
},
]
);
// --- Page Test Wizard: create a temporary Custom CSS preview token for unsaved wizard CSS ---
register_rest_route(
'abtestkit/v1',
'/pt/custom-css-preview',
[
'methods' => 'POST',
'permission_callback' => function () { return current_user_can( 'manage_options' ); },
'callback' => function ( WP_REST_Request $req ) {
$control_id = absint( $req->get_param( 'control_id' ) );
$custom_css = abtestkit_sanitize_custom_css_input( $req->get_param( 'custom_css' ) );
$css_markers = abtestkit_sanitize_custom_css_markers( $req->get_param( 'css_markers' ) );
if ( $control_id <= 0 ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
}
$post_type = get_post_type( $control_id );
if ( ! in_array( $post_type, [ 'page', 'post', 'product' ], true ) ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
}
if ( $custom_css === '' && empty( $css_markers ) ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'empty_preview' ] );
}
$token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : wp_hash( microtime( true ) . wp_rand() );
$key = 'abtestkit_custom_css_preview_' . $token;
set_transient(
$key,
[
'control_id' => $control_id,
'post_type' => $post_type,
'custom_css' => $custom_css,
'css_markers' => $css_markers,
'user_id' => get_current_user_id(),
'created' => time(),
],
10 * MINUTE_IN_SECONDS
);
return rest_ensure_response(
[
'ok' => true,
'token' => $token,
]
);
},
]
);
// --- Page Test Wizard: create a temporary Custom HTML preview token for unsaved wizard HTML ---
register_rest_route(
'abtestkit/v1',
'/pt/custom-html-preview',
[
'methods' => 'POST',
'permission_callback' => function () { return current_user_can( 'manage_options' ); },
'callback' => function ( WP_REST_Request $req ) {
$control_id = absint( $req->get_param( 'control_id' ) );
$html_changes = abtestkit_sanitize_custom_html_changes( $req->get_param( 'html_changes' ) );
if ( $control_id <= 0 ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
}
$post_type = get_post_type( $control_id );
if ( ! in_array( $post_type, [ 'page', 'post', 'product' ], true ) ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
}
if ( empty( $html_changes ) ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'empty_preview' ] );
}
$token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : wp_hash( microtime( true ) . wp_rand() );
$key = 'abtestkit_custom_html_preview_' . $token;
set_transient(
$key,
[
'control_id' => $control_id,
'post_type' => $post_type,
'html_changes' => $html_changes,
'user_id' => get_current_user_id(),
'created' => time(),
],
10 * MINUTE_IN_SECONDS
);
return rest_ensure_response(
[
'ok' => true,
'token' => $token,
]
);
},
]
);
// --- Page Test Wizard: record admin preview iframe failures for the Health Checker ---
register_rest_route(
'abtestkit/v1',
'/pt/preview-health',
[
'methods' => 'POST',
'permission_callback' => function () { return current_user_can( 'manage_options' ); },
'callback' => function ( WP_REST_Request $req ) {
$post_id = absint( $req->get_param( 'post_id' ) );
$url = esc_url_raw( (string) $req->get_param( 'url' ) );
$label = sanitize_text_field( (string) $req->get_param( 'label' ) );
$context = sanitize_key( (string) $req->get_param( 'context' ) );
if ( $post_id <= 0 && $url === '' ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'missing_preview' ] );
}
if ( function_exists( 'abtestkit_preview_health_record_failure' ) ) {
abtestkit_preview_health_record_failure( $post_id, $url, $label, $context );
}
return rest_ensure_response( [ 'ok' => true ] );
},
]
);
// --- Custom CSS test helpers ---
if ( ! function_exists( 'abtestkit_sanitize_custom_css_input' ) ) {
function abtestkit_sanitize_custom_css_input( $css ) : string {
$css = is_string( $css ) ? wp_unslash( $css ) : '';
$css = str_replace( "\0", '', $css );
// Remove HTML/script/style wrappers. The saved value should be stylesheet rules only.
$css = preg_replace( '##is', '', $css );
$css = preg_replace( '#?style\b[^>]*>#i', '', $css );
$css = preg_replace( '/<\?(?:php)?/i', '', $css );
// Remove high-risk CSS features while keeping normal override CSS intact.
$css = preg_replace( '/@import\b[^;]+;?/i', '', $css );
$css = preg_replace( '/expression\s*\(/i', '', $css );
$css = preg_replace( '/behavior\s*:/i', 'x-abtestkit-removed:', $css );
$css = preg_replace( '/-moz-binding\s*:/i', 'x-abtestkit-removed:', $css );
$css = preg_replace( '/url\s*\(\s*([\'"]?)\s*(?:javascript|vbscript|data):[^)]*\)/i', 'url($1#)', $css );
// Keep tabs/newlines, remove other control characters.
$css = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $css );
return trim( (string) $css );
}
}
if ( ! function_exists( 'abtestkit_custom_css_marker_class_from_label' ) ) {
function abtestkit_custom_css_marker_class_from_label( string $label ) : string {
$slug = sanitize_title( $label );
if ( $slug === '' ) {
$slug = 'marker';
}
return sanitize_html_class( 'abtestkit-marker-' . $slug );
}
}
if ( ! function_exists( 'abtestkit_sanitize_custom_css_markers' ) ) {
function abtestkit_sanitize_custom_css_markers( $markers ) : array {
if ( ! is_array( $markers ) ) {
return [];
}
$clean = [];
foreach ( $markers as $marker ) {
if ( ! is_array( $marker ) ) {
continue;
}
$label = isset( $marker['label'] ) ? sanitize_text_field( wp_unslash( (string) $marker['label'] ) ) : '';
$selector = isset( $marker['selector'] ) ? sanitize_text_field( wp_unslash( (string) $marker['selector'] ) ) : '';
$selector = trim( str_replace( [ '<', '>', '`' ], '', $selector ) );
if ( $label === '' || $selector === '' ) {
continue;
}
$class_name = isset( $marker['class_name'] )
? sanitize_html_class( wp_unslash( (string) $marker['class_name'] ) )
: '';
if ( $class_name === '' || strpos( $class_name, 'abtestkit-marker-' ) !== 0 ) {
$class_name = abtestkit_custom_css_marker_class_from_label( $label );
}
$clean[] = [
'label' => $label,
'selector' => $selector,
'class_name' => $class_name,
];
}
return array_values( $clean );
}
}
if ( ! function_exists( 'abtestkit_custom_css_data_for_test' ) ) {
function abtestkit_custom_css_data_for_test( array $test ) : array {
return [
'css_scope' => isset( $test['css_scope'] ) ? sanitize_key( (string) $test['css_scope'] ) : '',
'custom_css' => isset( $test['custom_css'] ) ? abtestkit_sanitize_custom_css_input( $test['custom_css'] ) : '',
'css_markers' => isset( $test['css_markers'] ) ? abtestkit_sanitize_custom_css_markers( $test['css_markers'] ) : [],
];
}
}
// --- Page Test Wizard: create test (duplicate or use existing as Version B) ---
register_rest_route(
'abtestkit/v1',
'/pt/create',
[
'methods' => 'POST',
'permission_callback' => function () { return current_user_can( 'manage_options' ); },
'callback' => function ( WP_REST_Request $req ) {
if ( ! abtestkit_connected_platform_can_create_or_launch( true ) ) {
return abtestkit_connected_platform_required_error();
}
$control_id = absint( $req->get_param( 'control_id' ) );
$test_title = sanitize_text_field( (string) $req->get_param( 'test_title' ) );
$mode = sanitize_key( $req->get_param( 'b_mode' ) ); // 'duplicate' | 'existing'
$b_page_id = absint( $req->get_param( 'b_page_id' ) );
$test_type = sanitize_key( (string) $req->get_param( 'test_type' ) );
$edit_test_id = sanitize_text_field( (string) $req->get_param( 'edit_test_id' ) );
$edit_previous = null;
if ( $edit_test_id !== '' ) {
$edit_previous = abtestkit_pt_get( $edit_test_id );
if (
! is_array( $edit_previous )
|| empty( $edit_previous['edit_in_progress'] )
|| (string) ( $edit_previous['status'] ?? '' ) !== 'paused'
) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'edit_unavailable' ] );
}
}
// Existing page as B: optional SEO protection toggle (default ON).
$seo_safe_existing_b = $req->get_param( 'seo_safe_existing_b' );
$seo_safe_existing_b = ( $seo_safe_existing_b === null ) ? true : (bool) $seo_safe_existing_b;
$start = (bool) $req->get_param( 'start' );
$split = max( 0, min( 100, (int) ( $req->get_param( 'split' ) ?? 50 ) ) );
// Decision rule (wizard dropdown) — controls when a winner may be auto-declared.
$decision_rule = sanitize_key( (string) $req->get_param( 'decision_rule' ) ); // fast|balanced|precise|manual
$decision_mode = sanitize_key( (string) $req->get_param( 'decision_mode' ) ); // auto|manual
$allowed_rules = [ 'fast', 'balanced', 'precise', 'manual' ];
if ( ! in_array( $decision_rule, $allowed_rules, true ) ) {
$decision_rule = 'balanced';
}
// Defaults
$min_impressions = 50;
$min_conversions = 5;
$decision_mode = ( $decision_mode === 'manual' || $decision_rule === 'manual' ) ? 'manual' : 'auto';
if ( $decision_mode === 'manual' ) {
// Manual mode: never auto-declare a winner.
$min_impressions = 0;
$min_conversions = 0;
$decision_rule = 'manual';
} else {
// Auto mode: accept explicit thresholds but enforce allowed values.
$mi = absint( $req->get_param( 'min_impressions' ) );
$mc = absint( $req->get_param( 'min_conversions' ) );
$allowed_mi = [ 25, 50, 75 ];
$allowed_mc = [ 3, 5, 10 ];
$min_impressions = in_array( $mi, $allowed_mi, true ) ? $mi : 50;
$min_conversions = in_array( $mc, $allowed_mc, true ) ? $mc : 5;
// Ensure rule presets stay consistent even if JS changes later.
if ( $decision_rule === 'fast' ) { $min_impressions = 25; $min_conversions = 3; }
if ( $decision_rule === 'balanced' ){ $min_impressions = 50; $min_conversions = 5; }
if ( $decision_rule === 'precise' ) { $min_impressions = 75; $min_conversions = 10; }
}
$post_type = $control_id ? get_post_type( $control_id ) : '';
$allowed_types = [ 'page', 'post', 'product' ];
$is_reusable_section = ( $test_type === 'reusable_section' );
if ( $is_reusable_section && $post_type !== 'page' ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
}
if ( ! $control_id || ! in_array( $post_type, $allowed_types, true ) ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
}
$requested_goal = sanitize_key( (string) $req->get_param( 'goal' ) );
if ( $requested_goal === 'button' || $requested_goal === 'link' ) {
$requested_goal = 'clicks';
}
if ( in_array( $requested_goal, [ 'destination', 'url', 'destination-url' ], true ) ) {
$requested_goal = 'destination_url';
}
if ( in_array( $requested_goal, [ 'scroll', 'scroll-depth', 'scroll_percentage', 'scroll-percentage' ], true ) ) {
$requested_goal = 'scroll_depth';
}
$commerce_config = null;
if ( in_array( $requested_goal, [ 'add_to_cart', 'purchase' ], true ) ) {
if ( ! function_exists( 'wc_get_product' ) ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'woocommerce_unavailable' ] );
}
$commerce_config = abtestkit_commerce_normalize_create_config(
$req->get_param( 'commerce' ),
$post_type === 'product' ? $control_id : 0
);
if ( $commerce_config['scope'] === '' ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_commerce_scope' ] );
}
}
if ( $test_type === 'custom_javascript' ) {
$javascript_scope = sanitize_key( (string) $req->get_param( 'javascript_scope' ) );
if ( ! in_array( $javascript_scope, [ 'page', 'post', 'product' ], true ) ) {
$javascript_scope = $post_type;
}
if ( $javascript_scope !== $post_type ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_javascript_scope' ] );
}
$source_metadata = abtestkit_custom_javascript_code_create(
$req->get_param( 'custom_javascript' )
);
if ( is_wp_error( $source_metadata ) ) {
$source_metadata->add_data( [ 'status' => 400 ] );
return $source_metadata;
}
$test = array_merge(
[
'id' => 'pt-' . substr( md5( $control_id . '|custom_javascript|' . microtime( true ) ), 0, 8 ),
'title' => $test_title !== '' ? $test_title : get_the_title( $control_id ),
'control_id' => $control_id,
'variant_id' => 0,
'status' => $start ? 'running' : 'draft',
'split' => $split,
'decision_rule' => $decision_rule,
'decision_mode' => $decision_mode,
'min_impressions' => (int) $min_impressions,
'min_conversions' => (int) $min_conversions,
'cookie_ttl_days' => 30,
'started_at' => $start ? time() : 0,
'finished_at' => 0,
'paused_at' => 0,
'paused_total' => 0,
'kind' => 'custom_javascript',
'javascript_scope' => $javascript_scope,
'javascript_run_timing' => 'dom_ready',
'javascript_ajax_policy' => 'once_per_document',
],
$source_metadata
);
$conflicts = abtestkit_pt_conflicts_for_pages(
(int) $test['control_id'],
0,
'',
'custom_javascript'
);
if ( ! empty( $conflicts ) ) {
abtestkit_custom_javascript_code_delete( $test['javascript_code_ref'] );
return rest_ensure_response(
[
'ok' => false,
'error' => 'conflict_running',
'info' => [
'message' => 'This page is already in a running test, or a reusable section test is causing a conflict.',
'conflicts' => $conflicts,
],
]
);
}
$goal = sanitize_key( $req->get_param( 'goal' ) );
$links = array_filter(
array_map(
'sanitize_text_field',
(array) $req->get_param( 'links' )
)
);
if ( $goal === 'button' || $goal === 'link' ) {
$goal = 'clicks';
}
if ( $goal === 'destination' || $goal === 'url' || $goal === 'destination-url' ) {
$goal = 'destination_url';
}
if ( in_array( $goal, [ 'scroll', 'scroll-depth', 'scroll_percentage', 'scroll-percentage' ], true ) ) {
$goal = 'scroll_depth';
}
if ( in_array( $goal, [ 'clicks', 'form', 'add_to_cart', 'purchase', 'destination_url', 'scroll_depth' ], true ) ) {
$test['goal'] = $goal;
if ( $goal === 'scroll_depth' ) {
$scroll_depth = absint( $req->get_param( 'scroll_depth' ) );
$allowed_scroll_depths = [ 25, 50, 75, 90 ];
if ( ! in_array( $scroll_depth, $allowed_scroll_depths, true ) ) {
$scroll_depth = 50;
}
$test['scroll_depth'] = $scroll_depth;
$test['links'] = [];
} elseif ( in_array( $goal, [ 'clicks', 'add_to_cart', 'destination_url' ], true ) ) {
$test['links'] = $links;
} else {
$test['links'] = [];
}
}
if ( is_array( $commerce_config ) ) {
$test['commerce'] = $commerce_config;
}
abtestkit_pt_put( $test );
$saved_test = abtestkit_pt_get( (string) $test['id'] );
if (
! is_array( $saved_test )
|| (string) ( $saved_test['javascript_code_ref'] ?? '' ) !== (string) $test['javascript_code_ref']
|| (string) ( $saved_test['javascript_code_hash'] ?? '' ) !== (string) $test['javascript_code_hash']
) {
if ( is_array( $saved_test ) ) {
abtestkit_pt_delete( (string) $test['id'] );
} else {
abtestkit_custom_javascript_code_delete( $test['javascript_code_ref'] );
}
return new WP_Error(
'abtestkit_custom_javascript_registry_failed',
__( 'The JavaScript test could not be saved.', 'abtestkit' ),
[ 'status' => 500 ]
);
}
if ( $edit_previous && ! abtestkit_pt_finish_edit( $edit_previous, $test ) ) {
return new WP_Error( 'edit_save_failed', __( 'The revised test could not be saved. The original remains paused.', 'abtestkit' ), [ 'status' => 500 ] );
}
if ( function_exists( 'abtestkit_review_prompt_note_test_created' ) ) {
abtestkit_review_prompt_note_test_created();
}
abtestkit_pt_clear_last_duplicate_for_user( (int) $control_id, (int) get_current_user_id() );
abtestkit_connected_onboarding_complete_after_test_created( (string) $test['id'] );
update_option( 'abtestkit_onboarding_done', '1' );
return rest_ensure_response(
[
'ok' => true,
'test' => $test,
'redirect' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
]
);
}
if ( $test_type === 'custom_css' ) {
$custom_css = abtestkit_sanitize_custom_css_input( $req->get_param( 'custom_css' ) );
$css_markers = abtestkit_sanitize_custom_css_markers( $req->get_param( 'css_markers' ) );
$css_scope = sanitize_key( (string) $req->get_param( 'css_scope' ) );
if ( ! in_array( $css_scope, [ 'page', 'post', 'product' ], true ) ) {
$css_scope = $post_type;
}
if ( $custom_css === '' ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'missing_custom_css' ] );
}
$test = [
'id' => 'pt-' . substr( md5( $control_id . '|custom_css|' . microtime( true ) ), 0, 8 ),
'title' => $test_title !== '' ? $test_title : get_the_title( $control_id ),
'control_id' => $control_id,
'variant_id' => 0,
'status' => $start ? 'running' : 'draft',
'split' => $split,
'decision_rule' => $decision_rule,
'decision_mode' => $decision_mode,
'min_impressions' => (int) $min_impressions,
'min_conversions' => (int) $min_conversions,
'cookie_ttl_days' => 30,
'started_at' => $start ? time() : 0,
'finished_at' => 0,
'paused_at' => 0,
'paused_total' => 0,
'kind' => 'custom_css',
'css_scope' => $css_scope,
'custom_css' => $custom_css,
'css_markers' => $css_markers,
];
$conflicts = abtestkit_pt_conflicts_for_pages(
(int) $test['control_id'],
0,
'',
'custom_css'
);
if ( ! empty( $conflicts ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'conflict_running',
'info' => [
'message' => 'This page is already in a running test, or a reusable section test is causing a conflict.',
'conflicts' => $conflicts,
],
]
);
}
$goal = sanitize_key( $req->get_param( 'goal' ) );
$links = array_filter(
array_map(
'sanitize_text_field',
(array) $req->get_param( 'links' )
)
);
if ( $goal === 'button' || $goal === 'link' ) {
$goal = 'clicks';
}
if ( $goal === 'destination' || $goal === 'url' || $goal === 'destination-url' ) {
$goal = 'destination_url';
}
if ( in_array( $goal, [ 'scroll', 'scroll-depth', 'scroll_percentage', 'scroll-percentage' ], true ) ) {
$goal = 'scroll_depth';
}
if ( in_array( $goal, [ 'clicks', 'form', 'add_to_cart', 'purchase', 'destination_url', 'scroll_depth' ], true ) ) {
$test['goal'] = $goal;
if ( $goal === 'scroll_depth' ) {
$scroll_depth = absint( $req->get_param( 'scroll_depth' ) );
$allowed_scroll_depths = [ 25, 50, 75, 90 ];
if ( ! in_array( $scroll_depth, $allowed_scroll_depths, true ) ) {
$scroll_depth = 50;
}
$test['scroll_depth'] = $scroll_depth;
$test['links'] = [];
} elseif ( in_array( $goal, [ 'clicks', 'add_to_cart', 'destination_url' ], true ) ) {
$test['links'] = $links;
} else {
$test['links'] = [];
}
}
if ( is_array( $commerce_config ) ) {
$test['commerce'] = $commerce_config;
}
abtestkit_pt_put( $test );
if ( $edit_previous && ! abtestkit_pt_finish_edit( $edit_previous, $test ) ) {
return new WP_Error( 'edit_save_failed', __( 'The revised test could not be saved. The original remains paused.', 'abtestkit' ), [ 'status' => 500 ] );
}
if ( function_exists( 'abtestkit_review_prompt_note_test_created' ) ) {
abtestkit_review_prompt_note_test_created();
}
abtestkit_pt_clear_last_duplicate_for_user( (int) $control_id, (int) get_current_user_id() );
abtestkit_connected_onboarding_complete_after_test_created( (string) $test['id'] );
update_option( 'abtestkit_onboarding_done', '1' );
return rest_ensure_response(
[
'ok' => true,
'test' => $test,
'redirect' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
]
);
}
if ( $test_type === 'custom_html' ) {
$html_changes = abtestkit_sanitize_custom_html_changes( $req->get_param( 'html_changes' ) );
$html_scope = sanitize_key( (string) $req->get_param( 'html_scope' ) );
if ( ! in_array( $html_scope, [ 'page', 'post', 'product' ], true ) ) {
$html_scope = $post_type;
}
if ( empty( $html_changes ) ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'missing_custom_html' ] );
}
$test = [
'id' => 'pt-' . substr( md5( $control_id . '|custom_html|' . microtime( true ) ), 0, 8 ),
'title' => $test_title !== '' ? $test_title : get_the_title( $control_id ),
'control_id' => $control_id,
'variant_id' => 0,
'status' => $start ? 'running' : 'draft',
'split' => $split,
'decision_rule' => $decision_rule,
'decision_mode' => $decision_mode,
'min_impressions' => (int) $min_impressions,
'min_conversions' => (int) $min_conversions,
'cookie_ttl_days' => 30,
'started_at' => $start ? time() : 0,
'finished_at' => 0,
'paused_at' => 0,
'paused_total' => 0,
'kind' => 'custom_html',
'html_scope' => $html_scope,
'html_changes' => $html_changes,
];
$conflicts = abtestkit_pt_conflicts_for_pages(
(int) $test['control_id'],
0,
'',
'custom_html'
);
if ( ! empty( $conflicts ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'conflict_running',
'info' => [
'message' => 'This page is already in a running test, or a reusable section test is causing a conflict.',
'conflicts' => $conflicts,
],
]
);
}
$goal = sanitize_key( $req->get_param( 'goal' ) );
$links = array_filter(
array_map(
'sanitize_text_field',
(array) $req->get_param( 'links' )
)
);
if ( $goal === 'button' || $goal === 'link' ) {
$goal = 'clicks';
}
if ( $goal === 'destination' || $goal === 'url' || $goal === 'destination-url' ) {
$goal = 'destination_url';
}
if ( in_array( $goal, [ 'scroll', 'scroll-depth', 'scroll_percentage', 'scroll-percentage' ], true ) ) {
$goal = 'scroll_depth';
}
if ( in_array( $goal, [ 'clicks', 'form', 'add_to_cart', 'purchase', 'destination_url', 'scroll_depth' ], true ) ) {
$test['goal'] = $goal;
if ( $goal === 'scroll_depth' ) {
$scroll_depth = absint( $req->get_param( 'scroll_depth' ) );
$allowed_scroll_depths = [ 25, 50, 75, 90 ];
if ( ! in_array( $scroll_depth, $allowed_scroll_depths, true ) ) {
$scroll_depth = 50;
}
$test['scroll_depth'] = $scroll_depth;
$test['links'] = [];
} elseif ( in_array( $goal, [ 'clicks', 'add_to_cart', 'destination_url' ], true ) ) {
$test['links'] = $links;
} else {
$test['links'] = [];
}
}
if ( is_array( $commerce_config ) ) {
$test['commerce'] = $commerce_config;
}
abtestkit_pt_put( $test );
if ( $edit_previous && ! abtestkit_pt_finish_edit( $edit_previous, $test ) ) {
return new WP_Error( 'edit_save_failed', __( 'The revised test could not be saved. The original remains paused.', 'abtestkit' ), [ 'status' => 500 ] );
}
if ( function_exists( 'abtestkit_review_prompt_note_test_created' ) ) {
abtestkit_review_prompt_note_test_created();
}
abtestkit_pt_clear_last_duplicate_for_user( (int) $control_id, (int) get_current_user_id() );
abtestkit_connected_onboarding_complete_after_test_created( (string) $test['id'] );
update_option( 'abtestkit_onboarding_done', '1' );
return rest_ensure_response(
[
'ok' => true,
'test' => $test,
'redirect' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
]
);
}
// Shadow product approach: do NOT accept product_overrides from the wizard.
// Variant B is edited directly on the shadow product, and parsing large payloads can OOM.
$overrides = [];
if ( $post_type === 'product' ) {
// Intentionally ignore $req->get_param('product_overrides')
}
$variant_id = 0;
$reuse_previous_variant = $edit_previous
&& (int) ( $edit_previous['control_id'] ?? 0 ) === $control_id
&& (string) ( $edit_previous['kind'] ?? '' ) === $test_type
&& (int) ( $edit_previous['variant_id'] ?? 0 ) === $b_page_id;
// A shadow from the paused test can only be reused for the same setup.
if ( $edit_previous && $b_page_id && (int) ( $edit_previous['variant_id'] ?? 0 ) === $b_page_id
&& abtestkit_is_shadow_variant( $b_page_id ) && ! $reuse_previous_variant ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_mode' ] );
}
// Wizard safety:
// The wizard can call /pt/duplicate (create B for editing) and then /pt/create.
// If we "duplicate" again here, you end up with two shadows and the test wired to the wrong one.
$user_id = (int) get_current_user_id();
// If the UI already has a B post ID, prefer using it even if b_mode still says "duplicate".
if ( $b_page_id && get_post_type( $b_page_id ) === $post_type ) {
$ok = true;
// Product tests: only accept a valid shadow product of this control.
if ( $post_type === 'product' ) {
$ok = abtestkit_is_shadow_product( $b_page_id )
&& (int) get_post_meta( $b_page_id, '_abtestkit_shadow_of', true ) === (int) $control_id;
}
if ( $ok && ( ! abtestkit_pt_variant_id_in_any_test( (int) $b_page_id ) || $reuse_previous_variant ) ) {
$variant_id = (int) $b_page_id;
}
}
if ( ! $variant_id ) {
if ( $mode === 'duplicate' ) {
// Reuse the last duplicate created in this wizard session (if any).
$maybe = abtestkit_pt_get_last_duplicate_for_user( (int) $control_id, (int) $user_id );
if ( $maybe ) {
$variant_id = (int) $maybe;
} else {
$variant_id = abtestkit_duplicate_post_deep( $control_id );
}
} elseif ( $mode === 'existing' && $b_page_id && get_post_type( $b_page_id ) === $post_type ) {
// Existing mode: for products, only allow a valid shadow of this control.
if ( $post_type === 'product' ) {
if ( abtestkit_is_shadow_product( $b_page_id )
&& (int) get_post_meta( $b_page_id, '_abtestkit_shadow_of', true ) === (int) $control_id
&& ( ! abtestkit_pt_variant_id_in_any_test( (int) $b_page_id ) || $reuse_previous_variant )
) {
$variant_id = (int) $b_page_id;
} else {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_mode' ] );
}
} else {
$variant_id = $b_page_id;
}
} else {
return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_mode' ] );
}
}
// For all tests (including products now), we require a real variant ID.
if ( ! $variant_id ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'create_failed' ] );
}
// For non-product tests we still require a real B page.
if ( $post_type !== 'product' && ! $variant_id ) {
return rest_ensure_response( [ 'ok' => false, 'error' => 'create_failed' ] );
}
$test = [
'id' => 'pt-' . substr( md5( $control_id . '|' . microtime( true ) ), 0, 8 ),
'title' => $test_title !== '' ? $test_title : get_the_title( $control_id ),
'control_id' => $control_id,
// Product tests use a virtual B (no separate post); page tests keep a real B.
'variant_id' => $variant_id,
'status' => $start ? 'running' : 'draft',
'split' => $split,
'decision_rule' => $decision_rule,
'decision_mode' => $decision_mode,
'min_impressions' => (int) $min_impressions,
'min_conversions' => (int) $min_conversions,
'cookie_ttl_days' => 30,
'started_at' => $start ? time() : 0,
'finished_at' => 0,
'paused_at' => 0,
'paused_total' => 0,
// Mark what kind of test this is.
'kind' => $is_reusable_section
? 'reusable_section'
: (
( $post_type === 'product' )
? 'product'
: ( $post_type === 'post' ? 'post' : 'page' )
),
];
if ( $is_reusable_section ) {
$test['shortcode_tag'] = 'embed_page';
$test['shortcode_attribute'] = 'id';
$test['source_page_id'] = (int) $control_id;
}
// Attach overrides for product tests so Woo filters can read them.
if ( $post_type === 'product' ) {
$test['overrides'] = $overrides;
}
// Prevent creating a test if either page/product is already part of another running test.
// This applies when creating both drafts (paused) and running tests.
$conflicts = abtestkit_pt_conflicts_for_pages(
(int) $test['control_id'],
(int) $test['variant_id'],
'',
isset( $test['kind'] ) ? (string) $test['kind'] : ''
);
if ( ! empty( $conflicts ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'conflict_running',
'info' => [
'message' => 'This page is already in a running test, or a reusable section test is causing a conflict.',
'conflicts' => $conflicts,
],
]
);
}
// store wizard-picked goal info
// Goal can be: 'clicks' | 'form' | 'add_to_cart' | 'purchase' | 'destination_url' | 'scroll_depth'
$goal = sanitize_key( $req->get_param( 'goal' ) );
$links = array_filter(
array_map(
'sanitize_text_field',
(array) $req->get_param( 'links' )
)
);
// Back-compat: normalise any old values to the new shape.
if ( $goal === 'button' || $goal === 'link' ) {
$goal = 'clicks';
}
if ( $goal === 'destination' || $goal === 'url' || $goal === 'destination-url' ) {
$goal = 'destination_url';
}
if ( in_array( $goal, [ 'scroll', 'scroll-depth', 'scroll_percentage', 'scroll-percentage' ], true ) ) {
$goal = 'scroll_depth';
}
if ( in_array( $goal, [ 'clicks', 'form', 'add_to_cart', 'purchase', 'destination_url', 'scroll_depth' ], true ) ) {
$test['goal'] = $goal;
// Scroll depth stores a percentage threshold, not a URL/selector target.
if ( $goal === 'scroll_depth' ) {
$scroll_depth = absint( $req->get_param( 'scroll_depth' ) );
$allowed_scroll_depths = [ 25, 50, 75, 90 ];
if ( ! in_array( $scroll_depth, $allowed_scroll_depths, true ) ) {
$scroll_depth = 50;
}
$test['scroll_depth'] = $scroll_depth;
$test['links'] = [];
} elseif ( in_array( $goal, [ 'clicks', 'add_to_cart', 'destination_url' ], true ) ) {
// Goals with explicit targets store those targets in links.
// For clicks/add-to-cart these are selectors or hrefs.
// For destination_url these are URL/path targets to match on page load.
$test['links'] = $links;
} else {
$test['links'] = [];
}
}
if ( is_array( $commerce_config ) ) {
$test['commerce'] = $commerce_config;
}
// If using an EXISTING page/post as Version B, mark it as SEO-protected while this test exists
// (even if paused/draft). Shadow duplicates are permanently protected already.
if ( $post_type !== 'product' && $mode === 'existing' && $seo_safe_existing_b ) {
if ( ! abtestkit_is_shadow_variant( (int) $variant_id ) ) {
abtestkit_pt_mark_existing_variant_in_use(
(int) $variant_id,
(int) $control_id,
(string) $test['id']
);
}
}
abtestkit_pt_put( $test );
if ( $edit_previous && ! abtestkit_pt_finish_edit( $edit_previous, $test ) ) {
return new WP_Error( 'edit_save_failed', __( 'The revised test could not be saved. The original remains paused.', 'abtestkit' ), [ 'status' => 500 ] );
}
if ( function_exists( 'abtestkit_review_prompt_note_test_created' ) ) {
abtestkit_review_prompt_note_test_created();
}
// Clear any cached duplicate pointer for this user+control once the test is created.
abtestkit_pt_clear_last_duplicate_for_user( (int) $control_id, (int) get_current_user_id() );
// Once the user has created a test, never show onboarding again.
abtestkit_connected_onboarding_complete_after_test_created( (string) $test['id'] );
update_option( 'abtestkit_onboarding_done', '1' );
return rest_ensure_response(
[
'ok' => true,
'test' => $test,
'redirect' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
]
);
},
]
);
// --- Dashboard: list all tests with basic stats ---
register_rest_route(
'abtestkit/v1',
'/pt',
[
'methods' => 'GET',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
$tests = abtestkit_pt_all();
$tests = is_array( $tests ) ? $tests : [];
/*
* Do not cache the full dashboard payload.
*
* The dashboard includes live impression/conversion counts. Caching the
* whole response makes new reusable-section impressions appear delayed,
* especially when testing in repeated incognito sessions.
*
* abtestkit_pt_stats_bulk() already performs one grouped stats query for
* all tests, so keeping this response live is acceptable and much clearer.
*/
$out = [];
$stats_bulk = abtestkit_pt_stats_bulk( $tests );
$http_exclusions_bulk = abtestkit_pt_http_exclusions_bulk( $tests );
foreach ( $tests as $test ) {
$test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
$control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
$kind = isset( $test['kind'] ) ? (string) $test['kind'] : '';
if ( $kind === '' ) {
$control_type = $control_id > 0 ? get_post_type( $control_id ) : '';
if ( $control_type === 'product' ) {
$kind = 'product';
} elseif ( $control_type === 'post' ) {
$kind = 'post';
} else {
$kind = 'page';
}
}
// Base admin previews are different for product tests vs page/post tests.
// For products, we keep the same URL and force A/B via query args.
// For pages/posts, A and B each have their own permalink.
// Normal URL (non-preview) for the dashboard title link:
$url = '';
$preview_a = '';
$preview_b = '';
if ( $control_id > 0 ) {
$base = get_permalink( $control_id );
$url = $base;
if (
$kind === 'custom_javascript'
&& (string) ( $test['status'] ?? '' ) === 'draft'
) {
$preview_a = add_query_arg(
[
'abtestkit_preview' => '1',
'abtestkit_force' => 'A',
'abtestkit_preview_test' => $test_id,
],
$base
);
} elseif ( in_array( $kind, [ 'custom_css', 'custom_html', 'custom_javascript' ], true ) ) {
$preview_a = add_query_arg(
[
'abtestkit_preview' => '1',
'abtestkit_force' => 'A',
'abtestkit_preview_test' => $test_id,
],
$base
);
$preview_b = add_query_arg(
[
'abtestkit_preview' => '1',
'abtestkit_force' => 'B',
'abtestkit_preview_test' => $test_id,
],
$base
);
} elseif ( $kind === 'product' ) {
// Product tests: force the variant via query string, but still include
// abtestkit_preview=1 so no impressions/clicks are logged.
$preview_a = add_query_arg(
[
'abtestkit_preview' => '1',
'abtestkit_force' => 'A',
],
$base
);
$preview_b_args = [
'abtestkit_preview' => '1',
'abtestkit_force' => 'B',
];
if ( $variant_id > 0 ) {
$preview_b_args['abtestkit_shadow_preview_id'] = (int) $variant_id;
}
$preview_b = add_query_arg( $preview_b_args, $base );
} else {
// Page/post tests: previews point at the underlying permalinks.
$preview_a = add_query_arg( 'abtestkit_preview', '1', $base );
if ( $variant_id > 0 ) {
$preview_b = add_query_arg(
'abtestkit_preview',
'1',
get_permalink( $variant_id )
);
}
}
}
$kind_label = ( $kind === 'custom_javascript' )
? 'Custom JavaScript'
: (
( $kind === 'custom_html' )
? 'Custom HTML'
: (
( $kind === 'custom_css' )
? 'Custom CSS'
: (
( $kind === 'reusable_section' )
? 'Reusable Section'
: ucfirst( str_replace( '_', ' ', $kind ) )
)
)
);
$reporting_test = abtestkit_pt_custom_code_reporting_test( $test );
$custom_css_data = ( $kind === 'custom_css' )
? abtestkit_custom_css_data_for_test( $reporting_test )
: [
'css_scope' => '',
'custom_css' => '',
'css_markers' => [],
];
$custom_html_data = ( $kind === 'custom_html' )
? abtestkit_custom_html_data_for_test( $reporting_test )
: [
'html_scope' => '',
'html_changes' => [],
];
$testing_title = $control_id > 0 ? get_the_title( $control_id ) : '';
$test_stats = $stats_bulk[ $test_id ] ?? abtestkit_pt_stats_default();
$http_exclusion_context = $http_exclusions_bulk[ $test_id ] ?? [
'http_excluded_count' => 0,
'http_excluded_last' => '',
];
$health = abtestkit_pt_health_summary( $test, $test_stats, $http_exclusion_context );
$out[] = [
'id' => isset( $test['id'] ) ? (string) $test['id'] : '',
'name' => isset( $test['title'] ) ? (string) $test['title'] : '',
'kind' => $kind,
'kind_label' => $kind_label,
'testing_title' => $testing_title,
'status' => isset( $test['status'] ) ? (string) $test['status'] : 'paused',
'edit_in_progress' => ! empty( $test['edit_in_progress'] ),
'decision_rule' => isset( $test['decision_rule'] ) ? (string) $test['decision_rule'] : 'balanced',
'decision_mode' => isset( $test['decision_mode'] ) ? (string) $test['decision_mode'] : 'auto',
'min_impressions' => isset( $test['min_impressions'] ) ? (int) $test['min_impressions'] : 50,
'min_conversions' => isset( $test['min_conversions'] ) ? (int) $test['min_conversions'] : 5,
'started_at' => isset( $test['started_at'] ) ? (int) $test['started_at'] : 0,
'finished_at' => isset( $test['finished_at'] ) ? (int) $test['finished_at'] : 0,
'winner' => abtestkit_pt_winner_for_dashboard( $test ),
'winner_basis' => isset( $test['winner_basis'] ) ? sanitize_key( (string) $test['winner_basis'] ) : '',
'winner_applied_at' => isset( $test['winner_applied_at'] ) ? (int) $test['winner_applied_at'] : 0,
'goal' => isset( $test['goal'] ) ? abtestkit_pt_normalize_goal_for_display( $test['goal'] ) : '',
'scroll_depth' => abtestkit_pt_scroll_depth_for_test( $test ),
'css_scope' => $custom_css_data['css_scope'],
'custom_css' => $custom_css_data['custom_css'],
'css_markers' => $custom_css_data['css_markers'],
'html_scope' => $custom_html_data['html_scope'],
'html_changes' => $custom_html_data['html_changes'],
'javascript_scope' => $kind === 'custom_javascript' ? (string) ( $reporting_test['javascript_scope'] ?? '' ) : '',
'javascript_code_length' => $kind === 'custom_javascript' ? max( 0, (int) ( $reporting_test['javascript_code_length'] ?? 0 ) ) : 0,
'javascript_run_timing' => $kind === 'custom_javascript' ? (string) ( $reporting_test['javascript_run_timing'] ?? '' ) : '',
'javascript_ajax_policy' => $kind === 'custom_javascript' ? (string) ( $reporting_test['javascript_ajax_policy'] ?? '' ) : '',
'stats' => $test_stats,
'health' => $health,
'http_excluded_count' => (int) $http_exclusion_context['http_excluded_count'],
'http_excluded_last' => (string) $http_exclusion_context['http_excluded_last'],
'auto_paused_broken' => ! empty( $test['auto_paused_broken'] ),
'auto_paused_broken_at' => isset( $test['auto_paused_broken_at'] ) ? (int) $test['auto_paused_broken_at'] : 0,
'url' => $url,
'performance_url' => add_query_arg(
'test_id',
(string) $test_id,
admin_url( 'admin.php?page=abtestkit-test-performance' )
),
// Legacy: keep preview_url pointing at Version A (Version A permalink or forced A for products)
'preview_url' => $preview_a,
// New: explicit admin preview URLs for A/B
'preview_a' => $preview_a,
'preview_b' => $preview_b,
];
}
return rest_ensure_response( $out );
},
]
);
// --- Test performance: update test title ---
register_rest_route(
'abtestkit/v1',
'/pt/update-title',
[
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
$test_id = sanitize_text_field( (string) $req->get_param( 'test_id' ) );
$title = sanitize_text_field( (string) $req->get_param( 'title' ) );
if ( $test_id === '' ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'missing_test_id',
]
);
}
if ( $title === '' ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'missing_title',
]
);
}
$test = abtestkit_pt_get( $test_id );
if ( ! is_array( $test ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'not_found',
]
);
}
$test['title'] = $title;
abtestkit_pt_put( $test );
return rest_ensure_response(
[
'ok' => true,
'title' => $title,
'test' => [
'id' => (string) ( $test['id'] ?? '' ),
'title' => (string) ( $test['title'] ?? '' ),
],
]
);
},
]
);
// --- Test performance: acknowledge or restore one supported health warning ---
register_rest_route(
'abtestkit/v1',
'/pt/health-acknowledgement',
[
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
$test_id = sanitize_text_field( (string) $req->get_param( 'test_id' ) );
$check_id = sanitize_key( (string) $req->get_param( 'check_id' ) );
$mode = sanitize_key( (string) $req->get_param( 'mode' ) );
if ( $test_id === '' || $check_id !== 'http_tracking' || ! in_array( $mode, [ 'acknowledge', 'restore' ], true ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'invalid_health_acknowledgement',
]
);
}
$test = abtestkit_pt_get( $test_id );
if ( ! is_array( $test ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'not_found',
]
);
}
$http_contexts = abtestkit_pt_http_exclusions_bulk( [ $test ] );
$http_context = $http_contexts[ $test_id ] ?? [
'http_excluded_count' => 0,
'http_excluded_last' => '',
];
$health = abtestkit_pt_health_summary( $test, abtestkit_pt_stats( $test ), $http_context );
if ( $mode === 'acknowledge' ) {
$can_acknowledge = false;
foreach ( (array) ( $health['checks'] ?? [] ) as $check ) {
if (
is_array( $check )
&& (string) ( $check['id'] ?? '' ) === $check_id
&& ! empty( $check['acknowledgeable'] )
&& empty( $check['acknowledged'] )
) {
$can_acknowledge = true;
break;
}
}
if ( ! $can_acknowledge ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'health_warning_not_available',
]
);
}
}
$acknowledgements = abtestkit_pt_health_acknowledgements( $test );
if ( $mode === 'acknowledge' ) {
$acknowledgements[ $check_id ] = current_time( 'timestamp' );
} else {
unset( $acknowledgements[ $check_id ] );
}
if ( empty( $acknowledgements ) ) {
unset( $test['health_acknowledgements'] );
} else {
$test['health_acknowledgements'] = $acknowledgements;
}
abtestkit_pt_put( $test );
$health = abtestkit_pt_health_summary( $test, abtestkit_pt_stats( $test ), $http_context );
return rest_ensure_response(
[
'ok' => true,
'health' => $health,
]
);
},
]
);
// --- Test performance: compatibility help request ---
register_rest_route(
'abtestkit/v1',
'/pt/compatibility-help',
[
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
$test_id = sanitize_text_field( (string) $req->get_param( 'test_id' ) );
$message = sanitize_textarea_field( (string) $req->get_param( 'message' ) );
$source = sanitize_key( (string) $req->get_param( 'source' ) );
if ( $test_id === '' ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'missing_test_id',
]
);
}
if ( ! function_exists( 'abtestkit_telemetry_track_compatibility_help_request' ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'missing_handler',
]
);
}
$sent = abtestkit_telemetry_track_compatibility_help_request( $test_id, $message, $source );
if ( ! $sent ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'not_found',
]
);
}
return rest_ensure_response(
[
'ok' => true,
]
);
},
]
);
// --- Test performance: single test details + timeline ---
register_rest_route(
'abtestkit/v1',
'/pt/performance',
[
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
global $wpdb;
$test_id = sanitize_text_field( (string) $req->get_param( 'test_id' ) );
$range = sanitize_key( (string) $req->get_param( 'range' ) );
if ( ! in_array( $range, [ 'day', 'week', 'month' ], true ) ) {
$range = 'day';
}
if ( $test_id === '' ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'missing_test_id',
]
);
}
$test = abtestkit_pt_get( $test_id );
if ( ! is_array( $test ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'not_found',
]
);
}
$cache_key = abtestkit_pt_performance_cache_key( $test_id, $range );
$cached = get_transient( $cache_key );
if ( is_array( $cached ) && ! empty( $cached['ok'] ) && ! empty( $cached['test'] ) ) {
$cached_targets = abtestkit_pt_click_targets_for_test( $test );
$cached_reporting_test = abtestkit_pt_custom_code_reporting_test( $test );
$cached_goal = isset( $test['goal'] )
? abtestkit_pt_normalize_goal_for_display( $test['goal'] )
: '';
$cached['test']['goal'] = $cached_goal;
$cached['test']['links'] = $cached_targets;
$cached['test']['click_targets'] = $cached_targets;
$cached_custom_css_data = abtestkit_custom_css_data_for_test( $cached_reporting_test );
$cached['test']['scroll_depth'] = abtestkit_pt_scroll_depth_for_test( $test );
$cached['test']['css_scope'] = $cached_custom_css_data['css_scope'];
$cached['test']['custom_css'] = $cached_custom_css_data['custom_css'];
$cached['test']['css_markers'] = $cached_custom_css_data['css_markers'];
$cached_custom_html_data = abtestkit_custom_html_data_for_test( $cached_reporting_test );
$cached['test']['html_scope'] = $cached_custom_html_data['html_scope'];
$cached['test']['html_changes'] = $cached_custom_html_data['html_changes'];
$cached['test']['javascript_scope'] = (string) ( $cached_reporting_test['javascript_scope'] ?? '' );
$cached['test']['javascript_code_length'] = max( 0, (int) ( $cached_reporting_test['javascript_code_length'] ?? 0 ) );
$cached['test']['javascript_run_timing'] = (string) ( $cached_reporting_test['javascript_run_timing'] ?? '' );
$cached['test']['javascript_ajax_policy'] = (string) ( $cached_reporting_test['javascript_ajax_policy'] ?? '' );
$cached['test']['status'] = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
$cached['test']['winner'] = isset( $test['winner'] ) ? (string) $test['winner'] : '';
$cached['test']['winner_basis'] = isset( $test['winner_basis'] ) ? sanitize_key( (string) $test['winner_basis'] ) : '';
$cached['test']['winner_applied_at'] = isset( $test['winner_applied_at'] ) ? (int) $test['winner_applied_at'] : 0;
$cached['test']['auto_paused_broken'] = ! empty( $test['auto_paused_broken'] );
$cached['test']['auto_paused_broken_at'] = isset( $test['auto_paused_broken_at'] ) ? (int) $test['auto_paused_broken_at'] : 0;
$cached['test']['engagement'] = abtestkit_pt_engagement_stats( $test_id );
$cached_audience_breakdowns = abtestkit_pt_audience_breakdowns( $test_id, $cached_goal );
$cached_stats = ( isset( $cached['test']['stats'] ) && is_array( $cached['test']['stats'] ) )
? $cached['test']['stats']
: abtestkit_pt_stats( $test );
$cached['test']['health'] = abtestkit_pt_health_summary( $test, $cached_stats, [
'http_excluded_count' => isset( $cached['test']['http_excluded_count'] ) ? (int) $cached['test']['http_excluded_count'] : 0,
'http_excluded_last' => isset( $cached['test']['http_excluded_last'] ) ? (string) $cached['test']['http_excluded_last'] : '',
] );
$cached_analytics_test = $test;
$cached_analytics_test['goal'] = $cached_goal;
$cached['test']['analytics_summary'] = abtestkit_build_analytics_summary(
$cached_analytics_test,
$cached_stats,
$cached['test']['engagement'],
$cached['test']['health'],
isset( $cached['test']['timeline'] ) && is_array( $cached['test']['timeline'] )
? $cached['test']['timeline']
: [],
$range,
[
'now' => current_time( 'timestamp' ),
'click_target_count' => count( $cached_targets ),
'currency' => abtestkit_reporting_currency_settings(),
'audience_breakdowns' => $cached_audience_breakdowns,
]
);
return rest_ensure_response( $cached );
}
$control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
$kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
$url = $control_id > 0 ? get_permalink( $control_id ) : '';
$preview_a = '';
$preview_b = '';
if ( $control_id > 0 ) {
$base = get_permalink( $control_id );
if (
$kind === 'custom_javascript'
&& (string) ( $test['status'] ?? '' ) === 'draft'
) {
$preview_a = add_query_arg(
[
'abtestkit_preview' => '1',
'abtestkit_force' => 'A',
'abtestkit_preview_test' => $test_id,
],
$base
);
} elseif ( in_array( $kind, [ 'custom_css', 'custom_html', 'custom_javascript' ], true ) ) {
$preview_a = add_query_arg(
[
'abtestkit_preview' => '1',
'abtestkit_force' => 'A',
'abtestkit_preview_test' => $test_id,
],
$base
);
$preview_b = add_query_arg(
[
'abtestkit_preview' => '1',
'abtestkit_force' => 'B',
'abtestkit_preview_test' => $test_id,
],
$base
);
} elseif ( $kind === 'product' ) {
$preview_a = add_query_arg(
[
'abtestkit_preview' => '1',
'abtestkit_force' => 'A',
],
$base
);
$preview_b_args = [
'abtestkit_preview' => '1',
'abtestkit_force' => 'B',
];
if ( $variant_id > 0 ) {
$preview_b_args['abtestkit_shadow_preview_id'] = (int) $variant_id;
}
$preview_b = add_query_arg( $preview_b_args, $base );
} else {
$preview_a = add_query_arg( 'abtestkit_preview', '1', $base );
if ( $variant_id > 0 ) {
$preview_b = add_query_arg(
'abtestkit_preview',
'1',
get_permalink( $variant_id )
);
}
}
}
$timeline = [];
$http_excluded_count = 0;
$http_excluded_last = '';
if ( $test_id !== '' ) {
$table = ABTESTKIT_EVENTS_TABLE;
// Keep timeline queries bounded on slow sites.
$days_back = abtestkit_pt_timeline_days_back( $range );
$from_date = gmdate( 'Y-m-d 00:00:00', strtotime( '-' . $days_back . ' days', current_time( 'timestamp' ) ) );
if ( abtestkit_events_table_has_column( 'amount' ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"
SELECT
DATE(`time`) AS bucket,
variant,
event_type,
protocol,
excluded_reason,
COUNT(*) AS total,
COALESCE(SUM(amount), 0) AS revenue,
MAX(`time`) AS last_seen
FROM %i
WHERE ab_test_id = %s
AND (
event_type IN ('impression', 'click', 'purchase')
OR (
event_type = 'protocol_warning'
AND protocol = 'http'
AND excluded_reason = 'http'
)
)
AND `time` >= %s
GROUP BY bucket, variant, event_type, protocol, excluded_reason
ORDER BY bucket ASC
",
$table,
$test_id,
$from_date
),
ARRAY_A
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"
SELECT
DATE(`time`) AS bucket,
variant,
event_type,
protocol,
excluded_reason,
COUNT(*) AS total,
0 AS revenue,
MAX(`time`) AS last_seen
FROM %i
WHERE ab_test_id = %s
AND (
event_type IN ('impression', 'click', 'purchase')
OR (
event_type = 'protocol_warning'
AND protocol = 'http'
AND excluded_reason = 'http'
)
)
AND `time` >= %s
GROUP BY bucket, variant, event_type, protocol, excluded_reason
ORDER BY bucket ASC
",
$table,
$test_id,
$from_date
),
ARRAY_A
);
}
$daily_indexed = [];
foreach ( (array) $rows as $row ) {
$bucket = isset( $row['bucket'] ) ? (string) $row['bucket'] : '';
$variant = isset( $row['variant'] ) ? (string) $row['variant'] : '';
$event_type = isset( $row['event_type'] ) ? (string) $row['event_type'] : '';
$protocol = isset( $row['protocol'] ) ? (string) $row['protocol'] : '';
$excluded_reason = isset( $row['excluded_reason'] ) ? (string) $row['excluded_reason'] : '';
$total = isset( $row['total'] ) ? (int) $row['total'] : 0;
$revenue = isset( $row['revenue'] ) ? (float) $row['revenue'] : 0.0;
$last_seen = isset( $row['last_seen'] ) ? (string) $row['last_seen'] : '';
if ( $event_type === 'protocol_warning' && $protocol === 'http' && $excluded_reason === 'http' ) {
$http_excluded_count += $total;
if ( $last_seen !== '' && ( $http_excluded_last === '' || strtotime( $last_seen ) > strtotime( $http_excluded_last ) ) ) {
$http_excluded_last = $last_seen;
}
continue;
}
if ( $bucket === '' || ! in_array( $variant, [ 'A', 'B' ], true ) ) {
continue;
}
if ( ! isset( $daily_indexed[ $bucket ] ) ) {
$daily_indexed[ $bucket ] = [
'bucket' => $bucket,
'A' => [
'impressions' => 0,
'clicks' => 0,
'purchases' => 0,
'revenue' => 0.0,
],
'B' => [
'impressions' => 0,
'clicks' => 0,
'purchases' => 0,
'revenue' => 0.0,
],
];
}
if ( $event_type === 'impression' ) {
$daily_indexed[ $bucket ][ $variant ]['impressions'] = $total;
} elseif ( $event_type === 'click' ) {
$daily_indexed[ $bucket ][ $variant ]['clicks'] = $total;
} elseif ( $event_type === 'purchase' ) {
$daily_indexed[ $bucket ][ $variant ]['purchases'] = $total;
$daily_indexed[ $bucket ][ $variant ]['revenue'] = round( $revenue, 2 );
}
}
$start_ts = isset( $test['started_at'] ) ? (int) $test['started_at'] : 0;
$end_ts = isset( $test['finished_at'] ) ? (int) $test['finished_at'] : 0;
$first_bucket = array_key_first( $daily_indexed );
if ( $first_bucket ) {
$first_bucket_ts = strtotime( $first_bucket . ' 00:00:00' );
// Always include the earliest recorded event bucket in the chart,
// even if started_at was previously shifted forward by pause/resume.
if ( $start_ts <= 0 || ( $first_bucket_ts && $first_bucket_ts < $start_ts ) ) {
$start_ts = $first_bucket_ts;
}
}
if ( $end_ts <= 0 ) {
$end_ts = current_time( 'timestamp' );
}
$daily_timeline = [];
if ( $start_ts > 0 && $end_ts > 0 && $end_ts >= $start_ts ) {
$cursor = strtotime( gmdate( 'Y-m-d 00:00:00', $start_ts ) );
$end_day = strtotime( gmdate( 'Y-m-d 00:00:00', $end_ts ) );
while ( $cursor <= $end_day ) {
$bucket = gmdate( 'Y-m-d', $cursor );
$daily_timeline[] = isset( $daily_indexed[ $bucket ] )
? $daily_indexed[ $bucket ]
: [
'bucket' => $bucket,
'A' => [
'impressions' => 0,
'clicks' => 0,
'purchases' => 0,
'revenue' => 0.0,
],
'B' => [
'impressions' => 0,
'clicks' => 0,
'purchases' => 0,
'revenue' => 0.0,
],
];
$cursor = strtotime( '+1 day', $cursor );
}
} else {
$daily_timeline = array_values( $daily_indexed );
}
if ( $range === 'day' ) {
$timeline = $daily_timeline;
} else {
$rolled = [];
foreach ( $daily_timeline as $day_row ) {
$day_bucket = isset( $day_row['bucket'] ) ? (string) $day_row['bucket'] : '';
if ( $day_bucket === '' ) {
continue;
}
$day_ts = strtotime( $day_bucket . ' 00:00:00' );
if ( false === $day_ts ) {
continue;
}
if ( $range === 'month' ) {
$rollup_bucket = gmdate( 'Y-m-01', $day_ts );
} else {
$weekday = (int) gmdate( 'N', $day_ts );
$rollup_bucket = gmdate( 'Y-m-d', strtotime( '-' . ( $weekday - 1 ) . ' days', $day_ts ) );
}
if ( ! isset( $rolled[ $rollup_bucket ] ) ) {
$rolled[ $rollup_bucket ] = [
'bucket' => $rollup_bucket,
'A' => [
'impressions' => 0,
'clicks' => 0,
'purchases' => 0,
'revenue' => 0.0,
],
'B' => [
'impressions' => 0,
'clicks' => 0,
'purchases' => 0,
'revenue' => 0.0,
],
];
}
$rolled[ $rollup_bucket ]['A']['impressions'] += isset( $day_row['A']['impressions'] ) ? (int) $day_row['A']['impressions'] : 0;
$rolled[ $rollup_bucket ]['A']['clicks'] += isset( $day_row['A']['clicks'] ) ? (int) $day_row['A']['clicks'] : 0;
$rolled[ $rollup_bucket ]['A']['purchases'] += isset( $day_row['A']['purchases'] ) ? (int) $day_row['A']['purchases'] : 0;
$rolled[ $rollup_bucket ]['A']['revenue'] += isset( $day_row['A']['revenue'] ) ? (float) $day_row['A']['revenue'] : 0.0;
$rolled[ $rollup_bucket ]['B']['impressions'] += isset( $day_row['B']['impressions'] ) ? (int) $day_row['B']['impressions'] : 0;
$rolled[ $rollup_bucket ]['B']['clicks'] += isset( $day_row['B']['clicks'] ) ? (int) $day_row['B']['clicks'] : 0;
$rolled[ $rollup_bucket ]['B']['purchases'] += isset( $day_row['B']['purchases'] ) ? (int) $day_row['B']['purchases'] : 0;
$rolled[ $rollup_bucket ]['B']['revenue'] += isset( $day_row['B']['revenue'] ) ? (float) $day_row['B']['revenue'] : 0.0;
}
foreach ( $rolled as $bucket_key => $bucket_row ) {
$rolled[ $bucket_key ]['A']['revenue'] = round( (float) $bucket_row['A']['revenue'], 2 );
$rolled[ $bucket_key ]['B']['revenue'] = round( (float) $bucket_row['B']['revenue'], 2 );
}
ksort( $rolled );
$timeline = array_values( $rolled );
}
}
if ( ! empty( $wpdb->last_error ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'db_error',
'debug' => $wpdb->last_error,
]
);
}
/*
* Health is independent of the selected chart range. Use the same
* lifetime HTTP-exclusion aggregate as the dashboard so changing
* Day/Week/Month cannot change a test's health status.
*/
$http_exclusions = abtestkit_pt_http_exclusions_bulk( [ $test ] );
$http_context = $http_exclusions[ $test_id ] ?? [
'http_excluded_count' => 0,
'http_excluded_last' => '',
];
$http_excluded_count = (int) $http_context['http_excluded_count'];
$http_excluded_last = (string) $http_context['http_excluded_last'];
if ( ! empty( $wpdb->last_error ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'db_error',
'debug' => $wpdb->last_error,
]
);
}
$stats = abtestkit_pt_stats( $test );
if ( ! empty( $wpdb->last_error ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'db_error',
'debug' => $wpdb->last_error,
]
);
}
$goal = '';
if ( isset( $test['goal'] ) ) {
$goal = abtestkit_pt_normalize_goal_for_display( $test['goal'] );
}
$click_targets = abtestkit_pt_click_targets_for_test( $test );
$kind_label = ( $kind === 'custom_javascript' )
? 'Custom JavaScript'
: (
( $kind === 'custom_html' )
? 'Custom HTML'
: (
( $kind === 'custom_css' )
? 'Custom CSS'
: (
( $kind === 'reusable_section' )
? 'Reusable Section'
: ucfirst( str_replace( '_', ' ', $kind ) )
)
)
);
$reporting_test = abtestkit_pt_custom_code_reporting_test( $test );
$custom_css_data = ( $kind === 'custom_css' )
? abtestkit_custom_css_data_for_test( $reporting_test )
: [
'css_scope' => '',
'custom_css' => '',
'css_markers' => [],
];
$custom_html_data = ( $kind === 'custom_html' )
? abtestkit_custom_html_data_for_test( $reporting_test )
: [
'html_scope' => '',
'html_changes' => [],
];
$testing_title = $control_id > 0 ? get_the_title( $control_id ) : '';
$health = abtestkit_pt_health_summary( $test, $stats, [
'http_excluded_count' => $http_excluded_count,
'http_excluded_last' => $http_excluded_last,
] );
$engagement = abtestkit_pt_engagement_stats( $test_id );
$audience_breakdowns = abtestkit_pt_audience_breakdowns( $test_id, $goal );
$analytics_test = $test;
$analytics_test['goal'] = $goal;
$analytics_summary = abtestkit_build_analytics_summary(
$analytics_test,
$stats,
$engagement,
$health,
$timeline,
$range,
[
'now' => current_time( 'timestamp' ),
'click_target_count' => count( $click_targets ),
'currency' => abtestkit_reporting_currency_settings(),
'audience_breakdowns' => $audience_breakdowns,
]
);
$response = [
'ok' => true,
'test' => [
'id' => isset( $test['id'] ) ? (string) $test['id'] : '',
'title' => isset( $test['title'] ) ? (string) $test['title'] : '',
'kind' => $kind,
'kind_label' => $kind_label,
'testing_title' => $testing_title,
'status' => isset( $test['status'] ) ? (string) $test['status'] : 'paused',
'goal' => $goal,
'links' => $click_targets,
'click_targets' => $click_targets,
'scroll_depth' => abtestkit_pt_scroll_depth_for_test( $test ),
'css_scope' => $custom_css_data['css_scope'],
'custom_css' => $custom_css_data['custom_css'],
'css_markers' => $custom_css_data['css_markers'],
'html_scope' => $custom_html_data['html_scope'],
'html_changes' => $custom_html_data['html_changes'],
'javascript_scope' => $kind === 'custom_javascript' ? (string) ( $reporting_test['javascript_scope'] ?? '' ) : '',
'javascript_code_length' => $kind === 'custom_javascript' ? max( 0, (int) ( $reporting_test['javascript_code_length'] ?? 0 ) ) : 0,
'javascript_run_timing' => $kind === 'custom_javascript' ? (string) ( $reporting_test['javascript_run_timing'] ?? '' ) : '',
'javascript_ajax_policy' => $kind === 'custom_javascript' ? (string) ( $reporting_test['javascript_ajax_policy'] ?? '' ) : '',
'decision_rule' => isset( $test['decision_rule'] ) ? (string) $test['decision_rule'] : 'balanced',
'decision_mode' => isset( $test['decision_mode'] ) ? (string) $test['decision_mode'] : 'auto',
'min_impressions' => isset( $test['min_impressions'] ) ? (int) $test['min_impressions'] : 50,
'min_conversions' => isset( $test['min_conversions'] ) ? (int) $test['min_conversions'] : 5,
'started_at' => isset( $test['started_at'] ) ? (int) $test['started_at'] : 0,
'finished_at' => isset( $test['finished_at'] ) ? (int) $test['finished_at'] : 0,
'edit_in_progress' => ! empty( $test['edit_in_progress'] ),
'winner' => isset( $test['winner'] ) ? (string) $test['winner'] : '',
'winner_basis' => isset( $test['winner_basis'] ) ? sanitize_key( (string) $test['winner_basis'] ) : '',
'winner_applied_at' => isset( $test['winner_applied_at'] ) ? (int) $test['winner_applied_at'] : 0,
'url' => $url ? (string) $url : '',
'preview_a' => $preview_a ? (string) $preview_a : '',
'preview_b' => $preview_b ? (string) $preview_b : '',
'control_id' => $control_id,
'variant_id' => $variant_id,
'stats' => $stats,
'engagement' => $engagement,
'health' => $health,
'analytics_summary' => $analytics_summary,
'auto_paused_broken' => ! empty( $test['auto_paused_broken'] ),
'auto_paused_broken_at' => isset( $test['auto_paused_broken_at'] ) ? (int) $test['auto_paused_broken_at'] : 0,
'timeline' => $timeline,
'http_excluded_count' => $http_excluded_count,
'http_excluded_last' => $http_excluded_last,
],
];
set_transient(
$cache_key,
$response,
abtestkit_pt_performance_cache_ttl( $range )
);
return rest_ensure_response( $response );
},
]
);
// --- Test performance: reset a whole test's metrics ---
register_rest_route(
'abtestkit/v1',
'/pt/reset-test',
[
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
global $wpdb;
$test_id = sanitize_text_field( (string) $req->get_param( 'test_id' ) );
if ( $test_id === '' ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'missing_test_id',
]
);
}
$test = abtestkit_pt_get( $test_id );
if ( ! is_array( $test ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'not_found',
]
);
}
$table = ABTESTKIT_EVENTS_TABLE;
// Delete by test ID only so reset clears the same data source used by cards and timeline.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$deleted = $wpdb->query(
$wpdb->prepare(
"DELETE FROM %i
WHERE ab_test_id = %s",
$table,
$test_id
)
);
$control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
$kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
$post_ids = [ $control_id ];
if ( $kind !== 'product' && $variant_id > 0 ) {
$post_ids[] = $variant_id;
}
$post_ids = array_values( array_unique( array_filter( array_map( 'absint', $post_ids ) ) ) );
foreach ( $post_ids as $pid ) {
wp_cache_delete( "stats:$pid:$test_id", 'abtestkit_stats' );
wp_cache_delete( "stats_multi:$pid", 'abtestkit_stats' );
}
// The Performance page caches its complete response separately
// from the object-cache entries above. Reset must invalidate all
// ranges before the browser immediately reloads the test.
abtestkit_pt_flush_test_caches( $test_id );
return rest_ensure_response(
[
'ok' => true,
'deleted' => (int) $deleted,
]
);
},
]
);
// --- Page Test Wizard: delete an unused temporary duplicate/shadow created during the wizard ---
register_rest_route(
'abtestkit/v1',
'/pt/cleanup-duplicate',
[
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
$control_id = absint( $req->get_param( 'control_id' ) );
$variant_id = absint( $req->get_param( 'variant_id' ) );
$user_id = (int) get_current_user_id();
$post_type = $control_id ? get_post_type( $control_id ) : '';
$allowed_types = [ 'page', 'post', 'product' ];
if ( ! $control_id || ! in_array( $post_type, $allowed_types, true ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'invalid_control',
]
);
}
if ( $variant_id <= 0 ) {
$variant_id = abtestkit_pt_get_last_duplicate_for_user( (int) $control_id, (int) $user_id );
}
if ( $variant_id <= 0 ) {
abtestkit_pt_clear_last_duplicate_for_user( (int) $control_id, (int) $user_id );
return rest_ensure_response(
[
'ok' => true,
'deleted' => 0,
'deleted_children' => 0,
]
);
}
$variant = get_post( $variant_id );
if ( ! $variant ) {
abtestkit_pt_clear_last_duplicate_for_user( (int) $control_id, (int) $user_id );
return rest_ensure_response(
[
'ok' => true,
'deleted' => 0,
'deleted_children' => 0,
]
);
}
if ( $variant->post_type !== $post_type ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'invalid_variant',
]
);
}
if ( abtestkit_pt_variant_id_in_any_test( (int) $variant_id ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'variant_in_use',
]
);
}
if ( ! function_exists( 'abtestkit_is_shadow_variant' ) || ! abtestkit_is_shadow_variant( (int) $variant_id ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'invalid_variant',
]
);
}
$shadow_of = (int) get_post_meta( $variant_id, '_abtestkit_shadow_of', true );
if ( $shadow_of !== (int) $control_id ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'invalid_variant',
]
);
}
$deleted_children = 0;
// Variable-product shadows can have shadow child variations.
// Delete those first so product cleanup is complete and reliable.
if ( $post_type === 'product' ) {
$child_ids = get_children(
[
'post_parent' => (int) $variant_id,
'post_type' => 'product_variation',
'post_status' => 'any',
'fields' => 'ids',
'posts_per_page' => -1,
]
);
if ( is_array( $child_ids ) && ! empty( $child_ids ) ) {
foreach ( $child_ids as $child_id ) {
$child_id = (int) $child_id;
if ( $child_id <= 0 ) {
continue;
}
$child_deleted = wp_delete_post( $child_id, true );
if ( $child_deleted ) {
$deleted_children++;
}
}
}
}
$deleted = wp_delete_post( $variant_id, true );
if ( ! $deleted ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'delete_failed',
]
);
}
abtestkit_pt_clear_last_duplicate_for_user( (int) $control_id, (int) $user_id );
if ( function_exists( 'abtestkit_clear_shadow_counts_cache' ) ) {
abtestkit_clear_shadow_counts_cache( $post_type );
}
clean_post_cache( (int) $variant_id );
clean_post_cache( (int) $control_id );
return rest_ensure_response(
[
'ok' => true,
'deleted' => (int) $variant_id,
'deleted_children' => (int) $deleted_children,
]
);
},
]
);
// --- Page Test Wizard: duplicate Version A now (returns the new page so you can edit it) ---
register_rest_route(
'abtestkit/v1',
'/pt/duplicate',
[
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => function ( WP_REST_Request $req ) {
if ( ! abtestkit_connected_platform_can_create_or_launch( true ) ) {
return abtestkit_connected_platform_required_error();
}
$control_id = absint( $req->get_param( 'control_id' ) );
$requested_test_type = sanitize_key( (string) $req->get_param( 'test_type' ) );
$post_type = $control_id ? get_post_type( $control_id ) : '';
$allowed_types = [ 'page', 'post', 'product' ];
if ( ! $control_id || ! in_array( $post_type, $allowed_types, true ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'invalid_control',
]
);
}
// Block creating a Version B draft if this page/product is already in a running test.
// For reusable-section tests, also block if this source page is embedded inside
// another running page/product/post test.
$conflict_kind = ( $requested_test_type === 'reusable_section' ) ? 'reusable_section' : $post_type;
$conflicts = abtestkit_pt_conflicts_for_pages( (int) $control_id, 0, '', $conflict_kind );
if ( ! empty( $conflicts ) ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'conflict_running',
'info' => [
'message' => 'This page is already in a running test, or a reusable section test is causing a conflict.',
'conflicts' => $conflicts,
],
]
);
}
$user_id = (int) get_current_user_id();
// If this user already duplicated this control in the current wizard session,
// return the same B post instead of creating another clone.
$variant_id = abtestkit_pt_get_last_duplicate_for_user( (int) $control_id, (int) $user_id );
if ( ! $variant_id ) {
$variant_id = abtestkit_duplicate_post_deep( $control_id );
if ( $variant_id ) {
abtestkit_pt_set_last_duplicate_for_user( (int) $control_id, (int) $variant_id, (int) $user_id );
}
}
if ( ! $variant_id ) {
return rest_ensure_response(
[
'ok' => false,
'error' => 'duplicate_failed',
]
);
}
if (
$post_type === 'product'
&& function_exists( 'abtestkit_pt_sync_shadow_product_type' )
) {
abtestkit_pt_sync_shadow_product_type( (int) $control_id, (int) $variant_id );
}
$preview_url = '';
if ( $post_type !== 'product' ) {
if ( function_exists( 'get_preview_post_link' ) ) {
$preview_url = (string) get_preview_post_link( $variant_id );
}
if ( $preview_url === '' ) {
$preview_url = (string) get_permalink( $variant_id );
}
if ( $preview_url !== '' ) {
$preview_url = add_query_arg( 'abtestkit_preview', '1', $preview_url );
}
}
$page = [
'id' => (int) $variant_id,
'title' => get_the_title( $variant_id ),
'status' => get_post_status( $variant_id ),
'preview_url' => $preview_url,
'date' => get_the_date( 'Y/m/d', $variant_id ),
'date_iso' => get_post_field( 'post_date', $variant_id ),
];
return rest_ensure_response(
[
'ok' => true,
'page' => $page,
]
);
},
]
);
} );
/**
* Load block-config.json into a PHP array for localization.
*/
function abtestkit_load_block_config() {
// Make sure block-config.json lives under assets/js/, next to editor.js & ab-sidebar.js
$config_path = plugin_dir_path( __FILE__ ) . 'assets/js/block-config.json';
if ( ! file_exists( $config_path ) ) {
return [];
}
$json = file_get_contents( $config_path );
$arr = json_decode( $json, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return [];
}
return $arr;
}
function abtestkit_make_sig( int $post_id, int $ts ): string {
return hash_hmac('sha256', $post_id . '|' . $ts, wp_salt('auth'));
}
function abtestkit_sanitize_test_id( $id ): string {
return preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $id);
}
function abtestkit_rest_check_nonce(WP_REST_Request $request) {
$nonce = $request->get_header('X-WP-Nonce');
if (!$nonce) return false;
return wp_verify_nonce($nonce, 'wp_rest') === 1;
}
function abtestkit_rest_permission( WP_REST_Request $request ) {
return abtestkit_rest_check_nonce( $request ) && current_user_can( 'manage_options' );
}
function abtestkit_handle_html_runtime_health( WP_REST_Request $request ) {
if ( ! abtestkit_is_same_origin_request() && ! abtestkit_rest_check_nonce( $request ) ) {
return new WP_Error(
'abtestkit_html_health_forbidden',
__( 'This selector health report could not be verified.', 'abtestkit' ),
[ 'status' => 403 ]
);
}
$test_id = abtestkit_sanitize_test_id( $request->get_param( 'test_id' ) );
$control_id = absint( $request->get_param( 'control_id' ) );
$test = $test_id !== '' ? abtestkit_pt_get( $test_id ) : null;
if (
! is_array( $test )
|| ( $test['kind'] ?? '' ) !== 'custom_html'
|| ( $test['status'] ?? '' ) !== 'running'
|| (int) ( $test['control_id'] ?? 0 ) !== $control_id
) {
return new WP_Error(
'abtestkit_html_health_test_not_found',
__( 'The running Custom HTML test could not be verified.', 'abtestkit' ),
[ 'status' => 404 ]
);
}
$changes = isset( $test['html_changes'] )
? abtestkit_sanitize_custom_html_changes( $test['html_changes'] )
: [];
$total = count( $changes );
$matched = max( 0, (int) $request->get_param( 'matched' ) );
$missing = max( 0, (int) $request->get_param( 'missing' ) );
$invalid = max( 0, (int) $request->get_param( 'invalid' ) );
if (
$total <= 0
|| (int) $request->get_param( 'total' ) !== $total
|| $matched + $missing + $invalid !== $total
) {
return new WP_Error(
'abtestkit_html_health_invalid_counts',
__( 'The selector health counts did not match the saved test.', 'abtestkit' ),
[ 'status' => 400 ]
);
}
$minute_key = 'abtestkit_html_health_' . md5( $test_id . '|' . gmdate( 'YmdHi' ) );
$requests = (int) get_transient( $minute_key );
if ( $requests >= 30 ) {
return new WP_Error(
'abtestkit_html_health_rate_limited',
__( 'Selector health is already up to date. Try again shortly.', 'abtestkit' ),
[ 'status' => 429 ]
);
}
set_transient( $minute_key, $requests + 1, 2 * MINUTE_IN_SECONDS );
$record = abtestkit_html_runtime_health_record(
$test,
[
'matched' => $matched,
'missing' => $missing,
'invalid' => $invalid,
]
);
if ( empty( $record ) ) {
return new WP_Error(
'abtestkit_html_health_not_saved',
__( 'The selector health report could not be saved.', 'abtestkit' ),
[ 'status' => 400 ]
);
}
return rest_ensure_response(
[
'ok' => true,
'status' => (string) $record['status'],
]
);
}
/**
* Register REST endpoints: /track, /stats, /evaluate, /reset.
*/
add_action('rest_api_init', function() {
// Tracking endpoint (anonymous allowed; security handled inside handler)
register_rest_route('abtestkit/v1', '/track', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => 'abtestkit_handle_track',
]);
register_rest_route( 'abtestkit/v1', '/pt/html-runtime-health', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => 'abtestkit_handle_html_runtime_health',
] );
// Secure endpoints (require nonce+capability)
foreach (['stats', 'evaluate', 'reset'] as $route) {
register_rest_route('abtestkit/v1', "/$route", [
'methods' => $route === 'reset' ? 'POST' : 'GET',
'permission_callback' => 'abtestkit_rest_permission',
'callback' => "abtestkit_handle_$route",
]);
}
// Capture delete reason from the Installed Plugins screen before WP deletes the plugin.
register_rest_route('abtestkit/v1', '/delete-reason', [
'methods' => 'POST',
'permission_callback' => function( WP_REST_Request $request ) {
return abtestkit_rest_check_nonce( $request ) && current_user_can( 'activate_plugins' );
},
'callback' => function( WP_REST_Request $request ) {
$reason = sanitize_key( (string) $request->get_param( 'reason' ) );
$detail = sanitize_textarea_field( (string) $request->get_param( 'detail' ) );
$detail_tag = sanitize_key( (string) $request->get_param( 'detail_tag' ) );
$area = sanitize_key( (string) $request->get_param( 'area' ) );
$wizard = $request->get_param( 'wizard' );
$wizard = is_array( $wizard ) ? $wizard : [];
abtestkit_telemetry_track_plugin_delete_reason( $reason, $detail, $detail_tag, $area, $wizard );
return rest_ensure_response( [ 'ok' => true ] );
},
]);
});
/**
* One-off upgrade: remove " (Version B)" suffix from any existing titles created by
* older versions of the plugin that appended this to duplicated pages/posts/products.
*/
function abtestkit_upgrade_strip_version_b_titles() {
// Only run once.
if ( get_option( 'abtestkit_fixed_version_b_titles' ) ) {
return;
}
$suffix = ' (Version B)';
$post_types = [ 'page', 'post', 'product' ];
$args = [
'post_type' => $post_types,
'post_status' => 'any',
'posts_per_page' => -1,
's' => $suffix, // candidate filter; we still double-check in PHP.
'fields' => 'ids',
];
$query = new WP_Query( $args );
if ( $query->have_posts() ) {
foreach ( $query->posts as $post_id ) {
$title = get_the_title( $post_id );
if ( substr( $title, -strlen( $suffix ) ) === $suffix ) {
$clean = substr( $title, 0, -strlen( $suffix ) );
wp_update_post( [
'ID' => $post_id,
'post_title' => $clean,
] );
}
}
}
update_option( 'abtestkit_fixed_version_b_titles', 1 );
}
add_action( 'admin_init', 'abtestkit_upgrade_strip_version_b_titles' );
function abtestkit_log_event_to_db( $type, $post_id, $ab_test_id, $variant, array $extra = [] ) {
global $wpdb;
// keep IDs tight and types sane
$ab_test_id = abtestkit_sanitize_test_id( $ab_test_id );
$variant = ( $variant === 'A' || $variant === 'B' ) ? $variant : '';
$allowed = [ 'impression', 'click', 'purchase', 'engagement', 'decision', 'decision_applied', 'stale', 'protocol_warning' ];
$event_type = in_array( $type, $allowed, true ) ? $type : 'impression';
// Privacy-safe storage: hash IP / UA so we keep light dedupe value without raw personal data.
// Engagement aggregates do not need either value, so avoid storing them for those samples.
$ip_raw = $event_type !== 'engagement' && isset( $_SERVER['REMOTE_ADDR'] )
? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
: '';
$ip = '';
if ( filter_var( $ip_raw, FILTER_VALIDATE_IP ) ) {
$ip = substr( hash_hmac( 'sha256', $ip_raw, wp_salt( 'auth' ) ), 0, 32 );
}
$ua_raw = $event_type !== 'engagement' && isset( $_SERVER['HTTP_USER_AGENT'] )
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
: '';
$ua = '';
if ( $ua_raw !== '' ) {
$ua = substr( hash_hmac( 'sha256', $ua_raw, wp_salt( 'auth' ) ), 0, 32 );
}
$order_id = 0;
if ( isset( $extra['order_id'] ) ) {
$order_id = absint( $extra['order_id'] );
}
$amount = null;
if ( isset( $extra['amount'] ) && is_numeric( $extra['amount'] ) ) {
$amount = number_format( (float) $extra['amount'], 2, '.', '' );
}
$protocol = '';
if ( isset( $extra['protocol'] ) ) {
$protocol = sanitize_key( (string) $extra['protocol'] );
}
$excluded_reason = '';
if ( isset( $extra['excluded_reason'] ) ) {
$excluded_reason = sanitize_key( (string) $extra['excluded_reason'] );
}
$scroll_pct = 0;
if ( isset( $extra['scroll_pct'] ) && is_numeric( $extra['scroll_pct'] ) ) {
$scroll_pct = max( 0, min( 100, (int) round( (float) $extra['scroll_pct'] ) ) );
}
$time_sec = 0;
if ( isset( $extra['time_sec'] ) && is_numeric( $extra['time_sec'] ) ) {
$time_sec = max( 0, min( 3600, (int) round( (float) $extra['time_sec'] ) ) );
}
$audience = isset( $extra['audience'] )
? abtestkit_audience_normalize_context( $extra['audience'] )
: abtestkit_audience_get_exposure_context( $ab_test_id );
if ( empty( $audience ) ) {
$audience = [
'device_class' => 'unknown',
'country_code' => 'unknown',
];
}
/*
* Final safety net:
* never write normal tracking rows for non-HTTPS frontend requests,
* even if a higher-level caller reaches this function by mistake.
*/
if ( $event_type !== 'protocol_warning' ) {
// Absolute request-level guard.
if ( ! abtestkit_request_is_real_https() ) {
return;
}
// Caller-provided protocol guard.
if ( $protocol === 'http' ) {
return;
}
// Matching page-test request-context guard.
if (
isset( $GLOBALS['abtestkit_current_pt_assignment'] )
&& is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
) {
$ctx = $GLOBALS['abtestkit_current_pt_assignment'];
$ctx_protocol = isset( $ctx['protocol'] )
? sanitize_key( (string) $ctx['protocol'] )
: '';
$ctx_no_track = ! empty( $ctx['no_track'] );
$ctx_test_id = '';
if ( isset( $ctx['test'] ) && is_array( $ctx['test'] ) && isset( $ctx['test']['id'] ) ) {
$ctx_test_id = abtestkit_sanitize_test_id( (string) $ctx['test']['id'] );
}
if (
$ctx_test_id !== ''
&& $ctx_test_id === $ab_test_id
&& ( $ctx_no_track || $ctx_protocol === 'http' )
) {
return;
}
}
}
$now = current_time( 'mysql' );
$data = [
'time' => $now,
'post_id' => absint( $post_id ),
'ab_test_id' => $ab_test_id,
'variant' => $variant,
'event_type' => $event_type,
'order_id' => $order_id > 0 ? $order_id : null,
'amount' => $amount,
'protocol' => $protocol !== '' ? $protocol : null,
'excluded_reason' => $excluded_reason !== '' ? $excluded_reason : null,
'ip' => $ip,
'user_agent' => $ua,
];
$format = [
'%s', // time
'%d', // post_id
'%s', // ab_test_id
'%s', // variant
'%s', // event_type
'%d', // order_id
'%s', // amount
'%s', // protocol
'%s', // excluded_reason
'%s', // ip
'%s', // user_agent
];
if ( $event_type === 'engagement' && abtestkit_events_table_has_column( 'scroll_pct' ) ) {
$data['scroll_pct'] = $scroll_pct;
$format[] = '%d';
}
if ( $event_type === 'engagement' && abtestkit_events_table_has_column( 'time_sec' ) ) {
$data['time_sec'] = $time_sec;
$format[] = '%d';
}
if ( abtestkit_events_table_has_column( 'device_class' ) ) {
$data['device_class'] = abtestkit_audience_normalize_device_class( $audience['device_class'] ?? '' );
$format[] = '%s';
}
if ( abtestkit_events_table_has_column( 'country_code' ) ) {
$data['country_code'] = abtestkit_audience_normalize_country_code( $audience['country_code'] ?? '' );
$format[] = '%s';
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
$wpdb->insert( ABTESTKIT_EVENTS_TABLE, $data, $format );
if ( $event_type === 'engagement' ) {
return;
}
// Invalidate per-post/test stats cache so dashboard + performance reflect new rows.
wp_cache_delete( "stats:$post_id:$ab_test_id", 'abtestkit_stats' );
wp_cache_delete( "stats_multi:$post_id", 'abtestkit_stats' );
abtestkit_pt_flush_test_caches( (string) $ab_test_id );
}
// Save A/B test variants into post meta on save
add_action('save_post', function ($post_id) {
if (wp_is_post_revision($post_id)) return;
$content = get_post_field('post_content', $post_id);
$blocks = parse_blocks($content);
$variants = [];
$extract_variants = function($blocks) use (&$extract_variants, &$variants) {
foreach ($blocks as $block) {
if (!is_array($block) || !isset($block['blockName'])) continue;
$attrs = $block['attrs'] ?? [];
$abTestId = $attrs['abTestId'] ?? null;
$abTestVariants = $attrs['abTestVariants'] ?? null;
if ($abTestId && $abTestVariants && isset($abTestVariants[$abTestId])) {
$variants[$abTestId] = $abTestVariants[$abTestId];
$locked = $abTestVariants[$abTestId]['locked'] ?? true;
$variants[$abTestId]['running'] = !$locked;
}
// Recursively scan innerBlocks if needed
if (!empty($block['innerBlocks'])) {
$extract_variants($block['innerBlocks']);
}
}
};
$extract_variants($blocks);
update_post_meta($post_id, '_abtestkit_variants', $variants);
});
// Frontend-only injection of data-ab-test-id
add_filter('render_block', function ($block_content, $block) {
if (is_admin()) return $block_content; // don't touch editor
$name = $block['blockName'] ?? '';
$supported = ['core/button','core/heading','core/paragraph','core/image'];
if (!in_array($name, $supported, true)) return $block_content;
if (!$block_content) return $block_content;
// Use existing abTestId if present; otherwise make a stable one
$ab_id = $block['attrs']['abTestId'] ?? '';
if (!$ab_id) {
$post_id = get_the_ID() ?: 0;
$seed = $post_id . '|' . $name . '|' . wp_json_encode($block['attrs'] ?? []);
$ab_id = 'ab-' . substr(md5($seed), 0, 9);
}
// Inject attributes with DOMDocument
$html = ''.$block_content.'
';
libxml_use_internal_errors(true);
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->loadHTML(''.$html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$wrap = $dom->getElementById('__abtest_wrap__');
if (!$wrap || !$wrap->firstChild) return $block_content;
// Add data-ab-test-id on outermost element
$root = $wrap->firstChild;
if ($root instanceof DOMElement && !$root->hasAttribute('data-ab-test-id')) {
$root->setAttribute('data-ab-test-id', $ab_id);
}
// 🔗 Group Sync: if this block is configured to sync, tag the DOM with data-ab-group
$attrs = $block['attrs'] ?? [];
if ( ! empty( $attrs['abSync'] ) && ! empty( $attrs['abGroupKey'] ) ) {
$group = sanitize_key( (string) $attrs['abGroupKey'] );
if ( $group && $root instanceof DOMElement && ! $root->hasAttribute('data-ab-group') ) {
$root->setAttribute('data-ab-group', $group);
}
}
// For buttons, also add it on
if ($name === 'core/button') {
$xpath = new DOMXPath($dom);
$aList = $xpath->query('//*[@class and contains(concat(" ", normalize-space(@class), " "), " wp-block-button__link ")]');
foreach ($aList as $a) {
if ($a instanceof DOMElement && !$a->hasAttribute('data-ab-test-id')) {
$a->setAttribute('data-ab-test-id', $ab_id);
}
}
}
// Return inner HTML
$out = '';
foreach ($wrap->childNodes as $child) {
$out .= $dom->saveHTML($child);
}
return $out;
}, 10, 2);
/**
* Dynamically extend ACF blocks to include abTestId and abTestVariants attributes.
*/
add_filter('register_block_type_args', function($args, $block_name) {
if ($block_name === 'acf/bv-panel') {
$args['attributes']['abTestId'] = [
'type' => 'string',
'default' => '',
];
$args['attributes']['abTestVariants'] = [
'type' => 'object',
'default' => [],
];
$args['attributes']['abTestEnabled'] = [
'type' => 'boolean',
'default' => false,
];
$args['attributes']['abTestRunning'] = [
'type' => 'boolean',
'default' => false,
];
$args['attributes']['abTestWinner'] = [
'type' => 'string',
'default' => '',
];
}
return $args;
}, 10, 2);
// 3️⃣ Beta + Gamma samplers
function abtestkit_sample_beta($alpha, $beta) {
$x = abtestkit_sample_gamma($alpha, 1.0);
$y = abtestkit_sample_gamma($beta, 1.0);
return $x / ($x + $y);
}
function abtestkit_sample_gamma($shape, $scale) {
if ($shape >= 1) {
$d = $shape - 1/3;
$c = 1 / sqrt(9 * $d);
while (true) {
do {
$u = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
$v = 1 + $c * abtestkit_normal_rand();
} while ($v <= 0);
$v = $v * $v * $v;
$u2 = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
if ($u2 < 1 - 0.0331 * pow(abtestkit_normal_rand(), 4)) break;
if (log($u2) < 0.5 * pow(abtestkit_normal_rand(), 2) + $d * (1 - $v + log($v))) break;
}
return $d * $v * $scale;
}
$u = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
return abtestkit_sample_gamma($shape + 1, $scale) * pow( max( $u, 1.0 / PHP_INT_MAX ), 1 / $shape );
}
function abtestkit_normal_rand() {
static $useLast = false, $y2;
if ( $useLast ) {
$useLast = false;
return $y2;
}
// Ensure u1 ∈ (0,1) to avoid log(0)
do {
$u1 = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
} while ($u1 <= 0.0 || $u1 >= 1.0);
$u2 = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
$r = sqrt(-2.0 * log($u1));
$t = 2.0 * pi() * $u2;
$x1 = $r * cos($t);
$y2 = $r * sin($t);
$useLast = true;
return $x1;
}
/**
* Sample Revenue Per Visitor for purchase-goal tests.
*
* Model:
* - purchase rate ~ Beta posterior
* - average order value ~ Gamma approximation around observed AOV
* - RPV sample = sampled purchase rate * sampled AOV
*
* This keeps purchase-goal auto-winning focused on money per visitor,
* while still accounting for uncertainty in both order rate and order value.
*/
function abtestkit_sample_rpv( int $impressions, int $purchases, float $revenue ): float {
$impressions = max( 0, $impressions );
$purchases = max( 0, $purchases );
$revenue = max( 0.0, $revenue );
// Purchase-rate posterior
$rate_sample = abtestkit_sample_beta(
1 + $purchases,
1 + max( 0, $impressions - $purchases )
);
// If there are no orders or no revenue, RPV is driven to zero.
if ( $purchases <= 0 || $revenue <= 0 ) {
return 0.0;
}
$aov = $revenue / $purchases;
/*
* Gamma approximation for AOV uncertainty:
* mean = AOV
* var = AOV^2 / purchases
* shape = purchases
* scale = AOV / purchases
*/
$shape = max( 1, $purchases );
$scale = max( 0.0001, $aov / $shape );
$aov_sample = abtestkit_sample_gamma( $shape, $scale );
return max( 0.0, $rate_sample * $aov_sample );
}
/**
* ─────────────────────────────────────────────────────────────────────────────
* PHP-side variant rendering for core/image, core/heading, core/paragraph, core/button
* Pattern: wrapper [data-ab-test-id], 2 children [data-ab-variant="A|B"] hidden by default
* ─────────────────────────────────────────────────────────────────────────────
*/
function abtestkit_can_render_variants(array $block): bool {
$attrs = $block['attrs'] ?? [];
if (empty($attrs['abTestEnabled']) || empty($attrs['abTestId'])) return false;
$id = $attrs['abTestId'];
$all = $attrs['abTestVariants'][$id] ?? [];
return is_array($all) && isset($all['A']) && isset($all['B']);
}
function abtestkit_pick($arr, $keys, $fallback = '') {
foreach ((array)$keys as $k) {
if (isset($arr[$k]) && $arr[$k] !== '') return $arr[$k];
}
return $fallback;
}
function abtestkit_clean_id($id) {
return preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$id);
}
/**
* core/image → outputs with two
*/
add_filter('render_block_core/image', function($html, $block){
if (!abtestkit_can_render_variants($block)) return $html;
$attrs = $block['attrs'];
$id = abtestkit_clean_id($attrs['abTestId']);
$varArr = $attrs['abTestVariants'][$id];
$A = $varArr['A'] ?? [];
$B = $varArr['B'] ?? [];
// Try common keys; fall back to existing markup’s
$existing_src = '';
if (preg_match('/
]+src=["\']([^"\']+)["\']/i', $html, $m)) {
$existing_src = $m[1];
}
$srcA = abtestkit_pick($A, ['url','src','image','img'], $existing_src);
$srcB = abtestkit_pick($B, ['url','src','image','img'], $existing_src);
// Alt text if present in variants, otherwise try to scrape from existing
$altExisting = '';
if (preg_match('/
]+alt=["\']([^"\']*)["\']/i', $html, $m)) {
$altExisting = $m[1];
}
$altA = abtestkit_pick($A, ['alt','alt_text'], $altExisting);
$altB = abtestkit_pick($B, ['alt','alt_text'], $altExisting);
// Preserve figcaption if present
$caption = '';
if (preg_match('/]*>.*?<\/figcaption>/is', $html, $m)) {
$caption = $m[0];
}
$groupAttr = '';
$attrsAll = $block['attrs'] ?? [];
if ( ! empty( $attrsAll['abSync'] ) && ! empty( $attrsAll['abGroupKey'] ) ) {
$groupAttr = ' data-ab-group="' . esc_attr( sanitize_key( (string) $attrsAll['abGroupKey'] ) ) . '"';
}
$out = '';
$out .= '
';
$out .= '
';
$out .= $caption;
$out .= '';
return $out;
}, 10, 2);
/**
* core/heading → wraps one heading tag containing two spans with variant text
*/
add_filter('render_block_core/heading', function($html, $block){
if (!abtestkit_can_render_variants($block)) return $html;
$attrs = $block['attrs'];
$id = abtestkit_clean_id($attrs['abTestId']);
$varArr = $attrs['abTestVariants'][$id];
$A = $varArr['A'] ?? [];
$B = $varArr['B'] ?? [];
// Determine tag (h1..h6); fall back to h2
$tag = 'h2';
if (preg_match('/<(h[1-6])\b/i', $html, $m)) $tag = strtolower($m[1]);
// Variant text: prefer 'content' then 'text'; fallback to stripped existing
$existing = trim( wp_strip_all_tags( $html, true ) );
$textA = abtestkit_pick($A, ['content','text','html'], $existing);
$textB = abtestkit_pick($B, ['content','text','html'], $existing);
$groupAttr = '';
$attrsAll = $block['attrs'] ?? [];
if ( ! empty( $attrsAll['abSync'] ) && ! empty( $attrsAll['abGroupKey'] ) ) {
$groupAttr = ' data-ab-group="' . esc_attr( sanitize_key( (string) $attrsAll['abGroupKey'] ) ) . '"';
}
$out = '<' . $tag . ' data-ab-test-id="' . esc_attr($id) . '"' . $groupAttr . '>';
$out .= '' . wp_kses_post($textA) . '';
$out .= '' . wp_kses_post($textB) . '';
$out .= '' . $tag . '>';
return $out;
}, 10, 2);
/**
* core/paragraph → renders two spans with variant text inside a
*/
add_filter('render_block_core/paragraph', function($html, $block){
if (!abtestkit_can_render_variants($block)) return $html;
$attrs = $block['attrs'];
$id = abtestkit_clean_id($attrs['abTestId']);
$varArr = $attrs['abTestVariants'][$id];
$A = $varArr['A'] ?? [];
$B = $varArr['B'] ?? [];
$existing = trim( wp_strip_all_tags( $html, true ) );
$textA = abtestkit_pick($A, ['content','text','html'], $existing);
$textB = abtestkit_pick($B, ['content','text','html'], $existing);
$groupAttr = '';
$attrsAll = $block['attrs'] ?? [];
if ( ! empty( $attrsAll['abSync'] ) && ! empty( $attrsAll['abGroupKey'] ) ) {
$groupAttr = ' data-ab-group="' . esc_attr( sanitize_key( (string) $attrsAll['abGroupKey'] ) ) . '"';
}
$out = '
';
$out .= '' . wp_kses_post($textA) . '';
$out .= '' . wp_kses_post($textB) . '';
$out .= '
';
return $out;
}, 10, 2);
/**
* core/button → your plugin already did this; here’s a hardened version.
* Keeps original attributes, injects two label spans. (URL can be variant too.)
*/
add_filter('render_block_core/button', function ($html, $block) {
// Only touch when an AB test is actually running on this button
if (empty($block['attrs']['abTestEnabled']) || empty($block['attrs']['abTestId'])) {
return $html;
}
$id = abtestkit_clean_id($block['attrs']['abTestId']);
$vars = $block['attrs']['abTestVariants'][$id] ?? [];
if (empty($vars['A']) || empty($vars['B'])) return $html;
// Parse the existing so we preserve ALL original attributes/classes (incl. Popup Maker)
libxml_use_internal_errors(true);
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$a = $dom->getElementsByTagName('a')->item(0);
if (!$a) return $html;
// Add AB markers, but DO NOT touch href/class/data-* from the original block
$a->setAttribute('data-ab-test-id', $id);
$a->setAttribute('data-ab-index', '0');
$attrsAll = $block['attrs'] ?? [];
if ( ! empty( $attrsAll['abSync'] ) && ! empty( $attrsAll['abGroupKey'] ) ) {
$group = sanitize_key( (string) $attrsAll['abGroupKey'] );
if ( $group ) {
$a->setAttribute('data-ab-group', $group);
}
}
// Current visible label (fallback)
$existing = trim($a->textContent);
// Compute variant labels (prefer content/text if provided)
$labelA = '';
$labelB = '';
$labelA = isset($vars['A']['content']) ? $vars['A']['content'] :
(isset($vars['A']['text']) ? $vars['A']['text'] : $existing);
$labelB = isset($vars['B']['content']) ? $vars['B']['content'] :
(isset($vars['B']['text']) ? $vars['B']['text'] : $existing);
// Optional: attach variant URLs to the span as data-href (frontend.js already reads this)
$urlA = '';
foreach (['url','myButtonURL','href'] as $k) { if (!empty($vars['A'][$k])) { $urlA = $vars['A'][$k]; break; } }
$urlB = '';
foreach (['url','myButtonURL','href'] as $k) { if (!empty($vars['B'][$k])) { $urlB = $vars['B'][$k]; break; } }
// Remove plain text nodes (keep icons/elements) before inserting our spans
for ($i = $a->childNodes->length - 1; $i >= 0; $i--) {
$n = $a->childNodes->item($i);
if ($n->nodeType === XML_TEXT_NODE) {
$a->removeChild($n);
}
}
// Build hidden A & B spans (JS reveals the assigned one)
$spanA = $dom->createElement('span');
$spanA->setAttribute('data-ab-variant', 'A');
$spanA->setAttribute('style', 'display:none');
$spanA->appendChild($dom->createTextNode($labelA));
if ($urlA !== '') $spanA->setAttribute('data-href', esc_url_raw($urlA));
$spanB = $dom->createElement('span');
$spanB->setAttribute('data-ab-variant', 'B');
$spanB->setAttribute('style', 'display:none');
$spanB->appendChild($dom->createTextNode($labelB));
if ($urlB !== '') $spanB->setAttribute('data-href', esc_url_raw($urlB));
$a->appendChild($spanA);
$a->appendChild($spanB);
// Return just the updated fragment
return $dom->saveHTML($dom->documentElement);
}, 10, 2);
/**
* ACF block (bv-panel) wrapper injection (minimal): wrap existing HTML with data-ab-test-id
* and append two transparent markers if you need them later.
*/
add_filter('render_block_acf/bv-panel', function($html, $block){
if (!abtestkit_can_render_variants($block)) return $html;
$id = abtestkit_clean_id($block['attrs']['abTestId']);
// If it already starts with a tag, inject attribute on the first tag.
if (preg_match('/^<([a-z0-9:-]+)\b/i', $html, $m)) {
$tag = $m[1];
return preg_replace(
'/^<' . preg_quote($tag, '/') . '\b(?![^>]*data-ab-test-id)/i',
'<' . $tag . ' data-ab-test-id="' . esc_attr($id) . '"',
$html,
1
);
}
// Fallback: wrap
return '' . $html . '
';
}, 10, 2);
/**
* Accept only requests coming from this site (same-origin).
*/
function abtestkit_safe_set_cookie( string $name, string $value, int $expires = 0 ): bool {
$path = COOKIEPATH ?: '/';
$domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
$secure = abtestkit_request_is_real_https();
$httponly = true;
if ( ! headers_sent() ) {
return setcookie(
$name,
$value,
[
'expires' => $expires,
'path' => $path,
'domain' => $domain,
'secure' => $secure,
'httponly' => $httponly,
'samesite' => 'Lax',
]
);
}
// Fallback for WooCommerce flows when output has already started.
// This avoids frontend warnings while still keeping assignment/click state alive.
if ( function_exists( 'WC' ) && WC() && isset( WC()->session ) && WC()->session ) {
WC()->session->set( 'abtk_cookie_' . $name, [
'value' => $value,
'expires' => $expires,
] );
return false;
}
return false;
}
function abtestkit_safe_get_cookie_value( string $name ): string {
if ( isset( $_COOKIE[ $name ] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
return sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) );
}
if ( function_exists( 'WC' ) && WC() && isset( WC()->session ) && WC()->session ) {
$stored = WC()->session->get( 'abtk_cookie_' . $name );
if ( is_array( $stored ) && isset( $stored['value'] ) ) {
return sanitize_text_field( (string) $stored['value'] );
}
}
return '';
}
/**
* Trackability must reflect the real frontend request, not a misleading proxy hint.
*
* Deliberately DO NOT trust HTTP_X_FORWARDED_PROTO / HTTP_X_FORWARDED_SSL here,
* because this site is proving they can say "https" while the browser is on an
* actual http:// page.
*/
function abtestkit_request_is_real_https(): bool {
$allowed_server_keys = [
'REQUEST_SCHEME',
'HTTPS',
'HTTP_X_FORWARDED_PROTO',
'HTTP_X_FORWARDED_SCHEME',
'HTTP_X_FORWARDED_SSL',
'HTTP_FRONT_END_HTTPS',
'HTTP_X_URL_SCHEME',
'HTTP_CF_VISITOR',
'SERVER_PORT',
];
$server_value = static function ( string $key ) use ( $allowed_server_keys ): string {
if ( ! in_array( $key, $allowed_server_keys, true ) ) {
return '';
}
if ( ! isset( $_SERVER[ $key ] ) ) {
return '';
}
return strtolower( trim( sanitize_text_field( wp_unslash( (string) $_SERVER[ $key ] ) ) ) );
};
$csv_server_value = static function ( string $key ) use ( $server_value ): string {
$raw = $server_value( $key );
if ( $raw === '' ) {
return '';
}
$parts = array_map( 'trim', explode( ',', $raw ) );
return (string) ( $parts[0] ?? '' );
};
$scheme = $csv_server_value( 'REQUEST_SCHEME' );
$https = $csv_server_value( 'HTTPS' );
$forwarded_proto = $csv_server_value( 'HTTP_X_FORWARDED_PROTO' );
$forwarded_scheme = $csv_server_value( 'HTTP_X_FORWARDED_SCHEME' );
$forwarded_ssl = $csv_server_value( 'HTTP_X_FORWARDED_SSL' );
$front_end_https = $csv_server_value( 'HTTP_FRONT_END_HTTPS' );
$url_scheme = $csv_server_value( 'HTTP_X_URL_SCHEME' );
$cf_visitor_raw = $server_value( 'HTTP_CF_VISITOR' );
$cf_visitor_scheme = '';
if ( $cf_visitor_raw !== '' ) {
$cf_visitor = json_decode( $cf_visitor_raw, true );
if ( is_array( $cf_visitor ) && isset( $cf_visitor['scheme'] ) ) {
$cf_visitor_scheme = strtolower( trim( sanitize_text_field( (string) $cf_visitor['scheme'] ) ) );
}
}
$port_raw = $server_value( 'SERVER_PORT' );
$port = ctype_digit( $port_raw ) ? (int) $port_raw : 0;
/*
* Be deliberately strict.
*
* This site has already proven it can present a real browser HTTP request
* while PHP still thinks HTTPS is on somewhere upstream.
*
* So explicit plain-HTTP signals must win first.
*/
if ( $scheme === 'http' ) {
return false;
}
if ( $forwarded_proto === 'http' ) {
return false;
}
if ( $forwarded_scheme === 'http' ) {
return false;
}
if ( $url_scheme === 'http' ) {
return false;
}
if ( $forwarded_ssl === 'off' || $forwarded_ssl === '0' ) {
return false;
}
if ( $front_end_https === 'off' || $front_end_https === '0' ) {
return false;
}
if ( $https === 'off' || $https === '0' ) {
return false;
}
if ( $cf_visitor_scheme === 'http' ) {
return false;
}
if ( $port === 80 ) {
return false;
}
if ( $scheme === 'https' ) {
return true;
}
if ( $forwarded_proto === 'https' ) {
return true;
}
if ( $forwarded_scheme === 'https' ) {
return true;
}
if ( $url_scheme === 'https' ) {
return true;
}
if ( $forwarded_ssl === 'on' || $forwarded_ssl === '1' ) {
return true;
}
if ( $front_end_https === 'on' || $front_end_https === '1' ) {
return true;
}
if ( $cf_visitor_scheme === 'https' ) {
return true;
}
if ( $port === 443 ) {
return true;
}
if ( $https === 'on' || $https === '1' ) {
return true;
}
return false;
}
function abtestkit_is_same_origin_request(): bool {
$origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : '';
$referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
$host = wp_parse_url( home_url(), PHP_URL_HOST );
if ($origin) {
$o = wp_parse_url($origin, PHP_URL_HOST);
if ($o && $o === $host) return true;
}
if ($referer) {
$r = wp_parse_url($referer, PHP_URL_HOST);
if ($r && $r === $host) return true;
}
return false;
}
// Admins (or any role/cap decided via filter) are exempt from tests/tracking.
// They should always see the original (Version A / Control) and not be counted.
function abtestkit_is_exempt_viewer(): bool {
$is_exempt_cap = is_user_logged_in() && ( current_user_can( 'manage_options' ) || current_user_can( 'edit_posts' ) );
/**
* Filter: allow site owners to widen/narrow who is exempt.
* Return true to exempt (no test, no logging, show original).
*/
return (bool) apply_filters( 'abtestkit_is_exempt_viewer', $is_exempt_cap );
}
/**
* True only for the Custom CSS wizard marker picker preview window.
*
* Custom CSS product marker selection now opens the real frontend URL in a
* popup picker instead of embedding WooCommerce products in an admin iframe.
*/
function abtestkit_is_custom_css_picker_preview_request(): bool {
return false;
}
add_action( 'template_redirect', function() {
if ( ! abtestkit_is_custom_css_picker_preview_request() ) {
return;
}
remove_action( 'template_redirect', 'redirect_canonical' );
add_filter( 'show_admin_bar', '__return_false', 9999 );
// These constants are deliberately the canonical names used by cache/minify plugins.
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true );
}
if ( ! defined( 'DONOTMINIFY' ) ) {
define( 'DONOTMINIFY', true );
}
// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
if ( ! headers_sent() ) {
nocache_headers();
header( 'Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0', true );
header( 'X-Abtestkit-Custom-CSS-Popup-Picker: 1', true );
}
}, 0 );
add_action( 'wp_footer', function() {
if ( ! abtestkit_is_custom_css_picker_preview_request() ) {
return;
}
?>
$test,
'source_id' => $source_id,
'variant_id'=> $variant_id,
'attribute' => $attribute,
];
}
return $map;
}
function abtestkit_reusable_section_assign_variant( array $test ) : string {
$test_id = isset( $test['id'] ) ? abtestkit_sanitize_test_id( (string) $test['id'] ) : '';
if ( $test_id === '' ) {
return 'A';
}
// Admins/editors/exempt users should always see the original reusable section.
// Do this before reading or setting the test cookie, otherwise an existing B
// assignment can still cause the shortcode-rendered content to show Version B.
if ( abtestkit_is_exempt_viewer() ) {
return 'A';
}
// Never assign or track live B over HTTP.
if ( ! abtestkit_request_is_real_https() ) {
return 'A';
}
$cookie_name = 'abtestkit_pt_' . $test_id;
$assigned = abtestkit_safe_get_cookie_value( $cookie_name );
if ( $assigned !== 'A' && $assigned !== 'B' ) {
$split = max( 0, min( 100, (int) ( $test['split'] ?? 50 ) ) );
$ttl_days = max( 1, (int) ( $test['cookie_ttl_days'] ?? 30 ) );
$assigned = ( wp_rand( 1, 100 ) <= $split ) ? 'B' : 'A';
abtestkit_safe_set_cookie(
$cookie_name,
$assigned,
time() + ( $ttl_days * DAY_IN_SECONDS )
);
$_COOKIE[ $cookie_name ] = $assigned;
}
return ( $assigned === 'B' ) ? 'B' : 'A';
}
function abtestkit_reusable_section_render_page( int $post_id ) : string {
$post_id = absint( $post_id );
if ( $post_id <= 0 ) {
return '';
}
$embedded_post = get_post( $post_id );
if ( ! $embedded_post || $embedded_post->post_type !== 'page' ) {
return '';
}
static $stack = [];
if ( in_array( $post_id, $stack, true ) ) {
return '';
}
$stack[] = $post_id;
$content = '';
/*
* Important:
* Elementor may be loaded on the site even when this specific page is a
* normal Gutenberg/block page. Only use Elementor rendering when this exact
* page was built with Elementor.
*/
$is_elementor_page = (
did_action( 'elementor/loaded' )
&& class_exists( '\Elementor\Plugin' )
&& 'builder' === get_post_meta( $post_id, '_elementor_edit_mode', true )
&& isset( \Elementor\Plugin::$instance->frontend )
);
if ( $is_elementor_page ) {
try {
if ( method_exists( \Elementor\Plugin::$instance->frontend, 'get_builder_content_for_display' ) ) {
$content = \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $post_id, true );
} elseif ( method_exists( \Elementor\Plugin::$instance->frontend, 'get_builder_content' ) ) {
$content = \Elementor\Plugin::$instance->frontend->get_builder_content( $post_id, true );
}
} catch ( \Throwable $e ) {
$content = '';
}
}
/*
* Gutenberg / normal WordPress fallback.
*
* If Elementor is not used for this exact page, or Elementor returns empty,
* render through WordPress' normal content pipeline.
*/
if ( '' === trim( (string) $content ) ) {
global $post;
$previous_post = $post;
$post = $embedded_post;
setup_postdata( $post );
try {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress content filter.
$content = apply_filters( 'the_content', $embedded_post->post_content );
} catch ( \Throwable $e ) {
$content = '';
}
wp_reset_postdata();
$post = $previous_post;
}
array_pop( $stack );
return is_string( $content ) ? $content : '';
}
function abtestkit_reusable_section_log_impression_once( array $test, string $variant, int $source_id, int $rendered_id ) : void {
if ( abtestkit_is_exempt_viewer() ) {
return;
}
if ( ! abtestkit_request_is_real_https() ) {
return;
}
$test_id = isset( $test['id'] ) ? abtestkit_sanitize_test_id( (string) $test['id'] ) : '';
if ( $test_id === '' || ! in_array( $variant, [ 'A', 'B' ], true ) ) {
return;
}
$host_id = (int) get_queried_object_id();
if ( $host_id <= 0 ) {
$host_id = $source_id;
}
/*
* Do not write the impression directly here.
*
* Reusable sections render inside a host product/page, and the rest of
* abtestkit's live impression model is JS -> REST /track. The shortcode
* renderer only records the exposure cookie for Woo order attribution.
* frontend.js logs the actual impression against the host object.
*/
$exposure_cookie = 'abtestkit_reusable_seen_' . $test_id;
abtestkit_safe_set_cookie(
$exposure_cookie,
wp_json_encode(
[
'variant' => $variant,
'source_id' => $source_id,
'rendered_id' => $rendered_id,
'host_id' => $host_id,
]
),
time() + ( 30 * DAY_IN_SECONDS )
);
$_COOKIE[ $exposure_cookie ] = wp_json_encode(
[
'variant' => $variant,
'source_id' => $source_id,
'rendered_id' => $rendered_id,
'host_id' => $host_id,
]
);
}
/**
* Persist reusable-section assignment and commerce exposure from a request
* that can still send response headers.
*
* Shortcodes can render after the theme has started output, so their initial
* setcookie() calls are best-effort only. The accepted browser impression
* reaches REST with the same resolved A/B variant and gives WordPress a second,
* reliable header-safe boundary before a later WooCommerce cart request.
*/
function abtestkit_reusable_section_persist_tracking_state( array $test, string $variant, int $host_id ) : void {
$test_id = isset( $test['id'] )
? abtestkit_sanitize_test_id( (string) $test['id'] )
: '';
if (
$test_id === ''
|| ! in_array( $variant, [ 'A', 'B' ], true )
|| ( $test['status'] ?? 'paused' ) !== 'running'
) {
return;
}
$source_id = isset( $test['source_page_id'] )
? absint( $test['source_page_id'] )
: absint( $test['control_id'] ?? 0 );
$variant_id = absint( $test['variant_id'] ?? 0 );
if ( $source_id <= 0 || $variant_id <= 0 || $host_id <= 0 ) {
return;
}
$rendered_id = ( $variant === 'B' ) ? $variant_id : $source_id;
$ttl_days = max( 1, (int) ( $test['cookie_ttl_days'] ?? 30 ) );
$expires = time() + ( $ttl_days * DAY_IN_SECONDS );
$assignment = 'abtestkit_pt_' . $test_id;
$exposure = 'abtestkit_reusable_seen_' . $test_id;
$exposure_value = wp_json_encode(
[
'variant' => $variant,
'source_id' => $source_id,
'rendered_id' => $rendered_id,
'host_id' => $host_id,
]
);
abtestkit_safe_set_cookie( $assignment, $variant, $expires );
abtestkit_safe_set_cookie( $exposure, $exposure_value, $expires );
// Keep any later hooks in this REST request aligned with the response.
$_COOKIE[ $assignment ] = $variant;
$_COOKIE[ $exposure ] = $exposure_value;
}
function abtestkit_intercept_reusable_section_shortcode( $output, $tag, $attr, $m ) {
if ( is_admin() && ! wp_doing_ajax() ) {
return $output;
}
$tag = sanitize_key( (string) $tag );
if ( $tag === '' ) {
return $output;
}
$map = abtestkit_reusable_section_get_active_map();
if ( empty( $map[ $tag ] ) || ! is_array( $map[ $tag ] ) ) {
return $output;
}
$raw_attr = is_array( $attr ) ? $attr : [];
/*
* Reusable Section Test v1 currently defaults to [embed_page id="123"],
* but keep this flexible so future shortcode integrations can use a
* different attribute name without breaking interception.
*/
$source_id = 0;
foreach ( $map[ $tag ] as $possible_source_id => $possible_entry ) {
$attribute = isset( $possible_entry['attribute'] )
? sanitize_key( (string) $possible_entry['attribute'] )
: 'id';
if ( $attribute === '' ) {
$attribute = 'id';
}
if ( isset( $raw_attr[ $attribute ] ) && absint( $raw_attr[ $attribute ] ) === (int) $possible_source_id ) {
$source_id = (int) $possible_source_id;
break;
}
}
if ( $source_id <= 0 || empty( $map[ $tag ][ $source_id ] ) ) {
return $output;
}
$entry = $map[ $tag ][ $source_id ];
$test = isset( $entry['test'] ) && is_array( $entry['test'] ) ? $entry['test'] : [];
if ( empty( $test['id'] ) ) {
return $output;
}
$variant = abtestkit_reusable_section_assign_variant( $test );
$rendered_id = ( $variant === 'B' ) ? (int) $entry['variant_id'] : (int) $entry['source_id'];
abtestkit_reusable_section_log_impression_once(
$test,
$variant,
(int) $entry['source_id'],
$rendered_id
);
$content = abtestkit_reusable_section_render_page( $rendered_id );
if ( $content === '' ) {
return '';
}
$test_id = abtestkit_sanitize_test_id( (string) $test['id'] );
$goal = isset( $test['goal'] )
? sanitize_key( (string) $test['goal'] )
: '';
$links = [];
if ( ! empty( $test['links'] ) && is_array( $test['links'] ) ) {
$links = array_values(
array_filter(
array_map( 'strval', $test['links'] )
)
);
}
$scroll_depth = ( $goal === 'scroll_depth' )
? abtestkit_pt_scroll_depth_for_test( $test )
: 0;
return sprintf(
'%s
',
esc_attr( $test_id ),
esc_attr( $variant ),
(int) $entry['source_id'],
(int) $rendered_id,
esc_attr( $tag ),
esc_attr( $goal ),
esc_attr( wp_json_encode( $links ) ),
(int) $scroll_depth,
$content
);
}
add_filter( 'pre_do_shortcode_tag', 'abtestkit_intercept_reusable_section_shortcode', 10, 4 );
function abtestkit_handle_track( WP_REST_Request $request ) {
// Parse + sanitize JSON body
$raw = $request->get_body();
$body = is_string( $raw ) && $raw !== '' ? json_decode( $raw, true ) : [];
$type = sanitize_text_field( $body['type'] ?? '' );
$post_id = absint( $body['postId'] ?? 0 );
$index = absint( $body['index'] ?? 0 );
$variant = $body['variant'] ?? '';
$ab_id = abtestkit_sanitize_test_id( $body['abTestId'] ?? '' );
$protocol = sanitize_key( (string) ( $body['protocol'] ?? '' ) );
$excluded_reason = sanitize_key( (string) ( $body['excluded_reason'] ?? '' ) );
$scroll_pct = isset( $body['scroll'] ) && is_numeric( $body['scroll'] )
? max( 0, min( 100, (int) round( (float) $body['scroll'] ) ) )
: 0;
$time_sec = isset( $body['seconds'] ) && is_numeric( $body['seconds'] )
? max( 0, min( 3600, (int) round( (float) $body['seconds'] ) ) )
: 0;
$viewport_width = isset( $body['viewportWidth'] ) && is_numeric( $body['viewportWidth'] )
? max( 0, min( 10000, (int) round( (float) $body['viewportWidth'] ) ) )
: 0;
// Public tracking must come from a real same-origin browser request.
$nonce_ok = abtestkit_rest_check_nonce( $request );
$origin_ok = abtestkit_is_same_origin_request();
if ( ! $origin_ok && ! $nonce_ok ) {
return rest_ensure_response( [
'success' => false,
'error' => 'Unauthorised: same-origin browser request required.',
] );
}
$valid_types = [ 'impression', 'click', 'engagement', 'decision', 'decision_applied', 'stale', 'protocol_warning' ];
$needs_variant = in_array( $type, [ 'impression', 'click', 'engagement', 'decision', 'decision_applied', 'protocol_warning' ], true );
if (
empty( $ab_id ) ||
! in_array( $type, $valid_types, true ) ||
( $needs_variant && ! in_array( $variant, [ 'A', 'B' ], true ) ) ||
$post_id <= 0
) {
return rest_ensure_response( [
'success' => false,
'error' => 'Invalid tracking payload',
] );
}
$pt = null;
// For Page Tests: resolve the test once so protocol_warning can validate
// against the real PT record without depending on block/post scan logic.
if ( strpos( $ab_id, 'pt-' ) === 0 ) {
$pt = abtestkit_pt_get( (string) $ab_id );
if ( ! is_array( $pt ) ) {
return rest_ensure_response( [ 'success' => true ] );
}
if ( ( $pt['status'] ?? 'paused' ) !== 'running' ) {
return rest_ensure_response( [ 'success' => true ] );
}
}
// Admins should never log events.
if ( abtestkit_is_exempt_viewer() ) {
return rest_ensure_response( [ 'success' => true ] );
}
// Allow a lightweight diagnostic row for excluded HTTP visits,
// but continue blocking all normal tracking on HTTP.
if ( $type === 'protocol_warning' ) {
if ( $protocol !== 'http' || $excluded_reason !== 'http' ) {
return rest_ensure_response( [ 'success' => true ] );
}
// For page/product tests, validate against the stored PT record directly.
if ( is_array( $pt ) ) {
$control_id = (int) ( $pt['control_id'] ?? 0 );
$variant_id = (int) ( $pt['variant_id'] ?? 0 );
if ( $post_id !== $control_id && ( $variant_id <= 0 || $post_id !== $variant_id ) ) {
return rest_ensure_response( [ 'success' => true ] );
}
} elseif ( ! abtestkit_test_id_exists_on_post( $post_id, $ab_id ) ) {
return rest_ensure_response( [ 'success' => true ] );
}
abtestkit_log_event_to_db(
'protocol_warning',
$post_id,
$ab_id,
in_array( $variant, [ 'A', 'B' ], true ) ? $variant : 'A',
[
'protocol' => 'http',
'excluded_reason' => 'http',
]
);
abtestkit_pt_flush_test_caches( (string) $ab_id );
return rest_ensure_response( [ 'success' => true ] );
}
// Ensure the test actually exists on this post for normal tracking.
// Reusable Section Tests are different: the tested page is rendered inside
// a host product/page via shortcode, so the event should be allowed against
// the current host object as long as the PT record is a running reusable test.
$tracking_allowed = abtestkit_test_id_exists_on_post( $post_id, $ab_id );
if (
! $tracking_allowed
&& is_array( $pt )
&& ( $pt['kind'] ?? '' ) === 'reusable_section'
&& ( $pt['status'] ?? 'paused' ) === 'running'
) {
$tracking_allowed = true;
}
if ( ! $tracking_allowed ) {
return rest_ensure_response( [
'success' => false,
'error' => 'Unknown test on this post',
] );
}
// Respect the browser's reported page protocol when the JS tracker sends it.
if ( $protocol === 'http' ) {
return rest_ensure_response( [ 'success' => true ] );
}
// Belt-and-braces: reject normal event writes if the real frontend request is not HTTPS.
if ( ! abtestkit_request_is_real_https() ) {
return rest_ensure_response( [ 'success' => true ] );
}
// Simple rate limit: max 120 events/minute per IP + test.
$ip_for_limit_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$ip_for_limit = filter_var( $ip_for_limit_raw, FILTER_VALIDATE_IP ) ? $ip_for_limit_raw : 'unknown';
$limit_key = 'abtestkit_trk_' . md5( $ip_for_limit . '|' . $post_id . '|' . $ab_id . '|' . gmdate( 'YmdHi' ) );
$current_count = (int) get_transient( $limit_key );
if ( $current_count > 120 ) {
return rest_ensure_response( [ 'success' => true ] );
}
set_transient( $limit_key, $current_count + 1, MINUTE_IN_SECONDS + 5 );
if (
$type === 'impression'
&& is_array( $pt )
&& ( $pt['kind'] ?? '' ) === 'reusable_section'
) {
abtestkit_reusable_section_persist_tracking_state( $pt, $variant, $post_id );
}
$audience_context = abtestkit_audience_get_exposure_context( $ab_id );
if (
empty( $audience_context )
&& in_array( $type, [ 'impression', 'click', 'engagement' ], true )
) {
$ttl_days = is_array( $pt )
? max( 1, (int) ( $pt['cookie_ttl_days'] ?? 30 ) )
: 30;
$audience_context = abtestkit_audience_persist_exposure_context(
$ab_id,
abtestkit_audience_context_from_request( $viewport_width ),
$ttl_days
);
}
// One click per session per variant. Page-Test Add to cart goals retain
// their dedupe for the full assignment lifetime.
if ( $type === 'click' ) {
$commerce_cookie_name = '';
if (
is_array( $pt )
&& sanitize_key( (string) ( $pt['goal'] ?? '' ) ) === 'add_to_cart'
) {
/*
* Explicit commerce scopes are server-authoritative because the
* legacy browser payload does not carry a trusted product and
* variation identity. Confirmed browser events remain a fallback
* only for older tests without a stored commerce scope.
*/
if ( array_key_exists( 'commerce', $pt ) ) {
return rest_ensure_response( [ 'success' => true ] );
}
$commerce_cookie_name = abtestkit_commerce_add_to_cart_cookie_name( $ab_id );
if (
$commerce_cookie_name !== ''
&& abtestkit_safe_get_cookie_value( $commerce_cookie_name ) === '1'
) {
return rest_ensure_response( [ 'success' => true ] );
}
}
$click_cookie_name = 'abtestkit_pt_click_' . $post_id . '_' . $ab_id . '_' . $variant;
$already_clicked = ( abtestkit_safe_get_cookie_value( $click_cookie_name ) === '1' );
if ( $already_clicked ) {
return rest_ensure_response( [ 'success' => true ] );
}
abtestkit_safe_set_cookie( $click_cookie_name, '1', 0 );
if ( $commerce_cookie_name !== '' ) {
$ttl_days = max( 1, (int) ( $pt['cookie_ttl_days'] ?? 30 ) );
abtestkit_safe_set_cookie(
$commerce_cookie_name,
'1',
time() + ( $ttl_days * DAY_IN_SECONDS )
);
$_COOKIE[ $commerce_cookie_name ] = '1';
}
}
abtestkit_log_event_to_db(
$type,
$post_id,
$ab_id,
$variant,
[
'protocol' => 'https',
'scroll_pct' => $scroll_pct,
'time_sec' => $time_sec,
'audience' => $audience_context,
]
);
if ( $type === 'click' && strpos( $ab_id, 'pt-' ) === 0 ) {
abtestkit_pt_maybe_lock_winner( (string) $ab_id );
}
return rest_ensure_response( [ 'success' => true ] );
}
// ─────────────────────────────────────────────────────────────────────────────
// Force no-cache headers on abtestkit REST responses (works with WP Rocket/CDNs)
// ─────────────────────────────────────────────────────────────────────────────
add_filter('rest_post_dispatch', function( $result, $server, $request ) {
$route = $request->get_route();
if ( strpos( $route, '/abtestkit/' ) !== false ) {
// Always a WP_REST_Response by here, but guard anyway
if ( is_a( $result, WP_REST_Response::class ) ) {
$result->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' );
$result->header( 'Pragma', 'no-cache' );
} else {
$resp = new WP_REST_Response( $result );
$resp->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' );
$resp->header( 'Pragma', 'no-cache' );
return $resp;
}
}
return $result;
}, 10, 3);
//Handler for /stats
function abtestkit_handle_stats( WP_REST_Request $request ) {
global $wpdb;
$post_id = absint( $request->get_param( 'post_id' ) );
if ( $post_id <= 0 ) {
return rest_ensure_response([
'A' => [ 'impressions' => 0, 'clicks' => 0 ],
'B' => [ 'impressions' => 0, 'clicks' => 0 ],
]);
}
// Accept either a single abTestId or multiple abTestIds
$single = $request->get_param( 'abTestId' );
$many = $request->get_param( 'abTestIds' );
// Normalise to an array of sanitized IDs
$ab_ids = [];
if ( is_array( $many ) ) {
foreach ( $many as $id ) {
$clean = abtestkit_sanitize_test_id( $id );
if ( $clean !== '' ) $ab_ids[] = $clean;
}
} elseif ( is_string( $many ) && $many !== '' ) {
foreach ( preg_split( '/[,\|]/', $many ) as $id ) {
$clean = abtestkit_sanitize_test_id( $id );
if ( $clean !== '' ) $ab_ids[] = $clean;
}
} elseif ( $single ) {
$clean = abtestkit_sanitize_test_id( $single );
if ( $clean !== '' ) $ab_ids[] = $clean;
}
$ab_ids = array_values( array_unique( $ab_ids ) );
if ( empty( $ab_ids ) ) {
return rest_ensure_response([
'A' => [ 'impressions' => 0, 'clicks' => 0 ],
'B' => [ 'impressions' => 0, 'clicks' => 0 ],
]);
}
// ── Simple object cache for stats ───────────────────────────────────────────
$cache_key = '';
if ( count( $ab_ids ) === 1 ) {
$cache_key = "stats:$post_id:{$ab_ids[0]}";
} else {
$cache_key = "stats_multi:$post_id";
}
$cached = wp_cache_get( $cache_key, 'abtestkit_stats' );
if ( false !== $cached ) {
return rest_ensure_response( $cached );
}
$table = ABTESTKIT_EVENTS_TABLE;
if ( count( $ab_ids ) === 1 ) {
$post_id_i = (int) $post_id;
$ab_id = isset( $ab_ids[0] ) ? sanitize_text_field( (string) $ab_ids[0] ) : '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT ab_test_id, variant, event_type, COUNT(*) AS count ' .
'FROM %i ' .
'WHERE post_id = %d AND ab_test_id = %s ' .
'GROUP BY ab_test_id, variant, event_type',
$table,
$post_id_i,
$ab_id
),
ARRAY_A
);
} else {
$ab_id_placeholders = implode( ', ', array_fill( 0, count( $ab_ids ), '%s' ) );
$query_args = array_merge( [ $table, $post_id ], $ab_ids );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
/*
* The fragment below contains generated %s tokens only. The argument
* array supplies the table, post ID and one value for every token.
*/
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$wpdb->prepare(
'SELECT ab_test_id, variant, event_type, COUNT(*) AS count ' .
'FROM %i ' .
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
'WHERE post_id = %d AND ab_test_id IN (' . $ab_id_placeholders . ') ' .
'GROUP BY ab_test_id, variant, event_type',
$query_args
),
ARRAY_A
);
}
$default_pair = [ 'A' => [ 'impressions' => 0, 'clicks' => 0 ],
'B' => [ 'impressions' => 0, 'clicks' => 0 ] ];
if ( count( $ab_ids ) === 1 && isset( $ab_ids[0] ) && $single ) {
// Back-compat single shape
$stats = $default_pair;
foreach ( $rows as $r ) {
$v = $r['variant'];
$t = $r['event_type'];
$c = (int) $r['count'];
if ( isset( $stats[$v][$t . 's'] ) ) {
$stats[$v][$t . 's'] += $c;
}
}
wp_cache_set( $cache_key, $stats, 'abtestkit_stats', MINUTE_IN_SECONDS );
return rest_ensure_response( $stats );
}
// Multi shape keyed by test id
$out = [];
foreach ( $ab_ids as $id ) {
$out[$id] = $default_pair;
}
foreach ( $rows as $r ) {
$id = $r['ab_test_id'];
$v = $r['variant'];
$t = $r['event_type'];
$c = (int) $r['count'];
if ( isset( $out[$id][$v][$t . 's'] ) ) {
$out[$id][$v][$t . 's'] += $c;
}
}
wp_cache_set( $cache_key, $out, 'abtestkit_stats', MINUTE_IN_SECONDS );
return rest_ensure_response( $out );
}
/**
* Handler for /evaluate — Bayesian with Beta(5,5) prior + 5-impression floor.
*/
function abtestkit_handle_evaluate( WP_REST_Request $request ) {
$abTestId = abtestkit_sanitize_test_id( $request->get_param('abTestId') );
$post_id = absint( $request->get_param('post_id') );
if ( empty( $abTestId ) || empty( $post_id ) ) {
return rest_ensure_response([
'error' => 'Missing abTestId or post_id',
'message' => 'Evaluation requires abTestId and post_id.'
]);
}
// Parse post content to find if this test has grouped dependencies (conversionFrom)
$content = get_post_field('post_content', $post_id );
$blocks = parse_blocks( $content );
$grouped = [];
$scan = function( $blocks ) use ( &$scan, $abTestId, &$grouped ) {
foreach ( $blocks as $block ) {
if ( ! is_array( $block ) ) continue;
$attrs = $block['attrs'] ?? [];
if ( ( $attrs['abTestId'] ?? null ) === $abTestId && ! empty( $attrs['conversionFrom'] ) ) {
$grouped = $attrs['conversionFrom'];
return;
}
if ( ! empty( $block['innerBlocks'] ) ) {
$scan( $block['innerBlocks'] );
}
}
};
$scan( $blocks );
$conversionEvent = 'click';
// Defaults (legacy)
$decisionMode = 'auto';
$minImpressions = 50;
$minConversions = 5;
// Page Test Wizard (pt-*) thresholds + manual mode
if ( strpos( (string) $abTestId, 'pt-' ) === 0 ) {
$pt_test = abtestkit_pt_get( (string) $abTestId );
if ( is_array( $pt_test ) ) {
$decisionMode = sanitize_key( (string) ( $pt_test['decision_mode'] ?? 'auto' ) );
$decisionRule = sanitize_key( (string) ( $pt_test['decision_rule'] ?? 'balanced' ) );
$goal = sanitize_key( (string) ( $pt_test['goal'] ?? '' ) );
if ( $goal === 'purchase' ) {
$conversionEvent = 'purchase';
}
if ( $decisionMode === 'manual' || $decisionRule === 'manual' ) {
$decisionMode = 'manual';
$minImpressions = 0;
$minConversions = 0;
} else {
$mi = isset( $pt_test['min_impressions'] ) ? absint( $pt_test['min_impressions'] ) : 0;
$mc = isset( $pt_test['min_conversions'] ) ? absint( $pt_test['min_conversions'] ) : 0;
$minImpressions = in_array( $mi, [ 25, 50, 75 ], true ) ? $mi : 50;
$minConversions = in_array( $mc, [ 3, 5, 10 ], true ) ? $mc : 5;
}
}
}
// Get current stats for this test.
// Purchase-goal tests are evaluated on RPV, not just raw purchase count.
$revA = 0.0;
$revB = 0.0;
if ( $conversionEvent === 'purchase' ) {
global $wpdb;
$table = ABTESTKIT_EVENTS_TABLE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT variant, event_type, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS revenue ' .
'FROM %i ' .
'WHERE ab_test_id = %s ' .
"AND event_type IN ('impression','purchase') " .
'GROUP BY variant, event_type',
$table,
(string) $abTestId
),
ARRAY_A
);
$impA = 0;
$clkA = 0;
$impB = 0;
$clkB = 0;
foreach ( (array) $rows as $r ) {
$v = isset( $r['variant'] ) ? (string) $r['variant'] : '';
$t = isset( $r['event_type'] ) ? (string) $r['event_type'] : '';
$c = isset( $r['c'] ) ? (int) $r['c'] : 0;
$revenue = isset( $r['revenue'] ) ? (float) $r['revenue'] : 0.0;
if ( $v === 'A' && $t === 'impression' ) {
$impA = $c;
} elseif ( $v === 'A' && $t === 'purchase' ) {
$clkA = $c;
$revA = $revenue;
} elseif ( $v === 'B' && $t === 'impression' ) {
$impB = $c;
} elseif ( $v === 'B' && $t === 'purchase' ) {
$clkB = $c;
$revB = $revenue;
}
}
} else {
$stats_resp = abtestkit_handle_stats( $request );
$stats = $stats_resp instanceof WP_REST_Response
? $stats_resp->get_data()
: (array) $stats_resp;
$impA = (int) ( $stats['A']['impressions'] ?? 0 );
$clkA = (int) ( $stats['A']['clicks'] ?? 0 );
$impB = (int) ( $stats['B']['impressions'] ?? 0 );
$clkB = (int) ( $stats['B']['clicks'] ?? 0 );
}
// Cache evaluation for this exact stats state (60s)
$eval_cache_key = sprintf(
'abtestkit_eval:%d:%s:%d:%d:%d:%d:%0.2f:%0.2f:%s:%d:%d',
(int) $post_id,
(string) $abTestId,
$impA,
$clkA,
$impB,
$clkB,
(float) $revA,
(float) $revB,
(string) $decisionMode,
(int) $minImpressions,
(int) $minConversions
);
$cached_eval = wp_cache_get( $eval_cache_key, 'abtestkit_eval' );
if ( false !== $cached_eval ) {
return rest_ensure_response( $cached_eval );
}
// Early exits
if ( $impA === 0 && $impB === 0 ) {
$out = [ 'probA' => 0.5, 'probB' => 0.5, 'winner' => '', 'message' => 'No impressions recorded yet.' ];
wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
return rest_ensure_response( $out );
}
if ( $impA === 0 || $impB === 0 ) {
$out = [ 'probA' => 0.5, 'probB' => 0.5, 'winner' => '', 'message' => 'Only one variant has impressions — test needs more data.' ];
wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
return rest_ensure_response( $out );
}
$totalImpressions = $impA + $impB;
$totalConversions = $clkA + $clkB;
// Auto mode gates
if ( $decisionMode !== 'manual' ) {
if ( $totalImpressions < (int) $minImpressions ) {
$out = [
'probA' => 0.5, 'probB' => 0.5, 'winner' => '',
'message' => sprintf( 'Not enough data yet — %d/%d total impressions.', $totalImpressions, (int) $minImpressions ),
];
wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
return rest_ensure_response( $out );
}
if ( $totalConversions < (int) $minConversions ) {
$out = [
'probA' => 0.5, 'probB' => 0.5, 'winner' => '',
'message' => sprintf( 'Not enough conversions yet — %d/%d total conversions.', $totalConversions, (int) $minConversions ),
];
wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
return rest_ensure_response( $out );
}
}
// Bayesian sampling
$numSamples = 8000;
$countA = 0;
$diffs = [];
if ( $conversionEvent === 'purchase' ) {
for ( $i = 0; $i < $numSamples; $i++ ) {
$sampA = abtestkit_sample_rpv( $impA, $clkA, $revA );
$sampB = abtestkit_sample_rpv( $impB, $clkB, $revB );
if ( $sampA > $sampB ) {
$countA++;
}
$diffs[] = $sampA - $sampB;
}
} else {
$priorN = 2;
$alphaA = $priorN / 2 + $clkA;
$betaA = $priorN / 2 + max( 0, $impA - $clkA );
$alphaB = $priorN / 2 + $clkB;
$betaB = $priorN / 2 + max( 0, $impB - $clkB );
for ( $i = 0; $i < $numSamples; $i++ ) {
$sampA = abtestkit_sample_beta( $alphaA, $betaA );
$sampB = abtestkit_sample_beta( $alphaB, $betaB );
if ( $sampA > $sampB ) {
$countA++;
}
$diffs[] = $sampA - $sampB;
}
}
$probA = $countA / $numSamples;
$probB = 1 - $probA;
// Confidence level by rule:
// - Fast (25 impressions / 3 conversions): 90%
// - Balanced + Precise: 95%
$confidence = 0.95;
if ( $decisionMode !== 'manual' ) {
// Prefer the saved rule label if present, otherwise infer from thresholds.
if (
( isset( $decisionRule ) && $decisionRule === 'fast' )
|| ( (int) $minImpressions === 25 && (int) $minConversions === 3 )
) {
$confidence = 0.90;
}
}
sort( $diffs );
$tail = ( 1 - $confidence ) / 2; // e.g. 0.05 for 90%, 0.025 for 95%
$ciLower = $diffs[ (int) ( $tail * $numSamples ) ];
$ciUpper = $diffs[ (int) ( ( 1 - $tail ) * $numSamples ) ];
// Winner decision (never in manual mode)
$winner = '';
if ( $decisionMode !== 'manual' ) {
if ( $probA > $confidence && $ciLower > 0 ) {
$winner = 'A';
} elseif ( $probB > $confidence && $ciUpper < 0 ) {
$winner = 'B';
}
}
$result = [
'probA' => round( $probA, 4 ),
'probB' => round( $probB, 4 ),
'ciLower' => round( $ciLower, 4 ),
'ciUpper' => round( $ciUpper, 4 ),
'winner' => $winner
];
wp_cache_set( $eval_cache_key, $result, 'abtestkit_eval', 60 );
return rest_ensure_response( $result );
}
/**
* Handler for /reset — removes events for given post, index, and abTestId only.
*/
function abtestkit_handle_reset( WP_REST_Request $request ) {
global $wpdb;
$body = json_decode( $request->get_body(), true );
$post_id = absint( $body['post_id'] ?? 0 );
$abTestId = abtestkit_sanitize_test_id( $body['abTestId'] ?? '' );
if ( empty( $post_id ) || empty( $abTestId ) ) {
return rest_ensure_response( [
'success' => false,
'error' => 'Missing post_id or abTestId'
] );
}
// Admin action: safe, parameterized delete scoped to this post/test.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
$deleted = $wpdb->delete(
ABTESTKIT_EVENTS_TABLE,
[
'post_id' => $post_id,
'ab_test_id' => $abTestId,
],
[ '%d', '%s' ]
);
// Invalidate stats cache for this post/test.
wp_cache_delete( "stats:$post_id:$abTestId", 'abtestkit_stats' );
wp_cache_delete( "stats_multi:$post_id", 'abtestkit_stats' );
return rest_ensure_response( [ 'success' => true, 'deleted' => $deleted ] );
}
/**
* Enqueue block-editor scripts: ab-sidebar.js (plugin sidebar) + editor.js (inline-lock HOC).
*/
add_action( 'enqueue_block_editor_assets', function() {
// Only on post editor screens for a specific post.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
if ( ! $post_id ) {
return;
}
$post = get_post( $post_id );
if ( ! $post ) {
return;
}
$post_type = (string) $post->post_type;
if ( $post_type !== 'post' && $post_type !== 'page' ) {
return;
}
// Detect Version B states used by your plugin.
$is_shadow = (int) get_post_meta( $post_id, '_abtestkit_shadow', true ) === 1;
$is_existingB = (int) get_post_meta( $post_id, '_abtestkit_variant_in_use', true ) === 1;
if ( ! $is_shadow && ! $is_existingB ) {
return;
}
$type_label = ( $post_type === 'page' ) ? 'Page' : 'Post';
// Linked Version A ID differs depending on how B was created.
$control_id = 0;
if ( $is_shadow ) {
$control_id = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
} elseif ( $is_existingB ) {
$control_id = (int) get_post_meta( $post_id, '_abtestkit_variant_of', true );
}
if ( $is_shadow ) {
$msg = 'abtestkit: This is your Version B ' . $type_label . ' for this A/B test. Make your changes, save as draft, then close this tab to continue creating your test. This ' . strtolower( $type_label ) . ' cannot be published.';
} else {
$msg = 'abtestkit: You are editing Version B (' . $type_label . '). Make your changes, then Publish/Save and close this tab to continue creating your test.';
}
$show_blocked_notice = $is_shadow && abtestkit_peek_shadow_publish_blocked( $post_id );
// Show a native Gutenberg notice (reliable in block editor).
$js = "(function(){\n"
. " if(!window.wp || !wp.data || !wp.data.dispatch){ return; }\n"
. " var msg = " . wp_json_encode( $msg ) . ";\n"
. " try {\n"
. " wp.data.dispatch('core/notices').createNotice('warning', msg, { isDismissible: true });\n";
if ( $show_blocked_notice ) {
$js .= " wp.data.dispatch('core/notices').createNotice('error', 'Publish blocked: Shadow " . strtolower( $type_label ) . "s are never allowed to go live.', { isDismissible: true });\n";
}
$js .= " } catch(e) {}\n"
. "})();\n";
// wp-edit-post is loaded on the block editor screen.
wp_add_inline_script( 'wp-edit-post', $js, 'after' );
} );
add_action( 'admin_post_abtestkit_review_prompt', function () {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'forbidden' );
}
check_admin_referer( 'abtestkit_review_prompt' );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce already verified above.
$do = isset( $_GET['do'] ) ? sanitize_key( wp_unslash( $_GET['do'] ) ) : '';
$state = abtestkit_review_prompt_get_state();
if ( $do === 'later' ) {
$state['snooze_until'] = time() + ( 30 * DAY_IN_SECONDS );
} elseif ( in_array( $do, [ 'review', 'never' ], true ) ) {
$state['dismissed'] = 1;
$state['snooze_until'] = 0;
}
abtestkit_review_prompt_save_state( $state );
if ( $do === 'review' ) {
// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
wp_redirect( esc_url_raw( ABTESTKIT_REVIEW_URL ) );
exit;
}
wp_safe_redirect( abtestkit_admin_get_safe_return_url() );
exit;
} );
add_action( 'admin_footer', function () {
if ( ! abtestkit_review_prompt_is_dashboard_screen() ) {
return;
}
if ( ! abtestkit_review_prompt_should_show() ) {
return;
}
$review_url = add_query_arg(
[
'action' => 'abtestkit_review_prompt',
'do' => 'review',
'_wpnonce' => wp_create_nonce( 'abtestkit_review_prompt' ),
],
admin_url( 'admin-post.php' )
);
$later_url = add_query_arg(
[
'action' => 'abtestkit_review_prompt',
'do' => 'later',
'_wpnonce' => wp_create_nonce( 'abtestkit_review_prompt' ),
],
admin_url( 'admin-post.php' )
);
$never_url = add_query_arg(
[
'action' => 'abtestkit_review_prompt',
'do' => 'never',
'_wpnonce' => wp_create_nonce( 'abtestkit_review_prompt' ),
],
admin_url( 'admin-post.php' )
);
?>
$post_id,
'index' => 0,
'nonce' => wp_create_nonce('wp_rest'),
'restUrl' => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
];
// If this page is part of a full-page/product test, expose the REAL request state to JS.
// Forced previews must resolve paused/previewable tests too.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$preview_flag_js = isset( $_GET['abtestkit_preview'] )
? sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_preview'] ) )
: '';
$raw_force_js = isset( $_GET['abtestkit_force'] )
? strtoupper( sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_force'] ) ) )
: '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$force_variant_js = ( $preview_flag_js === '1' && in_array( $raw_force_js, [ 'A', 'B' ], true ) )
? $raw_force_js
: '';
if ( $force_variant_js !== '' ) {
list( $pt, $pt_role ) = abtestkit_pt_find_by_post_any_status( (int) $post_id );
} else {
list( $pt, $pt_role ) = abtestkit_pt_find_by_post( (int) $post_id );
}
if ( $pt && isset( $pt['id'] ) ) {
$variant_letter = '';
$request_is_https = abtestkit_request_is_real_https();
$protocol_label = $request_is_https ? 'https' : 'http';
$no_track = ! $request_is_https;
$http_excluded = ! $request_is_https;
// 1) First choice: the assignment already decided earlier in this request.
if (
isset( $GLOBALS['abtestkit_current_pt_assignment'] )
&& is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
) {
$ctx = $GLOBALS['abtestkit_current_pt_assignment'];
$ctx_test = $ctx['test'] ?? null;
$ctx_id = ( is_array( $ctx_test ) && isset( $ctx_test['id'] ) ) ? (string) $ctx_test['id'] : '';
if ( $ctx_id !== '' && $ctx_id === (string) $pt['id'] ) {
$ctx_variant = isset( $ctx['variant'] ) ? (string) $ctx['variant'] : '';
if ( $ctx_variant === 'A' || $ctx_variant === 'B' ) {
$variant_letter = $ctx_variant;
}
if ( isset( $ctx['protocol'] ) ) {
$protocol_label = (string) $ctx['protocol'];
}
if ( isset( $ctx['no_track'] ) ) {
$no_track = (bool) $ctx['no_track'];
}
if ( $protocol_label === 'http' ) {
$http_excluded = true;
}
}
}
// 2) Fallback for product tests.
if ( $variant_letter === '' && isset( $pt['kind'] ) && $pt['kind'] === 'product' ) {
list( $prod_test, $prod_variant ) = abtestkit_get_active_product_test_for_product( (int) $post_id );
if ( $prod_test && isset( $prod_test['id'] ) && (string) $prod_test['id'] === (string) $pt['id'] ) {
if ( $prod_variant === 'A' || $prod_variant === 'B' ) {
$variant_letter = $prod_variant;
}
}
}
// 3) Final fallback.
if ( $variant_letter === '' ) {
if ( ! $request_is_https ) {
$variant_letter = 'A';
} else {
$variant_letter = ( $pt_role === 'control' ) ? 'A' : ( ( $pt_role === 'variant' ) ? 'B' : '' );
}
}
if ( $variant_letter === 'A' || $variant_letter === 'B' ) {
$links = [];
if ( ! empty( $pt['links'] ) && is_array( $pt['links'] ) ) {
$links = array_values(
array_map( 'strval', $pt['links'] )
);
}
$frontendConfig['__pageTest'] = [
'id' => (string) $pt['id'],
'variant' => $variant_letter,
'goal' => isset( $pt['goal'] ) ? (string) $pt['goal'] : '',
'links' => $links,
'protocol' => $protocol_label,
'noTrack' => $no_track,
'httpExcluded' => $http_excluded,
];
}
}
// On Woo product listing views (shop, category, tag, taxonomy archives),
// surface any active PRODUCT tests for the products in the main loop so
// frontend JS can attribute add_to_cart events from cards correctly.
if (
function_exists( 'is_shop' )
&& function_exists( 'wc_get_product' )
&& ( is_shop() || is_product_category() || is_product_tag() || is_product_taxonomy() )
) {
global $wp_query;
if ( $wp_query && ! empty( $wp_query->posts ) && is_array( $wp_query->posts ) ) {
$productTests = [];
foreach ( $wp_query->posts as $loop_post ) {
$product_id = 0;
if ( is_object( $loop_post ) && isset( $loop_post->ID ) ) {
$product_id = (int) $loop_post->ID;
} elseif ( is_numeric( $loop_post ) ) {
$product_id = (int) $loop_post;
}
if ( $product_id <= 0 ) {
continue;
}
// Ask the existing helper which PRODUCT test (if any) applies to this product
// and which variant (A/B) this viewer is assigned to.
[ $prod_test, $prod_variant ] = abtestkit_get_active_product_test_for_product( $product_id );
if ( ! $prod_test || ! isset( $prod_test['id'] ) ) {
continue;
}
$goal = isset( $prod_test['goal'] ) ? (string) $prod_test['goal'] : '';
$productTests[ (string) $product_id ] = [
'id' => (string) $prod_test['id'],
'variant' => ( $prod_variant === 'B' ? 'B' : 'A' ),
'goal' => $goal,
];
}
if ( ! empty( $productTests ) ) {
$frontendConfig['__productTests'] = $productTests;
}
}
}
// For “button converts other tests” relations
$clickTargetMap = [];
$extract_variants = function(array $blocks) use (&$extract_variants, &$frontendConfig, &$clickTargetMap) {
foreach ($blocks as $block) {
if (!is_array($block) || empty($block['blockName'])) continue;
$attrs = $block['attrs'] ?? [];
$abTestId = $attrs['abTestId'] ?? null;
$abTestVariants = $attrs['abTestVariants'] ?? null;
$conversionFrom = $attrs['conversionFrom'] ?? [];
// If the block lists tests it “converts”, remember that mapping
foreach ((array) $conversionFrom as $sourceId) {
if (!isset($clickTargetMap[$sourceId])) $clickTargetMap[$sourceId] = [];
$clickTargetMap[$sourceId][] = $abTestId;
}
// Only build a payload when this block actually hosts a test
if ($abTestId && is_array($abTestVariants) && isset($abTestVariants[$abTestId])) {
// Prefer values already stored inside abTestVariants[abTestId] (they persist across reloads),
// then fall back to the loose block attributes, then defaults.
$stored = $abTestVariants[$abTestId];
// Goal: prefer stored → then loose attribute → default
$goal = $stored['conversionGoalType'] ?? ($attrs['conversionGoalType'] ?? 'button');
// Allow clicks, forms and Woo "add_to_cart" goals (treated like form submit on the frontend)
$goal = in_array( $goal, [ 'button', 'link', 'form', 'add_to_cart' ], true ) ? $goal : 'button';
// Links: prefer stored → then loose attribute → []
$links = [];
if (!empty($stored['conversionLinks']) && is_array($stored['conversionLinks'])) {
$links = array_values(array_filter(array_map('strval', $stored['conversionLinks'])));
} elseif (!empty($attrs['conversionLinks']) && is_array($attrs['conversionLinks'])) {
$links = array_values(array_filter(array_map('strval', $attrs['conversionLinks'])));
}
// Attach canonical values back to the per-test object
$abTestVariants[$abTestId]['conversionGoalType'] = $goal;
$abTestVariants[$abTestId]['conversionLinks'] = $links;
// “This button converts these tests”: prefer loose attr (UI is source of truth), else stored
$from = [];
if (!empty($attrs['conversionFrom']) && is_array($attrs['conversionFrom'])) {
$from = array_values(array_unique(array_filter(array_map('strval', $attrs['conversionFrom']))));
} elseif (!empty($stored['conversionFrom']) && is_array($stored['conversionFrom'])) {
$from = array_values(array_unique(array_filter(array_map('strval', $stored['conversionFrom']))));
}
$abTestVariants[$abTestId]['conversionFrom'] = $from;
// Running flag: unlocked tests are “running”
$locked = $abTestVariants[$abTestId]['locked'] ?? true;
$abTestVariants[$abTestId]['running'] = !$locked;
// Merge grouped tests from whatever we calculated plus anything already saved
$storedGrouped = $abTestVariants[$abTestId]['groupedAbTests'] ?? [];
$calculatedGrouped = $clickTargetMap[$abTestId] ?? [];
$mergedGrouped = array_values(array_unique(array_merge($storedGrouped, $calculatedGrouped)));
$abTestVariants[$abTestId]['groupedAbTests'] = $mergedGrouped;
// Keep the earlier-resolved $from (attrs preferred, else stored), do not overwrite it here.
$abTestVariants[$abTestId]['conversionFrom'] = $from;
// Finally, expose this test on window.abTestConfig under its ID
$frontendConfig[$abTestId] = $abTestVariants[$abTestId];
}
// Recurse
if (!empty($block['innerBlocks'])) {
$extract_variants($block['innerBlocks']);
}
}
};
$extract_variants($blocks);
wp_localize_script('abtestkit-frontend', 'abTestConfig', $frontendConfig);
});
register_activation_hook(__FILE__, function () {
abtestkit_create_event_table();
abtestkit_telemetry_unschedule_heartbeat();
});
/**
* Delete an ABTestKit-owned shadow Version B post/product/page/reusable section.
*
* Safety rule:
* - Only deletes posts explicitly marked with _abtestkit_shadow = 1.
* - Never deletes user-owned "existing page/product as Version B" content.
*/
function abtestkit_delete_owned_shadow_variant( int $post_id ) : bool {
$post_id = (int) $post_id;
if ( $post_id <= 0 || ! get_post( $post_id ) ) {
return false;
}
if ( (int) get_post_meta( $post_id, '_abtestkit_shadow', true ) !== 1 ) {
return false;
}
$post_type = get_post_type( $post_id );
// Variable-product shadows can have shadow child variations. Delete children first.
if ( $post_type === 'product' ) {
$child_ids = get_children(
[
'post_parent' => $post_id,
'post_type' => 'product_variation',
'post_status' => 'any',
'fields' => 'ids',
'posts_per_page' => -1,
]
);
if ( is_array( $child_ids ) && ! empty( $child_ids ) ) {
foreach ( $child_ids as $child_id ) {
$child_id = (int) $child_id;
if ( $child_id > 0 ) {
wp_delete_post( $child_id, true );
clean_post_cache( $child_id );
}
}
}
}
$deleted = wp_delete_post( $post_id, true );
if ( $deleted ) {
clean_post_cache( $post_id );
if ( function_exists( 'abtestkit_clear_shadow_counts_cache' ) ) {
abtestkit_clear_shadow_counts_cache( (string) $post_type );
}
return true;
}
return false;
}
/**
* On deactivation, stop tests from silently resuming if the user reactivates.
*
* We intentionally do NOT delete user content on deactivation.
* Full deletion happens on uninstall.
*/
function abtestkit_pause_tests_for_safe_deactivation() : void {
$option_name = defined( 'ABTESTKIT_PAGE_TESTS_OPTION' ) ? ABTESTKIT_PAGE_TESTS_OPTION : 'abtestkit_page_tests';
$tests = get_option( $option_name, [] );
$original_tests = is_array( $tests ) ? $tests : array();
if ( ! is_array( $tests ) || empty( $tests ) ) {
return;
}
$changed = false;
$now = time();
foreach ( $tests as &$test ) {
if ( ! is_array( $test ) ) {
continue;
}
if ( (string) ( $test['status'] ?? '' ) === 'running' ) {
$test['status'] = 'paused';
if ( empty( $test['paused_at'] ) ) {
$test['paused_at'] = $now;
}
if ( ! isset( $test['paused_total'] ) ) {
$test['paused_total'] = 0;
}
$changed = true;
}
}
unset( $test );
if ( $changed ) {
update_option( $option_name, $tests, false );
if ( get_option( $option_name, array() ) === $tests ) {
abtestkit_connected_collect_registry_change( $original_tests, $tests );
abtestkit_connected_registration_observe_change( $original_tests, $tests );
}
}
}
/**
* Full ABTestKit uninstall cleanup.
*
* Deletes:
* - ABTestKit-created shadow Version B objects.
* - Stored tests.
* - ABTestKit options/transients.
* - ABTestKit post meta markers.
*
* Does NOT delete user-owned existing content used as Version B.
*/
function abtestkit_nuke_owned_plugin_state() : void {
global $wpdb;
abtestkit_connected_transport_remove_local_state();
abtestkit_connected_registration_clear();
ABTestKit_Connected_Outbox::uninstall();
if ( function_exists( 'abtestkit_hosted_sync_unschedule' ) ) {
abtestkit_hosted_sync_unschedule();
}
$option_name = defined( 'ABTESTKIT_PAGE_TESTS_OPTION' ) ? ABTESTKIT_PAGE_TESTS_OPTION : 'abtestkit_page_tests';
$tests = get_option( $option_name, [] );
$tests = is_array( $tests ) ? $tests : [];
$shadow_ids = [];
foreach ( $tests as $test ) {
if ( ! is_array( $test ) ) {
continue;
}
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
$test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
if ( $variant_id <= 0 ) {
continue;
}
if ( (int) get_post_meta( $variant_id, '_abtestkit_shadow', true ) === 1 ) {
$shadow_ids[] = $variant_id;
} else {
// Existing user-owned B content: only remove ABTestKit markers.
if ( function_exists( 'abtestkit_pt_unmark_existing_variant_in_use' ) ) {
abtestkit_pt_unmark_existing_variant_in_use( $variant_id, $test_id );
} else {
delete_post_meta( $variant_id, '_abtestkit_variant_in_use' );
delete_post_meta( $variant_id, '_abtestkit_variant_of' );
delete_post_meta( $variant_id, '_abtestkit_variant_test_id' );
}
}
}
// Also catch any orphan shadow objects not present in the test registry.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$orphan_shadow_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT post_id
FROM {$wpdb->postmeta}
WHERE meta_key = %s
AND meta_value = %s",
'_abtestkit_shadow',
'1'
)
);
if ( is_array( $orphan_shadow_ids ) ) {
foreach ( $orphan_shadow_ids as $orphan_shadow_id ) {
$shadow_ids[] = (int) $orphan_shadow_id;
}
}
$shadow_ids = array_values( array_unique( array_filter( array_map( 'absint', $shadow_ids ) ) ) );
foreach ( $shadow_ids as $shadow_id ) {
abtestkit_delete_owned_shadow_variant( $shadow_id );
}
// Remove ABTestKit meta mirrors/markers from any remaining user-owned posts.
$meta_keys = [
'_abtestkit_variants',
'_abtestkit_variant_in_use',
'_abtestkit_variant_of',
'_abtestkit_variant_test_id',
];
foreach ( $meta_keys as $meta_key ) {
if ( function_exists( 'delete_post_meta_by_key' ) ) {
delete_post_meta_by_key( $meta_key );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
$meta_key
)
);
}
}
// Delete known ABTestKit options.
$options = [
$option_name,
'abtestkit_do_activation_redirect',
'abtestkit_events_schema_version',
'abtestkit_fixed_version_b_titles',
'abtestkit_onboarding_done',
'abtestkit_telemetry_opted_in',
'abtestkit_telemetry_flags',
'abtestkit_telemetry_installed_at',
'abtestkit_telemetry_tests_created',
'abtestkit_review_prompt_state',
'abtestkit_compatibility_prompt_state',
'abtestkit_preview_health_failures',
'abtestkit_html_runtime_health',
'abtestkit_custom_javascript_health',
'abtestkit_hosted_connection',
'abtestkit_hosted_connection_attempt',
'abtestkit_hosted_email_updates_banner_state',
'abtestkit_hosted_sync_state',
'abtestkit_connected_collection_state',
'abtestkit_connected_milestone_cursor',
'abtestkit_connected_deactivation_event',
];
foreach ( $options as $option ) {
delete_option( $option );
}
// Opaque per-test references are retained across reconnects, but removed
// on clean uninstall with the rest of this plugin's owned state.
$connected_ref_like = $wpdb->esc_like( ABTESTKIT_CONNECTED_TEST_REF_PREFIX ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall enumerates only this plugin's owned option prefix before deleting through the Options API.
$connected_ref_options = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $connected_ref_like ) );
foreach ( (array) $connected_ref_options as $connected_ref_option ) {
delete_option( (string) $connected_ref_option );
}
// Use literal owned prefixes: the standalone uninstall path does not load
// the normal plugin bootstrap where the collector constants are defined.
foreach ( array( 'abtestkit_connected_milestone_baseline_', 'abtestkit_connected_milestone_event_' ) as $milestone_prefix ) {
$milestone_like = $wpdb->esc_like( $milestone_prefix ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall enumerates only this plugin's owned option prefix before deleting through the Options API.
$milestone_options = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $milestone_like ) );
foreach ( (array) $milestone_options as $milestone_option ) {
delete_option( (string) $milestone_option );
}
}
// Standalone uninstall does not bootstrap connected-onboarding.php.
$onboarding_like = $wpdb->esc_like( 'abtestkit_connected_onboarding_event_' ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall enumerates only this plugin's owned option prefix before deleting through the Options API.
$onboarding_options = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $onboarding_like ) );
foreach ( (array) $onboarding_options as $onboarding_option ) {
delete_option( (string) $onboarding_option );
}
// Custom JavaScript source is stored in immutable, non-autoloaded options.
// Remove registered and orphaned records using only the owned exact prefix.
$javascript_option_prefix = defined( 'ABTESTKIT_CUSTOM_JAVASCRIPT_CODE_OPTION_PREFIX' )
? ABTESTKIT_CUSTOM_JAVASCRIPT_CODE_OPTION_PREFIX
: 'abtestkit_custom_javascript_code_';
$javascript_option_like = $wpdb->esc_like( $javascript_option_prefix ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$javascript_option_names = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$javascript_option_like
)
);
if ( is_array( $javascript_option_names ) ) {
foreach ( $javascript_option_names as $javascript_option_name ) {
delete_option( (string) $javascript_option_name );
}
}
// Delete ABTestKit transients and transient timeouts.
$transient_prefixes = [
'_transient_abtestkit_',
'_transient_timeout_abtestkit_',
];
foreach ( $transient_prefixes as $transient_prefix ) {
$like = $wpdb->esc_like( $transient_prefix ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
$like
)
);
}
}
// Deactivation: pause running tests, remove rate-limit transients and flush page/object caches
register_deactivation_hook(__FILE__, 'abtestkit_on_deactivate');
function abtestkit_on_deactivate() {
// The authenticated connected-service ping is independent of the optional
// feedback modal and must never prevent deactivation when delivery fails.
abtestkit_connected_deactivation_send();
abtestkit_connected_transport_unschedule();
if ( function_exists( 'abtestkit_telemetry_unschedule_heartbeat' ) ) {
abtestkit_telemetry_unschedule_heartbeat();
}
if ( function_exists( 'abtestkit_hosted_sync_unschedule' ) ) {
abtestkit_hosted_sync_unschedule();
}
// Safety: if a site owner deactivates because something broke, do not let
// a running test silently resume if they reactivate/reinstall.
abtestkit_pause_tests_for_safe_deactivation();
global $wpdb;
// Maintenance: clear this plugin’s rate-limit transients on deactivation.
$transient_prefixes = [
'_transient_abtestkit_',
'_transient_timeout_abtestkit_',
];
foreach ( $transient_prefixes as $transient_prefix ) {
$like = $wpdb->esc_like( $transient_prefix ) . '%';
$cache_key = 'abtestkit_deactivate_option_names_' . md5( $like );
$option_names = wp_cache_get( $cache_key, 'abtestkit' );
if ( false === $option_names ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$option_names = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$like
)
);
wp_cache_set( $cache_key, $option_names, 'abtestkit', MINUTE_IN_SECONDS );
}
if ( empty( $option_names ) || ! is_array( $option_names ) ) {
continue;
}
foreach ( $option_names as $option_name ) {
$option_name = (string) $option_name;
if ( strpos( $option_name, '_transient_timeout_' ) === 0 ) {
delete_transient( substr( $option_name, strlen( '_transient_timeout_' ) ) );
} elseif ( strpos( $option_name, '_transient_' ) === 0 ) {
delete_transient( substr( $option_name, strlen( '_transient_' ) ) );
}
}
}
// Best-effort cache flushers (object cache + common caching plugins)
if (function_exists('wp_cache_flush')) { wp_cache_flush(); }
if (function_exists('w3tc_flush_all')) { w3tc_flush_all(); }
if (function_exists('rocket_clean_domain')) { rocket_clean_domain(); }
if (function_exists('autoptimize_flush_cache')) { autoptimize_flush_cache(); }
// Purge LiteSpeed Cache via documented external hook.
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
if (class_exists('LiteSpeed_Cache')) { do_action('litespeed_purge_all'); }
// WP Super Cache
if (function_exists('wp_cache_clear_cache')) { wp_cache_clear_cache(); }
}
// --- Helper to strip AB attributes from block trees ---
function abtestkit_strip_ab_attrs_from_blocks(array $blocks, &$changed = false) {
$out = [];
foreach ($blocks as $b) {
if (!is_array($b)) { $out[] = $b; continue; }
// Remove all AB-related attributes the plugin added
$attrs = isset($b['attrs']) && is_array($b['attrs']) ? $b['attrs'] : [];
$keys = [
'abTestEnabled','abTestVariants','abTestId','abTestRunning','abTestWinner',
'abTestResultsViewed','conversionFrom','abTestLastUnlocked','abTestStartedAt',
'abTestFinishedAt','abSync','abGroupKey'
];
$touched = false;
foreach ($keys as $k) {
if (isset($attrs[$k])) { unset($attrs[$k]); $touched = true; }
}
if ($touched) { $changed = true; }
$b['attrs'] = $attrs;
// Recurse innerBlocks
if (!empty($b['innerBlocks']) && is_array($b['innerBlocks'])) {
$b['innerBlocks'] = abtestkit_strip_ab_attrs_from_blocks($b['innerBlocks'], $changed);
}
$out[] = $b;
}
return $out;
}
// ─────────────────────────────────────────────────────────────────────────────
// abtestkit – Page Duplicator A/B Tests (MVP Dashboard)
// ─────────────────────────────────────────────────────────────────────────────
if ( ! defined( 'ABTESTKIT_PAGE_TESTS_OPTION' ) ) {
define( 'ABTESTKIT_PAGE_TESTS_OPTION', 'abtestkit_page_tests' );
}
/**
* Automatically pause a running test when its health check says it is broken.
*
* This is intentionally based on the same health summary used in the dashboard
* and performance screen, so the automatic safety behaviour matches what the
* user can already see in the UI.
*
* @return array{0: array, 1: bool}
*/
function abtestkit_pt_maybe_auto_pause_broken_test( array $test ) : array {
$status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
if ( $status !== 'running' ) {
return [ $test, false ];
}
if ( ! function_exists( 'abtestkit_pt_health_summary' ) ) {
return [ $test, false ];
}
$health = abtestkit_pt_health_summary( $test, abtestkit_pt_stats_default() );
if ( ! is_array( $health ) || ( (string) ( $health['status'] ?? '' ) ) !== 'broken' ) {
return [ $test, false ];
}
$now = current_time( 'timestamp' );
$test['status'] = 'paused';
$test['paused_at'] = empty( $test['paused_at'] ) ? $now : (int) $test['paused_at'];
$test['paused_total'] = isset( $test['paused_total'] ) ? max( 0, (int) $test['paused_total'] ) : 0;
$test['auto_paused_broken'] = 1;
if ( empty( $test['auto_paused_broken_at'] ) ) {
$test['auto_paused_broken_at'] = $now;
}
$test['auto_paused_broken_summary'] = sanitize_text_field( (string) ( $health['summary'] ?? '' ) );
$broken_checks = [];
foreach ( (array) ( $health['checks'] ?? [] ) as $check ) {
if ( ! is_array( $check ) || ( (string) ( $check['status'] ?? '' ) ) !== 'broken' ) {
continue;
}
$title = sanitize_text_field( (string) ( $check['title'] ?? '' ) );
if ( $title !== '' ) {
$broken_checks[] = $title;
}
}
$test['auto_paused_broken_checks'] = array_slice( array_values( array_unique( $broken_checks ) ), 0, 5 );
if ( ! empty( $test['id'] ) && function_exists( 'abtestkit_pt_flush_test_caches' ) ) {
abtestkit_pt_flush_test_caches( (string) $test['id'] );
}
return [ $test, true ];
}
/**
* Registry helpers (stored as a single option).
* Test shape: id, title, control_id, variant_id, status(paused|running|complete|draft),
* split (0..100 => % to B), cookie_ttl_days, started_at, finished_at
*/
function abtestkit_pt_all() : array {
$tests = get_option( ABTESTKIT_PAGE_TESTS_OPTION, [] );
if ( ! is_array( $tests ) ) {
return [];
}
$original_tests = $tests;
/*
* Registry reads happen from frontend hooks, WooCommerce data stores and
* health checks. Never let a nested product/filter callback normalise the
* registry again while the outer read is still in progress.
*/
static $normalising = false;
if ( $normalising ) {
return $tests;
}
$normalising = true;
$changed = false;
foreach ( $tests as &$t ) {
if ( ! is_array( $t ) || empty( $t['id'] ) ) {
continue;
}
// Normalise status – default to "paused"
if ( empty( $t['status'] ) ) {
$t['status'] = 'paused';
$changed = true;
} else {
$valid_statuses = [ 'paused', 'running', 'winner', 'complete', 'draft' ];
if ( ! in_array( $t['status'], $valid_statuses, true ) ) {
$t['status'] = 'paused';
$changed = true;
}
}
// If a test has a finished_at timestamp but is still "running", mark it complete.
$finished_at = isset( $t['finished_at'] ) ? (int) $t['finished_at'] : 0;
if ( $finished_at > 0 && $t['status'] === 'running' ) {
$t['status'] = 'complete';
$changed = true;
}
// Normalise pause bookkeeping fields so older installs don't break.
$paused_at = isset( $t['paused_at'] ) ? (int) $t['paused_at'] : 0;
if ( $paused_at < 0 ) { $paused_at = 0; }
if ( ! isset( $t['paused_at'] ) || (int) $t['paused_at'] !== $paused_at ) {
$t['paused_at'] = $paused_at;
$changed = true;
}
$paused_total = isset( $t['paused_total'] ) ? (int) $t['paused_total'] : 0;
if ( $paused_total < 0 ) { $paused_total = 0; }
if ( ! isset( $t['paused_total'] ) || (int) $t['paused_total'] !== $paused_total ) {
$t['paused_total'] = $paused_total;
$changed = true;
}
$started_at = isset( $t['started_at'] ) ? (int) $t['started_at'] : 0;
if ( $started_at < 0 ) { $started_at = 0; }
if ( ! isset( $t['started_at'] ) || (int) $t['started_at'] !== $started_at ) {
$t['started_at'] = $started_at;
$changed = true;
}
// Safety: if a "running" test has lost both page references, pause it.
if ( $t['status'] === 'running' ) {
$control_id = isset( $t['control_id'] ) ? (int) $t['control_id'] : 0;
$variant_id = isset( $t['variant_id'] ) ? (int) $t['variant_id'] : 0;
if ( $control_id <= 0 && $variant_id <= 0 ) {
$t['status'] = 'paused';
$changed = true;
}
}
if ( $t['status'] === 'running' && function_exists( 'abtestkit_pt_maybe_auto_pause_broken_test' ) ) {
list( $maybe_paused_test, $was_auto_paused ) = abtestkit_pt_maybe_auto_pause_broken_test( $t );
if ( $was_auto_paused ) {
$t = $maybe_paused_test;
$changed = true;
}
}
// Infer missing test kind for older installs/tests.
if ( empty( $t['kind'] ) ) {
$control_id = isset( $t['control_id'] ) ? (int) $t['control_id'] : 0;
$control_type = $control_id > 0 ? get_post_type( $control_id ) : '';
if ( $control_type === 'product' ) {
$t['kind'] = 'product';
$changed = true;
} elseif ( $control_type === 'post' ) {
$t['kind'] = 'post';
$changed = true;
} else {
$t['kind'] = 'page';
$changed = true;
}
}
// Decision settings (new) — defaults for older installs/tests.
if ( empty( $t['decision_rule'] ) ) {
$t['decision_rule'] = 'balanced';
$changed = true;
}
if ( empty( $t['decision_mode'] ) ) {
// If rule is manual, mode must be manual.
$t['decision_mode'] = ( (string) $t['decision_rule'] === 'manual' ) ? 'manual' : 'auto';
$changed = true;
}
// Normalise thresholds.
$rule = (string) $t['decision_rule'];
$mode = (string) $t['decision_mode'];
// Manual mode: never auto-declare winner => allow 0/0 thresholds.
if ( $mode === 'manual' || $rule === 'manual' ) {
if ( (int) ( $t['min_impressions'] ?? -1 ) !== 0 ) {
$t['min_impressions'] = 0;
$changed = true;
}
if ( (int) ( $t['min_conversions'] ?? -1 ) !== 0 ) {
$t['min_conversions'] = 0;
$changed = true;
}
} else {
// Auto mode: only allow preset thresholds.
$allowed_mi = [ 25, 50, 75 ];
$allowed_mc = [ 3, 5, 10 ];
$mi = isset( $t['min_impressions'] ) ? (int) $t['min_impressions'] : 0;
$mc = isset( $t['min_conversions'] ) ? (int) $t['min_conversions'] : 0;
if ( ! in_array( $mi, $allowed_mi, true ) ) {
$t['min_impressions'] = 50;
$changed = true;
}
if ( ! in_array( $mc, $allowed_mc, true ) ) {
$t['min_conversions'] = 5;
$changed = true;
}
// Keep rule presets consistent even if data got edited.
if ( $rule === 'fast' ) { $t['min_impressions'] = 25; $t['min_conversions'] = 3; $changed = true; }
if ( $rule === 'balanced' ){ $t['min_impressions'] = 50; $t['min_conversions'] = 5; $changed = true; }
if ( $rule === 'precise' ) { $t['min_impressions'] = 75; $t['min_conversions'] = 10; $changed = true; }
}
}
unset( $t ); // break reference
if ( $changed ) {
// Persist the normalised test list back to the option so stale
// "running" tests stop blocking new ones.
update_option( ABTESTKIT_PAGE_TESTS_OPTION, $tests, false );
if ( get_option( ABTESTKIT_PAGE_TESTS_OPTION, array() ) === $tests ) {
abtestkit_connected_collect_registry_change( $original_tests, $tests );
abtestkit_connected_registration_observe_change( $original_tests, $tests );
}
}
$normalising = false;
return $tests;
}
function abtestkit_pt_save( array $tests ) {
$before = get_option( ABTESTKIT_PAGE_TESTS_OPTION, array() );
update_option( ABTESTKIT_PAGE_TESTS_OPTION, $tests, false );
$saved = get_option( ABTESTKIT_PAGE_TESTS_OPTION, array() );
if ( is_array( $saved ) && $saved === $tests && $before !== $saved ) {
abtestkit_connected_collect_registry_change( is_array( $before ) ? $before : array(), $saved );
abtestkit_connected_registration_observe_change( is_array( $before ) ? $before : array(), $saved );
}
}
function abtestkit_pt_get( string $id ) : ?array {
foreach ( abtestkit_pt_all() as $t ) {
if ( isset( $t['id'] ) && $t['id'] === $id ) return $t;
}
return null;
}
/**
* Purge full-page caches when a test starts or stops affecting live delivery.
*
* A cached control response can otherwise be returned before abtestkit reaches
* template_redirect and sets the standard do-not-cache constants. Lifecycle
* changes are rare, so a site-wide purge is safer than guessing which pages
* contain a reusable section or another indirectly scoped test.
*/
function abtestkit_pt_purge_delivery_cache( array $test ) : void {
if ( class_exists( 'Cache_Enabler' ) && is_callable( [ 'Cache_Enabler', 'clear_site_cache' ] ) ) {
Cache_Enabler::clear_site_cache();
}
if ( function_exists( 'wp_cache_clear_cache' ) ) {
wp_cache_clear_cache();
}
if ( function_exists( 'w3tc_flush_all' ) ) {
w3tc_flush_all();
}
if ( function_exists( 'rocket_clean_domain' ) ) {
rocket_clean_domain();
}
if ( has_action( 'litespeed_purge_all' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed Cache external purge hook.
do_action( 'litespeed_purge_all' );
}
/**
* Fires after built-in page-cache integrations are purged.
*
* @param array $test Test whose live-delivery status changed.
*/
do_action( 'abtestkit_pt_delivery_cache_purged', $test );
}
function abtestkit_pt_put( array $test ) {
$tests = abtestkit_pt_all();
$found = false;
$previous_status = '';
foreach ( $tests as &$t ) {
if ( $t['id'] === $test['id'] ) {
$previous_status = isset( $t['status'] ) ? (string) $t['status'] : '';
$t = $test;
$found = true;
break;
}
}
if ( ! $found ) $tests[] = $test;
abtestkit_pt_save( $tests );
$new_status = isset( $test['status'] ) ? (string) $test['status'] : '';
if (
$previous_status !== $new_status
&& ( $previous_status === 'running' || $new_status === 'running' )
) {
abtestkit_pt_purge_delivery_cache( $test );
}
if ( ! empty( $test['id'] ) ) {
abtestkit_pt_flush_test_caches( (string) $test['id'] );
}
}
function abtestkit_pt_delete( string $id ) {
$all = abtestkit_pt_all();
// Find the test before deleting it (so we can clean up any "existing B" SEO markers).
$test = null;
foreach ( $all as $t ) {
if ( ( $t['id'] ?? '' ) === $id ) {
$test = $t;
break;
}
}
if ( is_array( $test ) ) {
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
// Only unmark existing variants (never touch shadow duplicates).
if ( $variant_id > 0 && ! abtestkit_is_shadow_variant( $variant_id ) ) {
abtestkit_pt_unmark_existing_variant_in_use( $variant_id, (string) $id );
}
}
$tests = array_values(
array_filter(
$all,
fn( $t ) => ( $t['id'] ?? '' ) !== $id
)
);
if ( is_array( $test ) && ! empty( $test['id'] ) ) {
abtestkit_pt_flush_test_caches( (string) $test['id'] );
}
if ( function_exists( 'abtestkit_html_runtime_health_delete' ) ) {
abtestkit_html_runtime_health_delete( $id );
}
abtestkit_pt_save( $tests );
if ( is_array( $test ) && (string) ( $test['status'] ?? '' ) === 'running' ) {
abtestkit_pt_purge_delivery_cache( $test );
}
if ( is_array( $test ) && function_exists( 'abtestkit_custom_javascript_cleanup_for_test' ) ) {
abtestkit_custom_javascript_cleanup_for_test( $test );
}
}
/**
* Wizard safety: track the last "duplicate" created for a user+control so we don't
* accidentally create multiple Version B posts (especially for products).
*/
function abtestkit_pt_last_duplicate_key( int $control_id, int $user_id ) : string {
return 'abtestkit_pt_last_dup_' . (int) $user_id . '_' . (int) $control_id;
}
/** True if a post ID is already the variant_id of any stored test (any status). */
function abtestkit_pt_variant_id_in_any_test( int $variant_id ) : bool {
$variant_id = (int) $variant_id;
if ( $variant_id <= 0 ) return false;
foreach ( abtestkit_pt_all() as $t ) {
if ( (int) ( $t['variant_id'] ?? 0 ) === $variant_id ) {
return true;
}
}
return false;
}
/**
* Return the last duplicate for this user+control if it still looks valid and isn't already in use.
* Also validates shadow relationship for products.
*/
function abtestkit_pt_get_last_duplicate_for_user( int $control_id, int $user_id ) : int {
$control_id = (int) $control_id;
$user_id = (int) $user_id;
if ( $control_id <= 0 || $user_id <= 0 ) return 0;
$key = abtestkit_pt_last_duplicate_key( $control_id, $user_id );
$pid = (int) get_transient( $key );
if ( $pid <= 0 ) return 0;
$p = get_post( $pid );
if ( ! $p ) return 0;
$control_type = get_post_type( $control_id );
if ( ! $control_type || $p->post_type !== $control_type ) return 0;
// Don't reuse if it is already used by any test.
if ( abtestkit_pt_variant_id_in_any_test( $pid ) ) return 0;
// Product safety: must be a shadow of this control.
if ( $control_type === 'product' ) {
if ( ! function_exists( 'abtestkit_is_shadow_product' ) || ! abtestkit_is_shadow_product( $pid ) ) return 0;
$shadow_of = (int) get_post_meta( $pid, '_abtestkit_shadow_of', true );
if ( $shadow_of !== $control_id ) return 0;
}
return $pid;
}
function abtestkit_pt_set_last_duplicate_for_user( int $control_id, int $variant_id, int $user_id, int $ttl_seconds = 1800 ) : void {
$control_id = (int) $control_id;
$variant_id = (int) $variant_id;
$user_id = (int) $user_id;
if ( $control_id <= 0 || $variant_id <= 0 || $user_id <= 0 ) return;
$ttl_seconds = max( 60, (int) $ttl_seconds );
set_transient( abtestkit_pt_last_duplicate_key( $control_id, $user_id ), $variant_id, $ttl_seconds );
}
function abtestkit_pt_clear_last_duplicate_for_user( int $control_id, int $user_id ) : void {
$control_id = (int) $control_id;
$user_id = (int) $user_id;
if ( $control_id <= 0 || $user_id <= 0 ) return;
delete_transient( abtestkit_pt_last_duplicate_key( $control_id, $user_id ) );
}
/**
* Find the best RUNNING test by page id.
* Returns [test, "control"|"variant"].
* If multiple running tests match, pick the newest (highest started_at).
*/
function abtestkit_pt_find_by_post( int $post_id ) : array {
$post_id = (int) $post_id;
if ( $post_id <= 0 ) return [ null, '' ];
$variant_matches = [];
$control_matches = [];
foreach ( abtestkit_pt_all() as $t ) {
if ( ! is_array( $t ) ) continue;
if ( ( $t['status'] ?? 'paused' ) !== 'running' ) continue;
if ( ( $t['kind'] ?? '' ) === 'reusable_section' ) continue;
if ( (int) ( $t['variant_id'] ?? 0 ) === $post_id ) {
$variant_matches[] = $t;
continue;
}
if ( (int) ( $t['control_id'] ?? 0 ) === $post_id ) {
$control_matches[] = $t;
}
}
// Variant match should win immediately (unique URL for PAGE tests).
if ( count( $variant_matches ) === 1 ) {
return [ $variant_matches[0], 'variant' ];
}
if ( count( $variant_matches ) > 1 ) {
if ( ! headers_sent() ) header( 'X-Abtestkit-Conflict: variant-in-multiple-tests' );
return [ null, '' ];
}
// Control match: if multiple, pick newest by started_at.
if ( empty( $control_matches ) ) return [ null, '' ];
usort( $control_matches, function( $a, $b ) {
$sa = (int) ( $a['started_at'] ?? 0 );
$sb = (int) ( $b['started_at'] ?? 0 );
if ( $sa !== $sb ) return $sb <=> $sa; // newest first
return strcmp( (string) ( $b['id'] ?? '' ), (string) ( $a['id'] ?? '' ) );
} );
return [ $control_matches[0], 'control' ];
}
/**
* Like abtestkit_pt_find_by_post() but allows resolving tests that are paused/complete/etc.
* Used for preview forcing (?abtestkit_preview=1&abtestkit_force=A|B).
*/
function abtestkit_pt_find_by_post_any_status( int $post_id ) : array {
$post_id = (int) $post_id;
if ( $post_id <= 0 ) return [ null, '' ];
$variant_matches = [];
$control_matches = [];
foreach ( abtestkit_pt_all() as $t ) {
if ( ! is_array( $t ) ) continue;
// Skip drafts; allow paused/running/complete
if ( ( $t['status'] ?? 'paused' ) === 'draft' ) continue;
if ( ( $t['kind'] ?? '' ) === 'reusable_section' ) continue;
if ( (int) ( $t['variant_id'] ?? 0 ) === $post_id ) {
$variant_matches[] = $t;
continue;
}
if ( (int) ( $t['control_id'] ?? 0 ) === $post_id ) {
$control_matches[] = $t;
}
}
// Variant match wins if unique.
if ( count( $variant_matches ) === 1 ) {
return [ $variant_matches[0], 'variant' ];
}
if ( count( $variant_matches ) > 1 ) {
if ( ! headers_sent() ) header( 'X-Abtestkit-Conflict: variant-in-multiple-tests' );
return [ null, '' ];
}
if ( empty( $control_matches ) ) return [ null, '' ];
// Prefer running > paused > complete, then newest started_at.
usort( $control_matches, function( $a, $b ) {
$pa = function_exists( 'abtestkit_pt_status_priority' ) ? abtestkit_pt_status_priority( (string) ( $a['status'] ?? '' ) ) : 5;
$pb = function_exists( 'abtestkit_pt_status_priority' ) ? abtestkit_pt_status_priority( (string) ( $b['status'] ?? '' ) ) : 5;
if ( $pa !== $pb ) return $pa <=> $pb;
$sa = (int) ( $a['started_at'] ?? 0 );
$sb = (int) ( $b['started_at'] ?? 0 );
if ( $sa !== $sb ) return $sb <=> $sa;
return strcmp( (string) ( $b['id'] ?? '' ), (string) ( $a['id'] ?? '' ) );
} );
return [ $control_matches[0], 'control' ];
}
/**
* Extract [embed_page id="123"] source IDs from a post-like object.
*
* We scan post_content, post_excerpt, and common builder meta. This avoids
* running shortcodes or rendering builders during conflict checks.
*/
function abtestkit_pt_embedded_shortcode_source_ids_for_post( int $post_id, string $tag = 'embed_page', string $attribute = 'id' ) : array {
static $cache = [];
$post_id = absint( $post_id );
$tag = sanitize_key( $tag );
$attribute = sanitize_key( $attribute );
if ( $post_id <= 0 || $tag === '' || $attribute === '' ) {
return [];
}
$cache_key = $post_id . '|' . $tag . '|' . $attribute;
if ( isset( $cache[ $cache_key ] ) ) {
return $cache[ $cache_key ];
}
$post = get_post( $post_id );
if ( ! $post ) {
$cache[ $cache_key ] = [];
return [];
}
$haystack = '';
$haystack .= "\n" . (string) $post->post_content;
$haystack .= "\n" . (string) $post->post_excerpt;
/*
* Builder/meta fields where shortcode-like content is commonly stored.
* Kept intentionally small to avoid expensive whole-meta scans in the wizard.
*/
$meta_keys = apply_filters(
'abtestkit_reusable_section_conflict_meta_keys',
[
'_elementor_data',
'_fl_builder_data',
'_bricks_page_content',
'_oxygen_builder_shortcodes',
'_ct_builder_shortcodes',
],
$post_id
);
foreach ( (array) $meta_keys as $meta_key ) {
$meta_key = sanitize_key( (string) $meta_key );
if ( $meta_key === '' ) {
continue;
}
$values = get_post_meta( $post_id, $meta_key, false );
foreach ( (array) $values as $value ) {
if ( is_scalar( $value ) ) {
$haystack .= "\n" . (string) $value;
} elseif ( is_array( $value ) || is_object( $value ) ) {
$encoded = wp_json_encode( $value );
if ( is_string( $encoded ) ) {
$haystack .= "\n" . $encoded;
}
}
}
}
if ( $haystack === '' ) {
$cache[ $cache_key ] = [];
return [];
}
$charset = get_bloginfo( 'charset' );
$haystack = html_entity_decode( $haystack, ENT_QUOTES, $charset ? $charset : 'UTF-8' );
if ( strpos( $haystack, '[' . $tag ) === false ) {
$cache[ $cache_key ] = [];
return [];
}
$ids = [];
$pattern = get_shortcode_regex( [ $tag ] );
if ( preg_match_all( '/' . $pattern . '/s', $haystack, $matches, PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
$found_tag = isset( $match[2] ) ? sanitize_key( (string) $match[2] ) : '';
if ( $found_tag !== $tag ) {
continue;
}
$raw_atts = isset( $match[3] ) ? (string) $match[3] : '';
$atts = shortcode_parse_atts( $raw_atts );
if ( ! is_array( $atts ) ) {
continue;
}
if ( isset( $atts[ $attribute ] ) ) {
$id = absint( $atts[ $attribute ] );
if ( $id > 0 ) {
$ids[] = $id;
}
}
}
}
$ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
$cache[ $cache_key ] = $ids;
return $ids;
}
/**
* Does a host post/product/page embed a given reusable source page?
*/
function abtestkit_pt_post_embeds_reusable_source( int $host_id, int $source_id ) : bool {
$host_id = absint( $host_id );
$source_id = absint( $source_id );
if ( $host_id <= 0 || $source_id <= 0 ) {
return false;
}
$ids = abtestkit_pt_embedded_shortcode_source_ids_for_post( $host_id, 'embed_page', 'id' );
return in_array( $source_id, $ids, true );
}
/**
* Return IDs of running tests that conflict with a proposed test.
*
* Normal direct conflict:
* - A/B page IDs cannot be reused in another running test.
*
* Reusable-section conflict:
* - A reusable-section source cannot be tested while a running page/product/post
* test embeds that source with [embed_page id="123"].
* - A page/product/post cannot be tested while it embeds a source that already
* has a running reusable-section test.
*/
function abtestkit_pt_conflicts_for_pages( int $control_id, int $variant_id = 0, string $exclude_id = '', string $new_kind = '' ) : array {
$control_id = absint( $control_id );
$variant_id = absint( $variant_id );
$new_kind = sanitize_key( $new_kind );
$conflicts = [];
foreach ( abtestkit_pt_all() as $t ) {
if ( ! is_array( $t ) ) {
continue;
}
if ( ( $t['status'] ?? 'paused' ) !== 'running' ) {
continue;
}
$existing_id = isset( $t['id'] ) ? (string) $t['id'] : '';
if ( $exclude_id && $existing_id !== '' && $existing_id === $exclude_id ) {
continue;
}
$existing_control = isset( $t['control_id'] ) ? absint( $t['control_id'] ) : 0;
$existing_variant = isset( $t['variant_id'] ) ? absint( $t['variant_id'] ) : 0;
$existing_kind = isset( $t['kind'] ) ? sanitize_key( (string) $t['kind'] ) : '';
// Existing direct guardrail: same A/B IDs cannot be reused.
$uses_control = $existing_control === $control_id || ( $variant_id && $existing_control === $variant_id );
$uses_variant = $existing_variant === $control_id || ( $variant_id && $existing_variant === $variant_id );
if ( $uses_control || $uses_variant ) {
$conflicts[] = $existing_id;
continue;
}
/*
* Direction 1:
* New test is a reusable-section source page.
* Block it if any existing running page/product/post test embeds that source.
*/
if ( $new_kind === 'reusable_section' && $control_id > 0 && $existing_kind !== 'reusable_section' ) {
$existing_hosts = array_values(
array_filter(
array_unique(
[
$existing_control,
$existing_variant,
]
)
)
);
foreach ( $existing_hosts as $host_id ) {
if ( abtestkit_pt_post_embeds_reusable_source( (int) $host_id, $control_id ) ) {
$conflicts[] = $existing_id;
continue 2;
}
}
}
/*
* Direction 2:
* New test is a normal page/post/product.
* Block it if it embeds a source page already under a running reusable-section test.
*/
if ( $new_kind !== 'reusable_section' && $existing_kind === 'reusable_section' ) {
$existing_source = isset( $t['source_page_id'] )
? absint( $t['source_page_id'] )
: $existing_control;
if ( $existing_source <= 0 ) {
continue;
}
$new_hosts = array_values(
array_filter(
array_unique(
[
$control_id,
$variant_id,
]
)
)
);
foreach ( $new_hosts as $host_id ) {
if ( abtestkit_pt_post_embeds_reusable_source( (int) $host_id, $existing_source ) ) {
$conflicts[] = $existing_id;
continue 2;
}
}
}
}
return array_values( array_filter( array_unique( $conflicts ) ) );
}
/**
* Integration layer: regenerate CSS/assets for common page builders
* when we duplicate a page (Version B).
*
* @param int $new_id The ID of the duplicated page.
* @param int $source_id The original/control page ID.
*/
function abtestkit_regenerate_builder_assets( int $new_id, int $source_id = 0 ) : void {
$new_id = (int) $new_id;
$source_id = (int) $source_id;
if ( $new_id <= 0 ) {
return;
}
// ─────────────────────────────────────────────────────
// Elementor
// ─────────────────────────────────────────────────────
if ( defined( 'ELEMENTOR_VERSION' ) && class_exists( '\Elementor\Plugin' ) ) {
try {
// Clear any old CSS and build fresh CSS for this post
if ( isset( \Elementor\Plugin::$instance->files_manager )
&& method_exists( \Elementor\Plugin::$instance->files_manager, 'clear_cache' )
) {
\Elementor\Plugin::$instance->files_manager->clear_cache();
}
// Ask Elementor to clear generated CSS for the post (external hook).
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
do_action( 'elementor/css-file/post/clear', $new_id );
// Newer Elementor has a per-post CSS object.
if ( class_exists( '\Elementor\Core\Files\CSS\Post' ) ) {
$css = new \Elementor\Core\Files\CSS\Post( $new_id );
if ( method_exists( $css, 'update' ) ) {
$css->update();
}
}
} catch ( \Throwable $e ) {
// Fail silently – never break the site if Elementor changes APIs.
}
}
// ─────────────────────────────────────────────────────
// Breakdance
// ─────────────────────────────────────────────────────
if ( function_exists( 'breakdance' ) ) {
try {
// Official per-post CSS regeneration.
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
do_action( 'breakdance_generate_css_for_post', $new_id );
// Optional: full CSS cache clear (heavier; keep only if needed).
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
// do_action( 'breakdance_clear_css_cache' );
} catch ( \Throwable $e ) {
}
}
// ─────────────────────────────────────────────────────
// Beaver Builder
// ─────────────────────────────────────────────────────
if ( class_exists( 'FLBuilderModel' ) ) {
try {
if ( method_exists( 'FLBuilderModel', 'delete_asset_cache_for_post' ) ) {
\FLBuilderModel::delete_asset_cache_for_post( $new_id );
}
if ( function_exists( 'fl_builder_flush_caches' ) ) {
fl_builder_flush_caches();
}
} catch ( \Throwable $e ) {
}
}
// ─────────────────────────────────────────────────────
// Divi
// ─────────────────────────────────────────────────────
if ( function_exists( 'et_core_cache_flush' ) ) {
try {
et_core_cache_flush();
} catch ( \Throwable $e ) {
}
}
// ─────────────────────────────────────────────────────
// Bricks
// ─────────────────────────────────────────────────────
if ( defined( 'BRICKS_VERSION' ) ) {
try {
// External hook – you or Bricks can hook into this.
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
do_action( 'bricks/abtestkit/regenerate_css', $new_id, $source_id );
} catch ( \Throwable $e ) {
}
}
// ─────────────────────────────────────────────────────
// Oxygen
// ─────────────────────────────────────────────────────
if ( function_exists( 'ct_cssbuffer_update' ) ) {
try {
ct_cssbuffer_update();
} catch ( \Throwable $e ) {
}
}
// ─────────────────────────────────────────────────────
// WPBakery / Visual Composer: shortcode-based
// ─────────────────────────────────────────────────────
// No direct API needed, but expose a hook for add-ons.
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
do_action( 'abtestkit/after_duplicate/wpbakery', $new_id, $source_id );
// ─────────────────────────────────────────────────────
// Generic caches (object cache / page caches)
// ─────────────────────────────────────────────────────
if ( function_exists( 'wp_cache_flush' ) ) {
wp_cache_flush();
}
}
/**
* Keep a shadow product's canonical WooCommerce type identical to Version A.
*
* WooCommerce resolves a product class from the single `product_type` taxonomy
* term. A broad taxonomy clone is normally enough, but explicitly replacing and
* verifying this term prevents a newly inserted shadow from being read as the
* default simple type by either the classic or block-based product editor.
*/
function abtestkit_pt_sync_shadow_product_type( int $control_product_id, int $shadow_product_id ) : string {
if (
$control_product_id <= 0
|| $shadow_product_id <= 0
|| get_post_type( $control_product_id ) !== 'product'
|| get_post_type( $shadow_product_id ) !== 'product'
|| ! taxonomy_exists( 'product_type' )
) {
return '';
}
$source_type = '';
if ( function_exists( 'wc_get_product' ) ) {
$control_product = wc_get_product( $control_product_id );
if ( $control_product instanceof WC_Product ) {
$source_type = sanitize_title( (string) $control_product->get_type() );
}
}
if ( $source_type === '' ) {
$source_types = wp_get_object_terms(
$control_product_id,
'product_type',
[ 'fields' => 'slugs' ]
);
if ( ! is_wp_error( $source_types ) && ! empty( $source_types ) ) {
$source_type = sanitize_title( (string) reset( $source_types ) );
}
}
if ( $source_type === '' || ! term_exists( $source_type, 'product_type' ) ) {
return '';
}
$shadow_types = wp_get_object_terms(
$shadow_product_id,
'product_type',
[ 'fields' => 'slugs' ]
);
$shadow_types = is_wp_error( $shadow_types )
? []
: array_values( array_unique( array_map( 'sanitize_title', (array) $shadow_types ) ) );
if ( count( $shadow_types ) !== 1 || $shadow_types[0] !== $source_type ) {
$updated = wp_set_object_terms( $shadow_product_id, $source_type, 'product_type', false );
if ( is_wp_error( $updated ) ) {
return '';
}
}
clean_object_term_cache( $shadow_product_id, 'product' );
clean_post_cache( $shadow_product_id );
if ( function_exists( 'wc_delete_product_transients' ) ) {
wc_delete_product_transients( $shadow_product_id );
}
$verified_types = wp_get_object_terms(
$shadow_product_id,
'product_type',
[ 'fields' => 'slugs' ]
);
if (
is_wp_error( $verified_types )
|| count( $verified_types ) !== 1
|| sanitize_title( (string) reset( $verified_types ) ) !== $source_type
) {
return '';
}
return $source_type;
}
/**
* Repair an already-created shadow before WooCommerce chooses its editor form.
*
* WooCommerce's beta editor redirects on `current_screen` at priority 30, so
* this runs earlier and leaves editor selection to WooCommerce itself.
*/
function abtestkit_pt_sync_shadow_product_type_before_edit( $screen ) : void {
if (
! is_object( $screen )
|| (string) ( $screen->base ?? '' ) !== 'post'
|| (string) ( $screen->post_type ?? '' ) !== 'product'
) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing values select an authorised product repair.
$post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing values select an authorised product repair.
$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
if (
$post_id <= 0
|| $action !== 'edit'
|| ! current_user_can( 'edit_post', $post_id )
|| ! function_exists( 'abtestkit_is_shadow_product' )
|| ! abtestkit_is_shadow_product( $post_id )
) {
return;
}
$control_product_id = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
abtestkit_pt_sync_shadow_product_type( $control_product_id, $post_id );
}
add_action( 'current_screen', 'abtestkit_pt_sync_shadow_product_type_before_edit', 5 );
/** Duplicate a page or product (content + meta + taxonomies). */
function abtestkit_duplicate_post_deep( int $post_id ) : int {
$orig = get_post( $post_id );
if ( ! $orig ) {
return 0;
}
// Only allow types we deliberately support in the wizard.
$allowed_types = [ 'page', 'post', 'product' ];
if ( ! in_array( $orig->post_type, $allowed_types, true ) ) {
return 0;
}
$is_product = ( $orig->post_type === 'product' );
$new_postarr = [
// Keep the original title (we’ll show “Shadow / Variant B” via admin UI instead).
'post_title' => $orig->post_title,
'post_content' => $orig->post_content,
// Always create a draft clone from the wizard so it's not publicly accessible.
'post_status' => 'draft',
'post_type' => $orig->post_type,
'post_author' => get_current_user_id() ?: $orig->post_author,
'post_parent' => $orig->post_parent,
'menu_order' => $orig->menu_order,
'post_excerpt' => $orig->post_excerpt,
// Keep a distinct slug
'post_name' => sanitize_title( $orig->post_name . '-variant-b' ),
];
$new_id = wp_insert_post( wp_slash( $new_postarr ), true );
if ( is_wp_error( $new_id ) || ! $new_id ) {
return 0;
}
// Copy taxonomies (works for products and pages)
$taxes = get_object_taxonomies( $orig->post_type );
foreach ( $taxes as $tx ) {
$terms = wp_get_object_terms( $post_id, $tx, [ 'fields' => 'ids' ] );
if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
wp_set_object_terms( $new_id, $terms, $tx, false );
}
}
if ( $is_product ) {
abtestkit_pt_sync_shadow_product_type( (int) $post_id, (int) $new_id );
}
// Copy meta (FAST + low memory): do it in SQL (do not load all meta into PHP).
global $wpdb;
// Exclude a small, fixed set of meta keys from the fast SQL clone.
// Keeping placeholders fixed avoids PHPCS false-positives around dynamic placeholder lists.
$exclude = [
'_edit_lock',
'_edit_last',
'_wp_old_slug',
$is_product ? '_sku' : '__abtestkit_noop__',
];
// Direct INSERT is intentional here (fast meta clone). Caching is not applicable for writes.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"
INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
SELECT %d, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE post_id = %d
AND meta_key NOT IN ( %s, %s, %s, %s )
",
(int) $new_id,
(int) $post_id,
$exclude[0],
$exclude[1],
$exclude[2],
$exclude[3]
)
);
// ───────────────────────────────────────────────────────────
// WooCommerce-specific hygiene for Version B products
// ───────────────────────────────────────────────────────────
if ( $orig->post_type === 'product' ) {
// Mark this as a shadow variant product, linked to its real (A) product.
update_post_meta( $new_id, '_abtestkit_shadow', 1 );
update_post_meta( $new_id, '_abtestkit_shadow_of', (int) $post_id );
// Hide from catalog/search so it never appears in loops.
if ( function_exists( 'wc_get_product' ) ) {
$orig_product = wc_get_product( $post_id );
if (
$orig_product instanceof WC_Product
&& function_exists( 'abtestkit_pt_product_supports_shadow_children' )
&& abtestkit_pt_product_supports_shadow_children( $orig_product )
&& function_exists( 'abtestkit_pt_duplicate_shadow_variations' )
) {
abtestkit_pt_duplicate_shadow_variations( (int) $post_id, (int) $new_id );
// Remove stale variable-product caches/meta copied from A before we save/sync B.
foreach ( [
'_children',
'_visible_children',
'_price',
'_min_variation_price',
'_max_variation_price',
'_min_variation_regular_price',
'_max_variation_regular_price',
'_min_variation_sale_price',
'_max_variation_sale_price',
'_min_price_variation_id',
'_max_price_variation_id',
] as $stale_key ) {
delete_post_meta( $new_id, $stale_key );
}
if ( class_exists( 'WC_Product_Variable' ) && method_exists( 'WC_Product_Variable', 'sync' ) ) {
WC_Product_Variable::sync( $new_id );
}
}
$product_b = wc_get_product( $new_id );
if ( $product_b instanceof WC_Product ) {
$product_b->set_catalog_visibility( 'hidden' ); // not in catalog/search
$product_b->save();
}
if ( function_exists( 'abtestkit_pt_sync_shadow_stock_truth' ) ) {
abtestkit_pt_sync_shadow_stock_truth( (int) $new_id );
}
clean_post_cache( $new_id );
if ( function_exists( 'wc_delete_product_transients' ) ) {
wc_delete_product_transients( $new_id );
wc_delete_product_transients( $post_id );
}
}
// Prevent SKU collisions.
delete_post_meta( $new_id, '_sku' );
// Optional: make sure the shadow cannot ever be purchased if somehow linked.
// (We also enforce this with filters later.)
update_post_meta( $new_id, '_sold_individually', 'yes' );
}
// ───────────────────────────────────────────────────────────
// hygiene: mark ALL duplicates as "shadow" variants
// This is used for SEO (noindex), canonical, and sitemap exclusion,
// even if the test is paused or deleted later.
// ───────────────────────────────────────────────────────────
update_post_meta( $new_id, '_abtestkit_shadow', 1 );
update_post_meta( $new_id, '_abtestkit_shadow_of', (int) $post_id );
// ───────────────────────────────────────────────────────────
// Builder compatibility: regenerate CSS/assets on the clone
// ───────────────────────────────────────────────────────────
abtestkit_regenerate_builder_assets( $new_id, $post_id );
return $new_id;
}
/**
* Shadow / Variant products guardrails
* - Never allow a shadow product to be published
* - Hide shadow products from the normal Products list
* - Ensure shadow product URLs are noindex if accessed
* - Show a strong admin warning banner on edit screens
*/
/**
* Clear cached admin shadow counts (used for list count tabs).
*
* @param string|null $type post type to clear (product/post/page). If null, clears all.
*/
function abtestkit_clear_shadow_counts_cache( $type = null ) {
$cache_group = 'abtestkit';
$blog_id = (int) get_current_blog_id();
if ( $type ) {
wp_cache_delete( 'shadow_counts_by_status_' . $type . '_' . $blog_id, $cache_group );
return;
}
foreach ( [ 'product', 'post', 'page' ] as $pt ) {
wp_cache_delete( 'shadow_counts_by_status_' . $pt . '_' . $blog_id, $cache_group );
}
}
function abtestkit_is_shadow_product( $post_id ) {
return (int) get_post_meta( (int) $post_id, '_abtestkit_shadow', true ) === 1;
}
/**
* Keep non-catalog products out of Woodmart's adjacent-product query.
*
* Woodmart walks past invisible products after get_adjacent_post() returns
* them. Its date-only cursor can select the same invisible product forever,
* which eventually exhausts PHP's execution time. Excluding products that
* WooCommerce cannot show in the catalog also keeps abtestkit shadow products
* out of that loop without changing their stored visibility.
*
* @param string $where Adjacent-post query WHERE clause.
* @return string
*/
function abtestkit_woodmart_adjacent_product_where( $where ) {
global $post, $wpdb;
if (
! class_exists( '\\XTS\\Modules\\WC_Adjacent_Products' )
|| ! ( $post instanceof WP_Post )
|| $post->post_type !== 'product'
) {
return $where;
}
$where .= " AND NOT EXISTS (
SELECT 1
FROM {$wpdb->term_relationships} AS abtk_tr
INNER JOIN {$wpdb->term_taxonomy} AS abtk_tt
ON abtk_tt.term_taxonomy_id = abtk_tr.term_taxonomy_id
INNER JOIN {$wpdb->terms} AS abtk_t
ON abtk_t.term_id = abtk_tt.term_id
WHERE abtk_tr.object_id = p.ID
AND abtk_tt.taxonomy = 'product_visibility'
AND abtk_t.slug = 'exclude-from-catalog'
)";
$where .= " AND NOT EXISTS (
SELECT 1
FROM {$wpdb->postmeta} AS abtk_pm
WHERE abtk_pm.post_id = p.ID
AND abtk_pm.meta_key = '_abtestkit_shadow'
AND abtk_pm.meta_value = '1'
)";
return $where;
}
add_filter( 'get_previous_post_where', 'abtestkit_woodmart_adjacent_product_where', 20 );
add_filter( 'get_next_post_where', 'abtestkit_woodmart_adjacent_product_where', 20 );
function abtestkit_pt_get_product_test_control_id( $product ) : int {
if ( $product instanceof WC_Product_Variation ) {
$parent_id = (int) $product->get_parent_id();
return $parent_id > 0 ? $parent_id : (int) $product->get_id();
}
if ( $product instanceof WC_Product ) {
return (int) $product->get_id();
}
$product_id = absint( $product );
if ( $product_id <= 0 ) {
return 0;
}
if ( get_post_type( $product_id ) === 'product_variation' ) {
$parent_id = (int) wp_get_post_parent_id( $product_id );
return $parent_id > 0 ? $parent_id : $product_id;
}
return $product_id;
}
function abtestkit_pt_variation_signature( $variation ) : string {
if ( ! function_exists( 'wc_get_product' ) ) {
return '';
}
if ( $variation instanceof WC_Product_Variation ) {
$wc_variation = $variation;
} else {
$wc_variation = wc_get_product( $variation );
if ( ! ( $wc_variation instanceof WC_Product_Variation ) ) {
return '';
}
}
$attrs = $wc_variation->get_attributes();
if ( ! is_array( $attrs ) || empty( $attrs ) ) {
return '';
}
$normalized = [];
foreach ( $attrs as $key => $value ) {
$normalized[ sanitize_title( (string) $key ) ] = sanitize_title( (string) $value );
}
ksort( $normalized );
$parts = [];
foreach ( $normalized as $key => $value ) {
$parts[] = $key . '=' . $value;
}
return implode( '|', $parts );
}
function abtestkit_pt_get_shadow_variation_ids_for_product( int $shadow_product_id ) : array {
$shadow_product_id = (int) $shadow_product_id;
if ( $shadow_product_id <= 0 ) {
return [];
}
static $cache = [];
if ( isset( $cache[ $shadow_product_id ] ) ) {
return $cache[ $shadow_product_id ];
}
$ids = get_posts( [
'post_parent' => $shadow_product_id,
'post_type' => 'product_variation',
'post_status' => [ 'publish', 'private', 'draft', 'pending', 'future' ],
'fields' => 'ids',
'posts_per_page' => -1,
'orderby' => 'menu_order ID',
'order' => 'ASC',
] );
$cache[ $shadow_product_id ] = array_values( array_map( 'absint', (array) $ids ) );
return $cache[ $shadow_product_id ];
}
function abtestkit_pt_product_supports_shadow_children( $product ) : bool {
if ( ! ( $product instanceof WC_Product ) ) {
return false;
}
if ( method_exists( $product, 'is_type' ) ) {
if ( $product->is_type( [ 'variable', 'variable-subscription' ] ) ) {
return true;
}
}
if ( $product instanceof WC_Product_Variable ) {
return true;
}
if ( ! method_exists( $product, 'get_children' ) ) {
return false;
}
$children = $product->get_children();
return is_array( $children ) && ! empty( $children );
}
function abtestkit_pt_duplicate_shadow_variations( int $control_product_id, int $shadow_product_id ) : void {
if ( ! function_exists( 'wc_get_product' ) ) {
return;
}
$control_product_id = (int) $control_product_id;
$shadow_product_id = (int) $shadow_product_id;
if ( $control_product_id <= 0 || $shadow_product_id <= 0 ) {
return;
}
$control_product = wc_get_product( $control_product_id );
if (
! ( $control_product instanceof WC_Product )
|| ! function_exists( 'abtestkit_pt_product_supports_shadow_children' )
|| ! abtestkit_pt_product_supports_shadow_children( $control_product )
|| ! method_exists( $control_product, 'get_children' )
) {
return;
}
$control_variation_ids = $control_product->get_children();
if ( empty( $control_variation_ids ) || ! is_array( $control_variation_ids ) ) {
return;
}
global $wpdb;
foreach ( $control_variation_ids as $control_variation_id ) {
$control_variation_id = absint( $control_variation_id );
if ( $control_variation_id <= 0 ) {
continue;
}
$variation_post = get_post( $control_variation_id );
if ( ! $variation_post || $variation_post->post_type !== 'product_variation' ) {
continue;
}
$new_variation_id = wp_insert_post( wp_slash( [
'post_title' => $variation_post->post_title,
'post_content' => $variation_post->post_content,
'post_excerpt' => $variation_post->post_excerpt,
'post_status' => $variation_post->post_status ?: 'publish',
'post_type' => 'product_variation',
'post_author' => get_current_user_id() ?: $variation_post->post_author,
'post_parent' => $shadow_product_id,
'menu_order' => (int) $variation_post->menu_order,
'post_name' => sanitize_title( $variation_post->post_name . '-variant-b-' . $shadow_product_id ),
] ), true );
if ( is_wp_error( $new_variation_id ) || ! $new_variation_id ) {
continue;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"
INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
SELECT %d, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE post_id = %d
AND meta_key NOT IN ( %s, %s, %s, %s )
",
(int) $new_variation_id,
(int) $control_variation_id,
'_edit_lock',
'_edit_last',
'_wp_old_slug',
'_sku'
)
);
update_post_meta( $new_variation_id, '_abtestkit_shadow', 1 );
update_post_meta( $new_variation_id, '_abtestkit_shadow_of', (int) $control_variation_id );
delete_post_meta( $new_variation_id, '_sku' );
}
}
if ( ! function_exists( 'abtestkit_pt_copy_meta_key_if_exists' ) ) {
function abtestkit_pt_copy_meta_key_if_exists( int $from_id, int $to_id, string $meta_key ) : void {
if ( $from_id <= 0 || $to_id <= 0 || $meta_key === '' ) {
return;
}
if ( metadata_exists( 'post', $from_id, $meta_key ) ) {
update_post_meta( $to_id, $meta_key, get_post_meta( $from_id, $meta_key, true ) );
} else {
delete_post_meta( $to_id, $meta_key );
}
}
}
if ( ! function_exists( 'abtestkit_pt_sync_shadow_stock_truth' ) ) {
function abtestkit_pt_sync_shadow_stock_truth( int $shadow_product_id ) : void {
if ( $shadow_product_id <= 0 ) {
return;
}
if ( ! function_exists( 'abtestkit_is_shadow_product' ) || ! abtestkit_is_shadow_product( $shadow_product_id ) ) {
return;
}
$control_product_id = (int) get_post_meta( $shadow_product_id, '_abtestkit_shadow_of', true );
if ( $control_product_id <= 0 ) {
return;
}
$stock_meta_keys = [
'_manage_stock',
'_stock',
'_stock_status',
'_backorders',
'_backorders_allowed',
'_backordered',
];
foreach ( $stock_meta_keys as $meta_key ) {
abtestkit_pt_copy_meta_key_if_exists( $control_product_id, $shadow_product_id, $meta_key );
}
$shadow_variation_ids = function_exists( 'abtestkit_pt_get_shadow_variation_ids_for_product' )
? abtestkit_pt_get_shadow_variation_ids_for_product( $shadow_product_id )
: [];
foreach ( $shadow_variation_ids as $shadow_variation_id ) {
$shadow_variation_id = (int) $shadow_variation_id;
if ( $shadow_variation_id <= 0 || get_post_type( $shadow_variation_id ) !== 'product_variation' ) {
continue;
}
$control_variation_id = (int) get_post_meta( $shadow_variation_id, '_abtestkit_shadow_of', true );
if ( $control_variation_id <= 0 ) {
continue;
}
foreach ( $stock_meta_keys as $meta_key ) {
abtestkit_pt_copy_meta_key_if_exists( $control_variation_id, $shadow_variation_id, $meta_key );
}
clean_post_cache( $shadow_variation_id );
}
clean_post_cache( $shadow_product_id );
clean_object_term_cache( $shadow_product_id, 'product' );
if ( function_exists( 'wc_delete_product_transients' ) ) {
wc_delete_product_transients( $shadow_product_id );
wc_delete_product_transients( $control_product_id );
}
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_posted_variation_value' ) ) {
function abtestkit_pt_shadow_posted_variation_value( string $field, int $index, $default = null ) {
if (
! isset( $_POST['woocommerce_meta_nonce'] )
|| ! wp_verify_nonce(
sanitize_text_field( wp_unslash( $_POST['woocommerce_meta_nonce'] ) ),
'woocommerce_save_data'
)
) {
return $default;
}
$field = sanitize_key( $field );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce product save nonce is verified above.
if ( '' === $field || ! isset( $_POST[ $field ] ) || ! is_array( $_POST[ $field ] ) || ! array_key_exists( $index, $_POST[ $field ] ) ) {
return $default;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WooCommerce nonce verified above; rejected if array and sanitized immediately below.
$raw = wp_unslash( $_POST[ $field ][ $index ] );
if ( is_array( $raw ) ) {
return $default;
}
return sanitize_text_field( (string) $raw );
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_save_subscription_variation_fields' ) ) {
function abtestkit_pt_shadow_save_subscription_variation_fields( int $variation_id, int $i ) : void {
if ( $variation_id <= 0 || get_post_type( $variation_id ) !== 'product_variation' ) {
return;
}
if ( ! function_exists( 'abtestkit_is_shadow_product' ) || ! abtestkit_is_shadow_product( $variation_id ) ) {
return;
}
$parent_id = (int) wp_get_post_parent_id( $variation_id );
if ( $parent_id <= 0 || ! abtestkit_is_shadow_product( $parent_id ) ) {
return;
}
$subscription_field_map = [
'variable_subscription_sign_up_fee' => '_subscription_sign_up_fee',
'variable_subscription_trial_length' => '_subscription_trial_length',
'variable_subscription_trial_period' => '_subscription_trial_period',
'variable_subscription_period_interval' => '_subscription_period_interval',
'variable_subscription_period' => '_subscription_period',
'variable_subscription_length' => '_subscription_length',
];
foreach ( $subscription_field_map as $posted_key => $meta_key ) {
$posted_value = abtestkit_pt_shadow_posted_variation_value( $posted_key, $i, null );
if ( null === $posted_value ) {
continue;
}
switch ( $meta_key ) {
case '_subscription_sign_up_fee':
$posted_value = ( '' === $posted_value ) ? '' : wc_format_decimal( $posted_value );
break;
case '_subscription_trial_period':
case '_subscription_period':
$posted_value = sanitize_key( $posted_value );
break;
default:
$posted_value = (string) absint( $posted_value );
break;
}
update_post_meta( $variation_id, $meta_key, $posted_value );
}
$regular_price = abtestkit_pt_shadow_posted_variation_value( 'variable_regular_price', $i, null );
$sale_price = abtestkit_pt_shadow_posted_variation_value( 'variable_sale_price', $i, null );
if ( null !== $regular_price ) {
if ( '' === $regular_price ) {
delete_post_meta( $variation_id, '_regular_price' );
delete_post_meta( $variation_id, '_subscription_price' );
} else {
$regular_price = wc_format_decimal( $regular_price );
update_post_meta( $variation_id, '_regular_price', $regular_price );
update_post_meta( $variation_id, '_subscription_price', $regular_price );
}
}
if ( null !== $sale_price ) {
if ( '' === $sale_price ) {
delete_post_meta( $variation_id, '_sale_price' );
} else {
$sale_price = wc_format_decimal( $sale_price );
update_post_meta( $variation_id, '_sale_price', $sale_price );
}
}
$active_price = '';
if ( null !== $sale_price && '' !== $sale_price ) {
$active_price = $sale_price;
} elseif ( null !== $regular_price && '' !== $regular_price ) {
$active_price = $regular_price;
}
if ( '' !== $active_price ) {
update_post_meta( $variation_id, '_price', wc_format_decimal( $active_price ) );
}
update_post_meta( $parent_id, '_abtestkit_rev', (string) time() );
if ( function_exists( 'abtestkit_pt_sync_shadow_stock_truth' ) ) {
abtestkit_pt_sync_shadow_stock_truth( $parent_id );
}
clean_post_cache( $variation_id );
clean_post_cache( $parent_id );
if ( function_exists( 'wc_delete_product_transients' ) ) {
wc_delete_product_transients( $parent_id );
}
}
}
add_action( 'woocommerce_save_product_variation', 'abtestkit_pt_shadow_save_subscription_variation_fields', 999, 2 );
add_action( 'save_post_product', function( $post_id, $post, $update ) {
$post_id = (int) $post_id;
if ( $post_id <= 0 ) {
return;
}
if ( function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $post_id ) && function_exists( 'abtestkit_pt_sync_shadow_stock_truth' ) ) {
abtestkit_pt_sync_shadow_stock_truth( $post_id );
}
}, 30, 3 );
function abtestkit_pt_get_matching_shadow_variation_id( int $control_variation_id, int $shadow_product_id ) : int {
$control_variation_id = (int) $control_variation_id;
$shadow_product_id = (int) $shadow_product_id;
if ( $control_variation_id <= 0 || $shadow_product_id <= 0 ) {
return 0;
}
static $cache = [];
$cache_key = $control_variation_id . ':' . $shadow_product_id;
if ( isset( $cache[ $cache_key ] ) ) {
return (int) $cache[ $cache_key ];
}
$control_signature = abtestkit_pt_variation_signature( $control_variation_id );
$shadow_ids = abtestkit_pt_get_shadow_variation_ids_for_product( $shadow_product_id );
if ( $control_signature !== '' ) {
foreach ( $shadow_ids as $shadow_variation_id ) {
if ( abtestkit_pt_variation_signature( (int) $shadow_variation_id ) === $control_signature ) {
$cache[ $cache_key ] = (int) $shadow_variation_id;
return (int) $cache[ $cache_key ];
}
}
}
$control_post = get_post( $control_variation_id );
$control_menu_order = $control_post ? (int) $control_post->menu_order : 0;
foreach ( $shadow_ids as $shadow_variation_id ) {
$shadow_post = get_post( $shadow_variation_id );
if ( $shadow_post && (int) $shadow_post->menu_order === $control_menu_order ) {
$cache[ $cache_key ] = (int) $shadow_variation_id;
return (int) $cache[ $cache_key ];
}
}
$cache[ $cache_key ] = 0;
return 0;
}
function abtestkit_pt_apply_shadow_variation_to_control( int $control_variation_id, int $shadow_variation_id ) : void {
if ( ! function_exists( 'wc_get_product' ) ) {
return;
}
$control_variation_id = (int) $control_variation_id;
$shadow_variation_id = (int) $shadow_variation_id;
if ( $control_variation_id <= 0 || $shadow_variation_id <= 0 ) {
return;
}
$control_post = get_post( $control_variation_id );
$shadow_post = get_post( $shadow_variation_id );
if (
! $control_post
|| ! $shadow_post
|| $control_post->post_type !== 'product_variation'
|| $shadow_post->post_type !== 'product_variation'
) {
return;
}
$shadow_variation = wc_get_product( $shadow_variation_id );
/*
* 1) Copy core post fields from shadow child -> live child.
*/
wp_update_post(
wp_slash( [
'ID' => $control_variation_id,
'post_title' => (string) $shadow_post->post_title,
'post_content' => (string) $shadow_post->post_content,
'post_excerpt' => (string) $shadow_post->post_excerpt,
'menu_order' => (int) $shadow_post->menu_order,
] )
);
/*
* 2) Replace most child variation meta on A with B's child meta.
* Preserve commerce identity / stock truth on the live variation.
*
* This also brings across Woo Subscriptions variation meta automatically,
* because those recurring fields are stored as normal variation post meta.
*/
$preserve_meta_keys = [
'_sku',
'_stock',
'_stock_status',
'_manage_stock',
'_backorders',
'_backorders_allowed',
'_backordered',
'total_sales',
'_edit_lock',
'_edit_last',
'_wp_old_slug',
// Never copy shadow markers onto the real variation.
'_abtestkit_shadow',
'_abtestkit_shadow_of',
'_abtestkit_variant_in_use',
'_abtestkit_variant_of',
'_abtestkit_variant_test_id',
];
$existing_meta = get_post_meta( $control_variation_id );
if ( is_array( $existing_meta ) ) {
foreach ( array_keys( $existing_meta ) as $meta_key ) {
$meta_key = (string) $meta_key;
if ( in_array( $meta_key, $preserve_meta_keys, true ) ) {
continue;
}
delete_post_meta( $control_variation_id, $meta_key );
}
}
$shadow_meta = get_post_meta( $shadow_variation_id );
if ( is_array( $shadow_meta ) ) {
foreach ( $shadow_meta as $meta_key => $meta_values ) {
$meta_key = (string) $meta_key;
if ( $meta_key === '' || in_array( $meta_key, $preserve_meta_keys, true ) ) {
continue;
}
foreach ( (array) $meta_values as $meta_value ) {
add_post_meta( $control_variation_id, $meta_key, wp_slash( $meta_value ) );
}
}
}
/*
* 3) Make sure key variation price / image fields are explicitly aligned.
*/
if ( $shadow_variation instanceof WC_Product ) {
$regular_price = $shadow_variation->get_regular_price( 'edit' );
$sale_price = $shadow_variation->get_sale_price( 'edit' );
$active_price = $shadow_variation->get_price( 'edit' );
$image_id = (int) $shadow_variation->get_image_id( 'edit' );
if ( $regular_price !== '' && $regular_price !== null ) {
update_post_meta( $control_variation_id, '_regular_price', wc_format_decimal( $regular_price ) );
}
if ( $sale_price !== '' && $sale_price !== null ) {
update_post_meta( $control_variation_id, '_sale_price', wc_format_decimal( $sale_price ) );
} else {
delete_post_meta( $control_variation_id, '_sale_price' );
}
if ( $active_price !== '' && $active_price !== null ) {
update_post_meta( $control_variation_id, '_price', wc_format_decimal( $active_price ) );
}
if ( $image_id > 0 ) {
update_post_meta( $control_variation_id, '_thumbnail_id', $image_id );
} else {
delete_post_meta( $control_variation_id, '_thumbnail_id' );
}
}
clean_post_cache( $control_variation_id );
wp_cache_delete( $control_variation_id, 'posts' );
$parent_id = (int) wp_get_post_parent_id( $control_variation_id );
if ( $parent_id > 0 ) {
clean_post_cache( $parent_id );
clean_object_term_cache( $parent_id, 'product' );
if ( function_exists( 'wc_delete_product_transients' ) ) {
wc_delete_product_transients( $parent_id );
}
}
}
function abtestkit_pt_apply_shadow_variations_to_product( int $control_product_id, int $shadow_product_id ) : void {
if ( ! function_exists( 'wc_get_product' ) || ! function_exists( 'abtestkit_pt_get_matching_shadow_variation_id' ) ) {
return;
}
$control_product_id = (int) $control_product_id;
$shadow_product_id = (int) $shadow_product_id;
if ( $control_product_id <= 0 || $shadow_product_id <= 0 ) {
return;
}
$control_product = wc_get_product( $control_product_id );
$shadow_product = wc_get_product( $shadow_product_id );
if ( ! ( $control_product instanceof WC_Product ) || ! ( $shadow_product instanceof WC_Product ) ) {
return;
}
if ( ! method_exists( $control_product, 'get_children' ) || ! method_exists( $shadow_product, 'get_children' ) ) {
return;
}
$control_variation_ids = array_values(
array_filter(
array_map( 'absint', (array) $control_product->get_children() )
)
);
if ( empty( $control_variation_ids ) ) {
return;
}
foreach ( $control_variation_ids as $control_variation_id ) {
$shadow_variation_id = abtestkit_pt_get_matching_shadow_variation_id(
(int) $control_variation_id,
(int) $shadow_product_id
);
if ( $shadow_variation_id <= 0 ) {
continue;
}
abtestkit_pt_apply_shadow_variation_to_control(
(int) $control_variation_id,
(int) $shadow_variation_id
);
}
clean_post_cache( $control_product_id );
clean_object_term_cache( $control_product_id, 'product' );
wp_cache_delete( $control_product_id, 'posts' );
if ( function_exists( 'wc_delete_product_transients' ) ) {
wc_delete_product_transients( $control_product_id );
wc_delete_product_transients( $shadow_product_id );
}
}
function abtestkit_pt_get_shadow_product_for_runtime( $product, $test ) {
$shadow = function_exists( 'abtestkit_pt_get_shadow_product_for_test' )
? abtestkit_pt_get_shadow_product_for_test( $test )
: null;
if ( ! ( $shadow instanceof WC_Product ) ) {
return null;
}
$variation_id = 0;
// Variation object: map to matching shadow variation.
if ( $product instanceof WC_Product_Variation ) {
$variation_id = (int) $product->get_id();
// Any non-variation WC_Product (simple, variable, etc): use the shadow product itself.
} elseif ( $product instanceof WC_Product ) {
return $shadow;
// WP_Post support for any theme/plugin paths that pass posts around.
} elseif ( $product instanceof WP_Post ) {
$product_id = (int) $product->ID;
if ( $product_id > 0 && get_post_type( $product_id ) === 'product_variation' ) {
$variation_id = $product_id;
} else {
return $shadow;
}
// Raw numeric IDs are still supported.
} elseif ( is_numeric( $product ) ) {
$product_id = absint( $product );
if ( $product_id > 0 && get_post_type( $product_id ) === 'product_variation' ) {
$variation_id = $product_id;
} else {
return $shadow;
}
// Unknown input type: safest fallback is the parent shadow product.
} else {
return $shadow;
}
if ( $variation_id > 0 ) {
$shadow_variation_id = abtestkit_pt_get_matching_shadow_variation_id( $variation_id, (int) $shadow->get_id() );
if ( $shadow_variation_id > 0 && function_exists( 'wc_get_product' ) ) {
$shadow_variation = wc_get_product( $shadow_variation_id );
if ( $shadow_variation instanceof WC_Product ) {
return $shadow_variation;
}
}
}
return $shadow;
}
function abtestkit_pt_get_shadow_product_for_cart_item( array $cart_item ) {
if ( ! function_exists( 'wc_get_product' ) ) {
return null;
}
if ( ! empty( $cart_item['abtestkit_shadow_variation_id'] ) ) {
$shadow_variation = wc_get_product( (int) $cart_item['abtestkit_shadow_variation_id'] );
if ( $shadow_variation instanceof WC_Product ) {
return $shadow_variation;
}
}
if ( ! empty( $cart_item['abtestkit_shadow_product_id'] ) ) {
$shadow_product = wc_get_product( (int) $cart_item['abtestkit_shadow_product_id'] );
if ( $shadow_product instanceof WC_Product ) {
return $shadow_product;
}
}
return null;
}
/**
* Universal SEO hygiene for ALL shadow variants (posts/pages/products):
* - Always noindex/nofollow
* - Canonical back to Version A (shadow_of)
* This applies even if a test is paused, draft, or deleted.
*/
function abtestkit_is_shadow_variant( int $post_id ): bool {
return (int) get_post_meta( $post_id, '_abtestkit_shadow', true ) === 1;
}
function abtestkit_shadow_block_notice_key( int $post_id, int $user_id = 0 ) : string {
$post_id = (int) $post_id;
$user_id = $user_id > 0 ? (int) $user_id : (int) get_current_user_id();
return 'abtestkit_shadow_blocked_' . $post_id . '_' . $user_id;
}
function abtestkit_mark_shadow_publish_blocked( int $post_id ) : void {
$post_id = (int) $post_id;
$user_id = (int) get_current_user_id();
if ( $post_id <= 0 || $user_id <= 0 ) {
return;
}
set_transient( abtestkit_shadow_block_notice_key( $post_id, $user_id ), 1, 2 * MINUTE_IN_SECONDS );
}
function abtestkit_consume_shadow_publish_blocked( int $post_id ) : bool {
$post_id = (int) $post_id;
$user_id = (int) get_current_user_id();
if ( $post_id <= 0 || $user_id <= 0 ) {
return false;
}
$key = abtestkit_shadow_block_notice_key( $post_id, $user_id );
$found = (bool) get_transient( $key );
if ( $found ) {
delete_transient( $key );
}
return $found;
}
function abtestkit_peek_shadow_publish_blocked( int $post_id ) : bool {
$post_id = (int) $post_id;
$user_id = (int) get_current_user_id();
if ( $post_id <= 0 || $user_id <= 0 ) {
return false;
}
return (bool) get_transient( abtestkit_shadow_block_notice_key( $post_id, $user_id ) );
}
function abtestkit_is_seo_protected_variant( int $post_id ) : bool {
return abtestkit_is_shadow_variant( $post_id ) || abtestkit_is_existing_variant_in_use( $post_id );
}
function abtestkit_shadow_of( int $post_id ): int {
return (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
}
// ──────────────────────────────────────────────────────────────
// Variant-in-use markers (for "existing page as Version B")
// ──────────────────────────────────────────────────────────────
function abtestkit_pt_mark_existing_variant_in_use( int $variant_id, int $control_id, string $test_id ) : void {
if ( $variant_id <= 0 || $control_id <= 0 || $test_id === '' ) return;
update_post_meta( $variant_id, '_abtestkit_variant_in_use', 1 );
update_post_meta( $variant_id, '_abtestkit_variant_of', (int) $control_id );
update_post_meta( $variant_id, '_abtestkit_variant_test_id', (string) $test_id );
}
function abtestkit_pt_unmark_existing_variant_in_use( int $variant_id, string $test_id = '' ) : void {
if ( $variant_id <= 0 ) return;
// If we know the test_id, only remove if it matches (safety).
if ( $test_id !== '' ) {
$cur = (string) get_post_meta( $variant_id, '_abtestkit_variant_test_id', true );
if ( $cur !== '' && $cur !== $test_id ) return;
}
delete_post_meta( $variant_id, '_abtestkit_variant_in_use' );
delete_post_meta( $variant_id, '_abtestkit_variant_of' );
delete_post_meta( $variant_id, '_abtestkit_variant_test_id' );
}
function abtestkit_is_existing_variant_in_use( int $post_id ) : bool {
return (int) get_post_meta( $post_id, '_abtestkit_variant_in_use', true ) === 1;
}
add_filter( 'wp_robots', function( $robots ) {
if ( is_admin() ) {
return $robots;
}
if ( ! is_singular() ) {
return $robots;
}
$pid = (int) get_queried_object_id();
if ( ! $pid ) {
return $robots;
}
if ( abtestkit_is_seo_protected_variant( $pid ) ) {
$robots['noindex'] = true;
$robots['nofollow'] = true;
$robots['noarchive'] = true;
$robots['nosnippet'] = true;
}
return $robots;
}, 20 );
add_filter( 'get_canonical_url', function( $canonical, $post ) {
if ( is_admin() ) {
return $canonical;
}
$pid = 0;
if ( $post instanceof WP_Post ) {
$pid = (int) $post->ID;
} elseif ( is_numeric( $post ) ) {
$pid = (int) $post;
}
if ( ! $pid ) {
return $canonical;
}
if ( ! abtestkit_is_seo_protected_variant( $pid ) ) {
return $canonical;
}
// Shadow duplicates store A in _abtestkit_shadow_of.
// Existing pages used as B store A in _abtestkit_variant_of.
$a_id = abtestkit_is_shadow_variant( $pid )
? abtestkit_shadow_of( $pid )
: (int) get_post_meta( $pid, '_abtestkit_variant_of', true );
if ( $a_id > 0 ) {
$a_url = get_permalink( $a_id );
if ( $a_url ) {
return $a_url;
}
}
return $canonical;
}, 20, 2 );
// Exclude shadow variants from WordPress core sitemaps.
add_filter( 'wp_sitemaps_posts_query_args', function( $args, $post_type ) {
if ( empty( $args['meta_query'] ) || ! is_array( $args['meta_query'] ) ) {
$args['meta_query'] = [];
}
$args['meta_query'][] = [
'relation' => 'AND',
[
'key' => '_abtestkit_shadow',
'value' => 1,
'compare' => '!=',
'type' => 'NUMERIC',
],
[
'key' => '_abtestkit_variant_in_use',
'value' => 1,
'compare' => '!=',
'type' => 'NUMERIC',
],
];
return $args;
}, 20, 2 );
/**
* FRONTEND: Exclude Version B (shadow) variants from listings (blog archives, search, sliders, etc).
* Does NOT affect singular views (direct access), so preview/edit flows still work.
*/
/**
* FRONTEND: Exclude Version B variants from listings (blog archives, search, sliders, etc).
* - Hides duplicates (shadow) via _abtestkit_shadow
* - Hides existing Version B when SEO-safe is ON via _abtestkit_variant_in_use
*
* Applies to ALL frontend queries (not just main query) so Elementor widgets/sliders/custom WP_Query loops
* also exclude Version B content.
*/
add_action( 'pre_get_posts', function( $q ) {
if ( is_admin() ) return;
if ( $q->is_singular() ) return;
$pt = $q->get( 'post_type' );
// Affect default blog queries, 'any', and any query that includes post/page (and optionally product).
$affects = false;
if ( empty( $pt ) || $pt === 'any' ) {
$affects = true;
} elseif ( is_string( $pt ) && in_array( $pt, [ 'post', 'page', 'product' ], true ) ) {
$affects = true;
} elseif ( is_array( $pt ) && array_intersect( $pt, [ 'post', 'page', 'product' ] ) ) {
$affects = true;
}
if ( ! $affects ) return;
$meta_query = (array) $q->get( 'meta_query' );
// Remove any existing clauses we might add (defensive).
$meta_query = array_values( array_filter( $meta_query, function( $clause ) {
if ( ! is_array( $clause ) ) return true;
if ( isset( $clause['key'] ) && in_array( $clause['key'], [ '_abtestkit_shadow', '_abtestkit_variant_in_use' ], true ) ) {
return false;
}
return true;
} ) );
// Exclude duplicate B (shadow)
$meta_query[] = [
'key' => '_abtestkit_shadow',
'compare' => 'NOT EXISTS',
];
// Exclude existing B when SEO-safe is ON
$meta_query[] = [
'key' => '_abtestkit_variant_in_use',
'compare' => 'NOT EXISTS',
];
$q->set( 'meta_query', $meta_query );
}, 20 );
/**
* If someone tries to publish a shadow variant (product/post/page), force it back to draft.
* Also append a query flag so we can show a “blocked” notice after redirect.
*/
/**
* If someone tries to publish a shadow PRODUCT, force it back to draft.
* (We do NOT block publishing for shadow pages/posts.)
*/
add_filter( 'wp_insert_post_data', function( $data, $postarr ) {
if ( empty( $postarr['ID'] ) ) return $data;
$post_id = (int) $postarr['ID'];
// Only apply to duplicate Version B shadows.
if ( ! abtestkit_is_shadow_variant( $post_id ) ) {
return $data;
}
$attempting_publish = in_array( $data['post_status'], [ 'publish', 'future', 'private' ], true );
if ( $attempting_publish ) {
$data['post_status'] = 'draft';
$GLOBALS['abtestkit_shadow_publish_blocked'] = 1;
abtestkit_mark_shadow_publish_blocked( $post_id );
}
return $data;
}, 99, 2 );
add_filter( 'redirect_post_location', function( $location, $post_id ) {
if ( ! empty( $GLOBALS['abtestkit_shadow_publish_blocked'] ) && abtestkit_is_shadow_variant( (int) $post_id ) ) {
$location = add_query_arg( [ 'abtestkit_shadow_blocked' => 1 ], $location );
}
return $location;
}, 99, 2 );
/**
* Fix the wp-admin "Mine" view count so it matches our list-table exclusion logic.
* Core computes "Mine" separately, so the count can include Version B items even if hidden.
*/
function abtestkit_fix_mine_view_count( array $views, string $post_type ) : array {
if ( empty( $views['mine'] ) ) {
return $views;
}
$user_id = get_current_user_id();
if ( ! $user_id ) {
return $views;
}
// Count ONLY items authored by current user that are NOT Version B and NOT trash/auto-draft.
// (Matches the intent of core "Mine" without pulling in our hidden variants.)
$cache_key = 'mine_count_' . $post_type . '_' . (int) $user_id;
$mine_count = wp_cache_get( $cache_key, 'abtestkit' );
if ( false === $mine_count ) {
$q = new WP_Query(
array(
'post_type' => $post_type,
'author' => (int) $user_id,
// "Mine" should include anything except trash/auto-draft.
// WP_Query doesn't support post_status NOT IN directly, so we whitelist the common valid statuses.
'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ),
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => false, // we need found_posts
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_abtestkit_shadow',
'compare' => 'NOT EXISTS',
),
array(
'key' => '_abtestkit_variant_in_use',
'compare' => 'NOT EXISTS',
),
),
)
);
$mine_count = (int) $q->found_posts;
// Cache briefly to satisfy WPCS without risking stale counts for long.
wp_cache_set( $cache_key, $mine_count, 'abtestkit', 60 );
}
// Core only shows "Mine" if there's something there.
if ( $mine_count <= 0 ) {
unset( $views['mine'] );
return $views;
}
$formatted = number_format_i18n( $mine_count );
// Replace the "(n)" count inside the existing Mine link HTML.
$views['mine'] = preg_replace(
'/\\([^)]*\\)<\\/span>/',
'(' . $formatted . ')',
(string) $views['mine']
);
return $views;
}
add_filter( 'views_edit-product', function( $views ) {
if ( ! current_user_can( 'edit_products' ) && ! current_user_can( 'edit_posts' ) ) {
return $views;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || $screen->id !== 'edit-product' ) {
return $views;
}
$count_q = new WP_Query( [
'post_type' => 'product',
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_query' => [
[
'relation' => 'OR',
[ 'key' => '_abtestkit_shadow', 'compare' => 'EXISTS' ],
[ 'key' => '_abtestkit_variant_in_use', 'compare' => 'EXISTS' ],
],
],
] );
$count = (int) $count_q->found_posts;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$shadow_flag = isset( $_GET['abtestkit_shadow'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_shadow'] ) ) : '';
$is_shadow_view = ( $shadow_flag === '1' );
$url = add_query_arg(
[ 'post_type' => 'product', 'abtestkit_shadow' => 1 ],
admin_url( 'edit.php' )
);
$class = $is_shadow_view ? ' class="current"' : '';
$label = sprintf(
/* translators: %s: Number of Version B products. */
__( 'Version B Products (%s)', 'abtestkit' ),
number_format_i18n( $count )
);
$shadow_link = sprintf( '%s', esc_url( $url ), $class, $label );
if ( isset( $views['all'] ) ) {
$out = [ 'all' => $views['all'], 'abtestkit_shadow' => $shadow_link ];
foreach ( $views as $k => $v ) {
if ( $k === 'all' ) {
continue;
}
$out[ $k ] = $v;
}
$views = $out;
} else {
$views['abtestkit_shadow'] = $shadow_link;
}
$views = abtestkit_fix_mine_view_count( $views, 'product' );
return $views;
} );
/**
* Add "Version B" view tabs in wp-admin list tables (Products / Posts / Pages).
*/
add_filter( 'views_edit-post', function( $views ) {
if ( ! current_user_can( 'edit_posts' ) ) {
return $views;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || $screen->id !== 'edit-post' ) {
return $views;
}
$count_q = new WP_Query( [
'post_type' => 'post',
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_query' => [
[ 'key' => '_abtestkit_shadow', 'compare' => 'EXISTS' ],
],
] );
$count = (int) $count_q->found_posts;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$shadow_flag = isset( $_GET['abtestkit_shadow'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_shadow'] ) ) : '';
$is_shadow_view = ( $shadow_flag === '1' );
$url = add_query_arg(
[ 'post_type' => 'post', 'abtestkit_shadow' => 1 ],
admin_url( 'edit.php' )
);
$class = $is_shadow_view ? ' class="current"' : '';
$label = sprintf(
/* translators: %s: Number of Version B posts. */
__( 'Version B Posts (%s)', 'abtestkit' ),
number_format_i18n( $count )
);
$shadow_link = sprintf( '%s', esc_url( $url ), $class, $label );
if ( isset( $views['all'] ) ) {
$out = [ 'all' => $views['all'], 'abtestkit_shadow' => $shadow_link ];
foreach ( $views as $k => $v ) { if ( $k === 'all' ) continue; $out[ $k ] = $v; }
$views = $out; // don't return yet — we also need to fix "Mine"
} else {
$views['abtestkit_shadow'] = $shadow_link;
}
$views = abtestkit_fix_mine_view_count( $views, 'post' );
return $views;
} );
add_filter( 'views_edit-page', function( $views ) {
if ( ! current_user_can( 'edit_pages' ) ) {
return $views;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || $screen->id !== 'edit-page' ) {
return $views;
}
$count_q = new WP_Query( [
'post_type' => 'page',
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_query' => [
[ 'key' => '_abtestkit_shadow', 'compare' => 'EXISTS' ],
],
] );
$count = (int) $count_q->found_posts;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$shadow_flag = isset( $_GET['abtestkit_shadow'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_shadow'] ) ) : '';
$is_shadow_view = ( $shadow_flag === '1' );
$url = add_query_arg(
[ 'post_type' => 'page', 'abtestkit_shadow' => 1 ],
admin_url( 'edit.php' )
);
$class = $is_shadow_view ? ' class="current"' : '';
$label = sprintf(
/* translators: %s: Number of Version B pages. */
__( 'Version B Pages (%s)', 'abtestkit' ),
number_format_i18n( $count )
);
$shadow_link = sprintf( '%s', esc_url( $url ), $class, $label );
if ( isset( $views['all'] ) ) {
$out = [ 'all' => $views['all'], 'abtestkit_shadow' => $shadow_link ];
foreach ( $views as $k => $v ) {
if ( $k === 'all' ) continue;
$out[ $k ] = $v;
}
$views = $out; // <-- IMPORTANT: do not return yet, we still need to fix "Mine"
} else {
$views['abtestkit_shadow'] = $shadow_link;
}
$views = abtestkit_fix_mine_view_count( $views, 'page' );
return $views;
} );
/**
* Hide Version B variants from wp-admin list tables by default,
* but show them when the "Version B" view is active.
*
* Applies to Products, Posts, and Pages.
*
* - Duplicates use: _abtestkit_shadow
* - Existing B (SEO-safe) uses: _abtestkit_variant_in_use
*
* This also ensures they never show up under the "Mine" view.
*/
add_action( 'pre_get_posts', function( $q ) {
if ( ! is_admin() ) return;
if ( ! $q->is_main_query() ) return;
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || ! in_array( $screen->id, [ 'edit-product', 'edit-post', 'edit-page' ], true ) ) return;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$shadow_flag = isset( $_GET['abtestkit_shadow'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_shadow'] ) ) : '';
$is_shadow_view = ( $shadow_flag === '1' );
// Trash view: keep variants visible so they can be deleted/recovered.
$requested_status = $q->get( 'post_status' );
if ( empty( $requested_status ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$requested_status = isset( $_GET['post_status'] ) ? sanitize_text_field( wp_unslash( $_GET['post_status'] ) ) : '';
}
$is_trash_view = (string) $requested_status === 'trash';
$meta_query = (array) $q->get( 'meta_query' );
// Remove any existing clauses we might add (defensive).
$meta_query = array_values( array_filter( $meta_query, function( $clause ) {
if ( ! is_array( $clause ) ) return true;
if ( isset( $clause['key'] ) && in_array( $clause['key'], [ '_abtestkit_shadow', '_abtestkit_variant_in_use' ], true ) ) {
return false;
}
return true;
} ) );
if ( $is_shadow_view ) {
// Show ALL Version B items (duplicates + existing B in use).
$meta_query[] = [
'relation' => 'OR',
[ 'key' => '_abtestkit_shadow', 'compare' => 'EXISTS' ],
[ 'key' => '_abtestkit_variant_in_use', 'compare' => 'EXISTS' ],
];
$q->set( 'meta_query', $meta_query );
return;
}
if ( ! $is_trash_view ) {
// Hide ALL Version B items from normal tabs (All, Published, Draft, Mine, etc).
$meta_query[] = [ 'key' => '_abtestkit_shadow', 'compare' => 'NOT EXISTS' ];
$meta_query[] = [ 'key' => '_abtestkit_variant_in_use', 'compare' => 'NOT EXISTS' ];
$q->set( 'meta_query', $meta_query );
}
}, 20 );
// Bust admin shadow-count cache when products/posts/pages change.
add_action( 'save_post', function( $post_id, $post, $update ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( empty( $post ) ) {
return;
}
if ( in_array( $post->post_type, [ 'product', 'post', 'page' ], true ) ) {
abtestkit_clear_shadow_counts_cache( $post->post_type );
}
}, 10, 3 );
add_action( 'trashed_post', function( $post_id ) {
$pt = get_post_type( $post_id );
if ( in_array( $pt, [ 'product', 'post', 'page' ], true ) ) {
abtestkit_clear_shadow_counts_cache( $pt );
}
}, 10, 1 );
add_action( 'untrashed_post', function( $post_id ) {
$pt = get_post_type( $post_id );
if ( in_array( $pt, [ 'product', 'post', 'page' ], true ) ) {
abtestkit_clear_shadow_counts_cache( $pt );
}
}, 10, 1 );
add_action( 'deleted_post', function( $post_id ) {
$pt = get_post_type( $post_id );
if ( in_array( $pt, [ 'product', 'post', 'page' ], true ) ) {
abtestkit_clear_shadow_counts_cache( $pt );
}
}, 10, 1 );
// Optional: catches status transitions that don't go through save_post the way you expect.
add_action( 'transition_post_status', function( $new_status, $old_status, $post ) {
if ( empty( $post ) ) return;
if ( $new_status === $old_status ) return;
if ( in_array( $post->post_type, [ 'product', 'post', 'page' ], true ) ) {
abtestkit_clear_shadow_counts_cache( $post->post_type );
}
}, 10, 3 );
add_filter( 'wp_count_posts', function( $counts, $type, $perm ) {
if ( ! in_array( $type, [ 'product', 'post', 'page' ], true ) ) {
return $counts;
}
if ( ! is_admin() ) {
return $counts;
}
global $wpdb;
$cache_group = 'abtestkit';
$cache_key = 'shadow_counts_by_status_' . $type . '_' . (int) get_current_blog_id();
$rows = wp_cache_get( $cache_key, $cache_group );
if ( false === $rows ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"
SELECT p.post_status, COUNT(*) AS c
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm
ON pm.post_id = p.ID
AND pm.meta_key = %s
WHERE p.post_type = %s
GROUP BY p.post_status
",
'_abtestkit_shadow',
$type
),
ARRAY_A
);
wp_cache_set( $cache_key, $rows, $cache_group, 60 );
}
if ( empty( $rows ) ) {
return $counts;
}
foreach ( $rows as $r ) {
$status = (string) $r['post_status'];
$c = (int) $r['c'];
if ( $status === 'trash' ) {
continue;
}
if ( isset( $counts->$status ) ) {
$counts->$status = max( 0, (int) $counts->$status - $c );
}
}
if ( isset( $counts->total ) ) {
$sum = 0;
foreach ( get_object_vars( $counts ) as $k => $v ) {
if ( $k === 'total' ) continue;
$sum += (int) $v;
}
$counts->total = $sum;
}
return $counts;
}, 10, 3 );
/**
* Big banner on version b edit screens.
*/
add_action( 'admin_notices', function() {
if ( ! is_admin() ) return;
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || $screen->base !== 'post' ) return;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
if ( ! $post_id ) return;
$post = get_post( $post_id );
if ( ! $post ) return;
$post_type = (string) $post->post_type;
// ───────────────────────────────────────────────────────────
// 1) Existing banner: Shadow Product helper (keep behaviour)
// ───────────────────────────────────────────────────────────
if ( $post_type === 'product' && abtestkit_is_shadow_product( $post_id ) ) {
$shadow_of = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
echo '';
echo '
abtestkit: This is your Version B product for this A/B test.
';
echo '
It is only used to build and preview the Version B experience. It will not go live as its own product.
';
echo '
This product cannot be published. If you try to publish it, abtestkit will keep it saved as a draft.
';
if ( $shadow_of ) {
echo '
Linked Version A product ID: ' . esc_html( (string) $shadow_of ) . '
';
}
echo '
When you are done, save as draft and close this tab to return to your test.
';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET['abtestkit_shadow_blocked'] ) ) {
echo '
Publish blocked: Shadow products are never allowed to go live.
';
}
echo '
';
return;
}
// ───────────────────────────────────────────────────────────
// 2) New banner: Version B helper for Posts + Pages
// - duplicates: _abtestkit_shadow (+ optional _abtestkit_shadow_of)
// - existing B: _abtestkit_variant_in_use
// ───────────────────────────────────────────────────────────
if ( $post_type !== 'post' && $post_type !== 'page' ) {
return;
}
$is_shadow = (bool) get_post_meta( $post_id, '_abtestkit_shadow', true );
$is_existingB = (bool) get_post_meta( $post_id, '_abtestkit_variant_in_use', true );
if ( ! $is_shadow && ! $is_existingB ) {
return;
}
$shadow_of = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
$type_label = ( $post_type === 'page' ) ? 'Page' : 'Post';
$type_label_lc = strtolower( $type_label );
$show_blocked_notice = $is_shadow && abtestkit_consume_shadow_publish_blocked( $post_id );
echo '';
if ( $is_shadow ) {
echo '
abtestkit: This is your Version B ' . esc_html( $type_label_lc ) . ' for this A/B test.
';
echo '
It is only used to build and preview the Version B experience. It will not go live as its own ' . esc_html( $type_label_lc ) . '.
';
echo '
This ' . esc_html( $type_label_lc ) . ' cannot be published. If you try to publish it, abtestkit will keep it saved as a draft.
';
if ( $shadow_of ) {
echo '
Linked Version A ' . esc_html( $type_label ) . ' ID: ' . esc_html( (string) $shadow_of ) . '
';
}
echo '
When you are done, save as draft and close this tab to return to your test.
';
if ( $show_blocked_notice ) {
echo '
Publish blocked: Shadow ' . esc_html( $type_label_lc ) . 's are never allowed to go live.
';
}
} else {
echo '
abtestkit: You are editing a Version B ' . esc_html( $type_label ) . ' (used for A/B testing).
';
echo '
Make your changes, publish/update, then close this tab to continue creating your test.
';
if ( $shadow_of ) {
echo '
Linked Version A ' . esc_html( $type_label ) . ' ID: ' . esc_html( (string) $shadow_of ) . '
';
}
}
echo '
';
} );
// ── Admin Dashboard UI ───────────────────────────────────────────────────────
add_action( 'admin_menu', function () {
// Top-level "abtestkit" menu (icon)
add_menu_page(
__( 'abtestkit', 'abtestkit' ),
__( 'abtestkit', 'abtestkit' ),
'manage_options',
'abtestkit-dashboard',
'abtestkit_render_page_tests_dashboard',
plugins_url( 'assets/img/menu-icon.png', __FILE__ ),
59
);
// Explicit "Dashboard" submenu under the icon
add_submenu_page(
'abtestkit-dashboard',
__( 'Dashboard', 'abtestkit' ),
__( 'Dashboard', 'abtestkit' ),
'manage_options',
'abtestkit-dashboard',
'abtestkit_render_page_tests_dashboard'
);
}, 9 );
/**
* Show A/B test membership for pages, posts and products in the list tables.
*/
function abtestkit_register_ab_columns( $columns ) {
$columns['abtestkit_variant'] = __( 'A/B Version', 'abtestkit' );
$columns['abtestkit_test'] = __( 'A/B Test', 'abtestkit' );
return $columns;
}
add_filter( 'manage_page_posts_columns', 'abtestkit_register_ab_columns' );
add_filter( 'manage_post_posts_columns', 'abtestkit_register_ab_columns' );
// WooCommerce products get a single "Test Status" column (no A/B Version column).
add_filter( 'manage_product_posts_columns', 'abtestkit_register_product_test_status_column' );
/**
* Add a single "Test Status" column to WooCommerce products list table.
* (Products don't have a physical Version B post, so A/B Version isn't shown here.)
*/
function abtestkit_register_product_test_status_column( $columns ) {
// Insert right after the product name column when possible.
$new = [];
foreach ( $columns as $key => $label ) {
$new[ $key ] = $label;
if ( $key === 'name' ) {
$new['abtestkit_test_status'] = __( 'Test Status', 'abtestkit' );
}
}
if ( ! isset( $new['abtestkit_test_status'] ) ) {
$new['abtestkit_test_status'] = __( 'Test Status', 'abtestkit' );
}
return $new;
}
/**
* Render the "Test Status" column for WooCommerce products.
*/
function abtestkit_render_product_test_status_column( $column, $post_id ) {
if ( $column !== 'abtestkit_test_status' ) {
return;
}
// If this row is a shadow product, resolve its control product ID.
$is_shadow = (bool) get_post_meta( $post_id, '_abtestkit_shadow', true );
$shadow_of = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
$control_id = ( $is_shadow && $shadow_of > 0 ) ? $shadow_of : (int) $post_id;
$matches = [];
foreach ( abtestkit_pt_all() as $t ) {
if ( empty( $t['id'] ) ) {
continue;
}
// Only product tests.
if ( ( $t['kind'] ?? '' ) !== 'product' ) {
continue;
}
// Product tests are tied to the control product (variant_id is 0).
if ( (int) ( $t['control_id'] ?? 0 ) !== (int) $control_id ) {
continue;
}
$matches[] = $t;
}
if ( empty( $matches ) ) {
echo '—';
return;
}
// Prefer running > paused > draft > complete (best-effort).
$rank = [ 'running' => 1, 'paused' => 2, 'draft' => 3, 'complete' => 4 ];
usort( $matches, function( $a, $b ) use ( $rank ) {
$sa = (string) ( $a['status'] ?? 'paused' );
$sb = (string) ( $b['status'] ?? 'paused' );
$ra = $rank[ $sa ] ?? 99;
$rb = $rank[ $sb ] ?? 99;
return $ra <=> $rb;
} );
$t = $matches[0];
$status = (string) ( $t['status'] ?? 'paused' );
$title = (string) ( $t['title'] ?? $t['id'] ?? '' );
$test_id = (string) ( $t['id'] ?? '' );
switch ( $status ) {
case 'running':
$status_label = __( 'Running', 'abtestkit' );
break;
case 'complete':
$status_label = __( 'Complete', 'abtestkit' );
break;
case 'draft':
$status_label = __( 'Draft', 'abtestkit' );
break;
case 'paused':
default:
$status_label = __( 'Paused', 'abtestkit' );
break;
}
$url = add_query_arg(
[
'page' => 'abtestkit-dashboard',
'tab' => 'products',
'highlight' => $test_id,
],
admin_url( 'admin.php' )
);
printf(
'%s',
esc_url( $url ),
esc_attr( $title ),
esc_html( $status_label )
);
}
/**
* Render A/B test info in admin list columns.
*/
function abtestkit_render_ab_columns( $column, $post_id ) {
if ( $column !== 'abtestkit_variant' && $column !== 'abtestkit_test' ) {
return;
}
$variant_label = '—';
$test_title = '—';
$test_status = '';
$test_id = '';
$post_type = get_post_type( $post_id );
foreach ( abtestkit_pt_all() as $t ) {
if ( empty( $t['id'] ) ) {
continue;
}
$control_id = isset( $t['control_id'] ) ? (int) $t['control_id'] : 0;
$variant_id = isset( $t['variant_id'] ) ? (int) $t['variant_id'] : 0;
if ( $control_id === (int) $post_id ) {
$variant_label = __( 'Version A', 'abtestkit' );
$test_title = $t['title'] ?? $t['id'];
$test_status = $t['status'] ?? 'paused';
$test_id = (string) ( $t['id'] ?? '' );
break;
}
if ( $variant_id === (int) $post_id ) {
$variant_label = __( 'Version B', 'abtestkit' );
$test_title = $t['title'] ?? $t['id'];
$test_status = $t['status'] ?? 'paused';
$test_id = (string) ( $t['id'] ?? '' );
break;
}
}
if ( $column === 'abtestkit_variant' ) {
echo esc_html( $variant_label );
return;
}
// A/B Test column
if ( $test_title === '—' ) {
echo '—';
return;
}
// Human label for status (stored values: paused|running|complete|draft)
switch ( (string) $test_status ) {
case 'running':
$status_label = __( 'Running', 'abtestkit' );
break;
case 'complete':
$status_label = __( 'Complete', 'abtestkit' );
break;
case 'draft':
$status_label = __( 'Draft', 'abtestkit' );
break;
case 'paused':
default:
$status_label = __( 'Paused', 'abtestkit' );
break;
}
$linked_text = sprintf(
'%s (%s)',
(string) $test_title,
(string) $status_label
);
// “Relevant tab” (best-effort): pages/posts/products.
// Even if your React dashboard ignores this, it won’t break anything.
$tab = 'pages';
if ( $post_type === 'post' ) {
$tab = 'posts';
} elseif ( $post_type === 'product' ) {
$tab = 'products';
}
$url = add_query_arg(
[
'page' => 'abtestkit-dashboard',
'tab' => $tab,
'highlight' => $test_id,
],
admin_url( 'admin.php' )
);
printf(
'%s',
esc_url( $url ),
esc_html( $linked_text )
);
}
add_action( 'manage_page_posts_custom_column', 'abtestkit_render_ab_columns', 10, 2 );
add_action( 'manage_post_posts_custom_column', 'abtestkit_render_ab_columns', 10, 2 );
add_action( 'manage_product_posts_custom_column', 'abtestkit_render_product_test_status_column', 10, 2 );
/**
* Small CSS tweak so the columns don’t squash.
*/
add_action( 'admin_head-edit.php', function () {
?>
'abtestkit-dashboard', 'error' => 'connected_account_required' ], admin_url( 'admin.php' ) ) );
exit;
}
$src = absint( $_POST['source_page'] ?? 0 );
if ( $src ) {
$post_type = get_post_type( $src );
if ( $post_type === 'product' ) {
// Product test: no duplicate product; we'll use overrides on the control product.
$b_id = 0;
} else {
$b_id = abtestkit_duplicate_post_deep( $src );
}
if ( $post_type === 'product' || $b_id ) {
$test = [
'id' => 'pt-' . substr( md5( $src . '|' . microtime( true ) ), 0, 8 ),
'title' => get_the_title( $src ),
'control_id' => $src,
'variant_id' => ( $post_type === 'product' ) ? 0 : $b_id,
'status' => 'paused',
'split' => 50,
'cookie_ttl_days' => 30,
'started_at' => 0,
'finished_at' => 0,
'paused_at' => 0,
'paused_total' => 0,
'kind' => ( $post_type === 'product' ) ? 'product' : 'page',
];
abtestkit_pt_put( $test );
if ( function_exists( 'abtestkit_review_prompt_note_test_created' ) ) {
abtestkit_review_prompt_note_test_created();
}
// Once the user has created a test, never show onboarding again.
abtestkit_connected_onboarding_complete_after_test_created( (string) $test['id'] );
update_option( 'abtestkit_onboarding_done', '1' );
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'made' => '1' ], admin_url( 'admin.php' ) ) );
exit;
}
}
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'create_failed' ], admin_url( 'admin.php' ) ) );
exit;
}
if ( ! $id ) {
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'no_id' ], admin_url( 'admin.php' ) ) );
exit;
}
$test = abtestkit_pt_get( $id );
if ( ! $test ) {
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'not_found' ], admin_url( 'admin.php' ) ) );
exit;
}
switch ( $do ) {
case 'edit':
if ( ! abtestkit_connected_platform_can_create_or_launch( true ) ) {
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'connected_account_required' ], admin_url( 'admin.php' ) ) );
exit;
}
$setup = abtestkit_pt_edit_setup( $test );
if ( is_wp_error( $setup ) ) {
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => $setup->get_error_code() ], admin_url( 'admin.php' ) ) );
exit;
}
if ( empty( $test['edit_in_progress'] ) ) {
// Stop live delivery before deleting old-run events.
$test['status'] = 'paused';
$test['paused_at'] = time();
abtestkit_pt_put( $test );
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Reset the one confirmed local run before opening its editor.
$deleted = $wpdb->delete( ABTESTKIT_EVENTS_TABLE, [ 'ab_test_id' => (string) $test['id'] ], [ '%s' ] );
if ( false === $deleted ) {
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'edit_reset_failed' ], admin_url( 'admin.php' ) ) );
exit;
}
unset(
$test['winner'], $test['winner_basis'], $test['winner_applied_at'],
$test['final_stats'], $test['auto_paused_broken'],
$test['auto_paused_broken_at'], $test['auto_paused_broken_summary'],
$test['auto_paused_broken_checks'], $test['health_acknowledgements']
);
$test['started_at'] = 0;
$test['finished_at'] = 0;
$test['paused_at'] = 0;
$test['paused_total'] = 0;
$test['edit_in_progress'] = 1;
if ( function_exists( 'abtestkit_html_runtime_health_delete' ) ) {
abtestkit_html_runtime_health_delete( (string) $test['id'] );
}
if ( function_exists( 'abtestkit_custom_javascript_health_delete' ) ) {
abtestkit_custom_javascript_health_delete( (string) $test['id'] );
}
abtestkit_pt_put( $test );
}
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-pt-wizard', 'edit_test' => (string) $test['id'] ], admin_url( 'admin.php' ) ) );
exit;
case 'start':
if ( ! empty( $test['edit_in_progress'] ) ) {
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'edit_in_progress' ], admin_url( 'admin.php' ) ) );
exit;
}
if ( ! abtestkit_connected_platform_can_create_or_launch( true ) ) {
$redirect = $return_url
? add_query_arg( 'abtestkit_error', 'connected_account_required', $return_url )
: add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'connected_account_required' ], admin_url( 'admin.php' ) );
wp_safe_redirect( $redirect );
exit;
}
if ( in_array( (string) ( $test['status'] ?? '' ), [ 'winner', 'complete' ], true ) ) {
$redirect = $return_url
? add_query_arg( 'abtestkit_error', 'finished_test_cannot_resume', $return_url )
: add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'finished_test_cannot_resume' ], admin_url( 'admin.php' ) );
wp_safe_redirect( $redirect );
exit;
}
$confirm_broken_resume = isset( $_POST['confirm_broken_resume'] )
&& '1' === sanitize_text_field( wp_unslash( $_POST['confirm_broken_resume'] ) );
if ( ! empty( $test['auto_paused_broken'] ) && ! $confirm_broken_resume ) {
$redirect = $return_url
? add_query_arg( 'abtestkit_error', 'broken_resume_requires_confirmation', $return_url )
: add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'broken_resume_requires_confirmation' ], admin_url( 'admin.php' ) );
wp_safe_redirect( $redirect );
exit;
}
// Block starting if either A or B page is already used by another running test
$conflicts = abtestkit_pt_conflicts_for_pages( (int) $test['control_id'], (int) $test['variant_id'], (string) $test['id'] );
if ( ! empty( $conflicts ) ) {
// Bounce back with an error flag; UI can show a notice
wp_safe_redirect( add_query_arg(
[ 'page' => 'abtestkit-dashboard', 'error' => 'conflict_running', 'conflicts' => implode(',', $conflicts) ],
admin_url( 'admin.php' )
) );
exit;
}
// Preserve the original started_at across pause/resume.
// We track paused_total for runtime calculations, but we must NOT move started_at,
// otherwise the performance timeline loses earlier history after resume.
$prev_status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
if ( $prev_status === 'paused' ) {
$paused_at = isset( $test['paused_at'] ) ? (int) $test['paused_at'] : 0;
if ( $paused_at > 0 ) {
$delta = max( 0, time() - $paused_at );
$test['paused_total'] = (int) ( $test['paused_total'] ?? 0 ) + $delta;
$test['paused_at'] = 0;
}
}
if ( (int) ( $test['started_at'] ?? 0 ) <= 0 || $prev_status === 'draft' ) {
$test['started_at'] = time();
}
$test['status'] = 'running';
if ( $confirm_broken_resume ) {
unset(
$test['auto_paused_broken'],
$test['auto_paused_broken_at'],
$test['auto_paused_broken_summary'],
$test['auto_paused_broken_checks']
);
if (
( $test['kind'] ?? '' ) === 'custom_html'
&& function_exists( 'abtestkit_html_runtime_health_delete' )
) {
abtestkit_html_runtime_health_delete( (string) $test['id'] );
}
}
abtestkit_pt_put( $test );
if ( $return_url ) {
wp_safe_redirect( $return_url );
exit;
}
break;
case 'pause':
// Record when the pause started so we can exclude it from runtime on resume.
if ( ( $test['status'] ?? 'paused' ) === 'running' ) {
$test['paused_at'] = time();
if ( ! isset( $test['paused_total'] ) ) {
$test['paused_total'] = 0;
}
}
$test['status'] = 'paused';
abtestkit_pt_put( $test );
if ( $return_url ) {
wp_safe_redirect( $return_url );
exit;
}
break;
case 'delete':
$variant_id = (int) ( $test['variant_id'] ?? 0 );
$control_id = (int) ( $test['control_id'] ?? 0 );
/*
* Normal in-plugin delete respects the UI choice.
*
* Dashboard/performance send trash_b=1 when their single delete
* confirmation explains that an abtestkit-owned Version B is included.
*
* Full automatic cleanup of ABTestKit-owned shadows is reserved for uninstall.
*/
$trash_b_raw = isset( $_POST['trash_b'] )
? sanitize_text_field( wp_unslash( $_POST['trash_b'] ) )
: '0';
$trash_b = ( '1' === $trash_b_raw );
if ( $trash_b && $variant_id > 0 && get_post_status( $variant_id ) ) {
$is_shadow_b = ( (int) get_post_meta( $variant_id, '_abtestkit_shadow', true ) === 1 );
$shadow_of = (int) get_post_meta( $variant_id, '_abtestkit_shadow_of', true );
/*
* Safety:
* - Delete only ABTestKit-created shadow Version B objects.
* - Never delete existing user-owned content, even if trash_b=1.
*/
if ( $is_shadow_b && $control_id > 0 && $shadow_of === $control_id ) {
if ( function_exists( 'abtestkit_delete_owned_shadow_variant' ) ) {
abtestkit_delete_owned_shadow_variant( $variant_id );
} else {
wp_trash_post( $variant_id );
}
}
}
abtestkit_pt_delete( (string) $test['id'] );
break;
case 'reset':
global $wpdb;
$table = defined( 'ABTESTKIT_EVENTS_TABLE' ) ? ABTESTKIT_EVENTS_TABLE : ( $wpdb->prefix . 'abtestkit_events' );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$table}
WHERE ab_test_id = %s",
(string) $test['id']
)
);
// phpcs:enable
break;
case 'apply_b_winner':
// Two flavours:
// - 'page' tests: copy the B page's content/meta/taxonomies onto A, then mark test complete.
// - 'product' tests: commit overrides from the virtual B onto the real product, then mark test complete.
$kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
$winner_basis = isset( $_POST['winner_basis'] )
? sanitize_key( wp_unslash( (string) $_POST['winner_basis'] ) )
: '';
$winner_basis = abtestkit_pt_prepare_winner_application( $test, 'B', $winner_basis );
if ( is_wp_error( $winner_basis ) ) {
$redirect = $return_url
? add_query_arg( 'abtestkit_error', $winner_basis->get_error_code(), $return_url )
: add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => $winner_basis->get_error_code() ], admin_url( 'admin.php' ) );
wp_safe_redirect( $redirect );
exit;
}
// Snapshot now (before we overwrite A / commit overrides / trash B).
abtestkit_pt_snapshot_completed_test( $test );
if ( in_array( $kind, [ 'custom_css', 'custom_html', 'custom_javascript' ], true ) ) {
$test['status'] = 'complete';
$test['winner'] = 'B';
$test['finished_at'] = time();
abtestkit_pt_put( $test );
break;
}
if ( $kind === 'product' ) {
global $wpdb;
$product_id = (int) $test['control_id'];
$shadow_id = (int) $test['variant_id'];
if (
$product_id > 0
&& $shadow_id > 0
&& function_exists( 'wc_get_product' )
&& get_post_type( $product_id ) === 'product'
&& get_post_type( $shadow_id ) === 'product'
&& function_exists( 'abtestkit_is_shadow_product' )
&& abtestkit_is_shadow_product( $shadow_id )
&& (int) get_post_meta( $shadow_id, '_abtestkit_shadow_of', true ) === $product_id
) {
$shadow_post = get_post( $shadow_id );
$control_post = get_post( $product_id );
$shadow_product = wc_get_product( $shadow_id );
if ( $shadow_post && $control_post && ( $shadow_product instanceof WC_Product ) ) {
/*
* 1) Copy core post fields from B -> A
* Keep A's ID, slug, status and authoring identity stable.
*/
wp_update_post(
wp_slash( [
'ID' => $product_id,
'post_title' => (string) $shadow_post->post_title,
'post_content' => (string) $shadow_post->post_content,
'post_excerpt' => (string) $shadow_post->post_excerpt,
'menu_order' => (int) $shadow_post->menu_order,
] )
);
/*
* 2) Copy taxonomies from B -> A
* This brings across product categories, tags, attributes, etc.
*/
$taxonomies = get_object_taxonomies( 'product' );
foreach ( $taxonomies as $taxonomy ) {
$terms = wp_get_object_terms( $shadow_id, $taxonomy, [ 'fields' => 'ids' ] );
if ( ! is_wp_error( $terms ) ) {
wp_set_object_terms( $product_id, $terms, $taxonomy, false );
}
}
/*
* 3) Replace most post meta on A with B's post meta
* This is the important part for ACF/template/meta-driven setups.
*
* We preserve A-side identity/commerce history fields so the
* live product keeps its real stock/order/review/SKU record.
*/
$preserve_meta_keys = [
'_sku',
'_stock',
'_stock_status',
'_manage_stock',
'_backorders',
'_backorders_allowed',
'_backordered',
'total_sales',
'_wc_average_rating',
'_wc_review_count',
'_wc_rating_count',
'_edit_lock',
'_edit_last',
/*
* Keep A's SEO/plugin identity meta intact.
* This preserves the live product's existing SEO setup
* even when Version B content/design/meta is applied.
*/
'_yoast_wpseo_title',
'_yoast_wpseo_metadesc',
'_yoast_wpseo_focuskw',
'_yoast_wpseo_focuskeywords',
'_yoast_wpseo_canonical',
'_yoast_wpseo_opengraph-title',
'_yoast_wpseo_opengraph-description',
'_yoast_wpseo_opengraph-image',
'_yoast_wpseo_twitter-title',
'_yoast_wpseo_twitter-description',
'_yoast_wpseo_twitter-image',
'_yoast_wpseo_meta-robots-noindex',
'_yoast_wpseo_meta-robots-nofollow',
'_yoast_wpseo_schema_page_type',
'_yoast_wpseo_schema_article_type',
'rank_math_title',
'rank_math_description',
'rank_math_focus_keyword',
'rank_math_canonical_url',
'rank_math_robots',
'rank_math_facebook_title',
'rank_math_facebook_description',
'rank_math_facebook_image',
'rank_math_twitter_title',
'rank_math_twitter_description',
'rank_math_twitter_image',
'rank_math_schema_type',
'_aioseo_title',
'_aioseo_description',
'_aioseo_keywords',
'_aioseo_og_title',
'_aioseo_og_description',
'_aioseo_og_image',
'_aioseo_twitter_title',
'_aioseo_twitter_description',
'_aioseo_twitter_image',
'_aioseo_canonical_url',
'_aioseo_robots_noindex',
'_aioseo_robots_nofollow',
// Never copy shadow markers onto the real product.
'_abtestkit_shadow',
'_abtestkit_shadow_of',
'_abtestkit_variant_in_use',
'_abtestkit_variant_of',
'_abtestkit_variant_test_id',
];
$shadow_meta_cache_key = 'abtestkit_shadow_meta_rows_' . (int) $shadow_id;
$product_meta_cache_key = 'abtestkit_product_meta_keys_' . (int) $product_id;
$meta_rows = wp_cache_get( $shadow_meta_cache_key, 'abtestkit' );
if ( false === $meta_rows ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE post_id = %d",
$shadow_id
),
ARRAY_A
);
wp_cache_set( $shadow_meta_cache_key, $meta_rows, 'abtestkit', MINUTE_IN_SECONDS );
}
if ( is_array( $meta_rows ) ) {
// Clear existing A meta except preserved keys.
$existing_keys = wp_cache_get( $product_meta_cache_key, 'abtestkit' );
if ( false === $existing_keys ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$existing_keys = $wpdb->get_col(
$wpdb->prepare(
"SELECT meta_key
FROM {$wpdb->postmeta}
WHERE post_id = %d",
$product_id
)
);
wp_cache_set( $product_meta_cache_key, $existing_keys, 'abtestkit', MINUTE_IN_SECONDS );
}
if ( is_array( $existing_keys ) ) {
foreach ( $existing_keys as $meta_key ) {
$meta_key = (string) $meta_key;
if ( in_array( $meta_key, $preserve_meta_keys, true ) ) {
continue;
}
delete_post_meta( $product_id, $meta_key );
}
}
// Copy B meta onto A, excluding preserved keys.
foreach ( $meta_rows as $row ) {
$meta_key = isset( $row['meta_key'] ) ? (string) $row['meta_key'] : '';
if ( $meta_key === '' ) {
continue;
}
if ( in_array( $meta_key, $preserve_meta_keys, true ) ) {
continue;
}
$meta_value = $row['meta_value'] ?? '';
add_post_meta( $product_id, $meta_key, wp_slash( maybe_unserialize( $meta_value ) ) );
}
}
/*
* 4) Make absolutely sure key Woo price/image fields are aligned
* with the shadow product after meta copy.
*/
$regular_price = $shadow_product->get_regular_price( 'edit' );
$sale_price = $shadow_product->get_sale_price( 'edit' );
$active_price = $shadow_product->get_price( 'edit' );
$image_id = (int) $shadow_product->get_image_id( 'edit' );
$gallery_ids = $shadow_product->get_gallery_image_ids( 'edit' );
if ( $regular_price !== '' && $regular_price !== null ) {
update_post_meta( $product_id, '_regular_price', wc_format_decimal( $regular_price ) );
}
if ( $sale_price !== '' && $sale_price !== null ) {
update_post_meta( $product_id, '_sale_price', wc_format_decimal( $sale_price ) );
} else {
delete_post_meta( $product_id, '_sale_price' );
}
if ( $active_price !== '' && $active_price !== null ) {
update_post_meta( $product_id, '_price', wc_format_decimal( $active_price ) );
}
if ( $image_id > 0 ) {
update_post_meta( $product_id, '_thumbnail_id', $image_id );
} else {
delete_post_meta( $product_id, '_thumbnail_id' );
}
if ( is_array( $gallery_ids ) ) {
$gallery_ids = array_values( array_filter( array_map( 'absint', $gallery_ids ) ) );
update_post_meta( $product_id, '_product_image_gallery', implode( ',', $gallery_ids ) );
}
/*
* 5) For variable products / variable subscriptions,
* merge each shadow child variation back onto its real A child.
*/
if ( function_exists( 'abtestkit_pt_apply_shadow_variations_to_product' ) ) {
abtestkit_pt_apply_shadow_variations_to_product( (int) $product_id, (int) $shadow_id );
}
/*
* 6) Clear caches/transients
*/
clean_post_cache( $product_id );
clean_object_term_cache( $product_id, 'product' );
wp_cache_delete( $product_id, 'posts' );
if ( function_exists( 'wc_delete_product_transients' ) ) {
wc_delete_product_transients( $product_id );
wc_delete_product_transients( $shadow_id );
}
/*
* 6) Auto-trash the shadow B product now it has been applied
*/
if ( get_post_status( $shadow_id ) ) {
wp_trash_post( $shadow_id );
}
}
}
/*
* 7) Remove the test entirely so frontend assignment stops cleanly.
* No need to keep a live completed product test once B is committed.
*/
$test['status'] = 'complete';
$test['winner'] = 'B';
$test['finished_at'] = time();
abtestkit_pt_put( $test );
break;
}
// Default behaviour for page tests (with a real B page)
$A = get_post( $test['control_id'] );
$B = get_post( $test['variant_id'] );
if ( $A && $B ) {
// 1) Content/excerpt
wp_update_post( wp_slash( [
'ID' => $A->ID,
'post_content' => $B->post_content,
'post_excerpt' => $B->post_excerpt,
] ) );
// 2) Meta
$meta = get_post_meta( $B->ID );
foreach ( $meta as $k => $vals ) {
if ( in_array( $k, [ '_edit_lock','_edit_last','_wp_old_slug' ], true ) ) {
continue;
}
delete_post_meta( $A->ID, $k );
foreach ( (array) $vals as $v ) {
add_post_meta( $A->ID, $k, wp_slash( maybe_unserialize( $v ) ) );
}
}
// 3) Taxonomies
$taxes = get_object_taxonomies( $A->post_type );
foreach ( $taxes as $tx ) {
$terms = wp_get_object_terms( $B->ID, $tx, [ 'fields' => 'ids' ] );
if ( ! is_wp_error( $terms ) ) {
wp_set_object_terms( $A->ID, $terms, $tx, false );
}
}
$test['status'] = 'complete';
$test['finished_at'] = time();
abtestkit_pt_put( $test );
// If Version B is an existing page/post (not a shadow duplicate), restore SEO now the test is finished.
if ( $B && ! abtestkit_is_shadow_variant( (int) $B->ID ) ) {
abtestkit_pt_unmark_existing_variant_in_use( (int) $B->ID, (string) $test['id'] );
}
wp_trash_post( $B->ID ); // optional
}
break;
case 'keep_a_winner':
$kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
$control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
$winner_basis = isset( $_POST['winner_basis'] )
? sanitize_key( wp_unslash( (string) $_POST['winner_basis'] ) )
: '';
$winner_basis = abtestkit_pt_prepare_winner_application( $test, 'A', $winner_basis );
if ( is_wp_error( $winner_basis ) ) {
$redirect = $return_url
? add_query_arg( 'abtestkit_error', $winner_basis->get_error_code(), $return_url )
: add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => $winner_basis->get_error_code() ], admin_url( 'admin.php' ) );
wp_safe_redirect( $redirect );
exit;
}
// Snapshot now before removing the live test record.
abtestkit_pt_snapshot_completed_test( $test );
// PRODUCT TESTS:
// Keep A live exactly as-is, auto-trash the shadow B product if it is a valid shadow of A,
// but keep the test record and mark it complete so it still shows on the dashboard.
if ( $kind === 'product' ) {
$trashed_b = false;
$is_shadow_b = false;
if (
$variant_id > 0
&& $control_id > 0
&& function_exists( 'abtestkit_is_shadow_product' )
&& abtestkit_is_shadow_product( $variant_id )
) {
$shadow_of = (int) get_post_meta( $variant_id, '_abtestkit_shadow_of', true );
$is_shadow_b = ( $shadow_of === $control_id );
}
if ( $is_shadow_b && get_post_status( $variant_id ) ) {
wp_trash_post( $variant_id );
$trashed_b = true;
}
$test['status'] = 'complete';
$test['winner'] = 'A';
$test['finished_at'] = time();
abtestkit_pt_put( $test );
break;
}
// PAGE / POST TESTS:
// End test, keep A as-is, optionally trash B depending on user choice.
$test['status'] = 'complete';
$test['finished_at'] = time();
abtestkit_pt_put( $test );
$trash_b_raw = isset( $_POST['trash_b'] )
? sanitize_text_field( wp_unslash( $_POST['trash_b'] ) )
: '0';
$trash_b = ( '1' === $trash_b_raw );
if ( $trash_b && $variant_id > 0 && get_post_status( $variant_id ) ) {
// If Version B is an existing page/post (not a shadow duplicate), restore SEO now the test is finished.
if ( ! abtestkit_is_shadow_variant( $variant_id ) ) {
abtestkit_pt_unmark_existing_variant_in_use( $variant_id, (string) $test['id'] );
}
wp_trash_post( $variant_id );
}
break;
case 'save_split':
$split = max( 0, min( 100, intval( $_POST['split'] ?? 50 ) ) );
$test['split'] = $split;
abtestkit_pt_put( $test );
break;
}
if ( $return_url && in_array( $do, [ 'apply_b_winner', 'keep_a_winner' ], true ) ) {
wp_safe_redirect( add_query_arg( 'updated', '1', $return_url ) );
exit;
}
wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'updated' => '1' ], admin_url( 'admin.php' ) ) );
exit;
} );
/** Stats helper (reads abtestkit_events) */
/**
* Compute stats for snapshotting (by ab_test_id only).
* This avoids "0 stats" issues when events don't have a reliable post_id (e.g. Woo add_to_cart / AJAX).
*/
function abtestkit_pt_stats_for_snapshot( string $test_id ) : array {
global $wpdb;
$out = [
'A' => [ 'impressions' => 0, 'clicks' => 0, 'purchases' => 0, 'revenue' => 0.0, 'revenue_per_customer' => 0.0 ],
'B' => [ 'impressions' => 0, 'clicks' => 0, 'purchases' => 0, 'revenue' => 0.0, 'revenue_per_customer' => 0.0 ],
];
if ( $test_id === '' ) {
return $out;
}
$table = ABTESTKIT_EVENTS_TABLE;
if ( abtestkit_events_table_has_column( 'amount' ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT variant, event_type, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS revenue ' .
'FROM %i ' .
'WHERE ab_test_id = %s ' .
"AND event_type IN ('impression','click','purchase') " .
'GROUP BY variant, event_type',
$table,
$test_id
),
ARRAY_A
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT variant, event_type, COUNT(*) AS c, 0 AS revenue ' .
'FROM %i ' .
'WHERE ab_test_id = %s ' .
"AND event_type IN ('impression','click','purchase') " .
'GROUP BY variant, event_type',
$table,
$test_id
),
ARRAY_A
);
}
foreach ( (array) $rows as $r ) {
$v = isset( $r['variant'] ) ? $r['variant'] : '';
$t = isset( $r['event_type'] ) ? $r['event_type'] : '';
$c = isset( $r['c'] ) ? (int) $r['c'] : 0;
$revenue = isset( $r['revenue'] ) ? (float) $r['revenue'] : 0.0;
if ( ! isset( $out[ $v ] ) ) {
continue;
}
if ( $t === 'purchase' ) {
$out[ $v ]['purchases'] += $c;
$out[ $v ]['revenue'] += $revenue;
} elseif ( isset( $out[ $v ][ $t . 's' ] ) ) {
$out[ $v ][ $t . 's' ] += $c;
}
}
foreach ( [ 'A', 'B' ] as $variant ) {
$purchases = (int) $out[ $variant ]['purchases'];
$revenue = (float) $out[ $variant ]['revenue'];
$out[ $variant ]['revenue_per_customer'] = $purchases > 0 ? round( $revenue / $purchases, 2 ) : 0.0;
$out[ $variant ]['revenue'] = round( $revenue, 2 );
}
return $out;
}
function abtestkit_pt_capture_post_snapshot( int $post_id ) : array {
$p = $post_id > 0 ? get_post( $post_id ) : null;
if ( ! ( $p instanceof WP_Post ) ) {
return [];
}
return [
'id' => (int) $p->ID,
'post_type' => (string) $p->post_type,
'post_status' => (string) $p->post_status,
'post_title' => (string) $p->post_title,
'post_name' => (string) $p->post_name,
'post_date_gmt' => (string) $p->post_date_gmt,
'modified_gmt' => (string) $p->post_modified_gmt,
'post_excerpt' => (string) $p->post_excerpt,
'post_content' => (string) $p->post_content,
];
}
function abtestkit_pt_capture_product_snapshot( int $product_id ) : array {
$p = $product_id > 0 ? get_post( $product_id ) : null;
if ( ! ( $p instanceof WP_Post ) ) {
return [];
}
$regular = get_post_meta( $product_id, '_regular_price', true );
$sale = get_post_meta( $product_id, '_sale_price', true );
$price = get_post_meta( $product_id, '_price', true );
if ( function_exists( 'wc_get_product' ) ) {
$prod = wc_get_product( $product_id );
if ( $prod ) {
$regular = $prod->get_regular_price();
$sale = $prod->get_sale_price();
$price = $prod->get_price();
}
}
$image_id = (int) get_post_thumbnail_id( $product_id );
$gallery_csv = (string) get_post_meta( $product_id, '_product_image_gallery', true );
$gallery_ids = [];
if ( $gallery_csv !== '' ) {
$gallery_ids = array_values(
array_filter(
array_map(
'intval',
array_map( 'trim', explode( ',', $gallery_csv ) )
)
)
);
}
return [
'id' => (int) $p->ID,
'post_title' => (string) $p->post_title,
'short_description' => (string) $p->post_excerpt,
'description' => (string) $p->post_content,
'regular_price' => (string) $regular,
'sale_price' => (string) $sale,
'price' => (string) $price,
'image_id' => (int) $image_id,
'gallery_ids' => $gallery_ids,
];
}
/**
* Return the current raw metric leader for an unfinished test.
*
* Revenue tests compare revenue per impression. All other test goals compare
* conversions per impression, matching the performance screen's leader card.
*/
function abtestkit_pt_current_leader( array $test ) : string {
$stats = abtestkit_pt_stats( $test );
$is_revenue_test = 'purchase' === abtestkit_pt_normalize_goal_for_display( $test['goal'] ?? '' );
$metrics = [];
foreach ( [ 'A', 'B' ] as $variant ) {
$variant_stats = isset( $stats[ $variant ] ) && is_array( $stats[ $variant ] )
? $stats[ $variant ]
: [];
$impressions = max( 0, (int) ( $variant_stats['impressions'] ?? 0 ) );
$value = $is_revenue_test
? (float) ( $variant_stats['revenue'] ?? 0 )
: (float) ( $variant_stats['clicks'] ?? 0 );
$metrics[ $variant ] = $impressions > 0 ? $value / $impressions : 0.0;
}
if ( $metrics['A'] === $metrics['B'] ) {
return '';
}
return $metrics['B'] > $metrics['A'] ? 'B' : 'A';
}
/**
* Validate and record why an administrator is completing a test with A or B.
*
* @return string|WP_Error Normalised winner basis or an action error.
*/
function abtestkit_pt_prepare_winner_application( array &$test, string $selected, string $submitted_basis = '' ) {
$selected = strtoupper( sanitize_text_field( $selected ) );
$status = sanitize_key( (string) ( $test['status'] ?? '' ) );
if (
! in_array( $selected, [ 'A', 'B' ], true )
|| ! in_array( $status, [ 'running', 'paused', 'winner' ], true )
) {
return new WP_Error(
'abtestkit_winner_action_unavailable',
__( 'This result can no longer be applied.', 'abtestkit' )
);
}
$locked_winner = strtoupper( sanitize_text_field( (string) ( $test['winner'] ?? '' ) ) );
$basis = sanitize_key( $submitted_basis );
// Preserve compatibility with older admin forms and direct integrations.
if ( $basis === '' ) {
$basis = $locked_winner === $selected ? 'statistical_decision' : 'manual_selection';
}
if ( ! in_array( $basis, [ 'statistical_decision', 'current_leader', 'manual_selection' ], true ) ) {
return new WP_Error(
'abtestkit_winner_basis_invalid',
__( 'The result selection method is invalid.', 'abtestkit' )
);
}
if ( $basis === 'statistical_decision' && $locked_winner !== $selected ) {
return new WP_Error(
'abtestkit_statistical_winner_changed',
__( 'The declared statistical winner has changed. Reload the test before applying it.', 'abtestkit' )
);
}
if ( $basis === 'current_leader' && abtestkit_pt_current_leader( $test ) !== $selected ) {
return new WP_Error(
'abtestkit_current_leader_changed',
__( 'The current leader has changed. Reload the test before applying it.', 'abtestkit' )
);
}
$test['winner'] = $selected;
$test['winner_basis'] = $basis;
$test['winner_applied_at'] = time();
$test['winner_applied_by'] = get_current_user_id();
return $basis;
}
/**
* Capture the exact Version B configuration for custom-code tests.
*
* JavaScript source remains in its existing content-addressed protected store;
* the normal test registry keeps only its immutable reference and hash.
*/
function abtestkit_pt_capture_custom_code_snapshot( array $test ) : array {
$kind = sanitize_key( (string) ( $test['kind'] ?? '' ) );
$snapshot = [ 'kind' => $kind ];
if ( $kind === 'custom_css' ) {
$data = abtestkit_custom_css_data_for_test( $test );
$snapshot += [
'css_scope' => $data['css_scope'],
'custom_css' => $data['custom_css'],
'css_markers' => $data['css_markers'],
];
} elseif ( $kind === 'custom_html' ) {
$data = abtestkit_custom_html_data_for_test( $test );
$snapshot += [
'html_scope' => $data['html_scope'],
'html_changes' => $data['html_changes'],
];
} elseif ( $kind === 'custom_javascript' ) {
$snapshot += [
'javascript_scope' => sanitize_key( (string) ( $test['javascript_scope'] ?? '' ) ),
'javascript_code_ref' => sanitize_text_field( (string) ( $test['javascript_code_ref'] ?? '' ) ),
'javascript_code_hash' => sanitize_text_field( (string) ( $test['javascript_code_hash'] ?? '' ) ),
'javascript_code_length' => max( 0, (int) ( $test['javascript_code_length'] ?? 0 ) ),
'javascript_run_timing' => sanitize_key( (string) ( $test['javascript_run_timing'] ?? '' ) ),
'javascript_ajax_policy' => sanitize_key( (string) ( $test['javascript_ajax_policy'] ?? '' ) ),
];
}
$snapshot['configuration_hash'] = hash( 'sha256', (string) wp_json_encode( $snapshot ) );
return $snapshot;
}
/**
* Use an immutable completed snapshot for reporting when one is available.
*/
function abtestkit_pt_custom_code_reporting_test( array $test ) : array {
$kind = sanitize_key( (string) ( $test['kind'] ?? '' ) );
$snapshot = isset( $test['snapshot_versions']['B'] ) && is_array( $test['snapshot_versions']['B'] )
? $test['snapshot_versions']['B']
: [];
if (
in_array( (string) ( $test['status'] ?? '' ), [ 'winner', 'complete' ], true )
&& (string) ( $snapshot['kind'] ?? '' ) === $kind
) {
return array_merge( $test, $snapshot );
}
return $test;
}
/**
* Snapshot the "final" state of a test so Completed/Winner tabs remain stable forever.
* - final_stats: A/B impressions+clicks computed by ab_test_id
* - snapshot_versions: the exact A and B versions at snapshot time
*/
function abtestkit_pt_snapshot_completed_test( array &$test ) : void {
$test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
if ( $test_id === '' ) {
return;
}
if ( empty( $test['final_stats'] ) || ! is_array( $test['final_stats'] ) ) {
$test['final_stats'] = abtestkit_pt_stats_for_snapshot( $test_id );
}
if ( empty( $test['snapshot_at'] ) ) {
$test['snapshot_at'] = time();
}
$kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
$snapshot_versions = isset( $test['snapshot_versions'] ) && is_array( $test['snapshot_versions'] )
? $test['snapshot_versions']
: [];
$custom_kinds = [ 'custom_css', 'custom_html', 'custom_javascript' ];
if (
in_array( $kind, $custom_kinds, true )
&& (
empty( $snapshot_versions['B'] )
|| (string) ( $snapshot_versions['B']['kind'] ?? '' ) !== $kind
)
) {
$test['snapshot_versions'] = [
'A' => abtestkit_pt_capture_post_snapshot( (int) ( $test['control_id'] ?? 0 ) ),
'B' => abtestkit_pt_capture_custom_code_snapshot( $test ),
];
} elseif ( empty( $snapshot_versions ) ) {
if ( $kind === 'product' ) {
$a = abtestkit_pt_capture_product_snapshot( (int) ( $test['control_id'] ?? 0 ) );
// For product tests, B might be "virtual" via overrides (variant_id can be 0).
$b = [];
$variant_id = (int) ( $test['variant_id'] ?? 0 );
if ( $variant_id > 0 ) {
$b = abtestkit_pt_capture_product_snapshot( $variant_id );
} else {
$b = $a;
$overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];
foreach ( $overrides as $k => $v ) {
$b[ $k ] = $v;
}
$b['virtual'] = true;
}
$test['snapshot_versions'] = [
'A' => $a,
'B' => $b,
];
} else {
$test['snapshot_versions'] = [
'A' => abtestkit_pt_capture_post_snapshot( (int) ( $test['control_id'] ?? 0 ) ),
'B' => abtestkit_pt_capture_post_snapshot( (int) ( $test['variant_id'] ?? 0 ) ),
];
}
}
}
function abtestkit_pt_stats_default() : array {
return [
'A' => [ 'impressions' => 0, 'clicks' => 0, 'purchases' => 0, 'revenue' => 0.0, 'revenue_per_customer' => 0.0 ],
'B' => [ 'impressions' => 0, 'clicks' => 0, 'purchases' => 0, 'revenue' => 0.0, 'revenue_per_customer' => 0.0 ],
];
}
function abtestkit_engagement_sample_rate() : float {
$rate = defined( 'ABTESTKIT_ENGAGEMENT_SAMPLE_RATE' )
? (float) ABTESTKIT_ENGAGEMENT_SAMPLE_RATE
: 1.0;
return max( 0.0, min( 1.0, $rate ) );
}
/**
* Return aggregate device and country metrics for one test.
*
* Historical rows and requests without a local country signal are grouped as
* unknown. Only aggregate counts and revenue leave this function.
*
* @param string $test_id Test ID.
* @param string $goal Normalised goal.
* @return array
*/
function abtestkit_pt_audience_breakdowns( string $test_id, string $goal ) : array {
global $wpdb;
$empty = [
'device' => [ 'availability' => 'not_collected', 'values' => [] ],
'geography' => [ 'availability' => 'not_collected', 'values' => [] ],
];
$test_id = abtestkit_sanitize_test_id( $test_id );
if (
$test_id === ''
|| ! abtestkit_events_table_has_column( 'device_class' )
|| ! abtestkit_events_table_has_column( 'country_code' )
) {
return $empty;
}
$conversion_event = $goal === 'purchase' ? 'purchase' : 'click';
$table = ABTESTKIT_EVENTS_TABLE;
$output = [
'device' => [ 'availability' => 'collected', 'values' => [] ],
'geography' => [ 'availability' => 'collected', 'values' => [] ],
];
$dimensions = [
'device' => 'device_class',
'geography' => 'country_code',
];
foreach ( $dimensions as $name => $column ) {
if ( abtestkit_events_table_has_column( 'amount' ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT COALESCE(NULLIF(%i, ''), 'unknown') AS dimension_value, variant, event_type, COUNT(*) AS total, COALESCE(SUM(amount), 0) AS revenue
FROM %i
WHERE ab_test_id = %s
AND event_type IN ('impression', %s)
GROUP BY dimension_value, variant, event_type
ORDER BY dimension_value ASC
LIMIT 1000",
$column,
$table,
$test_id,
$conversion_event
),
ARRAY_A
);
} else {
// Keep audience reporting available for legacy event tables that
// predate purchase revenue storage.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT COALESCE(NULLIF(%i, ''), 'unknown') AS dimension_value, variant, event_type, COUNT(*) AS total, 0 AS revenue
FROM %i
WHERE ab_test_id = %s
AND event_type IN ('impression', %s)
GROUP BY dimension_value, variant, event_type
ORDER BY dimension_value ASC
LIMIT 1000",
$column,
$table,
$test_id,
$conversion_event
),
ARRAY_A
);
}
$indexed = [];
foreach ( (array) $rows as $row ) {
$value = $name === 'device'
? abtestkit_audience_normalize_device_class( $row['dimension_value'] ?? '' )
: abtestkit_audience_normalize_country_code( $row['dimension_value'] ?? '' );
$variant = isset( $row['variant'] ) ? (string) $row['variant'] : '';
$event_type = isset( $row['event_type'] ) ? (string) $row['event_type'] : '';
if ( ! in_array( $variant, [ 'A', 'B' ], true ) ) {
continue;
}
if ( ! isset( $indexed[ $value ] ) ) {
$indexed[ $value ] = [
'key' => $value,
'A' => [ 'impressions' => 0, 'conversions' => 0, 'revenue' => 0.0 ],
'B' => [ 'impressions' => 0, 'conversions' => 0, 'revenue' => 0.0 ],
];
}
if ( $event_type === 'impression' ) {
$indexed[ $value ][ $variant ]['impressions'] += max( 0, (int) ( $row['total'] ?? 0 ) );
} elseif ( $event_type === $conversion_event ) {
$indexed[ $value ][ $variant ]['conversions'] += max( 0, (int) ( $row['total'] ?? 0 ) );
if ( $goal === 'purchase' ) {
$indexed[ $value ][ $variant ]['revenue'] += max( 0.0, (float) ( $row['revenue'] ?? 0 ) );
}
}
}
if ( $name === 'device' ) {
$order = [ 'desktop' => 0, 'tablet' => 1, 'mobile' => 2, 'unknown' => 3 ];
uasort(
$indexed,
static function ( array $left, array $right ) use ( $order ): int {
return ( $order[ $left['key'] ] ?? 99 ) <=> ( $order[ $right['key'] ] ?? 99 );
}
);
} else {
uksort(
$indexed,
static function ( string $left, string $right ): int {
if ( $left === 'unknown' ) {
return 1;
}
if ( $right === 'unknown' ) {
return -1;
}
return strcmp( $left, $right );
}
);
}
$output[ $name ]['values'] = array_values( $indexed );
}
return $output;
}
function abtestkit_pt_engagement_stats( string $test_id ) : array {
global $wpdb;
$out = [
'sample_rate' => abtestkit_engagement_sample_rate(),
'A' => [
'count' => 0,
'avg_scroll' => 0.0,
'avg_time' => 0.0,
],
'B' => [
'count' => 0,
'avg_scroll' => 0.0,
'avg_time' => 0.0,
],
];
$test_id = abtestkit_sanitize_test_id( $test_id );
if (
$test_id === ''
|| ! abtestkit_events_table_has_column( 'scroll_pct' )
|| ! abtestkit_events_table_has_column( 'time_sec' )
) {
return $out;
}
// Read the small engagement aggregate only when an admin opens performance.
// Engagement events deliberately do not invalidate the larger conversion caches.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT
variant,
COUNT(*) AS sample_count,
AVG(scroll_pct) AS avg_scroll,
AVG(time_sec) AS avg_time
FROM %i
WHERE ab_test_id = %s
AND event_type = 'engagement'
GROUP BY variant",
ABTESTKIT_EVENTS_TABLE,
$test_id
),
ARRAY_A
);
foreach ( (array) $rows as $row ) {
$variant = isset( $row['variant'] ) ? (string) $row['variant'] : '';
if ( ! isset( $out[ $variant ] ) ) {
continue;
}
$out[ $variant ] = [
'count' => isset( $row['sample_count'] ) ? (int) $row['sample_count'] : 0,
'avg_scroll' => isset( $row['avg_scroll'] ) ? round( (float) $row['avg_scroll'], 1 ) : 0.0,
'avg_time' => isset( $row['avg_time'] ) ? round( (float) $row['avg_time'], 1 ) : 0.0,
];
}
return $out;
}
function abtestkit_pt_stats_cache_key( string $test_id ) : string {
return 'abtestkit_pt_stats_' . md5( $test_id );
}
function abtestkit_pt_performance_cache_key( string $test_id, string $range ) : string {
return 'abtestkit_pt_perf_preview_v2_' . md5( $test_id . '|' . $range );
}
function abtestkit_pt_dashboard_cache_key( array $tests ) : string {
$fingerprint = [];
foreach ( $tests as $test ) {
$fingerprint[] = [
'id' => (string) ( $test['id'] ?? '' ),
'status' => (string) ( $test['status'] ?? '' ),
'updated_at' => (int) ( $test['snapshot_at'] ?? 0 ),
'winner' => (string) ( $test['winner'] ?? '' ),
'title' => (string) ( $test['title'] ?? '' ),
];
}
return 'abtestkit_pt_dash_preview_v2_' . md5( wp_json_encode( $fingerprint ) );
}
function abtestkit_pt_performance_cache_ttl( string $range ) : int {
switch ( $range ) {
case 'month':
return 10 * MINUTE_IN_SECONDS;
case 'week':
return 5 * MINUTE_IN_SECONDS;
case 'day':
default:
return 2 * MINUTE_IN_SECONDS;
}
}
function abtestkit_pt_normalize_goal_for_display( $goal ) : string {
$goal = sanitize_key( (string) $goal );
if ( in_array( $goal, [ 'button', 'link', 'click' ], true ) ) {
return 'clicks';
}
if ( in_array( $goal, [ 'destination', 'url', 'destination-url' ], true ) ) {
return 'destination_url';
}
if ( in_array( $goal, [ 'scroll', 'scroll-depth', 'scroll_percentage', 'scroll-percentage' ], true ) ) {
return 'scroll_depth';
}
return $goal;
}
function abtestkit_pt_scroll_depth_for_test( array $test ) : int {
$depth = isset( $test['scroll_depth'] ) ? absint( $test['scroll_depth'] ) : 50;
$allowed_depths = [ 25, 50, 75, 90 ];
if ( ! in_array( $depth, $allowed_depths, true ) ) {
$depth = 50;
}
return $depth;
}
function abtestkit_pt_click_targets_for_test( array $test ) : array {
$keys = [
'links',
'click_targets',
'clickTargets',
'targets',
'conversion_targets',
'conversionTargets',
'target_urls',
'targetUrls',
'link_targets',
'linkTargets',
];
$raw_targets = [];
foreach ( $keys as $key ) {
if ( empty( $test[ $key ] ) ) {
continue;
}
$raw_targets = is_array( $test[ $key ] )
? $test[ $key ]
: [ $test[ $key ] ];
break;
}
if ( empty( $raw_targets ) ) {
return [];
}
$targets = [];
foreach ( $raw_targets as $target ) {
if ( is_array( $target ) ) {
$target = $target['target']
?? $target['url']
?? $target['href']
?? $target['selector']
?? $target['value']
?? '';
} elseif ( is_object( $target ) ) {
$target = $target->target
?? $target->url
?? $target->href
?? $target->selector
?? $target->value
?? '';
}
$target = sanitize_text_field( (string) $target );
if ( $target !== '' ) {
$targets[] = $target;
}
}
return array_values( array_unique( $targets ) );
}
/**
* Return the result-safety and acknowledgement policy for one health check.
*
* Unknown attention checks fail closed. Broken checks are always blocking and
* can never be acknowledged away.
*
* @param string $id Health check identifier.
* @param string $status Raw check status.
* @return array{blocks_result:bool,acknowledgeable:bool}
*/
function abtestkit_pt_health_check_policy( string $id, string $status ) : array {
$id = sanitize_key( $id );
$status = sanitize_key( $status );
$advisory_attention_checks = [ 'http_tracking', 'preview_load', 'traffic_split' ];
return [
'blocks_result' => $status === 'broken'
|| ( $status === 'attention' && ! in_array( $id, $advisory_attention_checks, true ) ),
'acknowledgeable' => $status === 'attention' && $id === 'http_tracking',
];
}
/**
* Return the bounded per-test health acknowledgements.
*
* @param array $test Test record.
* @return array
*/
function abtestkit_pt_health_acknowledgements( array $test ) : array {
$stored = isset( $test['health_acknowledgements'] ) && is_array( $test['health_acknowledgements'] )
? $test['health_acknowledgements']
: [];
$acknowledgements = [];
foreach ( [ 'http_tracking' ] as $check_id ) {
$acknowledged_at = isset( $stored[ $check_id ] ) ? max( 0, (int) $stored[ $check_id ] ) : 0;
if ( $acknowledged_at > 0 ) {
$acknowledgements[ $check_id ] = $acknowledged_at;
}
}
return $acknowledgements;
}
function abtestkit_pt_health_summary( array $test, array $stats = [], array $context = [] ) : array {
$checks = [];
$overall = 'good';
$raw_overall = 'good';
$acknowledgements = abtestkit_pt_health_acknowledgements( $test );
$add_check = static function( string $id, string $status, string $title, string $description = '' ) use ( &$checks, &$overall, &$raw_overall, $acknowledgements ) : void {
$allowed_statuses = [ 'good', 'attention', 'broken', 'info' ];
if ( ! in_array( $status, $allowed_statuses, true ) ) {
$status = 'info';
}
if ( $status === 'broken' ) {
$raw_overall = 'broken';
} elseif ( $status === 'attention' && $raw_overall !== 'broken' ) {
$raw_overall = 'attention';
}
$policy = abtestkit_pt_health_check_policy( $id, $status );
$acknowledged_at = $policy['acknowledgeable'] && isset( $acknowledgements[ $id ] )
? (int) $acknowledgements[ $id ]
: 0;
$effective_status = $acknowledged_at > 0 ? 'info' : $status;
if ( $effective_status === 'broken' ) {
$overall = 'broken';
} elseif ( $effective_status === 'attention' && $overall !== 'broken' ) {
$overall = 'attention';
}
$checks[] = [
'id' => $id,
'status' => $effective_status,
'original_status' => $status,
'title' => $title,
'description' => $description,
'blocks_result' => $policy['blocks_result'],
'acknowledgeable' => $policy['acknowledgeable'],
'acknowledged' => $acknowledged_at > 0,
'acknowledged_at' => $acknowledged_at,
];
};
$test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
$kind = isset( $test['kind'] ) ? sanitize_key( (string) $test['kind'] ) : 'page';
$status = isset( $test['status'] ) ? sanitize_key( (string) $test['status'] ) : 'paused';
$control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
$goal = isset( $test['goal'] ) ? abtestkit_pt_normalize_goal_for_display( $test['goal'] ) : '';
$targets = abtestkit_pt_click_targets_for_test( $test );
$started_at = isset( $test['started_at'] ) ? (int) $test['started_at'] : 0;
$min_imps = isset( $test['min_impressions'] ) ? max( 0, (int) $test['min_impressions'] ) : 50;
$http_count = isset( $context['http_excluded_count'] ) ? (int) $context['http_excluded_count'] : 0;
$http_last = isset( $context['http_excluded_last'] ) ? (string) $context['http_excluded_last'] : '';
$preview_failure = function_exists( 'abtestkit_preview_health_latest_for_test' )
? abtestkit_preview_health_latest_for_test( $test )
: [];
$html_runtime_health = (
$kind === 'custom_html'
&& $test_id !== ''
&& function_exists( 'abtestkit_html_runtime_health_get' )
)
? abtestkit_html_runtime_health_get( $test_id )
: [];
$javascript_runtime_health = (
$kind === 'custom_javascript'
&& $test_id !== ''
&& function_exists( 'abtestkit_custom_javascript_health_get' )
)
? abtestkit_custom_javascript_health_get( $test_id )
: [];
$stats = ( is_array( $stats ) && isset( $stats['A'], $stats['B'] ) ) ? $stats : abtestkit_pt_stats_default();
$impressions_a = isset( $stats['A']['impressions'] ) ? (int) $stats['A']['impressions'] : 0;
$impressions_b = isset( $stats['B']['impressions'] ) ? (int) $stats['B']['impressions'] : 0;
$clicks_a = isset( $stats['A']['clicks'] ) ? (int) $stats['A']['clicks'] : 0;
$clicks_b = isset( $stats['B']['clicks'] ) ? (int) $stats['B']['clicks'] : 0;
$purchases_a = isset( $stats['A']['purchases'] ) ? (int) $stats['A']['purchases'] : 0;
$purchases_b = isset( $stats['B']['purchases'] ) ? (int) $stats['B']['purchases'] : 0;
$total_impressions = $impressions_a + $impressions_b;
$conversion_total = ( $goal === 'purchase' )
? ( $purchases_a + $purchases_b )
: ( $clicks_a + $clicks_b );
if ( $test_id === '' ) {
$add_check( 'test_config', 'broken', __( 'Test configuration is missing', 'abtestkit' ), __( 'This test does not have a valid internal ID.', 'abtestkit' ) );
} else {
$add_check( 'test_config', 'good', __( 'Test configuration found', 'abtestkit' ), __( 'The saved test settings can be loaded.', 'abtestkit' ) );
}
if ( $status === 'running' ) {
$add_check( 'test_status', 'good', __( 'Test is running', 'abtestkit' ), __( 'Visitors can be assigned to Version A or Version B.', 'abtestkit' ) );
} elseif ( in_array( $status, [ 'complete', 'winner' ], true ) ) {
$add_check( 'test_status', 'good', __( 'Test is complete', 'abtestkit' ), __( 'This test has finished and its saved results are available.', 'abtestkit' ) );
} elseif ( $status === 'draft' ) {
$add_check( 'test_status', 'info', __( 'Test is still a draft', 'abtestkit' ), __( 'Start the test when you are ready to collect visitor data.', 'abtestkit' ) );
} else {
$add_check( 'test_status', 'info', __( 'Test is paused', 'abtestkit' ), __( 'Paused tests keep their setup and results, but do not assign new visitors.', 'abtestkit' ) );
}
if ( $status === 'paused' && ! empty( $test['auto_paused_broken'] ) ) {
$add_check(
'auto_paused_broken',
'attention',
__( 'Automatically paused after a broken health check', 'abtestkit' ),
__( 'Fix the broken item, then resume the test when you are ready. Broken tests will be paused automatically.', 'abtestkit' )
);
}
if ( ! empty( $preview_failure ) ) {
$preview_failed_at = isset( $preview_failure['last_failed_at'] ) ? (int) $preview_failure['last_failed_at'] : 0;
$preview_label = isset( $preview_failure['label'] ) ? sanitize_text_field( (string) $preview_failure['label'] ) : '';
$preview_context = isset( $preview_failure['context'] ) ? sanitize_key( (string) $preview_failure['context'] ) : '';
$description = __( 'An admin preview iframe recently timed out before the page finished loading. This does not pause the test, but it can indicate a theme, plugin, or local environment is blocking preview rendering.', 'abtestkit' );
if ( $preview_failed_at > 0 ) {
$description .= ' ' . sprintf(
/* translators: %s is a date/time string for the last failed preview. */
__( 'Last failed: %s.', 'abtestkit' ),
date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $preview_failed_at )
);
}
if ( $preview_label !== '' || $preview_context !== '' ) {
$description .= ' ' . sprintf(
/* translators: 1: preview label, 2: preview context key. */
__( 'Preview: %1$s%2$s', 'abtestkit' ),
$preview_label !== '' ? $preview_label : __( 'Unknown', 'abtestkit' ),
$preview_context !== '' ? ' (' . $preview_context . ')' : ''
);
}
$add_check(
'preview_load',
'attention',
__( 'Preview recently failed to load', 'abtestkit' ),
$description
);
}
$control_post = $control_id > 0 ? get_post( $control_id ) : null;
if ( ! ( $control_post instanceof WP_Post ) ) {
$add_check( 'version_a', 'broken', __( 'Version A is missing', 'abtestkit' ), __( 'The original page, post, or product could not be found.', 'abtestkit' ) );
} elseif ( in_array( (string) $control_post->post_status, [ 'trash', 'auto-draft' ], true ) ) {
$add_check( 'version_a', 'broken', __( 'Version A is not available', 'abtestkit' ), __( 'The original content exists, but it is not in a usable state.', 'abtestkit' ) );
} else {
$add_check( 'version_a', 'good', __( 'Version A exists', 'abtestkit' ), __( 'The original content for this test can be found.', 'abtestkit' ) );
}
if ( $kind === 'custom_css' ) {
$custom_css_data = abtestkit_custom_css_data_for_test( $test );
if ( empty( $custom_css_data['custom_css'] ) ) {
$add_check( 'custom_css_saved', 'broken', __( 'Version B CSS is missing', 'abtestkit' ), __( 'Custom CSS tests need saved Version B CSS before they can show a visual difference.', 'abtestkit' ) );
} else {
$add_check( 'custom_css_saved', 'good', __( 'Version B CSS is saved', 'abtestkit' ), __( 'The Custom CSS override layer is saved for Version B visitors.', 'abtestkit' ) );
}
if ( empty( $custom_css_data['css_markers'] ) ) {
$add_check( 'custom_css_markers', 'info', __( 'No B-only markers saved', 'abtestkit' ), __( 'This is fine if your CSS targets existing selectors directly.', 'abtestkit' ) );
} else {
$add_check( 'custom_css_markers', 'good', __( 'B-only markers are saved', 'abtestkit' ), __( 'Marker classes will be added to selected elements for Version B visitors only.', 'abtestkit' ) );
}
} elseif ( $kind === 'custom_html' ) {
$custom_html_data = abtestkit_custom_html_data_for_test( $test );
if ( empty( $custom_html_data['html_changes'] ) ) {
$add_check( 'custom_html_saved', 'broken', __( 'Version B HTML is missing', 'abtestkit' ), __( 'Custom HTML tests need at least one saved selector, operation and HTML change before they can show a difference.', 'abtestkit' ) );
} else {
$add_check( 'custom_html_saved', 'good', __( 'Version B HTML is saved', 'abtestkit' ), __( 'The selected targets, operations and Version B HTML are saved.', 'abtestkit' ) );
}
if ( ! empty( $custom_html_data['html_changes'] ) ) {
$runtime_status = isset( $html_runtime_health['status'] ) ? sanitize_key( (string) $html_runtime_health['status'] ) : '';
$runtime_matched = isset( $html_runtime_health['matched'] ) ? max( 0, (int) $html_runtime_health['matched'] ) : 0;
$runtime_missing = isset( $html_runtime_health['missing'] ) ? max( 0, (int) $html_runtime_health['missing'] ) : 0;
$runtime_invalid = isset( $html_runtime_health['invalid'] ) ? max( 0, (int) $html_runtime_health['invalid'] ) : 0;
$runtime_total = isset( $html_runtime_health['total'] ) ? max( 0, (int) $html_runtime_health['total'] ) : 0;
if ( empty( $html_runtime_health ) ) {
$add_check(
'custom_html_runtime',
'info',
__( 'Awaiting a live selector check', 'abtestkit' ),
__( 'Aggregate selector health will appear after Version B is served on the live target page. Preview visits are not recorded.', 'abtestkit' )
);
} elseif ( $runtime_status === 'broken' ) {
$add_check(
'custom_html_runtime',
'broken',
__( 'Live HTML targets are not reliable', 'abtestkit' ),
sprintf(
/* translators: 1: matched selector count, 2: total selector count, 3: missing selector count, 4: invalid selector count. */
__( 'The latest confirmed checks matched %1$d of %2$d saved targets. Missing: %3$d. Invalid: %4$d. The report contains counts only, never selector text or visitor data.', 'abtestkit' ),
$runtime_matched,
$runtime_total,
$runtime_missing,
$runtime_invalid
)
);
} elseif ( $runtime_status === 'attention' ) {
$add_check(
'custom_html_runtime',
'attention',
__( 'Some live HTML targets need checking', 'abtestkit' ),
sprintf(
/* translators: 1: matched selector count, 2: total selector count, 3: missing selector count, 4: invalid selector count. */
__( 'The latest live check matched %1$d of %2$d saved targets. Missing: %3$d. Invalid: %4$d. Missing targets stay in Needs attention because conditional or device-specific content can legitimately be absent; repeatedly confirmed invalid selector syntax can pause the test.', 'abtestkit' ),
$runtime_matched,
$runtime_total,
$runtime_missing,
$runtime_invalid
)
);
} else {
$add_check(
'custom_html_runtime',
'good',
__( 'Live HTML targets are matching', 'abtestkit' ),
sprintf(
/* translators: %d is the number of matched saved HTML targets. */
__( 'The latest Version B check matched all %d saved targets.', 'abtestkit' ),
$runtime_total
)
);
}
}
} elseif ( $kind === 'custom_javascript' ) {
$javascript_scope = sanitize_key( (string) ( $test['javascript_scope'] ?? '' ) );
$source_record = function_exists( 'abtestkit_custom_javascript_code_get' )
? abtestkit_custom_javascript_code_get(
$test['javascript_code_ref'] ?? '',
$test['javascript_code_hash'] ?? ''
)
: new WP_Error( 'abtestkit_custom_javascript_unavailable' );
if (
! in_array( $javascript_scope, [ 'page', 'post', 'product' ], true )
|| ! ( $control_post instanceof WP_Post )
|| $javascript_scope !== (string) $control_post->post_type
) {
$add_check(
'custom_javascript_scope',
'broken',
__( 'JavaScript target is invalid', 'abtestkit' ),
__( 'The saved JavaScript scope does not match its page, post, or product target.', 'abtestkit' )
);
} else {
$add_check(
'custom_javascript_scope',
'good',
__( 'JavaScript target is exact', 'abtestkit' ),
__( 'Version B JavaScript is limited to the selected page, post, or product.', 'abtestkit' )
);
}
if ( is_wp_error( $source_record ) ) {
$add_check(
'custom_javascript_saved',
'broken',
__( 'Version B JavaScript is missing', 'abtestkit' ),
__( 'The immutable JavaScript source failed its saved-reference or integrity check, so it will not be delivered.', 'abtestkit' )
);
} else {
$add_check(
'custom_javascript_saved',
'good',
__( 'Version B JavaScript is saved', 'abtestkit' ),
sprintf(
/* translators: %d is a byte count. */
__( 'The immutable external JavaScript source passed its integrity check (%d bytes).', 'abtestkit' ),
(int) $source_record['length']
)
);
}
$last_runtime_status = sanitize_key( (string) ( $javascript_runtime_health['last_status'] ?? '' ) );
$last_delivery_at = max( 0, (int) ( $javascript_runtime_health['last_delivery_at'] ?? 0 ) );
$last_runtime_at = max( 0, (int) ( $javascript_runtime_health['last_runtime_at'] ?? 0 ) );
if ( $last_runtime_status === 'failed' ) {
$add_check(
'custom_javascript_runtime',
'attention',
__( 'The latest JavaScript run failed', 'abtestkit' ),
__( 'The latest Version B browser reported a synchronous exception. Reloading restores the original page; check the JavaScript before resuming wider traffic.', 'abtestkit' )
);
} elseif ( $last_runtime_status === 'complete' ) {
$add_check(
'custom_javascript_runtime',
'good',
__( 'JavaScript completed in the browser', 'abtestkit' ),
__( 'The latest Version B browser completed the saved JavaScript without a synchronous exception.', 'abtestkit' )
);
} elseif (
$last_delivery_at > 0
&& $last_runtime_at === 0
&& ( time() - $last_delivery_at ) >= ( 5 * MINUTE_IN_SECONDS )
) {
$add_check(
'custom_javascript_runtime',
'attention',
__( 'Runtime confirmation is missing', 'abtestkit' ),
__( 'The external script was served, but no bounded completion signal was received. A browser policy or connection rule may be blocking the report.', 'abtestkit' )
);
} else {
$add_check(
'custom_javascript_runtime',
'info',
__( 'Awaiting a live JavaScript check', 'abtestkit' ),
__( 'Runtime health will appear after the external script is served to an eligible Version B visitor. Preview visits are not recorded.', 'abtestkit' )
);
}
} else {
$variant_post = $variant_id > 0 ? get_post( $variant_id ) : null;
if ( ! ( $variant_post instanceof WP_Post ) ) {
$add_check( 'version_b', 'broken', __( 'Version B is missing', 'abtestkit' ), __( 'The test cannot reliably show a variant because Version B could not be found.', 'abtestkit' ) );
} elseif ( in_array( (string) $variant_post->post_status, [ 'trash', 'auto-draft' ], true ) ) {
$add_check( 'version_b', 'broken', __( 'Version B is not available', 'abtestkit' ), __( 'Version B exists, but it is not in a usable state.', 'abtestkit' ) );
} else {
$variant_is_shadow = function_exists( 'abtestkit_is_shadow_variant' )
&& abtestkit_is_shadow_variant( (int) $variant_id );
$variant_shadow_of = $variant_is_shadow
? (int) get_post_meta( (int) $variant_id, '_abtestkit_shadow_of', true )
: 0;
$variant_is_linked_shadow = $variant_is_shadow
&& $control_id > 0
&& $variant_shadow_of === (int) $control_id;
if ( $variant_is_linked_shadow ) {
$add_check( 'version_b', 'good', __( 'Version B shadow exists', 'abtestkit' ), __( 'Version B is saved as a hidden shadow version linked to Version A.', 'abtestkit' ) );
} elseif ( $variant_is_shadow ) {
$add_check( 'version_b', 'attention', __( 'Version B shadow link may need checking', 'abtestkit' ), __( 'Version B is marked as a shadow version, but its link back to Version A could not be confirmed.', 'abtestkit' ) );
} elseif ( $status === 'running' && ! in_array( (string) $variant_post->post_status, [ 'publish', 'private' ], true ) ) {
$add_check( 'version_b', 'attention', __( 'Version B may need checking', 'abtestkit' ), __( 'Version B exists, but it is not a confirmed shadow version. Preview it before relying on this test.', 'abtestkit' ) );
} else {
$add_check( 'version_b', 'good', __( 'Version B exists', 'abtestkit' ), __( 'The variant content for this test can be found.', 'abtestkit' ) );
}
}
}
if ( $kind === 'product' && $variant_post instanceof WP_Post && function_exists( 'abtestkit_is_shadow_product' ) ) {
if ( abtestkit_is_shadow_product( $variant_id ) ) {
$add_check( 'product_shadow', 'good', __( 'Shadow product is linked', 'abtestkit' ), __( 'Version B is set up as a hidden WooCommerce shadow product.', 'abtestkit' ) );
} else {
$add_check( 'product_shadow', 'attention', __( 'Shadow product link may need checking', 'abtestkit' ), __( 'Version B exists, but it does not look like a linked shadow product.', 'abtestkit' ) );
}
}
if ( in_array( $goal, [ 'click', 'clicks' ], true ) ) {
if ( empty( $targets ) ) {
$add_check( 'click_target', 'broken', __( 'Click target is missing', 'abtestkit' ), __( 'This click test does not have a saved click target.', 'abtestkit' ) );
} else {
$add_check( 'click_target', 'good', __( 'Click target is saved', 'abtestkit' ), __( 'The selected click target is stored for conversion tracking.', 'abtestkit' ) );
}
}
if ( $goal === 'destination_url' ) {
if ( empty( $targets ) ) {
$add_check( 'destination_url', 'broken', __( 'Destination URL is missing', 'abtestkit' ), __( 'This destination URL test does not have a saved destination URL.', 'abtestkit' ) );
} else {
$add_check( 'destination_url', 'good', __( 'Destination URL is saved', 'abtestkit' ), __( 'The selected destination URL is stored for conversion tracking.', 'abtestkit' ) );
}
}
if ( $goal === 'scroll_depth' ) {
$scroll_depth = abtestkit_pt_scroll_depth_for_test( $test );
$add_check(
'scroll_depth',
'good',
__( 'Scroll depth is saved', 'abtestkit' ),
sprintf(
/* translators: %d is the scroll depth percentage. */
__( 'Visitors count as converted when they reach %d%% of the page.', 'abtestkit' ),
$scroll_depth
)
);
}
if (
function_exists( 'abtestkit_events_table_has_column' )
&& abtestkit_events_table_has_column( 'time' )
&& abtestkit_events_table_has_column( 'ab_test_id' )
&& abtestkit_events_table_has_column( 'variant' )
&& abtestkit_events_table_has_column( 'event_type' )
) {
$add_check( 'tracking_table', 'good', __( 'Tracking storage is ready', 'abtestkit' ), __( 'The event table has the columns needed for impressions and conversions.', 'abtestkit' ) );
} else {
$add_check( 'tracking_table', 'broken', __( 'Tracking storage needs repair', 'abtestkit' ), __( 'The event table is missing one or more required columns.', 'abtestkit' ) );
}
if ( $total_impressions > 0 ) {
$add_check( 'impressions', 'good', __( 'Visitor data is recording', 'abtestkit' ), sprintf(
/* translators: %d is the number of recorded impressions. */
__( '%d total impressions have been recorded for this test.', 'abtestkit' ),
$total_impressions
) );
if ( $total_impressions >= 50 && ( $impressions_a === 0 || $impressions_b === 0 ) && $status === 'running' ) {
$add_check( 'traffic_split', 'attention', __( 'Only one version has visitor data', 'abtestkit' ), __( 'The test has traffic, but one version has not received visitors yet. This can happen early, but it is worth checking if it continues.', 'abtestkit' ) );
}
} else {
$age_seconds = ( $started_at > 0 ) ? max( 0, current_time( 'timestamp' ) - $started_at ) : 0;
if ( $status === 'running' && $age_seconds >= ( 2 * DAY_IN_SECONDS ) ) {
$add_check( 'impressions', 'attention', __( 'No visitor data yet', 'abtestkit' ), __( 'This test has been running for over a week, but no impressions have been recorded yet. Check the test URL and preview links if you expected traffic.', 'abtestkit' ) );
} else {
$add_check( 'impressions', 'info', __( 'Waiting for visitors', 'abtestkit' ), __( 'No impressions have been recorded yet.', 'abtestkit' ) );
}
}
if ( $total_impressions > 0 ) {
if ( $conversion_total > 0 ) {
$add_check( 'conversions', 'good', __( 'Conversion data is recording', 'abtestkit' ), __( 'At least one conversion has been recorded for this test.', 'abtestkit' ) );
} else {
$conversion_attention_threshold = max( 500, $min_imps * 5 );
if ( $total_impressions >= $conversion_attention_threshold ) {
$add_check( 'conversions', 'attention', __( 'Conversion setup may need checking', 'abtestkit' ), __( 'This test has visitor data but no conversions yet. If you expected conversions by now, check that the goal is still present on the page.', 'abtestkit' ) );
} else {
$add_check( 'conversions', 'info', __( 'Waiting for conversion data', 'abtestkit' ), __( 'This test is recording visitors, but no conversions have been recorded yet. That can be normal while traffic is building.', 'abtestkit' ) );
}
}
}
if ( $http_count > 0 ) {
$description = sprintf(
/* translators: %d is the number of HTTP visits excluded from tracking. */
__( '%d visit(s) were excluded because they came through an insecure HTTP version of the site.', 'abtestkit' ),
$http_count
);
if ( $http_last !== '' ) {
$description .= ' ' . sprintf(
/* translators: %s is a date/time string for the last excluded HTTP visit. */
__( 'Last seen: %s.', 'abtestkit' ),
$http_last
);
}
$add_check( 'http_tracking', 'attention', __( 'HTTP visits are being excluded', 'abtestkit' ), $description );
}
$label = [
'good' => __( 'Good', 'abtestkit' ),
'attention' => __( 'Needs attention', 'abtestkit' ),
'broken' => __( 'Broken', 'abtestkit' ),
][ $overall ];
$summary = [
'good' => __( 'Core setup looks ready. Keep an eye on the results as traffic builds.', 'abtestkit' ),
'attention' => __( 'The test can run, but one or more items are worth checking.', 'abtestkit' ),
'broken' => __( 'One or more required parts of this test are missing or unavailable.', 'abtestkit' ),
][ $overall ];
if ( $overall === 'good' && $raw_overall === 'attention' ) {
$summary = __( 'Core setup looks ready. An advisory warning has been acknowledged.', 'abtestkit' );
}
return [
'status' => $overall,
'raw_status' => $raw_overall,
'label' => $label,
'summary' => $summary,
'checks' => $checks,
];
}
function abtestkit_pt_timeline_days_back( string $range ) : int {
switch ( $range ) {
case 'month':
return 365;
case 'week':
return 182;
case 'day':
default:
return 45;
}
}
function abtestkit_pt_flush_test_caches( string $test_id ) : void {
$test_id = (string) $test_id;
if ( $test_id === '' ) {
return;
}
delete_transient( abtestkit_pt_stats_cache_key( $test_id ) );
delete_transient( abtestkit_pt_performance_cache_key( $test_id, 'day' ) );
delete_transient( abtestkit_pt_performance_cache_key( $test_id, 'week' ) );
delete_transient( abtestkit_pt_performance_cache_key( $test_id, 'month' ) );
}
/**
* Return lifetime HTTP-exclusion health context for multiple tests in one query.
*
* @param array $tests Page-test records.
* @return array
*/
function abtestkit_pt_http_exclusions_bulk( array $tests ) : array {
global $wpdb;
$out = [];
$test_ids = [];
foreach ( $tests as $test ) {
$test_id = is_array( $test ) && isset( $test['id'] )
? abtestkit_sanitize_test_id( (string) $test['id'] )
: '';
if ( $test_id === '' ) {
continue;
}
$test_ids[] = $test_id;
$out[ $test_id ] = [
'http_excluded_count' => 0,
'http_excluded_last' => '',
];
}
$test_ids = array_values( array_unique( $test_ids ) );
if (
empty( $test_ids )
|| ! abtestkit_events_table_has_column( 'protocol' )
|| ! abtestkit_events_table_has_column( 'excluded_reason' )
) {
return $out;
}
$table = ABTESTKIT_EVENTS_TABLE;
$placeholders = implode( ', ', array_fill( 0, count( $test_ids ), '%s' ) );
$query_args = array_merge( [ $table ], $test_ids );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
/*
* The fragment below contains generated %s tokens only. The argument
* array supplies the table and one sanitized value for every token.
*/
$wpdb->prepare(
'SELECT ab_test_id, COUNT(*) AS total, MAX(`time`) AS last_seen ' .
'FROM %i ' .
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
'WHERE ab_test_id IN (' . $placeholders . ') ' .
"AND event_type = 'protocol_warning' " .
"AND protocol = 'http' " .
"AND excluded_reason = 'http' " .
'GROUP BY ab_test_id',
$query_args
),
ARRAY_A
);
foreach ( (array) $rows as $row ) {
$test_id = isset( $row['ab_test_id'] ) ? (string) $row['ab_test_id'] : '';
if ( $test_id === '' || ! isset( $out[ $test_id ] ) ) {
continue;
}
$out[ $test_id ] = [
'http_excluded_count' => isset( $row['total'] ) ? (int) $row['total'] : 0,
'http_excluded_last' => isset( $row['last_seen'] ) ? (string) $row['last_seen'] : '',
];
}
return $out;
}
function abtestkit_pt_stats_bulk( array $tests ) : array {
global $wpdb;
$out = [];
if ( empty( $tests ) || ! is_array( $tests ) ) {
return $out;
}
$live_test_ids = [];
foreach ( $tests as $test ) {
$test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
$status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
if ( $test_id === '' ) {
continue;
}
if (
in_array( $status, [ 'winner', 'complete' ], true )
&& isset( $test['final_stats'] )
&& is_array( $test['final_stats'] )
&& isset( $test['final_stats']['A'], $test['final_stats']['B'] )
) {
$out[ $test_id ] = $test['final_stats'];
continue;
}
$cached = get_transient( abtestkit_pt_stats_cache_key( $test_id ) );
if ( is_array( $cached ) && isset( $cached['A'], $cached['B'] ) ) {
$out[ $test_id ] = $cached;
continue;
}
$live_test_ids[] = $test_id;
}
if ( empty( $live_test_ids ) ) {
return $out;
}
$table = ABTESTKIT_EVENTS_TABLE;
$live_test_ids = array_values(
array_unique(
array_filter(
array_map(
static function( $test_id ) {
return abtestkit_sanitize_test_id( (string) $test_id );
},
$live_test_ids
)
)
)
);
if ( empty( $live_test_ids ) ) {
return $out;
}
$live_test_id_placeholders = implode( ', ', array_fill( 0, count( $live_test_ids ), '%s' ) );
$query_args = array_merge( [ $table ], $live_test_ids );
if ( abtestkit_events_table_has_column( 'amount' ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
/*
* The fragment below contains generated %s tokens only. The argument
* array supplies the table and one sanitized value for every token.
*/
$wpdb->prepare(
'SELECT ab_test_id, variant, event_type, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS revenue ' .
'FROM %i ' .
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
'WHERE ab_test_id IN (' . $live_test_id_placeholders . ') ' .
"AND event_type IN ('impression','click','purchase') " .
'GROUP BY ab_test_id, variant, event_type',
$query_args
),
ARRAY_A
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
/*
* The fragment below contains generated %s tokens only. The argument
* array supplies the table and one sanitized value for every token.
*/
$wpdb->prepare(
'SELECT ab_test_id, variant, event_type, COUNT(*) AS c, 0 AS revenue ' .
'FROM %i ' .
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
'WHERE ab_test_id IN (' . $live_test_id_placeholders . ') ' .
"AND event_type IN ('impression','click','purchase') " .
'GROUP BY ab_test_id, variant, event_type',
$query_args
),
ARRAY_A
);
}
foreach ( $live_test_ids as $test_id ) {
$out[ $test_id ] = abtestkit_pt_stats_default();
}
foreach ( (array) $rows as $row ) {
$row_test_id = isset( $row['ab_test_id'] ) ? (string) $row['ab_test_id'] : '';
$variant = isset( $row['variant'] ) ? (string) $row['variant'] : '';
$type = isset( $row['event_type'] ) ? (string) $row['event_type'] : '';
$count = isset( $row['c'] ) ? (int) $row['c'] : 0;
$revenue = isset( $row['revenue'] ) ? (float) $row['revenue'] : 0.0;
if ( $row_test_id === '' || ! isset( $out[ $row_test_id ] ) ) {
continue;
}
if ( ! in_array( $variant, [ 'A', 'B' ], true ) ) {
continue;
}
if ( $type === 'impression' ) {
$out[ $row_test_id ][ $variant ]['impressions'] = $count;
} elseif ( $type === 'click' ) {
$out[ $row_test_id ][ $variant ]['clicks'] = $count;
} elseif ( $type === 'purchase' ) {
$out[ $row_test_id ][ $variant ]['purchases'] = $count;
$out[ $row_test_id ][ $variant ]['revenue'] = round( $revenue, 2 );
}
}
foreach ( $live_test_ids as $test_id ) {
foreach ( [ 'A', 'B' ] as $variant ) {
$purchases = (int) $out[ $test_id ][ $variant ]['purchases'];
$revenue = (float) $out[ $test_id ][ $variant ]['revenue'];
$out[ $test_id ][ $variant ]['revenue_per_customer'] = $purchases > 0
? round( $revenue / $purchases, 2 )
: 0.0;
}
set_transient( abtestkit_pt_stats_cache_key( $test_id ), $out[ $test_id ], 5 * MINUTE_IN_SECONDS );
}
return $out;
}
function abtestkit_pt_stats( array $test ) : array {
// If Winner/Complete and we have a snapshot, ALWAYS use it.
$status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
if ( in_array( $status, [ 'winner', 'complete' ], true )
&& isset( $test['final_stats'] )
&& is_array( $test['final_stats'] )
&& isset( $test['final_stats']['A'], $test['final_stats']['B'] )
) {
return $test['final_stats'];
}
global $wpdb;
$control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
$test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
if ( $control_id <= 0 || $test_id === '' ) {
return abtestkit_pt_stats_default();
}
$cache_key = abtestkit_pt_stats_cache_key( $test_id );
$cached = get_transient( $cache_key );
if ( is_array( $cached ) && isset( $cached['A'], $cached['B'] ) ) {
return $cached;
}
$table = ABTESTKIT_EVENTS_TABLE;
if ( abtestkit_events_table_has_column( 'amount' ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT variant, event_type, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS revenue ' .
'FROM %i ' .
'WHERE ab_test_id = %s ' .
"AND event_type IN ('impression','click','purchase') " .
'GROUP BY variant, event_type',
$table,
$test_id
),
ARRAY_A
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT variant, event_type, COUNT(*) AS c, 0 AS revenue ' .
'FROM %i ' .
'WHERE ab_test_id = %s ' .
"AND event_type IN ('impression','click','purchase') " .
'GROUP BY variant, event_type',
$table,
$test_id
),
ARRAY_A
);
}
$out = abtestkit_pt_stats_default();
foreach ( (array) $rows as $r ) {
$v = isset( $r['variant'] ) ? (string) $r['variant'] : '';
$t = isset( $r['event_type'] ) ? (string) $r['event_type'] : '';
$c = isset( $r['c'] ) ? (int) $r['c'] : 0;
$revenue = isset( $r['revenue'] ) ? (float) $r['revenue'] : 0.0;
if ( ! isset( $out[ $v ] ) ) {
continue;
}
if ( $t === 'purchase' ) {
$out[ $v ]['purchases'] += $c;
$out[ $v ]['revenue'] += $revenue;
} elseif ( isset( $out[ $v ][ $t . 's' ] ) ) {
$out[ $v ][ $t . 's' ] += $c;
}
}
foreach ( [ 'A', 'B' ] as $variant ) {
$purchases = (int) $out[ $variant ]['purchases'];
$revenue = (float) $out[ $variant ]['revenue'];
$out[ $variant ]['revenue'] = round( $revenue, 2 );
$out[ $variant ]['revenue_per_customer'] = $purchases > 0 ? round( $revenue / $purchases, 2 ) : 0.0;
}
set_transient( $cache_key, $out, 5 * MINUTE_IN_SECONDS );
return $out;
}
/**
* Dashboard helper: ask the existing /evaluate logic who is winning.
* Returns 'A', 'B', or '' (no winner yet).
*/
/**
* Dashboard helper: return the winner for a PT test.
* If we already locked a winner, use that; otherwise fall back to /evaluate.
*/
function abtestkit_pt_winner_for_dashboard( array $test ) : string {
// If we already locked a winner, always show it.
$locked = isset( $test['winner'] ) ? sanitize_text_field( (string) $test['winner'] ) : '';
if ( in_array( $locked, [ 'A', 'B' ], true ) ) {
return $locked;
}
// IMPORTANT: do not evaluate paused/draft/complete tests.
// This prevents paused/draft tests from appearing as "Winner" in the dashboard UI.
$status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
if ( $status !== 'running' ) {
return '';
}
$req = new WP_REST_Request( 'GET', '/abtestkit/v1/evaluate' );
$req->set_param( 'abTestId', (string) ( $test['id'] ?? '' ) );
$req->set_param( 'post_id', (int) ( $test['control_id'] ?? 0 ) );
$res = abtestkit_handle_evaluate( $req );
$data = ( $res instanceof WP_REST_Response ) ? $res->get_data() : (array) $res;
$winner = isset( $data['winner'] ) ? sanitize_text_field( (string) $data['winner'] ) : '';
return in_array( $winner, [ 'A', 'B' ], true ) ? $winner : '';
}
/**
* When the Bayesian evaluator declares a winner, lock it in and pause the test.
* (This is the "automatic decision making" moment.)
*/
function abtestkit_pt_maybe_lock_winner( string $test_id ) : void {
if ( strpos( $test_id, 'pt-' ) !== 0 ) {
return;
}
$test = abtestkit_pt_get( $test_id );
if ( ! is_array( $test ) ) {
return;
}
// Only lock while running; only lock once.
if ( ( $test['status'] ?? 'paused' ) !== 'running' ) {
return;
}
// Manual mode: never auto-lock a winner.
// Guard both decision_mode and decision_rule (older/newer tests may set either).
if ( ( $test['decision_mode'] ?? 'auto' ) === 'manual' || ( $test['decision_rule'] ?? '' ) === 'manual' ) {
return;
}
$already = isset( $test['winner'] ) ? sanitize_text_field( (string) $test['winner'] ) : '';
if ( in_array( $already, [ 'A', 'B' ], true ) ) {
return;
}
$control_id = (int) ( $test['control_id'] ?? 0 );
if ( $control_id <= 0 ) {
return;
}
$req = new WP_REST_Request( 'GET', '/abtestkit/v1/evaluate' );
$req->set_param( 'abTestId', $test_id );
$req->set_param( 'post_id', $control_id );
$res = abtestkit_handle_evaluate( $req );
$data = ( $res instanceof WP_REST_Response ) ? $res->get_data() : (array) $res;
$winner = isset( $data['winner'] ) ? sanitize_text_field( (string) $data['winner'] ) : '';
if ( ! in_array( $winner, [ 'A', 'B' ], true ) ) {
return;
}
$test['winner'] = $winner;
$test['winner_at'] = time();
$test['winner_basis'] = 'statistical_decision';
$test['status'] = 'winner'; // Paused automatically, but with an explicit Winner state.
abtestkit_pt_snapshot_completed_test( $test );
abtestkit_pt_put( $test );
delete_transient( abtestkit_pt_stats_cache_key( (string) $test['id'] ) );
}
// Dashboard Render //
function abtestkit_render_page_tests_dashboard() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
' . esc_html__( 'Create A/B Test', 'abtestkit' ) . '
';
return;
}
$edit_setup = abtestkit_pt_requested_edit_setup();
if ( is_wp_error( $edit_setup ) ) {
echo '';
return;
}
abtestkit_connected_onboarding_attempt();
echo '' . esc_html( $edit_setup ? __( 'Edit A/B Test', 'abtestkit' ) : __( 'Create A/B Test', 'abtestkit' ) ) . '
';
}
/** Resolve an edit URL only for an administrator after the confirmed reset. */
function abtestkit_pt_requested_edit_setup() {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error( 'edit_unavailable', __( 'This edit session is no longer available.', 'abtestkit' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin page context.
$id = isset( $_GET['edit_test'] ) ? sanitize_text_field( wp_unslash( $_GET['edit_test'] ) ) : '';
if ( $id === '' ) {
return null;
}
$test = abtestkit_pt_get( $id );
if ( ! is_array( $test ) || empty( $test['edit_in_progress'] ) || (string) ( $test['status'] ?? '' ) !== 'paused' ) {
return new WP_Error( 'edit_unavailable', __( 'This edit session is no longer available.', 'abtestkit' ) );
}
return abtestkit_pt_edit_setup( $test );
}
function abtestkit_render_test_performance_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
echo '';
}
// Enqueue wizard assets on the Create A/B Test page
add_action( 'admin_enqueue_scripts', function( $hook ) {
// Much simpler: just check the page slug in the URL.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['page'] ) || $_GET['page'] !== 'abtestkit-pt-wizard' ) {
return;
}
if ( ! abtestkit_connected_platform_can_create_or_launch( true ) ) {
return;
}
// Load the WordPress editor stack so we can embed classic editors in the wizard
if ( function_exists( 'wp_enqueue_editor' ) ) {
wp_enqueue_editor();
} else {
wp_enqueue_script( 'wp-editor' );
}
// React + components
wp_enqueue_script( 'wp-element' );
wp_enqueue_script( 'wp-components' );
wp_enqueue_script( 'wp-api-fetch' );
wp_enqueue_style( 'wp-components' );
// Needed for wp.media() (image/gallery pickers in the Page Test wizard)
wp_enqueue_media();
// The actual wizard app
wp_enqueue_script(
'abtestkit-pt-wizard',
plugins_url( 'assets/js/pt-wizard.js', __FILE__ ),
[ 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-editor' ],
file_exists( plugin_dir_path( __FILE__ ) . 'assets/js/pt-wizard.js' )
? filemtime( plugin_dir_path( __FILE__ ) . 'assets/js/pt-wizard.js' )
: ( defined( 'ABTESTKIT_VERSION' ) ? ABTESTKIT_VERSION : '1.7.2' ),
true
);
$requested_edit = abtestkit_pt_requested_edit_setup();
wp_localize_script( 'abtestkit-pt-wizard', 'abtestkit_PT', [
'nonce' => wp_create_nonce( 'wp_rest' ),
'rest' => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
'dashboard' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
'editBase' => admin_url( 'post.php?post=' ),
'viewBase' => home_url( '/?p=' ), // legacy fallback
'postViewBase' => home_url( '/?p=' ),
'pageViewBase' => home_url( '/?page_id=' ),
'edit' => $requested_edit && ! is_wp_error( $requested_edit ) ? $requested_edit : null,
] );
} );
// Enqueue JS admin list guard
add_action( 'admin_enqueue_scripts', function ( $hook ) {
if ( ! in_array( $hook, [ 'edit.php' ], true ) ) {
return;
}
wp_enqueue_script(
'abtestkit-admin-list-guard',
plugins_url( 'assets/js/admin-list-guard.js', __FILE__ ),
[ 'jquery' ],
( defined( 'ABTESTKIT_VERSION' ) ? ABTESTKIT_VERSION : '1.7.2' ),
true
);
// Build a map of post_id => status for running tests only
$map = [];
foreach ( abtestkit_pt_all() as $t ) {
if ( empty( $t['status'] ) || $t['status'] !== 'running' ) {
continue;
}
if ( ! empty( $t['control_id'] ) ) {
$map[ (int) $t['control_id'] ] = 'running';
}
if ( ! empty( $t['variant_id'] ) ) {
$map[ (int) $t['variant_id'] ] = 'running';
}
}
wp_localize_script(
'abtestkit-admin-list-guard',
'ABTESTKIT_RUNNING_MAP',
$map
);
});
add_action( 'admin_enqueue_scripts', function( $hook ) {
if ( 'plugins.php' !== $hook ) {
return;
}
wp_enqueue_script(
'abtestkit-delete-reason',
plugins_url( 'assets/js/delete-reason.js', __FILE__ ),
[],
filemtime( plugin_dir_path( __FILE__ ) . 'assets/js/delete-reason.js' ),
true
);
wp_localize_script(
'abtestkit-delete-reason',
'ABTESTKIT_DELETE_REASON',
[
'rest' => esc_url_raw( rest_url( 'abtestkit/v1/delete-reason' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'pluginBase' => plugin_basename( __FILE__ ),
'title' => __( 'Before you deactivate abtestkit', 'abtestkit' ),
'intro' => __( 'One quick answer helps us fix the right things.', 'abtestkit' ),
'stepOneLabel' => __( 'What made you deactivate?', 'abtestkit' ),
'stepTwoLabel' => __( 'What happened?', 'abtestkit' ),
'areaLabel' => __( 'Where did this happen?', 'abtestkit' ),
'detailLabel' => __( 'What happened?', 'abtestkit' ),
'detailHelp' => __( 'One sentence is enough. What were you trying to do, and what went wrong?', 'abtestkit' ),
'snapshotText' => '',
'continueText' => __( 'Continue', 'abtestkit' ),
'backText' => __( 'Back', 'abtestkit' ),
'confirmText' => __( 'Send feedback and deactivate', 'abtestkit' ),
'skipText' => __( 'Skip feedback and deactivate', 'abtestkit' ),
'cancelText' => __( 'Keep plugin active', 'abtestkit' ),
'requiredText' => __( 'Please choose the closest reason before continuing.', 'abtestkit' ),
'placeholder' => __( 'Example: I wanted to test a form submission after someone clicks a pricing button.', 'abtestkit' ),
'reasons' => [
[
'value' => 'frontend_woocommerce_product_issues',
'title' => __( 'Frontend WooCommerce Product issues', 'abtestkit' ),
'desc' => __( 'Products, cart or checkout did not behave correctly.', 'abtestkit' ),
'followupLabel' => __( 'Which WooCommerce/product area was affected?', 'abtestkit' ),
'tags' => [
[ 'value' => 'simple_product', 'label' => __( 'Simple product', 'abtestkit' ) ],
[ 'value' => 'variable_product', 'label' => __( 'Variable product', 'abtestkit' ) ],
[ 'value' => 'subscription_product', 'label' => __( 'Subscription product', 'abtestkit' ) ],
[ 'value' => 'price_variations', 'label' => __( 'Prices/variations', 'abtestkit' ) ],
[ 'value' => 'images_gallery', 'label' => __( 'Images/gallery', 'abtestkit' ) ],
[ 'value' => 'cart_checkout', 'label' => __( 'Cart/checkout', 'abtestkit' ) ],
[ 'value' => 'product_preview', 'label' => __( 'Product preview', 'abtestkit' ) ],
],
],
[
'value' => 'frontend_pages_posts_issues',
'title' => __( 'Frontend Pages/Post issues', 'abtestkit' ),
'desc' => __( 'Click targets, form submissions or frontend display did not behave correctly.', 'abtestkit' ),
'followupLabel' => __( 'Which page/post area was affected?', 'abtestkit' ),
'tags' => [
[ 'value' => 'page_test', 'label' => __( 'Page test', 'abtestkit' ) ],
[ 'value' => 'post_test', 'label' => __( 'Post test', 'abtestkit' ) ],
[ 'value' => 'test_not_showing', 'label' => __( 'Test did not show', 'abtestkit' ) ],
[ 'value' => 'wrong_variant', 'label' => __( 'Wrong variant shown', 'abtestkit' ) ],
[ 'value' => 'preview', 'label' => __( 'Preview looked wrong', 'abtestkit' ) ],
[ 'value' => 'click_targeting', 'label' => __( 'Click targeting', 'abtestkit' ) ],
[ 'value' => 'builder_template', 'label' => __( 'Builder/template issue', 'abtestkit' ) ],
],
],
[
'value' => 'backend_wordpress_issues',
'title' => __( 'Backend issues in WordPress', 'abtestkit' ),
'desc' => __( 'The WordPress admin, setup wizard, dashboard, reports, saving, loading, or applying a winner caused problems.', 'abtestkit' ),
'followupLabel' => __( 'Which WordPress admin area was affected?', 'abtestkit' ),
'tags' => [
[ 'value' => 'create_test_wizard', 'label' => __( 'Create Test wizard', 'abtestkit' ) ],
[ 'value' => 'build_version_b', 'label' => __( 'Building Version B', 'abtestkit' ) ],
[ 'value' => 'dashboard_reports', 'label' => __( 'Dashboard/reports', 'abtestkit' ) ],
[ 'value' => 'performance_page', 'label' => __( 'Performance page', 'abtestkit' ) ],
[ 'value' => 'saving_changes', 'label' => __( 'Saving changes', 'abtestkit' ) ],
[ 'value' => 'admin_slow', 'label' => __( 'Admin felt slow', 'abtestkit' ) ],
[ 'value' => 'server_error', 'label' => __( 'Error/white screen', 'abtestkit' ) ],
],
],
[
'value' => 'missing_feature',
'title' => __( 'Missing feature', 'abtestkit' ),
'desc' => __( 'abtestkit did not yet support the testing, workflow, report, or targeting option I needed.', 'abtestkit' ),
'followupLabel' => __( 'What were you hoping abtestkit could do?', 'abtestkit' ),
'tags' => [
[ 'value' => 'heatmaps_click_maps', 'label' => __( 'Heatmaps / click maps', 'abtestkit' ) ],
[ 'value' => 'ai_test_suggestions', 'label' => __( 'AI test suggestions', 'abtestkit' ) ],
[ 'value' => 'css_javascript_tests', 'label' => __( 'CSS / JavaScript tests', 'abtestkit' ) ],
[ 'value' => 'form_lead_tracking', 'label' => __( 'Form or lead tracking', 'abtestkit' ) ],
[ 'value' => 'split_url_tests', 'label' => __( 'URL / redirect tests', 'abtestkit' ) ],
[ 'value' => 'woocommerce_options', 'label' => __( 'More WooCommerce options', 'abtestkit' ) ],
[ 'value' => 'better_reports', 'label' => __( 'Better reports', 'abtestkit' ) ],
[ 'value' => 'advanced_targeting_rules', 'label' => __( 'Advanced targeting rules', 'abtestkit' ) ],
[ 'value' => 'multi_variant_tests', 'label' => __( 'A/B/C or multi-variant tests', 'abtestkit' ) ],
[ 'value' => 'something_else', 'label' => __( 'Something else', 'abtestkit' ) ],
],
],
],
'areas' => [
[ 'value' => 'not_started', 'label' => __( 'Did not get started', 'abtestkit' ) ],
[ 'value' => 'page_test', 'label' => __( 'Page/post test', 'abtestkit' ) ],
[ 'value' => 'product_test', 'label' => __( 'Product test', 'abtestkit' ) ],
[ 'value' => 'woocommerce_revenue', 'label' => __( 'WooCommerce revenue tracking', 'abtestkit' ) ],
[ 'value' => 'click_tracking', 'label' => __( 'Click tracking', 'abtestkit' ) ],
[ 'value' => 'preview_version_b', 'label' => __( 'Version B preview/editing', 'abtestkit' ) ],
[ 'value' => 'dashboard_reports', 'label' => __( 'Dashboard/reports', 'abtestkit' ) ],
[ 'value' => 'onboarding', 'label' => __( 'Onboarding/setup', 'abtestkit' ) ],
],
]
);
} );
/**
* Get defensive currency settings for revenue reporting.
*
* Revenue rows currently store amounts without a per-order currency, so the
* performance screen uses the site's current WooCommerce reporting currency.
* Keep the saved WooCommerce options useful when the plugin is temporarily
* inactive, then preserve the historical GBP fallback for non-WooCommerce
* installs.
*
* @return array{code:string,decimals:int}
*/
function abtestkit_reporting_currency_settings(): array {
$currency_code = function_exists( 'get_woocommerce_currency' )
? (string) get_woocommerce_currency()
: (string) get_option( 'woocommerce_currency', 'GBP' );
$currency_code = strtoupper( sanitize_text_field( $currency_code ) );
$currency_code = preg_replace( '/[^A-Z0-9_-]/', '', $currency_code );
$currency_code = is_string( $currency_code ) ? substr( $currency_code, 0, 12 ) : '';
if ( $currency_code === '' ) {
$currency_code = 'GBP';
}
$decimals = function_exists( 'wc_get_price_decimals' )
? (int) wc_get_price_decimals()
: (int) get_option( 'woocommerce_price_num_decimals', 2 );
return [
'code' => $currency_code,
'decimals' => max( 0, min( 20, $decimals ) ),
];
}
// Enqueue dashboard/performance admin apps
add_action( 'admin_enqueue_scripts', function( $hook ) {
$is_dashboard_page = (
$hook === 'toplevel_page_abtestkit-dashboard'
|| $hook === 'abtestkit-dashboard_page_abtestkit-dashboard'
);
$is_performance_page = ( $hook === 'admin_page_abtestkit-test-performance' );
if ( ! $is_dashboard_page && ! $is_performance_page ) {
return;
}
// Core deps from WordPress
wp_enqueue_script( 'wp-element' );
wp_enqueue_script( 'wp-components' );
wp_enqueue_script( 'wp-api-fetch' );
wp_enqueue_style( 'wp-components' );
if ( $is_dashboard_page ) {
wp_enqueue_script(
'abtestkit-dashboard-app',
plugins_url( 'assets/js/dashboard.js', __FILE__ ),
[ 'wp-element', 'wp-components', 'wp-api-fetch' ],
filemtime( plugin_dir_path( __FILE__ ) . 'assets/js/dashboard.js' ),
true
);
$conflict_banner = '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['error'] ) && $_GET['error'] === 'conflict_running' ) {
$conflicts = [];
if ( ! empty( $_GET['conflicts'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$conflicts = array_filter(
array_map(
'sanitize_text_field',
explode( ',', (string) wp_unslash( $_GET['conflicts'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
)
);
}
$base = __( 'This page is already in a running test, or a reusable section test is causing a conflict.', 'abtestkit' );
if ( ! empty( $conflicts ) ) {
$base .= ' ' . sprintf(
/* translators: %s is a comma-separated list of conflicting test IDs. */
__( 'Conflicting test ID(s): %s.', 'abtestkit' ),
implode( ', ', $conflicts )
);
}
$conflict_banner = $base;
}
if ( $conflict_banner === '' ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only dashboard notice.
$error_code = isset( $_GET['error'] ) ? sanitize_key( wp_unslash( $_GET['error'] ) ) : '';
$edit_errors = [
'edit_unavailable' => __( 'This test cannot be edited. Refresh the dashboard and try again.', 'abtestkit' ),
'edit_missing_control' => __( 'Version A is no longer available, so this test cannot be edited.', 'abtestkit' ),
'edit_missing_variant' => __( 'Version B is no longer available, so this test cannot be edited.', 'abtestkit' ),
'edit_invalid_variant' => __( 'Version B does not belong to this test.', 'abtestkit' ),
'edit_missing_javascript' => __( 'The saved JavaScript is unavailable, so this test cannot be edited.', 'abtestkit' ),
'edit_reset_failed' => __( 'The test was paused, but its results could not be reset. Nothing else was changed.', 'abtestkit' ),
'edit_in_progress' => __( 'Finish or leave the open edit session before starting this test.', 'abtestkit' ),
];
$conflict_banner = $edit_errors[ $error_code ] ?? '';
}
wp_localize_script( 'abtestkit-dashboard-app', 'abtestkitDashboard', [
'nonce' => wp_create_nonce( 'wp_rest' ),
'rest' => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
'createUrl' => abtestkit_connected_platform_can_create_or_launch() ? admin_url( 'admin.php?page=abtestkit-pt-wizard' ) : abtestkit_hosted_admin_url(),
'canCreateOrLaunch' => abtestkit_connected_platform_can_create_or_launch(),
'connectionUrl' => abtestkit_hosted_admin_url(),
'adminAction' => admin_url( 'admin-post.php?action=abtestkit_pt_action' ),
'adminNonce' => wp_create_nonce( 'abtestkit_pt_action' ),
'editUrl' => admin_url( 'admin.php?page=abtestkit-pt-wizard' ),
'conflictBanner' => $conflict_banner,
] );
}
if ( $is_performance_page ) {
wp_enqueue_script(
'abtestkit-test-performance',
plugins_url( 'assets/js/test-performance.js', __FILE__ ),
[ 'wp-element', 'wp-components', 'wp-api-fetch' ],
filemtime( plugin_dir_path( __FILE__ ) . 'assets/js/test-performance.js' ),
true
);
$performance_test_id = '';
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only admin page context for this screen.
if ( isset( $_GET['test_id'] ) ) {
$performance_test_id = sanitize_text_field( wp_unslash( $_GET['test_id'] ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$currency_settings = abtestkit_reporting_currency_settings();
wp_localize_script( 'abtestkit-test-performance', 'abtestkitTestPerformance', [
'nonce' => wp_create_nonce( 'wp_rest' ),
'rest' => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
'testId' => $performance_test_id,
'dashboardUrl' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
'adminAction' => admin_url( 'admin-post.php?action=abtestkit_pt_action' ),
'adminNonce' => wp_create_nonce( 'abtestkit_pt_action' ),
'editUrl' => admin_url( 'admin.php?page=abtestkit-pt-wizard' ),
'currencyCode' => $currency_settings['code'],
'currencyDecimals' => $currency_settings['decimals'],
'canCreateOrLaunch' => abtestkit_connected_platform_can_create_or_launch(),
'connectionUrl' => abtestkit_hosted_admin_url(),
] );
}
} );
function abtestkit_enqueue_admin_assets( $hook ) {
// Only load on your AB Test Kit wizard page or post type page
if ( strpos( $hook, 'abtestkit' ) === false ) {
return;
}
wp_enqueue_style(
'abtestkit-admin',
plugin_dir_url( __FILE__ ) . 'assets/css/admin.css',
[],
filemtime( plugin_dir_path( __FILE__ ) . 'assets/css/admin.css' )
);
}
add_action( 'admin_enqueue_scripts', 'abtestkit_enqueue_admin_assets' );
// ───────────────────────────────────────────────────────────
// Page/Post draft shadow helpers
// Keep these above the frontend assignment hook because Version B
// page/post shadow rendering is activated during template_redirect.
// ───────────────────────────────────────────────────────────
if ( ! function_exists( 'abtestkit_pt_page_shadow_ctx_get' ) ) {
function abtestkit_pt_page_shadow_ctx_get() : array {
$ctx = $GLOBALS['abtestkit_pt_page_shadow_ctx'] ?? null;
return is_array( $ctx ) ? $ctx : [];
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_ctx_set' ) ) {
function abtestkit_pt_page_shadow_ctx_set( int $control_id, int $shadow_id, array $test ) : void {
$GLOBALS['abtestkit_pt_page_shadow_ctx'] = [
'control_id' => $control_id,
'shadow_id' => $shadow_id,
'test_id' => isset( $test['id'] ) ? (string) $test['id'] : '',
];
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_get_post_metadata' ) ) {
function abtestkit_pt_page_shadow_get_post_metadata( $value, $object_id, $meta_key, $single ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return $value;
}
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $value;
}
$control_id = (int) $ctx['control_id'];
$shadow_id = (int) $ctx['shadow_id'];
$pid = (int) $object_id;
if ( $control_id <= 0 || $shadow_id <= 0 || $control_id === $shadow_id || $pid !== $control_id ) {
return $value;
}
static $guard = false;
if ( $guard ) {
return $value;
}
// Never shadow these housekeeping/meta relationship keys.
$protected = [
'_abtestkit_shadow',
'_abtestkit_shadow_of',
'_abtestkit_variant_in_use',
'_abtestkit_variant_of',
'_abtestkit_variant_test_id',
];
if ( ! is_string( $meta_key ) || $meta_key === '' ) {
$guard = true;
$shadow_all = get_post_meta( $shadow_id );
$control_all = get_post_meta( $control_id );
$guard = false;
if ( ! is_array( $shadow_all ) ) {
return $value;
}
foreach ( $protected as $k ) {
if ( isset( $control_all[ $k ] ) ) {
$shadow_all[ $k ] = $control_all[ $k ];
} else {
unset( $shadow_all[ $k ] );
}
}
return $shadow_all;
}
if ( in_array( $meta_key, $protected, true ) ) {
return $value;
}
$guard = true;
$shadow_val = get_post_meta( $shadow_id, $meta_key, $single );
$guard = false;
if ( $single ) {
if ( $shadow_val === '' || $shadow_val === null ) {
return $value;
}
return [ $shadow_val ];
}
return ( is_array( $shadow_val ) && ! empty( $shadow_val ) ) ? $shadow_val : $value;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_acf_pre_load_post_id' ) ) {
function abtestkit_pt_page_shadow_acf_pre_load_post_id( $null, $post_id ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return $null;
}
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $null;
}
$control_id = (int) $ctx['control_id'];
$shadow_id = (int) $ctx['shadow_id'];
if ( $control_id <= 0 || $shadow_id <= 0 || $control_id === $shadow_id ) {
return $null;
}
$pid = 0;
if ( is_numeric( $post_id ) ) {
$pid = (int) $post_id;
} elseif ( is_string( $post_id ) && preg_match( '/_(\d+)$/', $post_id, $m ) ) {
$pid = (int) $m[1];
}
if ( $pid !== $control_id ) {
return $null;
}
return $shadow_id;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_the_title' ) ) {
function abtestkit_pt_page_shadow_the_title( $title, $post_id ) {
if ( is_admin() ) {
return $title;
}
if ( ! is_singular( [ 'page', 'post' ] ) ) {
return $title;
}
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $title;
}
if ( (int) $post_id !== (int) $ctx['control_id'] ) {
return $title;
}
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_title ) && $shadow_post->post_title !== '' ) {
return $shadow_post->post_title;
}
return $title;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_get_the_excerpt' ) ) {
function abtestkit_pt_page_shadow_get_the_excerpt( $excerpt, $post ) {
if ( is_admin() ) {
return $excerpt;
}
if ( ! is_singular( [ 'page', 'post' ] ) ) {
return $excerpt;
}
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $excerpt;
}
$pid = 0;
if ( $post instanceof WP_Post ) {
$pid = (int) $post->ID;
} elseif ( is_numeric( $post ) ) {
$pid = (int) $post;
} else {
$pid = (int) get_the_ID();
}
if ( $pid !== (int) $ctx['control_id'] ) {
return $excerpt;
}
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_excerpt ) && $shadow_post->post_excerpt !== '' ) {
return $shadow_post->post_excerpt;
}
return $excerpt;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_the_content' ) ) {
function abtestkit_pt_page_shadow_the_content( $content ) {
if ( is_admin() ) {
return $content;
}
if ( ! is_singular( [ 'page', 'post' ] ) ) {
return $content;
}
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $content;
}
$current_id = (int) get_the_ID();
if ( $current_id !== (int) $ctx['control_id'] ) {
return $content;
}
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_content ) ) {
return $shadow_post->post_content;
}
return $content;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_activate' ) ) {
function abtestkit_pt_page_shadow_activate() : void {
static $did = false;
if ( $did ) {
return;
}
if ( is_admin() || is_feed() || is_embed() ) {
return;
}
if ( ! is_singular( [ 'page', 'post' ] ) ) {
return;
}
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return;
}
$did = true;
add_filter( 'get_post_metadata', 'abtestkit_pt_page_shadow_get_post_metadata', 9999, 4 );
add_filter( 'acf/pre_load_post_id', 'abtestkit_pt_page_shadow_acf_pre_load_post_id', 9999, 2 );
add_filter( 'the_title', 'abtestkit_pt_page_shadow_the_title', 9999, 2 );
add_filter( 'get_the_excerpt', 'abtestkit_pt_page_shadow_get_the_excerpt', 9999, 2 );
// Priority 1 lets normal WP content filters still render blocks/shortcodes afterwards.
add_filter( 'the_content', 'abtestkit_pt_page_shadow_the_content', 1 );
}
}
// ── Assignment + redirect + server-side impression logging ───────────────────
if ( ! function_exists( 'abtestkit_pt_redirect_url_with_request_query' ) ) {
/**
* Carry public request query parameters across a split-page redirect.
*
* Campaign and attribution parameters belong on whichever published page
* the visitor is assigned. abtestkit's own preview/routing parameters must
* not leak into the live destination or influence a second request.
*/
function abtestkit_pt_redirect_url_with_request_query( string $destination_url ): string {
if ( $destination_url === '' ) {
return $destination_url;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only public query parameters are sanitised and carried to another local permalink; wp_safe_redirect validates the result.
$query_args = map_deep( wp_unslash( $_GET ), 'sanitize_text_field' );
if ( empty( $query_args ) || ! is_array( $query_args ) ) {
return $destination_url;
}
foreach ( array_keys( $query_args ) as $query_key ) {
if ( 0 === stripos( (string) $query_key, 'abtestkit_' ) ) {
unset( $query_args[ $query_key ] );
}
}
return empty( $query_args )
? $destination_url
: add_query_arg( urlencode_deep( $query_args ), $destination_url );
}
}
add_action( 'template_redirect', function () {
if ( abtestkit_is_custom_css_picker_preview_request() ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$preview_flag = isset( $_GET['abtestkit_preview'] )
? sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_preview'] ) )
: '';
$raw_force = isset( $_GET['abtestkit_force'] )
? strtoupper( sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_force'] ) ) )
: '';
$preview_test_id = isset( $_GET['abtestkit_preview_test'] )
? abtestkit_sanitize_test_id( sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_preview_test'] ) ) )
: '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$is_preview = ( $preview_flag === '1' );
$force_variant = ( $is_preview && in_array( $raw_force, [ 'A', 'B' ], true ) ) ? $raw_force : '';
// Exempt viewers (admins etc): never enter normal live assignment/tracking.
// Explicit forced previews are allowed so the wizard/dashboard can preview Version A/B.
if ( $force_variant === '' && abtestkit_is_exempt_viewer() ) {
return;
}
// No tracking in admin, feeds, embeds, or non-singular views.
if ( is_admin() || is_feed() || is_embed() ) {
return;
}
if ( ! is_singular() ) {
return;
}
$post_id = (int) get_queried_object_id();
$post_type = get_post_type( $post_id );
if ( ! $post_id || ! $post_type ) {
return;
}
// See if this page/product belongs to a Page Test.
// Forced previews must be able to resolve paused/complete tests too.
if ( $force_variant !== '' ) {
$test = null;
$role = '';
if ( $preview_test_id !== '' ) {
$preview_test = abtestkit_pt_get( $preview_test_id );
$preview_kind = is_array( $preview_test ) ? (string) ( $preview_test['kind'] ?? '' ) : '';
if (
is_array( $preview_test )
&& in_array( $preview_kind, [ 'custom_css', 'custom_html', 'custom_javascript' ], true )
&& (string) ( $preview_test['status'] ?? 'draft' ) !== 'draft'
&& (int) ( $preview_test['control_id'] ?? 0 ) === $post_id
) {
$test = $preview_test;
$role = 'control';
}
}
if ( ! $test ) {
[ $test, $role ] = abtestkit_pt_find_by_post_any_status( $post_id );
}
} else {
[ $test, $role ] = abtestkit_pt_find_by_post( $post_id );
}
if ( ! $test ) {
return;
}
// Product tests keep a single URL and use "virtual B" overrides instead of redirects.
$is_product_test = ( $post_type === 'product' && ( $test['kind'] ?? '' ) === 'product' );
$cookie_name = 'abtestkit_pt_' . (string) $test['id'];
$ttl = max( 1, (int) ( $test['cookie_ttl_days'] ?? 30 ) );
$split = max( 0, min( 100, (int) ( $test['split'] ?? 50 ) ) );
/**
* Running full-page tests neve cache.
* Otherwise a cached 302 or cached HTML page can pin everyone to one variant.
*/
// These are standard interoperability constants recognised by major caching/minification plugins.
// They must use their canonical names and must not be plugin-prefixed.
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true );
}
if ( ! defined( 'DONOTCACHEDB' ) ) {
define( 'DONOTCACHEDB', true );
}
if ( ! defined( 'DONOTCACHEOBJECT' ) ) {
define( 'DONOTCACHEOBJECT', true );
}
if ( ! defined( 'DONOTMINIFY' ) ) {
define( 'DONOTMINIFY', true );
}
// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
if ( ! headers_sent() ) {
nocache_headers();
header( 'Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0', true );
header( 'Pragma: no-cache', true );
header( 'Expires: Wed, 11 Jan 1984 05:00:00 GMT', true );
header( 'Surrogate-Control: no-store', true );
header( 'Vary: Cookie', false );
}
/**
* HTTP must never:
* - assign a live variant
* - bounce into the HTTPS test flow
* - write an impression/click row
*
* Force control for this request and stop here.
*/
if ( ! abtestkit_request_is_real_https() ) {
$authenticated_custom_preview = (
$force_variant !== ''
&& $preview_test_id !== ''
&& is_user_logged_in()
&& current_user_can( 'manage_options' )
&& in_array( (string) ( $test['kind'] ?? '' ), [ 'custom_css', 'custom_html', 'custom_javascript' ], true )
&& (string) ( $test['id'] ?? '' ) === $preview_test_id
);
$GLOBALS['abtestkit_current_pt_assignment'] = [
'test' => $test,
'variant' => $authenticated_custom_preview ? $force_variant : 'A',
'role' => $role,
'post_id' => $post_id,
'protocol' => 'http',
'no_track' => true,
'forced_a' => $authenticated_custom_preview ? ( $force_variant === 'A' ) : true,
'forced_b' => $authenticated_custom_preview && $force_variant === 'B',
];
if ( ! $is_product_test && $role === 'variant' && ! $is_preview ) {
$control_http_url = set_url_scheme( get_permalink( (int) $test['control_id'] ), 'http' );
wp_safe_redirect( abtestkit_pt_redirect_url_with_request_query( $control_http_url ), 302 );
exit;
}
return;
}
/**
* HTTPS only:
* if someone lands directly on the Version B URL for a PAGE/POST test,
* route them via the control URL so assignment happens first.
*/
if ( ! $is_product_test && $role === 'variant' && ! $is_preview ) {
$assigned_raw = '';
if ( isset( $_COOKIE[ $cookie_name ] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$assigned_raw = wp_unslash( $_COOKIE[ $cookie_name ] );
}
$assigned = ( '' !== $assigned_raw ) ? sanitize_text_field( $assigned_raw ) : '';
if ( 'A' !== $assigned && 'B' !== $assigned ) {
wp_safe_redirect( abtestkit_pt_redirect_url_with_request_query( get_permalink( (int) $test['control_id'] ) ), 302 );
exit;
}
}
// Exempt viewers (admins etc): always see Version A, never counted,
// UNLESS this is an explicit preview force from the wizard/dashboard.
if ( $force_variant === '' && abtestkit_is_exempt_viewer() ) {
if ( $role === 'variant' && ! $is_product_test ) {
wp_safe_redirect( abtestkit_pt_redirect_url_with_request_query( get_permalink( (int) $test['control_id'] ) ), 302 );
exit;
}
return;
}
// Assignment: explicit preview force → cookie → else random by split.
$assigned = '';
if ( $force_variant !== '' ) {
$assigned = $force_variant;
} else {
$assigned = abtestkit_safe_get_cookie_value( $cookie_name );
}
// Only accept valid values; otherwise assign and persist it safely.
if ( 'A' !== $assigned && 'B' !== $assigned ) {
$assigned = ( wp_rand( 1, 100 ) <= (int) $split ) ? 'B' : 'A';
abtestkit_safe_set_cookie(
$cookie_name,
$assigned,
time() + ( (int) $ttl ) * DAY_IN_SECONDS
);
// Keep this same request aligned immediately.
$_COOKIE[ $cookie_name ] = $assigned;
}
// Expose current assignment globally so other hooks (Woo overrides) can read it.
$GLOBALS['abtestkit_current_pt_assignment'] = [
'test' => $test,
'variant' => $assigned,
'role' => $role,
'post_id' => $post_id,
'protocol' => 'https',
'no_track' => false,
'forced_a' => ( $force_variant === 'A' ),
'forced_b' => ( $force_variant === 'B' ),
];
$uses_single_url_shadow =
( isset( $test['kind'] ) && in_array( (string) $test['kind'], [ 'custom_css', 'custom_html', 'custom_javascript' ], true ) )
|| $is_product_test
|| (
in_array( $post_type, [ 'page', 'post' ], true )
&& ! empty( $test['variant_id'] )
&& abtestkit_is_shadow_variant( (int) $test['variant_id'] )
);
// Redirect to the correct variant if the landing page mismatches assignment.
// Products and draft shadow page/post tests keep a single public URL and shadow B onto A.
if ( ! $uses_single_url_shadow ) {
if ( $assigned === 'B' && $role === 'control' && ! empty( $test['variant_id'] ) ) {
wp_safe_redirect( abtestkit_pt_redirect_url_with_request_query( get_permalink( (int) $test['variant_id'] ) ), 302 );
exit;
}
if ( $assigned === 'A' && $role === 'variant' ) {
wp_safe_redirect( abtestkit_pt_redirect_url_with_request_query( get_permalink( (int) $test['control_id'] ) ), 302 );
exit;
}
} else {
// Page/post draft shadow mode: render B on the control URL instead of redirecting to the draft permalink.
if ( ! $is_product_test && $assigned === 'B' && $role === 'control' && ! empty( $test['variant_id'] ) ) {
abtestkit_pt_page_shadow_ctx_set(
(int) $test['control_id'],
(int) $test['variant_id'],
(array) $test
);
abtestkit_pt_page_shadow_activate();
}
// Direct visits to the draft B permalink should still bounce back to A unless previewing.
if ( ! $is_product_test && $assigned === 'A' && $role === 'variant' ) {
wp_safe_redirect( abtestkit_pt_redirect_url_with_request_query( get_permalink( (int) $test['control_id'] ) ), 302 );
exit;
}
}
}, 1 );
if ( ! function_exists( 'abtestkit_custom_css_preview_assignment' ) ) {
function abtestkit_custom_css_preview_assignment() : array {
if ( is_admin() || ! current_user_can( 'manage_options' ) ) {
return [];
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only preview token; ownership is checked against the current admin user.
$preview_flag = isset( $_GET['abtestkit_preview'] )
? sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_preview'] ) )
: '';
$token = isset( $_GET['abtestkit_custom_css_preview'] )
? sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_custom_css_preview'] ) )
: '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( $preview_flag !== '1' || $token === '' ) {
return [];
}
$data = get_transient( 'abtestkit_custom_css_preview_' . $token );
if ( ! is_array( $data ) ) {
return [];
}
$user_id = isset( $data['user_id'] ) ? (int) $data['user_id'] : 0;
if ( $user_id <= 0 || $user_id !== (int) get_current_user_id() ) {
return [];
}
$control_id = isset( $data['control_id'] ) ? absint( $data['control_id'] ) : 0;
if ( $control_id <= 0 ) {
return [];
}
$current_id = (int) get_queried_object_id();
if ( $current_id > 0 && $current_id !== $control_id ) {
return [];
}
$custom_css = isset( $data['custom_css'] ) ? abtestkit_sanitize_custom_css_input( $data['custom_css'] ) : '';
$css_markers = isset( $data['css_markers'] ) ? abtestkit_sanitize_custom_css_markers( $data['css_markers'] ) : [];
if ( $custom_css === '' && empty( $css_markers ) ) {
return [];
}
return [
'ctx' => [
'variant' => 'B',
'preview' => 1,
],
'test' => [
'id' => 'preview-' . substr( md5( $token ), 0, 12 ),
'kind' => 'custom_css',
'control_id' => $control_id,
],
'data' => [
'css_scope' => isset( $data['post_type'] ) ? sanitize_key( (string) $data['post_type'] ) : '',
'custom_css' => $custom_css,
'css_markers' => $css_markers,
],
];
}
}
if ( ! function_exists( 'abtestkit_custom_css_current_assignment' ) ) {
function abtestkit_custom_css_current_assignment() : array {
$preview_assignment = abtestkit_custom_css_preview_assignment();
if ( ! empty( $preview_assignment ) ) {
return $preview_assignment;
}
$ctx = isset( $GLOBALS['abtestkit_current_pt_assignment'] ) && is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
? $GLOBALS['abtestkit_current_pt_assignment']
: [];
$test = isset( $ctx['test'] ) && is_array( $ctx['test'] ) ? $ctx['test'] : [];
if ( empty( $test['id'] ) || ( $test['kind'] ?? '' ) !== 'custom_css' ) {
return [];
}
if ( ( $ctx['variant'] ?? '' ) !== 'B' ) {
return [];
}
return [
'ctx' => $ctx,
'test' => $test,
'data' => abtestkit_custom_css_data_for_test( $test ),
];
}
}
add_filter( 'body_class', function ( $classes ) {
$assignment = abtestkit_custom_css_current_assignment();
if ( empty( $assignment ) ) {
return $classes;
}
$classes[] = 'abtestkit-custom-css-b';
$test_id = isset( $assignment['test']['id'] ) ? sanitize_html_class( (string) $assignment['test']['id'] ) : '';
if ( $test_id !== '' ) {
$classes[] = 'abtestkit-custom-css-b-' . $test_id;
}
return array_values( array_unique( array_filter( $classes ) ) );
} );
add_action( 'wp_head', function () {
$assignment = abtestkit_custom_css_current_assignment();
if ( empty( $assignment ) || empty( $assignment['data']['custom_css'] ) ) {
return;
}
$test_id = isset( $assignment['test']['id'] ) ? sanitize_html_class( (string) $assignment['test']['id'] ) : 'custom-css';
$css = (string) $assignment['data']['custom_css'];
echo "\n" . '' . "\n";
}, 999 );
/**
* Apply optional Version B marker classes before initial body paint.
*
* Direct Custom CSS already loads in wp_head. Marker-based rules also need
* their saved classes before the first rendered frame, then incremental
* reapplication when builders or AJAX replace the target later.
*/
add_action( 'wp_head', function () {
$assignment = abtestkit_custom_css_current_assignment();
if ( empty( $assignment ) ) {
return;
}
$markers = isset( $assignment['data']['css_markers'] ) && is_array( $assignment['data']['css_markers'] )
? $assignment['data']['css_markers']
: [];
if ( empty( $markers ) ) {
return;
}
?>
0 && $current_id !== $control_id ) {
return [];
}
$html_changes = isset( $data['html_changes'] )
? abtestkit_sanitize_custom_html_changes( $data['html_changes'] )
: [];
if ( empty( $html_changes ) ) {
return [];
}
return [
'ctx' => [
'variant' => 'B',
'preview' => 1,
],
'test' => [
'id' => 'preview-' . substr( md5( $token ), 0, 12 ),
'kind' => 'custom_html',
'control_id' => $control_id,
],
'data' => [
'html_scope' => isset( $data['post_type'] ) ? sanitize_key( (string) $data['post_type'] ) : '',
'html_changes' => $html_changes,
],
];
}
}
if ( ! function_exists( 'abtestkit_custom_html_current_assignment' ) ) {
function abtestkit_custom_html_current_assignment() : array {
$preview_assignment = abtestkit_custom_html_preview_assignment();
if ( ! empty( $preview_assignment ) ) {
return $preview_assignment;
}
$ctx = isset( $GLOBALS['abtestkit_current_pt_assignment'] ) && is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
? $GLOBALS['abtestkit_current_pt_assignment']
: [];
$test = isset( $ctx['test'] ) && is_array( $ctx['test'] ) ? $ctx['test'] : [];
if ( empty( $test['id'] ) || ( $test['kind'] ?? '' ) !== 'custom_html' ) {
return [];
}
if ( ( $ctx['variant'] ?? '' ) !== 'B' ) {
return [];
}
return [
'ctx' => $ctx,
'test' => $test,
'data' => abtestkit_custom_html_data_for_test( $test ),
];
}
}
add_filter( 'body_class', function ( $classes ) {
$assignment = abtestkit_custom_html_current_assignment();
if ( empty( $assignment ) ) {
return $classes;
}
$classes[] = 'abtestkit-custom-html-b';
$test_id = isset( $assignment['test']['id'] ) ? sanitize_html_class( (string) $assignment['test']['id'] ) : '';
if ( $test_id !== '' ) {
$classes[] = 'abtestkit-custom-html-b-' . $test_id;
}
return array_values( array_unique( array_filter( $classes ) ) );
} );
/**
* Hide Custom HTML targets only while Version B is being applied.
*
* The selector text is evaluated by querySelectorAll() rather than emitted as
* CSS. This keeps malformed selectors isolated and prevents one bad selector
* from invalidating the complete anti-flicker layer. The timeout is deliberately
* short and fail-open so a runtime or third-party JavaScript error can never
* leave live content hidden.
*/
add_action( 'wp_head', function () {
$assignment = abtestkit_custom_html_current_assignment();
if ( empty( $assignment ) ) {
return;
}
$changes = isset( $assignment['data']['html_changes'] ) && is_array( $assignment['data']['html_changes'] )
? $assignment['data']['html_changes']
: [];
if ( empty( $changes ) ) {
return;
}
$selector_configs = [];
foreach ( $changes as $change ) {
$selector = isset( $change['selector'] ) ? trim( (string) $change['selector'] ) : '';
if ( $selector === '' ) {
continue;
}
$selector_configs[] = [
'selector' => $selector,
'match_mode' => ( ( $change['match_mode'] ?? 'all' ) === 'first' ) ? 'first' : 'all',
];
}
if ( empty( $selector_configs ) ) {
return;
}
$test_id = isset( $assignment['test']['id'] )
? sanitize_html_class( (string) $assignment['test']['id'] )
: 'custom-html';
$safe_test_id = preg_replace( '/[^a-zA-Z0-9_-]/', '', $test_id );
$pending_attribute = 'data-abtestkit-html-' . ( $safe_test_id ?: 'custom-html' ) . '-pending';
?>
0
);
$health_endpoint = $health_reporting_enabled
? rest_url( 'abtestkit/v1/pt/html-runtime-health' )
: '';
?>
'https',
]
);
abtestkit_pt_maybe_lock_winner( $test_id );
}
}
add_action( 'template_redirect', 'abtestkit_pt_maybe_track_destination_url_goal', 20 );
function abtestkit_pt_status_priority( string $status ) : int {
// Lower number = higher priority
if ( $status === 'running' ) return 0;
if ( $status === 'paused' ) return 1;
if ( $status === 'draft' ) return 2;
if ( $status === 'complete' ) return 9;
return 5;
}
function abtestkit_pt_pick_best_test_for_control( array $tests, int $control_id ) : ?array {
$matches = [];
foreach ( $tests as $t ) {
if ( ! is_array( $t ) ) continue;
if ( ( $t['kind'] ?? '' ) !== 'product' ) continue;
if ( (int)( $t['control_id'] ?? 0 ) !== $control_id ) continue;
$matches[] = $t;
}
if ( empty( $matches ) ) return null;
usort( $matches, function( $a, $b ) {
$pa = abtestkit_pt_status_priority( (string)($a['status'] ?? '') );
$pb = abtestkit_pt_status_priority( (string)($b['status'] ?? '') );
if ( $pa !== $pb ) {
return $pa <=> $pb;
}
$sa = (int) ( $a['started_at'] ?? 0 );
$sb = (int) ( $b['started_at'] ?? 0 );
if ( $sa !== $sb ) {
return $sb <=> $sa; // newest first
}
return strcmp(
(string)($b['id'] ?? ''),
(string)($a['id'] ?? '')
);
});
return $matches[0];
}
/**
* Helper: get the active product test (if any) and the viewer's variant
* for a given WooCommerce product.
*/
function abtestkit_get_active_product_test_for_product( $product ) : array {
if ( ! function_exists( 'wc_get_product' ) ) {
return [ null, '' ];
}
if ( abtestkit_is_custom_css_picker_preview_request() ) {
return [ null, '' ];
}
// Allow explicit preview overrides for admin/exempt viewers when using the
// dashboard preview links (abtestkit_preview=1&abtestkit_force=A|B).
$force_variant = '';
$preview_flag = '';
$raw_force = '';
// Read-only GET flags; no state is changed here.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['abtestkit_preview'] ) ) {
$preview_flag = sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_preview'] ) );
}
if ( isset( $_GET['abtestkit_force'] ) ) {
$raw_force = strtoupper( sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_force'] ) ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( $preview_flag === '1' && ( $raw_force === 'A' || $raw_force === 'B' ) ) {
$force_variant = $raw_force;
}
// If we're previewing Version B for a product *before the test exists*,
// allow passing either:
// - a temporary preview token containing overrides, or
// - an explicit shadow product ID from the wizard.
$preview_token = '';
$shadow_preview_id = 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['abtestkit_token'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$preview_token = sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_token'] ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['abtestkit_shadow_preview_id'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$shadow_preview_id = absint( wp_unslash( (string) $_GET['abtestkit_shadow_preview_id'] ) );
}
if ( $force_variant === 'B' && $preview_token ) {
$key = 'abtestkit_product_preview_' . $preview_token;
$data = get_transient( $key );
if ( is_array( $data ) && ! empty( $data['overrides'] ) ) {
// Ensure the preview token matches this product ID.
// $product can be a WC_Product OR a numeric product ID depending on hook context.
$pid = 0;
if ( $product instanceof WC_Product ) {
$pid = (int) $product->get_id();
} elseif ( is_numeric( $product ) ) {
$pid = absint( $product );
} elseif ( is_object( $product ) && method_exists( $product, 'get_id' ) ) {
$pid = (int) $product->get_id();
} else {
// Last-ditch attempt: try to normalise to a WC_Product
if ( function_exists( 'wc_get_product' ) ) {
$maybe = wc_get_product( $product );
if ( $maybe instanceof WC_Product ) {
$pid = (int) $maybe->get_id();
}
}
}
if ( $pid > 0 && isset( $data['control_id'] ) && (int) $data['control_id'] === $pid ) {
$fake_test = [
'id' => 'preview-' . $preview_token,
'status' => 'preview',
'goal' => 'add_to_cart',
'control_id' => $pid,
'variant_id' => 0,
'min_conversions' => 5,
'overrides' => (array) $data['overrides'],
'product_overrides' => (array) $data['overrides'],
];
// Return as "variant" so your existing override filters apply.
return [ $fake_test, 'B' ];
}
}
}
// Never apply product test overrides inside normal wp-admin screens
// (product list, editor, etc.) or for exempt viewers.
//
// Important: some frontend WooCommerce/theme add-to-cart flows use
// admin-ajax.php. In those requests is_admin() is true, but the request is
// still a shopper-facing cart request and must keep the visitor's A/B
// assignment so cart metadata and totals stay consistent.
$is_frontend_ajax = function_exists( 'wp_doing_ajax' ) && wp_doing_ajax();
if (
$force_variant === ''
&& (
( is_admin() && ! $is_frontend_ajax )
|| abtestkit_is_exempt_viewer()
)
) {
return [ null, '' ];
}
// Normalise to a WC_Product.
if ( $product instanceof WC_Product ) {
$wc_product = $product;
} else {
$wc_product = wc_get_product( $product );
if ( ! $wc_product ) {
return [ null, '' ];
}
}
$product_id = function_exists( 'abtestkit_pt_get_product_test_control_id' )
? abtestkit_pt_get_product_test_control_id( $wc_product )
: (int) $wc_product->get_id();
// Wizard pre-save preview: if the wizard tells us exactly which shadow product
// to use, prefer that over guessed/stored lookup.
if ( $force_variant === 'B' && $shadow_preview_id > 0 ) {
if (
function_exists( 'abtestkit_is_shadow_product' )
&& abtestkit_is_shadow_product( $shadow_preview_id )
&& (int) get_post_meta( $shadow_preview_id, '_abtestkit_shadow_of', true ) === $product_id
) {
$fake_test = [
'id' => 'preview-shadow-' . (int) $shadow_preview_id,
'status' => 'preview',
'goal' => 'add_to_cart',
'control_id' => (int) $product_id,
'variant_id' => (int) $shadow_preview_id,
'kind' => 'product',
'min_conversions' => 5,
];
return [ $fake_test, 'B' ];
}
}
// If we're forcing A/B via preview link, allow resolving paused tests too.
if ( $force_variant !== '' ) {
[ $test, $role ] = abtestkit_pt_find_by_post_any_status( $product_id );
} else {
[ $test, $role ] = abtestkit_pt_find_by_post( $product_id );
}
if ( ! $test || ( $test['kind'] ?? '' ) !== 'product' ) {
// Wizard pre-save preview: if an admin forces Version B before the test exists,
// use the last duplicated shadow product created in this wizard session.
if ( $force_variant === 'B' ) {
$shadow_id = abtestkit_pt_get_last_duplicate_for_user( $product_id, (int) get_current_user_id() );
if (
$shadow_id
&& abtestkit_is_shadow_product( (int) $shadow_id )
&& (int) get_post_meta( (int) $shadow_id, '_abtestkit_shadow_of', true ) === (int) $product_id
) {
$fake_test = [
'id' => 'preview-shadow-' . (int) $shadow_id,
'status' => 'preview',
'goal' => 'add_to_cart',
'control_id' => (int) $product_id,
'variant_id' => (int) $shadow_id,
'kind' => 'product',
'min_conversions' => 5,
];
return [ $fake_test, 'B' ];
}
}
return [ null, '' ];
}
// If we're on a preview with an explicit forced variant, honour it and bail.
if ( $force_variant === 'A' || $force_variant === 'B' ) {
return [ $test, $force_variant ];
}
// Live product tests must never assign or render Version B on HTTP.
// HTTP is diagnostics-only and always treated as control/no-track.
if ( ! abtestkit_request_is_real_https() ) {
return [ $test, 'A' ];
}
$variant = '';
// 1) Prefer the assignment that template_redirect already made for this request.
if (
isset( $GLOBALS['abtestkit_current_pt_assignment'] )
&& is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
) {
$ctx = $GLOBALS['abtestkit_current_pt_assignment'];
$ctx_test = $ctx['test'] ?? null;
$ctx_var = $ctx['variant'] ?? '';
$ctx_id = ( is_array( $ctx_test ) && isset( $ctx_test['id'] ) ) ? (string) $ctx_test['id'] : '';
if ( $ctx_id !== '' && $ctx_id === (string) $test['id'] ) {
$variant = $ctx_var;
}
}
// 2) Fallback to the cookie (archives, menus, carts, or if global isn't set).
if ( $variant !== 'A' && $variant !== 'B' ) {
$cookie_name = 'abtestkit_pt_' . (string) $test['id'];
$cookie_val = '';
if ( isset( $_COOKIE[ $cookie_name ] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$cookie_raw = wp_unslash( $_COOKIE[ $cookie_name ] );
$cookie_val = sanitize_text_field( $cookie_raw );
}
if ( $cookie_val === 'A' || $cookie_val === 'B' ) {
// Existing assignment – just reuse it.
$variant = $cookie_val;
} else {
// No cookie yet (first ever view, often from a category/archive).
// Assign now using the same split + TTL rules as template_redirect,
// and write the cookie so subsequent views stay consistent.
$ttl = max( 1, (int) ( $test['cookie_ttl_days'] ?? 30 ) );
$split = max( 0, min( 100, (int) ( $test['split'] ?? 50 ) ) );
// Same rule as template_redirect: random A/B based on "split" (chance to show B).
$assigned = ( wp_rand( 1, 100 ) <= (int) $split ) ? 'B' : 'A';
abtestkit_safe_set_cookie(
$cookie_name,
$assigned,
time() + ( (int) $ttl ) * DAY_IN_SECONDS
);
// Keep the current request in sync even if the helper had to fall back
// to Woo session because headers were already sent.
$_COOKIE[ $cookie_name ] = $assigned;
$variant = $assigned;
}
}
return [ $test, $variant ];
}
/**
* Prepare an override string exactly once:
* - decode HTML entities ([shortcode] / <div> etc)
* - normalise smart quotes
* DO NOT run wpautop/shortcodes/blocks here — let the normal filter pipeline do that.
*/
function abtestkit_prepare_override_raw( $html ) : string {
$out = (string) $html;
$charset = function_exists( 'get_bloginfo' ) ? get_bloginfo( 'charset' ) : 'UTF-8';
$out = html_entity_decode( $out, ENT_QUOTES | ENT_HTML5, $charset );
$out = str_replace(
[ "“", "”", "‘", "’" ],
[ '"', '"', "'", "'" ],
$out
);
return $out;
}
function abtestkit_render_product_override_html( $html ) : string {
$out = abtestkit_prepare_override_raw( $html );
// Render the override without using the_content, because Elementor can hijack it
// and replace our override entirely with its own template output.
if ( function_exists( 'do_blocks' ) ) {
$out = do_blocks( $out );
}
$out = wpautop( $out );
$out = shortcode_unautop( $out );
$out = do_shortcode( $out );
return $out;
}
// WooCommerce product field overrides for "virtual B" product tests.
add_action( 'plugins_loaded', function () {
// When Variant B has a real variant_id (a duplicated product / Elementor document),
// enqueue Elementor's per-post CSS for that variant so styling actually appears.
add_action( 'wp_enqueue_scripts', function() {
if ( is_admin() ) return;
if ( ! function_exists( 'is_product' ) || ! is_product() ) {
return;
}
if ( ! class_exists( '\Elementor\Plugin' ) ) {
return; // Elementor not active
}
global $post;
if ( ! ( $post instanceof WP_Post ) ) {
return;
}
$product = function_exists( 'wc_get_product' ) ? wc_get_product( $post->ID ) : null;
if ( ! $product ) {
return;
}
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
if ( ! $test || $variant !== 'B' ) {
return;
}
$variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
if ( $variant_id <= 0 ) {
return; // "virtual B" preview tokens have variant_id = 0
}
// Base Elementor frontend styles.
\Elementor\Plugin::instance()->frontend->enqueue_styles();
}, 20 );
if ( ! function_exists( 'wc_get_product' ) ) {
// WooCommerce not active – nothing to do.
return;
}
/**
* Return the shadow WC_Product for a product test (Version B), or null.
*/
function abtestkit_pt_get_shadow_product_for_test( $test ) {
if ( ! is_array( $test ) ) return null;
$vid = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
if ( $vid <= 0 ) return null;
static $cache = [];
// Use array_key_exists so null is cached too.
if ( array_key_exists( $vid, $cache ) ) {
return $cache[ $vid ];
}
if ( get_post_type( $vid ) !== 'product' ) {
$cache[ $vid ] = null;
return null;
}
$p = null;
// Fast path.
if ( function_exists( 'wc_get_product' ) ) {
$maybe = wc_get_product( $vid );
if ( $maybe instanceof WC_Product ) {
$p = $maybe;
}
}
// Fallback: explicitly include non-public statuses.
if ( ! $p && function_exists( 'wc_get_products' ) ) {
$found = wc_get_products( [
'include' => [ $vid ],
'limit' => 1,
'status' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
'return' => 'objects',
] );
if ( is_array( $found ) && ! empty( $found ) && $found[0] instanceof WC_Product ) {
$p = $found[0];
}
}
// Last resort: construct a WC_Product directly (bypasses frontend status restrictions).
if ( ! $p && class_exists( 'WC_Product_Factory' ) ) {
try {
$factory = new WC_Product_Factory();
$type = '';
if ( method_exists( $factory, 'get_product_type' ) ) {
$type = (string) $factory->get_product_type( $vid );
}
if ( $type === '' ) {
$type = 'simple';
}
$classname = 'WC_Product_Simple';
if ( method_exists( 'WC_Product_Factory', 'get_product_classname' ) ) {
$maybe_class = WC_Product_Factory::get_product_classname( $vid, $type );
if ( is_string( $maybe_class ) && $maybe_class !== '' && class_exists( $maybe_class ) ) {
$classname = $maybe_class;
}
}
$maybe = new $classname( $vid );
if ( $maybe instanceof WC_Product ) {
$p = $maybe;
}
} catch ( \Throwable $e ) {
// ignore
}
}
$cache[ $vid ] = ( $p instanceof WC_Product ) ? $p : null;
return $cache[ $vid ];
}
/**
* Shadow products should never be purchasable or visible.
*/
add_filter( 'woocommerce_is_purchasable', function( $purchasable, $product ) {
$pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : (int) $product;
if ( $pid > 0 && (int) get_post_meta( $pid, '_abtestkit_shadow', true ) === 1 ) {
return false;
}
return $purchasable;
}, 10, 2 );
add_filter( 'woocommerce_product_is_visible', function( $visible, $product_id ) {
$pid = (int) $product_id;
if ( $pid > 0 && (int) get_post_meta( $pid, '_abtestkit_shadow', true ) === 1 ) {
return false;
}
return $visible;
}, 10, 2 );
// Noindex shadow products if they ever become accessible.
add_filter( 'wp_robots', function( $robots ) {
if ( function_exists( 'is_product' ) && is_product() ) {
$pid = get_queried_object_id();
if ( $pid > 0 && (int) get_post_meta( $pid, '_abtestkit_shadow', true ) === 1 ) {
$robots['noindex'] = true;
$robots['nofollow'] = true;
}
}
return $robots;
} );
// Name/title
add_filter( 'woocommerce_product_get_name', function ( $name, $product ) {
$pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;
if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
return $name;
}
static $in = false;
if ( $in ) return $name;
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $product, $test )
: null;
$in = false;
if ( ! ( $shadow instanceof WC_Product ) ) return $name;
if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $name;
$n = $shadow->get_name( 'edit' );
return $n !== '' ? $n : $name;
}, 10, 2 );
// Descriptions
function abtestkit_pt_store_api_cart_context_start( $response, $handler, $request ) {
if ( is_object( $request ) && method_exists( $request, 'get_route' ) ) {
$route = (string) $request->get_route();
if ( preg_match( '#^/wc/store(?:/v[0-9]+)?/(?:cart|checkout|order)(?:/|$)#', $route ) ) {
$GLOBALS['abtestkit_store_api_cart_context'] = true;
}
}
return $response;
}
add_filter( 'rest_request_before_callbacks', 'abtestkit_pt_store_api_cart_context_start', 10, 3 );
function abtestkit_pt_store_api_cart_context_end( $response, $handler, $request ) {
if ( is_object( $request ) && method_exists( $request, 'get_route' ) ) {
$route = (string) $request->get_route();
if ( preg_match( '#^/wc/store(?:/v[0-9]+)?/(?:cart|checkout|order)(?:/|$)#', $route ) ) {
unset( $GLOBALS['abtestkit_store_api_cart_context'] );
}
}
return $response;
}
add_filter( 'rest_request_after_callbacks', 'abtestkit_pt_store_api_cart_context_end', 10, 3 );
function abtestkit_pt_is_cart_summary_context(): bool {
if ( ! empty( $GLOBALS['abtestkit_store_api_cart_context'] ) ) {
return true;
}
if ( function_exists( 'is_cart' ) && is_cart() ) {
return true;
}
return function_exists( 'is_checkout' ) && is_checkout();
}
add_filter( 'woocommerce_product_get_short_description', function( $val, $product ) {
$pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;
if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
return $val;
}
static $in = false;
if ( $in ) return $val;
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $product, $test )
: null;
$in = false;
if ( ! ( $shadow instanceof WC_Product ) ) return $val;
if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $val;
$v = $shadow->get_short_description( 'edit' );
if ( $v !== '' && abtestkit_pt_is_cart_summary_context() ) {
return abtestkit_commerce_cart_safe_short_description( $v );
}
return $v !== '' ? $v : $val;
}, 10, 2 );
add_filter( 'woocommerce_product_get_description', function( $val, $product ) {
$pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;
if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
return $val;
}
static $in = false;
if ( $in ) return $val;
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $product, $test )
: null;
$in = false;
if ( ! ( $shadow instanceof WC_Product ) ) return $val;
if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $val;
$v = $shadow->get_description( 'edit' );
return $v !== '' ? $v : $val;
}, 10, 2 );
// Images
$abtestkit_shadow_image_id_filter = function( $image_id, $product ) {
$pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;
if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
return $image_id;
}
static $in = false;
if ( $in ) {
return $image_id;
}
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $product, $test )
: null;
$in = false;
if ( ! ( $shadow instanceof WC_Product ) ) {
return $image_id;
}
if ( $pid > 0 && (int) $shadow->get_id() === $pid ) {
return $image_id;
}
$iid = (int) $shadow->get_image_id( 'edit' );
return $iid > 0 ? $iid : $image_id;
};
add_filter( 'woocommerce_product_get_image_id', $abtestkit_shadow_image_id_filter, 10, 2 );
add_filter( 'woocommerce_product_variation_get_image_id', $abtestkit_shadow_image_id_filter, 10, 2 );
add_filter( 'woocommerce_product_get_gallery_image_ids', function( $ids, $product ) {
$pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;
if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
return $ids;
}
static $in = false;
if ( $in ) return $ids;
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $product, $test )
: null;
$in = false;
if ( ! ( $shadow instanceof WC_Product ) ) return $ids;
if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $ids;
$g = $shadow->get_gallery_image_ids( 'edit' );
return ( is_array( $g ) && ! empty( $g ) ) ? $g : $ids;
}, 10, 2 );
// Variable-product display helpers
add_filter( 'woocommerce_get_price_html', function( $price_html, $product ) {
$pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;
if ( ! ( $product instanceof WC_Product ) ) {
return $price_html;
}
if (
! function_exists( 'abtestkit_pt_product_supports_shadow_children' )
|| ! abtestkit_pt_product_supports_shadow_children( $product )
) {
return $price_html;
}
if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
return $price_html;
}
static $in = false;
if ( $in ) return $price_html;
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $product, $test )
: null;
$in = false;
if ( ! ( $shadow instanceof WC_Product ) ) {
return $price_html;
}
if (
! function_exists( 'abtestkit_pt_product_supports_shadow_children' )
|| ! abtestkit_pt_product_supports_shadow_children( $shadow )
) {
return $price_html;
}
$shadow_html = $shadow->get_price_html();
return $shadow_html !== '' ? $shadow_html : $price_html;
}, 10, 2 );
add_filter( 'woocommerce_subscriptions_product_price_string', function( $subscription_string, $product, $include ) {
if ( ! ( $product instanceof WC_Product ) ) {
return $subscription_string;
}
$pid = (int) $product->get_id();
if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
return $subscription_string;
}
static $in = false;
if ( $in ) {
return $subscription_string;
}
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $product, $test )
: null;
$in = false;
if ( ! ( $shadow instanceof WC_Product ) ) {
return $subscription_string;
}
if ( ! class_exists( 'WC_Subscriptions_Product' ) || ! method_exists( 'WC_Subscriptions_Product', 'get_subscription_price_string' ) ) {
return $subscription_string;
}
$shadow_string = WC_Subscriptions_Product::get_subscription_price_string( $shadow, $include );
return $shadow_string !== '' ? $shadow_string : $subscription_string;
}, 10, 3 );
add_filter( 'woocommerce_available_variation', function( $data, $product, $variation ) {
if ( ! $variation instanceof WC_Product_Variation ) {
return $data;
}
$variation_id = (int) $variation->get_id();
if ( $variation_id > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $variation_id ) ) {
return $data;
}
static $in = false;
if ( $in ) return $data;
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow_variation = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $variation, $test )
: null;
$in = false;
if ( ! ( $shadow_variation instanceof WC_Product_Variation ) ) {
return $data;
}
$shadow_price = $shadow_variation->get_price( 'edit' );
$shadow_regular_price = $shadow_variation->get_regular_price( 'edit' );
$shadow_description = $shadow_variation->get_description( 'edit' );
$shadow_price_html = $shadow_variation->get_price_html();
$shadow_image_id = (int) $shadow_variation->get_image_id( 'edit' );
if ( class_exists( 'WC_Subscriptions_Product' ) && method_exists( 'WC_Subscriptions_Product', 'get_subscription_price_string' ) ) {
$subscription_price_html = WC_Subscriptions_Product::get_subscription_price_string( $shadow_variation );
if ( is_string( $subscription_price_html ) && $subscription_price_html !== '' ) {
$shadow_price_html = $subscription_price_html;
}
}
if ( $shadow_price !== '' && $shadow_price !== null ) {
$data['display_price'] = (float) $shadow_price;
}
if ( $shadow_regular_price !== '' && $shadow_regular_price !== null ) {
$data['display_regular_price'] = (float) $shadow_regular_price;
}
if ( $shadow_price_html !== '' ) {
$data['price_html'] = $shadow_price_html;
}
if ( $shadow_description !== '' ) {
$data['variation_description'] = $shadow_description;
}
foreach ( [
'_subscription_sign_up_fee' => 'subscription_sign_up_fee',
'_subscription_trial_length' => 'subscription_trial_length',
'_subscription_trial_period' => 'subscription_trial_period',
'_subscription_period_interval' => 'subscription_period_interval',
'_subscription_period' => 'subscription_period',
'_subscription_length' => 'subscription_length',
] as $meta_key => $payload_key ) {
$raw = get_post_meta( (int) $shadow_variation->get_id(), $meta_key, true );
if ( $raw === '' || $raw === null ) {
continue;
}
$data[ $payload_key ] = is_numeric( $raw ) ? (float) $raw : (string) $raw;
}
if ( $shadow_image_id > 0 && function_exists( 'wc_get_product_attachment_props' ) ) {
$props = wc_get_product_attachment_props( $shadow_image_id );
if ( is_array( $props ) ) {
$data['image_id'] = $shadow_image_id;
$data['image'] = $props;
}
}
return $data;
}, 10, 3 );
// ───────────────────────────────────────────────────────────
// Variant B "Shadow Overlay" (builder-agnostic, fast)
// - Keep REAL product (A) as the WooCommerce ID for SKU/stock/orders
// - Overlay *display* layer (meta/content/featured image/builder data/ACF/etc) from shadow product (B)
// - Enable only on single product pages AND only when visitor is Variant B
// ───────────────────────────────────────────────────────────
if ( ! function_exists( 'abtestkit_pt_shadow_ctx_get' ) ) {
function abtestkit_pt_shadow_ctx_get() : array {
$ctx = $GLOBALS['abtestkit_pt_shadow_ctx'] ?? null;
return is_array( $ctx ) ? $ctx : [];
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_ctx_set' ) ) {
function abtestkit_pt_shadow_ctx_set( int $control_id, int $shadow_id, array $test ) : void {
$GLOBALS['abtestkit_pt_shadow_ctx'] = [
'control_id' => $control_id,
'shadow_id' => $shadow_id,
'test_id' => isset( $test['id'] ) ? (string) $test['id'] : '',
];
}
}
/**
* Meta keys that must ALWAYS come from the real product (A).
* These are the "commerce truth" keys that should never be shadowed.
*/
if ( ! function_exists( 'abtestkit_pt_shadow_protected_meta_keys' ) ) {
function abtestkit_pt_shadow_protected_meta_keys() : array {
return [
'_sku',
'_manage_stock',
'_stock',
'_stock_status',
'_backorders',
'_sold_individually',
'_abtestkit_shadow',
'_abtestkit_shadow_of',
];
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_is_meta_key_protected' ) ) {
function abtestkit_pt_shadow_is_meta_key_protected( $meta_key ) : bool {
$meta_key = (string) $meta_key;
if ( $meta_key === '' ) {
return false;
}
if ( in_array( $meta_key, abtestkit_pt_shadow_protected_meta_keys(), true ) ) {
return true;
}
// Elementor/builder meta must stay attached to the real control product.
// We render the shadow product's content explicitly where needed instead of
// globally proxying these keys onto Version A.
if ( strpos( $meta_key, '_elementor' ) === 0 ) {
return true;
}
if ( strpos( $meta_key, '_oembed_' ) === 0 ) {
return true;
}
return false;
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_get_post_metadata' ) ) {
function abtestkit_pt_shadow_get_post_metadata( $value, $object_id, $meta_key, $single ) {
// Only frontend (AJAX is fine)
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return $value;
}
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $value;
}
$control_id = (int) $ctx['control_id'];
$shadow_id = (int) $ctx['shadow_id'];
// Safety: never shadow onto itself (can cause recursion / weirdness)
if ( $control_id <= 0 || $shadow_id <= 0 || $control_id === $shadow_id ) {
return $value;
}
$pid = (int) $object_id;
if ( $pid !== $control_id ) {
return $value; // only overlay the control product
}
static $guard = false;
if ( $guard ) {
return $value;
}
// If meta_key is empty, WP core is asking for the whole meta cache.
// ACF (especially repeaters/flexible) often does this, so we must return the shadow's full meta array,
// but keep "commerce truth" keys from the real product (A).
if ( ! is_string( $meta_key ) || $meta_key === '' ) {
$guard = true;
// Fetch ALL meta arrays.
$shadow_all = get_post_meta( $shadow_id );
$control_all = get_post_meta( $control_id );
$guard = false;
if ( ! is_array( $shadow_all ) ) {
return $value;
}
// Start with shadow meta, then overwrite protected keys with control meta.
$all_keys = array_unique(
array_merge(
array_keys( $shadow_all ),
array_keys( $control_all )
)
);
foreach ( $all_keys as $k ) {
if ( ! abtestkit_pt_shadow_is_meta_key_protected( $k ) ) {
continue;
}
if ( isset( $control_all[ $k ] ) ) {
$shadow_all[ $k ] = $control_all[ $k ];
} else {
unset( $shadow_all[ $k ] );
}
}
// get_post_metadata filters must return an array for full meta.
return $shadow_all;
}
// Never shadow protected runtime/meta-system keys.
if ( abtestkit_pt_shadow_is_meta_key_protected( $meta_key ) ) {
return $value;
}
// IMPORTANT: don't build our own cache here (it explodes memory).
// WP already caches post meta internally via update_meta_cache().
$guard = true;
$shadow_val = get_post_meta( $shadow_id, $meta_key, $single );
$guard = false;
// If shadow has nothing meaningful for this key, keep original behaviour.
if ( $single ) {
if ( $shadow_val === '' || $shadow_val === null ) {
return $value; // let WP fetch A normally
}
// IMPORTANT: WP core expects an ARRAY from get_post_metadata filters.
// When $single=true, core returns $check[0], so index 0 must exist.
return [ $shadow_val ];
}
return ( is_array( $shadow_val ) && ! empty( $shadow_val ) ) ? $shadow_val : $value;
}
}
/**
* ACF compatibility: force ACF to resolve field values against the shadow product ID
* when a visitor is assigned to Variant B.
*
* ACF calls: apply_filters( 'acf/pre_load_post_id', null, $post_id );
*/
if ( ! function_exists( 'abtestkit_pt_shadow_acf_pre_load_post_id' ) ) {
function abtestkit_pt_shadow_acf_pre_load_post_id( $null, $post_id ) {
// Only frontend (AJAX is fine)
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return $null;
}
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $null;
}
$control_id = (int) $ctx['control_id'];
$shadow_id = (int) $ctx['shadow_id'];
if ( $control_id <= 0 || $shadow_id <= 0 || $control_id === $shadow_id ) {
return $null;
}
$pid = 0;
if ( is_numeric( $post_id ) ) {
$pid = (int) $post_id;
} elseif ( is_string( $post_id ) && preg_match( '/_(\\d+)$/', $post_id, $m ) ) {
$pid = (int) $m[1];
}
if ( $pid !== $control_id ) {
return $null;
}
return $shadow_id;
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_the_content' ) ) {
// Keep the filter priority in one place so we can safely remove/re-add it.
if ( ! defined( 'ABTESTKIT_PT_SHADOW_CONTENT_PRIORITY' ) ) {
define( 'ABTESTKIT_PT_SHADOW_CONTENT_PRIORITY', 9999 );
}
/**
* Return a cache-busting revision token for a shadow product.
* - post_modified is usually enough
* - _abtestkit_rev is a lightweight “bump” value we can update on saves/meta edits
*/
function abtestkit_pt_shadow_rev_token( int $shadow_id ) : string {
$shadow_id = (int) $shadow_id;
$mod = (string) get_post_modified_time( 'U', true, $shadow_id );
$rev = (string) get_post_meta( $shadow_id, '_abtestkit_rev', true );
if ( $rev === '' ) {
$rev = '0';
}
return $mod . ':' . $rev;
}
function abtestkit_pt_shadow_cache_key( int $shadow_id, string $token ) : string {
// token already changes when content/meta changes
return 'abtestkit_pt_shadow_html_' . $shadow_id . '_' . md5( $token );
}
function abtestkit_pt_shadow_cache_get( string $key ) {
// Prefer object cache if present; fallback to transient.
$group = 'abtestkit';
$val = wp_cache_get( $key, $group );
if ( $val !== false && is_string( $val ) ) {
return $val;
}
$val = get_transient( $key );
if ( is_string( $val ) && $val !== '' ) {
wp_cache_set( $key, $val, $group, 3600 );
return $val;
}
return false;
}
function abtestkit_pt_shadow_cache_set( string $key, string $html ) : void {
$group = 'abtestkit';
// Keep it long; the key changes automatically when the shadow changes.
set_transient( $key, $html, 30 * DAY_IN_SECONDS );
wp_cache_set( $key, $html, $group, 3600 );
}
/**
* Render a product post's content through the normal WP pipeline.
* This lets Elementor/ACF/shortcodes run against the correct post object.
*/
function abtestkit_pt_render_product_post_content_for_display( int $post_id ) : string {
$post_obj = get_post( $post_id );
if ( ! $post_obj || empty( $post_obj->post_content ) ) {
return '';
}
$orig_post = $GLOBALS['post'] ?? null;
// Switch global post context to the requested product post.
$GLOBALS['post'] = $post_obj;
setup_postdata( $post_obj );
// Prevent recursion / double swaps.
remove_filter( 'the_content', 'abtestkit_pt_shadow_the_content', ABTESTKIT_PT_SHADOW_CONTENT_PRIORITY );
// Let Elementor/ACF/shortcodes/blocks run as they normally would for THIS post.
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Intentionally invoking core 'the_content' filters to render builder content for the product post.
$html = apply_filters( 'the_content', $post_obj->post_content );
// Restore.
add_filter( 'the_content', 'abtestkit_pt_shadow_the_content', ABTESTKIT_PT_SHADOW_CONTENT_PRIORITY );
wp_reset_postdata();
if ( $orig_post ) {
$GLOBALS['post'] = $orig_post;
}
return is_string( $html ) ? $html : '';
}
/**
* Back-compat wrapper used elsewhere in the file.
*/
function abtestkit_pt_render_shadow_content_for_display( int $shadow_id ) : string {
return abtestkit_pt_render_product_post_content_for_display( $shadow_id );
}
function abtestkit_pt_shadow_normalize_content_for_compare( string $html ) : string {
$html = preg_replace( '//', '', (string) $html );
$html = preg_replace( '/\\s+/', ' ', trim( (string) $html ) );
return is_string( $html ) ? $html : '';
}
function abtestkit_pt_get_cached_rendered_product_content( int $post_id, string $scope = 'shadow' ) : string {
$post_id = (int) $post_id;
if ( $post_id <= 0 ) {
return '';
}
$token = $scope . ':' . abtestkit_pt_shadow_rev_token( $post_id );
$cache_key = abtestkit_pt_shadow_cache_key( $post_id, $token );
$cached = abtestkit_pt_shadow_cache_get( $cache_key );
if ( is_string( $cached ) && $cached !== '' ) {
return $cached;
}
$html = abtestkit_pt_render_product_post_content_for_display( $post_id );
$html = is_string( $html ) ? trim( $html ) : '';
if ( $html !== '' ) {
abtestkit_pt_shadow_cache_set( $cache_key, $html );
}
return $html;
}
function abtestkit_pt_shadow_description_tab_callback() : void {
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['shadow_id'] ) ) {
the_content();
return;
}
$html = abtestkit_pt_get_cached_rendered_product_content( (int) $ctx['shadow_id'], 'shadow-desc' );
if ( $html !== '' ) {
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Rendered through the normal content pipeline.
return;
}
the_content();
}
function abtestkit_pt_shadow_product_tabs( $tabs ) {
if ( ! is_array( $tabs ) ) {
return $tabs;
}
if ( is_admin() ) {
return $tabs;
}
if ( ! function_exists( 'is_product' ) || ! is_product() ) {
return $tabs;
}
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $tabs;
}
if ( (int) get_the_ID() !== (int) $ctx['control_id'] ) {
return $tabs;
}
if ( isset( $tabs['description'] ) && is_array( $tabs['description'] ) ) {
$tabs['description']['callback'] = 'abtestkit_pt_shadow_description_tab_callback';
}
return $tabs;
}
/**
* Swap the final rendered product description content to Version B ONLY when
* the content currently being filtered matches the control product's own
* rendered description/content output.
*
* This avoids replacing larger template fragments on themes/builders that may
* also pass other product-page HTML through the_content().
*/
function abtestkit_pt_shadow_the_content( $content ) {
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $content;
}
if ( is_admin() ) {
return $content;
}
if ( ! function_exists( 'is_product' ) || ! is_product() ) {
return $content;
}
$control_id = (int) $ctx['control_id'];
$shadow_id = (int) $ctx['shadow_id'];
$current_id = (int) get_the_ID();
if ( $control_id <= 0 || $shadow_id <= 0 || $current_id !== $control_id ) {
return $content;
}
static $compare_cache = [];
if ( ! isset( $compare_cache[ $control_id ] ) ) {
$control_html = abtestkit_pt_get_cached_rendered_product_content( $control_id, 'control-desc' );
$compare_cache[ $control_id ] = abtestkit_pt_shadow_normalize_content_for_compare( $control_html );
}
$current_html = abtestkit_pt_shadow_normalize_content_for_compare( (string) $content );
if ( $current_html === '' || $current_html !== $compare_cache[ $control_id ] ) {
return $content;
}
$shadow_html = abtestkit_pt_get_cached_rendered_product_content( $shadow_id, 'shadow-desc' );
return $shadow_html !== '' ? $shadow_html : $content;
}
/**
* Bump a lightweight revision flag whenever a shadow product is saved.
* This avoids needing to diff meta (ACF etc.) — cache key changes automatically.
*/
add_action( 'save_post_product', function( $post_id, $post, $update ) {
$post_id = (int) $post_id;
if ( $post_id <= 0 ) {
return;
}
// Only for shadow products.
if ( function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $post_id ) ) {
update_post_meta( $post_id, '_abtestkit_rev', (string) time() );
}
}, 20, 3 );
// If ACF is installed, bump on ACF saves too (covers edge cases).
if ( function_exists( 'acf_add_action' ) ) {
add_action( 'acf/save_post', function( $post_id ) {
$pid = is_numeric( $post_id ) ? (int) $post_id : 0;
if ( $pid <= 0 ) {
return;
}
if ( function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
update_post_meta( $pid, '_abtestkit_rev', (string) time() );
}
}, 20 );
}
}
/**
* Enable Version B (shadow product) when needed.
* IMPORTANT:
* - Hook on 'wp' (earlier than template_redirect) so Elementor/meta consumers see the shadowed meta.
* - Also shadow WP fields used by Woo templates: title + excerpt.
*/
if ( ! function_exists( 'abtestkit_pt_shadow_the_title' ) ) {
function abtestkit_pt_shadow_the_title( $title, $post_id ) {
if ( is_admin() ) return $title;
if ( ! function_exists( 'is_product' ) || ! is_product() ) return $title;
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) return $title;
if ( (int) $post_id !== (int) $ctx['control_id'] ) return $title;
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_title ) && $shadow_post->post_title !== '' ) {
return $shadow_post->post_title;
}
return $title;
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_get_the_excerpt' ) ) {
function abtestkit_pt_shadow_get_the_excerpt( $excerpt, $post ) {
if ( is_admin() ) return $excerpt;
if ( ! function_exists( 'is_product' ) || ! is_product() ) return $excerpt;
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) return $excerpt;
$pid = 0;
if ( $post instanceof WP_Post ) {
$pid = (int) $post->ID;
} elseif ( is_numeric( $post ) ) {
$pid = (int) $post;
} else {
$pid = (int) get_the_ID();
}
if ( $pid !== (int) $ctx['control_id'] ) return $excerpt;
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_excerpt ) && $shadow_post->post_excerpt !== '' ) {
return $shadow_post->post_excerpt;
}
return $excerpt;
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_woocommerce_short_description' ) ) {
function abtestkit_pt_shadow_woocommerce_short_description( $desc ) {
if ( is_admin() ) return $desc;
if ( ! function_exists( 'is_product' ) || ! is_product() ) return $desc;
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['shadow_id'] ) ) return $desc;
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_excerpt ) && $shadow_post->post_excerpt !== '' ) {
return $shadow_post->post_excerpt;
}
return $desc;
}
}
if ( ! function_exists( 'abtestkit_pt_shadow_maybe_activate' ) ) {
function abtestkit_pt_shadow_maybe_activate() {
static $did = false;
if ( $did ) return;
if ( is_admin() || is_feed() || is_embed() ) return;
if ( ! function_exists( 'is_product' ) || ! is_product() ) return;
$control_id = (int) get_queried_object_id();
if ( $control_id <= 0 ) return;
// Decide variant/test (supports preview force flags too).
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $control_id );
if ( ! is_array( $test ) || ( $test['kind'] ?? '' ) !== 'product' || $variant !== 'B' ) return;
// Do NOT depend on wc_get_product() for draft shadows; use the stored ID directly.
$shadow_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
if ( $shadow_id <= 0 || $shadow_id === $control_id ) return;
abtestkit_pt_shadow_ctx_set( $control_id, $shadow_id, (array) $test );
// Register once.
$did = true;
// Shadow meta (Elementor/ACF/builder data mostly lives in post meta).
add_filter( 'get_post_metadata', 'abtestkit_pt_shadow_get_post_metadata', 9999, 4 );
// ACF: make get_field()/Elementor ACF integrations resolve against the shadow product.
add_filter( 'acf/pre_load_post_id', 'abtestkit_pt_shadow_acf_pre_load_post_id', 9999, 2 );
// Shadow WP fields used by Woo templates.
add_filter( 'the_title', 'abtestkit_pt_shadow_the_title', 9999, 2 );
add_filter( 'get_the_excerpt', 'abtestkit_pt_shadow_get_the_excerpt', 9999, 2 );
add_filter( 'woocommerce_short_description', 'abtestkit_pt_shadow_woocommerce_short_description', 1 );
// Shadow main product content/tab content (when it *is* used).
add_filter( 'the_content', 'abtestkit_pt_shadow_the_content', 9999 );
add_filter( 'woocommerce_product_tabs', 'abtestkit_pt_shadow_product_tabs', 9999 );
}
}
add_action( 'wp', 'abtestkit_pt_shadow_maybe_activate', 1 );
// ───────────────────────────────────────────────────────────
// Page/Post draft shadow overlay (single public URL like products)
// - Keep A permalink public
// - When visitor is assigned B, render the shadow draft's content/meta on A
// - Avoid redirecting live traffic to the draft B permalink
// ───────────────────────────────────────────────────────────
if ( ! function_exists( 'abtestkit_pt_page_shadow_ctx_get' ) ) {
function abtestkit_pt_page_shadow_ctx_get() : array {
$ctx = $GLOBALS['abtestkit_pt_page_shadow_ctx'] ?? null;
return is_array( $ctx ) ? $ctx : [];
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_ctx_set' ) ) {
function abtestkit_pt_page_shadow_ctx_set( int $control_id, int $shadow_id, array $test ) : void {
$GLOBALS['abtestkit_pt_page_shadow_ctx'] = [
'control_id' => $control_id,
'shadow_id' => $shadow_id,
'test_id' => isset( $test['id'] ) ? (string) $test['id'] : '',
];
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_get_post_metadata' ) ) {
function abtestkit_pt_page_shadow_get_post_metadata( $value, $object_id, $meta_key, $single ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return $value;
}
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $value;
}
$control_id = (int) $ctx['control_id'];
$shadow_id = (int) $ctx['shadow_id'];
$pid = (int) $object_id;
if ( $control_id <= 0 || $shadow_id <= 0 || $control_id === $shadow_id || $pid !== $control_id ) {
return $value;
}
static $guard = false;
if ( $guard ) {
return $value;
}
// Never shadow these housekeeping/meta relationship keys.
$protected = [
'_abtestkit_shadow',
'_abtestkit_shadow_of',
'_abtestkit_variant_in_use',
'_abtestkit_variant_of',
'_abtestkit_variant_test_id',
];
if ( ! is_string( $meta_key ) || $meta_key === '' ) {
$guard = true;
$shadow_all = get_post_meta( $shadow_id );
$control_all = get_post_meta( $control_id );
$guard = false;
if ( ! is_array( $shadow_all ) ) {
return $value;
}
foreach ( $protected as $k ) {
if ( isset( $control_all[ $k ] ) ) {
$shadow_all[ $k ] = $control_all[ $k ];
} else {
unset( $shadow_all[ $k ] );
}
}
return $shadow_all;
}
if ( in_array( $meta_key, $protected, true ) ) {
return $value;
}
$guard = true;
$shadow_val = get_post_meta( $shadow_id, $meta_key, $single );
$guard = false;
if ( $single ) {
if ( $shadow_val === '' || $shadow_val === null ) {
return $value;
}
return [ $shadow_val ];
}
return ( is_array( $shadow_val ) && ! empty( $shadow_val ) ) ? $shadow_val : $value;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_acf_pre_load_post_id' ) ) {
function abtestkit_pt_page_shadow_acf_pre_load_post_id( $null, $post_id ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return $null;
}
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
return $null;
}
$control_id = (int) $ctx['control_id'];
$shadow_id = (int) $ctx['shadow_id'];
if ( $control_id <= 0 || $shadow_id <= 0 || $control_id === $shadow_id ) {
return $null;
}
$pid = 0;
if ( is_numeric( $post_id ) ) {
$pid = (int) $post_id;
} elseif ( is_string( $post_id ) && preg_match( '/_(\\d+)$/', $post_id, $m ) ) {
$pid = (int) $m[1];
}
if ( $pid !== $control_id ) {
return $null;
}
return $shadow_id;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_the_title' ) ) {
function abtestkit_pt_page_shadow_the_title( $title, $post_id ) {
if ( is_admin() ) return $title;
if ( ! is_singular( [ 'page', 'post' ] ) ) return $title;
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) return $title;
if ( (int) $post_id !== (int) $ctx['control_id'] ) return $title;
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_title ) && $shadow_post->post_title !== '' ) {
return $shadow_post->post_title;
}
return $title;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_get_the_excerpt' ) ) {
function abtestkit_pt_page_shadow_get_the_excerpt( $excerpt, $post ) {
if ( is_admin() ) return $excerpt;
if ( ! is_singular( [ 'page', 'post' ] ) ) return $excerpt;
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) return $excerpt;
$pid = 0;
if ( $post instanceof WP_Post ) {
$pid = (int) $post->ID;
} elseif ( is_numeric( $post ) ) {
$pid = (int) $post;
} else {
$pid = (int) get_the_ID();
}
if ( $pid !== (int) $ctx['control_id'] ) return $excerpt;
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_excerpt ) && $shadow_post->post_excerpt !== '' ) {
return $shadow_post->post_excerpt;
}
return $excerpt;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_the_content' ) ) {
function abtestkit_pt_page_shadow_the_content( $content ) {
if ( is_admin() ) return $content;
if ( ! is_singular( [ 'page', 'post' ] ) ) return $content;
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) return $content;
$current_id = (int) get_the_ID();
if ( $current_id !== (int) $ctx['control_id'] ) {
return $content;
}
$shadow_post = get_post( (int) $ctx['shadow_id'] );
if ( $shadow_post && isset( $shadow_post->post_content ) ) {
return $shadow_post->post_content;
}
return $content;
}
}
if ( ! function_exists( 'abtestkit_pt_page_shadow_activate' ) ) {
function abtestkit_pt_page_shadow_activate() : void {
static $did = false;
if ( $did ) return;
if ( is_admin() || is_feed() || is_embed() ) return;
if ( ! is_singular( [ 'page', 'post' ] ) ) return;
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) return;
$did = true;
add_filter( 'get_post_metadata', 'abtestkit_pt_page_shadow_get_post_metadata', 9999, 4 );
add_filter( 'acf/pre_load_post_id', 'abtestkit_pt_page_shadow_acf_pre_load_post_id', 9999, 2 );
add_filter( 'the_title', 'abtestkit_pt_page_shadow_the_title', 9999, 2 );
add_filter( 'get_the_excerpt', 'abtestkit_pt_page_shadow_get_the_excerpt', 9999, 2 );
// Priority 1 lets normal WP content filters still render blocks/shortcodes afterwards.
add_filter( 'the_content', 'abtestkit_pt_page_shadow_the_content', 1 );
}
}
// Optional: enqueue Elementor CSS for page/post shadow variants too.
add_action( 'wp_enqueue_scripts', function() {
if ( ! is_singular( [ 'page', 'post' ] ) ) return;
$ctx = abtestkit_pt_page_shadow_ctx_get();
if ( empty( $ctx['shadow_id'] ) ) return;
$shadow_id = (int) $ctx['shadow_id'];
if ( $shadow_id <= 0 ) return;
$uploads = wp_upload_dir();
if ( empty( $uploads['basedir'] ) || empty( $uploads['baseurl'] ) ) return;
$rel = '/elementor/css/post-' . $shadow_id . '.css';
$path = rtrim( (string) $uploads['basedir'], '/' ) . $rel;
$url = rtrim( (string) $uploads['baseurl'], '/' ) . $rel;
if ( file_exists( $path ) ) {
$ver = (string) @filemtime( $path );
wp_enqueue_style( 'abtestkit-pt-page-shadow-' . $shadow_id, $url, [], $ver );
}
}, 100 );
/**
* Optional: Elementor per-post CSS file for the shadow.
*/
add_action( 'wp_enqueue_scripts', function() {
if ( ! function_exists( 'is_product' ) || ! is_product() ) return;
$ctx = abtestkit_pt_shadow_ctx_get();
if ( empty( $ctx['shadow_id'] ) ) return;
$shadow_id = (int) $ctx['shadow_id'];
if ( $shadow_id <= 0 ) return;
$uploads = wp_upload_dir();
if ( empty( $uploads['basedir'] ) || empty( $uploads['baseurl'] ) ) return;
$rel = '/elementor/css/post-' . $shadow_id . '.css';
$path = rtrim( (string) $uploads['basedir'], '/' ) . $rel;
$url = rtrim( (string) $uploads['baseurl'], '/' ) . $rel;
if ( file_exists( $path ) ) {
$ver = (string) @filemtime( $path );
wp_enqueue_style( 'abtestkit-pt-elementor-shadow-' . $shadow_id, $url, [], $ver );
}
}, 100 );
/**
* When visitor is Variant B on a product test, attach shadow product ID to the cart item.
* Product ID remains the REAL product (A), so SKU/stock/orders stay correct.
*/
add_filter( 'woocommerce_add_cart_item_data', function( $cart_item_data, $product_id, $variation_id ) {
$attributions = abtestkit_commerce_cart_item_attributions( (array) $cart_item_data );
$control_product_id = function_exists( 'abtestkit_pt_get_product_test_control_id' )
? abtestkit_pt_get_product_test_control_id( (int) $product_id )
: (int) $product_id;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $control_product_id );
if ( $test && in_array( $variant, [ 'A', 'B' ], true ) ) {
$cart_item_data['abtestkit_pt_id'] = (string) $test['id'];
$cart_item_data['abtestkit_pt_variant'] = ( $variant === 'B' ? 'B' : 'A' );
if ( $variation_id > 0 ) {
$cart_item_data['abtestkit_control_variation_id'] = (int) $variation_id;
}
if ( $variant === 'B' ) {
$shadow = abtestkit_pt_get_shadow_product_for_test( $test );
if ( $shadow ) {
$cart_item_data['abtestkit_shadow_product_id'] = (int) $shadow->get_id();
if ( $variation_id > 0 && function_exists( 'abtestkit_pt_get_matching_shadow_variation_id' ) ) {
$shadow_variation_id = abtestkit_pt_get_matching_shadow_variation_id(
(int) $variation_id,
(int) $shadow->get_id()
);
if ( $shadow_variation_id > 0 ) {
$cart_item_data['abtestkit_shadow_variation_id'] = (int) $shadow_variation_id;
}
}
}
}
if ( abtestkit_commerce_test_matches( $test, $control_product_id, $variation_id ) ) {
$attributions = abtestkit_commerce_add_attribution(
$attributions,
(string) $test['id'],
$variant,
abtestkit_audience_get_exposure_context( (string) $test['id'] )
);
}
}
// Reusable Section Tests are not tied to one product ID.
// If the visitor saw a tested shortcode section before adding to cart,
// attribute the later order/revenue to that reusable section test.
foreach ( abtestkit_pt_all() as $maybe_reusable ) {
if ( ! is_array( $maybe_reusable ) ) {
continue;
}
if ( ( $maybe_reusable['status'] ?? 'paused' ) !== 'running' ) {
continue;
}
if ( ( $maybe_reusable['kind'] ?? '' ) !== 'reusable_section' ) {
continue;
}
$test_id = isset( $maybe_reusable['id'] ) ? abtestkit_sanitize_test_id( (string) $maybe_reusable['id'] ) : '';
if ( $test_id === '' ) {
continue;
}
$cookie_name = 'abtestkit_reusable_seen_' . $test_id;
$raw = abtestkit_safe_get_cookie_value( $cookie_name );
if ( $raw === '' ) {
continue;
}
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
continue;
}
$seen_variant = isset( $decoded['variant'] ) ? sanitize_text_field( (string) $decoded['variant'] ) : '';
if ( ! in_array( $seen_variant, [ 'A', 'B' ], true ) ) {
continue;
}
if ( ! abtestkit_commerce_test_matches( $maybe_reusable, $control_product_id, $variation_id ) ) {
continue;
}
$attributions = abtestkit_commerce_add_attribution(
$attributions,
$test_id,
$seen_variant,
abtestkit_audience_get_exposure_context( $test_id )
);
}
/*
* Explicitly scoped page, post and Custom Code commerce tests use
* the same assignment cookie as their normal live runtime. Legacy
* tests without a commerce config remain on their existing client
* tracking path until they are edited and saved with a scope.
*/
foreach ( abtestkit_pt_all() as $maybe_scoped ) {
if ( ! is_array( $maybe_scoped ) ) {
continue;
}
if ( ( $maybe_scoped['status'] ?? 'paused' ) !== 'running' ) {
continue;
}
if ( in_array( (string) ( $maybe_scoped['kind'] ?? '' ), [ 'product', 'reusable_section' ], true ) ) {
continue;
}
$goal = isset( $maybe_scoped['goal'] ) ? sanitize_key( (string) $maybe_scoped['goal'] ) : '';
if ( ! in_array( $goal, [ 'add_to_cart', 'purchase' ], true ) ) {
continue;
}
if ( ! array_key_exists( 'commerce', $maybe_scoped ) ) {
continue;
}
if ( ! abtestkit_commerce_test_matches( $maybe_scoped, $control_product_id, $variation_id ) ) {
continue;
}
$test_id = isset( $maybe_scoped['id'] )
? abtestkit_sanitize_test_id( (string) $maybe_scoped['id'] )
: '';
if ( $test_id === '' ) {
continue;
}
$assigned = abtestkit_safe_get_cookie_value( 'abtestkit_pt_' . $test_id );
if ( ! in_array( $assigned, [ 'A', 'B' ], true ) ) {
continue;
}
$attributions = abtestkit_commerce_add_attribution(
$attributions,
$test_id,
$assigned,
abtestkit_audience_get_exposure_context( $test_id )
);
}
return abtestkit_commerce_set_cart_item_attributions(
$cart_item_data,
$attributions
);
}, 10, 3 );
/**
* Log Add to cart conversions only after WooCommerce accepts the cart item.
*
* WC_Cart::add_to_cart fires this action after the cart contents have changed.
* It is shared by classic, AJAX and Store API add-item flows. Browser tracking
* remains a confirmed-event compatibility fallback and is deduplicated through
* the existing click cookie.
*/
function abtestkit_log_wc_add_to_cart_conversions(
$cart_item_key,
$product_id,
$quantity,
$variation_id,
$variation,
$cart_item_data
) {
if ( abtestkit_is_exempt_viewer() || ! abtestkit_request_is_real_https() ) {
return;
}
$cart_item = is_array( $cart_item_data ) ? $cart_item_data : [];
if (
function_exists( 'WC' )
&& WC()
&& isset( WC()->cart )
&& WC()->cart
&& method_exists( WC()->cart, 'get_cart_item' )
) {
$stored_item = WC()->cart->get_cart_item( (string) $cart_item_key );
if ( is_array( $stored_item ) ) {
$cart_item = $stored_item;
}
}
$control_variation_id = absint( $variation_id );
if ( $control_variation_id <= 0 && ! empty( $cart_item['abtestkit_control_variation_id'] ) ) {
$control_variation_id = absint( $cart_item['abtestkit_control_variation_id'] );
}
$candidates = abtestkit_commerce_add_to_cart_candidates(
$cart_item,
absint( $product_id ),
$control_variation_id
);
foreach ( $candidates as $candidate ) {
$test_id = (string) $candidate['test_id'];
$variant = (string) $candidate['variant'];
$post_id = (int) $candidate['post_id'];
$ttl_days = max( 1, (int) $candidate['ttl_days'] );
$audience = ! empty( $candidate['audience'] ) && is_array( $candidate['audience'] )
? $candidate['audience']
: abtestkit_audience_get_exposure_context( $test_id );
$dedupe_cookie = abtestkit_commerce_add_to_cart_cookie_name( $test_id );
$legacy_cookie = 'abtestkit_pt_click_' . $post_id . '_' . $test_id . '_' . $variant;
if (
$dedupe_cookie === ''
|| abtestkit_safe_get_cookie_value( $dedupe_cookie ) === '1'
|| abtestkit_safe_get_cookie_value( $legacy_cookie ) === '1'
) {
continue;
}
abtestkit_safe_set_cookie(
$dedupe_cookie,
'1',
time() + ( $ttl_days * DAY_IN_SECONDS )
);
abtestkit_safe_set_cookie( $legacy_cookie, '1', 0 );
// Keep repeated hooks in the current request aligned immediately.
$_COOKIE[ $dedupe_cookie ] = '1';
$_COOKIE[ $legacy_cookie ] = '1';
abtestkit_log_event_to_db(
'click',
$post_id,
$test_id,
$variant,
[
'protocol' => 'https',
'audience' => $audience,
]
);
abtestkit_pt_maybe_lock_winner( $test_id );
}
}
add_action( 'woocommerce_add_to_cart', 'abtestkit_log_wc_add_to_cart_conversions', 20, 6 );
$abtestkit_pt_apply_runtime_price = function( $price, $product, $getter ) {
$pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;
if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
return $price;
}
static $in = false;
if ( $in ) return $price;
$in = true;
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
$shadow = ( $test && $variant === 'B' && function_exists( 'abtestkit_pt_get_shadow_product_for_runtime' ) )
? abtestkit_pt_get_shadow_product_for_runtime( $product, $test )
: null;
$in = false;
if ( ! ( $shadow instanceof WC_Product ) ) return $price;
if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $price;
if ( ! method_exists( $shadow, $getter ) ) return $price;
$p = $shadow->{$getter}( 'edit' );
return ( $p !== '' && $p !== null ) ? $p : $price;
};
add_filter( 'woocommerce_product_get_price', function( $price, $product ) use ( $abtestkit_pt_apply_runtime_price ) {
return $abtestkit_pt_apply_runtime_price( $price, $product, 'get_price' );
}, 10, 2 );
add_filter( 'woocommerce_product_variation_get_price', function( $price, $product ) use ( $abtestkit_pt_apply_runtime_price ) {
return $abtestkit_pt_apply_runtime_price( $price, $product, 'get_price' );
}, 10, 2 );
add_filter( 'woocommerce_product_get_regular_price', function( $price, $product ) use ( $abtestkit_pt_apply_runtime_price ) {
return $abtestkit_pt_apply_runtime_price( $price, $product, 'get_regular_price' );
}, 10, 2 );
add_filter( 'woocommerce_product_variation_get_regular_price', function( $price, $product ) use ( $abtestkit_pt_apply_runtime_price ) {
return $abtestkit_pt_apply_runtime_price( $price, $product, 'get_regular_price' );
}, 10, 2 );
add_filter( 'woocommerce_product_get_sale_price', function( $price, $product ) use ( $abtestkit_pt_apply_runtime_price ) {
return $abtestkit_pt_apply_runtime_price( $price, $product, 'get_sale_price' );
}, 10, 2 );
add_filter( 'woocommerce_product_variation_get_sale_price', function( $price, $product ) use ( $abtestkit_pt_apply_runtime_price ) {
return $abtestkit_pt_apply_runtime_price( $price, $product, 'get_sale_price' );
}, 10, 2 );
/**
* Resolve/display Version B product data for Woo cart items.
*/
if ( ! function_exists( 'abtestkit_pt_cart_shadow_product' ) ) {
function abtestkit_pt_cart_shadow_product( array $cart_item ) {
if ( empty( $cart_item['abtestkit_shadow_product_id'] ) || ! function_exists( 'wc_get_product' ) ) {
return null;
}
$shadow_display = function_exists( 'abtestkit_pt_get_shadow_product_for_cart_item' )
? abtestkit_pt_get_shadow_product_for_cart_item( $cart_item )
: null;
if ( $shadow_display instanceof WC_Product ) {
return $shadow_display;
}
if ( ! empty( $cart_item['abtestkit_shadow_variation_id'] ) ) {
$shadow_variation = wc_get_product( (int) $cart_item['abtestkit_shadow_variation_id'] );
if ( $shadow_variation instanceof WC_Product ) {
return $shadow_variation;
}
}
$shadow_parent = wc_get_product( (int) $cart_item['abtestkit_shadow_product_id'] );
return ( $shadow_parent instanceof WC_Product ) ? $shadow_parent : null;
}
}
if ( ! function_exists( 'abtestkit_pt_cart_shadow_price' ) ) {
function abtestkit_pt_cart_shadow_price( array $cart_item ) {
$shadow_product = abtestkit_pt_cart_shadow_product( $cart_item );
if ( ! ( $shadow_product instanceof WC_Product ) ) {
return null;
}
$price = $shadow_product->get_price( 'edit' );
if ( ( $price === '' || $price === null ) && ! empty( $cart_item['abtestkit_shadow_product_id'] ) && function_exists( 'wc_get_product' ) ) {
$shadow_parent = wc_get_product( (int) $cart_item['abtestkit_shadow_product_id'] );
if ( $shadow_parent instanceof WC_Product ) {
$price = $shadow_parent->get_price( 'edit' );
}
}
if ( $price === '' || $price === null ) {
return null;
}
return (float) wc_format_decimal( $price );
}
}
if ( ! function_exists( 'abtestkit_pt_cart_has_shadow_items' ) ) {
function abtestkit_pt_cart_has_shadow_items( $cart ): bool {
if ( ! $cart || ! method_exists( $cart, 'get_cart' ) ) {
return false;
}
foreach ( $cart->get_cart() as $cart_item ) {
if ( ! empty( $cart_item['abtestkit_shadow_product_id'] ) ) {
return true;
}
}
return false;
}
}
if ( ! function_exists( 'abtestkit_pt_force_shadow_cart_item_price' ) ) {
function abtestkit_pt_force_shadow_cart_item_price( array &$cart_item ) : bool {
if ( empty( $cart_item['abtestkit_shadow_product_id'] ) ) {
return false;
}
$b_price = abtestkit_pt_cart_shadow_price( $cart_item );
if ( $b_price === null ) {
return false;
}
if ( isset( $cart_item['data'] ) && $cart_item['data'] instanceof WC_Product ) {
$cart_item['data']->set_price( $b_price );
return true;
}
return false;
}
}
if ( ! function_exists( 'abtestkit_pt_force_shadow_cart_prices' ) ) {
function abtestkit_pt_force_shadow_cart_prices( $cart ): bool {
if ( is_admin() && ! wp_doing_ajax() ) {
return false;
}
if ( ! $cart || ! method_exists( $cart, 'get_cart' ) ) {
return false;
}
$changed = false;
foreach ( $cart->get_cart() as $cart_item ) {
if ( abtestkit_pt_force_shadow_cart_item_price( $cart_item ) ) {
$changed = true;
}
}
return $changed;
}
}
if ( ! function_exists( 'abtestkit_pt_format_shadow_cart_price' ) ) {
function abtestkit_pt_format_shadow_cart_price( array $cart_item, int $qty = 1 ) {
$shadow_product = abtestkit_pt_cart_shadow_product( $cart_item );
$b_price = abtestkit_pt_cart_shadow_price( $cart_item );
if ( ! ( $shadow_product instanceof WC_Product ) || $b_price === null ) {
return '';
}
$qty = max( 1, $qty );
$display_price = function_exists( 'wc_get_price_to_display' )
? wc_get_price_to_display( $shadow_product, [ 'qty' => $qty, 'price' => $b_price ] )
: ( $b_price * $qty );
return wc_price( $display_price );
}
}
/**
* Set the B price as soon as Woo creates/loads the cart item.
* This is the server-side fix for themes that add to cart through admin-ajax.php
* and then build fragments before the normal full-page cart path runs.
*/
add_filter( 'woocommerce_add_cart_item', function( $cart_item ) {
if ( is_array( $cart_item ) ) {
abtestkit_pt_force_shadow_cart_item_price( $cart_item );
}
return $cart_item;
}, 20, 1 );
add_filter( 'woocommerce_get_cart_item_from_session', function( $cart_item, $values, $cart_item_key ) {
if ( ! is_array( $cart_item ) || ! is_array( $values ) ) {
return $cart_item;
}
foreach ( [
'abtestkit_pt_id',
'abtestkit_pt_variant',
'abtestkit_commerce_attributions',
'abtestkit_control_variation_id',
'abtestkit_shadow_product_id',
'abtestkit_shadow_variation_id',
] as $meta_key ) {
if ( ! isset( $cart_item[ $meta_key ] ) && isset( $values[ $meta_key ] ) ) {
$cart_item[ $meta_key ] = $values[ $meta_key ];
}
}
abtestkit_pt_force_shadow_cart_item_price( $cart_item );
return $cart_item;
}, 20, 3 );
/**
* Ensure cart/checkout totals match Version B price (if B differs).
* Run early and late so theme/dynamic-pricing hooks cannot accidentally
* put the A price back onto a B-assigned shadow cart item before totals are built.
*/
$abtestkit_pt_before_calculate_totals = function( $cart ) {
abtestkit_pt_force_shadow_cart_prices( $cart );
};
add_action( 'woocommerce_before_calculate_totals', $abtestkit_pt_before_calculate_totals, 1 );
add_action( 'woocommerce_before_calculate_totals', $abtestkit_pt_before_calculate_totals, 9999 );
/**
* Keep cart/mini-cart item prices aligned even in AJAX fragment requests
* where a theme renders fragments before Woo has fully recalculated totals.
*/
add_filter( 'woocommerce_cart_item_price', function( $price_html, $cart_item, $cart_item_key ) {
if ( empty( $cart_item['abtestkit_shadow_product_id'] ) ) {
return $price_html;
}
$shadow_price_html = abtestkit_pt_format_shadow_cart_price( (array) $cart_item, 1 );
return ( $shadow_price_html !== '' ) ? $shadow_price_html : $price_html;
}, 20, 3 );
add_filter( 'woocommerce_cart_item_subtotal', function( $subtotal_html, $cart_item, $cart_item_key ) {
if ( empty( $cart_item['abtestkit_shadow_product_id'] ) ) {
return $subtotal_html;
}
$qty = isset( $cart_item['quantity'] ) ? (int) $cart_item['quantity'] : 1;
$shadow_subtotal_html = abtestkit_pt_format_shadow_cart_price( (array) $cart_item, $qty );
return ( $shadow_subtotal_html !== '' ) ? $shadow_subtotal_html : $subtotal_html;
}, 20, 3 );
/**
* Fragment robustness:
* - prepare the cart state before theme fragment filters build header totals;
* - regenerate only the standard mini-cart fragment late, because Woo renders it
* before this filter is applied;
* - only overwrite Woodmart-specific fragment keys if Woodmart already added them.
*/
add_filter( 'woocommerce_add_to_cart_fragments', function( $fragments ) {
if ( function_exists( 'WC' ) && WC()->cart && abtestkit_pt_cart_has_shadow_items( WC()->cart ) ) {
abtestkit_pt_force_shadow_cart_prices( WC()->cart );
WC()->cart->calculate_totals();
}
return $fragments;
}, 0 );
add_filter( 'woocommerce_add_to_cart_fragments', function( $fragments ) {
if ( ! function_exists( 'WC' ) || ! WC()->cart || ! abtestkit_pt_cart_has_shadow_items( WC()->cart ) ) {
return $fragments;
}
static $refreshing = false;
if ( $refreshing ) {
return $fragments;
}
$refreshing = true;
abtestkit_pt_force_shadow_cart_prices( WC()->cart );
WC()->cart->calculate_totals();
if ( function_exists( 'woocommerce_mini_cart' ) ) {
ob_start();
woocommerce_mini_cart();
$mini_cart = ob_get_clean();
if ( $mini_cart !== '' ) {
$fragments['div.widget_shopping_cart_content'] = '' . $mini_cart . '
';
}
}
if ( array_key_exists( 'span.wd-cart-number_wd', $fragments ) ) {
$count = WC()->cart->get_cart_contents_count();
$count_label = _n( 'item', 'items', $count, 'abtestkit' );
$fragments['span.wd-cart-number_wd'] = sprintf(
"\t\t%d %s\n\t\t",
(int) $count,
esc_html( $count_label )
);
}
if ( array_key_exists( 'span.wd-cart-subtotal_wd', $fragments ) ) {
$fragments['span.wd-cart-subtotal_wd'] = "\t\t" . WC()->cart->get_cart_subtotal() . "\n\t\t";
}
$refreshing = false;
return $fragments;
}, 9999 );
/**
* Display B title + thumbnail in cart/checkout.
*/
add_filter( 'woocommerce_cart_item_name', function( $name, $cart_item, $cart_item_key ) {
if ( empty( $cart_item['abtestkit_shadow_product_id'] ) ) return $name;
$shadow_display = abtestkit_pt_cart_shadow_product( (array) $cart_item );
if ( $shadow_display instanceof WC_Product ) {
$shadow_name = $shadow_display->get_name( 'edit' );
if ( $shadow_name !== '' ) {
return $shadow_name;
}
}
$shadow_id = (int) $cart_item['abtestkit_shadow_product_id'];
$t = get_the_title( $shadow_id );
return $t ? $t : $name;
}, 10, 3 );
add_filter( 'woocommerce_cart_item_thumbnail', function( $thumb, $cart_item, $cart_item_key ) {
if ( empty( $cart_item['abtestkit_shadow_product_id'] ) ) return $thumb;
if ( ! function_exists( 'wc_get_product' ) ) return $thumb;
$shadow_display = abtestkit_pt_cart_shadow_product( (array) $cart_item );
$iid = 0;
if ( $shadow_display instanceof WC_Product ) {
$iid = (int) $shadow_display->get_image_id( 'edit' );
}
if ( $iid <= 0 ) {
$shadow_parent = wc_get_product( (int) $cart_item['abtestkit_shadow_product_id'] );
if ( $shadow_parent instanceof WC_Product ) {
$iid = (int) $shadow_parent->get_image_id( 'edit' );
}
}
if ( $iid <= 0 ) return $thumb;
return wp_get_attachment_image( $iid, 'woocommerce_thumbnail' );
}, 10, 3 );
/**
* Persist variant info into the order line item so orders can be attributed back to A/B.
*/
add_action( 'woocommerce_checkout_create_order_line_item', function( $item, $cart_item_key, $values, $order ) {
if ( ! empty( $values['abtestkit_shadow_product_id'] ) ) {
$item->add_meta_data( '_abtestkit_shadow_product_id', (int) $values['abtestkit_shadow_product_id'], true );
}
if ( ! empty( $values['abtestkit_shadow_variation_id'] ) ) {
$item->add_meta_data( '_abtestkit_shadow_variation_id', (int) $values['abtestkit_shadow_variation_id'], true );
}
if ( ! empty( $values['abtestkit_control_variation_id'] ) ) {
$item->add_meta_data( '_abtestkit_control_variation_id', (int) $values['abtestkit_control_variation_id'], true );
}
if ( ! empty( $values['abtestkit_pt_id'] ) ) {
$item->add_meta_data( '_abtestkit_pt_id', (string) $values['abtestkit_pt_id'], true );
}
if ( ! empty( $values['abtestkit_pt_variant'] ) && in_array( $values['abtestkit_pt_variant'], [ 'A', 'B' ], true ) ) {
$item->add_meta_data( '_abtestkit_pt_variant', (string) $values['abtestkit_pt_variant'], true );
}
abtestkit_commerce_persist_order_item_attributions( $item, (array) $values );
}, 10, 4 );
/**
* Log one purchase event per order per test.
* Revenue is based on the matched product line total after discounts, excluding shipping.
*/
function abtestkit_log_wc_purchase_for_order( $order_id ) {
if ( ! function_exists( 'wc_get_order' ) ) {
return;
}
$order_id = absint( $order_id );
if ( $order_id <= 0 ) {
return;
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$grouped = [];
foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) {
if ( ! is_object( $item ) || ! method_exists( $item, 'get_meta' ) ) {
continue;
}
$attributions = abtestkit_commerce_order_item_attributions( $item );
if ( empty( $attributions ) ) {
continue;
}
$product_id = method_exists( $item, 'get_product_id' )
? absint( $item->get_product_id() )
: 0;
$variation_id = method_exists( $item, 'get_variation_id' )
? absint( $item->get_variation_id() )
: 0;
if ( $variation_id <= 0 ) {
$variation_id = absint( $item->get_meta( '_abtestkit_control_variation_id', true ) );
}
$line_total = 0.0;
if ( method_exists( $item, 'get_total' ) ) {
$line_total = (float) $item->get_total();
}
foreach ( $attributions as $attribution ) {
$test_id = (string) $attribution['test_id'];
$variant = (string) $attribution['variant'];
$test = abtestkit_pt_get( $test_id );
if ( ! is_array( $test ) ) {
continue;
}
$goal = isset( $test['goal'] ) ? sanitize_key( (string) $test['goal'] ) : '';
if ( $goal !== 'purchase' ) {
continue;
}
if ( ! abtestkit_commerce_test_matches( $test, $product_id, $variation_id ) ) {
continue;
}
if ( ! isset( $grouped[ $test_id ] ) ) {
$grouped[ $test_id ] = [
'variant' => $variant,
'amount' => 0.0,
'post_id' => isset( $test['control_id'] ) ? (int) $test['control_id'] : 0,
'audience' => ! empty( $attribution['audience'] ) && is_array( $attribution['audience'] )
? $attribution['audience']
: [],
];
}
// A test assignment must be stable. Do not combine contradictory
// variants into one order conversion if stored data is malformed.
if ( $grouped[ $test_id ]['variant'] !== $variant ) {
continue;
}
$grouped[ $test_id ]['amount'] += $line_total;
}
}
foreach ( $grouped as $test_id => $row ) {
$meta_key = '_abtestkit_purchase_logged_' . sanitize_key( (string) $test_id );
if ( $order->get_meta( $meta_key, true ) ) {
continue;
}
abtestkit_log_event_to_db(
'purchase',
(int) $row['post_id'],
(string) $test_id,
(string) $row['variant'],
[
'order_id' => $order_id,
'amount' => (float) $row['amount'],
'audience' => isset( $row['audience'] ) && is_array( $row['audience'] )
? $row['audience']
: [],
]
);
$order->update_meta_data( $meta_key, 1 );
}
$order->save();
}
add_action( 'woocommerce_order_status_processing', 'abtestkit_log_wc_purchase_for_order', 10, 1 );
add_action( 'woocommerce_order_status_completed', 'abtestkit_log_wc_purchase_for_order', 10, 1 );
// Show B name on order line items (emails / order received / My Account).
add_filter( 'woocommerce_order_item_name', function( $name, $item ) {
if ( ! is_object( $item ) || ! method_exists( $item, 'get_meta' ) ) return $name;
if ( ! function_exists( 'wc_get_product' ) ) return $name;
$shadow_variation_id = (int) $item->get_meta( '_abtestkit_shadow_variation_id', true );
if ( $shadow_variation_id > 0 ) {
$shadow_variation = wc_get_product( $shadow_variation_id );
if ( $shadow_variation instanceof WC_Product ) {
$shadow_name = $shadow_variation->get_name( 'edit' );
if ( $shadow_name !== '' ) {
return $shadow_name;
}
}
}
$shadow_id = (int) $item->get_meta( '_abtestkit_shadow_product_id', true );
if ( $shadow_id <= 0 ) return $name;
$shadow_product = wc_get_product( $shadow_id );
if ( $shadow_product instanceof WC_Product ) {
$shadow_name = $shadow_product->get_name( 'edit' );
if ( $shadow_name !== '' ) {
return $shadow_name;
}
}
$t = get_the_title( $shadow_id );
return $t ? $t : $name;
}, 10, 2 );
// --- Extra safety for themes that use the_title() / the_excerpt() directly on products ---
// Override the_title() for product posts on the frontend so single-product
// templates and shop loops that use the_title() pick up the B title.
add_filter( 'the_title', function ( $title, $post_id ) {
if ( is_admin() ) {
return $title;
}
$post = get_post( $post_id );
if ( ! $post || $post->post_type !== 'product' ) {
return $title;
}
$product = wc_get_product( $post_id );
if ( ! $product ) {
return $title;
}
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
if ( ! $test || $variant !== 'B' ) {
return $title;
}
$overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];
if ( isset( $overrides['title'] ) && $overrides['title'] !== '' ) {
return $overrides['title'];
}
return $title;
}, 20, 2 );
// Override get_the_excerpt() for product posts on the frontend so single-product
// templates and shop loops that use the_excerpt() pick up the B short description.
add_filter( 'get_the_excerpt', function ( $excerpt, $post ) {
if ( is_admin() ) {
return $excerpt;
}
if ( ! $post instanceof WP_Post ) {
return $excerpt;
}
if ( $post->post_type !== 'product' ) {
return $excerpt;
}
if ( ! function_exists( 'wc_get_product' ) ) {
return $excerpt;
}
$product = wc_get_product( $post->ID );
if ( ! $product ) {
return $excerpt;
}
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
if ( ! $test || $variant !== 'B' ) {
return $excerpt;
}
$overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];
if ( isset( $overrides['short_description'] ) && $overrides['short_description'] !== '' ) {
return abtestkit_render_product_override_html( $overrides['short_description'] );
}
return $excerpt;
}, 20, 2 );
// Override the main product description content on single-product pages (B only).
add_filter( 'the_content', function( $content ) {
if ( is_admin() ) {
return $content;
}
if ( ! function_exists( 'wc_get_product' ) ) {
return $content;
}
global $post;
if ( ! ( $post instanceof WP_Post ) || $post->post_type !== 'product' ) {
return $content;
}
$product = wc_get_product( $post->ID );
if ( ! $product ) {
return $content;
}
[ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
if ( ! $test || $variant !== 'B' ) {
return $content;
}
$overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];
if ( isset( $overrides['description'] ) && $overrides['description'] !== '' ) {
return abtestkit_render_product_override_html( $overrides['description'] );
}
return $content;
}, 20, 2 );
} );
// Client-side click tracker for Page Tests (Gutenberg buttons)
add_action( 'wp_footer', function () {
// Read-only flag to skip click tracker in wizard iframe.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['abtestkit_preview'] ) ) { return; }
if ( ! is_singular( [ 'post', 'page', 'product' ] ) ) return;
if ( abtestkit_is_exempt_viewer() ) return;
$post_id = get_the_ID();
[ $test, $role ] = abtestkit_pt_find_by_post( $post_id );
if ( ! $test ) return;
$assigned = '';
if (
isset( $GLOBALS['abtestkit_current_pt_assignment'] )
&& is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
) {
$ctx = $GLOBALS['abtestkit_current_pt_assignment'];
$ctx_test = $ctx['test'] ?? null;
$ctx_id = ( is_array( $ctx_test ) && isset( $ctx_test['id'] ) ) ? (string) $ctx_test['id'] : '';
$ctx_var = isset( $ctx['variant'] ) ? (string) $ctx['variant'] : '';
if ( $ctx_id !== '' && $ctx_id === (string) $test['id'] && in_array( $ctx_var, [ 'A', 'B' ], true ) ) {
$assigned = $ctx_var;
}
}
if ( $assigned !== 'A' && $assigned !== 'B' ) {
// Derive the variant from the page we are rendering.
// If we're on the variant page, it's B; if on control, it's usually A.
$assigned = ( $role === 'variant' ) ? 'B' : 'A';
}
// Optional: defensive fallback to cookie (read-only; sanitize before use).
if ( $assigned !== 'A' && $assigned !== 'B' ) {
$cookie_name = 'abtestkit_pt_' . (string) $test['id'];
$cookie_val_raw = '';
if ( isset( $_COOKIE[ $cookie_name ] ) ) {
// Immediately unslash; sanitized below.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$cookie_val_raw = wp_unslash( $_COOKIE[ $cookie_name ] );
}
$cookie_val = sanitize_text_field( $cookie_val_raw );
if ( in_array( $cookie_val, array( 'A', 'B' ), true ) ) {
$assigned = $cookie_val;
} else {
$assigned = 'A';
}
}
$control_id = (int) $test['control_id'];
$rest = esc_url_raw( rest_url( 'abtestkit/v1' ) );
$nonce = wp_create_nonce( 'wp_rest' );
// Conversion goal for this page test: 'clicks' | 'form' | 'add_to_cart' | 'destination_url' | 'scroll_depth'
$goal = isset( $test['goal'] ) ? abtestkit_pt_normalize_goal_for_display( $test['goal'] ) : '';
// Targets come from the wizard's "links" field: hrefs or CSS selectors
$targets = array_values(
array_filter(
array_map( 'strval', (array) ( $test['links'] ?? [] ) )
)
);
// If goal is WooCommerce add_to_cart and no explicit targets were set,
// default to common single-product add to cart selectors.
if ( $goal === 'add_to_cart' && empty( $targets ) ) {
$targets = [
'form.cart',
'.single_add_to_cart_button',
'form.cart button[type="submit"]',
'form.cart input[type="submit"]',
];
}
$scroll_depth = abtestkit_pt_scroll_depth_for_test( $test );
$engagement_sample_rate = abtestkit_engagement_sample_rate();
?>
prefix . 'abtestkit_events' );
$table_esc = esc_sql( $table );
// Schema change on a custom table. Identifier is safe (prefix + fixed suffix).
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
$wpdb->query( 'DROP TABLE IF EXISTS `' . $table_esc . '`' );
// --- 3) Strip AB attributes from saved block content ---
// This ensures blocks re-render as normal Gutenberg blocks everywhere.
$post_types = get_post_types( [ 'public' => true ], 'names' );
if ( ! empty( $post_types ) ) {
foreach ( $post_types as $pt ) {
$paged = 1;
do {
$q = new WP_Query(
[
'post_type' => $pt,
'post_status' => 'any',
'posts_per_page' => 200,
'paged' => $paged,
'no_found_rows' => true,
'fields' => 'ids',
]
);
if ( empty( $q->posts ) ) {
break;
}
foreach ( $q->posts as $pid ) {
$pid = (int) $pid;
$content = get_post_field( 'post_content', $pid );
if ( ! $content ) {
continue;
}
$changed = false;
$blocks = parse_blocks( $content );
$blocks = abtestkit_strip_ab_attrs_from_blocks( $blocks, $changed );
if ( $changed ) {
wp_update_post(
[
'ID' => $pid,
'post_content' => serialize_blocks( $blocks ),
]
);
}
delete_post_meta( $pid, '_abtestkit_variants' );
}
$paged++;
} while ( true );
}
}
// --- 4) Best-effort cache flush so no stale HTML remains in caches/CDNs ---
if ( function_exists( 'wp_cache_flush' ) ) {
wp_cache_flush();
}
if ( function_exists( 'w3tc_flush_all' ) ) {
w3tc_flush_all();
}
if ( function_exists( 'rocket_clean_domain' ) ) {
rocket_clean_domain();
}
if ( function_exists( 'autoptimize_flush_cache' ) ) {
autoptimize_flush_cache();
}
// Purge LiteSpeed Cache via documented external hook.
if ( class_exists( 'LiteSpeed_Cache' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed Cache external purge hook.
do_action( 'litespeed_purge_all' );
}
if ( function_exists( 'wp_cache_clear_cache' ) ) {
wp_cache_clear_cache();
}
}
function abtestkit_events_table_has_column( string $column ) : bool {
global $wpdb;
static $columns = null;
if ( ! is_array( $columns ) ) {
$table = ABTESTKIT_EVENTS_TABLE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$found = $wpdb->get_col(
$wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ),
0
);
$columns = is_array( $found ) ? $found : [];
}
return in_array( $column, $columns, true );
}
function abtestkit_create_event_table() {
global $wpdb;
$table = ABTESTKIT_EVENTS_TABLE;
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
time DATETIME NOT NULL,
post_id BIGINT,
ab_test_id VARCHAR(64),
variant CHAR(1),
event_type ENUM('impression','click','purchase','engagement','decision','decision_applied','stale','protocol_warning'),
order_id BIGINT NULL,
amount DECIMAL(18,2) NULL,
protocol VARCHAR(10) NULL,
excluded_reason VARCHAR(50) NULL,
scroll_pct TINYINT UNSIGNED NULL,
time_sec INT UNSIGNED NULL,
device_class VARCHAR(10) NULL,
country_code VARCHAR(7) NULL,
ip VARCHAR(45),
user_agent TEXT,
KEY idx_time (time),
KEY idx_post_test (post_id, ab_test_id),
KEY idx_variant_type (variant, event_type),
KEY idx_order_id (order_id),
KEY idx_protocol_reason (protocol, excluded_reason),
KEY idx_test_type_variant_time (ab_test_id, event_type, variant, time),
KEY idx_test_time (ab_test_id, time),
KEY idx_test_variant (ab_test_id, variant)
) $charset_collate;";
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Required schema creation/upgrades for plugin-owned custom table.
dbDelta( $sql );
// Force-heal older installs where dbDelta/version flags do not fully catch up.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$columns = $wpdb->get_col(
$wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ),
0
);
// dbDelta can leave an existing ENUM unchanged, so verify the new event
// type explicitly before engagement rows are accepted on upgraded sites.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$event_type_definition = $wpdb->get_row(
$wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table, 'event_type' ),
ARRAY_A
);
$event_type_sql = is_array( $event_type_definition )
? (string) ( $event_type_definition['Type'] ?? $event_type_definition['type'] ?? '' )
: '';
if ( $event_type_sql !== '' && strpos( $event_type_sql, "'engagement'" ) === false ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"ALTER TABLE %i MODIFY `event_type` ENUM('impression','click','purchase','engagement','decision','decision_applied','stale','protocol_warning')",
$table
)
);
}
if ( is_array( $columns ) ) {
if ( ! in_array( 'order_id', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD COLUMN `order_id` BIGINT NULL AFTER `event_type`',
$table
)
);
}
if ( ! in_array( 'amount', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD COLUMN `amount` DECIMAL(18,2) NULL AFTER `order_id`',
$table
)
);
}
if ( ! in_array( 'protocol', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD COLUMN `protocol` VARCHAR(10) NULL AFTER `amount`',
$table
)
);
}
if ( ! in_array( 'excluded_reason', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD COLUMN `excluded_reason` VARCHAR(50) NULL AFTER `protocol`',
$table
)
);
}
if ( ! in_array( 'scroll_pct', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD COLUMN `scroll_pct` TINYINT UNSIGNED NULL AFTER `excluded_reason`',
$table
)
);
}
if ( ! in_array( 'time_sec', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD COLUMN `time_sec` INT UNSIGNED NULL AFTER `scroll_pct`',
$table
)
);
}
if ( ! in_array( 'device_class', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD COLUMN `device_class` VARCHAR(10) NULL AFTER `time_sec`',
$table
)
);
}
if ( ! in_array( 'country_code', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD COLUMN `country_code` VARCHAR(7) NULL AFTER `device_class`',
$table
)
);
}
}
// Add missing indexes if needed.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$order_index = $wpdb->get_var(
$wpdb->prepare(
'SHOW INDEX FROM %i WHERE Key_name = %s',
$table,
'idx_order_id'
)
);
if ( ! $order_index ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD KEY `idx_order_id` (`order_id`)',
$table
)
);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$protocol_index = $wpdb->get_var(
$wpdb->prepare(
'SHOW INDEX FROM %i WHERE Key_name = %s',
$table,
'idx_protocol_reason'
)
);
if ( ! $protocol_index ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD KEY `idx_protocol_reason` (`protocol`, `excluded_reason`)',
$table
)
);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$test_type_variant_time_index = $wpdb->get_var(
$wpdb->prepare(
'SHOW INDEX FROM %i WHERE Key_name = %s',
$table,
'idx_test_type_variant_time'
)
);
if ( ! $test_type_variant_time_index ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD KEY `idx_test_type_variant_time` (`ab_test_id`, `event_type`, `variant`, `time`)',
$table
)
);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$test_time_index = $wpdb->get_var(
$wpdb->prepare(
'SHOW INDEX FROM %i WHERE Key_name = %s',
$table,
'idx_test_time'
)
);
if ( ! $test_time_index ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD KEY `idx_test_time` (`ab_test_id`, `time`)',
$table
)
);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$test_variant_index = $wpdb->get_var(
$wpdb->prepare(
'SHOW INDEX FROM %i WHERE Key_name = %s',
$table,
'idx_test_variant'
)
);
if ( ! $test_variant_index ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
'ALTER TABLE %i ADD KEY `idx_test_variant` (`ab_test_id`, `variant`)',
$table
)
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange
update_option( 'abtestkit_events_schema_version', 6, false );
}
add_action( 'admin_init', function() {
// Always run the schema healer on admin requests.
// It is cheap, and it fixes older live sites where the version flag says "2"
// but the table is still missing newer columns like order_id / amount.
abtestkit_create_event_table();
} );
// Align abtestkit admin menu icon
add_action( 'admin_head', function () {
?>