Enable Dynamic Population. * 3. Parameter Name: abtestversion_{slug} (e.g., abtestversion_hero-cta). * 4. The value saved will be "{slug}:{version}" (e.g., hero-cta:contentversionb). * * B. Using Merge Tags: * Use {abtestversion:slug} in notifications or confirmations to display the visitor's version. * * --- USAGE: EXTERNAL TRACKING (GA4, etc.) --- * 1. Automatic Resolution Events: * a) 'bl_abtest_resolved' — dispatched on every page where a test is resolved. * Use this to set GA4 User Properties to tie the version to ALL future events. * Example: * document.addEventListener('bl_abtest_resolved', function(e) { * const { slug, version } = e.detail; * // GA4 User Property (Ties ALL future events like clicks/purchases to this version) * if (window.gtag) { * gtag('set', 'user_properties', { ['ab_test_' + slug]: version }); * gtag('event', 'ab_test_view', { 'test_name': slug, 'test_version': version }); * } * }); * b) 'bl_abtest_first_resolved' — dispatched exactly once per slug when the cookie is first created. * Use this to associate a user with a cohort a single time. * * 2. Manual Retrieval: * You can access resolved versions anytime via window.BL_ABTest.versions * Example: const pricingVersion = window.BL_ABTest.versions['pricing-table']; */ /** * Core class for handling A/B testing functionality. * * The `BL_ABTest` class provides utilities for managing A/B testing of * content through shortcodes, Gutenberg blocks, and integration with Gravity Forms. * It allows the tracking and serving of different content versions to users, * maintaining state using cookies and choosing versions through random selection or block-level logic. */ class BL_ABTest { const COOKIE_PREFIX = 'bl_abtest_'; const COOKIE_TTL_DAYS = 30; /** * Per-request counters for block occurrences * * @var array */ private static $block_seen = []; /** * Initializes the class by setting up actions and filters. * * This method registers the necessary WordPress actions and filters required * for the functionality of the class, including shortcodes, block rendering, * and integration with Gravity Forms for merge tags and dynamic population. * * @return void */ public static function init() { add_action( 'init', [ __CLASS__, 'register_shortcodes' ] ); add_filter( 'render_block', [ __CLASS__, 'filter_render_block' ], 10, 2 ); // Gravity Forms merge tags add_filter( 'gform_replace_merge_tags', [ __CLASS__, 'gf_replace_merge_tags' ] ); // Gravity Forms dynamic population: {Field} param name "abtestversion_slug" add_filter( 'gform_field_value', [ __CLASS__, 'gf_dynamic_population' ], 10, 3 ); add_action( 'wp_head', [ __CLASS__, 'print_assets' ] ); add_action( 'wp_footer', [ __CLASS__, 'print_footer_scripts' ] ); } /** * Prints CSS and JS to the head to manage A/B test visibility and selection. */ public static function print_assets() { ?> '', ], $atts, 'abtest' ); $slug = self::normalize_slug( $atts['title'] ); if ( '' === $slug ) { return do_shortcode( $content ); } // Simply output both versions with data attributes. // The JS will handle showing the correct one. return sprintf( '
%2$s
', esc_attr( $slug ), do_shortcode( $content ) ); } /** * Processes the [abcontent] shortcode within the context of A/B testing. * * @param array $atts The attributes passed to the shortcode. * @param string $content The enclosed content between the shortcode tags. * * @return string */ public static function sc_abcontent( $atts, $content = '' ) { $atts = shortcode_atts( [ 'contentversion' => '', ], $atts, 'abcontent' ); $ver = strtolower( trim( (string) $atts['contentversion'] ) ); if ( 'contentversiona' !== $ver && 'contentversionb' !== $ver ) { return do_shortcode( $content ); } return sprintf( '
%s
', esc_attr( $ver ), do_shortcode( $content ) ); } /* --------------------------- Gutenberg block filter --------------------------- */ /** * Filters the rendered block content to modify it for A/B testing purposes. * * This method checks if the block contains an A/B test configuration based on its class name, * assigns a version (A or B) based on the block's occurrence, and adds appropriate * data attributes (`data-abtest-slug` and `data-abtest-version`) for JavaScript handling. * * @param string $block_content The HTML content of the rendered block. * @param array $block The block configuration and attributes array. * * @return string The modified block content with added data attributes for A/B testing. */ public static function filter_render_block( $block_content, $block ) { // Check for the class in the block attributes (Gutenberg way) $class_name = $block['attrs']['className'] ?? ''; if ( ! $class_name || stripos( $class_name, 'abtestcontainer-' ) === false ) { return $block_content; } if ( ! preg_match( '/\babtestcontainer-([a-z0-9-]+)\b/i', $class_name, $match ) ) { return $block_content; } $slug = self::normalize_slug( $match[1] ); if ( '' === $slug ) { return $block_content; } if ( ! isset( self::$block_seen[ $slug ] ) ) { self::$block_seen[ $slug ] = 0; } ++self::$block_seen[ $slug ]; $occurrence = self::$block_seen[ $slug ]; $assigned = null; if ( 1 === $occurrence ) { $assigned = 'contentversiona'; } elseif ( 2 === $occurrence ) { $assigned = 'contentversionb'; } if ( ! $assigned ) { return $block_content; } // Add data attributes for the JS to handle selection $block_content = self::add_data_attr_to_first_tag( $block_content, 'data-abtest-slug', $slug ); return self::add_data_attr_to_first_tag( $block_content, 'data-abtest-version', $assigned ); } /** * Adds a data attribute to the first HTML tag in a string. * * @param string $html HTML string. * @param string $attr Attribute name. * @param string $value Attribute value. * * @return string */ private static function add_data_attr_to_first_tag( $html, $attr, $value ) { return preg_replace( '/<([a-z0-9-]+)/i', '<$1 ' . esc_attr( $attr ) . '="' . esc_attr( $value ) . '"', $html, 1 ); } /* --------------------------- Gravity Forms merge tag replacement --------------------------- */ /** * Replaces merge tags in the provided text with the corresponding values. * * @param string $text The text containing merge tags to be replaced. * * @return string The text with the merge tags replaced by their corresponding values. */ public static function gf_replace_merge_tags( $text ) { // Support {abtestversion:slug} if ( ! str_contains( $text, '{abtestversion:' ) ) { return $text; } return preg_replace_callback( '/{abtestversion:([a-z0-9-_]+)}/i', function ( $m ) { $slug = self::normalize_slug( $m[1] ); return esc_html( self::get_token( $slug ) ); }, $text ); } /** * Dynamic population for any field parameter that starts with "abtestversion_". * Example parameter name: abtestversion_pricinghero * * @param string $value value. * @param object $field field. * @param string $name Name. * @return string */ public static function gf_dynamic_population( $value, $field, $name ) { if ( stripos( $name, 'abtestversion_' ) !== 0 ) { return $value; } $slug = substr( $name, strlen( 'abtestversion_' ) ); $slug = self::normalize_slug( $slug ); if ( '' === $slug ) { return $value; } // We add a data-attribute so JS can find this input and update it correctly. // Note: Using a unique hook name per field to avoid conflicts with multiple A/B tests on one form. add_filter( 'gform_field_content', function ( $field_content, $field_obj ) use ( $slug, $field ) { if ( (int) $field_obj->id === (int) $field->id ) { // We look for name="input_X" and inject data-abtest-slug return str_replace( 'name="input_', 'data-abtest-slug="' . esc_attr( $slug ) . '" name="input_', $field_content ); } return $field_content; }, 10, 2 ); return self::get_token( $slug ); } } BL_ABTest::init();