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