@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,173 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { act, fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import SpecificPostsControl from './specific-posts-control';
10
+
11
+ const noop = () => {};
12
+ const noItems = () => Promise.resolve( [] );
13
+
14
+ const ITEMS = [
15
+ { value: 11, label: 'Alpha' },
16
+ { value: 22, label: 'Beta' },
17
+ ];
18
+
19
+ const titles = () => Array.from( document.querySelectorAll( '.newspack-blocks-reorder-modal__title' ) ).map( el => el.textContent );
20
+
21
+ describe( 'SpecificPostsControl', () => {
22
+ it( 'exposes the token field to assistive technology as "Content"', () => {
23
+ render( <SpecificPostsControl postIds={ [] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ noop } /> );
24
+ expect( screen.getByRole( 'combobox', { name: 'Content' } ) ).toBeInTheDocument();
25
+ } );
26
+
27
+ it( 'keeps the reorder button focusable while too little content is selected', async () => {
28
+ render( <SpecificPostsControl postIds={ [ 11 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ noItems } /> );
29
+ const reorder = await screen.findByRole( 'button', { name: 'Reorder Content' } );
30
+ expect( reorder ).toHaveAttribute( 'aria-disabled', 'true' );
31
+ reorder.focus();
32
+ expect( document.activeElement ).toBe( reorder );
33
+ } );
34
+
35
+ it( 'describes why the reorder button is unavailable without renaming it', async () => {
36
+ render( <SpecificPostsControl postIds={ [ 11 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ noItems } /> );
37
+ const reorder = await screen.findByRole( 'button', { name: 'Reorder Content' } );
38
+ const description = document.getElementById( reorder.getAttribute( 'aria-describedby' ) );
39
+ expect( description ).toHaveTextContent( 'Pick at least two items to reorder them.' );
40
+ } );
41
+
42
+ // A button with visible children shows no tooltip by default, so the reason
43
+ // would otherwise reach assistive technology only.
44
+ it( 'shows the reason to sighted users too', async () => {
45
+ render( <SpecificPostsControl postIds={ [ 11 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ noItems } /> );
46
+ ( await screen.findByRole( 'button', { name: 'Reorder Content' } ) ).focus();
47
+ expect( await screen.findByRole( 'tooltip' ) ).toHaveTextContent( 'Pick at least two items to reorder them.' );
48
+ } );
49
+
50
+ it( 'shows no tooltip once the button is usable', async () => {
51
+ render(
52
+ <SpecificPostsControl
53
+ postIds={ [ 11, 22 ] }
54
+ onChange={ noop }
55
+ fetchSuggestions={ noop }
56
+ fetchSavedInfo={ () => Promise.resolve( ITEMS ) }
57
+ />
58
+ );
59
+ ( await screen.findByRole( 'button', { name: 'Reorder Content' } ) ).focus();
60
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
61
+ } );
62
+
63
+ it( 'enables the reorder button once two items are selected', async () => {
64
+ render(
65
+ <SpecificPostsControl
66
+ postIds={ [ 11, 22 ] }
67
+ onChange={ noop }
68
+ fetchSuggestions={ noop }
69
+ fetchSavedInfo={ () => Promise.resolve( ITEMS ) }
70
+ />
71
+ );
72
+ expect( await screen.findByRole( 'button', { name: 'Reorder Content' } ) ).not.toHaveAttribute( 'aria-disabled' );
73
+ } );
74
+
75
+ it( 'writes the reordered IDs back through onChange', async () => {
76
+ const onChange = jest.fn();
77
+ render(
78
+ <SpecificPostsControl
79
+ postIds={ [ 11, 22 ] }
80
+ onChange={ onChange }
81
+ fetchSuggestions={ noop }
82
+ fetchSavedInfo={ () => Promise.resolve( ITEMS ) }
83
+ />
84
+ );
85
+
86
+ fireEvent.click( await screen.findByRole( 'button', { name: 'Reorder Content' } ) );
87
+ await screen.findByLabelText( 'Move Up: Beta' );
88
+ expect( titles() ).toEqual( [ 'Alpha', 'Beta' ] );
89
+
90
+ fireEvent.click( screen.getByLabelText( 'Move Up: Beta' ) );
91
+ fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
92
+
93
+ expect( onChange ).toHaveBeenCalledWith( [ 22, 11 ] );
94
+ expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
95
+ } );
96
+
97
+ it( 'closes the modal without changing anything when the reorder is cancelled', async () => {
98
+ const onChange = jest.fn();
99
+ render(
100
+ <SpecificPostsControl
101
+ postIds={ [ 11, 22 ] }
102
+ onChange={ onChange }
103
+ fetchSuggestions={ noop }
104
+ fetchSavedInfo={ () => Promise.resolve( ITEMS ) }
105
+ />
106
+ );
107
+
108
+ fireEvent.click( await screen.findByRole( 'button', { name: 'Reorder Content' } ) );
109
+ await screen.findByLabelText( 'Move Up: Beta' );
110
+ fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
111
+
112
+ expect( onChange ).not.toHaveBeenCalled();
113
+ expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
114
+ } );
115
+
116
+ it( 'asks for the titles once and shares them with the modal', async () => {
117
+ const fetchSavedInfo = jest.fn( () => Promise.resolve( ITEMS ) );
118
+ render( <SpecificPostsControl postIds={ [ 11, 22 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ fetchSavedInfo } /> );
119
+
120
+ fireEvent.click( await screen.findByRole( 'button', { name: 'Reorder Content' } ) );
121
+ await screen.findByLabelText( 'Move Up: Beta' );
122
+
123
+ expect( fetchSavedInfo ).toHaveBeenCalledTimes( 1 );
124
+ } );
125
+
126
+ describe( 'when a request fails', () => {
127
+ let errorSpy;
128
+
129
+ beforeEach( () => {
130
+ errorSpy = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
131
+ } );
132
+
133
+ afterEach( () => {
134
+ errorSpy.mockRestore();
135
+ } );
136
+
137
+ it( 'asks again rather than sharing the failure', async () => {
138
+ const fetchSavedInfo = jest.fn().mockRejectedValueOnce( new Error( 'unreachable' ) ).mockResolvedValue( ITEMS );
139
+ render( <SpecificPostsControl postIds={ [ 11, 22 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ fetchSavedInfo } /> );
140
+
141
+ // The token field falls back to the IDs, which is where the failure lands.
142
+ await screen.findByText( '11' );
143
+
144
+ fireEvent.click( screen.getByRole( 'button', { name: 'Reorder Content' } ) );
145
+ expect( await screen.findByLabelText( 'Move Up: Beta' ) ).toBeInTheDocument();
146
+ expect( fetchSavedInfo ).toHaveBeenCalledTimes( 2 );
147
+ } );
148
+
149
+ it( 'leaves a newer request in place when an older one fails late', async () => {
150
+ let failFirst;
151
+ const first = new Promise( ( resolve, reject ) => {
152
+ failFirst = reject;
153
+ } );
154
+ const fetchSavedInfo = jest.fn().mockReturnValueOnce( first ).mockResolvedValue( ITEMS );
155
+ const props = { onChange: noop, fetchSuggestions: noop, fetchSavedInfo };
156
+ const { rerender } = render( <SpecificPostsControl postIds={ [ 11 ] } { ...props } /> );
157
+
158
+ rerender( <SpecificPostsControl postIds={ [ 11, 22 ] } { ...props } /> );
159
+ fireEvent.click( screen.getByRole( 'button', { name: 'Reorder Content' } ) );
160
+ await screen.findByLabelText( 'Move Up: Beta' );
161
+
162
+ await act( async () => {
163
+ failFirst( new Error( 'too late' ) );
164
+ } );
165
+
166
+ fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
167
+ fireEvent.click( screen.getByRole( 'button', { name: 'Reorder Content' } ) );
168
+ await screen.findByLabelText( 'Move Up: Beta' );
169
+
170
+ expect( fetchSavedInfo ).toHaveBeenCalledTimes( 2 );
171
+ } );
172
+ } );
173
+ } );
@@ -24,6 +24,7 @@ const eventKeys = [
24
24
  'currency',
25
25
  'product_id',
26
26
  'product_type',
27
+ 'quantity',
27
28
  'variation_id',
28
29
  'variation_ids',
29
30
  'is_variable',
@@ -150,8 +150,27 @@ export const PICKER_CONTEXT_FIELDS = [
150
150
  'newspack_popup_id',
151
151
  'prompt_title',
152
152
  'coupon',
153
+ 'quantity',
153
154
  ];
154
155
 
156
+ /**
157
+ * Whether the form already offers this field as a control the reader can use.
158
+ *
159
+ * Context fields are carried as hidden inputs, but a picker can render one of
160
+ * the same names as a real control — the per-seat group plans' seats field is
161
+ * `quantity`. That control is the reader's, not the block's: it must never be
162
+ * removed, and a hidden input of the same name alongside it would submit a
163
+ * second, competing value.
164
+ *
165
+ * @param {HTMLFormElement} form The form to inspect.
166
+ * @param {string} name The field name.
167
+ *
168
+ * @return {boolean} True when a non-hidden input of that name exists.
169
+ */
170
+ function hasVisibleField( form, name ) {
171
+ return !! form.querySelector( `input[name="${ name }"]:not([type="hidden"])` );
172
+ }
173
+
155
174
  /**
156
175
  * Stamp context fields onto a picker form from the button that opened it.
157
176
  *
@@ -174,8 +193,14 @@ export function applyContextFields( targetForm, data, fields = PICKER_CONTEXT_FI
174
193
  }
175
194
  const doc = targetForm.ownerDocument;
176
195
  fields.forEach( name => {
177
- // Drop whatever a previous open left behind before writing this one's value.
178
- targetForm.querySelectorAll( `input[name="${ name }"]` ).forEach( input => input.remove() );
196
+ // Drop whatever a previous open left behind before writing this one's value
197
+ // — but only the hidden inputs this function stamps. A visible control of
198
+ // the same name is the picker's own, and removing it takes the field away
199
+ // from the reader.
200
+ targetForm.querySelectorAll( `input[type="hidden"][name="${ name }"]` ).forEach( input => input.remove() );
201
+ if ( hasVisibleField( targetForm, name ) ) {
202
+ return;
203
+ }
179
204
  const raw = data[ name ];
180
205
  const value = raw === undefined || raw === null ? '' : String( raw );
181
206
  if ( ! value ) {
@@ -210,6 +235,8 @@ export function copyContextFields( sourceForm, targetForm, fields = PICKER_CONTE
210
235
  const doc = targetForm.ownerDocument;
211
236
  const sourceData = new FormData( sourceForm );
212
237
  fields.forEach( name => {
238
+ // A field the picker already carries wins, whether that is a value left by
239
+ // an earlier copy or a control the reader fills in themselves.
213
240
  if ( targetForm.querySelector( `input[name="${ name }"]` ) ) {
214
241
  return;
215
242
  }
@@ -371,6 +371,42 @@ describe( 'applyContextFields', () => {
371
371
  expect( form.querySelector( 'input[name="prompt_title"]' ) ).toBeNull();
372
372
  } );
373
373
 
374
+ it( 'stamps the quantity', () => {
375
+ const form = picker();
376
+
377
+ applyContextFields( form, { quantity: 3 } );
378
+
379
+ expect( form.querySelector( 'input[name="quantity"]' ).value ).toBe( '3' );
380
+ } );
381
+
382
+ it( 'leaves the picker’s own seats field in place and does not shadow it', () => {
383
+ // Per-seat group plans render a visible number input named `quantity`, the
384
+ // same name the block stamps its own quantity into. Removing it leaves the
385
+ // reader with no way to choose seats at all.
386
+ const form = render( '<form id="picker"><input type="number" name="quantity" id="group_seats" min="2" value="2"></form>' ).querySelector(
387
+ '#picker'
388
+ );
389
+
390
+ applyContextFields( form, { quantity: 3 } );
391
+
392
+ const inputs = form.querySelectorAll( 'input[name="quantity"]' );
393
+ expect( inputs ).toHaveLength( 1 );
394
+ expect( inputs[ 0 ].id ).toBe( 'group_seats' );
395
+ expect( inputs[ 0 ].value ).toBe( '2' );
396
+ } );
397
+
398
+ it( 'clears its own stale hidden field without touching a visible one', () => {
399
+ const form = picker();
400
+
401
+ applyContextFields( form, { quantity: 3 } );
402
+ form.insertAdjacentHTML( 'beforeend', '<input type="number" name="quantity" id="group_seats" value="4">' );
403
+ applyContextFields( form, { quantity: 5 } );
404
+
405
+ const inputs = form.querySelectorAll( 'input[name="quantity"]' );
406
+ expect( inputs ).toHaveLength( 1 );
407
+ expect( inputs[ 0 ].id ).toBe( 'group_seats' );
408
+ } );
409
+
374
410
  it( 'leaves the picker’s own fields alone', () => {
375
411
  const form = render(
376
412
  '<form id="picker"><input type="hidden" name="newspack_checkout" value="1"><input type="radio" name="product_id" value="9"></form>'
@@ -402,6 +438,7 @@ describe( 'PICKER_CONTEXT_FIELDS', () => {
402
438
  'newspack_popup_id',
403
439
  'prompt_title',
404
440
  'coupon',
441
+ 'quantity',
405
442
  ] )
406
443
  );
407
444
  } );
@@ -333,6 +333,33 @@
333
333
  }
334
334
  }
335
335
 
336
+ // Quantity (e.g. group subscription seats) styles
337
+ .modal_checkout_quantity {
338
+ margin-bottom: var(--newspack-ui-spacer-5, 24px);
339
+
340
+ h3 {
341
+ color: var(--newspack-ui-label-color, inherit);
342
+ margin-bottom: var(--newspack-ui-spacer-base, 8px);
343
+ }
344
+
345
+ .input-quantity {
346
+ align-items: center;
347
+ display: flex;
348
+ flex-wrap: nowrap;
349
+ gap: 0.5em;
350
+ margin: 0 0 8px;
351
+
352
+ input {
353
+ margin: 0;
354
+ width: 100%;
355
+ }
356
+
357
+ button {
358
+ margin-top: 0;
359
+ }
360
+ }
361
+ }
362
+
336
363
  // Coupon Styles
337
364
  .woocommerce-form-coupon {
338
365
  margin-bottom: var(--newspack-ui-spacer-5, 24px);
@@ -360,6 +387,7 @@
360
387
 
361
388
  // Processing styles
362
389
  .modal_checkout_nyp,
390
+ .modal_checkout_quantity,
363
391
  .modal_checkout_coupon {
364
392
  &.modal-processing {
365
393
  opacity: 0.5;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Resolve the form acted on by a modal checkout `donate` URL trigger.
3
+ *
4
+ * Resolution is side-effect free: no clicks, no input mutation. The caller
5
+ * performs the clicks/submission described by the returned resolution.
6
+ */
7
+
8
+ import { FREQUENCY_SLUGS } from '../blocks/donate/frequency-slugs';
9
+ import { TIERS_BASED_READY_ATTRIBUTE } from '../shared/js/tiers-based-ready';
10
+
11
+ /**
12
+ * Layouts a donate URL trigger can request.
13
+ *
14
+ * @type {string[]}
15
+ */
16
+ export const VALID_LAYOUTS = [ 'tiered', 'frequency', 'untiered' ];
17
+
18
+ /**
19
+ * Donation frequency slugs a trigger can request.
20
+ *
21
+ * @type {string[]}
22
+ */
23
+ export const VALID_FREQUENCIES = FREQUENCY_SLUGS;
24
+
25
+ /**
26
+ * Validate donate URL trigger parameters.
27
+ *
28
+ * The values are interpolated into attribute selectors, so anything outside
29
+ * the known closed sets (or a plain number) is rejected rather than escaped.
30
+ * The `other` amount only exists in the frequency layout.
31
+ *
32
+ * @param {string} layout The donation layout.
33
+ * @param {string} frequency The donation frequency.
34
+ * @param {string} amount The donation amount.
35
+ *
36
+ * @return {boolean} Whether the parameters are usable.
37
+ */
38
+ export function validateDonationTriggerParams( layout, frequency, amount ) {
39
+ if ( ! VALID_LAYOUTS.includes( layout ) || ! VALID_FREQUENCIES.includes( frequency ) ) {
40
+ return false;
41
+ }
42
+ if ( amount === 'other' ) {
43
+ return layout === 'frequency';
44
+ }
45
+ return /^\d+(\.\d+)?$/.test( amount );
46
+ }
47
+
48
+ /**
49
+ * Resolve which donate form a URL trigger should act on, and how.
50
+ *
51
+ * The first form in DOM order that can fully satisfy the trigger wins; forms
52
+ * that cannot are skipped untouched. (Before this resolver existed, the last
53
+ * match won for the frequency/untiered layouts, so on a page with several
54
+ * matching Donate blocks a published link may now act on a different block
55
+ * than it used to — and land on that block's own after-success settings.)
56
+ * Tiered forms are matched against the
57
+ * server-rendered tier markup (`data-frequency-slug`/`data-amount` spans), so
58
+ * no tab needs to be clicked to find out whether the amount exists. A tiered
59
+ * block whose view script has not initialized yet (no `data-tiers-based-ready`
60
+ * on the container) cannot be acted on — clicking its tabs or tiers would do
61
+ * nothing — so the scan continues past it; `not-ready` (carrying the first
62
+ * such form) is returned only when no initialized form matched.
63
+ *
64
+ * @param {Document|HTMLElement} root The DOM root to search.
65
+ * @param {Object} params Trigger parameters.
66
+ * @param {string} params.layout The donation layout.
67
+ * @param {string} params.frequency The donation frequency.
68
+ * @param {string} params.amount The donation amount.
69
+ *
70
+ * @return {Object} Resolution object. `status` is one of `invalid-params`,
71
+ * `no-match`, `not-ready`, `tiered`, `frequency` or
72
+ * `untiered`. Tiered resolutions carry `form`, `tierIndex`
73
+ * and `frequencyButton` (null when the block renders a single
74
+ * frequency and no tab click is needed); frequency/untiered
75
+ * resolutions carry `form`, `frequencyInput` and `amountInput`.
76
+ */
77
+ export function resolveDonationTrigger( root, { layout, frequency, amount } ) {
78
+ if ( ! validateDonationTriggerParams( layout, frequency, amount ) ) {
79
+ return { status: 'invalid-params' };
80
+ }
81
+ let notReadyForm = null;
82
+ for ( const form of root.querySelectorAll( '.wpbnbd.wpbnbd--platform-wc form' ) ) {
83
+ const tiersContainer = form.closest( '.wpbnbd--tiers-based' );
84
+ if ( layout === 'tiered' ) {
85
+ if ( ! tiersContainer ) {
86
+ continue;
87
+ }
88
+ const tierSpan = form.querySelector( `.wpbnbd__tiers__amount [data-frequency-slug="${ frequency }"][data-amount="${ amount }"]` );
89
+ if ( ! tierSpan ) {
90
+ continue;
91
+ }
92
+ const tierIndex = parseInt( tierSpan.getAttribute( 'data-tier-index' ) );
93
+ if ( isNaN( tierIndex ) ) {
94
+ continue;
95
+ }
96
+ // Blocks with a single enabled frequency render no tab buttons; the
97
+ // hidden frequency input then decides whether this form matches.
98
+ const frequencyButton = form.querySelector( `button[data-frequency-slug="${ frequency }"]` );
99
+ if ( ! frequencyButton && ! form.querySelector( `input[name="donation_frequency"][value="${ frequency }"]` ) ) {
100
+ continue;
101
+ }
102
+ if ( ! tiersContainer.hasAttribute( TIERS_BASED_READY_ATTRIBUTE ) ) {
103
+ // Remember the first not-ready match but keep scanning: a later,
104
+ // already-initialized block that satisfies the trigger beats waiting
105
+ // on one that may never become ready (the view script bails without
106
+ // stamping readiness on config or markup failures).
107
+ if ( ! notReadyForm ) {
108
+ notReadyForm = form;
109
+ }
110
+ continue;
111
+ }
112
+ return { status: 'tiered', form, frequencyButton, tierIndex };
113
+ }
114
+ if ( tiersContainer ) {
115
+ continue;
116
+ }
117
+ const frequencyInput = form.querySelector( `input[name="donation_frequency"][value="${ frequency }"]` );
118
+ if ( ! frequencyInput ) {
119
+ continue;
120
+ }
121
+ const amountInput =
122
+ layout === 'untiered'
123
+ ? form.querySelector( `input[name="donation_value_${ frequency }_untiered"]` )
124
+ : form.querySelector( `input[name="donation_value_${ frequency }"][value="${ amount }"]` );
125
+ if ( ! amountInput ) {
126
+ continue;
127
+ }
128
+ return { status: layout, form, frequencyInput, amountInput };
129
+ }
130
+ if ( notReadyForm ) {
131
+ return { status: 'not-ready', form: notReadyForm };
132
+ }
133
+ return { status: 'no-match' };
134
+ }