Commit 9d809cf541 for woocommerce
commit 9d809cf54168963534f55feba61d8e0f388642b9
Author: Francesco <frosso@users.noreply.github.com>
Date: Thu Feb 5 14:48:05 2026 +0100
feat: custom place order button by payment method (shortcode) (#62509)
diff --git a/plugins/woocommerce/.wp-env.json b/plugins/woocommerce/.wp-env.json
index 6c858cc72a..35d8661093 100644
--- a/plugins/woocommerce/.wp-env.json
+++ b/plugins/woocommerce/.wp-env.json
@@ -32,6 +32,7 @@
"wp-content/plugins/filter-setter.php": "./tests/e2e-pw/bin/filter-setter.php",
"wp-content/plugins/process-waiting-actions.php": "./tests/e2e-pw/bin/process-waiting-actions.php",
"wp-content/plugins/test-helper-apis.php": "./tests/e2e-pw/bin/test-helper-apis.php",
+ "wp-content/plugins/custom-place-order-button-test.php": "./client/blocks/tests/e2e/plugins/custom-place-order-button-test.php",
"test-data/images/": "./tests/e2e-pw/test-data/images/"
}
}
diff --git a/plugins/woocommerce/changelog/62138-feat-wc-blocks-dynamic-place-order-button b/plugins/woocommerce/changelog/62138-feat-wc-blocks-dynamic-place-order-button
index 51e2f132a0..a78d74f6e5 100644
--- a/plugins/woocommerce/changelog/62138-feat-wc-blocks-dynamic-place-order-button
+++ b/plugins/woocommerce/changelog/62138-feat-wc-blocks-dynamic-place-order-button
@@ -1,4 +1,4 @@
-Significance: patch
+Significance: minor
Type: update
feat: allow payment methods to render a custom "Place order" button on block-based checkout.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/feat-wc-shortcode-dynamic-place-order-button b/plugins/woocommerce/changelog/feat-wc-shortcode-dynamic-place-order-button
new file mode 100644
index 0000000000..fbc97ae994
--- /dev/null
+++ b/plugins/woocommerce/changelog/feat-wc-shortcode-dynamic-place-order-button
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+feat: allow payment methods to render a custom "Place order" button on shortcode-based checkout.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/plugins/custom-place-order-button-test.php b/plugins/woocommerce/client/blocks/tests/e2e/plugins/custom-place-order-button-test.php
index 7ec2490f14..17fbb36639 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/plugins/custom-place-order-button-test.php
+++ b/plugins/woocommerce/client/blocks/tests/e2e/plugins/custom-place-order-button-test.php
@@ -25,14 +25,15 @@ add_action(
* Constructor.
*/
public function __construct() {
- $this->id = 'test-custom-button';
- $this->method_title = 'Test Custom Button';
- $this->method_description = 'Test payment method with custom place order button';
- $this->title = 'Test Custom Button Payment';
- $this->description = 'Test payment method for e2e testing custom place order button';
- $this->has_fields = false;
- $this->supports = array( 'products' );
- $this->enabled = 'yes';
+ $this->id = 'test-custom-button';
+ $this->method_title = 'Test Custom Button';
+ $this->method_description = 'Test payment method with custom place order button';
+ $this->title = 'Test Custom Button Payment';
+ $this->description = 'Test payment method for e2e testing custom place order button';
+ $this->has_fields = false;
+ $this->supports = array( 'products' );
+ $this->enabled = 'yes';
+ $this->has_custom_place_order_button = true; // For shortcode checkout.
}
/**
@@ -94,9 +95,9 @@ add_action(
* @return array
*/
public function get_payment_method_script_handles() {
- wp_register_script( 'test-custom-button', '', array( 'wc-blocks-registry' ), '1.0.0', true );
- wp_add_inline_script( 'test-custom-button', $this->get_inline_script() );
- return array( 'test-custom-button' );
+ wp_register_script( 'test-custom-button-blocks', '', array( 'wc-blocks-registry' ), '1.0.0', true );
+ wp_add_inline_script( 'test-custom-button-blocks', $this->get_inline_script() );
+ return array( 'test-custom-button-blocks' );
}
/**
@@ -164,3 +165,63 @@ JS;
);
}
);
+
+add_action(
+ 'wp_enqueue_scripts',
+ function () {
+ if ( ! function_exists( 'is_checkout' ) || ! function_exists( 'is_wc_endpoint_url' ) ) {
+ return;
+ }
+
+ $is_shortcode_checkout = is_checkout() && ! has_block( 'woocommerce/checkout' );
+ $is_pay_for_order = is_wc_endpoint_url( 'order-pay' );
+ $is_add_payment_method = function_exists( 'is_add_payment_method_page' ) && is_add_payment_method_page();
+
+ if ( ! $is_shortcode_checkout && ! $is_pay_for_order && ! $is_add_payment_method ) {
+ return;
+ }
+
+ wp_register_script(
+ 'test-custom-button-shortcode',
+ '',
+ array( 'jquery', 'wc-custom-place-order-button' ),
+ '1.0.0',
+ true
+ );
+
+ $inline_script = <<<'JS'
+(function($) {
+ 'use strict';
+
+ wc.customPlaceOrderButton.register('test-custom-button', {
+ render: function(container, api) {
+ var $button = $('<button>', {
+ type: 'button',
+ 'data-testid': 'custom-place-order-button',
+ 'class': 'button alt',
+ text: 'Custom Payment Button',
+ });
+
+ $button.on('click', function(e) {
+ e.preventDefault();
+
+ api.validate().then(function(result) {
+ if (result.hasError) {
+ return;
+ }
+
+ api.submit();
+ });
+ });
+
+ $(container).append($button);
+ },
+ cleanup: function() {},
+ });
+})(jQuery);
+JS;
+
+ wp_add_inline_script( 'test-custom-button-shortcode', $inline_script );
+ wp_enqueue_script( 'test-custom-button-shortcode' );
+ }
+);
diff --git a/plugins/woocommerce/client/legacy/css/woocommerce.scss b/plugins/woocommerce/client/legacy/css/woocommerce.scss
index ba34cf7401..540ab2dcd9 100644
--- a/plugins/woocommerce/client/legacy/css/woocommerce.scss
+++ b/plugins/woocommerce/client/legacy/css/woocommerce.scss
@@ -2329,3 +2329,13 @@ body:not(.search-results) .twentysixteen .entry-summary {
background: inherit;
color: inherit;
}
+
+/**
+ * Hides default button when a gateway with custom "place order" button is selected.
+ * This applies to checkout, order-pay, and add-payment-method pages.
+ *
+ * @since 10.6.0
+ */
+form.has-custom-place-order-button #place_order {
+ display: none !important;
+}
diff --git a/plugins/woocommerce/client/legacy/js/frontend/add-payment-method.js b/plugins/woocommerce/client/legacy/js/frontend/add-payment-method.js
index 1ccdefac3c..014726e907 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/add-payment-method.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/add-payment-method.js
@@ -5,11 +5,46 @@ jQuery( function( $ ) {
return false;
}
- $( '#add_payment_method' )
+ var $form = $( '#add_payment_method' );
- /* Payment option selection */
+ /**
+ * Create the API object passed to custom place order button render callbacks.
+ * This is specific to the add-payment-method page.
+ *
+ * @return {Object} API object with validate and submit methods
+ */
+ function createAddPaymentMethodApi() {
+
+ return {
+ /**
+ * Validate the form.
+ * For add payment method, there's minimal validation - the payment gateway handles most of it.
+ *
+ * @return {Promise<{hasError: boolean}>} Promise resolving to a validation result
+ */
+ validate: function() {
+ return new Promise( function( resolve ) {
+ // The "add payment method" page has no form validation needs.
+ resolve( { hasError: false } );
+ } );
+ },
+
+ /**
+ * Submit the "add payment method" form.
+ */
+ submit: function() {
+ $form.trigger( 'submit' );
+ }
+ };
+ }
+
+ // When a gateway registers after a page load, render its button if it's selected.
+ $( document.body ).on( 'wc_custom_place_order_button_registered', function( e, gatewayId ) {
+ wc.customPlaceOrderButton.__maybeShow( gatewayId, createAddPaymentMethodApi() );
+ } );
- .on( 'click init_add_payment_method', '.payment_methods input.input-radio', function() {
+ /* Payment option selection */
+ $form.on( 'click init_add_payment_method', '.payment_methods input.input-radio', function() {
if ( $( '.payment_methods input.input-radio' ).length > 1 ) {
var target_payment_box = $( 'div.payment_box.' + $( this ).attr( 'ID' ) );
if ( $( this ).is( ':checked' ) && ! target_payment_box.is( ':visible' ) ) {
@@ -21,13 +56,23 @@ jQuery( function( $ ) {
} else {
$( 'div.payment_box' ).show();
}
- })
+
+ // Handle custom place order button for selected gateway
+ wc.customPlaceOrderButton.__maybeShow( $( this ).val(), createAddPaymentMethodApi() );
+ });
+
+ // Hide default button immediately if initially selected gateway has custom button.
+ // This must happen BEFORE triggering click to prevent flash of the default button.
+ var $initialPaymentMethod = $form.find( 'input[name="payment_method"]:checked' );
+ if ( $initialPaymentMethod.length ) {
+ wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( $initialPaymentMethod.val() );
+ }
// Trigger initial click
- .find( 'input[name=payment_method]:checked' ).trigger( 'click' );
+ $form.find( 'input[name=payment_method]:checked' ).trigger( 'click' );
- $( '#add_payment_method' ).on( 'submit', function() {
- $( '#add_payment_method' ).block({ message: null, overlayCSS: { background: '#fff', opacity: 0.6 } });
+ $form.on( 'submit', function() {
+ $form.block({ message: null, overlayCSS: { background: '#fff', opacity: 0.6 } });
});
$( document.body ).trigger( 'init_add_payment_method' );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/checkout.js b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
index f4645f53df..65e5a0bd62 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/checkout.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
@@ -7,6 +7,115 @@ jQuery( function ( $ ) {
$.blockUI.defaults.overlayCSS.cursor = 'default';
+ /**
+ * Create the API object passed to custom place order button render callbacks.
+ * This is checkout-specific and includes form validation.
+ *
+ * @return {Object} API object with validate and submit methods
+ */
+ function createCheckoutPlaceOrderApi() {
+ var $form = wc.customPlaceOrderButton.__getForm();
+
+ return {
+ /**
+ * Validate the checkout form.
+ * This gets a little tricky.
+ * The existing checkout.js does NOT have a "validate everything before submit" function - it's not needed.
+ * Validation is done:
+ * - Field-by-field via `validate_field` on blur/change
+ * - Server-side on form submission (errors are returned in AJAX response).
+ * This function tries to mimic client-side validation, but WooCommerce's real validation happens server-side.
+ *
+ * @return {Promise<{hasError: boolean}>} Promise resolving to validation result
+ */
+ validate: function () {
+ return new Promise( function ( resolve ) {
+ var hasError = false;
+
+ // On a "normal" shortcode checkout page, the page validates this server-side only (not via validate_field).
+ // We do client-side validation here for a better UX with custom place order buttons.
+ // Clearing any stale invalid state before re-validating, to ensure a clean slate.
+ var $termsCheckbox = $form.find( 'input[name="terms"]:visible' );
+ if ( $termsCheckbox.length ) {
+ $termsCheckbox.closest( '.form-row' ).removeClass( 'woocommerce-invalid' );
+ }
+
+ // Trigger field-level validation (which adds `.woocommerce-invalid` to invalid fields)
+ $form.find( '.input-text, select, input:checkbox' ).trigger( 'validate' );
+
+ // Check for validation errors (from validate_field handler)
+ if ( $form.find( '.woocommerce-invalid' ).length > 0 ) {
+ hasError = true;
+ }
+
+ // Check required fields (adds .woocommerce-invalid if not already set)
+ $form.find( '.validate-required:visible' ).each( function () {
+ var $field = $( this );
+ var $input = $field.find( 'input.input-text, select, input:checkbox' );
+
+ if ( $input.length === 0 ) {
+ return;
+ }
+
+ var isEmpty;
+ if ( $input.is( ':checkbox' ) ) {
+ isEmpty = ! $input.is( ':checked' );
+ } else {
+ isEmpty = $input.val() === '' || $input.val() === null;
+ }
+
+ if ( isEmpty ) {
+ hasError = true;
+ $field.addClass( 'woocommerce-invalid woocommerce-invalid-required-field' );
+ }
+ } );
+
+ // Check terms checkbox - this is our client-side validation for better UX
+ // (WC Core only validates terms server-side)
+ if ( $termsCheckbox.length && ! $termsCheckbox.is( ':checked' ) ) {
+ hasError = true;
+ $termsCheckbox.closest( '.form-row' ).addClass( 'woocommerce-invalid' );
+ }
+
+ // Scroll to the first invalid field in DOM order
+ if ( hasError ) {
+ var $firstInvalidField = $form.find( '.woocommerce-invalid' ).first();
+ if ( $firstInvalidField.length ) {
+ $( 'html, body' ).animate(
+ {
+ scrollTop: $firstInvalidField.offset().top - 100,
+ },
+ 500
+ );
+ }
+ }
+
+ resolve( { hasError: hasError } );
+ } );
+ },
+
+ /**
+ * Submit the checkout form.
+ * Triggers the same logic as clicking the default place order button.
+ */
+ submit: function () {
+ $form.trigger( 'submit' );
+ },
+ };
+ }
+
+ // Clean up custom place order button before checkout update destroys the DOM.
+ // After the update, init_payment_methods() will trigger payment method selection,
+ // which will call render() again for the active gateway.
+ $( document.body ).on( 'update_checkout', function () {
+ wc.customPlaceOrderButton.__cleanup();
+ } );
+
+ // When a gateway registers after a page load, render its button if it's selected.
+ $( document.body ).on( 'wc_custom_place_order_button_registered', function ( e, gatewayId ) {
+ wc.customPlaceOrderButton.__maybeShow( gatewayId, createCheckoutPlaceOrderApi() );
+ } );
+
var wc_checkout_form = {
updateTimer: false,
dirtyInput: false,
@@ -33,6 +142,13 @@ jQuery( function ( $ ) {
);
this.$order_review.on( 'submit', this.submitOrder );
this.$order_review.attr( 'novalidate', 'novalidate' );
+
+ // Initialize the custom place order button for the "order-pay" page
+ var $orderPayMethod = this.$order_review.find( 'input[name="payment_method"]:checked' );
+ if ( $orderPayMethod.length ) {
+ wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( $orderPayMethod.val() );
+ $orderPayMethod.trigger( 'click' );
+ }
}
// Prevent HTML5 validation which can conflict.
@@ -140,6 +256,13 @@ jQuery( function ( $ ) {
.slideUp( 0 );
}
+ // Check if initially selected gateway has custom place order button (via server-side flag)
+ // This hides the default button immediately to prevent flash while the gateway JS loads
+ var $selectedMethod = $payment_methods.filter( ':checked' ).eq( 0 );
+ if ( $selectedMethod.length ) {
+ wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( $selectedMethod.val() );
+ }
+
// Trigger click event for selected method
$payment_methods.filter( ':checked' ).eq( 0 ).trigger( 'click' );
},
@@ -186,6 +309,10 @@ jQuery( function ( $ ) {
$( document.body ).trigger( 'payment_method_selected' );
}
+ // Handle custom place order button
+ var gatewayId = $( this ).val();
+ wc.customPlaceOrderButton.__maybeShow( gatewayId, createCheckoutPlaceOrderApi() );
+
wc_checkout_form.selectedPaymentMethod = selectedPaymentMethod;
},
toggle_create_account: function () {
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
new file mode 100644
index 0000000000..a3e0a1c942
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
@@ -0,0 +1,273 @@
+/**
+ * @jest-environment jest-fixed-jsdom
+ */
+
+describe( 'createCheckoutPlaceOrderApi', () => {
+ let $form;
+ let $termsCheckbox;
+ let $termsRow;
+ let capturedApi;
+
+ beforeEach( () => {
+ capturedApi = null;
+
+ // used to track whether terms checkbox is checked
+ let termsChecked = false;
+
+ const termsRowClasses = new Set();
+ const formInvalidElements = new Set();
+
+ $termsRow = {
+ addClass: jest.fn( ( cls ) => {
+ cls.split( ' ' ).forEach( ( c ) => formInvalidElements.add( 'terms-row' ) );
+ cls.split( ' ' ).forEach( ( c ) => termsRowClasses.add( c ) );
+ return $termsRow;
+ } ),
+ removeClass: jest.fn( ( cls ) => {
+ cls.split( ' ' ).forEach( ( c ) => termsRowClasses.delete( c ) );
+ if ( cls.includes( 'woocommerce-invalid' ) ) {
+ formInvalidElements.delete( 'terms-row' );
+ }
+ return $termsRow;
+ } ),
+ hasClass: jest.fn( ( cls ) => termsRowClasses.has( cls ) ),
+ length: 1,
+ offset: jest.fn( () => ( { top: 100 } ) ),
+ };
+
+ $termsCheckbox = {
+ length: 1,
+ is: jest.fn( ( selector ) => {
+ if ( selector === ':checked' ) {
+ return termsChecked;
+ }
+ return false;
+ } ),
+ closest: jest.fn( () => $termsRow ),
+ trigger: jest.fn(),
+ };
+
+ // a helper to set the checkbox's state.
+ $termsCheckbox.setChecked = ( checked ) => {
+ termsChecked = checked;
+ };
+
+ $form = {
+ length: 1,
+ find: jest.fn( ( selector ) => {
+ if ( selector === 'input[name="terms"]:visible' ) {
+ return $termsCheckbox;
+ }
+ if ( selector === '.input-text, select, input:checkbox' ) {
+ return { trigger: jest.fn() };
+ }
+ if ( selector === '.woocommerce-invalid' ) {
+ return {
+ length: formInvalidElements.size,
+ first: jest.fn( () => ( {
+ length: formInvalidElements.size > 0 ? 1 : 0,
+ offset: jest.fn( () => ( { top: 100 } ) ),
+ } ) ),
+ };
+ }
+ if ( selector === '.validate-required:visible' ) {
+ return { each: jest.fn() };
+ }
+ if ( selector === 'input[name="payment_method"]:checked' ) {
+ return { val: jest.fn( () => 'test-gateway' ) };
+ }
+ return { length: 0, trigger: jest.fn() };
+ } ),
+ trigger: jest.fn(),
+ };
+
+ // Add methods to $form for checkout.js initialization
+ $form.on = jest.fn( () => $form );
+ $form.attr = jest.fn( () => $form );
+
+ // Default mock for unhandled selectors - provides all common jQuery methods
+ const createDefaultMock = () => {
+ const mock = {
+ length: 0,
+ on: jest.fn( () => mock ),
+ off: jest.fn( () => mock ),
+ attr: jest.fn( () => mock ),
+ find: jest.fn( () => createDefaultMock() ),
+ first: jest.fn( () => createDefaultMock() ),
+ filter: jest.fn( () => createDefaultMock() ),
+ eq: jest.fn( () => createDefaultMock() ),
+ trigger: jest.fn( () => mock ),
+ val: jest.fn(),
+ prop: jest.fn( () => mock ),
+ each: jest.fn( () => mock ),
+ data: jest.fn(),
+ serialize: jest.fn( () => '' ),
+ addClass: jest.fn( () => mock ),
+ removeClass: jest.fn( () => mock ),
+ hasClass: jest.fn( () => false ),
+ is: jest.fn( () => false ),
+ get: jest.fn( () => [] ),
+ text: jest.fn( () => '' ),
+ html: jest.fn( () => '' ),
+ closest: jest.fn( () => createDefaultMock() ),
+ parent: jest.fn( () => createDefaultMock() ),
+ parents: jest.fn( () => createDefaultMock() ),
+ siblings: jest.fn( () => createDefaultMock() ),
+ children: jest.fn( () => createDefaultMock() ),
+ append: jest.fn( () => mock ),
+ prepend: jest.fn( () => mock ),
+ remove: jest.fn( () => mock ),
+ empty: jest.fn( () => mock ),
+ show: jest.fn( () => mock ),
+ hide: jest.fn( () => mock ),
+ css: jest.fn( () => mock ),
+ slideUp: jest.fn( () => mock ),
+ slideDown: jest.fn( () => mock ),
+ fadeIn: jest.fn( () => mock ),
+ fadeOut: jest.fn( () => mock ),
+ offset: jest.fn( () => ( { top: 0, left: 0 } ) ),
+ width: jest.fn( () => 0 ),
+ height: jest.fn( () => 0 ),
+ outerWidth: jest.fn( () => 0 ),
+ outerHeight: jest.fn( () => 0 ),
+ scrollTop: jest.fn( () => 0 ),
+ focus: jest.fn( () => mock ),
+ blur: jest.fn( () => mock ),
+ block: jest.fn( () => mock ),
+ unblock: jest.fn( () => mock ),
+ };
+ return mock;
+ };
+
+ // Simple event system for document.body to enable event-based API capture
+ const bodyEventHandlers = {};
+ const mockBody = {
+ on: jest.fn( ( event, handler ) => {
+ if ( ! bodyEventHandlers[ event ] ) {
+ bodyEventHandlers[ event ] = [];
+ }
+ bodyEventHandlers[ event ].push( handler );
+ return mockBody;
+ } ),
+ trigger: jest.fn( ( event, args ) => {
+ const handlers = bodyEventHandlers[ event ] || [];
+ handlers.forEach( ( handler ) => handler( {}, ...( args || [] ) ) );
+ return mockBody;
+ } ),
+ hasClass: jest.fn( () => false ),
+ };
+
+ // Mock jQuery - needs to handle document ready pattern: jQuery(function($) { ... })
+ const jQueryMock = jest.fn( ( selectorOrCallback ) => {
+ // Handle document ready: jQuery(function($) { ... })
+ if ( typeof selectorOrCallback === 'function' ) {
+ // Execute immediately with jQuery mock as argument
+ selectorOrCallback( jQueryMock );
+ return jQueryMock;
+ }
+ if ( selectorOrCallback === 'form.checkout' ) {
+ return $form;
+ }
+ if ( selectorOrCallback === '#order_review' ) {
+ return { length: 0, on: jest.fn(), attr: jest.fn(), find: jest.fn( () => ( { length: 0, val: jest.fn() } ) ) };
+ }
+ if ( selectorOrCallback === 'html, body' ) {
+ return { animate: jest.fn() };
+ }
+ if ( selectorOrCallback === document.body ) {
+ return mockBody;
+ }
+ // Return a default mock for any other selector
+ return createDefaultMock();
+ } );
+ jQueryMock.blockUI = { defaults: { overlayCSS: {} } };
+
+ global.window.jQuery = jQueryMock;
+ global.window.$ = jQueryMock;
+ global.jQuery = jQueryMock;
+ global.$ = jQueryMock;
+
+ global.window.wc_checkout_params = {
+ gateways_with_custom_place_order_button: [ 'test-gateway' ],
+ };
+
+ global.window.wc = {
+ customPlaceOrderButton: {
+ __getForm: jest.fn( () => $form ),
+ __maybeShow: jest.fn( ( gatewayId, api ) => {
+ capturedApi = api;
+ } ),
+ __maybeHideDefaultButtonOnInit: jest.fn(),
+ __cleanup: jest.fn(),
+ },
+ };
+
+ // requiring checkout.js - this will execute the jQuery wrapper
+ jest.resetModules();
+ require( '../checkout' );
+
+ // Trigger the event to capture the API via __maybeShow
+ // This simulates a gateway registering after page load
+ mockBody.trigger( 'wc_custom_place_order_button_registered', [ 'test-gateway' ] );
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ describe( 'Terms checkbox validation', () => {
+ test( 'should return hasError: true when terms checkbox is not checked', async () => {
+ $termsCheckbox.setChecked( false );
+
+ const result = await capturedApi.validate();
+
+ expect( result.hasError ).toBe( true );
+ expect( $termsRow.addClass ).toHaveBeenCalledWith( 'woocommerce-invalid' );
+ } );
+
+ test( 'should return hasError: false when terms checkbox is checked', async () => {
+ $termsCheckbox.setChecked( true );
+
+ const result = await capturedApi.validate();
+
+ expect( result.hasError ).toBe( false );
+ } );
+
+ test( 'should clear stale invalid state before re-validating terms', async () => {
+ // First validation: terms not checked
+ $termsCheckbox.setChecked( false );
+ await capturedApi.validate();
+
+ expect( $termsRow.addClass ).toHaveBeenCalledWith( 'woocommerce-invalid' );
+
+ // clearing the mock history so the expectations are clearer.
+ $termsRow.removeClass.mockClear();
+ $termsRow.addClass.mockClear();
+
+ // Second validation: marking the terms as checked
+ $termsCheckbox.setChecked( true );
+ const result = await capturedApi.validate();
+
+ // Should have cleared the invalid state first
+ expect( $termsRow.removeClass ).toHaveBeenCalledWith( 'woocommerce-invalid' );
+ // Should NOT have re-added the invalid class
+ expect( $termsRow.addClass ).not.toHaveBeenCalledWith( 'woocommerce-invalid' );
+ // Should pass validation
+ expect( result.hasError ).toBe( false );
+ } );
+
+ test( 'should allow submission after checking terms following a failed validation', async () => {
+ // First attempt: terms not checked - should fail
+ $termsCheckbox.setChecked( false );
+ const firstResult = await capturedApi.validate();
+ expect( firstResult.hasError ).toBe( true );
+
+ // pretending the user checked the terms checkbox
+ $termsCheckbox.setChecked( true );
+
+ // Second attempt: should pass on first try (not require double-click)
+ const secondResult = await capturedApi.validate();
+ expect( secondResult.hasError ).toBe( false );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/custom-place-order-button.js b/plugins/woocommerce/client/legacy/js/frontend/test/custom-place-order-button.js
new file mode 100644
index 0000000000..a0e914df7e
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/custom-place-order-button.js
@@ -0,0 +1,537 @@
+/**
+ * @jest-environment jest-fixed-jsdom
+ */
+
+describe( 'Custom Place Order Button API', () => {
+ let jQueryMock;
+ let $form;
+ let $placeOrderButton;
+
+ beforeEach( () => {
+ // Resetting the window object
+ delete global.window.wc;
+
+ // creating some mocked DOM elements
+ $form = {
+ length: 1,
+ first: jest.fn( () => $form ),
+ find: jest.fn( () => ( {
+ length: 1,
+ val: jest.fn( () => 'test-gateway' ),
+ after: jest.fn(),
+ } ) ),
+ addClass: jest.fn( () => $form ),
+ removeClass: jest.fn( () => $form ),
+ };
+
+ $placeOrderButton = {
+ length: 1,
+ after: jest.fn(),
+ };
+
+ jQueryMock = jest.fn( ( selector ) => {
+ if ( selector === 'form.checkout' ) {
+ return { length: 1, first: jest.fn( () => $form ) };
+ }
+ if ( selector === '#order_review' ) {
+ return { length: 0 };
+ }
+ if ( selector === '#add_payment_method' ) {
+ return { length: 0 };
+ }
+ if ( typeof selector === 'string' && selector.includes( 'div' ) ) {
+ return {
+ length: 1,
+ get: jest.fn( () => document.createElement( 'div' ) ),
+ empty: jest.fn(),
+ remove: jest.fn(),
+ append: jest.fn(),
+ };
+ }
+ return { length: 0 };
+ } );
+
+ jQueryMock.contains = jest.fn( () => false );
+
+ global.window.jQuery = jQueryMock;
+ global.window.$ = jQueryMock;
+
+ global.window.wc_checkout_params = {
+ gateways_with_custom_place_order_button: [ 'test-gateway' ],
+ };
+
+ // mocking the event triggering on document.body
+ jQueryMock.fn = {};
+ const mockBody = {
+ trigger: jest.fn(),
+ };
+ jQueryMock.mockImplementation( ( selector ) => {
+ if ( selector === document.body ) {
+ return mockBody;
+ }
+ if ( selector === 'form.checkout' ) {
+ return { length: 1, first: jest.fn( () => $form ) };
+ }
+ if ( selector === '#order_review' ) {
+ return { length: 0 };
+ }
+ if ( selector === '#add_payment_method' ) {
+ return { length: 0 };
+ }
+ return { length: 0 };
+ } );
+
+ // using a fresh instance on each test
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ describe( 'Base tests', () => {
+ test( 'should expose the API', () => {
+ expect( window.wc ).toBeDefined();
+ expect( window.wc.customPlaceOrderButton ).toBeDefined();
+ expect(
+ typeof window.wc.customPlaceOrderButton.register
+ ).toBe( 'function' );
+ expect(
+ typeof window.wc.customPlaceOrderButton.__maybeShow
+ ).toBe( 'function' );
+ expect(
+ typeof window.wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit
+ ).toBe( 'function' );
+ expect(
+ typeof window.wc.customPlaceOrderButton.__cleanup
+ ).toBe( 'function' );
+ expect(
+ typeof window.wc.customPlaceOrderButton.__getForm
+ ).toBe( 'function' );
+ } );
+
+ test( 'should reject registration without proper configuration', () => {
+ const consoleSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation( () => {} );
+
+ window.wc.customPlaceOrderButton.register( 'test-gateway', {
+ cleanup: jest.fn(),
+ } );
+
+ expect( consoleSpy ).toHaveBeenLastCalledWith(
+ 'wc.customPlaceOrderButton.register: render must be a function'
+ );
+
+ window.wc.customPlaceOrderButton.register( 'test-gateway', {
+ render: jest.fn(),
+ } );
+
+ expect( consoleSpy ).toHaveBeenLastCalledWith(
+ 'wc.customPlaceOrderButton.register: cleanup must be a function'
+ );
+
+ window.wc.customPlaceOrderButton.register( null, {
+ render: jest.fn(),
+ cleanup: jest.fn(),
+ } );
+
+ expect( consoleSpy ).toHaveBeenLastCalledWith(
+ 'wc.customPlaceOrderButton.register: gatewayId must be a non-empty string'
+ );
+
+ window.wc.customPlaceOrderButton.register( '', {
+ render: jest.fn(),
+ cleanup: jest.fn(),
+ } );
+
+ expect( consoleSpy ).toHaveBeenLastCalledWith(
+ 'wc.customPlaceOrderButton.register: gatewayId must be a non-empty string'
+ );
+
+ window.wc.customPlaceOrderButton.register( 'test-gateway', null );
+
+ expect( consoleSpy ).toHaveBeenLastCalledWith(
+ 'wc.customPlaceOrderButton.register: config must be an object'
+ );
+
+ window.wc.customPlaceOrderButton.register( 'test-gateway', undefined );
+
+ expect( consoleSpy ).toHaveBeenLastCalledWith(
+ 'wc.customPlaceOrderButton.register: config must be an object'
+ );
+
+ window.wc.customPlaceOrderButton.register( 'test-gateway', 'not-an-object' );
+
+ expect( consoleSpy ).toHaveBeenLastCalledWith(
+ 'wc.customPlaceOrderButton.register: config must be an object'
+ );
+
+ consoleSpy.mockRestore();
+ } );
+
+ test( 'should inject critical CSS on load', () => {
+ const styleElement = document.getElementById(
+ 'wc-custom-place-order-button-styles'
+ );
+ expect( styleElement ).toBeTruthy();
+ expect( styleElement.textContent ).toContain(
+ '.has-custom-place-order-button #place_order'
+ );
+ expect( styleElement.textContent ).toContain( 'display: none' );
+ } );
+
+ test( 'should not inject duplicate styles', () => {
+ // Re-require the module
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+
+ const styleElements = document.querySelectorAll(
+ '#wc-custom-place-order-button-styles'
+ );
+ expect( styleElements.length ).toBe( 1 );
+ } );
+ } );
+
+ describe( 'getGatewaysWithCustomButton', () => {
+ test( 'should hide default button for gateway in wc_checkout_params list', () => {
+ // Gateway 'test-gateway' is in the server list, so maybeHideDefaultButtonOnInit
+ // should add the class to hide the default button
+ window.wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( 'test-gateway' );
+
+ expect( $form.addClass ).toHaveBeenCalledWith( 'has-custom-place-order-button' );
+ } );
+
+ test( 'should not hide default button for gateway not in list', () => {
+ // Gateway 'unknown-gateway' is NOT in the server list
+ window.wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( 'unknown-gateway' );
+
+ expect( $form.addClass ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should not hide default button when wc_checkout_params is undefined', () => {
+ delete global.window.wc_checkout_params;
+ delete global.window.wc_add_payment_method_params;
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+
+ window.wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( 'test-gateway' );
+
+ expect( $form.addClass ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should use wc_add_payment_method_params as fallback', () => {
+ delete global.window.wc_checkout_params;
+ global.window.wc_add_payment_method_params = {
+ gateways_with_custom_place_order_button: [ 'add-method-gateway' ],
+ };
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+
+ window.wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( 'add-method-gateway' );
+
+ expect( $form.addClass ).toHaveBeenCalledWith( 'has-custom-place-order-button' );
+ } );
+
+ test( 'should prefer wc_checkout_params over wc_add_payment_method_params', () => {
+ global.window.wc_checkout_params = {
+ gateways_with_custom_place_order_button: [ 'checkout-gateway' ],
+ };
+ global.window.wc_add_payment_method_params = {
+ gateways_with_custom_place_order_button: [ 'add-method-gateway' ],
+ };
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+
+ window.wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( 'checkout-gateway' );
+ expect( $form.addClass ).toHaveBeenCalledWith( 'has-custom-place-order-button' );
+
+ $form.addClass.mockClear();
+
+ window.wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit( 'add-method-gateway' );
+ expect( $form.addClass ).not.toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'Gateway switching behavior', () => {
+ let $form;
+ let selectedGateway;
+ let mockContainer;
+ let mockApi;
+
+ beforeEach( () => {
+ delete global.window.wc;
+ selectedGateway = 'gateway-a';
+ mockApi = { validate: jest.fn(), submit: jest.fn() };
+
+ $form = {
+ length: 1,
+ first: jest.fn( function () {
+ return this;
+ } ),
+ find: jest.fn( ( selector ) => {
+ if ( selector === 'input[name="payment_method"]:checked' ) {
+ return {
+ length: 1,
+ val: jest.fn( () => selectedGateway ),
+ };
+ }
+ if ( selector === '#place_order' ) {
+ return {
+ length: 1,
+ after: jest.fn(),
+ };
+ }
+ return { length: 0 };
+ } ),
+ addClass: jest.fn( function () {
+ return this;
+ } ),
+ removeClass: jest.fn( function () {
+ return this;
+ } ),
+ };
+
+ mockContainer = {
+ length: 1,
+ get: jest.fn( () => document.createElement( 'div' ) ),
+ empty: jest.fn(),
+ remove: jest.fn(),
+ append: jest.fn(),
+ };
+
+ const mockBody = {
+ trigger: jest.fn(),
+ };
+
+ global.window.jQuery = jest.fn( ( selector ) => {
+ if ( selector === document.body ) {
+ return mockBody;
+ }
+ if ( selector === 'form.checkout' ) {
+ return { length: 1, first: jest.fn( () => $form ) };
+ }
+ if ( selector === '#order_review' ) {
+ return { length: 0 };
+ }
+ if ( selector === '#add_payment_method' ) {
+ return { length: 0 };
+ }
+ if ( typeof selector === 'string' && selector.includes( 'div' ) ) {
+ return mockContainer;
+ }
+ return { length: 0 };
+ } );
+ global.window.jQuery.fn = {};
+ global.window.jQuery.contains = jest.fn( () => true );
+ global.window.$ = global.window.jQuery;
+
+ global.window.wc_checkout_params = {
+ gateways_with_custom_place_order_button: [ 'gateway-a', 'gateway-b' ],
+ };
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ test( 'should call cleanup when switching between two gateways with custom buttons', () => {
+ const renderA = jest.fn();
+ const cleanupA = jest.fn();
+ const renderB = jest.fn();
+ const cleanupB = jest.fn();
+
+ window.wc.customPlaceOrderButton.register( 'gateway-a', {
+ render: renderA,
+ cleanup: cleanupA,
+ } );
+ window.wc.customPlaceOrderButton.register( 'gateway-b', {
+ render: renderB,
+ cleanup: cleanupB,
+ } );
+
+ // Simulating to select `gateway-a`
+ selectedGateway = 'gateway-a';
+ window.wc.customPlaceOrderButton.__maybeShow( selectedGateway, mockApi );
+
+ expect( renderA ).toHaveBeenCalledTimes( 1 );
+ expect( cleanupA ).not.toHaveBeenCalled();
+ expect( $form.addClass ).toHaveBeenCalledWith( 'has-custom-place-order-button' );
+
+ // Simulating to switch to `gateway-b`
+ selectedGateway = 'gateway-b';
+ window.wc.customPlaceOrderButton.__maybeShow( selectedGateway, mockApi );
+
+ expect( cleanupA ).toHaveBeenCalledTimes( 1 );
+ expect( renderB ).toHaveBeenCalledTimes( 1 );
+ expect( cleanupB ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should call cleanup when switching from custom button gateway to regular gateway', () => {
+ const renderA = jest.fn();
+ const cleanupA = jest.fn();
+
+ window.wc.customPlaceOrderButton.register( 'gateway-a', {
+ render: renderA,
+ cleanup: cleanupA,
+ } );
+
+ // Simulating to selecting `gateway-a` (which has a custom button)
+ selectedGateway = 'gateway-a';
+ window.wc.customPlaceOrderButton.__maybeShow( selectedGateway, mockApi );
+
+ expect( renderA ).toHaveBeenCalledTimes( 1 );
+ expect( $form.addClass ).toHaveBeenCalledWith( 'has-custom-place-order-button' );
+
+ // Reset mocks to track new calls
+ $form.addClass.mockClear();
+ $form.removeClass.mockClear();
+
+ // Simulating to switch to `no-custom-button-gateway`
+ selectedGateway = 'no-custom-button-gateway';
+ window.wc.customPlaceOrderButton.__maybeShow( selectedGateway, mockApi );
+
+ expect( cleanupA ).toHaveBeenCalledTimes( 1 );
+ expect( $form.removeClass ).toHaveBeenCalledWith( 'has-custom-place-order-button' );
+ } );
+
+ test( 'should show custom button when switching from regular gateway to custom button gateway', () => {
+ const renderA = jest.fn();
+ const cleanupA = jest.fn();
+
+ window.wc.customPlaceOrderButton.register( 'gateway-a', {
+ render: renderA,
+ cleanup: cleanupA,
+ } );
+
+ // Starting with `no-custom-button-gateway`
+ selectedGateway = 'no-custom-button-gateway';
+ window.wc.customPlaceOrderButton.__maybeShow( selectedGateway, mockApi );
+
+ expect( renderA ).not.toHaveBeenCalled();
+ expect( $form.addClass ).not.toHaveBeenCalledWith( 'has-custom-place-order-button' );
+
+ // Simulating to switch to `gateway-a` (which has custom button)
+ selectedGateway = 'gateway-a';
+ window.wc.customPlaceOrderButton.__maybeShow( selectedGateway, mockApi );
+
+ expect( renderA ).toHaveBeenCalledTimes( 1 );
+ expect( $form.addClass ).toHaveBeenCalledWith( 'has-custom-place-order-button' );
+ } );
+ } );
+
+} );
+
+describe( 'getForm helper', () => {
+ beforeEach( () => {
+ delete global.window.wc;
+
+ // Default mock - no forms found
+ global.window.jQuery = jest.fn( ( ) => {
+ return { length: 0 };
+ } );
+
+ global.window.wc_checkout_params = {
+ gateways_with_custom_place_order_button: [],
+ };
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+ } );
+
+ test( 'should return form.checkout if present', () => {
+ const mockForm = { length: 1, first: jest.fn( () => mockForm ) };
+
+ global.window.jQuery = jest.fn( ( selector ) => {
+ if ( selector === 'form.checkout' ) {
+ return mockForm;
+ }
+ return { length: 0 };
+ } );
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+
+ const form = window.wc.customPlaceOrderButton.__getForm();
+ expect( form ).toBe( mockForm );
+ } );
+
+ test( 'should return #order_review if form.checkout not present', () => {
+ const mockOrderReview = { length: 1, first: jest.fn( () => mockOrderReview ) };
+
+ global.window.jQuery = jest.fn( ( selector ) => {
+ if ( selector === 'form.checkout' ) {
+ return { length: 0 };
+ }
+ if ( selector === '#order_review' ) {
+ return mockOrderReview;
+ }
+ return { length: 0 };
+ } );
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+
+ const form = window.wc.customPlaceOrderButton.__getForm();
+ expect( form ).toBe( mockOrderReview );
+ } );
+
+ test( 'should return #add_payment_method as last resort', () => {
+ const mockAddPaymentMethod = {
+ length: 1,
+ first: jest.fn( () => mockAddPaymentMethod ),
+ };
+
+ global.window.jQuery = jest.fn( ( selector ) => {
+ if ( selector === 'form.checkout' ) {
+ return { length: 0 };
+ }
+ if ( selector === '#order_review' ) {
+ return { length: 0 };
+ }
+ if ( selector === '#add_payment_method' ) {
+ return mockAddPaymentMethod;
+ }
+ return { length: 0 };
+ } );
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+
+ const form = window.wc.customPlaceOrderButton.__getForm();
+ expect( form ).toBe( mockAddPaymentMethod );
+ } );
+
+ test( 'should return empty jQuery object if no form found', () => {
+ const emptyJQuery = { length: 0 };
+
+ global.window.jQuery = jest.fn( ( selector ) => {
+ if ( selector === 'form.checkout' ) {
+ return { length: 0 };
+ }
+ if ( selector === '#order_review' ) {
+ return { length: 0 };
+ }
+ if ( selector === '#add_payment_method' ) {
+ return { length: 0 };
+ }
+ if ( Array.isArray( selector ) && selector.length === 0 ) {
+ return emptyJQuery;
+ }
+ return { length: 0 };
+ } );
+
+ jest.resetModules();
+ require( '../utils/custom-place-order-button' );
+
+ const form = window.wc.customPlaceOrderButton.__getForm();
+ expect( form.length ).toBe( 0 );
+ } );
+} );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/utils/custom-place-order-button.js b/plugins/woocommerce/client/legacy/js/frontend/utils/custom-place-order-button.js
new file mode 100644
index 0000000000..f81724bbe1
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/utils/custom-place-order-button.js
@@ -0,0 +1,263 @@
+/**
+ * Custom Place Order Button API
+ *
+ * Shared functionality for custom place order buttons across checkout, order-pay,
+ * and add-payment-method pages. This module provides the core registration and
+ * button management logic.
+ *
+ * @since 10.6.0
+ */
+
+( function ( $ ) {
+ 'use strict';
+
+ // Initialize the global wc.customPlaceOrderButton namespace
+ window.wc = window.wc || {};
+ window.wc.customPlaceOrderButton = window.wc.customPlaceOrderButton || {};
+
+ // Inject critical CSS inline to ensure it works even when themes dequeue woocommerce.css
+ ( function injectStyles() {
+ var styleId = 'wc-custom-place-order-button-styles';
+ if ( document.getElementById( styleId ) ) {
+ return;
+ }
+ var style = document.createElement( 'style' );
+ style.id = styleId;
+ style.textContent = 'form.has-custom-place-order-button #place_order { display: none !important; }';
+ document.head.appendChild( style );
+ } )();
+
+ /**
+ * Registry for custom place order buttons.
+ * Key: gateway_id, Value: { render: function, cleanup: function }
+ */
+ var customPlaceOrderButtons = {};
+
+ /**
+ * Currently active custom button gateway ID, or null if using the default button.
+ */
+ var activeCustomButtonGateway = null;
+
+ /**
+ * Container element for the custom place order button.
+ */
+ var $customButtonContainer = null;
+
+ /**
+ * Get the current form element based on page context.
+ *
+ * @return {jQuery} The form element.
+ */
+ function getForm() {
+ if ( $( 'form.checkout' ).length ) {
+ return $( 'form.checkout' ).first();
+ }
+ if ( $( '#order_review' ).length ) {
+ return $( '#order_review' ).first();
+ }
+ if ( $( '#add_payment_method' ).length ) {
+ return $( '#add_payment_method' ).first();
+ }
+ return $( [] );
+ }
+
+ /**
+ * Get a list of gateway IDs with custom place order buttons from server config.
+ *
+ * @return {Array} List of gateway IDs
+ */
+ function getGatewaysWithCustomButton() {
+ // Try multiple param sources for compatibility across pages
+ if ( typeof wc_checkout_params !== 'undefined' && wc_checkout_params.gateways_with_custom_place_order_button ) {
+ return wc_checkout_params.gateways_with_custom_place_order_button;
+ }
+ if ( typeof wc_add_payment_method_params !== 'undefined' && wc_add_payment_method_params.gateways_with_custom_place_order_button ) {
+ return wc_add_payment_method_params.gateways_with_custom_place_order_button;
+ }
+ return [];
+ }
+
+ /**
+ * Check if a gateway has a custom place order button registered via a server-side flag.
+ *
+ * @param {string} gatewayId - The payment gateway ID
+ * @return {boolean} True if gateway has custom button
+ */
+ function gatewayHasCustomPlaceOrderButton( gatewayId ) {
+ return getGatewaysWithCustomButton().indexOf( gatewayId ) !== -1;
+ }
+
+ /**
+ * Create or get the container for custom place order buttons.
+ * The container is scoped to the current form to handle multiple forms on a page.
+ * We're not creating the container server-side to avoid introducing a new template
+ * that might break compatibility with subscriptions or other extensions.
+ *
+ * @return {jQuery} The container element
+ */
+ function getOrCreateCustomButtonContainer() {
+ if ( $customButtonContainer && $customButtonContainer.length && $.contains( document, $customButtonContainer[ 0 ] ) ) {
+ return $customButtonContainer;
+ }
+
+ var $placeOrderButton = getForm().find( '#place_order' );
+ if ( ! $placeOrderButton.length ) {
+ return $( [] );
+ }
+
+ $customButtonContainer = $( '<div class="wc-custom-place-order-button"></div>' );
+ $placeOrderButton.after( $customButtonContainer );
+
+ return $customButtonContainer;
+ }
+
+ /**
+ * Remove the custom button container.
+ */
+ function removeCustomButtonContainer() {
+ if ( $customButtonContainer && $customButtonContainer.length ) {
+ $customButtonContainer.remove();
+ $customButtonContainer = null;
+ }
+ }
+
+ /**
+ * Clean up the current custom button if any.
+ */
+ function cleanupCurrentCustomButton() {
+ if ( activeCustomButtonGateway && customPlaceOrderButtons[ activeCustomButtonGateway ] ) {
+ try {
+ customPlaceOrderButtons[ activeCustomButtonGateway ].cleanup();
+ } catch ( e ) {
+ // Log errors to help gateway developers debug their cleanup implementation.
+ // eslint-disable-next-line no-console
+ console.error( 'Error in custom place order button cleanup:', e );
+ }
+ }
+ removeCustomButtonContainer();
+ activeCustomButtonGateway = null;
+ }
+
+ /**
+ * Show custom place order button for a gateway, or show default button.
+ * The `api` object is needed because the add-payment-method page is different than a checkout or a pay-for-order page.
+ * Each page can decide how to implement the `validate` and `submit` methods.
+ *
+ * @param {string} gatewayId - The payment gateway ID
+ * @param {Object} api - The API object to pass to render callback
+ */
+ function maybeShowCustomPlaceOrderButton( gatewayId, api ) {
+ var $form = getForm();
+
+ // Clean up any displayed custom button, if any
+ if ( activeCustomButtonGateway && customPlaceOrderButtons[ activeCustomButtonGateway ] ) {
+ try {
+ customPlaceOrderButtons[ activeCustomButtonGateway ].cleanup();
+ } catch ( e ) {
+ // Log errors to help gateway developers debug their cleanup implementation.
+ // eslint-disable-next-line no-console
+ console.error( 'Error in custom place order button cleanup:', e );
+ }
+ }
+
+ var isCustomButtonRegistered = Boolean( customPlaceOrderButtons[ gatewayId ] );
+ if ( isCustomButtonRegistered ) {
+ // Hide the default button and show the custom one, instead.
+ $form.addClass( 'has-custom-place-order-button' );
+ activeCustomButtonGateway = gatewayId;
+
+ var $container = getOrCreateCustomButtonContainer();
+ $container.empty();
+
+ try {
+ customPlaceOrderButtons[ gatewayId ].render( $container.get( 0 ), api );
+ } catch ( e ) {
+ // Log errors to help gateway developers debug their render implementation.
+ // eslint-disable-next-line no-console
+ console.error( 'Error rendering custom place order button:', e );
+ }
+ } else {
+ // Only show default button if gateway doesn't have a custom button pending registration.
+ // This prevents flash when gateway JS loads after the initial click.
+ // Basically, when `__maybeShow` is called for a gateway that isn't registered yet but has the server-side flag set,
+ // we keep the default button hidden instead of flashing it.
+ if ( ! gatewayHasCustomPlaceOrderButton( gatewayId ) ) {
+ $form.removeClass( 'has-custom-place-order-button' );
+ }
+ activeCustomButtonGateway = null;
+ removeCustomButtonContainer();
+ }
+ }
+
+ /**
+ * Hide default button immediately if selected gateway has custom button (prevents flash).
+ *
+ * @param {string} gatewayId - The payment gateway ID
+ */
+ function maybeHideDefaultButtonOnInit( gatewayId ) {
+ if ( gatewayHasCustomPlaceOrderButton( gatewayId ) ) {
+ var $form = getForm();
+ $form.addClass( 'has-custom-place-order-button' );
+ }
+ }
+
+ /**
+ * Register a custom place order button for a payment gateway.
+ *
+ * @param {string} gatewayId - The payment gateway ID (e.g., 'google_pay')
+ * @param {Object} config - Configuration object
+ * @param {Function} config.render - Function called to render the button. Receives (container, api)
+ * @param {Function} config.cleanup - Function called when switching away from this gateway
+ */
+ function registerCustomPlaceOrderButton( gatewayId, config ) {
+ // Silently ignore if already registered (prevents double-registration issues)
+ if ( customPlaceOrderButtons[ gatewayId ] ) {
+ return;
+ }
+
+ if ( typeof gatewayId !== 'string' || ! gatewayId ) {
+ // Log validation errors to help gateway developers fix incorrect API usage.
+ // eslint-disable-next-line no-console
+ console.error( 'wc.customPlaceOrderButton.register: gatewayId must be a non-empty string' );
+ return;
+ }
+ if ( typeof config !== 'object' || config === null ) {
+ // Log validation errors to help gateway developers fix incorrect API usage.
+ // eslint-disable-next-line no-console
+ console.error( 'wc.customPlaceOrderButton.register: config must be an object' );
+ return;
+ }
+ if ( typeof config.render !== 'function' ) {
+ // Log validation errors to help gateway developers fix incorrect API usage.
+ // eslint-disable-next-line no-console
+ console.error( 'wc.customPlaceOrderButton.register: render must be a function' );
+ return;
+ }
+ if ( typeof config.cleanup !== 'function' ) {
+ // Log validation errors to help gateway developers fix incorrect API usage.
+ // eslint-disable-next-line no-console
+ console.error( 'wc.customPlaceOrderButton.register: cleanup must be a function' );
+ return;
+ }
+
+ customPlaceOrderButtons[ gatewayId ] = config;
+
+ // If this gateway is already selected, notify that registration is complete
+ if ( getForm().find( 'input[name="payment_method"]:checked' ).val() === gatewayId ) {
+ // since this API needs to be used on checkout/pay for order/my account pages,
+ // we need to trigger a global event to ensure it's picked up by the WC Core JS used in those pages.
+ $( document.body ).trigger( 'wc_custom_place_order_button_registered', [ gatewayId ] );
+ }
+ }
+
+ // Export functions to the global namespace
+ // Public API (for gateway developers)
+ window.wc.customPlaceOrderButton.register = registerCustomPlaceOrderButton;
+
+ // Internal API (used by WooCommerce core, not for external use)
+ window.wc.customPlaceOrderButton.__maybeShow = maybeShowCustomPlaceOrderButton;
+ window.wc.customPlaceOrderButton.__maybeHideDefaultButtonOnInit = maybeHideDefaultButtonOnInit;
+ window.wc.customPlaceOrderButton.__cleanup = cleanupCurrentCustomButton;
+ window.wc.customPlaceOrderButton.__getForm = getForm;
+
+} )( jQuery );
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-payment-gateway.php b/plugins/woocommerce/includes/abstracts/abstract-wc-payment-gateway.php
index 1668209ddb..85d91f4d75 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-payment-gateway.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-payment-gateway.php
@@ -37,6 +37,22 @@ abstract class WC_Payment_Gateway extends WC_Settings_API {
*/
public $order_button_text;
+ /**
+ * Whether the gateway provides a custom place order button.
+ *
+ * When true, the default "Place order" button will be hidden on page load
+ * if this gateway is pre-selected. The gateway must register its custom
+ * button via JavaScript using wc.customPlaceOrderButton.register().
+ *
+ * Note: This property is purely for UX (preventing flash of default button).
+ * It does NOT affect security or functionality - the JS registration is what
+ * actually enables the custom button.
+ *
+ * @since 10.6.0
+ * @var bool
+ */
+ public $has_custom_place_order_button = false;
+
/**
* Yes or no based on whether the method is enabled.
*
diff --git a/plugins/woocommerce/includes/class-wc-frontend-scripts.php b/plugins/woocommerce/includes/class-wc-frontend-scripts.php
index 79377f108a..a2237fee33 100644
--- a/plugins/woocommerce/includes/class-wc-frontend-scripts.php
+++ b/plugins/woocommerce/includes/class-wc-frontend-scripts.php
@@ -214,164 +214,175 @@ class WC_Frontend_Scripts {
$version = Constants::get_constant( 'WC_VERSION' );
$scripts = array(
- 'selectWoo' => array(
+ 'selectWoo' => array(
'src' => self::get_asset_url( 'assets/js/selectWoo/selectWoo.full' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => '1.0.9-wc.' . $version,
),
- 'wc-account-i18n' => array(
+ 'wc-account-i18n' => array(
'src' => self::get_asset_url( 'assets/js/frontend/account-i18n' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => $version,
),
- 'wc-add-payment-method' => array(
+ 'wc-add-payment-method' => array(
'src' => self::get_asset_url( 'assets/js/frontend/add-payment-method' . $suffix . '.js' ),
- 'deps' => array( 'jquery', 'woocommerce' ),
+ 'deps' => array( 'jquery', 'woocommerce', 'wc-custom-place-order-button' ),
'version' => $version,
),
- 'wc-add-to-cart' => array(
+ 'wc-add-to-cart' => array(
'src' => self::get_asset_url( 'assets/js/frontend/add-to-cart' . $suffix . '.js' ),
'deps' => array( 'jquery', 'wc-jquery-blockui' ),
'version' => $version,
),
- 'wc-add-to-cart-variation' => array(
+ 'wc-add-to-cart-variation' => array(
'src' => self::get_asset_url( 'assets/js/frontend/add-to-cart-variation' . $suffix . '.js' ),
'deps' => array( 'jquery', 'wp-util', 'wc-jquery-blockui' ),
'version' => $version,
),
- 'wc-address-i18n' => array(
+ 'wc-address-i18n' => array(
'src' => self::get_asset_url( 'assets/js/frontend/address-i18n' . $suffix . '.js' ),
'deps' => array( 'jquery', 'wc-country-select' ),
'version' => $version,
),
- 'wc-back-in-stock-form' => array(
+ 'wc-back-in-stock-form' => array(
'src' => self::get_asset_url( 'assets/js/frontend/back-in-stock-form' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => $version,
),
- 'wc-cart' => array(
+ 'wc-cart' => array(
'src' => self::get_asset_url( 'assets/js/frontend/cart' . $suffix . '.js' ),
'deps' => array( 'jquery', 'woocommerce', 'wc-country-select', 'wc-address-i18n' ),
'version' => $version,
),
- 'wc-cart-fragments' => array(
+ 'wc-cart-fragments' => array(
'src' => self::get_asset_url( 'assets/js/frontend/cart-fragments' . $suffix . '.js' ),
'deps' => array( 'jquery', 'wc-js-cookie' ),
'version' => $version,
),
- 'wc-checkout' => array(
+ 'wc-checkout' => array(
'src' => self::get_asset_url( 'assets/js/frontend/checkout' . $suffix . '.js' ),
- 'deps' => array( 'jquery', 'woocommerce', 'wc-country-select', 'wc-address-i18n' ),
+ 'deps' => array(
+ 'jquery',
+ 'woocommerce',
+ 'wc-country-select',
+ 'wc-address-i18n',
+ 'wc-custom-place-order-button',
+ ),
'version' => $version,
),
- 'wc-country-select' => array(
+ 'wc-country-select' => array(
'src' => self::get_asset_url( 'assets/js/frontend/country-select' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => $version,
),
- 'wc-credit-card-form' => array(
+ 'wc-credit-card-form' => array(
'src' => self::get_asset_url( 'assets/js/frontend/credit-card-form' . $suffix . '.js' ),
'deps' => array( 'jquery', 'wc-jquery-payment' ),
'version' => $version,
),
- 'wc-dompurify' => array(
+ 'wc-custom-place-order-button' => array(
+ 'src' => self::get_asset_url( 'assets/js/frontend/utils/custom-place-order-button' . $suffix . '.js' ),
+ 'deps' => array( 'jquery' ),
+ 'version' => $version,
+ ),
+ 'wc-dompurify' => array(
'src' => self::get_asset_url( 'assets/js/dompurify/purify' . $suffix . '.js' ),
'deps' => array(),
'version' => $version,
),
- 'wc-flexslider' => array(
+ 'wc-flexslider' => array(
'src' => self::get_asset_url( 'assets/js/flexslider/jquery.flexslider' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => '2.7.2-wc.' . $version,
'legacy_handle' => 'flexslider',
),
- 'wc-geolocation' => array(
+ 'wc-geolocation' => array(
'src' => self::get_asset_url( 'assets/js/frontend/geolocation' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => $version,
),
- 'wc-jquery-blockui' => array(
+ 'wc-jquery-blockui' => array(
'src' => self::get_asset_url( 'assets/js/jquery-blockui/jquery.blockUI' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => '2.7.0-wc.' . $version,
'legacy_handle' => 'jquery-blockui',
),
- 'wc-jquery-cookie' => array(
+ 'wc-jquery-cookie' => array(
'src' => self::get_asset_url( 'assets/js/jquery-cookie/jquery.cookie' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => '1.4.1-wc.' . $version,
'legacy_handle' => 'jquery-cookie',
),
- 'wc-jquery-payment' => array(
+ 'wc-jquery-payment' => array(
'src' => self::get_asset_url( 'assets/js/jquery-payment/jquery.payment' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => '3.0.0-wc.' . $version,
'legacy_handle' => 'jquery-payment',
),
- 'wc-jquery-tiptip' => array(
+ 'wc-jquery-tiptip' => array(
'src' => self::get_asset_url( 'assets/js/jquery-tiptip/jquery.tipTip' . $suffix . '.js' ),
'deps' => array( 'jquery', 'wc-dompurify' ),
'version' => $version,
'legacy_handle' => 'jquery-tiptip',
),
- 'wc-js-cookie' => array(
+ 'wc-js-cookie' => array(
'src' => self::get_asset_url( 'assets/js/js-cookie/js.cookie' . $suffix . '.js' ),
'deps' => array(),
'version' => '2.1.4-wc.' . $version,
'legacy_handle' => 'js-cookie',
),
- 'wc-lost-password' => array(
+ 'wc-lost-password' => array(
'src' => self::get_asset_url( 'assets/js/frontend/lost-password' . $suffix . '.js' ),
'deps' => array( 'jquery', 'woocommerce' ),
'version' => $version,
),
- 'wc-password-strength-meter' => array(
+ 'wc-password-strength-meter' => array(
'src' => self::get_asset_url( 'assets/js/frontend/password-strength-meter' . $suffix . '.js' ),
'deps' => array( 'jquery', 'password-strength-meter' ),
'version' => $version,
),
- 'wc-photoswipe' => array(
+ 'wc-photoswipe' => array(
'src' => self::get_asset_url( 'assets/js/photoswipe/photoswipe' . $suffix . '.js' ),
'deps' => array(),
'version' => '4.1.1-wc.' . $version,
'legacy_handle' => 'photoswipe',
),
- 'wc-photoswipe-ui-default' => array(
+ 'wc-photoswipe-ui-default' => array(
'src' => self::get_asset_url( 'assets/js/photoswipe/photoswipe-ui-default' . $suffix . '.js' ),
'deps' => array( 'wc-photoswipe' ),
'version' => '4.1.1-wc.' . $version,
'legacy_handle' => 'photoswipe-ui-default',
),
- 'wc-prettyPhoto' => array( // deprecated.
+ 'wc-prettyPhoto' => array( // deprecated.
'src' => self::get_asset_url( 'assets/js/prettyPhoto/jquery.prettyPhoto' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => '3.1.6-wc.' . $version,
'legacy_handle' => 'prettyPhoto',
),
- 'wc-prettyPhoto-init' => array( // deprecated.
+ 'wc-prettyPhoto-init' => array( // deprecated.
'src' => self::get_asset_url( 'assets/js/prettyPhoto/jquery.prettyPhoto.init' . $suffix . '.js' ),
'deps' => array( 'jquery', 'wc-prettyPhoto' ),
'version' => $version,
'legacy_handle' => 'prettyPhoto-init',
),
- 'wc-select2' => array(
+ 'wc-select2' => array(
'src' => self::get_asset_url( 'assets/js/select2/select2.full' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => '4.0.3-wc.' . $version,
'legacy_handle' => 'select2',
),
- 'wc-single-product' => array(
+ 'wc-single-product' => array(
'src' => self::get_asset_url( 'assets/js/frontend/single-product' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => $version,
),
- 'wc-zoom' => array(
+ 'wc-zoom' => array(
'src' => self::get_asset_url( 'assets/js/zoom/jquery.zoom' . $suffix . '.js' ),
'deps' => array( 'jquery' ),
'version' => '1.7.21-wc.' . $version,
'legacy_handle' => 'zoom',
),
- 'woocommerce' => array(
+ 'woocommerce' => array(
'src' => self::get_asset_url( 'assets/js/frontend/woocommerce' . $suffix . '.js' ),
'deps' => array( 'jquery', 'wc-jquery-blockui', 'wc-js-cookie' ),
'version' => $version,
@@ -667,6 +678,7 @@ class WC_Frontend_Scripts {
'debug_mode' => Constants::is_true( 'WP_DEBUG' ),
/* translators: %s: Order history URL on My Account section */
'i18n_checkout_error' => sprintf( esc_attr__( 'There was an error processing your order. Please check for any charges in your payment method and review your <a href="%s">order history</a> before placing the order again.', 'woocommerce' ), esc_url( wc_get_account_endpoint_url( 'orders' ) ) ),
+ 'gateways_with_custom_place_order_button' => self::get_gateways_with_custom_place_order_button(),
);
break;
case 'wc-address-autocomplete-common':
@@ -732,6 +744,11 @@ class WC_Frontend_Scripts {
'cart_redirect_after_add' => get_option( 'woocommerce_cart_redirect_after_add' ),
);
break;
+ case 'wc-add-payment-method':
+ $params = array(
+ 'gateways_with_custom_place_order_button' => self::get_gateways_with_custom_place_order_button(),
+ );
+ break;
case 'wc-add-to-cart-variation':
// We also need the wp.template for this script :).
wc_get_template( 'single-product/add-to-cart/variation.php' );
@@ -777,6 +794,31 @@ class WC_Frontend_Scripts {
return apply_filters( 'woocommerce_get_script_data', $params, $handle );
}
+ /**
+ * Get a list of payment gateway IDs that have custom place order buttons.
+ *
+ * @return array List of gateway IDs with custom place order buttons.
+ */
+ private static function get_gateways_with_custom_place_order_button() {
+ $gateways_with_custom_button = array();
+
+ if ( ! WC()->payment_gateways() ) {
+ return $gateways_with_custom_button;
+ }
+
+ $available_gateways = WC()->payment_gateways()->get_available_payment_gateways();
+
+ foreach ( $available_gateways as $gateway ) {
+ // phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- Type hint for PHPStan.
+ /* @var WC_Payment_Gateway $gateway */
+ if ( true === $gateway->has_custom_place_order_button ) {
+ $gateways_with_custom_button[] = $gateway->id;
+ }
+ }
+
+ return $gateways_with_custom_button;
+ }
+
/**
* Localize scripts only when enqueued.
*/
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/checkout/checkout-shortcode-custom-place-order-button.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/checkout/checkout-shortcode-custom-place-order-button.spec.js
new file mode 100644
index 0000000000..e6bfa52274
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/checkout/checkout-shortcode-custom-place-order-button.spec.js
@@ -0,0 +1,226 @@
+/**
+ * External dependencies
+ */
+import {
+ addAProductToCart,
+ WC_API_PATH,
+} from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { expect, tags, test as baseTest } from '../../fixtures/fixtures';
+import { getFakeCustomer, getFakeProduct } from '../../utils/data';
+import {
+ createClassicCheckoutPage,
+ CLASSIC_CHECKOUT_PAGE,
+} from '../../utils/pages';
+import { wpCLI } from '../../utils/cli';
+
+const test = baseTest.extend( {
+ page: async ( { page, restApi }, use ) => {
+ await createClassicCheckoutPage();
+
+ // Activating the custom place order button test plugin (mapped in .wp-env.json).
+ await wpCLI( 'wp plugin activate custom-place-order-button-test' );
+
+ // The custom plugin comes with a custom gateway - enabling it through CLI to simplify our lives.
+ await wpCLI(
+ `wp option set woocommerce_test-custom-button_settings --format=json '{"enabled":"yes"}'`
+ );
+
+ // Ensuring that COD is enabled, so it can _also_ be used during checkout.
+ const codResponse = await restApi.get(
+ `${ WC_API_PATH }/payment_gateways/cod`
+ );
+ const codEnabled = codResponse.enabled;
+
+ if ( ! codEnabled ) {
+ await restApi.put( `${ WC_API_PATH }/payment_gateways/cod`, {
+ enabled: true,
+ } );
+ }
+
+ await page.context().clearCookies();
+ await use( page );
+
+ // Cleanup: restoring COD and removing the custom gateway
+ await wpCLI(
+ `wp option delete woocommerce_test-custom-button_settings`
+ );
+
+ if ( ! codEnabled ) {
+ await restApi.put( `${ WC_API_PATH }/payment_gateways/cod`, {
+ enabled: codEnabled,
+ } );
+ }
+ },
+ product: async ( { restApi }, use ) => {
+ let product;
+
+ await restApi
+ .post( `${ WC_API_PATH }/products`, getFakeProduct( { dec: 0 } ) )
+ .then( ( response ) => {
+ product = response.data;
+ } );
+
+ await use( product );
+
+ await restApi.delete( `${ WC_API_PATH }/products/${ product.id }`, {
+ force: true,
+ } );
+ },
+} );
+
+test.describe( 'Shortcode Checkout Custom Place Order Button', () => {
+ test(
+ 'clicking custom button triggers validation when form is invalid',
+ { tag: [ tags.PAYMENTS ] },
+ async ( { page, product } ) => {
+ await addAProductToCart( page, product.id, 1 );
+ await page.goto( CLASSIC_CHECKOUT_PAGE.slug );
+
+ // Selecting the custom button gateway without filling in the form.
+ await page.getByText( 'Test Custom Button Payment' ).click();
+
+ // Waiting for the custom button to appear.
+ await expect(
+ page.getByTestId( 'custom-place-order-button' )
+ ).toBeVisible();
+
+ // Clicking the custom button without filling required fields.
+ await page.getByTestId( 'custom-place-order-button' ).click();
+
+ // Ensuring validation errors are shown.
+ await expect(
+ page.locator( '.woocommerce-invalid' ).first()
+ ).toBeVisible();
+
+ // Ensuring we're still on checkout (order not submitted).
+ await expect( page ).not.toHaveURL( /order-received/ );
+ }
+ );
+
+ test(
+ 'switching between gateways shows/hides custom button',
+ { tag: [ tags.PAYMENTS ] },
+ async ( { page, product } ) => {
+ await addAProductToCart( page, product.id, 1 );
+ await page.goto( CLASSIC_CHECKOUT_PAGE.slug );
+
+ const customer = getFakeCustomer();
+ await page
+ .getByRole( 'textbox', { name: 'First name' } )
+ .fill( customer.billing.first_name );
+ await page
+ .getByRole( 'textbox', { name: 'Last name' } )
+ .fill( customer.billing.last_name );
+ await page
+ .getByRole( 'textbox', { name: 'Street address' } )
+ .fill( customer.billing.address_1 );
+ await page
+ .getByRole( 'textbox', { name: 'Town / City' } )
+ .fill( customer.billing.city );
+ await page
+ .getByRole( 'textbox', { name: 'ZIP Code' } )
+ .fill( customer.billing.postcode );
+ await page
+ .getByRole( 'textbox', { name: 'Phone' } )
+ .fill( customer.billing.phone );
+ await page
+ .getByRole( 'textbox', { name: 'Email address' } )
+ .fill( customer.billing.email );
+
+ // Selecting Cash on Delivery first.
+ await page.getByText( 'Cash on delivery' ).click();
+
+ // Ensuring the default button is visible and the custom button is not.
+ await expect( page.locator( '#place_order' ) ).toBeVisible();
+ await expect(
+ page.getByTestId( 'custom-place-order-button' )
+ ).toBeHidden();
+
+ // Switching to the custom button gateway.
+ await page.getByText( 'Test Custom Button Payment' ).click();
+
+ await page.waitForFunction( () => {
+ const form = document.querySelector( 'form.checkout' );
+ return (
+ form &&
+ form.classList.contains( 'has-custom-place-order-button' )
+ );
+ } );
+
+ // Ensuring the custom button is visible and the default one is hidden.
+ await expect(
+ page.getByTestId( 'custom-place-order-button' )
+ ).toBeVisible();
+ await expect( page.locator( '#place_order' ) ).toBeHidden();
+
+ // Switching back to Cash on Delivery.
+ await page.getByText( 'Cash on delivery' ).click();
+
+ await page.waitForFunction( () => {
+ const form = document.querySelector( 'form.checkout' );
+ return (
+ form &&
+ ! form.classList.contains( 'has-custom-place-order-button' )
+ );
+ } );
+
+ // Ensuring the default is visible and the custom one is hidden.
+ await expect( page.locator( '#place_order' ) ).toBeVisible();
+ await expect(
+ page.getByTestId( 'custom-place-order-button' )
+ ).toBeHidden();
+ }
+ );
+
+ test(
+ 'clicking custom button submits order when form is valid',
+ { tag: [ tags.PAYMENTS, tags.HPOS ] },
+ async ( { page, product } ) => {
+ await addAProductToCart( page, product.id, 1 );
+ await page.goto( CLASSIC_CHECKOUT_PAGE.slug );
+
+ const customer = getFakeCustomer();
+ await page
+ .getByRole( 'textbox', { name: 'First name' } )
+ .fill( customer.billing.first_name );
+ await page
+ .getByRole( 'textbox', { name: 'Last name' } )
+ .fill( customer.billing.last_name );
+ await page
+ .getByRole( 'textbox', { name: 'Street address' } )
+ .fill( customer.billing.address_1 );
+ await page
+ .getByRole( 'textbox', { name: 'Town / City' } )
+ .fill( customer.billing.city );
+ await page
+ .getByRole( 'textbox', { name: 'ZIP Code' } )
+ .fill( customer.billing.postcode );
+ await page
+ .getByRole( 'textbox', { name: 'Phone' } )
+ .fill( customer.billing.phone );
+ await page
+ .getByRole( 'textbox', { name: 'Email address' } )
+ .fill( customer.billing.email );
+
+ // Selecting the custom button gateway.
+ await page.getByText( 'Test Custom Button Payment' ).click();
+
+ // Waiting for the custom button to appear.
+ await expect(
+ page.getByTestId( 'custom-place-order-button' )
+ ).toBeVisible();
+
+ await page.getByTestId( 'custom-place-order-button' ).click();
+
+ // Ensuring the order was placed successfully.
+ await expect( page ).toHaveURL( /order-received/ );
+ await expect(
+ page.getByText( 'Your order has been received' )
+ ).toBeVisible();
+ }
+ );
+} );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-frontend-scripts-test.php b/plugins/woocommerce/tests/php/includes/class-wc-frontend-scripts-test.php
new file mode 100644
index 0000000000..3bee3ef903
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-frontend-scripts-test.php
@@ -0,0 +1,235 @@
+<?php
+declare( strict_types=1 );
+
+/**
+ * Tests for WC_Frontend_Scripts.
+ *
+ * @package WooCommerce\Tests\FrontendScripts
+ */
+
+/**
+ * Class WC_Frontend_Scripts_Test.
+ */
+class WC_Frontend_Scripts_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Gateways filter callback reference for cleanup.
+ *
+ * @var callable|null
+ */
+ private $gateways_filter_callback = null;
+
+ /**
+ * Setup test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ // Set jetpack_activation_source option to prevent "Cannot use bool as array" error.
+ update_option( 'jetpack_activation_source', array( '', '' ) );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+
+ // Remove the gateways filter if it was added.
+ if ( null !== $this->gateways_filter_callback ) {
+ remove_filter( 'woocommerce_payment_gateways', $this->gateways_filter_callback );
+ $this->gateways_filter_callback = null;
+ }
+
+ // Reinitialize payment gateways to clean state.
+ WC()->payment_gateways()->init();
+
+ delete_option( 'jetpack_activation_source' );
+ }
+
+ /**
+ * Create a mock gateway with custom place order button support.
+ *
+ * @return WC_Payment_Gateway
+ */
+ private function create_gateway_with_custom_button(): WC_Payment_Gateway {
+ return new class() extends WC_Payment_Gateway {
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'mock_custom_button';
+ $this->enabled = 'yes';
+ $this->method_title = 'Mock Gateway With Custom Button';
+ $this->has_custom_place_order_button = true;
+ }
+ };
+ }
+
+ /**
+ * Create a mock gateway without custom place order button.
+ *
+ * @return WC_Payment_Gateway
+ */
+ private function create_gateway_without_custom_button(): WC_Payment_Gateway {
+ return new class() extends WC_Payment_Gateway {
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'mock_no_custom_button';
+ $this->enabled = 'yes';
+ $this->method_title = 'Mock Gateway Without Custom Button';
+ }
+ };
+ }
+
+ /**
+ * Create a mock gateway with truthy but non-boolean value.
+ *
+ * @return WC_Payment_Gateway
+ */
+ private function create_gateway_with_truthy_value(): WC_Payment_Gateway {
+ return new class() extends WC_Payment_Gateway {
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'mock_truthy_value';
+ $this->enabled = 'yes';
+ $this->method_title = 'Mock Gateway With Truthy Value';
+ $this->has_custom_place_order_button = 'yes'; // Truthy but not boolean true.
+ }
+ };
+ }
+
+ /**
+ * Helper to register test gateway instances.
+ *
+ * @param array $gateway_instances Array of gateway instances to register.
+ */
+ private function register_test_gateways( array $gateway_instances ): void {
+ $this->gateways_filter_callback = function ( $gateways ) use ( $gateway_instances ) {
+ return array_merge( $gateways, $gateway_instances );
+ };
+ add_filter( 'woocommerce_payment_gateways', $this->gateways_filter_callback );
+ WC()->payment_gateways()->init();
+ }
+
+ /**
+ * Helper to call private static method get_script_data via Reflection.
+ *
+ * @param string $handle Script handle.
+ * @return array|bool Script data array or false.
+ */
+ private function get_script_data( string $handle ) {
+ $reflection = new ReflectionClass( 'WC_Frontend_Scripts' );
+ $method = $reflection->getMethod( 'get_script_data' );
+ $method->setAccessible( true );
+ return $method->invoke( null, $handle );
+ }
+
+ /**
+ * Test that script data for wc-checkout includes gateways_with_custom_place_order_button key.
+ */
+ public function test_checkout_script_data_includes_gateways_with_custom_place_order_button_key(): void {
+ $data = $this->get_script_data( 'wc-checkout' );
+
+ $this->assertArrayHasKey( 'gateways_with_custom_place_order_button', $data );
+ $this->assertIsArray( $data['gateways_with_custom_place_order_button'] );
+ }
+
+ /**
+ * Test that script data for wc-add-payment-method includes gateways_with_custom_place_order_button key.
+ */
+ public function test_add_payment_method_script_data_includes_gateways_with_custom_place_order_button_key(): void {
+ $data = $this->get_script_data( 'wc-add-payment-method' );
+
+ $this->assertArrayHasKey( 'gateways_with_custom_place_order_button', $data );
+ $this->assertIsArray( $data['gateways_with_custom_place_order_button'] );
+ }
+
+ /**
+ * Test that gateways with has_custom_place_order_button = true are included.
+ */
+ public function test_gateway_with_custom_button_is_included(): void {
+ $this->register_test_gateways( array( $this->create_gateway_with_custom_button() ) );
+
+ $data = $this->get_script_data( 'wc-checkout' );
+
+ $this->assertContains( 'mock_custom_button', $data['gateways_with_custom_place_order_button'] );
+ }
+
+ /**
+ * Test that gateways without has_custom_place_order_button are not included.
+ */
+ public function test_gateway_without_custom_button_is_not_included(): void {
+ $this->register_test_gateways( array( $this->create_gateway_without_custom_button() ) );
+
+ $data = $this->get_script_data( 'wc-checkout' );
+
+ $this->assertNotContains( 'mock_no_custom_button', $data['gateways_with_custom_place_order_button'] );
+ }
+
+ /**
+ * Test that gateways with truthy but non-boolean values are not included.
+ *
+ * The has_custom_place_order_button property must be strictly boolean true,
+ * not just truthy values like 'yes', '1', or 1.
+ */
+ public function test_gateway_with_truthy_non_boolean_value_is_not_included(): void {
+ $this->register_test_gateways( array( $this->create_gateway_with_truthy_value() ) );
+
+ $data = $this->get_script_data( 'wc-checkout' );
+
+ $this->assertNotContains( 'mock_truthy_value', $data['gateways_with_custom_place_order_button'] );
+ }
+
+ /**
+ * Test that multiple gateways with custom buttons are all included.
+ */
+ public function test_multiple_gateways_with_custom_buttons_are_included(): void {
+ $this->register_test_gateways(
+ array(
+ $this->create_gateway_with_custom_button(),
+ $this->create_gateway_without_custom_button(),
+ $this->create_gateway_with_truthy_value(),
+ )
+ );
+
+ $data = $this->get_script_data( 'wc-checkout' );
+
+ // Only the gateway with true boolean should be included.
+ $this->assertContains( 'mock_custom_button', $data['gateways_with_custom_place_order_button'] );
+ $this->assertNotContains( 'mock_no_custom_button', $data['gateways_with_custom_place_order_button'] );
+ $this->assertNotContains( 'mock_truthy_value', $data['gateways_with_custom_place_order_button'] );
+ }
+
+ /**
+ * Test that the same gateways are returned for both checkout and add-payment-method.
+ */
+ public function test_same_gateways_returned_for_checkout_and_add_payment_method(): void {
+ $this->register_test_gateways( array( $this->create_gateway_with_custom_button() ) );
+
+ $checkout_data = $this->get_script_data( 'wc-checkout' );
+ $add_payment_method_data = $this->get_script_data( 'wc-add-payment-method' );
+
+ $this->assertEquals(
+ $checkout_data['gateways_with_custom_place_order_button'],
+ $add_payment_method_data['gateways_with_custom_place_order_button']
+ );
+ }
+
+ /**
+ * Test that default WooCommerce gateways are not in the list.
+ *
+ * Default gateways like BACS, COD, Cheque don't have custom place order buttons.
+ */
+ public function test_default_gateways_are_not_in_list(): void {
+ $data = $this->get_script_data( 'wc-checkout' );
+
+ $this->assertNotContains( 'bacs', $data['gateways_with_custom_place_order_button'] );
+ $this->assertNotContains( 'cod', $data['gateways_with_custom_place_order_button'] );
+ $this->assertNotContains( 'cheque', $data['gateways_with_custom_place_order_button'] );
+ }
+}