10000 ) { return 'unknown'; } if ( $width <= 767 ) { return 'mobile'; } if ( $width <= 1024 ) { return 'tablet'; } return 'desktop'; } /** * Normalise an ISO-style country code without accepting provider placeholders. * * @param mixed $value Candidate country code. * @return string Two-letter code or "unknown". */ function abtestkit_audience_normalize_country_code( $value ): string { $value = strtoupper( trim( (string) $value ) ); if ( in_array( $value, [ '', 'XX', 'T1' ], true ) ) { return 'unknown'; } return preg_match( '/^[A-Z]{2}$/', $value ) ? $value : 'unknown'; } /** * Resolve a country from local/trusted request signals without an API lookup. * * Integrations can provide a value with the documented filter. A bounded list * of Cloudflare and LiteSpeed country variables is accepted when present. * WooCommerce's geolocator is used only with both fallback flags disabled, * which permits configured local database/header resolution but forbids its * remote API fallback. * * @return string Two-letter code or "unknown". */ function abtestkit_audience_country_code_from_request(): string { /** * Filters the locally resolved visitor country used for reporting. * * Return a two-letter country code. The value is analytics context only and * must not be treated as an authentication or security decision. * * @param string $country_code Country code, empty by default. */ $filtered = apply_filters( 'abtestkit_audience_country_code', '' ); $country = abtestkit_audience_normalize_country_code( $filtered ); if ( $country !== 'unknown' ) { return $country; } $server_country_keys = [ 'HTTP_CF_IPCOUNTRY', 'GEOIP_COUNTRY_CODE', 'HTTP_GEOIP_CITY_COUNTRY_CODE', ]; foreach ( $server_country_keys as $server_country_key ) { if ( ! isset( $_SERVER[ $server_country_key ] ) ) { continue; } $server_country = sanitize_text_field( wp_unslash( $_SERVER[ $server_country_key ] ) ); $country = abtestkit_audience_normalize_country_code( $server_country ); if ( $country !== 'unknown' ) { return $country; } } if ( class_exists( 'WC_Geolocation' ) && method_exists( 'WC_Geolocation', 'geolocate_ip' ) ) { try { $location = WC_Geolocation::geolocate_ip( '', false, false ); $country = is_array( $location ) ? abtestkit_audience_normalize_country_code( $location['country'] ?? '' ) : 'unknown'; if ( $country !== 'unknown' ) { return $country; } } catch ( Throwable $error ) { // Location reporting is optional and must fail safely to unknown. unset( $error ); } } return 'unknown'; } /** * Normalise a stored audience context. * * @param mixed $raw Raw audience context. * @return array{schema_version:int,device_class:string,country_code:string}|array{} */ function abtestkit_audience_normalize_context( $raw ): array { if ( ! is_array( $raw ) ) { return []; } return [ 'schema_version' => ABTESTKIT_AUDIENCE_SCHEMA_VERSION, 'device_class' => abtestkit_audience_normalize_device_class( $raw['device_class'] ?? '' ), 'country_code' => abtestkit_audience_normalize_country_code( $raw['country_code'] ?? '' ), ]; } /** * Build the current request context for a new experiment exposure. * * @param mixed $viewport_width Browser CSS viewport width. * @return array{schema_version:int,device_class:string,country_code:string} */ function abtestkit_audience_context_from_request( $viewport_width ): array { return [ 'schema_version' => ABTESTKIT_AUDIENCE_SCHEMA_VERSION, 'device_class' => abtestkit_audience_device_class_from_viewport( $viewport_width ), 'country_code' => abtestkit_audience_country_code_from_request(), ]; } /** * Return the bounded first-party exposure cookie name for one test. * * @param mixed $test_id Test ID. * @return string */ function abtestkit_audience_cookie_name( $test_id ): string { $test_id = function_exists( 'abtestkit_sanitize_test_id' ) ? abtestkit_sanitize_test_id( $test_id ) : preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $test_id ); return $test_id !== '' ? 'abtestkit_aud_' . md5( $test_id ) : ''; } /** * Read the original audience context for one test assignment. * * @param mixed $test_id Test ID. * @return array{schema_version:int,device_class:string,country_code:string}|array{} */ function abtestkit_audience_get_exposure_context( $test_id ): array { $cookie_name = abtestkit_audience_cookie_name( $test_id ); if ( $cookie_name === '' || ! function_exists( 'abtestkit_safe_get_cookie_value' ) ) { return []; } $raw = abtestkit_safe_get_cookie_value( $cookie_name ); if ( $raw === '' ) { return []; } $decoded = json_decode( $raw, true ); return abtestkit_audience_normalize_context( $decoded ); } /** * Preserve the first accepted exposure context for the assignment lifetime. * * Existing context is never overwritten, so conversions stay attributed to * the dimensions observed when the visitor first entered this test. * * @param mixed $test_id Test ID. * @param array $context Audience context. * @param int $ttl_days Assignment lifetime in days. * @return array Stored context, or an empty array on invalid input. */ function abtestkit_audience_persist_exposure_context( $test_id, array $context, int $ttl_days = 30 ): array { $existing = abtestkit_audience_get_exposure_context( $test_id ); if ( ! empty( $existing ) ) { return $existing; } $cookie_name = abtestkit_audience_cookie_name( $test_id ); $context = abtestkit_audience_normalize_context( $context ); if ( $cookie_name === '' || empty( $context ) || ! function_exists( 'abtestkit_safe_set_cookie' ) ) { return []; } $value = wp_json_encode( $context ); if ( ! is_string( $value ) || $value === '' ) { return []; } $ttl_days = max( 1, min( 365, $ttl_days ) ); abtestkit_safe_set_cookie( $cookie_name, $value, time() + ( $ttl_days * DAY_IN_SECONDS ) ); // Keep later hooks in this same request aligned with the response cookie. $_COOKIE[ $cookie_name ] = $value; return $context; }