get_id() ) ) { return null; } return $product; } /** * Main enqueue: product page or a product grid. * * @return void */ public function enqueue() { if ( ! self::active() ) { return; } $product = self::current_product(); if ( $product ) { if ( 'off' !== Settings::get( 'product_mode' ) ) { self::enqueue_product( $product ); } return; } if ( Settings::get( 'cards' ) && self::is_product_listing() ) { self::enqueue_cards(); } } /** * Shop, product category/tag/attribute archives and product search. * * @return bool */ private static function is_product_listing() { if ( function_exists( 'is_shop' ) && ( is_shop() || is_product_taxonomy() ) ) { return true; } return is_search() && 'product' === get_query_var( 'post_type' ); } /** * Product-page runtime. Also called by the "View in 3D" button block and * shortcode, which own the trigger (mode "button", placed in-place). * * @param \WC_Product $product Product. * @param bool $placed_button A block/shortcode renders the trigger. * @return void */ public static function enqueue_product( $product, $placed_button = false ) { $settings = Settings::all(); $mode = $settings['product_mode']; $data = array( 'accountId' => Connection::account_id(), // The storefront key is the WooCommerce product ID. Thridify aliases // it to whichever field the merchant matches on (SKU, slug, title), // so changing "Match products by" never touches the theme. 'custom_product_id' => (string) $product->get_id(), 'displayMode' => 'off' === $mode ? 'button' : $mode, 'triggerPosition' => $settings['trigger_position'], 'buttonText' => Settings::button_text(), 'inlineStartTab' => $settings['inline_start'], 'buttonPlacement' => $settings['button_placement'], 'threedIconPlaceHolder' => self::GALLERY_SELECTORS, ); if ( 'after-cta' === $settings['button_placement'] ) { $data['buttonPlaceholder'] = self::CTA_SELECTORS; } elseif ( 'after-description' === $settings['button_placement'] ) { $data['buttonPlaceholder'] = self::DESCRIPTION_SELECTORS; } if ( $placed_button ) { if ( 'icon' === $mode && ! self::$product_enqueued ) { $data['galleryIcon'] = true; // Keep the gallery icon next to the placed button. } $data['displayMode'] = 'button'; $data['buttonPlacement'] = 'in-place'; } if ( 'iframe' === $settings['render_mode'] ) { $data['renderMode'] = 'iframe'; $data['shopperBase'] = Env::host( 'shopper' ); } $context = array( 'surface' => 'product', 'product' => self::commerce_data( $product ), 'cart' => (bool) $settings['cart_from_viewer'], 'chooseOptions' => __( 'Please choose the product options first.', 'thridify' ), 'storeApi' => esc_url_raw( rest_url( 'wc/store/v1/' ) ), 'cartUrl' => esc_url_raw( wc_get_cart_url() ), 'attribution' => (bool) $settings['attribution'], 'selectors' => array( 'gallery' => self::GALLERY_SELECTORS, 'cta' => self::CTA_SELECTORS, 'description' => self::DESCRIPTION_SELECTORS, ), ); self::enqueue_runtime( $data, $context, $placed_button ); self::$product_enqueued = true; } /** * Shop-grid runtime: a 3D icon on each product card that has an experience. * * @return void */ private static function enqueue_cards() { $settings = Settings::all(); $data = array( 'accountId' => Connection::account_id() ); if ( 'iframe' === $settings['render_mode'] ) { $data['renderMode'] = 'iframe'; $data['shopperBase'] = Env::host( 'shopper' ); } $context = array( 'surface' => 'cards', 'accountId' => Connection::account_id(), 'maxCards' => (int) $settings['cards_max'], 'attribution' => (bool) $settings['attribution'], ); self::enqueue_runtime( $data, $context, false ); } /** * Enqueue our bootstrap, then Thridify's runtime after it. * * @param array $data window.thridifyData seed. * @param array $context window.thridifyWoo context for our bootstrap. * @param bool $placed_button A block/shortcode owns the trigger. * @return void */ private static function enqueue_runtime( array $data, array $context, $placed_button ) { if ( wp_script_is( self::JS_HANDLE, 'enqueued' ) ) { if ( $placed_button ) { // A block rendered after the page runtime (classic themes): the // block owns the trigger. Appended to the "before" seed so the // bootstrap keeps its defer strategy. wp_add_inline_script( self::JS_HANDLE, 'Object.assign(window.thridifyData=window.thridifyData||{},' . self::json( array( 'displayMode' => 'button', 'buttonPlacement' => 'in-place', 'galleryIcon' => 'icon' === Settings::get( 'product_mode' ), ) ) . ');', 'before' ); } return; } wp_enqueue_style( self::CSS_HANDLE, THRIDIFY_URL . 'assets/css/thridify-storefront.css', array(), THRIDIFY_VERSION ); $brand = (string) Settings::get( 'brand_color' ); $vars = ''; if ( '' !== $brand ) { $vars .= '--thridify-brand:' . $brand . ';'; } $radius = array( 'square' => '0', 'rounded' => '8px', 'pill' => '999px', ); $shape = (string) Settings::get( 'button_shape' ); if ( isset( $radius[ $shape ] ) ) { $vars .= '--thridify-btn-radius:' . $radius[ $shape ] . ';'; } if ( Settings::get( 'inherit_font' ) ) { $vars .= '--thridify-font:inherit;'; } if ( '' !== $vars ) { wp_add_inline_style( self::CSS_HANDLE, ':root{' . $vars . '}' ); } wp_enqueue_script( self::JS_HANDLE, THRIDIFY_URL . 'assets/js/thridify-storefront.js', array(), THRIDIFY_VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_add_inline_script( self::JS_HANDLE, 'window.thridifyData=Object.assign(' . self::json( $data ) . ',window.thridifyData||{});window.thridifyWoo=' . self::json( $context ) . ';', 'before' ); // Thridify's hosted runtime — the service this plugin connects to. wp_enqueue_script( self::VIEWER_HANDLE, Env::host( 'viewer' ) . '/floating-icon.js', array( self::JS_HANDLE ), null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- the hosted runtime is versioned by Thridify. array( 'in_footer' => true, 'strategy' => 'defer', ) ); } /** * Preconnect to the models CDN and preload this product's registration * answer (~1 KB) so the runtime's first lookup is already in flight. * * @return void */ public function resource_hints() { if ( ! self::active() ) { return; } $product = self::current_product(); $listing = Settings::get( 'cards' ) && self::is_product_listing(); $show_pdp = $product && 'off' !== Settings::get( 'product_mode' ); if ( ! $show_pdp && ! $listing ) { return; } $models = Env::host( 'models' ); printf( '' . "\n", esc_url( $models ) ); if ( $show_pdp ) { printf( '' . "\n", esc_url( $models . '/' . Connection::account_id() . '/products/' . rawurlencode( (string) $product->get_id() ) . '.json' ) ); } } /** * What the commerce bridge needs: the product's variations described by * merchant keys (SKU, option names). Prices and stock are not included. * * @param \WC_Product $product Product. * @return array */ public static function commerce_data( $product ) { $data = array( 'id' => $product->get_id(), 'sku' => (string) $product->get_sku(), 'type' => $product->get_type(), 'attributes' => array(), 'variations' => array(), ); if ( ! $product->is_type( 'variable' ) ) { return $data; } foreach ( $product->get_variation_attributes() as $name => $values ) { $data['attributes'][] = array( 'field' => 'attribute_' . sanitize_title( $name ), 'label' => wc_attribute_label( $name, $product ), ); } $children = array_slice( $product->get_children(), 0, self::MAX_VARIANTS ); foreach ( $children as $child_id ) { $variation = wc_get_product( $child_id ); if ( ! $variation || ! $variation->exists() || 'publish' !== $variation->get_status() ) { continue; } $fields = array(); $options = array(); foreach ( $variation->get_variation_attributes() as $field => $value ) { $fields[ $field ] = (string) $value; if ( '' === $value ) { continue; // "Any" value. } $taxonomy = substr( $field, strlen( 'attribute_' ) ); $term = taxonomy_exists( $taxonomy ) ? get_term_by( 'slug', $value, $taxonomy ) : false; $options[] = $term ? $term->name : (string) $value; } $data['variations'][] = array( 'id' => $variation->get_id(), 'sku' => (string) $variation->get_sku(), 'title' => implode( ' / ', $options ), 'options' => $options, 'fields' => $fields, 'available' => $variation->is_in_stock() && $variation->is_purchasable(), ); } return $data; } /** * JSON safe to print inside a