@automattic/newspack-blocks 4.31.1 → 4.32.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/composer.lock +73 -994
  3. package/dist/blocks/checkout-button/block.json +3 -0
  4. package/dist/carousel/view-rtl.css +1 -1
  5. package/dist/carousel/view.asset.php +1 -1
  6. package/dist/carousel/view.css +1 -1
  7. package/dist/editor-rtl.css +20 -16
  8. package/dist/editor.asset.php +1 -1
  9. package/dist/editor.css +20 -16
  10. package/dist/editor.js +32 -28
  11. package/dist/homepage-articles/view-rtl.css +1 -1
  12. package/dist/homepage-articles/view.asset.php +1 -1
  13. package/dist/homepage-articles/view.css +1 -1
  14. package/dist/modal.asset.php +1 -1
  15. package/dist/modal.js +1 -1
  16. package/dist/modalCheckout-rtl.css +1 -1
  17. package/dist/modalCheckout.asset.php +1 -1
  18. package/dist/modalCheckout.css +1 -1
  19. package/dist/modalCheckout.js +1 -1
  20. package/dist/tiersBased.asset.php +1 -1
  21. package/dist/tiersBased.js +1 -1
  22. package/includes/class-modal-checkout.php +354 -5
  23. package/includes/modal-checkout/class-checkout-data.php +30 -6
  24. package/languages/newspack-blocks-de_DE-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  25. package/languages/newspack-blocks-de_DE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  26. package/languages/newspack-blocks-de_DE-5c566531829f64486c13d7d8df1d6151.json +1 -0
  27. package/languages/newspack-blocks-de_DE-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  28. package/languages/newspack-blocks-de_DE-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  29. package/languages/newspack-blocks-de_DE-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  30. package/languages/newspack-blocks-de_DE.po +427 -322
  31. package/languages/newspack-blocks-es_ES-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  32. package/languages/newspack-blocks-es_ES-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  33. package/languages/newspack-blocks-es_ES-5c566531829f64486c13d7d8df1d6151.json +1 -0
  34. package/languages/newspack-blocks-es_ES-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  35. package/languages/newspack-blocks-es_ES-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  36. package/languages/newspack-blocks-es_ES-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  37. package/languages/newspack-blocks-es_ES.po +425 -322
  38. package/languages/newspack-blocks-fr_BE-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  39. package/languages/newspack-blocks-fr_BE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  40. package/languages/newspack-blocks-fr_BE-5c566531829f64486c13d7d8df1d6151.json +1 -0
  41. package/languages/newspack-blocks-fr_BE-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  42. package/languages/newspack-blocks-fr_BE-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  43. package/languages/newspack-blocks-fr_BE-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  44. package/languages/newspack-blocks-fr_BE.po +427 -322
  45. package/languages/newspack-blocks-nb_NO-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  46. package/languages/newspack-blocks-nb_NO-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  47. package/languages/newspack-blocks-nb_NO-5c566531829f64486c13d7d8df1d6151.json +1 -0
  48. package/languages/newspack-blocks-nb_NO-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  49. package/languages/newspack-blocks-nb_NO-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  50. package/languages/newspack-blocks-nb_NO-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  51. package/languages/newspack-blocks-nb_NO.po +426 -322
  52. package/languages/newspack-blocks-pt_PT-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  53. package/languages/newspack-blocks-pt_PT-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  54. package/languages/newspack-blocks-pt_PT-5c566531829f64486c13d7d8df1d6151.json +1 -0
  55. package/languages/newspack-blocks-pt_PT-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  56. package/languages/newspack-blocks-pt_PT-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  57. package/languages/newspack-blocks-pt_PT-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  58. package/languages/newspack-blocks-pt_PT.po +426 -322
  59. package/languages/newspack-blocks.pot +506 -379
  60. package/newspack-blocks.php +2 -2
  61. package/package.json +3 -2
  62. package/src/blocks/carousel/edit.js +1 -1
  63. package/src/blocks/carousel/view.scss +5 -1
  64. package/src/blocks/checkout-button/block.json +3 -0
  65. package/src/blocks/checkout-button/edit.js +32 -5
  66. package/src/blocks/checkout-button/view.php +37 -0
  67. package/src/blocks/donate/consts.ts +2 -1
  68. package/src/blocks/donate/frequency-slugs.js +10 -0
  69. package/src/blocks/donate/tiers-based/view.ts +8 -0
  70. package/src/blocks/homepage-articles/edit.tsx +1 -1
  71. package/src/blocks/homepage-articles/editor.scss +2 -1
  72. package/src/blocks/homepage-articles/view.scss +15 -5
  73. package/src/components/autocomplete-tokenfield.js +21 -7
  74. package/src/components/autocomplete-tokenfield.scss +1 -1
  75. package/src/components/autocomplete-tokenfield.test.js +58 -0
  76. package/src/components/query-controls.js +28 -6
  77. package/src/components/query-controls.test.js +56 -0
  78. package/src/components/reorder-modal.js +382 -0
  79. package/src/components/reorder-modal.scss +87 -0
  80. package/src/components/reorder-modal.test.js +487 -0
  81. package/src/components/specific-posts-control.js +91 -0
  82. package/src/components/specific-posts-control.scss +5 -0
  83. package/src/components/specific-posts-control.test.js +173 -0
  84. package/src/modal-checkout/analytics/ga4/utils/index.js +1 -0
  85. package/src/modal-checkout/checkout-button-trigger.js +29 -2
  86. package/src/modal-checkout/checkout-button-trigger.test.js +37 -0
  87. package/src/modal-checkout/checkout.scss +28 -0
  88. package/src/modal-checkout/donate-trigger.js +134 -0
  89. package/src/modal-checkout/donate-trigger.test.js +233 -0
  90. package/src/modal-checkout/index.js +91 -0
  91. package/src/modal-checkout/modal.js +129 -45
  92. package/src/modal-checkout/utils.test.js +49 -0
  93. package/src/shared/js/tiers-based-ready.js +26 -0
  94. package/vendor/autoload.php +1 -1
  95. package/vendor/composer/autoload_real.php +4 -4
  96. package/vendor/composer/autoload_static.php +2 -2
  97. package/vendor/composer/installed.php +2 -2
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Tests for the donate URL trigger resolution helpers.
3
+ */
4
+
5
+ import { resolveDonationTrigger, validateDonationTriggerParams } from './donate-trigger';
6
+ import { TIERS_BASED_READY_EVENT, TIERS_BASED_READY_ATTRIBUTE } from '../shared/js/tiers-based-ready';
7
+
8
+ /**
9
+ * Build a tiers-based Donate block markup string, mirroring
10
+ * Newspack_Blocks_Donate_Renderer_Tiers_Based::render output.
11
+ *
12
+ * @param {Object} options Options.
13
+ * @param {string[]} options.frequencies Enabled frequency slugs. Tab buttons render only when more than one.
14
+ * @param {string} options.defaultFreq The block's default (initially selected) frequency.
15
+ * @param {Object} options.amounts Map of frequency slug => array of tier amounts.
16
+ * @param {boolean} options.ready Whether the tiersBased view script marked the block initialized.
17
+ * @return {string} HTML.
18
+ */
19
+ const tieredBlock = ( {
20
+ frequencies = [ 'month', 'year' ],
21
+ defaultFreq = 'month',
22
+ amounts = { month: [ 7, 15, 30 ], year: [ 84, 180, 360 ] },
23
+ ready = true,
24
+ } = {} ) => {
25
+ const readyAttr = ready ? ` ${ TIERS_BASED_READY_ATTRIBUTE }` : '';
26
+ const tabs =
27
+ frequencies.length > 1
28
+ ? `<div class="wpbnbd__tiers__selection">${ frequencies
29
+ .map( slug => `<button type="button" data-frequency-slug="${ slug }" class="wpbnbd__button">${ slug }</button>` )
30
+ .join( '' ) }</div>`
31
+ : '';
32
+ const tiers = amounts[ defaultFreq ]
33
+ .map( ( amount, index ) => {
34
+ const spans = frequencies
35
+ .map(
36
+ slug =>
37
+ `<span data-frequency-slug="${ slug }" data-amount="${ amounts[ slug ][ index ] }" data-tier-index="${ index }">$${ amounts[ slug ][ index ] }</span>`
38
+ )
39
+ .join( '' );
40
+ return `<div class="wpbnbd__tiers__tier"><div class="wpbnbd__tiers__amount"><span>${ spans }</span></div><button type="submit" name="donation_value_${ defaultFreq }" value="${ amount }" data-tier-index="${ index }">Donate</button></div>`;
41
+ } )
42
+ .join( '' );
43
+ return `<div class="wpbnbd wpbnbd--platform-wc wpbnbd--tiers-based"${ readyAttr }><form data-is-init-form><input type="hidden" name="donation_frequency" value="${ defaultFreq }">${ tabs }<div class="wpbnbd__tiers__options">${ tiers }</div></form></div>`;
44
+ };
45
+
46
+ /**
47
+ * Build a frequency-based Donate block markup string, mirroring
48
+ * Newspack_Blocks_Donate_Renderer_Frequency_Based::render output.
49
+ *
50
+ * @param {Object} options Options.
51
+ * @param {string[]} options.frequencies Enabled frequency slugs.
52
+ * @param {Object} options.amounts Map of frequency slug => array of amounts.
53
+ * @param {boolean} options.untiered Render the untiered variant instead of amount radios.
54
+ * @return {string} HTML.
55
+ */
56
+ const frequencyBlock = ( {
57
+ frequencies = [ 'once', 'month', 'year' ],
58
+ amounts = { once: [ 7, 15, 30 ], month: [ 7, 15, 30 ], year: [ 84, 180, 360 ] },
59
+ untiered = false,
60
+ } = {} ) => {
61
+ const panels = frequencies
62
+ .map( slug => {
63
+ const freqRadio = `<input type="radio" name="donation_frequency" value="${ slug }">`;
64
+ if ( untiered ) {
65
+ return `${ freqRadio }<input type="number" name="donation_value_${ slug }_untiered" value="${ amounts[ slug ][ 0 ] }">`;
66
+ }
67
+ const amountRadios = amounts[ slug ]
68
+ .map( amount => `<input type="radio" name="donation_value_${ slug }" value="${ amount }">` )
69
+ .join( '' );
70
+ return `${ freqRadio }${ amountRadios }<input type="radio" name="donation_value_${ slug }" value="other"><input type="number" name="donation_value_${ slug }_other">`;
71
+ } )
72
+ .join( '' );
73
+ return `<div class="wpbnbd wpbnbd--platform-wc wpbnbd--frequency-based"><form>${ panels }<button type="submit">Donate</button></form></div>`;
74
+ };
75
+
76
+ const render = html => {
77
+ document.body.innerHTML = html;
78
+ return document.body;
79
+ };
80
+
81
+ afterEach( () => {
82
+ document.body.innerHTML = '';
83
+ } );
84
+
85
+ describe( 'validateDonationTriggerParams', () => {
86
+ it( 'accepts a tiered layout with a known frequency and integer amount', () => {
87
+ expect( validateDonationTriggerParams( 'tiered', 'month', '15' ) ).toBe( true );
88
+ } );
89
+
90
+ it( 'accepts a decimal amount', () => {
91
+ expect( validateDonationTriggerParams( 'untiered', 'year', '15.50' ) ).toBe( true );
92
+ } );
93
+
94
+ it( 'accepts amount "other" for the frequency layout only', () => {
95
+ expect( validateDonationTriggerParams( 'frequency', 'once', 'other' ) ).toBe( true );
96
+ expect( validateDonationTriggerParams( 'tiered', 'once', 'other' ) ).toBe( false );
97
+ expect( validateDonationTriggerParams( 'untiered', 'once', 'other' ) ).toBe( false );
98
+ } );
99
+
100
+ it( 'rejects an unknown layout', () => {
101
+ expect( validateDonationTriggerParams( 'banana', 'month', '15' ) ).toBe( false );
102
+ } );
103
+
104
+ it( 'rejects an unknown frequency', () => {
105
+ expect( validateDonationTriggerParams( 'tiered', 'weekly', '15' ) ).toBe( false );
106
+ } );
107
+
108
+ it( 'rejects selector-breaking values', () => {
109
+ expect( validateDonationTriggerParams( 'tiered', 'month"]', '15' ) ).toBe( false );
110
+ expect( validateDonationTriggerParams( 'tiered', 'month', '15"][name="x' ) ).toBe( false );
111
+ } );
112
+ } );
113
+
114
+ describe( 'resolveDonationTrigger — tiered layout', () => {
115
+ it( 'resolves a non-default frequency to the tab button and tier index', () => {
116
+ const root = render( tieredBlock() );
117
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '180' } );
118
+ expect( resolution.status ).toBe( 'tiered' );
119
+ expect( resolution.form ).toBe( root.querySelector( 'form' ) );
120
+ expect( resolution.frequencyButton ).toBe( root.querySelector( 'button[data-frequency-slug="year"]' ) );
121
+ expect( resolution.tierIndex ).toBe( 1 );
122
+ } );
123
+
124
+ it( 'resolves a single-frequency block without a tab button when the frequency matches', () => {
125
+ const root = render( tieredBlock( { frequencies: [ 'month' ], amounts: { month: [ 7, 15, 30 ] } } ) );
126
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'month', amount: '15' } );
127
+ expect( resolution.status ).toBe( 'tiered' );
128
+ expect( resolution.frequencyButton ).toBeNull();
129
+ expect( resolution.tierIndex ).toBe( 1 );
130
+ } );
131
+
132
+ it( 'does not match a single-frequency block for a different frequency', () => {
133
+ const root = render( tieredBlock( { frequencies: [ 'month' ], amounts: { month: [ 7, 15, 30 ] } } ) );
134
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '15' } );
135
+ expect( resolution.status ).toBe( 'no-match' );
136
+ } );
137
+
138
+ it( 'reports a matching block whose view script has not initialized as not ready', () => {
139
+ const root = render( tieredBlock( { ready: false } ) );
140
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '180' } );
141
+ expect( resolution.status ).toBe( 'not-ready' );
142
+ expect( resolution.form ).toBe( root.querySelector( 'form' ) );
143
+ } );
144
+
145
+ it( 'prefers a later initialized block over an earlier one that never initialized', () => {
146
+ const root = render( tieredBlock( { ready: false } ) + tieredBlock() );
147
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '180' } );
148
+ expect( resolution.status ).toBe( 'tiered' );
149
+ expect( resolution.form ).toBe( root.querySelectorAll( 'form' )[ 1 ] );
150
+ } );
151
+
152
+ it( 'reports not-ready, carrying the first such form, only when no initialized block matches', () => {
153
+ const root = render( tieredBlock( { ready: false } ) + tieredBlock( { ready: false } ) );
154
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '180' } );
155
+ expect( resolution.status ).toBe( 'not-ready' );
156
+ expect( resolution.form ).toBe( root.querySelectorAll( 'form' )[ 0 ] );
157
+ } );
158
+
159
+ it( 'resolves after the tiers view marks the block ready, matching the ready-event contract', () => {
160
+ const root = render( tieredBlock( { ready: false } ) );
161
+ expect( resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '180' } ).status ).toBe( 'not-ready' );
162
+ // What the tiers-based view does when it initializes (see view.ts):
163
+ // stamp the container, then announce readiness so waiting triggers retry.
164
+ expect( TIERS_BASED_READY_EVENT ).toBe( 'newspack-tiers-based-ready' );
165
+ expect( TIERS_BASED_READY_ATTRIBUTE ).toBe( 'data-tiers-based-ready' );
166
+ root.querySelector( '.wpbnbd--tiers-based' ).setAttribute( TIERS_BASED_READY_ATTRIBUTE, '' );
167
+ const retried = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '180' } );
168
+ expect( retried.status ).toBe( 'tiered' );
169
+ } );
170
+
171
+ it( 'returns no-match for an amount no tier offers, without mutating the form', () => {
172
+ const root = render( tieredBlock() );
173
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '999' } );
174
+ expect( resolution.status ).toBe( 'no-match' );
175
+ expect( root.querySelector( 'input[name="donation_frequency"]' ).value ).toBe( 'month' );
176
+ expect( root.querySelector( 'button[type="submit"]' ).getAttribute( 'name' ) ).toBe( 'donation_value_month' );
177
+ } );
178
+
179
+ it( 'picks the first matching form in DOM order, skipping non-matching forms', () => {
180
+ const root = render( tieredBlock( { frequencies: [ 'month' ], amounts: { month: [ 7, 15, 30 ] } } ) + tieredBlock() );
181
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'year', amount: '180' } );
182
+ expect( resolution.status ).toBe( 'tiered' );
183
+ expect( resolution.form ).toBe( root.querySelectorAll( 'form' )[ 1 ] );
184
+ } );
185
+
186
+ it( 'never acts on a frequency-based form', () => {
187
+ const root = render( frequencyBlock() );
188
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'month', amount: '15' } );
189
+ expect( resolution.status ).toBe( 'no-match' );
190
+ } );
191
+
192
+ it( 'rejects invalid parameters before touching the DOM', () => {
193
+ const root = render( tieredBlock() );
194
+ const resolution = resolveDonationTrigger( root, { layout: 'tiered', frequency: 'month"]', amount: '15' } );
195
+ expect( resolution.status ).toBe( 'invalid-params' );
196
+ } );
197
+ } );
198
+
199
+ describe( 'resolveDonationTrigger — frequency and untiered layouts', () => {
200
+ it( 'resolves the frequency layout to its frequency and amount inputs', () => {
201
+ const root = render( frequencyBlock() );
202
+ const resolution = resolveDonationTrigger( root, { layout: 'frequency', frequency: 'year', amount: '180' } );
203
+ expect( resolution.status ).toBe( 'frequency' );
204
+ expect( resolution.frequencyInput ).toBe( root.querySelector( 'input[name="donation_frequency"][value="year"]' ) );
205
+ expect( resolution.amountInput ).toBe( root.querySelector( 'input[name="donation_value_year"][value="180"]' ) );
206
+ } );
207
+
208
+ it( 'resolves amount "other" to the other radio', () => {
209
+ const root = render( frequencyBlock() );
210
+ const resolution = resolveDonationTrigger( root, { layout: 'frequency', frequency: 'once', amount: 'other' } );
211
+ expect( resolution.status ).toBe( 'frequency' );
212
+ expect( resolution.amountInput ).toBe( root.querySelector( 'input[name="donation_value_once"][value="other"]' ) );
213
+ } );
214
+
215
+ it( 'resolves the untiered layout to the untiered amount input', () => {
216
+ const root = render( frequencyBlock( { untiered: true } ) );
217
+ const resolution = resolveDonationTrigger( root, { layout: 'untiered', frequency: 'month', amount: '25' } );
218
+ expect( resolution.status ).toBe( 'untiered' );
219
+ expect( resolution.amountInput ).toBe( root.querySelector( 'input[name="donation_value_month_untiered"]' ) );
220
+ } );
221
+
222
+ it( 'never acts on a tiered form', () => {
223
+ const root = render( tieredBlock() );
224
+ const resolution = resolveDonationTrigger( root, { layout: 'frequency', frequency: 'month', amount: '15' } );
225
+ expect( resolution.status ).toBe( 'no-match' );
226
+ } );
227
+
228
+ it( 'returns no-match when the page has no donate forms', () => {
229
+ const root = render( '<p>No blocks here.</p>' );
230
+ const resolution = resolveDonationTrigger( root, { layout: 'frequency', frequency: 'month', amount: '15' } );
231
+ expect( resolution.status ).toBe( 'no-match' );
232
+ } );
233
+ } );
@@ -94,6 +94,7 @@ import { domReady, onCheckoutPlaceOrderProcessing } from './utils';
94
94
 
95
95
  const $coupon = $( 'form.modal_checkout_coupon' );
96
96
  const $nyp = $( 'form.modal_checkout_nyp' );
97
+ const $quantity = $( 'form.modal_checkout_quantity' );
97
98
  const $checkout_continue = $( '#checkout_continue' );
98
99
  const $customer_details = $( '#customer_details' );
99
100
  const $after_customer_details = $( '#after_customer_details' );
@@ -480,6 +481,90 @@ import { domReady, onCheckoutPlaceOrderProcessing } from './utils';
480
481
  $nyp.on( 'submit', handleNYPFormSubmit );
481
482
  }
482
483
 
484
+ /**
485
+ * Replace the quantity form's result line, and mark the field when the
486
+ * server refused the change.
487
+ *
488
+ * @param {boolean} success
489
+ * @param {string} message
490
+ */
491
+ function showQuantityResult( success, message ) {
492
+ $quantity.find( '.result' ).remove();
493
+ $quantity.append(
494
+ `<p class="result ${ CLASS_PREFIX }__helper-text ${ ! success ? CLASS_PREFIX + '__inline-error' : '' }">` + message + '</p>'
495
+ );
496
+ $quantity.find( 'h3, input[name="quantity"]' ).toggleClass( 'newspack-ui__field-error', ! success );
497
+ }
498
+
499
+ /**
500
+ * Handle quantity form submission.
501
+ *
502
+ * On success the order review is refreshed to show the new total, and the
503
+ * `#modal-checkout-product-details` data carrier is rewritten from the
504
+ * response — `update_checkout` only replaces the review-order table and
505
+ * payment box, and the carrier sits outside both.
506
+ *
507
+ * On failure the server has already put the original line item back, so
508
+ * the review still matches the cart and must be left alone.
509
+ *
510
+ * @param {Event} ev
511
+ */
512
+ function handleQuantityFormSubmit( ev ) {
513
+ ev.preventDefault();
514
+ const blocked = blockForm( $quantity );
515
+ if ( ! blocked ) {
516
+ return false;
517
+ }
518
+ const input = $quantity.find( 'input[name="quantity"]' );
519
+ input.attr( 'disabled', true );
520
+ const data = {
521
+ _ajax_nonce: newspackBlocksModalCheckout.quantity_nonce,
522
+ action: 'process_quantity_request',
523
+ quantity: input.val(),
524
+ product_id: $quantity.find( 'input[name="product_id"]' ).val(),
525
+ };
526
+ $.ajax( {
527
+ type: 'POST',
528
+ url: newspackBlocksModalCheckout.ajax_url,
529
+ data,
530
+ success: response => {
531
+ // A malformed request returns bare (`0`), so there is no envelope
532
+ // to read and nothing the reader could act on.
533
+ if ( ! response || typeof response.success === 'undefined' ) {
534
+ showQuantityResult( false, newspackBlocksModalCheckout.quantity_error );
535
+ return;
536
+ }
537
+ const { success, data: res } = response;
538
+ clearNotices();
539
+ showQuantityResult( success, res?.message || newspackBlocksModalCheckout.quantity_error );
540
+ if ( success ) {
541
+ if ( res.checkout_data ) {
542
+ $( '#modal-checkout-product-details' ).attr( 'data-checkout', JSON.stringify( res.checkout_data ) );
543
+ // The server may accept a quantity other than the one asked
544
+ // for — a product sold in bounds clamps to them. Show the
545
+ // count the reader is about to pay for, not the one they typed.
546
+ if ( res.checkout_data.quantity ) {
547
+ input.val( res.checkout_data.quantity );
548
+ }
549
+ }
550
+ $( document.body ).trigger( 'update_checkout', { update_shipping_method: false } );
551
+ } else {
552
+ input.focus();
553
+ }
554
+ },
555
+ // An expired nonce dies with a 403, which never reaches success:.
556
+ error: () => showQuantityResult( false, newspackBlocksModalCheckout.quantity_error ),
557
+ complete: () => {
558
+ unblockForm( $quantity );
559
+ input.attr( 'disabled', false );
560
+ input.focus();
561
+ },
562
+ } );
563
+ }
564
+ if ( $quantity.length ) {
565
+ $quantity.on( 'submit', handleQuantityFormSubmit );
566
+ }
567
+
483
568
  /**
484
569
  * Handle form 1st step submission.
485
570
  *
@@ -515,6 +600,9 @@ import { domReady, onCheckoutPlaceOrderProcessing } from './utils';
515
600
  if ( $nyp.length ) {
516
601
  $nyp.hide();
517
602
  }
603
+ if ( $quantity.length ) {
604
+ $quantity.hide();
605
+ }
518
606
  $customer_details.show();
519
607
  $after_customer_details.hide();
520
608
  $customer_details.find( 'input' ).first().focus();
@@ -550,6 +638,9 @@ import { domReady, onCheckoutPlaceOrderProcessing } from './utils';
550
638
  if ( $nyp.length ) {
551
639
  $nyp.show();
552
640
  }
641
+ if ( $quantity.length ) {
642
+ $quantity.show();
643
+ }
553
644
  $customer_details.hide();
554
645
  $after_customer_details.show();
555
646
  renderCheckoutDetails();
@@ -25,6 +25,8 @@ import {
25
25
  getFormattedAmount,
26
26
  } from './utils';
27
27
  import { resolveCheckoutButtonForm, readCheckoutData, applyContextFields } from './checkout-button-trigger';
28
+ import { resolveDonationTrigger } from './donate-trigger';
29
+ import { TIERS_BASED_READY_EVENT } from '../shared/js/tiers-based-ready';
28
30
  import { applyCtaAttribution } from '../shared/js/cta-attribution';
29
31
 
30
32
  const CLASS_PREFIX = newspackBlocksModal.newspack_class_prefix;
@@ -752,57 +754,123 @@ domReady( () => {
752
754
  } );
753
755
  } );
754
756
 
757
+ /**
758
+ * Query params a modal checkout URL trigger consumes (see
759
+ * handleModalCheckoutUrlParams). Only these are stripped after a successful
760
+ * trigger — unrelated params (UTMs, plain-permalink ids) and the hash
761
+ * survive.
762
+ */
763
+ const CHECKOUT_TRIGGER_PARAMS = [ 'checkout', 'type', 'layout', 'frequency', 'amount', 'other', 'product_id', 'variation_id' ];
764
+
765
+ /**
766
+ * Remove the modal checkout trigger params from the address bar.
767
+ */
768
+ const stripCheckoutUrlParams = () => {
769
+ const url = new URL( window.location.href );
770
+ CHECKOUT_TRIGGER_PARAMS.forEach( param => url.searchParams.delete( param ) );
771
+ window.history.replaceState( null, null, url.href );
772
+ };
773
+
755
774
  /**
756
775
  * Handle donation form triggers.
757
776
  *
777
+ * Resolution (which form, which tier) is delegated to resolveDonationTrigger,
778
+ * which validates the URL params and never mutates the DOM, so a trigger that
779
+ * can't complete leaves the page untouched. Only the first form in DOM order
780
+ * that fully satisfies the trigger is acted on.
781
+ *
758
782
  * @param {string} layout The donation layout.
759
783
  * @param {string} frequency The donation frequency.
760
784
  * @param {string} amount The donation amount.
761
785
  * @param {string|null} other Optional. The custom amount when other is selected.
786
+ *
787
+ * @return {boolean} Whether a donation form was triggered.
762
788
  */
763
789
  const triggerDonationForm = ( layout, frequency, amount, other = null ) => {
764
- let form;
765
- document.querySelectorAll( '.wpbnbd.wpbnbd--platform-wc form' ).forEach( donationForm => {
766
- const frequencyInput = donationForm.querySelector( `input[name="donation_frequency"][value="${ frequency }"]` );
767
- if ( ! frequencyInput ) {
768
- return;
769
- }
770
- if ( layout === 'tiered' ) {
771
- const frequencyButton = document.querySelector( `button[data-frequency-slug="${ frequency }"]` );
772
- if ( ! frequencyButton ) {
773
- return;
774
- }
775
- frequencyButton.click();
776
- const submitButton = donationForm.querySelector( `button[type="submit"][name="donation_value_${ frequency }"][value="${ amount }"]` );
777
- if ( ! submitButton ) {
778
- return;
779
- }
780
- submitButton.click();
781
- } else {
782
- const amountInput =
783
- layout === 'untiered'
784
- ? donationForm.querySelector( `input[name="donation_value_${ frequency }_untiered"]` )
785
- : donationForm.querySelector( `input[name="donation_value_${ frequency }"][value="${ amount }"]` );
786
- if ( frequencyInput && amountInput ) {
787
- frequencyInput.checked = true;
788
- if ( layout === 'untiered' ) {
789
- amountInput.value = amount;
790
- } else if ( amount === 'other' ) {
791
- amountInput.click();
792
- const otherInput = donationForm.querySelector( `input[name="donation_value_${ frequency }_other"]` );
793
- if ( otherInput && other ) {
794
- otherInput.value = other;
795
- }
796
- } else {
797
- amountInput.checked = true;
790
+ const resolution = resolveDonationTrigger( document, { layout, frequency, amount } );
791
+ const described = `layout "${ layout }", frequency "${ frequency }" and amount "${ amount }"`;
792
+ if ( resolution.status === 'invalid-params' ) {
793
+ // eslint-disable-next-line no-console
794
+ console.warn( `Newspack modal checkout: invalid donate trigger parameters (${ described }). The checkout was not triggered.` );
795
+ return false;
796
+ }
797
+ if ( resolution.status === 'no-match' ) {
798
+ // eslint-disable-next-line no-console
799
+ console.warn( `Newspack modal checkout: no donate form matches ${ described }. The checkout was not triggered.` );
800
+ return false;
801
+ }
802
+ if ( resolution.status === 'not-ready' ) {
803
+ // The tiersBased view script has not initialized this block yet —
804
+ // modal.js and tiersBased.js load async in either order, and when
805
+ // modal.js is first (typical on fast/cached loads, where execution
806
+ // follows document order and modal.js is printed first) tab and tier
807
+ // clicks would do nothing. Retry when the block announces readiness;
808
+ // the listener re-arms if the matching block still isn't the one that
809
+ // became ready. Returning false keeps the URL params (see
810
+ // handleModalCheckoutUrlParams), so the retry — or a reload, if
811
+ // readiness never comes — still has them; a successful retry strips
812
+ // them here since the caller's pass is already over. Waiting is the
813
+ // expected order on healthy loads, so nothing is logged unless
814
+ // readiness never arrives.
815
+ const readyWarningTimeout = setTimeout( () => {
816
+ // eslint-disable-next-line no-console
817
+ console.warn(
818
+ `Newspack modal checkout: the donate form matching ${ described } never finished initializing. The checkout was not triggered; the URL params were kept so a reload can retry.`
819
+ );
820
+ }, 5000 );
821
+ document.addEventListener(
822
+ TIERS_BASED_READY_EVENT,
823
+ () => {
824
+ clearTimeout( readyWarningTimeout );
825
+ if ( triggerDonationForm( layout, frequency, amount, other ) ) {
826
+ stripCheckoutUrlParams();
798
827
  }
799
- form = donationForm;
800
- }
828
+ },
829
+ { once: true }
830
+ );
831
+ return false;
832
+ }
833
+ const { form } = resolution;
834
+ if ( resolution.status === 'tiered' ) {
835
+ // Clicking the tab synchronously updates each tier submit button's
836
+ // `name` and `value` attributes (see src/blocks/donate/tiers-based/view.ts);
837
+ // resolution already verified the view script is initialized. Blocks
838
+ // with a single enabled frequency render no tabs and need no click.
839
+ if ( resolution.frequencyButton ) {
840
+ resolution.frequencyButton.click();
801
841
  }
802
- } );
803
- if ( form ) {
804
- triggerFormSubmit( form );
842
+ const submitButton = form.querySelector( `button[type="submit"][data-tier-index="${ resolution.tierIndex }"]` );
843
+ if ( ! submitButton ) {
844
+ // eslint-disable-next-line no-console
845
+ console.warn( `Newspack modal checkout: no tier submit button matches ${ described }. The checkout was not triggered.` );
846
+ return false;
847
+ }
848
+ submitButton.click();
849
+ return true;
850
+ }
851
+ resolution.frequencyInput.checked = true;
852
+ if ( resolution.status === 'untiered' ) {
853
+ resolution.amountInput.value = amount;
854
+ } else if ( amount === 'other' ) {
855
+ resolution.amountInput.click();
856
+ const otherInput = form.querySelector( `input[name="donation_value_${ frequency }_other"]` );
857
+ if ( otherInput && other ) {
858
+ otherInput.value = other;
859
+ }
860
+ } else {
861
+ resolution.amountInput.checked = true;
862
+ }
863
+ // requestSubmit runs the form's constraint validation (e.g. the untiered
864
+ // amount input's `min`), and a refused submit would otherwise still be
865
+ // reported as success — stripping the URL params of a link that did
866
+ // nothing. Check first so failures stay visible and retryable.
867
+ if ( ! form.checkValidity() ) {
868
+ // eslint-disable-next-line no-console
869
+ console.warn( `Newspack modal checkout: the donate form rejected the values for ${ described }. The checkout was not triggered.` );
870
+ return false;
805
871
  }
872
+ triggerFormSubmit( form );
873
+ return true;
806
874
  };
807
875
 
808
876
  /**
@@ -839,9 +907,10 @@ domReady( () => {
839
907
  if ( ! urlParams.has( 'checkout' ) ) {
840
908
  return;
841
909
  }
842
- // Default to stripping the params after handling. The checkout button
843
- // trigger overrides this so a link that matches no form stays visible
844
- // and diagnosable rather than being silently dropped.
910
+ // Default to stripping the params after handling. The donate and
911
+ // checkout button triggers override this so a link that fails to
912
+ // trigger stays visible and diagnosable rather than being silently
913
+ // dropped, and a reload can retry a transient failure.
845
914
  let shouldStripParams = true;
846
915
  const type = urlParams.get( 'type' );
847
916
  if ( type === 'donate' ) {
@@ -850,7 +919,11 @@ domReady( () => {
850
919
  const amount = urlParams.get( 'amount' );
851
920
  const other = urlParams.get( 'other' );
852
921
  if ( layout && frequency && amount ) {
853
- triggerDonationForm( layout, frequency, amount, other );
922
+ shouldStripParams = triggerDonationForm( layout, frequency, amount, other );
923
+ } else {
924
+ shouldStripParams = false;
925
+ // eslint-disable-next-line no-console
926
+ console.warn( 'Newspack modal checkout: donate trigger is missing layout, frequency or amount. The checkout was not triggered.' );
854
927
  }
855
928
  } else if ( type === 'checkout_button' ) {
856
929
  const productId = urlParams.get( 'product_id' );
@@ -871,10 +944,10 @@ domReady( () => {
871
944
  triggerFormSubmit( form );
872
945
  }
873
946
  }
874
- // Remove the URL params to prevent re-triggering, but only when the
947
+ // Remove the trigger params to prevent re-triggering, but only when the
875
948
  // trigger succeeded.
876
949
  if ( shouldStripParams ) {
877
- window.history.replaceState( null, null, window.location.pathname );
950
+ stripCheckoutUrlParams();
878
951
  }
879
952
  };
880
953
  handleModalCheckoutUrlParams();
@@ -888,6 +961,7 @@ domReady( () => {
888
961
  * @param {string} options.title The title to set for the modal.
889
962
  * @param {string} options.actionType The action type to set for the modal.
890
963
  * @param {Object} options.afterSuccess The after success configuration object.
964
+ * @param {number} options.quantity Optional. Seats/quantity to purchase. Only sent when above 1.
891
965
  * @param {Function} options.onCheckoutComplete The callback to call when the checkout is complete.
892
966
  * @param {Function} options.onClose The callback to call when the modal is closed.
893
967
  */
@@ -896,6 +970,7 @@ domReady( () => {
896
970
  title = null,
897
971
  actionType = null,
898
972
  afterSuccess = {},
973
+ quantity = null,
899
974
  // eslint-disable-next-line @typescript-eslint/no-shadow
900
975
  onCheckoutComplete = null,
901
976
  onClose = null,
@@ -933,6 +1008,15 @@ domReady( () => {
933
1008
  url.searchParams.set( 'action_type', actionType );
934
1009
  }
935
1010
 
1011
+ /**
1012
+ * Seat/quantity configuration. Only sent above 1 — the server already
1013
+ * defaults to a single seat, so there's nothing to add to the URL when
1014
+ * the reader is buying just one.
1015
+ */
1016
+ if ( quantity && parseInt( quantity, 10 ) > 1 ) {
1017
+ url.searchParams.set( 'quantity', String( parseInt( quantity, 10 ) ) );
1018
+ }
1019
+
936
1020
  /**
937
1021
  * After success parameters.
938
1022
  */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tests for modal-checkout utils.
3
+ */
4
+
5
+ import { getCheckoutData } from './utils';
6
+
7
+ afterEach( () => {
8
+ document.body.innerHTML = '';
9
+ } );
10
+
11
+ /**
12
+ * Build a checkout-button-style form: a `quantity` hidden input (as view.php
13
+ * emits when a block's default seat count is above 1) plus a `data-checkout`
14
+ * attribute carrying the server-computed checkout data.
15
+ *
16
+ * @param {Object} checkoutData Object to JSON-encode into data-checkout.
17
+ * @return {HTMLFormElement} The form element.
18
+ */
19
+ const formWithQuantityField = checkoutData => {
20
+ document.body.innerHTML = `<form data-checkout='${ JSON.stringify(
21
+ checkoutData
22
+ ) }'><input type="hidden" name="quantity" value="3"><button type="submit">Buy</button></form>`;
23
+ return document.body.querySelector( 'form' );
24
+ };
25
+
26
+ describe( 'getCheckoutData()', () => {
27
+ it( 'carries the quantity from a hidden form field when data-checkout omits it', () => {
28
+ // Mirrors a product source: Checkout_Data::get_checkout_data() omits
29
+ // `quantity` entirely for a bare product, so the only value with a seat
30
+ // count to report is the DOM's own hidden `quantity` input.
31
+ const form = formWithQuantityField( { product_id: '42', amount: '10' } );
32
+
33
+ const data = getCheckoutData( form );
34
+
35
+ expect( data.quantity ).toBe( '3' );
36
+ } );
37
+
38
+ it( 'lets data-checkout win when it does carry a quantity (cart/order sources)', () => {
39
+ // Mirrors a cart or order source: Checkout_Data::get_checkout_data() sets
40
+ // a real `quantity` there, and the merge order in getCheckoutData() must
41
+ // keep letting that JSON value win over whatever the DOM field says —
42
+ // this test exists to pin that merge order, not to change it.
43
+ const form = formWithQuantityField( { product_id: '42', amount: '10', quantity: 5 } );
44
+
45
+ const data = getCheckoutData( form );
46
+
47
+ expect( data.quantity ).toBe( 5 );
48
+ } );
49
+ } );