Extending the WooCommerce Checkout block with jQuery and PHP

The WooCommerce Checkout block is the default for new installs from WooCommerce version 8.31. That way, the shortcode [woocommerce_checkout] (Classic Checkout) is slowly replaced by the Checkout block. However, the shortcode is still available and will be for a long time until the majority of the plugins are compatible with the Checkout block.

At the moment (2024), there are not so many tutorials teaching how to extend the Checkout block. In the WooCommerce blog, there is a good one: Extending the WooCommerce Checkout Block. I tried to follow this tutorial at least three times but in the end the result was not working as expected. Some points weren’t clear (I don’t remember exactly which now) and it seems that some things are not well documented yet. It’s important to highlight that I have experience working with React and Redux.

Generally speaking, making an existing plugin compatible with the Checkout block is not a trivial task. That way, this tutorial shows a way to update a (simple) plugin to be compatible with the Checkout block using jQuery. Although React was chosen for the block editor and the block editor has been part of WordPress since version 5.0 (2018)2, a lot of plugins use jQuery. So the idea here is to present something new using something familiar.

Plugin: Adding random product to the cart

The example plugin shows the “Add Random Product” button on the Checkout page above the payment methods. When clicking the button, a random product is added to the cart. Other than that, the product name shows “(Random)” to identify that the product was added by the plugin. In the gif below, we can see the plugin in action on the Classic Checkout page (using the shortcode [woocommerce_checkout]).

You can see the plugin code below. I will not go deep into details, but the plugin works consist of:

  1. Show the “Add Random Product” button when there is no product added in this way to the cart
    • The action (hook) woocommerce_review_order_before_payment3 is used to add the button
  2. When clicking on the button, an AJAX4 request is sent via jQuery with the action add_random_product
  3. In the backend (PHP), the request is handled to add a random product to the cart
  4. When the request is processed successfully, an action is triggered via jQuery to update the products on the Checkout page so the random product can be shown.

<?php
/**
 * Plugin Name:       Add Random Product.
 * Description:       Add a random product to the cart.
 * Version:           1.0.0
 * Requires at least: 4.7.0
 * Requires PHP:      7.4
 * Author:            Ramon Ahnert
 * Author URI:        https://nomar.dev/
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       add-random-product
 */

namespace Add_Random_Product;

/**
 * Output Add Random Product button
 *
 * @return void
 */
function output_add_button() {
	if ( has_random_product_in_the_cart() ) {
		return;
	}

	wp_nonce_field( 'add-random-product', '_wpnonce-add-random-product' );
	?>
	<button
		class="add-random-product__button"
		style="margin: 0 auto 15px; display: block; text-align: center;"
		type="button"
	>
		<?php echo __( 'Add Random Product', 'add-random-product' ); ?>
	</button>
	<?php
}

add_action( 'woocommerce_review_order_before_payment', __NAMESPACE__ . '\\output_add_button' );

/**
 * Check if there is a random product in the cart.
 *
 * @return boolean
 */
function has_random_product_in_the_cart() {
	foreach ( WC()->cart->cart_contents as $cart_item ) {
		if ( empty( $cart_item['random-product'] ) ) {
			continue;
		}       return true;
	}   return false;
}

/**
 * Add a random product to the cart.
 *
 * @return string|bool
 */
function add_random_product_to_cart() {
	$products = wc_get_products(
		array(
			'limit'  => 15,
			'return' => 'ids',
		)
	);

	$random_number     = wp_rand( 0, count( $products ) - 1 );
	$random_product_id = $products[ $random_number ] ?? false;
	$product           = wc_get_product( $random_product_id );

	if ( ! $product ) {
		return false;
	}

	return WC()->cart->add_to_cart( $product->get_id(), 1, 0, array(), array( 'random-product' => true ) );
}

/**
 * Handle `add_random_product` AJAX request.
 *
 * @return void
 */
function handle_add_random_product_ajax_action() {
	if ( wp_verify_nonce( $_POST['_wpnonce-add-random-product'] ?? '' ) ) {
		wp_send_json_error( 'test', 400 );
	}

	if ( ! add_random_product_to_cart() ) {
		wp_send_json_error( null, 500 );
	}

	wp_send_json_success();
}

add_action( 'wp_ajax_nopriv_add_random_product', __NAMESPACE__ . '\\handle_add_random_product_ajax_action' );
add_action( 'wp_ajax_add_random_product', __NAMESPACE__ . '\\handle_add_random_product_ajax_action' );

add_filter(
	'woocommerce_cart_item_name',
	/**
	 * Append `(Random)` to the product name.
	 */
	function( $product_name, $cart_item ) {
		if ( empty( $cart_item['random-product'] ) ) {
			return $product_name;
		}

		// translators: %s: product name.
		return sprintf( esc_html__( '%s (Random)', 'add-random-product' ), $product_name );
	},
	10,
	2
);

add_action(
	'wp_print_footer_scripts',
	function() {
		if ( ! is_checkout() ) {
			return;
		}

		?>
		<script>
			jQuery(function($) {
				$( document ).on( 'click', '.add-random-product__button', function() {
					$( '.woocommerce-checkout-review-order-table' ).block({
						message: null,
						overlayCSS: {
							background: '#fff',
							opacity: 0.6
						}
					});

					$( this ).attr('disabled', true);

					$.post({
						url: wc_checkout_params.ajax_url,
						data: {
							'action': 'add_random_product',
							'_wpnonce-add-random-product': $( '#_wpnonce-add-random-product' ).val(),
						},
						success: function() {
							$( '.woocommerce-checkout-review-order-table' ).unblock();
							$( '.add-random-product__button' ).hide();
							$( 'body' ).trigger( 'update_checkout' );
						},
						error: function() {
							$( '.woocommerce-checkout-review-order-table' ).unblock();
							$( '.add-random-product__button' ).attr('disabled',false);
						}
					})
				})
			});
		</script>		
		<?php
	}
);
Expand

Compatibility with the Checkout block

Let’s edit the content of the Checkout page to remove the shortcode [woocommerce_checkout] and add the Checkout block. Reloading the Checkout page, we have the following result:

As we can notice the “Add Random Product” button doesn’t appear in the Checkout block. Because the action (hook) woocommerce_review_order_before_payment is not available in the Checkout block and there is not a direct equivalent for this hook. This happens with other hooks like woocommerce_review_order_after_order_total and woocommerce_review_order_before_submit.

If we want similar behaviour that we have using the shortcode, we need to use a different approach. So, let’s edit the Checkout page and take a look more closely at the Checkout block. The Checkout block is made by other blocks and we want to add the “Add Random Product” button right after the Order Summary block.

To achieve that, we can use the filter (hook) render_block_{$this->name}5. We will replace the dynamic portion of the hook name with the Order Summary block name: woocommerce/checkout-order-summary-block. Since the type of the hook is a filter, the HTML markup is appended to the block content to be returned.

add_filter(
	'render_block_woocommerce/checkout-order-summary-block',
	function( $block_content ) {
		ob_start();

		output_add_button();

		$add_button_html_markup = ob_get_clean();

		return $block_content . $add_button_html_markup;
	}
);

That way, the “Add Random Product” button shows on the Checkout page.

Also, it’s possible to use the render_block6 filter, but using this filter requires an additional check to apply the logic only to the Order Summary block since all blocks are passed to this filter.

This approach allows adding extra content before or after a block using the filter render_block_{$this->name}.

If we click on the “Add Random Product” button, we notice that a request to add a random product is made. Other than that, the button fades out of the screen (indicating that the request was successfully processed). However, although the product is added, the order summary doesn’t show the product. It’s necessary to reload the page.

After reloading the page we can see the product that was added.

Although the request has been processed by the PHP adding the random product, the Checkout block doesn’t know that a product was added and that is necessary to update the block state to reflect this change in the interface. Accordingly with the WooCommerce documentation7, a direct update to the Checkout block state is not allowed to prevent malformed or invalid data from being inserted and break the block.

That way, when something is processed on the server and it causes a change in the Checkout block state, WooCommerce provides two functions to process data on the server and to reflect the changes on the frontend: woocommerce_store_api_register_update_callback and extensionCartUpdate8.

The woocommerce_store_api_register_update_callback function allows a callback function to be registered on the backend to be executed when signalled by the frontend. So, we won’t use the AJAX endpoint as we did for the shortcode to process the request made in the frontend.

Registering the callback function:

add_action(
	'woocommerce_blocks_loaded',
	function() {
		woocommerce_store_api_register_update_callback(
			[
				'namespace' => 'add_random_product',
				'callback'  => function( $data ) {
					add_random_product_to_cart();
				},
			]
		);
	}
);

The extensionCartUpdate function allows a request to be sent from the frontend to the cart/extensions endpoint to execute the callback function in the server and that the new state of the cart to be returned and the block gets updated. That way, instead of using the jQuery $.post to send the request we will use the extensionCartUpdate function:

jQuery(function( $ ) {

	if ( 'undefined' !== typeof wc && wc?.blocksCheckout ) {
		$( document ).on( 'click', '.add-random-product__button', function() {
			const { extensionCartUpdate } = wc.blocksCheckout;

			$( '.wc-block-components-order-summary__content' ).block( {
				message: null,
				overlayCSS: {
					background: '#fff',
					opacity: 0.6
				}
			} );

			$( this ).attr( 'disabled', true );

			extensionCartUpdate(
				{
					namespace: 'add_random_product',
				}
			)
			.then( () => {
				$( '.add-random-product__button' ).hide();
			})
			.finally( () => {
				$( '.wc-block-components-order-summary__content' ).unblock();
				$( '.add-random-product__button' ).attr( 'disabled', false );	
			});
		})

		return;
	}

	[...]
}

With these changes, we have a closer result in the Checkout block to the Checkout using the shortcode:

Notice that the random product is added to the cart but without the “(Random)” after the product name. In the classic Checkout, we use the filter woocommerce_cart_item_name9 in the PHP. In the Checkout block, we have to use the filter itemName10 in the JavaScript. This filter is added using the registerCheckoutFilters11 function and we use a namespace (usually it is the plugin slug and has to be unique) and an object with the filter name and a callback function.

The itemName filter callback function has three parameters: defaultValue, extensions and args. However, these parameters don’t have the data we need to identify that a product was randomly added to the cart. Although we add the metadata random-product when a product is added to the cart. Look below for an output example of the parameters of the itemName filter.

By default, only the data added by WooCommerce are available. Any extra data should be explicitly declared and will be accessible via the extensions parameter. That said, we need to extend the wc/store/cart/items endpoint using the woocommerce_store_api_register_endpoint_data12 function:

use \Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema;

woocommerce_store_api_register_endpoint_data(
	[
		'endpoint'      => CartItemSchema::IDENTIFIER,
		'namespace'     => 'add_random_product',
		'data_callback' => function( $cart_item ) {
			return [
				'randomly_added' => ! empty( $cart_item['random-product'] ),
			];
		},
	]
);

Looking again at the extension parameter of the itemName filter, we can see that the data we need to determine if a product was randomly added or not is there.

Having this date we can add the itemName filter:

jQuery( function( $ ) {

	if ( 'undefined' !== typeof wc && wc?.blocksCheckout ) {
		[...]

		const { registerCheckoutFilters } = wc.blocksCheckout;
		const { sprintf, __ } = wp.i18n;

		registerCheckoutFilters(
			'add_random_product', 
			{
				itemName: function( defaultValue, extensions, args ) {
					if ( ! extensions?.add_random_product?.randomly_added ) {
						return defaultValue;
					}

					return sprintf( __( '%s (Random)' ), defaultValue );
				}
			}
		);

		return;
	}

	[...]
});

Our plugin works like in the classic Checkout. Last, we will declare that our plugin is compatible with the Checkout block13. To do that, it’s necessary to add the code below to the main plugin file:

add_action( 'before_woocommerce_init', function() {
    if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) {
        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
    }
} );

Summarizing

  • Actions (hooks) like woocommerce_review_order_after_order_total and woocommerce_review_order_before_submit are not available and do not have a direct equivalent in the Checkout block.
  • It’s possible to have a similar behaviour using the render_block_{$this->name} filter

add_action(
	'woocommerce_review_order_before_payment',
	function() {
		?>
		<div>My custom element</div>
		<?php
	}
);

// | | | |
// V V V V

add_filter(
	'render_block_woocommerce/checkout-order-summary-block',
	function( $block_content ) {
		return $block_content . '<div>My custom element</div>';
	}
);

  • If you are using the actions (hooks) wp_ajax_ or wp_ajax_nopriv_ to handle a request made via AJAX on the Checkout page, you can register a callback function using the woocommerce_store_api_register_update_callback function so the Checkout block will be updated in case the state gets changed (for example a new product was added to the cart)

add_action(
	'wp_ajax_my_action',
	function() {

		wp_send_json_success();
	}
);

// | | | |
// V V V V

add_action(
	'woocommerce_blocks_loaded',
	function() {
		woocommerce_store_api_register_update_callback(
			array(
				'namespace' => 'my_action',
				'callback'  => function( $data ) {
					// execute my action
				},
			)
		);
	}
);

  • If you use jQuery (or another lib) to send HTTP requests, maybe you will need to update it to the extensionCartUpdate function so the Checkout block can be updated.

Final code

The code below is the final result and works with the shortcode [woocommerce_checkout] and the Checkout block.

<?php
/**
 * Plugin Name:       Add Random Product.
 * Description:       Add a random product to the cart.
 * Version:           1.0.0
 * Requires at least: 4.7.0
 * Requires PHP:      7.4
 * Author:            Ramon Ahnert
 * Author URI:        https://nomar.dev/
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       add-random-product
 */

namespace Add_Random_Product;

add_action(
	'before_woocommerce_init',
	function() {
		if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) {
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
		}
	}
);

/**
 * Output Add Random Product button
 *
 * @return void
 */
function output_add_button() {
	if ( has_random_product_in_the_cart() ) {
		return;
	}

	$style = array(
		'margin: 0 auto 15px',
		'display: block',
		has_block( 'woocommerce/checkout' ) ? 'margin-top: 15px' : '',
	);

	wp_nonce_field( 'add-random-product', '_wpnonce-add-random-product' );
	?>
		<button
			class="add-random-product__button"
			style="<?php echo esc_attr( join( ';', $style ) ); ?>"
			type="button"
		>
			<?php echo __( 'Add Random Product', 'add-random-product' ); ?>
		</button>
	<?php
}

add_action( 'woocommerce_review_order_before_payment', __NAMESPACE__ . '\\output_add_button' );

add_filter(
	'render_block_woocommerce/checkout-order-summary-block',
	function( $block_content ) {
		ob_start();

		output_add_button();

		$add_button_html_markup = ob_get_clean();

		return $block_content . $add_button_html_markup;
	}
);

/**
 * Check if there is a random product in the cart.
 *
 * @return boolean
 */
function has_random_product_in_the_cart() {
	foreach ( WC()->cart->cart_contents ?? array() as $cart_item ) {
		if ( empty( $cart_item['random-product'] ) ) {
			continue;
		}

		return true;
	}

	return false;
}

/**
 * Add a random product to the cart.
 *
 * @return string|bool
 */
function add_random_product_to_cart() {
	$products = wc_get_products(
		array(
			'limit'  => 15,
			'return' => 'ids',
		)
	);

	$random_number = wp_rand( 0, count( $products ) - 1 );

	$random_product_id = $products[ $random_number ] ?? false;

	$product = wc_get_product( $random_product_id );

	if ( ! $product ) {
		return false;
	}

	return WC()->cart->add_to_cart(
		$product->get_id(),
		1,
		0,
		array(),
		array( 'random-product' => true )
	);
}

/**
 * Handle `add_random_product` AJAX request.
 *
 * @return void
 */
function handle_add_random_product_ajax_action() {
	if ( wp_verify_nonce( $_POST['_wpnonce-add-random-product'] ?? '' ) ) {
		wp_send_json_error( 'test', 400 );
	}

	if ( ! add_random_product_to_cart() ) {
		wp_send_json_error( null, 500 );
	}

	wp_send_json_success();
}

add_action( 'wp_ajax_nopriv_add_random_product', __NAMESPACE__ . '\\handle_add_random_product_ajax_action' );
add_action( 'wp_ajax_add_random_product', __NAMESPACE__ . '\\handle_add_random_product_ajax_action' );

add_action(
	'woocommerce_blocks_loaded',
	function() {
		woocommerce_store_api_register_update_callback(
			array(
				'namespace' => 'add_random_product',
				'callback'  => function( $data ) {
					add_random_product_to_cart();
				},
			)
		);

		woocommerce_store_api_register_endpoint_data(
			array(
				'endpoint'      => \Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema::IDENTIFIER,
				'namespace'     => 'add_random_product',
				'data_callback' => function( $cart_item ) {
					return array(
						'randomly_added' => ! empty( $cart_item['random-product'] ),
					);
				},
				'schema_type'   => ARRAY_A,
			)
		);
	}
);

add_filter(
	'woocommerce_cart_item_name',
	/**
	 * Append `(Random)` to the product name.
	 */
	function( $product_name, $cart_item ) {
		if ( empty( $cart_item['random-product'] ) ) {
			return $product_name;
		}

		// translators: %s: product name.
		return sprintf( esc_html__( '%s (Random)', 'add-random-product' ), $product_name );
	},
	10,
	2
);

add_action(
	'wp_print_footer_scripts',
	function() {
		if ( ! is_checkout() && ! has_block( 'woocommerce/checkout' ) ) {
			return;
		}

		?>
		<script>
			jQuery( function( $ ) {

				if ( 'undefined' !== typeof wc &&  wc?.blocksCheckout ) {
					$( document ).on( 'click', '.add-random-product__button', function() {
						const { extensionCartUpdate } = wc.blocksCheckout;

						$( '.wc-block-components-order-summary__content' ).block( {
							message: null,
							overlayCSS: {
								background: '#fff',
								opacity: 0.6
							}
						} );

						$( this ).attr( 'disabled', true );

						extensionCartUpdate( {
							namespace: 'add_random_product',
						} )
						.then( () => {
							$( '.add-random-product__button' ).hide();
						} )
						.finally(() => {
							$( '.wc-block-components-order-summary__content' ).unblock();
							$( '.add-random-product__button' ).attr( 'disabled', false );	
						});
					});

					const { registerCheckoutFilters } = wc.blocksCheckout;
					const { sprintf, __ } = wp.i18n;

					registerCheckoutFilters(
						'add_random_product', 
						{
							itemName: function( defaultValue, extensions, args ) {
								if ( ! extensions?.add_random_product?.randomly_added ) {
									return defaultValue;
								}

								return sprintf( __( '%s (Random)' ), defaultValue );
							}
						}
					);

					return;
				}

				$( document ).on( 'click', '.add-random-product__button', function() {
					$( '.woocommerce-checkout-review-order-table' ).block( {
						message: null,
						overlayCSS: {
							background: '#fff',
							opacity: 0.6
						}
					} );

					$( this ).attr( 'disabled', true );

					$.post({
						url: wc_checkout_params.ajax_url,
						data: {
							'action': 'add_random_product',
							'_wpnonce-add-random-product': $( '#_wpnonce-add-random-product' ).val(),
						},
						success: function() {
							$( '.woocommerce-checkout-review-order-table' ).unblock();	
							$( '.add-random-product__button' ).hide();
							$( 'body' ).trigger( 'update_checkout' );
						},
						error: function() {
							$( '.woocommerce-checkout-review-order-table' ).unblock();
							$( '.add-random-product__button' ).attr( 'disabled', false );
						}
					})
				})
			});
		</script>
		<?php
	}
);
Expand

Footnotes

  1. https://woocommerce.com/document/using-the-new-block-based-checkout/ ↩︎
  2. https://wordpress.org/documentation/article/wordpress-block-editor/ ↩︎
  3. https://woocommerce.github.io/code-reference/files/woocommerce-templates-checkout-payment.html#source-view.21 ↩︎
  4. https://developer.wordpress.org/plugins/javascript/ajax/ ↩︎
  5. https://developer.wordpress.org/reference/hooks/render_block_this-name/ ↩︎
  6. https://developer.wordpress.org/reference/hooks/render_block/ ↩︎
  7. https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/rest-api/extend-rest-api-update-cart.md#the-problem ↩︎
  8. https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/rest-api/extend-rest-api-update-cart.md#basic-usage ↩︎
  9. https://woocommerce.github.io/code-reference/files/woocommerce-templates-checkout-review-order.html#source-view.38 ↩︎
  10. https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/checkout-block/available-filters/cart-line-items.md#itemname ↩︎
  11. https://github.com/woocommerce/woocommerce/tree/trunk/plugins/woocommerce-blocks/packages/checkout/filter-registry#registercheckoutfilters ↩︎
  12. https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/rest-api/extend-rest-api-add-data.md#things-to-consider ↩︎
  13. https://developer.woocommerce.com/2023/11/06/faq-extending-cart-and-checkout-blocks/ ↩︎