From d6e3936148b48a7e8ab05d7cc29d5f7786571a10 Mon Sep 17 00:00:00 2001
From: Angela Blake
Date: Mon, 4 May 2026 11:31:41 -0500
Subject: [PATCH 1/6] Donations Block: add Security panel with min/max amount
enforcement
Inspector: new collapsed Security panel with Minimum and Maximum
amount fields (opt-in, no defaults). Stripe's floor for the active
currency is the lower bound on the minimum field. Inline validation
shows an error if maximum is set below minimum.
Enforcement: PHP stamps pre-translated data-min-error / data-max-error
strings and numeric data-min-amount / data-max-amount on the wrapper.
view.js checks on preset tile click, custom amount input, and when a
configured default amount is applied. Out-of-range keeps the donate
button disabled and shows a red message above the separator; clears
automatically on tab switch or when amount comes back in range.
Co-Authored-By: Claude Opus 4.7
---
.../add-donations-security-min-max-amounts | 4 ++
.../extensions/blocks/donations/block.json | 6 ++
.../extensions/blocks/donations/controls.js | 46 +++++++++++++-
.../extensions/blocks/donations/donations.php | 19 ++++++
.../extensions/blocks/donations/view.js | 60 +++++++++++++++++--
.../extensions/blocks/donations/view.scss | 11 ++++
6 files changed, 140 insertions(+), 6 deletions(-)
create mode 100644 projects/plugins/jetpack/changelog/add-donations-security-min-max-amounts
diff --git a/projects/plugins/jetpack/changelog/add-donations-security-min-max-amounts b/projects/plugins/jetpack/changelog/add-donations-security-min-max-amounts
new file mode 100644
index 000000000000..0e88e7fcac68
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-donations-security-min-max-amounts
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Donations Block: Add Security inspector panel with configurable minimum and maximum donation amounts to help prevent fraudulent transactions.
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/block.json b/projects/plugins/jetpack/extensions/blocks/donations/block.json
index eea62474b434..2ac0c242e4da 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/block.json
+++ b/projects/plugins/jetpack/extensions/blocks/donations/block.json
@@ -174,6 +174,12 @@
"type": "string",
"enum": [ "", "left", "center", "right" ],
"default": ""
+ },
+ "minimumAmount": {
+ "type": "number"
+ },
+ "maximumAmount": {
+ "type": "number"
}
},
"example": {}
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/controls.js b/projects/plugins/jetpack/extensions/blocks/donations/controls.js
index 4033e0cb20c1..abb3f263f008 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/controls.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/controls.js
@@ -45,9 +45,12 @@ const Controls = props => {
contentAlignment,
defaultInterval,
customAmountPlaceholder,
+ minimumAmount,
+ maximumAmount,
} = attributes;
- const computedCustomAmountPlaceholder = minimumTransactionAmountForCurrency( currency ) * 100;
+ const stripeMin = minimumTransactionAmountForCurrency( currency );
+ const computedCustomAmountPlaceholder = stripeMin * 100;
const effectiveCustomAmountPlaceholder =
customAmountPlaceholder ?? computedCustomAmountPlaceholder;
@@ -331,6 +334,47 @@ const Controls = props => {
+
+
+ { __(
+ 'Setting minimum and maximum donation amounts can help prevent fraudulent transactions, such as card testing with very small values.',
+ 'jetpack'
+ ) }
+
+
+ setAttributes( {
+ minimumAmount: value === '' ? undefined : Number( value ),
+ } )
+ }
+ min={ stripeMin }
+ step={ 0.01 }
+ __nextHasNoMarginBottom={ true }
+ />
+
+ setAttributes( {
+ maximumAmount: value === '' ? undefined : Number( value ),
+ } )
+ }
+ min={ minimumAmount ?? stripeMin }
+ step={ 0.01 }
+ help={
+ minimumAmount !== undefined &&
+ maximumAmount !== undefined &&
+ maximumAmount < minimumAmount
+ ? __( 'Maximum must be greater than the minimum amount.', 'jetpack' )
+ : undefined
+ }
+ __nextHasNoMarginBottom={ true }
+ />
+
>
);
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/donations.php b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
index e8cf430c37d9..c3748691988d 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/donations.php
+++ b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
@@ -254,6 +254,24 @@ function render_block( $attr, $content ) {
if ( $default_interval ) {
$wrapper_attr_array['data-default-interval'] = $default_interval;
}
+ $min_amount = isset( $attr['minimumAmount'] ) ? (float) $attr['minimumAmount'] : null;
+ $max_amount = isset( $attr['maximumAmount'] ) ? (float) $attr['maximumAmount'] : null;
+ if ( null !== $min_amount ) {
+ $wrapper_attr_array['data-min-amount'] = $min_amount;
+ $wrapper_attr_array['data-min-error'] = sprintf(
+ /* translators: %s: minimum donation amount formatted with currency symbol */
+ __( 'The minimum donation amount is %s.', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $min_amount, $currency )
+ );
+ }
+ if ( null !== $max_amount ) {
+ $wrapper_attr_array['data-max-amount'] = $max_amount;
+ $wrapper_attr_array['data-max-error'] = sprintf(
+ /* translators: %s: maximum donation amount formatted with currency symbol */
+ __( 'The maximum donation amount is %s.', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $max_amount, $currency )
+ );
+ }
$wrapper_attrs = get_block_wrapper_attributes( $wrapper_attr_array );
$custom_styles = build_custom_styles( $attr, '.' . $instance_id );
@@ -271,6 +289,7 @@ function render_block( $attr, $content ) {
%4$s
%5$s
%6$s
+
%7$s
%8$s
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.js b/projects/plugins/jetpack/extensions/blocks/donations/view.js
index db8b1f1a15ca..37e6d5de345e 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.js
@@ -18,6 +18,10 @@ class JetpackDonations {
this.amount = null;
this.isCustomAmount = false;
this.interval = block.dataset.defaultInterval || 'one-time';
+ this.minAmount = block.dataset.minAmount ? parseFloat( block.dataset.minAmount ) : null;
+ this.maxAmount = block.dataset.maxAmount ? parseFloat( block.dataset.maxAmount ) : null;
+ this.minError = block.dataset.minError || '';
+ this.maxError = block.dataset.maxError || '';
// Initialize block.
this.initNavigation();
@@ -50,7 +54,13 @@ class JetpackDonations {
this.amount = tile.dataset.amount;
this.isCustomAmount = false;
this.updateUrl();
- this.toggleDonateButton( true );
+ const defaultRangeError = this.checkAmountRange( parseFloat( tile.dataset.amount ) );
+ if ( defaultRangeError ) {
+ this.showRangeError( defaultRangeError );
+ this.toggleDonateButton( false );
+ } else {
+ this.toggleDonateButton( true );
+ }
}
getNavItem( interval ) {
@@ -115,9 +125,17 @@ class JetpackDonations {
if ( parsedAmount && parsedAmount >= minimumTransactionAmountForCurrency( currency ) ) {
wrapper.classList.remove( 'has-error' );
this.amount = parsedAmount;
- this.toggleDonateButton( true );
+ const customRangeError = this.checkAmountRange( parsedAmount );
+ if ( customRangeError ) {
+ this.showRangeError( customRangeError );
+ this.toggleDonateButton( false );
+ } else {
+ this.clearRangeError();
+ this.toggleDonateButton( true );
+ }
} else {
wrapper.classList.add( 'has-error' );
+ this.clearRangeError();
this.amount = null;
this.toggleDonateButton( false );
}
@@ -158,6 +176,7 @@ class JetpackDonations {
this.isCustomAmount = false;
this.resetSelectedAmount();
this.updateUrl();
+ this.clearRangeError();
// Disable donate button.
this.toggleDonateButton( false );
@@ -240,9 +259,14 @@ class JetpackDonations {
}
this.updateUrl();
- // Enables the donate button.
- const donateButton = this.getDonateButton();
- donateButton.classList.remove( 'is-disabled' );
+ const rangeError = this.checkAmountRange( parseFloat( event.target.dataset.amount ) );
+ if ( rangeError ) {
+ this.showRangeError( rangeError );
+ this.toggleDonateButton( false );
+ } else {
+ this.clearRangeError();
+ this.toggleDonateButton( true );
+ }
} );
} );
@@ -251,6 +275,32 @@ class JetpackDonations {
.querySelectorAll( '.donations__donate-button' )
.forEach( button => button.classList.add( 'is-disabled' ) );
}
+
+ checkAmountRange( amount ) {
+ if ( this.minAmount !== null && amount < this.minAmount ) {
+ return this.minError;
+ }
+ if ( this.maxAmount !== null && amount > this.maxAmount ) {
+ return this.maxError;
+ }
+ return null;
+ }
+
+ showRangeError( message ) {
+ const el = this.block.querySelector( '.donations__range-error' );
+ if ( el ) {
+ el.textContent = message;
+ el.classList.add( 'is-visible' );
+ }
+ }
+
+ clearRangeError() {
+ const el = this.block.querySelector( '.donations__range-error' );
+ if ( el ) {
+ el.textContent = '';
+ el.classList.remove( 'is-visible' );
+ }
+ }
}
domReady( () => {
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.scss b/projects/plugins/jetpack/extensions/blocks/donations/view.scss
index 4001e239ab7a..21ea3d817d91 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.scss
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.scss
@@ -107,6 +107,17 @@
}
}
+ .donations__range-error {
+ display: none;
+ color: #d63638;
+ font-size: 0.875em;
+ margin: 0 0 8px;
+
+ &.is-visible {
+ display: block;
+ }
+ }
+
.donations__donate-button.is-disabled {
pointer-events: none;
opacity: 0.2;
From 391f381d48d8ae04f9b001b706f0dcd4eb332116 Mon Sep 17 00:00:00 2001
From: Angela Blake
Date: Mon, 4 May 2026 11:54:50 -0500
Subject: [PATCH 2/6] Donations Block: show Stripe floor error on custom
amount; shorten security copy
Show a donor-facing message when a custom amount is valid but below
Stripe's minimum for the currency (e.g. entering $0.01 with no admin
minimum set). PHP stamps data-stripe-min-error on the wrapper; view.js
shows it in the else branch only when parsedAmount is a positive number.
Also shortens the Security panel description in the inspector.
Co-Authored-By: Claude Opus 4.7
---
.../jetpack/extensions/blocks/donations/controls.js | 2 +-
.../jetpack/extensions/blocks/donations/donations.php | 6 ++++++
.../plugins/jetpack/extensions/blocks/donations/view.js | 7 ++++++-
3 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/controls.js b/projects/plugins/jetpack/extensions/blocks/donations/controls.js
index abb3f263f008..61c6a2c69d30 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/controls.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/controls.js
@@ -337,7 +337,7 @@ const Controls = props => {
{ __(
- 'Setting minimum and maximum donation amounts can help prevent fraudulent transactions, such as card testing with very small values.',
+ 'Setting minimum and maximum donation amounts can help prevent fraudulent transactions.',
'jetpack'
) }
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/donations.php b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
index c3748691988d..e2f1407cff29 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/donations.php
+++ b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
@@ -272,6 +272,12 @@ function render_block( $attr, $content ) {
\Jetpack_Currencies::format_price( $max_amount, $currency )
);
}
+ $stripe_min = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1;
+ $wrapper_attr_array['data-stripe-min-error'] = sprintf(
+ /* translators: %s: minimum donation amount formatted with currency symbol */
+ __( 'The minimum donation amount is %s.', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $stripe_min, $currency )
+ );
$wrapper_attrs = get_block_wrapper_attributes( $wrapper_attr_array );
$custom_styles = build_custom_styles( $attr, '.' . $instance_id );
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.js b/projects/plugins/jetpack/extensions/blocks/donations/view.js
index 37e6d5de345e..00248474b6eb 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.js
@@ -22,6 +22,7 @@ class JetpackDonations {
this.maxAmount = block.dataset.maxAmount ? parseFloat( block.dataset.maxAmount ) : null;
this.minError = block.dataset.minError || '';
this.maxError = block.dataset.maxError || '';
+ this.stripeMinError = block.dataset.stripeMinError || '';
// Initialize block.
this.initNavigation();
@@ -135,7 +136,11 @@ class JetpackDonations {
}
} else {
wrapper.classList.add( 'has-error' );
- this.clearRangeError();
+ if ( parsedAmount && parsedAmount > 0 ) {
+ this.showRangeError( this.stripeMinError );
+ } else {
+ this.clearRangeError();
+ }
this.amount = null;
this.toggleDonateButton( false );
}
From 5eb54585a979c515d0d0f4e75b5db2e5d56a6c19 Mon Sep 17 00:00:00 2001
From: Angela Blake
Date: Mon, 4 May 2026 12:15:22 -0500
Subject: [PATCH 3/6] Donations Block: add tests for range validation and
security attrs
JS: add checkAmountRange as a named export in utils.js; view.js
delegates to it. New test/utils.test.js covers all range cases
(below min, above max, at boundaries, no limits set) plus the
existing firstShownInterval helper.
PHP: extract inline security attr logic from render_block into
build_security_data_attrs(). New tests in Donations_Test.php cover
min/max attribute presence, formatted currency values, and that the
Stripe floor message is currency-aware (USD vs GBP).
Co-Authored-By: Claude Opus 4.7
---
.../extensions/blocks/donations/donations.php | 67 ++++++++++++-------
.../blocks/donations/test/utils.test.js | 60 +++++++++++++++++
.../extensions/blocks/donations/utils.js | 20 ++++++
.../extensions/blocks/donations/view.js | 9 +--
.../php/extensions/blocks/Donations_Test.php | 50 ++++++++++++++
5 files changed, 173 insertions(+), 33 deletions(-)
create mode 100644 projects/plugins/jetpack/extensions/blocks/donations/test/utils.test.js
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/donations.php b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
index e2f1407cff29..975fad2a5a20 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/donations.php
+++ b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
@@ -254,32 +254,9 @@ function render_block( $attr, $content ) {
if ( $default_interval ) {
$wrapper_attr_array['data-default-interval'] = $default_interval;
}
- $min_amount = isset( $attr['minimumAmount'] ) ? (float) $attr['minimumAmount'] : null;
- $max_amount = isset( $attr['maximumAmount'] ) ? (float) $attr['maximumAmount'] : null;
- if ( null !== $min_amount ) {
- $wrapper_attr_array['data-min-amount'] = $min_amount;
- $wrapper_attr_array['data-min-error'] = sprintf(
- /* translators: %s: minimum donation amount formatted with currency symbol */
- __( 'The minimum donation amount is %s.', 'jetpack' ),
- \Jetpack_Currencies::format_price( $min_amount, $currency )
- );
- }
- if ( null !== $max_amount ) {
- $wrapper_attr_array['data-max-amount'] = $max_amount;
- $wrapper_attr_array['data-max-error'] = sprintf(
- /* translators: %s: maximum donation amount formatted with currency symbol */
- __( 'The maximum donation amount is %s.', 'jetpack' ),
- \Jetpack_Currencies::format_price( $max_amount, $currency )
- );
- }
- $stripe_min = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1;
- $wrapper_attr_array['data-stripe-min-error'] = sprintf(
- /* translators: %s: minimum donation amount formatted with currency symbol */
- __( 'The minimum donation amount is %s.', 'jetpack' ),
- \Jetpack_Currencies::format_price( $stripe_min, $currency )
- );
- $wrapper_attrs = get_block_wrapper_attributes( $wrapper_attr_array );
- $custom_styles = build_custom_styles( $attr, '.' . $instance_id );
+ $wrapper_attr_array = array_merge( $wrapper_attr_array, build_security_data_attrs( $attr, $currency ) );
+ $wrapper_attrs = get_block_wrapper_attributes( $wrapper_attr_array );
+ $custom_styles = build_custom_styles( $attr, '.' . $instance_id );
$choose_amount_html = wp_kses_post( $choose_amount_text );
$choose_amount_block = '' !== trim( $choose_amount_html ) ? '' . $choose_amount_html . '
' : '';
@@ -317,6 +294,44 @@ function render_block( $attr, $content ) {
);
}
+/**
+ * Build data-attributes array for security (min/max amount) constraints.
+ *
+ * Extracted so it can be tested independently of the full render pipeline.
+ *
+ * @param array $attr Block attributes.
+ * @param string $currency Currency code (e.g. 'USD').
+ * @return array Associative array of data-attribute name => value.
+ */
+function build_security_data_attrs( $attr, $currency ) {
+ $attrs = array();
+ $min_amount = isset( $attr['minimumAmount'] ) ? (float) $attr['minimumAmount'] : null;
+ $max_amount = isset( $attr['maximumAmount'] ) ? (float) $attr['maximumAmount'] : null;
+ if ( null !== $min_amount ) {
+ $attrs['data-min-amount'] = $min_amount;
+ $attrs['data-min-error'] = sprintf(
+ /* translators: %s: minimum donation amount formatted with currency symbol */
+ __( 'The minimum donation amount is %s.', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $min_amount, $currency )
+ );
+ }
+ if ( null !== $max_amount ) {
+ $attrs['data-max-amount'] = $max_amount;
+ $attrs['data-max-error'] = sprintf(
+ /* translators: %s: maximum donation amount formatted with currency symbol */
+ __( 'The maximum donation amount is %s.', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $max_amount, $currency )
+ );
+ }
+ $stripe_min = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1;
+ $attrs['data-stripe-min-error'] = sprintf(
+ /* translators: %s: minimum donation amount formatted with currency symbol */
+ __( 'The minimum donation amount is %s.', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $stripe_min, $currency )
+ );
+ return $attrs;
+}
+
/**
* Build a CSS string scoping per-state and tab-level style rules to a single
* block instance.
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/test/utils.test.js b/projects/plugins/jetpack/extensions/blocks/donations/test/utils.test.js
new file mode 100644
index 000000000000..6359d071bb6b
--- /dev/null
+++ b/projects/plugins/jetpack/extensions/blocks/donations/test/utils.test.js
@@ -0,0 +1,60 @@
+import { checkAmountRange, firstShownInterval } from '../utils';
+
+describe( 'firstShownInterval', () => {
+ test( 'returns one-time when it is shown', () => {
+ expect( firstShownInterval( true, true, true ) ).toBe( 'one-time' );
+ expect( firstShownInterval( true, false, false ) ).toBe( 'one-time' );
+ } );
+
+ test( 'skips one-time and returns monthly when one-time is hidden', () => {
+ expect( firstShownInterval( false, true, true ) ).toBe( '1 month' );
+ expect( firstShownInterval( false, true, false ) ).toBe( '1 month' );
+ } );
+
+ test( 'returns annual when only annual is shown', () => {
+ expect( firstShownInterval( false, false, true ) ).toBe( '1 year' );
+ } );
+
+ test( 'returns null when all are hidden', () => {
+ expect( firstShownInterval( false, false, false ) ).toBeNull();
+ } );
+} );
+
+describe( 'checkAmountRange', () => {
+ const MIN_ERROR = 'Minimum is $10.';
+ const MAX_ERROR = 'Maximum is $100.';
+
+ test( 'returns null when no limits are set', () => {
+ expect( checkAmountRange( 50, null, null, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns null when amount is within range', () => {
+ expect( checkAmountRange( 50, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns null when amount equals the minimum', () => {
+ expect( checkAmountRange( 10, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns null when amount equals the maximum', () => {
+ expect( checkAmountRange( 100, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns minError when amount is below minimum', () => {
+ expect( checkAmountRange( 5, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBe( MIN_ERROR );
+ } );
+
+ test( 'returns maxError when amount is above maximum', () => {
+ expect( checkAmountRange( 150, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBe( MAX_ERROR );
+ } );
+
+ test( 'returns minError with only a minimum set', () => {
+ expect( checkAmountRange( 1, 10, null, MIN_ERROR, MAX_ERROR ) ).toBe( MIN_ERROR );
+ expect( checkAmountRange( 10, 10, null, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns maxError with only a maximum set', () => {
+ expect( checkAmountRange( 200, null, 100, MIN_ERROR, MAX_ERROR ) ).toBe( MAX_ERROR );
+ expect( checkAmountRange( 100, null, 100, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+} );
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/utils.js b/projects/plugins/jetpack/extensions/blocks/donations/utils.js
index b129fbfbd281..5e7017a460d5 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/utils.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/utils.js
@@ -13,6 +13,26 @@ export function firstShownInterval( oneTimeShown, monthlyShown, annualShown ) {
return null;
}
+/**
+ * Returns an error message if amount falls outside the configured min/max range, or null if valid.
+ *
+ * @param {number} amount - The donation amount to check.
+ * @param {number|null} minAmount - Admin-configured minimum (null = no limit).
+ * @param {number|null} maxAmount - Admin-configured maximum (null = no limit).
+ * @param {string} minError - Pre-translated error string for below-minimum.
+ * @param {string} maxError - Pre-translated error string for above-maximum.
+ * @return {string|null} Error message, or null if amount is within range.
+ */
+export function checkAmountRange( amount, minAmount, maxAmount, minError, maxError ) {
+ if ( minAmount !== null && amount < minAmount ) {
+ return minError;
+ }
+ if ( maxAmount !== null && amount > maxAmount ) {
+ return maxError;
+ }
+ return null;
+}
+
/**
* Return the default texts defined in `donations.php` and injected client side by assigning them
* to the `Jetpack_DonationsBlock` attribute of the window object.
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.js b/projects/plugins/jetpack/extensions/blocks/donations/view.js
index 00248474b6eb..22d3780d287b 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.js
@@ -3,6 +3,7 @@ import domReady from '@wordpress/dom-ready';
import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
import { minimumTransactionAmountForCurrency, parseAmount } from '../../shared/currencies';
import { initializeMembershipButtons } from '../../shared/memberships';
+import { checkAmountRange as checkRange } from './utils';
import './view.scss';
@@ -282,13 +283,7 @@ class JetpackDonations {
}
checkAmountRange( amount ) {
- if ( this.minAmount !== null && amount < this.minAmount ) {
- return this.minError;
- }
- if ( this.maxAmount !== null && amount > this.maxAmount ) {
- return this.maxError;
- }
- return null;
+ return checkRange( amount, this.minAmount, this.maxAmount, this.minError, this.maxError );
}
showRangeError( message ) {
diff --git a/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php b/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
index aa731bfb0e0d..6515449aecbe 100644
--- a/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
+++ b/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
@@ -9,15 +9,18 @@
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\DataProvider;
require_once JETPACK__PLUGIN_DIR . '/extensions/blocks/donations/donations.php';
+require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-currencies.php';
/**
* Donations block tests.
*
* @covers ::Automattic\Jetpack\Extensions\Donations\build_custom_styles
* @covers ::Automattic\Jetpack\Extensions\Donations\sanitize_css_value
+ * @covers ::Automattic\Jetpack\Extensions\Donations\build_security_data_attrs
*/
#[CoversFunction( 'Automattic\\Jetpack\\Extensions\\Donations\\sanitize_css_value' )]
#[CoversFunction( 'Automattic\\Jetpack\\Extensions\\Donations\\build_custom_styles' )]
+#[CoversFunction( 'Automattic\\Jetpack\\Extensions\\Donations\\build_security_data_attrs' )]
class Donations_Test extends \WP_UnitTestCase {
use \Automattic\Jetpack\PHPUnit\WP_UnitTestCase_Fix;
@@ -340,4 +343,51 @@ public function test_build_custom_styles_drops_unknown_alignment() {
$css = Donations\build_custom_styles( array( 'buttonAlignment' => 'wat' ), '.jp-donations-1' );
$this->assertStringNotContainsString( 'donate-button-wrapper', $css );
}
+
+ /**
+ * No min/max set: only data-stripe-min-error is present.
+ */
+ public function test_build_security_data_attrs_no_limits() {
+ $attrs = Donations\build_security_data_attrs( array(), 'USD' );
+
+ $this->assertArrayNotHasKey( 'data-min-amount', $attrs );
+ $this->assertArrayNotHasKey( 'data-min-error', $attrs );
+ $this->assertArrayNotHasKey( 'data-max-amount', $attrs );
+ $this->assertArrayNotHasKey( 'data-max-error', $attrs );
+ $this->assertArrayHasKey( 'data-stripe-min-error', $attrs );
+ $this->assertStringContainsString( '$0.50', $attrs['data-stripe-min-error'] );
+ }
+
+ /**
+ * Minimum set: data-min-amount and data-min-error are present with correct values.
+ */
+ public function test_build_security_data_attrs_with_minimum() {
+ $attrs = Donations\build_security_data_attrs( array( 'minimumAmount' => 10 ), 'USD' );
+
+ $this->assertSame( 10.0, $attrs['data-min-amount'] );
+ $this->assertStringContainsString( '$10.00', $attrs['data-min-error'] );
+ $this->assertArrayNotHasKey( 'data-max-amount', $attrs );
+ }
+
+ /**
+ * Maximum set: data-max-amount and data-max-error are present with correct values.
+ */
+ public function test_build_security_data_attrs_with_maximum() {
+ $attrs = Donations\build_security_data_attrs( array( 'maximumAmount' => 500 ), 'USD' );
+
+ $this->assertSame( 500.0, $attrs['data-max-amount'] );
+ $this->assertStringContainsString( '$500.00', $attrs['data-max-error'] );
+ $this->assertArrayNotHasKey( 'data-min-amount', $attrs );
+ }
+
+ /**
+ * Stripe floor message reflects the currency — GBP minimum is £0.30, not $0.50.
+ */
+ public function test_build_security_data_attrs_stripe_min_is_currency_aware() {
+ $usd_attrs = Donations\build_security_data_attrs( array(), 'USD' );
+ $gbp_attrs = Donations\build_security_data_attrs( array(), 'GBP' );
+
+ $this->assertStringContainsString( '$0.50', $usd_attrs['data-stripe-min-error'] );
+ $this->assertStringContainsString( '£0.30', $gbp_attrs['data-stripe-min-error'] );
+ }
}
From 941f38c30c4b48bb3e4a1de9fe482e51fe9ac30e Mon Sep 17 00:00:00 2001
From: Angela Blake
Date: Mon, 4 May 2026 12:50:06 -0500
Subject: [PATCH 4/6] Donations Block: accessibility and i18n fixes from code
review
- aria-disabled + tabindex=-1 on donate buttons; early click guard stops
memberships.js from opening Stripe modal for keyboard/AT users
- role=alert set dynamically in showRangeError (not in static HTML) to
prevent false AT announcements on page load
- @since $$next-version$$ added to build_security_data_attrs()
- Stripe floor string uses _x() with context to avoid shared msgid with
admin-min string
- .donations__range-error uses gb.$alert-red token and margin-block-end
for RTL correctness
- PHP tests: both-limits and inverted-limits cases added
Co-Authored-By: Claude Opus 4.7
---
.../extensions/blocks/donations/donations.php | 8 +++--
.../extensions/blocks/donations/view.js | 30 ++++++++++++----
.../extensions/blocks/donations/view.scss | 4 +--
.../php/extensions/blocks/Donations_Test.php | 36 +++++++++++++++++++
4 files changed, 67 insertions(+), 11 deletions(-)
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/donations.php b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
index 975fad2a5a20..c8ee57f2667a 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/donations.php
+++ b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
@@ -272,7 +272,7 @@ function render_block( $attr, $content ) {
%4$s
%5$s
%6$s
-
+
%7$s
%8$s
@@ -299,6 +299,8 @@ function render_block( $attr, $content ) {
*
* Extracted so it can be tested independently of the full render pipeline.
*
+ * @since $$next-version$$
+ *
* @param array $attr Block attributes.
* @param string $currency Currency code (e.g. 'USD').
* @return array Associative array of data-attribute name => value.
@@ -325,8 +327,8 @@ function build_security_data_attrs( $attr, $currency ) {
}
$stripe_min = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1;
$attrs['data-stripe-min-error'] = sprintf(
- /* translators: %s: minimum donation amount formatted with currency symbol */
- __( 'The minimum donation amount is %s.', 'jetpack' ),
+ /* translators: %s: payment processor minimum donation amount formatted with currency symbol */
+ _x( 'The minimum donation amount is %s.', 'payment processor minimum', 'jetpack' ),
\Jetpack_Currencies::format_price( $stripe_min, $currency )
);
return $attrs;
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.js b/projects/plugins/jetpack/extensions/blocks/donations/view.js
index 22d3780d287b..29782dd44d09 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.js
@@ -89,9 +89,15 @@ class JetpackDonations {
toggleDonateButton( enable ) {
const donateButton = this.getDonateButton();
- enable
- ? donateButton.classList.remove( 'is-disabled' )
- : donateButton.classList.add( 'is-disabled' );
+ if ( enable ) {
+ donateButton.classList.remove( 'is-disabled' );
+ donateButton.removeAttribute( 'aria-disabled' );
+ donateButton.removeAttribute( 'tabindex' );
+ } else {
+ donateButton.classList.add( 'is-disabled' );
+ donateButton.setAttribute( 'aria-disabled', 'true' );
+ donateButton.setAttribute( 'tabindex', '-1' );
+ }
}
updateUrl() {
@@ -277,9 +283,19 @@ class JetpackDonations {
} );
// Disable all buttons on init since no amount has been chosen yet.
- this.block
- .querySelectorAll( '.donations__donate-button' )
- .forEach( button => button.classList.add( 'is-disabled' ) );
+ // Also attach a click guard before memberships.js adds its handler, so
+ // keyboard and AT users cannot activate a disabled button.
+ this.block.querySelectorAll( '.donations__donate-button' ).forEach( button => {
+ button.classList.add( 'is-disabled' );
+ button.setAttribute( 'aria-disabled', 'true' );
+ button.setAttribute( 'tabindex', '-1' );
+ button.addEventListener( 'click', event => {
+ if ( button.getAttribute( 'aria-disabled' ) === 'true' ) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+ } );
+ } );
}
checkAmountRange( amount ) {
@@ -289,6 +305,7 @@ class JetpackDonations {
showRangeError( message ) {
const el = this.block.querySelector( '.donations__range-error' );
if ( el ) {
+ el.setAttribute( 'role', 'alert' );
el.textContent = message;
el.classList.add( 'is-visible' );
}
@@ -297,6 +314,7 @@ class JetpackDonations {
clearRangeError() {
const el = this.block.querySelector( '.donations__range-error' );
if ( el ) {
+ el.removeAttribute( 'role' );
el.textContent = '';
el.classList.remove( 'is-visible' );
}
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.scss b/projects/plugins/jetpack/extensions/blocks/donations/view.scss
index 21ea3d817d91..e537c1d3b7eb 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.scss
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.scss
@@ -109,9 +109,9 @@
.donations__range-error {
display: none;
- color: #d63638;
+ color: gb.$alert-red;
font-size: 0.875em;
- margin: 0 0 8px;
+ margin-block-end: 8px;
&.is-visible {
display: block;
diff --git a/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php b/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
index 6515449aecbe..93f0c6969e9b 100644
--- a/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
+++ b/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
@@ -390,4 +390,40 @@ public function test_build_security_data_attrs_stripe_min_is_currency_aware() {
$this->assertStringContainsString( '$0.50', $usd_attrs['data-stripe-min-error'] );
$this->assertStringContainsString( '£0.30', $gbp_attrs['data-stripe-min-error'] );
}
+
+ /**
+ * Both min and max set: all four data attributes are present with correct values.
+ */
+ public function test_build_security_data_attrs_with_both_limits() {
+ $attrs = Donations\build_security_data_attrs(
+ array(
+ 'minimumAmount' => 5,
+ 'maximumAmount' => 250,
+ ),
+ 'USD'
+ );
+
+ $this->assertSame( 5.0, $attrs['data-min-amount'] );
+ $this->assertStringContainsString( '$5.00', $attrs['data-min-error'] );
+ $this->assertSame( 250.0, $attrs['data-max-amount'] );
+ $this->assertStringContainsString( '$250.00', $attrs['data-max-error'] );
+ $this->assertArrayHasKey( 'data-stripe-min-error', $attrs );
+ }
+
+ /**
+ * Inverted config (max < min): both data attributes are still emitted.
+ * The editor shows a warning; the PHP layer does not silently discard either value.
+ */
+ public function test_build_security_data_attrs_inverted_limits_both_emitted() {
+ $attrs = Donations\build_security_data_attrs(
+ array(
+ 'minimumAmount' => 100,
+ 'maximumAmount' => 10,
+ ),
+ 'USD'
+ );
+
+ $this->assertSame( 100.0, $attrs['data-min-amount'] );
+ $this->assertSame( 10.0, $attrs['data-max-amount'] );
+ }
}
From 9b4b26aa85b5231d658b5cb9ae8a824c7001deb1 Mon Sep 17 00:00:00 2001
From: Angela Blake
Date: Mon, 4 May 2026 13:02:30 -0500
Subject: [PATCH 5/6] Donations Block: suppress range error on page load; warn
when max < Stripe floor
- applyDefaultAmount() takes isUserInitiated flag; range error is only
displayed when the donor explicitly selects an amount or switches tabs,
not on the initial render
- Security panel shows inline help on the maximum field when the admin
sets a value below the payment processor's minimum for the chosen currency
Co-Authored-By: Claude Opus 4.7
---
.../extensions/blocks/donations/controls.js | 25 +++++++++++++------
.../extensions/blocks/donations/view.js | 8 +++---
2 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/controls.js b/projects/plugins/jetpack/extensions/blocks/donations/controls.js
index 61c6a2c69d30..37215cfdd520 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/controls.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/controls.js
@@ -19,7 +19,7 @@ import {
ToolbarButton,
} from '@wordpress/components';
import { useCallback } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
import { DOWN } from '@wordpress/keycodes';
import {
getDefaultDonationAmountsForCurrency,
@@ -133,6 +133,21 @@ const Controls = props => {
[ setAttributes ]
);
+ let maximumHelp;
+ if (
+ minimumAmount !== undefined &&
+ maximumAmount !== undefined &&
+ maximumAmount < minimumAmount
+ ) {
+ maximumHelp = __( 'Maximum must be greater than the minimum amount.', 'jetpack' );
+ } else if ( maximumAmount !== undefined && maximumAmount < stripeMin ) {
+ maximumHelp = sprintf(
+ /* translators: %s: minimum donation amount formatted with currency symbol */
+ __( 'Maximum must be at least %s, the minimum amount Stripe can process.', 'jetpack' ),
+ formatCurrency( stripeMin, currency )
+ );
+ }
+
const changeDefaultDonationAmounts = ccy => {
const defaultAmounts = getDefaultDonationAmountsForCurrency( ccy );
@@ -365,13 +380,7 @@ const Controls = props => {
}
min={ minimumAmount ?? stripeMin }
step={ 0.01 }
- help={
- minimumAmount !== undefined &&
- maximumAmount !== undefined &&
- maximumAmount < minimumAmount
- ? __( 'Maximum must be greater than the minimum amount.', 'jetpack' )
- : undefined
- }
+ help={ maximumHelp }
__nextHasNoMarginBottom={ true }
/>
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.js b/projects/plugins/jetpack/extensions/blocks/donations/view.js
index 29782dd44d09..7a5d7ca205db 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.js
@@ -35,7 +35,7 @@ class JetpackDonations {
this.block.querySelector( '.donations__container' ).classList.add( 'loaded' );
}
- applyDefaultAmount( interval ) {
+ applyDefaultAmount( interval, isUserInitiated = false ) {
const amountClass = INTERVAL_TO_AMOUNT_CLASS[ interval ];
if ( ! amountClass ) {
return;
@@ -58,7 +58,9 @@ class JetpackDonations {
this.updateUrl();
const defaultRangeError = this.checkAmountRange( parseFloat( tile.dataset.amount ) );
if ( defaultRangeError ) {
- this.showRangeError( defaultRangeError );
+ if ( isUserInitiated ) {
+ this.showRangeError( defaultRangeError );
+ }
this.toggleDonateButton( false );
} else {
this.toggleDonateButton( true );
@@ -194,7 +196,7 @@ class JetpackDonations {
this.toggleDonateButton( false );
// Apply the new tab's default amount, if one is configured.
- this.applyDefaultAmount( newInterval );
+ this.applyDefaultAmount( newInterval, true );
};
navItems.forEach( navItem => {
From 02c8f8aff9b5d64b1d089bb843f15db533f0e179 Mon Sep 17 00:00:00 2001
From: Angela Blake
Date: Mon, 4 May 2026 13:12:25 -0500
Subject: [PATCH 6/6] Donations Block: fix GBP test assertion to match
HTML-entity symbol
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CURRENCIES['GBP']['symbol'] is '£', not UTF-8 '£', so format_price
returns the HTML entity in raw PHP. The browser decodes it when reading
dataset.* so donors see the symbol correctly; the test must check the
raw value.
Co-Authored-By: Claude Opus 4.7
---
.../jetpack/tests/php/extensions/blocks/Donations_Test.php | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php b/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
index 93f0c6969e9b..1f8e15b9b0a7 100644
--- a/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
+++ b/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
@@ -388,7 +388,8 @@ public function test_build_security_data_attrs_stripe_min_is_currency_aware() {
$gbp_attrs = Donations\build_security_data_attrs( array(), 'GBP' );
$this->assertStringContainsString( '$0.50', $usd_attrs['data-stripe-min-error'] );
- $this->assertStringContainsString( '£0.30', $gbp_attrs['data-stripe-min-error'] );
+ // GBP symbol is stored as an HTML entity in CURRENCIES, so format_price returns '£'.
+ $this->assertStringContainsString( '£0.30', $gbp_attrs['data-stripe-min-error'] );
}
/**