status = $status; } /** * Register hooks. * * @return void */ public function init() { add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) ); // HARDENING (DESIGN decision #19/#20): enqueue the un-delayable bootstrap + // inline config UNCONDITIONALLY on eligible product pages in // wp_enqueue_scripts, independent of when (or whether) the trigger markup is // rendered. Page builders like Elementor (the 3sides.it stack) render the // product template from their own hooks/templates, so relying on the // render-path to enqueue (the old behaviour) could miss the loader entirely. // We register at the default priority and enqueue late so is_product() and // the global $product are resolved. The render path only prints trigger markup. add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_on_product' ), 20 ); // Set the durable first-party visitor cookie as early as possible (before any // output) on product pages, so attribution events carry a stable visitorKey. // We hook several increasingly-late points and let the method no-op once the // cookie is persisted: 'send_headers' / 'init' fire before most theme output // (shrinking the headers_sent() race window where the cookie can't be set), // and 'template_redirect' is the backstop once the main query is resolved and // is_product() is reliable. The earliest hook at which is_product() is true and // headers are still open wins; the rest no-op. add_action( 'send_headers', array( $this, 'maybe_set_visitor_cookie' ) ); add_action( 'init', array( $this, 'maybe_set_visitor_cookie' ) ); add_action( 'template_redirect', array( $this, 'maybe_set_visitor_cookie' ) ); // Auto placement: render after the Add to cart button on simple/variable products. add_action( 'woocommerce_after_add_to_cart_button', array( $this, 'maybe_auto_render' ) ); // Shortcode (universal fallback). add_shortcode( 'aisthetix_tryon', array( $this, 'shortcode' ) ); // Optimizer compatibility exclusions. $this->register_optimizer_exclusions(); } /** * Read the well-formed first-party `aisthetix_vid` cookie value, or '' if absent * or malformed. * * The value is a random 32-hex token. We deliberately do not trust an arbitrary * cookie value verbatim — only accept the expected shape — so a hostile cookie * can't inject markup or oversized keys downstream. * * @return string The validated cookie value, or '' when none. */ private function read_visitor_cookie() { if ( ! isset( $_COOKIE[ self::VISITOR_COOKIE ] ) ) { return ''; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by the strict regex below. $candidate = wp_unslash( $_COOKIE[ self::VISITOR_COOKIE ] ); if ( is_string( $candidate ) && preg_match( '/^[a-f0-9]{32}$/', $candidate ) ) { return $candidate; } return ''; } /** * Resolve the durable visitor key for this request: read the first-party * `aisthetix_vid` cookie if present and well-formed, otherwise mint a fresh one. * * Used by the cookie-setting path (the minted value WILL be persisted there). For * the client config use {@see config_visitor_key()} instead, which never advertises * an ephemeral (un-persistable) key. * * @return string */ public function get_visitor_key() { if ( null !== $this->visitor_key ) { return $this->visitor_key; } $existing = $this->read_visitor_cookie(); if ( '' === $existing ) { $existing = md5( wp_generate_password( 32, false, false ) . microtime() . wp_rand() ); } $this->visitor_key = $existing; return $this->visitor_key; } /** * Resolve the visitorKey to advertise in window.AISTHETIX_CONFIG. * * CONSISTENCY GUARANTEE: the value placed in the client config must equal the value * the later server-side order event will read from $_COOKIE — otherwise the client * try-on/view events and the server checkout event are filed under two different * visitor keys and never correlate. * * - If the cookie was (or could be) persisted this request, the resolved key is * durable → use it. * - If the cookie could NOT be set (headers already sent and no prior cookie), * minting a brand-new key here would be ephemeral: it would live only in this * page's JS and never reach the order-event cookie read. So instead we fall back * to the existing (validated) cookie value, or null. null is honest — the brain * then attributes via customerId for logged-in shoppers and simply can't tie * anonymous events from this single un-cookied page view. * * @return string|null */ public function config_visitor_key() { // A real (persistable) cookie value already exists on the request. $existing = $this->read_visitor_cookie(); if ( '' !== $existing ) { return $existing; } // No cookie yet. Only advertise a freshly minted key if it was actually // persisted (Set-Cookie emitted this request); otherwise it can't survive to // the order event, so report null rather than a value that won't agree. if ( $this->cookie_persisted ) { return $this->get_visitor_key(); } return null; } /** * Set (or refresh) the first-party visitor cookie on product pages, server-side. * * SameSite=Lax, ~13-month expiry, Secure when the site is HTTPS, HttpOnly=false * because the widget's browser JS must read it to attach visitorKey to client * events (it is a non-secret analytics id, not a credential). Runs on * template_redirect (before output) so the Set-Cookie header is valid. * * @return void */ public function maybe_set_visitor_cookie() { // Idempotent: this runs on send_headers + init + template_redirect; the first // eligible invocation persists the cookie and the rest no-op. if ( $this->cookie_persisted ) { return; } if ( headers_sent() ) { return; } if ( ! Plugin::is_connected() ) { return; } if ( ! function_exists( 'is_product' ) || ! is_product() ) { return; } // Opt-in analytics: do NOT set the durable visitor cookie unless the merchant // has enabled anonymous analytics (default OFF). See Plugin::analytics_enabled(). if ( ! Plugin::analytics_enabled() ) { return; } $value = $this->get_visitor_key(); // Refresh the cookie each product-page view to keep the 13-month window // rolling for active shoppers. $args = array( 'expires' => time() + self::VISITOR_COOKIE_TTL, 'path' => defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/', 'domain' => defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '', 'secure' => is_ssl(), 'httponly' => false, 'samesite' => 'Lax', ); if ( PHP_VERSION_ID >= 70300 ) { setcookie( self::VISITOR_COOKIE, $value, $args ); } else { // Older PHP: smuggle SameSite via the path argument (a well-known shim). setcookie( self::VISITOR_COOKIE, $value, $args['expires'], $args['path'] . '; samesite=Lax', $args['domain'], $args['secure'], $args['httponly'] ); } // Make the just-set value readable within this same request (so the order // event and config_visitor_key() see the same value the browser will return). $_COOKIE[ self::VISITOR_COOKIE ] = $value; $this->cookie_persisted = true; } /** * Enqueue the bootstrap + inline config on eligible product pages, regardless of * whether/when the trigger button is rendered. See init() for the rationale. * * @return void */ public function maybe_enqueue_on_product() { if ( ! $this->can_render_any() ) { return; } $this->ensure_assets( $this->resolve_product() ); } /** * Resolve the current product context. * * The global $product is frequently NOT set yet at wp_enqueue_scripts time (the * loop has not run), and on block themes the enqueue pass can run before the * product block renders. Fall back to the queried product so the injected config * is always complete (publishableKey + productId + productImage), never empty. * * @return \WC_Product|null */ private function resolve_product() { global $product; if ( $product instanceof \WC_Product ) { return $product; } if ( function_exists( 'wc_get_product' ) ) { $queried = wc_get_product( get_queried_object_id() ); if ( $queried instanceof \WC_Product ) { return $queried; } } return null; } /** * Whether the try-on entry point is allowed to render on the current request. * * @return bool */ public function can_render() { if ( ! Plugin::is_connected() ) { return false; } // Only on single product pages where we have a product image to try on. if ( ! function_exists( 'is_product' ) || ! is_product() ) { return false; } // Cached status gate: active && withinQuota. $product = $this->resolve_product(); return $product instanceof \WC_Product && $this->status->should_render( $this->product_context( $product ) ); } /** * Whether either independent PDP entry point can render. * * @return bool */ public function can_render_any() { if ( ! Plugin::is_connected() || ! function_exists( 'is_product' ) || ! is_product() ) { return false; } $product = $this->resolve_product(); if ( ! $product instanceof \WC_Product || $product->is_type( 'grouped' ) ) { return false; } return $this->status->should_render( $this->product_context( $product ) ) || $this->can_render_size( $product ); } /** * Whether this product has a real, purchasable size recommendation path. * * @param \WC_Product|null $product Product context. * @return bool */ public function can_render_size( $product = null ) { if ( ! Plugin::is_connected() ) { return false; } if ( null === $product ) { if ( ! function_exists( 'is_product' ) || ! is_product() ) { return false; } $product = $this->resolve_product(); } if ( ! $product instanceof \WC_Product || $product->is_type( 'grouped' ) ) { return false; } return self::size_is_eligible( $this->status->get(), $this->product_context( $product ), $this->get_size_variants( $product ) ); } /** * Pure size-entry gate shared by rendering and its regression test. * * @param array $status Normalised storefront status. * @param array $context Product id and category slugs. * @param array $variants Real WooCommerce size variations. * @return bool */ public static function size_is_eligible( $status, $context, $variants ) { if ( empty( $status['active'] ) ) { return false; } $size = isset( $status['size'] ) && is_array( $status['size'] ) ? $status['size'] : array(); if ( empty( $size['hasSizeGuide'] ) || ! is_array( $variants ) || empty( $variants ) ) { return false; } if ( ! Status::feature_enabled( isset( $size['targeting'] ) ? $size['targeting'] : null, $context ) ) { return false; } $guide = self::resolve_size_guide( $size, $context ); if ( ! is_array( $guide ) || ! isset( $guide['labels'] ) || ! is_array( $guide['labels'] ) ) { return false; } $guide_labels = array(); foreach ( $guide['labels'] as $label ) { $normalised = self::normalize_size_label( $label ); if ( '' !== $normalised ) { $guide_labels[ $normalised ] = true; } } $one_size = array_fill_keys( array( 'onesize', 'os', 'tagliaunica', 'tallaunica', 'tailleunique', 'einheitsgrosse', 'tamanhounico', 'maatuniek', 'uniwersalny', 'unisize', 'freesize', 'unica', 'unico' ), true ); $matched = array(); foreach ( $variants as $variant ) { if ( empty( $variant['available'] ) || ! isset( $variant['size_label'] ) ) { continue; } $normalised = self::normalize_size_label( $variant['size_label'] ); if ( '' !== $normalised && ! isset( $one_size[ $normalised ] ) && isset( $guide_labels[ $normalised ] ) ) { $matched[ $normalised ] = true; } } return count( $matched ) >= 2; } /** * Canonical comparison key used only by the storefront eligibility gate. * * @param mixed $label Merchant-provided size label. * @return string */ private static function normalize_size_label( $label ) { if ( ! is_string( $label ) ) { return ''; } $label = strtr( trim( $label ), array( 'À' => 'a', 'Á' => 'a', 'Â' => 'a', 'Ã' => 'a', 'Ä' => 'a', 'Å' => 'a', 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', 'È' => 'e', 'É' => 'e', 'Ê' => 'e', 'Ë' => 'e', 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'Ì' => 'i', 'Í' => 'i', 'Î' => 'i', 'Ï' => 'i', 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'Ò' => 'o', 'Ó' => 'o', 'Ô' => 'o', 'Õ' => 'o', 'Ö' => 'o', 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'Ù' => 'u', 'Ú' => 'u', 'Û' => 'u', 'Ü' => 'u', 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ß' => 'ss', 'Ç' => 'c', 'ç' => 'c', 'Ñ' => 'n', 'ñ' => 'n', ) ); return preg_replace( '/[^a-z0-9]+/', '', strtolower( $label ) ); } /** * Describe which merchant guide route serves the product for analytics. * * @param array $status Normalised storefront status. * @param array $context Product id and category slugs. * @return string collection, shop_default, or none. */ public static function size_guide_resolution( $status, $context ) { $size = isset( $status['size'] ) && is_array( $status['size'] ) ? $status['size'] : array(); $guide = self::resolve_size_guide( $size, $context ); if ( ! $guide ) { return 'none'; } $id = isset( $context['productId'] ) ? (string) $context['productId'] : ''; if ( in_array( $id, isset( $guide['categories']['productIds'] ) ? $guide['categories']['productIds'] : array(), true ) ) { return 'product_id'; } return empty( $guide['categories']['categories'] ) ? 'shop_default' : 'collection'; } /** Product > category > fallback; first guide in server order wins ties. */ private static function resolve_size_guide( $size, $context ) { $guides = isset( $size['guides'] ) && is_array( $size['guides'] ) ? $size['guides'] : array(); $id = isset( $context['productId'] ) ? (string) $context['productId'] : ''; $categories = isset( $context['categories'] ) && is_array( $context['categories'] ) ? $context['categories'] : array(); $fallback = null; $category = null; foreach ( $guides as $guide ) { if ( ! is_array( $guide ) ) { continue; } $c = isset( $guide['categories'] ) ? $guide['categories'] : array(); if ( ! is_array( $c ) ) { continue; } $ids = isset( $c['productIds'] ) ? $c['productIds'] : array(); $cats = isset( $c['categories'] ) ? $c['categories'] : array(); if ( ! is_array( $ids ) || ! is_array( $cats ) ) { continue; } if ( '' !== $id && in_array( $id, $ids, true ) ) { return $guide; } if ( null === $category && ! empty( array_intersect( $cats, $categories ) ) ) { $category = $guide; } if ( null === $fallback && empty( $ids ) && empty( $cats ) ) { $fallback = $guide; } } return null !== $category ? $category : $fallback; } /** * Register (do not yet enqueue) the bootstrap loader. * * The bootstrap is deliberately tiny and synchronous-friendly so JS-delay * optimizers cannot break it. The heavy widget bundle is lazy-loaded by the * bootstrap itself on first intent (click/hover). * * @return void */ public function register_assets() { wp_register_script( self::BOOTSTRAP_HANDLE, AISTHETIX_TRYON_URL . 'assets/bootstrap.js', array(), AISTHETIX_TRYON_VERSION, true ); // Tell optimizers (and the browser) to leave this script alone. WordPress // core does not render arbitrary data-* keys from wp_script_add_data into // the