0 ? abtestkit_commerce_canonical_product_id( $parent_id ) : 0; } if ( get_post_type( $product_id ) !== 'product' ) { return 0; } $control_id = function_exists( 'get_post_meta' ) ? absint( get_post_meta( $product_id, '_abtestkit_shadow_of', true ) ) : 0; if ( $control_id > 0 && $control_id !== $product_id && get_post_type( $control_id ) === 'product' ) { return $control_id; } return $product_id; } /** * Return the canonical control variation ID. * * @param int $variation_id Product variation ID. * @return int */ function abtestkit_commerce_canonical_variation_id( $variation_id ) { $variation_id = absint( $variation_id ); if ( $variation_id <= 0 || ! function_exists( 'get_post_type' ) || get_post_type( $variation_id ) !== 'product_variation' ) { return 0; } $control_id = function_exists( 'get_post_meta' ) ? absint( get_post_meta( $variation_id, '_abtestkit_shadow_of', true ) ) : 0; if ( $control_id > 0 && $control_id !== $variation_id && get_post_type( $control_id ) === 'product_variation' ) { return $control_id; } return $variation_id; } /** * Canonicalise a WooCommerce product/variation pair. * * @param int $product_id Product ID. * @param int $variation_id Optional variation ID. * @return array{product_id:int,variation_id:int} */ function abtestkit_commerce_canonical_item_ids( $product_id, $variation_id = 0 ) { $variation_id = abtestkit_commerce_canonical_variation_id( $variation_id ); if ( $variation_id > 0 && function_exists( 'wp_get_post_parent_id' ) ) { $variation_parent_id = absint( wp_get_post_parent_id( $variation_id ) ); $canonical_product = abtestkit_commerce_canonical_product_id( $variation_parent_id ); if ( $canonical_product > 0 ) { return [ 'product_id' => $canonical_product, 'variation_id' => $variation_id, ]; } } return [ 'product_id' => abtestkit_commerce_canonical_product_id( $product_id ), 'variation_id' => 0, ]; } /** * Normalise the stored shared commerce target model. * * Valid shape: * [ * 'scope' => 'all_products'|'selected_products', * 'targets' => [ * [ 'product_id' => 123, 'variation_ids' => [ 456, 789 ] ], * ], * ] * * An empty variation_ids list means every variation of that product. * * @param mixed $raw Raw configuration. * @return array{scope:string,targets:array} */ function abtestkit_commerce_normalize_config( $raw ) { $invalid = [ 'scope' => '', 'targets' => [], ]; if ( ! is_array( $raw ) ) { return $invalid; } $scope = isset( $raw['scope'] ) ? sanitize_key( (string) $raw['scope'] ) : ''; if ( $scope === 'all_products' ) { return [ 'scope' => 'all_products', 'targets' => [], ]; } if ( $scope !== 'selected_products' || empty( $raw['targets'] ) || ! is_array( $raw['targets'] ) ) { return $invalid; } $by_product = []; $order = []; foreach ( array_slice( $raw['targets'], 0, ABTESTKIT_COMMERCE_MAX_TARGETS ) as $raw_target ) { if ( ! is_array( $raw_target ) ) { continue; } $raw_product_id = isset( $raw_target['product_id'] ) ? absint( $raw_target['product_id'] ) : 0; $product_id = abtestkit_commerce_canonical_product_id( $raw_product_id ); if ( $product_id <= 0 ) { continue; } $product_key = (string) $product_id; if ( ! isset( $by_product[ $product_key ] ) ) { $by_product[ $product_key ] = [ 'product_id' => $product_id, 'variation_ids' => [], '_all' => false, ]; $order[] = $product_key; } $raw_variation_ids = isset( $raw_target['variation_ids'] ) && is_array( $raw_target['variation_ids'] ) ? array_slice( $raw_target['variation_ids'], 0, ABTESTKIT_COMMERCE_MAX_VARIATIONS_PER_TARGET ) : []; if ( empty( $raw_variation_ids ) ) { $by_product[ $product_key ]['_all'] = true; $by_product[ $product_key ]['variation_ids'] = []; continue; } if ( $by_product[ $product_key ]['_all'] ) { continue; } foreach ( $raw_variation_ids as $raw_variation_id ) { $item_ids = abtestkit_commerce_canonical_item_ids( $product_id, $raw_variation_id ); if ( $item_ids['product_id'] !== $product_id || $item_ids['variation_id'] <= 0 ) { continue; } $by_product[ $product_key ]['variation_ids'][ (string) $item_ids['variation_id'] ] = $item_ids['variation_id']; } } $targets = []; foreach ( $order as $product_key ) { $target = $by_product[ $product_key ]; if ( ! $target['_all'] && empty( $target['variation_ids'] ) ) { continue; } $targets[] = [ 'product_id' => (int) $target['product_id'], 'variation_ids' => $target['_all'] ? [] : array_values( $target['variation_ids'] ), ]; } if ( empty( $targets ) ) { return $invalid; } return [ 'scope' => 'selected_products', 'targets' => $targets, ]; } /** * Normalise a commerce config submitted while creating a new test. * * Product tests and Custom Code tests targeting a product are restricted to * their control product. Passing zero as the control product keeps the shared * all-products/selected-products model available to pages, posts and reusable * sections. * * @param mixed $raw Raw configuration. * @param int $control_product_id Optional product that must remain selected. * @return array{scope:string,targets:array} */ function abtestkit_commerce_normalize_create_config( $raw, $control_product_id = 0 ) { $config = abtestkit_commerce_normalize_config( $raw ); if ( $config['scope'] === '' ) { return $config; } $control_product_id = abtestkit_commerce_canonical_product_id( $control_product_id ); if ( $control_product_id <= 0 ) { return $config; } if ( $config['scope'] !== 'selected_products' || count( $config['targets'] ) !== 1 || (int) $config['targets'][0]['product_id'] !== $control_product_id ) { return [ 'scope' => '', 'targets' => [], ]; } return $config; } /** * Return an explicit or legacy-compatible commerce config for a test. * * Legacy product tests target their control product. Legacy reusable-section * tests target all products. Other legacy test types were not previously * eligible for server-side WooCommerce order attribution. * * @param array $test Test definition. * @return array|null */ function abtestkit_commerce_config_for_test( array $test ) { if ( array_key_exists( 'commerce', $test ) ) { $explicit = abtestkit_commerce_normalize_config( $test['commerce'] ); return $explicit['scope'] !== '' ? $explicit : null; } $kind = isset( $test['kind'] ) ? sanitize_key( (string) $test['kind'] ) : ''; if ( $kind === 'product' ) { $control_id = isset( $test['control_id'] ) ? abtestkit_commerce_canonical_product_id( $test['control_id'] ) : 0; if ( $control_id <= 0 ) { return null; } return [ 'scope' => 'selected_products', 'targets' => [ [ 'product_id' => $control_id, 'variation_ids' => [], ], ], ]; } if ( $kind === 'reusable_section' ) { return [ 'scope' => 'all_products', 'targets' => [], ]; } return null; } /** * Check whether a product/variation is inside a normalised commerce config. * * @param array $config Commerce config. * @param int $product_id Product ID. * @param int $variation_id Optional variation ID. * @return bool */ function abtestkit_commerce_config_matches( array $config, $product_id, $variation_id = 0 ) { $config = abtestkit_commerce_normalize_config( $config ); $item_ids = abtestkit_commerce_canonical_item_ids( $product_id, $variation_id ); if ( $config['scope'] === '' || $item_ids['product_id'] <= 0 ) { return false; } if ( $config['scope'] === 'all_products' ) { return true; } foreach ( $config['targets'] as $target ) { if ( (int) $target['product_id'] !== $item_ids['product_id'] ) { continue; } if ( empty( $target['variation_ids'] ) ) { return true; } return $item_ids['variation_id'] > 0 && in_array( $item_ids['variation_id'], $target['variation_ids'], true ); } return false; } /** * Check an item against a test's explicit or legacy commerce scope. * * @param array $test Test definition. * @param int $product_id Product ID. * @param int $variation_id Optional variation ID. * @return bool */ function abtestkit_commerce_test_matches( array $test, $product_id, $variation_id = 0 ) { $config = abtestkit_commerce_config_for_test( $test ); return is_array( $config ) && abtestkit_commerce_config_matches( $config, $product_id, $variation_id ); } /** * Resolve Add to cart conversion candidates from a cart-item attribution ledger. * * The ledger is populated before WooCommerce attempts the cart operation. This * resolver is deliberately side-effect free so the confirmed add-to-cart hook * can qualify events only after WooCommerce has accepted the item. * * @param array $cart_item Final cart item data. * @param int $product_id Product ID WooCommerce accepted. * @param int $variation_id Optional variation ID WooCommerce accepted. * @param callable|null $test_loader Optional test loader for contract tests. * @return array */ function abtestkit_commerce_add_to_cart_candidates( array $cart_item, $product_id, $variation_id = 0, $test_loader = null ) { if ( ! is_callable( $test_loader ) ) { if ( ! function_exists( 'abtestkit_pt_get' ) ) { return []; } $test_loader = 'abtestkit_pt_get'; } $candidates = []; foreach ( abtestkit_commerce_cart_item_attributions( $cart_item ) as $attribution ) { $test = call_user_func( $test_loader, (string) $attribution['test_id'] ); if ( ! is_array( $test ) || ( $test['status'] ?? 'paused' ) !== 'running' ) { continue; } $goal = isset( $test['goal'] ) ? sanitize_key( (string) $test['goal'] ) : ''; if ( $goal !== 'add_to_cart' ) { continue; } if ( ! abtestkit_commerce_test_matches( $test, $product_id, $variation_id ) ) { continue; } $attributed_test_id = function_exists( 'abtestkit_sanitize_test_id' ) ? abtestkit_sanitize_test_id( (string) $attribution['test_id'] ) : preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $attribution['test_id'] ); $test_id = isset( $test['id'] ) ? (string) $test['id'] : (string) $attribution['test_id']; $test_id = function_exists( 'abtestkit_sanitize_test_id' ) ? abtestkit_sanitize_test_id( $test_id ) : preg_replace( '/[^a-zA-Z0-9_-]/', '', $test_id ); $post_id = isset( $test['control_id'] ) ? absint( $test['control_id'] ) : 0; $ttl_days = max( 1, (int) ( $test['cookie_ttl_days'] ?? 30 ) ); if ( $test_id === '' || $test_id !== $attributed_test_id || $post_id <= 0 ) { continue; } $candidate = [ 'test_id' => $test_id, 'variant' => (string) $attribution['variant'], 'post_id' => $post_id, 'ttl_days' => $ttl_days, ]; if ( ! empty( $attribution['audience'] ) && is_array( $attribution['audience'] ) ) { $candidate['audience'] = $attribution['audience']; } $candidates[] = $candidate; } return $candidates; } /** * Return the session-dedupe cookie name for one Add to cart goal. * * @param string $test_id Test ID. * @return string */ function abtestkit_commerce_add_to_cart_cookie_name( $test_id ) { $test_id = function_exists( 'abtestkit_sanitize_test_id' ) ? abtestkit_sanitize_test_id( $test_id ) : preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $test_id ); if ( $test_id === '' ) { return ''; } return 'abtestkit_commerce_atc_' . md5( $test_id ); } /** * Normalise a multi-test attribution ledger. * * @param mixed $raw Raw ledger. * @return array */ function abtestkit_commerce_normalize_attributions( $raw ) { if ( ! is_array( $raw ) ) { return []; } $normalised = []; $seen = []; foreach ( $raw as $key => $row ) { if ( is_array( $row ) ) { $test_id = isset( $row['test_id'] ) ? (string) $row['test_id'] : ''; $variant = isset( $row['variant'] ) ? (string) $row['variant'] : ''; } else { $test_id = is_string( $key ) ? $key : ''; $variant = is_string( $row ) ? $row : ''; } $test_id = function_exists( 'abtestkit_sanitize_test_id' ) ? abtestkit_sanitize_test_id( $test_id ) : preg_replace( '/[^a-zA-Z0-9_-]/', '', $test_id ); $variant = strtoupper( sanitize_text_field( $variant ) ); if ( $test_id === '' || ! in_array( $variant, [ 'A', 'B' ], true ) || isset( $seen[ $test_id ] ) ) { continue; } $normalised_row = [ 'test_id' => $test_id, 'variant' => $variant, ]; if ( is_array( $row ) && ! empty( $row['audience'] ) && function_exists( 'abtestkit_audience_normalize_context' ) ) { $audience = abtestkit_audience_normalize_context( $row['audience'] ); if ( ! empty( $audience ) ) { $normalised_row['audience'] = $audience; } } $seen[ $test_id ] = true; $normalised[] = $normalised_row; } return $normalised; } /** * Add one assignment to a ledger without changing an existing assignment. * * @param array $attributions Existing ledger. * @param string $test_id Test ID. * @param string $variant A or B. * @param array $audience Optional original exposure dimensions. * @return array */ function abtestkit_commerce_add_attribution( array $attributions, $test_id, $variant, array $audience = [] ) { $attributions = abtestkit_commerce_normalize_attributions( $attributions ); $candidate_row = [ 'test_id' => $test_id, 'variant' => $variant, ]; if ( ! empty( $audience ) ) { $candidate_row['audience'] = $audience; } $candidate = abtestkit_commerce_normalize_attributions( [ $candidate_row, ] ); if ( empty( $candidate ) ) { return $attributions; } foreach ( $attributions as $index => $attribution ) { if ( $attribution['test_id'] === $candidate[0]['test_id'] ) { if ( empty( $attribution['audience'] ) && ! empty( $candidate[0]['audience'] ) ) { $attributions[ $index ]['audience'] = $candidate[0]['audience']; } return $attributions; } } $attributions[] = $candidate[0]; return $attributions; } /** * Read the new ledger and merge legacy single-test cart data. * * @param array $cart_item Cart item data. * @return array */ function abtestkit_commerce_cart_item_attributions( array $cart_item ) { $attributions = isset( $cart_item['abtestkit_commerce_attributions'] ) ? abtestkit_commerce_normalize_attributions( $cart_item['abtestkit_commerce_attributions'] ) : []; if ( ! empty( $cart_item['abtestkit_pt_id'] ) && ! empty( $cart_item['abtestkit_pt_variant'] ) ) { $attributions = abtestkit_commerce_add_attribution( $attributions, $cart_item['abtestkit_pt_id'], $cart_item['abtestkit_pt_variant'] ); } return $attributions; } /** * Persist the ledger on cart data while keeping legacy readers working. * * @param array $cart_item_data Cart item data. * @param array $attributions Attribution ledger. * @return array */ function abtestkit_commerce_set_cart_item_attributions( array $cart_item_data, array $attributions ) { $attributions = abtestkit_commerce_normalize_attributions( $attributions ); if ( empty( $attributions ) ) { return $cart_item_data; } $cart_item_data['abtestkit_commerce_attributions'] = $attributions; if ( empty( $cart_item_data['abtestkit_pt_id'] ) || empty( $cart_item_data['abtestkit_pt_variant'] ) ) { $cart_item_data['abtestkit_pt_id'] = $attributions[0]['test_id']; $cart_item_data['abtestkit_pt_variant'] = $attributions[0]['variant']; } return $cart_item_data; } /** * Read the new ledger and merge legacy single-test order-item metadata. * * @param object $item WooCommerce order item. * @return array */ function abtestkit_commerce_order_item_attributions( $item ) { if ( ! is_object( $item ) || ! method_exists( $item, 'get_meta' ) ) { return []; } $attributions = abtestkit_commerce_normalize_attributions( $item->get_meta( '_abtestkit_commerce_attributions', true ) ); $legacy_test_id = $item->get_meta( '_abtestkit_pt_id', true ); $legacy_variant = $item->get_meta( '_abtestkit_pt_variant', true ); if ( $legacy_test_id !== '' && $legacy_variant !== '' ) { $attributions = abtestkit_commerce_add_attribution( $attributions, $legacy_test_id, $legacy_variant ); } return $attributions; } /** * Copy the cart attribution ledger to a WooCommerce order item. * * Legacy metadata remains managed by the existing integration. * * @param object $item WooCommerce order item. * @param array $values Cart item values. * @return void */ function abtestkit_commerce_persist_order_item_attributions( $item, array $values ) { if ( ! is_object( $item ) || ! method_exists( $item, 'add_meta_data' ) ) { return; } $attributions = abtestkit_commerce_cart_item_attributions( $values ); if ( empty( $attributions ) ) { return; } $item->add_meta_data( '_abtestkit_commerce_attributions', $attributions, true ); } /** * Reduce a builder-backed product short description to cart-safe text. * * Product pages may intentionally render an Elementor document inside the * short description. WooCommerce Store API responses later pass that same * value through content filters for cart/order summaries, where a client can * remove the style tag but leave its CSS visible as text. Cart summaries need * the human introduction, not an embedded builder document. * * @param mixed $description Product short description. * @return string */ function abtestkit_commerce_cart_safe_short_description( $description ) { $description = is_string( $description ) ? $description : ''; if ( $description === '' ) { return ''; } // Remove shortcodes before WooCommerce can render a builder template and // inject its style block into a Store API cart response. if ( function_exists( 'strip_shortcodes' ) ) { $description = strip_shortcodes( $description ); } // If content has already been rendered, keep only the human introduction // before an embedded Elementor document begins. if ( preg_match( '/\bdata-elementor-type\s*=/i', $description, $elementor_marker, PREG_OFFSET_CAPTURE ) ) { $marker_offset = (int) $elementor_marker[0][1]; $tag_offset = strrpos( substr( $description, 0, $marker_offset ), '<' ); $description = substr( $description, 0, $tag_offset !== false ? $tag_offset : $marker_offset ); } $description = preg_replace( '@<(script|style|noscript)\b[^>]*>.*?@si', ' ', $description ); $description = is_string( $description ) ? $description : ''; $description = wp_strip_all_tags( $description, true ); $description = preg_replace( '/\s+/u', ' ', trim( $description ) ); return is_string( $description ) ? $description : ''; }