get_type() ? ProductType::SIMPLE : $product->get_type(); } /** * Enqueue assets specific to this block. * We enqueue frontend scripts only if the product type has a block template * part (that's WC core product types and extensions that migrated to block * templates). * * @param array $attributes Block attributes. * @param string $content Block content. * @param \WP_Block $block Block instance. * * @return void */ protected function enqueue_assets( $attributes, $content, $block ) { $product_id = ( is_object( $block ) && property_exists( $block, 'context' ) && is_array( $block->context ) && array_key_exists( 'postId', $block->context ) ) ? $block->context['postId'] : null; if ( isset( $product_id ) ) { $rendered_product = wc_get_product( $product_id ); if ( $rendered_product instanceof \WC_Product ) { $product_type = $this->get_product_type_for_add_to_cart_template( $rendered_product ); $template_part_path = $this->get_template_part_path( $product_type ); if ( is_string( $template_part_path ) && '' !== $template_part_path && file_exists( $template_part_path ) ) { wp_enqueue_script_module( 'woocommerce/add-to-cart-with-options' ); } } } parent::enqueue_assets( $attributes, $content, $block ); } /** * Extra data passed through from server to client for block. * * @param array $attributes Any attributes that currently are available from the block. * Note, this will be empty in the editor context when the block is * not in the post content on editor load. * @return void */ protected function enqueue_data( array $attributes = array() ): void { parent::enqueue_data( $attributes ); if ( is_admin() ) { $this->asset_data_registry->add( 'productTypes', wc_get_product_types() ); $this->asset_data_registry->add( 'addToCartWithOptionsTemplatePartIds', $this->get_template_part_ids() ); $this->asset_data_registry->add( 'wishlistFeatureEnabled', $this->is_wishlist_enabled() ); } } /** * Whether the wishlist feature is enabled. * * Gates the render-time injection of the Add to Wishlist Button block (and * its editor preview), so the button only appears while the (experimental, * feature-flagged) wishlist feature is on. * * @return bool True if the wishlist feature flag is enabled, false otherwise. */ private function is_wishlist_enabled(): bool { return wc_get_container()->get( ShopperListsController::class )->is_enabled( 'wishlist' ); } /** * Get template part IDs for each product type. * * @return array Array of product types with their corresponding template part IDs. */ protected function get_template_part_ids() { $product_types = array_keys( wc_get_product_types() ); $current_theme = wp_get_theme()->get_stylesheet(); $template_part_ids = array(); foreach ( $product_types as $product_type ) { $slug = $product_type . '-product-add-to-cart-with-options'; // Check if theme template exists. $theme_has_template = BlockTemplateUtils::theme_has_template_part( $slug ); if ( $theme_has_template ) { $template_part_ids[ $product_type ] = "{$current_theme}//{$slug}"; } else { $template_part_ids[ $product_type ] = "woocommerce/woocommerce//{$slug}"; } } return $template_part_ids; } /** * Modifies the block context for product button blocks when inside the Add to Cart + Options block. * * @param array $context The block context. * @param array $block The parsed block. * @return array Modified block context. */ public function set_is_descendant_of_add_to_cart_with_options_context( $context, $block ) { if ( 'woocommerce/product-button' === $block['blockName'] ) { $context['woocommerce/isDescendantOfAddToCartWithOptions'] = true; } return $context; } /** * Check if HTML content has form elements. * * @param string $html_content The HTML content. * @return bool True if the HTML content has form elements, false otherwise. */ public function has_form_elements( $html_content ) { $processor = new \WP_HTML_Tag_Processor( $html_content ); $form_elements = array( 'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'FORM' ); while ( $processor->next_tag() ) { if ( in_array( $processor->get_tag(), $form_elements, true ) ) { return true; } } return false; } /** * Render the block. * * @param array $attributes Block attributes. * @param string $content Block content. * @param \WP_Block $block Block instance. * * @return string|void Rendered block output. */ protected function render( $attributes, $content, $block ) { global $product; $product_id = ( is_object( $block ) && property_exists( $block, 'context' ) && is_array( $block->context ) && array_key_exists( 'postId', $block->context ) ) ? $block->context['postId'] : null; if ( ! isset( $product_id ) ) { return ''; } $previous_product = $product; $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { $product = $previous_product; return ''; } $product_type = $this->get_product_type_for_add_to_cart_template( $product ); $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes, array(), array( 'extra_classes' ) ); $classes = implode( ' ', array_filter( array( 'wp-block-add-to-cart-with-options wc-block-add-to-cart-with-options', esc_attr( $classes_and_styles['classes'] ), ) ) ); $template_part_path = $this->get_template_part_path( $product_type ); if ( is_string( $template_part_path ) && '' !== $template_part_path && file_exists( $template_part_path ) ) { $slug = $product_type . '-product-add-to-cart-with-options'; $template_part_contents = ''; // Determine if we need to load the template part from the DB, the theme or WooCommerce in that order. $templates_from_db = BlockTemplateUtils::get_block_templates_from_db( array( $slug ), 'wp_template_part' ); if ( is_countable( $templates_from_db ) && count( $templates_from_db ) > 0 ) { $template_slug_to_load = $templates_from_db[0]->theme; } else { $theme_has_template_part = BlockTemplateUtils::theme_has_template_part( $slug ); $template_slug_to_load = $theme_has_template_part ? get_stylesheet() : BlockTemplateUtils::PLUGIN_SLUG; } $template_part = get_block_template( $template_slug_to_load . '//' . $slug, 'wp_template_part' ); if ( $template_part && ! empty( $template_part->content ) ) { $template_part_contents = $template_part->content; } if ( '' === $template_part_contents ) { $template_part_contents = file_get_contents( $template_part_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents } $default_quantity = $product->get_min_purchase_quantity(); $product_id = $product->get_id(); wp_interactivity_state( 'woocommerce/add-to-cart-with-options', array( 'isFormValid' => function () use ( $product_id ) { $product = wc_get_product( $product_id ); if ( $product instanceof \WC_Product && ( $product->is_type( ProductType::GROUPED ) || $product->has_options() ) ) { return false; } return true; }, ) ); wp_interactivity_config( 'woocommerce/add-to-cart-with-options', array( 'errorMessages' => array( 'invalidQuantities' => esc_html__( 'Please select a valid quantity to add to the cart.', 'woocommerce' ), 'groupedProductAddToCartMissingItems' => esc_html__( 'Please select some products to add to the cart.', 'woocommerce' ), 'variableProductMissingAttributes' => esc_html__( 'Please select product attributes before adding to cart.', 'woocommerce' ), 'variableProductOutOfStock' => sprintf( /* translators: %s: product name */ esc_html__( 'You cannot add "%s" to the cart because the product is out of stock.', 'woocommerce' ), $product->get_name() ), ), ) ); // Load product into the shared store with full REST API data. wc_interactivity_api_load_product( 'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce', $product->get_id() ); $context = array( 'quantity' => array( $product->get_id() => $default_quantity ), 'validationErrors' => array(), ); if ( $product->is_type( ProductType::VARIABLE ) ) { $context['selectedAttributes'] = array(); // Load all variations into the shared store with full REST API data. $variations = wc_interactivity_api_load_variations( 'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce', $product->get_id() ); // Set up quantity context for each variation. // We intentionally set the default quantity to the product's min purchase quantity // instead of the variation's min purchase quantity. That's because we use the same // input for all variations, so we want quantities to be in sync. foreach ( array_keys( $variations ) as $variation_id ) { $context['quantity'][ $variation_id ] = $default_quantity; } } elseif ( $product->is_type( ProductType::VARIATION ) ) { $variation_attributes = $product->get_variation_attributes(); $formatted_attributes = array_map( function ( $key, $value ) { return [ 'attribute' => $key, 'value' => $value, ]; }, array_keys( $variation_attributes ), $variation_attributes ); $context['selectedAttributes'] = $formatted_attributes; } elseif ( $product->is_type( ProductType::GROUPED ) ) { // Load purchasable child products into the shared store with full REST API data. $child_products = wc_interactivity_api_load_purchasable_child_products( 'I acknowledge that using experimental APIs means my theme or plugin will inevitably break in the next version of WooCommerce', $product->get_id() ); $context['groupedProductIds'] = array_keys( $child_products ); // Add quantity context for purchasable child products. $context['quantity'] = array_fill_keys( $context['groupedProductIds'], 0 ); // Set default quantity for each child product. foreach ( $child_products as $child_product_id => $child_product_data ) { $default_child_quantity = isset( $_POST['quantity'][ $child_product_id ] ) ? wc_stock_amount( wc_clean( wp_unslash( $_POST['quantity'][ $child_product_id ] ) ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing $context['quantity'][ $child_product_id ] = $default_child_quantity; // Check for any "sold individually" products and set their default quantity to 0. if ( $child_product_data['sold_individually'] ) { $context['quantity'][ $child_product_id ] = 0; } } } $hooks_before = ''; $hooks_after = ''; /** * Filter to disable the compatibility layer for the blockified templates. * * This hook allows to disable the compatibility layer for the blockified. * * @since 7.6.0 * @param boolean $is_disabled_compatibility_layer Whether the compatibility layer should be disabled. */ $is_disabled_compatibility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false ); if ( ! $is_disabled_compatibility_layer && ! Utils::is_not_purchasable_product( $product ) ) { ob_start(); /** * Hook: woocommerce_before_add_to_cart_form. * * @since 10.1.0 */ do_action( 'woocommerce_before_add_to_cart_form' ); if ( ProductType::SIMPLE === $product_type ) { /** * Hook: woocommerce_before_add_to_cart_quantity. * * @since 10.0.0 */ do_action( 'woocommerce_before_add_to_cart_quantity' ); /** * Hook: woocommerce_before_add_to_cart_button. * * @since 10.0.0 */ do_action( 'woocommerce_before_add_to_cart_button' ); } elseif ( ProductType::EXTERNAL === $product_type ) { /** * Hook: woocommerce_before_add_to_cart_button. * * @since 10.0.0 */ do_action( 'woocommerce_before_add_to_cart_button' ); } elseif ( ProductType::GROUPED === $product_type ) { /** * Hook: woocommerce_before_add_to_cart_button. * * @since 10.0.0 */ do_action( 'woocommerce_before_add_to_cart_button' ); } elseif ( ProductType::VARIABLE === $product_type ) { /** * Hook: woocommerce_before_variations_form. * * @since 10.0.0 */ do_action( 'woocommerce_before_variations_form' ); /** * Hook: woocommerce_after_variations_table. * * @since 10.0.0 */ do_action( 'woocommerce_after_variations_table' ); /** * Hook: woocommerce_before_single_variation. * * @since 10.0.0 */ do_action( 'woocommerce_before_single_variation' ); // WooCommerce uses `woocommerce_single_variation` to render // some UI elements like the Add to Cart button for // variations. We need to remove them to avoid those UI // elements being duplicate with the blocks. // We later add these actions back to avoid affecting other // blocks or templates. remove_action( 'woocommerce_single_variation', 'woocommerce_single_variation', 10 ); remove_action( 'woocommerce_single_variation', 'woocommerce_single_variation_add_to_cart_button', 20 ); /** * Hook: woocommerce_single_variation. * * @since 10.0.0 */ do_action( 'woocommerce_single_variation' ); if ( function_exists( 'woocommerce_single_variation' ) ) { add_action( 'woocommerce_single_variation', 'woocommerce_single_variation', 10 ); } if ( function_exists( 'woocommerce_single_variation_add_to_cart_button' ) ) { add_action( 'woocommerce_single_variation', 'woocommerce_single_variation_add_to_cart_button', 20 ); } /** * Hook: woocommerce_before_add_to_cart_button. * * @since 10.0.0 */ do_action( 'woocommerce_before_add_to_cart_button' ); /** * Hook: woocommerce_before_add_to_cart_quantity. * * @since 10.0.0 */ do_action( 'woocommerce_before_add_to_cart_quantity' ); } $hooks_before = ob_get_clean(); ob_start(); if ( ProductType::SIMPLE === $product_type ) { /** * Hook: woocommerce_after_add_to_cart_quantity. * * @since 10.0.0 */ do_action( 'woocommerce_after_add_to_cart_quantity' ); /** * Hook: woocommerce_after_add_to_cart_button. * * @since 10.0.0 */ do_action( 'woocommerce_after_add_to_cart_button' ); } elseif ( ProductType::EXTERNAL === $product_type ) { /** * Hook: woocommerce_after_add_to_cart_button. * * @since 10.0.0 */ do_action( 'woocommerce_after_add_to_cart_button' ); } elseif ( ProductType::GROUPED === $product_type ) { /** * Hook: woocommerce_after_add_to_cart_button. * * @since 10.0.0 */ do_action( 'woocommerce_after_add_to_cart_button' ); } elseif ( ProductType::VARIABLE === $product_type ) { /** * Hook: woocommerce_after_add_to_cart_quantity. * * @since 10.0.0 */ do_action( 'woocommerce_after_add_to_cart_quantity' ); /** * Hook: woocommerce_after_add_to_cart_button. * * @since 10.0.0 */ do_action( 'woocommerce_after_add_to_cart_button' ); /** * Hook: woocommerce_after_single_variation. * * @since 10.0.0 */ do_action( 'woocommerce_after_single_variation' ); /** * Hook: woocommerce_after_variations_form. * * @since 10.0.0 */ do_action( 'woocommerce_after_variations_form' ); } /** * Hook: woocommerce_after_add_to_cart_form. * * @since 10.1.0 */ do_action( 'woocommerce_after_add_to_cart_form' ); $hooks_after = ob_get_clean(); } // Add to Wishlist Button: when the wishlist feature is enabled, // inject the button as the last child of the template part so it // renders inside the form's iAPI scope (where it can read the // selected variation/attributes). The markup is injected at render // time only, never persisted to the shipped template parts. if ( $this->is_wishlist_enabled() ) { $template_part_contents .= "\n"; } // Because we are printing the template part using do_blocks, context from the outside is lost. // This filter is used to add the isDescendantOfAddToCartWithOptions context back. add_filter( 'render_block_context', array( $this, 'set_is_descendant_of_add_to_cart_with_options_context' ), 10, 2 ); $template_part_blocks = do_blocks( $template_part_contents ); remove_filter( 'render_block_context', array( $this, 'set_is_descendant_of_add_to_cart_with_options_context' ) ); $wrapper_attributes = array( 'class' => $classes, 'style' => esc_attr( $classes_and_styles['styles'] ), 'data-wp-interactive' => 'woocommerce/add-to-cart-with-options', 'data-wp-class--is-invalid' => '!state.isFormValid', ); $context_directive = wp_interactivity_data_wp_context( $context ); $cart_redirect_after_add = get_option( 'woocommerce_cart_redirect_after_add' ); $form_attributes = ''; $legacy_mode = 'yes' === $cart_redirect_after_add || $this->has_form_elements( $hooks_before ) || $this->has_form_elements( $hooks_after ); if ( $legacy_mode ) { $action_url = BlocksUtils::get_current_page_url(); // If an extension is hooking into the form or we need to redirect to the cart, // we fall back to a regular HTML form. $form_attributes = array( 'action' => esc_url( /** * Filter the add to cart form action. * * @since 10.0.0 * @param string $action_url The add to cart form action URL, defaulting to the current page. * @return string The add to cart form action URL. */ apply_filters( 'woocommerce_add_to_cart_form_action', $action_url ) ), 'method' => 'post', 'enctype' => 'multipart/form-data', 'class' => 'cart', ); } else { // Otherwise, we use the Interactivity API. $form_attributes = array( 'data-wp-on--submit' => 'actions.addToCart', ); } // These hidden inputs are used by extensions or Express Payment methods to gather information of the form state. $hidden_input = ''; if ( ProductType::SIMPLE === $product_type ) { $hidden_input = ''; } elseif ( ProductType::GROUPED === $product_type ) { $hidden_input = ''; } elseif ( ProductType::VARIABLE === $product_type ) { $hidden_input = '