bool, * 'withinQuota' => bool, * 'plan' => string, * 'includedTryOns'=> int, * 'remaining' => int, * 'resetsAt' => string (ISO8601), * 'trial' => array|null, * 'review' => array{completed:bool}, * 'error' => string|null, // populated on transport/parse failure * ) * * @param bool $force Bypass the cache and re-fetch. * @return array */ public function get( $force = false ) { if ( ! Plugin::is_connected() ) { return $this->empty_status( __( 'Not connected', 'aisthetix-virtual-try-on' ) ); } if ( ! $force ) { $cached = get_transient( self::TRANSIENT ); if ( is_array( $cached ) ) { return $cached; } } $fresh = $this->fetch_from_brain(); // Cache successful responses for the full TTL; cache failures briefly so // a flaky brain does not hammer us, but recovers reasonably fast. $ttl = empty( $fresh['error'] ) ? self::CACHE_TTL : MINUTE_IN_SECONDS; set_transient( self::TRANSIENT, $fresh, $ttl ); return $fresh; } /** * Whether the try-on entry point should be shown on the storefront. * * Per DESIGN.md decision #14/#15: render only when active && withinQuota. * * @return bool */ public function should_render( $context = array() ) { $status = $this->get(); return ! empty( $status['active'] ) && ! empty( $status['withinQuota'] ) && self::feature_enabled( isset( $status['tryonTargeting'] ) ? $status['tryonTargeting'] : null, $context ); } /** * Product targeting predicate. Unknown/missing rules fail open, matching Core. * * @param mixed $rule Targeting rule. * @param array $context Product categories and id. * @return bool */ public static function feature_enabled( $rule, $context ) { if ( ! is_array( $rule ) || ! isset( $rule['mode'] ) || ! in_array( $rule['mode'], array( 'include', 'exclude' ), true ) ) { return true; } $categories = isset( $rule['categories'] ) && is_array( $rule['categories'] ) ? $rule['categories'] : array(); $product_ids = isset( $rule['productIds'] ) && is_array( $rule['productIds'] ) ? array_map( 'strval', $rule['productIds'] ) : array(); $context_categories = isset( $context['categories'] ) && is_array( $context['categories'] ) ? $context['categories'] : array(); $product_id = isset( $context['productId'] ) ? (string) $context['productId'] : null; $matched = ! empty( array_intersect( $categories, $context_categories ) ) || ( null !== $product_id && in_array( $product_id, $product_ids, true ) ); return 'include' === $rule['mode'] ? $matched : ! $matched; } /** * Force-refresh the cache (used after Connect or via an admin action). * * @return array Fresh status. */ public function refresh() { delete_transient( self::TRANSIENT ); return $this->get( true ); } /** * Perform the server-to-server status call to the brain. * * @return array Normalised status (see {@see get()}). */ private function fetch_from_brain() { $url = Plugin::brain_url() . '/api/storefront/status'; $response = wp_remote_get( $url, array( 'timeout' => 10, 'headers' => array( 'X-Aisthetix-Secret' => Plugin::secret_key(), 'Accept' => 'application/json', ), ) ); if ( is_wp_error( $response ) ) { return $this->empty_status( $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( 200 !== $code || ! is_array( $data ) ) { /* translators: %d: HTTP status code. */ return $this->empty_status( sprintf( __( 'Brain returned HTTP %d', 'aisthetix-virtual-try-on' ), $code ) ); } $trial = null; if ( isset( $data['trial'] ) && is_array( $data['trial'] ) ) { $trial = array( 'cap' => isset( $data['trial']['cap'] ) ? (int) $data['trial']['cap'] : 50, 'used' => isset( $data['trial']['used'] ) ? (int) $data['trial']['used'] : 0, 'remaining' => isset( $data['trial']['remaining'] ) ? (int) $data['trial']['remaining'] : 0, 'endsAt' => isset( $data['trial']['endsAt'] ) ? sanitize_text_field( $data['trial']['endsAt'] ) : '', ); } return array( 'active' => ! empty( $data['active'] ), 'withinQuota' => ! empty( $data['withinQuota'] ), 'plan' => isset( $data['plan'] ) ? sanitize_text_field( $data['plan'] ) : '', 'includedTryOns' => isset( $data['includedTryOns'] ) ? (int) $data['includedTryOns'] : 0, 'remaining' => isset( $data['remaining'] ) ? (int) $data['remaining'] : 0, 'resetsAt' => isset( $data['resetsAt'] ) ? sanitize_text_field( $data['resetsAt'] ) : '', 'trial' => $trial, 'replay' => self::normalise_replay( isset( $data['replay'] ) ? $data['replay'] : null ), 'review' => self::normalise_review( isset( $data['review'] ) ? $data['review'] : null ), 'tryonTargeting' => self::normalise_rule( isset( $data['tryonTargeting'] ) ? $data['tryonTargeting'] : null ), 'size' => self::normalise_size( isset( $data['size'] ) ? $data['size'] : null ), 'error' => null, ); } /** * Normalize Core's review completion state. * * Missing or malformed data is intentionally incomplete: the review notice * remains available until Core explicitly records a completion. * * @param mixed $raw Core review block, if any. * @return array{completed:bool} */ private static function normalise_review( $raw ) { return array( 'completed' => is_array( $raw ) && isset( $raw['completed'] ) && true === $raw['completed'], ); } private static function normalise_rule( $raw ) { if ( ! is_array( $raw ) ) { return array( 'mode' => 'all', 'categories' => array(), 'productIds' => array() ); } $mode = isset( $raw['mode'] ) && in_array( $raw['mode'], array( 'all', 'include', 'exclude' ), true ) ? $raw['mode'] : 'all'; return array( 'mode' => $mode, 'categories' => isset( $raw['categories'] ) && is_array( $raw['categories'] ) ? array_values( array_map( 'sanitize_title', $raw['categories'] ) ) : array(), 'productIds' => isset( $raw['productIds'] ) && is_array( $raw['productIds'] ) ? array_values( array_map( 'strval', $raw['productIds'] ) ) : array(), ); } private static function normalise_size( $raw ) { if ( ! is_array( $raw ) ) { return array( 'hasSizeGuide' => false, 'guides' => array(), 'targeting' => self::normalise_rule( null ), 'fitPreferenceEnabled' => false, 'defaultFitPreference' => 'regular', 'fitOverrides' => array(), ); } $guides = array(); foreach ( isset( $raw['guides'] ) && is_array( $raw['guides'] ) ? $raw['guides'] : array() as $guide ) { if ( ! is_array( $guide ) ) { continue; } if ( isset( $guide['categories'] ) && ! is_array( $guide['categories'] ) ) { continue; } if ( isset( $guide['categories']['categories'] ) && ! is_array( $guide['categories']['categories'] ) ) { continue; } $ids = isset( $guide['categories']['productIds'] ) ? $guide['categories']['productIds'] : array(); if ( ! is_array( $ids ) || count( $ids ) > 250 ) { continue; } $valid_ids = array_values( array_unique( array_filter( $ids, static function ( $id ) { return is_string( $id ) && preg_match( '/^[1-9][0-9]{0,15}$/D', $id ); } ) ) ); // Never turn corrupt product targeting into a shop-wide fallback. if ( count( $valid_ids ) !== count( array_unique( $ids, SORT_REGULAR ) ) ) { continue; } $guide_categories = isset( $guide['categories']['categories'] ) && is_array( $guide['categories']['categories'] ) ? array_values( array_map( 'sanitize_title', $guide['categories']['categories'] ) ) : array(); $guides[] = array( 'id' => isset( $guide['id'] ) ? sanitize_text_field( $guide['id'] ) : '', 'categories' => array( 'categories' => $guide_categories, 'productIds' => $valid_ids ), 'labels' => isset( $guide['labels'] ) && is_array( $guide['labels'] ) ? array_values( array_map( 'sanitize_text_field', $guide['labels'] ) ) : array(), ); } $fit = isset( $raw['defaultFitPreference'] ) && in_array( $raw['defaultFitPreference'], array( 'tight', 'regular', 'loose' ), true ) ? $raw['defaultFitPreference'] : 'regular'; $fit_overrides = array(); $seen = array(); foreach ( isset( $raw['fitOverrides'] ) && is_array( $raw['fitOverrides'] ) ? $raw['fitOverrides'] : array() as $override ) { if ( ! is_array( $override ) ) { continue; } $category_id = isset( $override['categoryId'] ) ? (string) $override['categoryId'] : ''; $preference = isset( $override['preference'] ) ? (string) $override['preference'] : ''; if ( ! preg_match( '/^wc:product_cat:[1-9][0-9]*$/', $category_id ) || isset( $seen[ $category_id ] ) || ! in_array( $preference, array( 'tight', 'regular', 'loose' ), true ) ) { continue; } $seen[ $category_id ] = true; $fit_overrides[] = array( 'categoryId' => $category_id, 'categoryLabel' => isset( $override['categoryLabel'] ) ? sanitize_text_field( $override['categoryLabel'] ) : $category_id, 'preference' => $preference, 'priority' => isset( $override['priority'] ) ? max( 0, min( 9999, (int) $override['priority'] ) ) : 0, ); } return array( 'hasSizeGuide' => ! empty( $raw['hasSizeGuide'] ), 'guides' => $guides, 'targeting' => self::normalise_rule( isset( $raw['targeting'] ) ? $raw['targeting'] : null ), 'fitPreferenceEnabled' => ! empty( $raw['fitPreferenceEnabled'] ), 'defaultFitPreference' => $fit, 'fitOverrides' => $fit_overrides, ); } /** * Reduce Core's replay block to the four values the widget needs. * * Defaults to OFF for anything it does not recognise. An older Core that * sends no block at all, a malformed response, a failed request — none of * them are permission to record a shopper. * * @param mixed $raw The `replay` value from Core, if any. * @return array */ private static function normalise_replay( $raw ) { if ( ! is_array( $raw ) ) { return array( 'enabled' => false, 'key' => '', 'host' => '', 'environment' => '', ); } return array( 'enabled' => ! empty( $raw['enabled'] ), 'key' => isset( $raw['key'] ) ? sanitize_text_field( $raw['key'] ) : '', 'host' => isset( $raw['host'] ) ? esc_url_raw( $raw['host'] ) : '', 'environment' => isset( $raw['environment'] ) ? sanitize_text_field( $raw['environment'] ) : '', ); } /** * Build a "not available" status payload. * * @param string $error Human-readable reason. * @return array */ private function empty_status( $error ) { return array( 'active' => false, 'withinQuota' => false, 'plan' => '', 'includedTryOns' => 0, 'remaining' => 0, 'resetsAt' => '', 'trial' => null, // No answer from Core is not permission to record. This is the state // during an outage, during a bad deploy, and on a site whose keys have // been revoked — every one of them a moment to be recording less, not // more. 'replay' => self::normalise_replay( null ), 'review' => self::normalise_review( null ), 'tryonTargeting' => self::normalise_rule( null ), 'size' => self::normalise_size( null ), 'error' => $error, ); } }