Utils.php
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions;
use Automattic\WooCommerce\Enums\ProductType;
use WP_Block;
/**
* Utility methods used for the Add to Cart + Options block.
* {@internal This class and its methods are not intended for public use.}
*/
class Utils {
/**
* Check if the HTML content has a visible quantity input.
*
* @param string $html_content The HTML content.
* @return bool True if the HTML content has a visible input, false otherwise.
*/
public static function has_visible_quantity_input( $html_content ) {
$processor = new \WP_HTML_Tag_Processor( $html_content );
while ( $processor->next_tag() ) {
if (
$processor->get_tag() === 'INPUT' &&
$processor->has_class( 'qty' ) &&
$processor->get_attribute( 'type' ) !== 'hidden'
) {
return true;
}
}
return false;
}
/**
* Add increment and decrement buttons to the quantity input field.
*
* @param string $quantity_html Quantity input HTML.
* @param string $product_name Product name.
* @return string Quantity input HTML with increment and decrement buttons.
*/
public static function add_quantity_steppers( $quantity_html, $product_name ) {
// Regex pattern to match the <input> element with id starting with 'quantity_'.
$pattern = '/(<input[^>]*id="quantity_[^"]*"[^>]*\/>)/';
// Replacement string to add button AFTER the matched <input> element.
// Use preg_replace_callback to avoid backreference interpretation of $, \ sequences in product names.
$new_html = preg_replace_callback(
$pattern,
function ( $matches ) use ( $product_name ) {
/* translators: %s refers to the item name in the cart. */
$plus_aria = esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) );
return $matches[1] . '<button aria-label="' . $plus_aria . '" type="button" data-wp-on--click="actions.increaseQuantity" data-wp-bind--disabled="!state.allowsIncrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
},
$quantity_html ?? ''
);
$new_html = preg_replace_callback(
$pattern,
function ( $matches ) use ( $product_name ) {
/* translators: %s refers to the item name in the cart. */
$minus_aria = esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) );
return $matches[1] . '<button aria-label="' . $minus_aria . '" type="button" data-wp-on--click="actions.decreaseQuantity" data-wp-bind--disabled="!state.allowsDecrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>';
},
$new_html ?? ''
);
return $new_html ?? '';
}
/**
* Add classes to the Quantity Selector needed for the stepper style.
*
* @param string $quantity_html The Quantity Selector HTML.
*
* @return string The Quantity Selector HTML with classes added.
*/
public static function add_quantity_stepper_classes( $quantity_html ) {
$processor = new \WP_HTML_Tag_Processor( $quantity_html );
while ( $processor->next_tag() ) {
if (
$processor->get_tag() === 'DIV' &&
$processor->has_class( 'quantity' )
) {
$processor->add_class( 'wc-block-components-quantity-selector' );
}
if (
$processor->get_tag() === 'INPUT' &&
$processor->has_class( 'qty' ) &&
$processor->get_attribute( 'type' ) !== 'hidden'
) {
$processor->add_class( 'wc-block-components-quantity-selector__input' );
}
}
return $processor->get_updated_html();
}
/**
* Make the quantity input interactive by wrapping it with the necessary data attribute and adding a blur event listener.
*
* @param string $quantity_html The quantity HTML.
* @param array $wrapper_attributes Optional wrapper attributes.
* @param array $input_attributes Optional input attributes.
* @param array $context {
* Optional context for quantity input.
* @type int $productId Product ID for context-specific behavior.
* @type bool $allowZero Whether to allow zero quantity.
* }
* @param bool $set_product_context Whether to set a local woocommerce/products context on the wrapper.
* Only needed when the quantity input belongs to a different product than
* the one provided by the inherited context (e.g. child items in grouped products).
* Setting this unnecessarily shadows the parent context and prevents
* variationId updates from propagating.
*
* @return string The quantity HTML with interactive wrapper.
*/
public static function make_quantity_input_interactive( $quantity_html, $wrapper_attributes = array(), $input_attributes = array(), $context = array(), $set_product_context = false ) {
$processor = new \WP_HTML_Tag_Processor( $quantity_html );
global $product;
if ( $set_product_context && $product instanceof \WC_Product ) {
$product_context = array(
'productId' => $product->get_id(),
'variationId' => null,
);
$products_context = 'woocommerce/products::' . wp_json_encode( $product_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP );
// This moves the `woocommerce/products` context to a nested `div`,
// as multiple context directives are not supported in the same
// element in WordPress 6.8. Once WooCommerce drops support for
// WordPress 6.8, this code can be refactored.
if (
$processor->next_tag(
array(
'tag_name' => 'div',
'class_name' => 'quantity',
)
)
) {
$processor->set_attribute( 'data-wp-context', $products_context );
} else {
// If filtered markup omits the `div.quantity`, reinitialize the
// processor so the input bindings below still execute.
$processor = new \WP_HTML_Tag_Processor( $quantity_html );
}
}//end if
if (
$processor->next_tag( 'input' ) &&
$processor->get_attribute( 'type' ) === 'number' &&
strpos( $processor->get_attribute( 'name' ), 'quantity' ) !== false
) {
$default_quantity = $product instanceof \WC_Product ? $product->get_min_purchase_quantity() : 1;
$input_quantity = isset( $context['allowZero'] ) && true === $context['allowZero'] ? 0 : $default_quantity;
wp_interactivity_state(
'woocommerce/add-to-cart-with-options-quantity-selector',
array(
'inputQuantity' => $input_quantity,
)
);
$processor->set_attribute( 'data-wp-on--blur', 'actions.handleQuantityBlur' );
$processor->set_attribute( 'data-wp-bind--value', 'state.inputQuantity' );
foreach ( $input_attributes as $attribute => $value ) {
$processor->set_attribute( $attribute, $value );
}
}
$quantity_html = $processor->get_updated_html();
$wrapper_attributes = array_merge(
array(
'data-wp-interactive' => 'woocommerce/add-to-cart-with-options-quantity-selector',
'data-wp-init' => 'callbacks.storeInputElementRef',
),
$wrapper_attributes
);
$context_attribute = wp_interactivity_data_wp_context( $context );
return sprintf(
'<div %1$s %2$s>%3$s</div>',
get_block_wrapper_attributes( $wrapper_attributes ),
$context_attribute,
$quantity_html
);
}
/**
* Get product from block context.
*
* @param \WP_Block $block The block instance.
* @param \WC_Product|null $previous_product The previous product (usually from global scope).
* @return \WC_Product|null The product instance or null if not found.
*/
public static function get_product_from_context( $block, $previous_product ) {
$post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
$product = null;
if ( ! empty( $post_id ) ) {
$product = wc_get_product( $post_id );
}
if ( ! $product instanceof \WC_Product && $previous_product instanceof \WC_Product ) {
$product = $previous_product;
}
return $product instanceof \WC_Product ? $product : null;
}
/**
* Check if a product is not purchasable or not in stock.
*
* @param \WC_Product $product The product to check.
* @return bool True if the product is not purchasable or not in stock.
*/
public static function is_not_purchasable_product( $product ) {
if ( $product->is_type( ProductType::SIMPLE ) ) {
return ! $product->is_in_stock() || ! $product->is_purchasable();
} elseif ( $product->is_type( ProductType::VARIABLE ) ) {
return ! $product->is_in_stock() || ! $product->has_purchasable_variations();
}
return false;
}
/**
* Renders a new block with custom context
*
* @param WP_Block $block The block instance.
* @param array $context The context for the new block.
* @return string Rendered block content
*/
public static function render_block_with_context( $block, $context ) {
// Get an instance of the current block.
$block_instance = $block->parsed_block;
// Create new block with custom context.
$new_block = new WP_Block(
$block_instance,
$context
);
// Render with dynamic set to false to prevent calling render_callback.
return $new_block->render( array( 'dynamic' => false ) );
}
/**
* Check if min and max purchase quantity are the same for a product.
*
* @param \WC_Product $product The product to check.
* @return bool True if min and max purchase quantity are the same, false otherwise.
*/
public static function is_min_max_quantity_same( $product ) {
$min_purchase_quantity = $product->get_min_purchase_quantity();
$max_purchase_quantity = $product->get_max_purchase_quantity();
return $min_purchase_quantity === $max_purchase_quantity;
}
/**
* Get the quantity constraints for a product.
*
* @param \WC_Product $product The product to get the quantity constraints for.
* @return array The quantity constraints.
*/
public static function get_product_quantity_constraints( $product ) {
$min = is_numeric( $product->get_min_purchase_quantity() ) ? $product->get_min_purchase_quantity() : 1;
$max_quantity = $product->get_max_purchase_quantity();
$max = is_numeric( $max_quantity ) && -1 !== $max_quantity ? $max_quantity : null;
$step = is_numeric( $product->get_purchase_quantity_step() ) ? $product->get_purchase_quantity_step() : 1;
return array(
'min' => $min,
'max' => $max,
'step' => $step,
);
}
}