Estendendo o bloco de Checkout do WooCommerce com jQuery e PHP

O bloco de Checkout do WooCommerce passou a ser o padrão para novas instalações a partir da versão 8.3 do WooCommerce1. Dessa forma, aos poucos o shortcode [woocommerce_checkout] (Checkout clássico) começa a ser substituído pelo bloco de Checkout. Contudo o shortcode ainda continua disponível e provavelmente continuará por um bom tempo até que a maioria dos plugins sejam compatíveis com o bloco de Checkout.

Por enquanto (2024), não existem muitos tutoriais explicando como estender o bloco de Checkout. No blog do WooCommerce tem um bom tutorial: Extending the WooCommerce Checkout Block. Tentei seguir esse tutorial pelo menos três vezes e no final o resultado não era exatamente como o esperado. Alguns pontos não ficaram claros (não me lembro exatamente quais agora) e parece que nem tudo está documentado adequadamente ainda. É importante destacar que eu tenho experiência trabalhando com React e Redux.

Via de regra fazer com que um plugin já existente seja compatível com o bloco de Checkout não é uma tarefa trivial. Dessa forma, esse tutorial mostra uma forma de atualizar um (simples) plugin já existente para ser compatível com o bloco de Checkout usando jQuery. Apesar do React ter sido escolhido para o editor de blocos e o editor de blocos fazer parte do WordPress desde a versão 5.0 (2018)2, muitos plugins usam o jQuery. Então a ideia é apresentar algo novo usando algo que já é familiar.

Plugin: adiciona produto aleatório ao carrinho

O plugin de exemplo exibe o botão “Add Random Product” na página de checkout acima dos métodos de pagamento. Ao clicar no botão, um produto aleatório é adicionado ao carrinho. Além disso, o nome do produto aleatório adicionado aparece no carrinho com “(Random)” para identificar que foi adicionado pelo plugin. No gif abaixo podemos ver o funcionamento do plugin na página de checkout clássico (usando o shortcode [woocommerce_checkout]).

Abaixo segue o código do plugin. Não entrarei muito em detalhes, mas o funcionamento do plugin consiste em:

  1. Exibir o botão “Add Random Product” caso não tenha nenhum produto adicionado dessa forma no carrinho
    • É utilizado o hook woocommerce_review_order_before_payment3 para adicionar o botão.
  2. Ao clicar no botão, uma requisição AJAX4 é enviada via jQuery com a action add_random_product.
  3. No backend (PHP), ao receber a requisição, um produto aleatório é adicionado ao carrinho
  4. Por fim, no caso da requisição ser executada com sucesso, é disparada uma ação via jQuery para que o carrinho seja atualizado e o produto aleatório seja exibido

<?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

Compatibilidade com o bloco de Checkout

Vamos editar o conteúdo da página de Checkout removendo o shortcode [woocommerce_checkout] e adicionando o bloco de Checkout. Atualizando a página de Checkout, temos o seguinte resultado:

Como podemos perceber o botão “Add Random Product” não apareceu no bloco de Checkout. Isso porque o hook woocommerce_review_order_before_payment não está disponível no bloco de Checkout e também não existe um equivalente direto para esse hook. Isso também acontece com outros hooks como woocommerce_review_order_after_order_total e woocommerce_review_order_before_submit.

Para termos um comportamento semelhante ao que tinhamos com o shortcode, precisamos usar uma outra abordagem. Para exemplificar melhor o que precisamos fazer, vamos editar a página de Checkout e olhar mais de perto o bloco de Checkout. O bloco de Checkout é formado por vários outros blocos e o que queremos fazer é adicionar o botão “Add Random Product” logo após o bloco Order Summary.

Para isso, podemos utilizar o hook render_block_{$this->name}5. Iremos substituir a parte variável do hook ($this->name) pelo nome do bloco Order Summary: woocommerce/checkout-order-summary-block. Por se tratar de um hook do tipo filtro, a marcação HTML do botão é concatenada ao conteúdo do bloco para que seja retornado.

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;
	}
);

Com isso, o botão “Add Random Product” passa a aparecer na página de Checkout.

Também é possível utilizar o filtro render_block6, mas usando esse filtro é preciso fazer uma verificação adicional para que a lógica seja aplicada apenas no bloco Order Summary pois todos os blocos passam por esse filtro.

Usando essa abordagem, podemos adicionar um conteúdo extra antes ou depois de um bloco por meio do filtro render_block_{$this->name}.

Se clicarmos no botão Add Random Product, podemos notar que a requisição para adicionar um produto aleatório é feita. Além disso, o botão desaparece da tela (indicando que a requisição foi processada com sucesso). Entretanto, apesar do produto ter sido adicionado, o carrinho não mostra o produto. É preciso recarregar a página para que apareça.

Ao recarregar a página podemos ver o produto que foi adicionado.

Apesar da requisição ter sido processada no PHP adicionando um produto aleatório, o bloco de Checkout não sabe que o produto foi adicionado e que é preciso atualizar o estado do bloco para que essa mudança seja refletida na interface. De acordo com a documentação do WooCommerce7, uma atualização direta no estado do bloco de Checkout não é permitida para evitar que dados mal formatados ou inválidos sejam inseridos e impeça o seu correto funcionamento.

Dessa forma, quando um processamento ocorre no servidor e isso causa uma mudança no estado do Checkout, o WooCommerce fornece duas funções para que o processamento ocorra no servidor e que as mudanças sejam refletidas no frontend: woocommerce_store_api_register_update_callback e extensionCartUpdate8.

A função woocommerce_store_api_register_update_callback permite que uma função de callback seja registrada no backend e executada quando sinalizada pelo frontend. Assim, não utilizaremos o endpoint AJAX do WordPress como fizemos para o shortcode para processar a requisição feita no frontend.

Registrando a função de callback :

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

A função extensionCartUpdate permite que uma requisição seja enviada do frontend para o endpoint cart/extensions para que a função de callback seja executada no servidor e que o novo estado do carrinho seja retornado e o bloco atualizado. Dessa forma, ao invés de utilzarmos a função $.post do jQuery para fazer a requisição, vamos usar a função extensionCartUpdate:

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;
	}

	[...]
}

Com essas mudanças, temos no bloco de Checkout um resultado próximo do que já temos no Checkout clássico com shortcode:

Note que o produto aleatório é adicionado ao carrinho, mas sem o “(Random)” depois do nome. No Checkout clássico, usamos o filtro woocommerce_cart_item_name9 no PHP. No bloco de Checkout temos que usar o filtro itemName10 no JavaScript. Esse filtro é adicionado usando a função registerCheckoutFilters11 passando como parâmetros um namespace (que normalmente é o slug do plugin e além disso precisa ser único) e um objeto com o nome do filtro e a função de callback.

A função de callback do filtro itemName recebe três parâmetros: defaultValue, extensions e args. Mas nenhum desses parâmtros possui a informação que precisamos para indicar que o produto foi adicionado de forma aleatória. Mesmo adicionando o parâmetro random-product como metadado quando o produto é adicionado ao carrinho. Veja abaixo um exemplo de saída dos parâmetros do filtro itemName.

Isso porque apenas os dados padrão do WooCommerce estão disponíveis. Qualquer dado extra precisa ser explicitamente declarado e será acessível pelo parâmetro extensions. Para isso precisamos extender o endpoint wc/store/cart/items usando a função woocommerce_store_api_register_endpoint_data12:

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'] ),
			];
		},
	]
);

Ao analisar novamente o parâmetro extension do filtro itemName, podemos ver que agora temos a informação que precisamos para determinar se o produto foi adicinado de forma aleatória.

Com essa informação podemos adicionar o filtro itemName:

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;
	}

	[...]
});

O nosso plugin já funciona como no Checkout clássico. Por fim, vamos declarar que o nosso plugin é compatível com o bloco de Checkout13. Para isso, é preciso adicionar o código abaixo no arquivo principal do plugin:

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

Resumo

  • Hooks como woocommerce_review_order_after_order_total e woocommerce_review_order_before_submit não estão disponíveis e não possuem um equivalente direto no bloco de Checkout
  • É possível ter um comportamento semelhante usando o filtro render_block_{$this->name}

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>';
	}
);

  • Se você está usando os hooks wp_ajax_ ou wp_ajax_nopriv_ para tratar uma requisição feita via AJAX na página de Checkout, você pode registrar um função de callback com a função woocommerce_store_api_register_update_callback para que o bloco de Checkout seja atualizado caso o seu estado tenha mudado (por exemplo um novo produto foi adicionado ao carrinho)

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
				},
			)
		);
	}
);

  • Se você usa o jQuery (ou outra biblioteca) para fazer requisições HTTP, talvez você precise atualizar para a função extensionCartUpdate para que o bloco de Checkout seja atualizado.

Código final

O código abaixo é o resultado final e funciona usando o shortcode [woocommerce_checkout] e no bloco de Checkout.

<?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

Notas de rodapé

  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/ ↩︎