@automattic/newspack-blocks 4.26.5 → 4.26.6-alpha.1

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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/block_styles-rtl.css +2 -2
  3. package/dist/block_styles.asset.php +1 -1
  4. package/dist/block_styles.css +2 -2
  5. package/dist/carousel/view-rtl.css +1 -1
  6. package/dist/carousel/view.asset.php +1 -1
  7. package/dist/carousel/view.css +1 -1
  8. package/dist/donate/view-rtl.css +1 -1
  9. package/dist/donate/view.asset.php +1 -1
  10. package/dist/donate/view.css +1 -1
  11. package/dist/editor-rtl.css +21 -21
  12. package/dist/editor.asset.php +1 -1
  13. package/dist/editor.css +21 -21
  14. package/dist/editor.js +4 -4
  15. package/dist/frequencyBased-rtl.css +1 -1
  16. package/dist/frequencyBased.asset.php +1 -1
  17. package/dist/frequencyBased.css +1 -1
  18. package/dist/homepage-articles/view-rtl.css +1 -1
  19. package/dist/homepage-articles/view.asset.php +1 -1
  20. package/dist/homepage-articles/view.css +1 -1
  21. package/dist/modal-rtl.css +1 -1
  22. package/dist/modal.asset.php +1 -1
  23. package/dist/modal.css +1 -1
  24. package/dist/modal.js +1 -1
  25. package/dist/modalCheckout-rtl.css +1 -1
  26. package/dist/modalCheckout.asset.php +1 -1
  27. package/dist/modalCheckout.css +1 -1
  28. package/dist/tiersBased-rtl.css +1 -1
  29. package/dist/tiersBased.asset.php +1 -1
  30. package/dist/tiersBased.css +1 -1
  31. package/includes/class-modal-checkout.php +1 -2
  32. package/includes/modal-checkout/class-checkout-data.php +1 -1
  33. package/newspack-blocks.php +2 -2
  34. package/package.json +2 -2
  35. package/src/blocks/carousel/view.scss +4 -4
  36. package/src/blocks/checkout-button/view.php +6 -1
  37. package/src/blocks/homepage-articles/view.scss +1 -1
  38. package/src/modal-checkout/checkout-button-trigger.js +190 -0
  39. package/src/modal-checkout/checkout-button-trigger.test.js +266 -0
  40. package/src/modal-checkout/modal.js +37 -36
  41. package/src/modal-checkout/modal.scss +1 -1
  42. package/vendor/composer/installed.php +2 -2
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Resolve the form submitted by a modal checkout `checkout_button` URL trigger.
3
+ */
4
+
5
+ /**
6
+ * Parse a form's `data-checkout` attribute without throwing.
7
+ * Picker forms do not carry `data-checkout`.
8
+ *
9
+ * @param {HTMLElement|null} form The form element.
10
+ *
11
+ * @return {Object|null} The parsed checkout data, or null.
12
+ */
13
+ export function readCheckoutData( form ) {
14
+ const raw = form && form.dataset ? form.dataset.checkout : null;
15
+ if ( ! raw ) {
16
+ return null;
17
+ }
18
+ try {
19
+ return JSON.parse( raw );
20
+ } catch ( e ) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Find a checkout button form matching the requested product.
27
+ *
28
+ * Variation requests are never served by a button locked to a different
29
+ * variation.
30
+ *
31
+ * @param {Document|HTMLElement} root The DOM root to search.
32
+ * @param {string} productId The requested product ID.
33
+ * @param {string|null} variationId Optional. The requested variation ID.
34
+ *
35
+ * @return {HTMLFormElement|null} The matching form, or null.
36
+ */
37
+ export function findCheckoutButtonForm( root, productId, variationId = null ) {
38
+ const buttons = root.querySelectorAll( '.wp-block-newspack-blocks-checkout-button' );
39
+ const hasVariation = variationId !== null && variationId !== undefined && String( variationId ) !== '';
40
+ let match = null;
41
+ buttons.forEach( button => {
42
+ if ( match ) {
43
+ return;
44
+ }
45
+ const form = button.querySelector( 'form' );
46
+ const data = readCheckoutData( form );
47
+ if ( ! data ) {
48
+ return;
49
+ }
50
+ if ( String( data.product_id ) !== String( productId ) ) {
51
+ return;
52
+ }
53
+ if ( hasVariation && String( data.variation_id ) !== String( variationId ) ) {
54
+ return;
55
+ }
56
+ match = form;
57
+ } );
58
+ return match;
59
+ }
60
+
61
+ /**
62
+ * Select the requested variation in a product picker.
63
+ * Picker forms use the selected radio value instead of `data-checkout`.
64
+ *
65
+ * Side effect: when a matching radio is found it is checked (mutating the DOM)
66
+ * before the form is returned, so the form submits the requested variation.
67
+ *
68
+ * @param {Document|HTMLElement} root The DOM root to search.
69
+ * @param {string} productId The parent product ID of the picker.
70
+ * @param {string} variationId The requested variation ID.
71
+ * @param {Object} options Options.
72
+ * @param {string} options.variationModalClassPrefix Class of the picker container.
73
+ * @param {string} options.iframeName The checkout iframe name (form target).
74
+ *
75
+ * @return {HTMLFormElement|null} The picker form, or null.
76
+ */
77
+ export function selectPickerForm( root, productId, variationId, options = {} ) {
78
+ const { variationModalClassPrefix, iframeName } = options;
79
+ const modals = root.querySelectorAll( `.${ variationModalClassPrefix }` );
80
+ const modal = [ ...modals ].find( el => String( el.dataset.productId ) === String( productId ) );
81
+ if ( ! modal ) {
82
+ return null;
83
+ }
84
+ const forms = modal.querySelectorAll( 'form' );
85
+ const form = iframeName ? [ ...forms ].find( el => el.getAttribute( 'target' ) === iframeName ) : forms[ 0 ];
86
+ if ( ! form ) {
87
+ return null;
88
+ }
89
+ const radios = form.querySelectorAll( 'input[type="radio"][name="product_id"]' );
90
+ const radio = [ ...radios ].find( input => String( input.value ) === String( variationId ) );
91
+ if ( ! radio ) {
92
+ return null;
93
+ }
94
+ radio.checked = true;
95
+ return form;
96
+ }
97
+
98
+ /**
99
+ * Hidden fields copied from a source checkout button to a picker submission.
100
+ *
101
+ * @type {string[]}
102
+ */
103
+ export const PICKER_CONTEXT_FIELDS = [
104
+ 'after_success_behavior',
105
+ 'after_success_url',
106
+ 'after_success_button_label',
107
+ 'gate_post_id',
108
+ 'newspack_popup_id',
109
+ 'prompt_title',
110
+ ];
111
+
112
+ /**
113
+ * Copy context fields. Target values are preserved, empty source values are
114
+ * skipped, and null forms are ignored.
115
+ *
116
+ * @param {HTMLFormElement|null} sourceForm Checkout button form to read from.
117
+ * @param {HTMLFormElement|null} targetForm Picker form to copy into.
118
+ * @param {string[]} fields Field names to copy.
119
+ *
120
+ * @return {void}
121
+ */
122
+ export function copyContextFields( sourceForm, targetForm, fields = PICKER_CONTEXT_FIELDS ) {
123
+ if ( ! sourceForm || ! targetForm ) {
124
+ return;
125
+ }
126
+ const doc = targetForm.ownerDocument;
127
+ const sourceData = new FormData( sourceForm );
128
+ fields.forEach( name => {
129
+ if ( targetForm.querySelector( `input[name="${ name }"]` ) ) {
130
+ return;
131
+ }
132
+ const values = sourceData.getAll( name ).filter( value => typeof value === 'string' && value );
133
+ if ( ! values.length ) {
134
+ return;
135
+ }
136
+ const input = doc.createElement( 'input' );
137
+ input.type = 'hidden';
138
+ input.name = name;
139
+ input.value = values[ values.length - 1 ];
140
+ targetForm.prepend( input );
141
+ } );
142
+ }
143
+
144
+ /**
145
+ * Resolve which form a `checkout_button` URL trigger should submit.
146
+ *
147
+ * Strict order: exact button, picker, then explicit product-only fallback.
148
+ * Returning null prevents silent substitution.
149
+ *
150
+ * @param {Document|HTMLElement} root The DOM root to search.
151
+ * @param {string} productId The requested product ID.
152
+ * @param {string|null} variationId Optional. The requested variation ID.
153
+ * @param {Object} options Options (see selectPickerForm) plus
154
+ * `allowProductOnlyFallback` (default false).
155
+ *
156
+ * @return {HTMLFormElement|null} The form to submit, or null.
157
+ */
158
+ export function resolveCheckoutButtonForm( root, productId, variationId, options = {} ) {
159
+ const { allowProductOnlyFallback = false } = options;
160
+ const hasVariation = variationId !== null && variationId !== undefined && String( variationId ) !== '';
161
+
162
+ if ( ! hasVariation ) {
163
+ // No variation requested. If several buttons on the page share this
164
+ // parent product, the first in DOM order is used (along with its
165
+ // context); the URL gives no signal to prefer one over another.
166
+ return findCheckoutButtonForm( root, productId, null );
167
+ }
168
+
169
+ const exact = findCheckoutButtonForm( root, productId, variationId );
170
+ if ( exact ) {
171
+ return exact;
172
+ }
173
+
174
+ const picker = selectPickerForm( root, productId, variationId, options );
175
+ if ( picker ) {
176
+ // The source button may be locked to another variation. Use it only
177
+ // for block context, then submit the picker. The picker is only reached
178
+ // because no button matches the requested variation, so when several
179
+ // buttons share this parent product there is no single correct one to
180
+ // prefer: the first in DOM order supplies the context.
181
+ copyContextFields( findCheckoutButtonForm( root, productId, null ), picker );
182
+ return picker;
183
+ }
184
+
185
+ if ( allowProductOnlyFallback ) {
186
+ return findCheckoutButtonForm( root, productId, null );
187
+ }
188
+
189
+ return null;
190
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Tests for the checkout-button URL trigger resolution helpers.
3
+ */
4
+
5
+ import { readCheckoutData, findCheckoutButtonForm, selectPickerForm, resolveCheckoutButtonForm, copyContextFields } from './checkout-button-trigger';
6
+
7
+ const VARIATION_MODAL_CLASS_PREFIX = 'newspack-blocks__modal-variation';
8
+ const IFRAME_NAME = 'newspack_modal_checkout_iframe';
9
+
10
+ const PICKER_OPTIONS = {
11
+ variationModalClassPrefix: VARIATION_MODAL_CLASS_PREFIX,
12
+ iframeName: IFRAME_NAME,
13
+ };
14
+
15
+ /**
16
+ * Build a checkout button block markup string.
17
+ *
18
+ * @param {Object|null} checkoutData Object to JSON-encode into data-checkout, or null to omit it.
19
+ * @param {string} label Button label.
20
+ * @return {string} HTML.
21
+ */
22
+ const checkoutButton = ( checkoutData, label = 'Buy' ) => {
23
+ const attr = checkoutData ? ` data-checkout='${ JSON.stringify( checkoutData ) }'` : '';
24
+ return `<div class="wp-block-newspack-blocks-checkout-button"><form${ attr }><button type="submit">${ label }</button></form></div>`;
25
+ };
26
+
27
+ /**
28
+ * Build a variation picker modal, mirroring Subscriptions_Tiers::render_form output:
29
+ * a single form with `target` set, radio inputs named product_id, and no data-checkout.
30
+ *
31
+ * @param {string} productId Parent product id for the picker container.
32
+ * @param {string[]} radioIds Radio values (variation/child ids).
33
+ * @return {string} HTML.
34
+ */
35
+ const variationPicker = ( productId, radioIds ) => {
36
+ const radios = radioIds.map( id => `<input type="radio" name="product_id" value="${ id }">` ).join( '' );
37
+ return `<div class="${ VARIATION_MODAL_CLASS_PREFIX }" data-product-id="${ productId }"><form target="${ IFRAME_NAME }">${ radios }<button type="submit">Purchase</button></form></div>`;
38
+ };
39
+
40
+ const render = html => {
41
+ document.body.innerHTML = html;
42
+ return document.body;
43
+ };
44
+
45
+ afterEach( () => {
46
+ document.body.innerHTML = '';
47
+ } );
48
+
49
+ describe( 'readCheckoutData', () => {
50
+ it( 'parses a valid data-checkout attribute', () => {
51
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408' } ) );
52
+ const form = root.querySelector( 'form' );
53
+ expect( readCheckoutData( form ) ).toEqual( { product_id: '1406', variation_id: '1408' } );
54
+ } );
55
+
56
+ it( 'returns null without throwing when the attribute is missing', () => {
57
+ const root = render( variationPicker( '1434', [ '158' ] ) );
58
+ const form = root.querySelector( 'form' );
59
+ expect( () => readCheckoutData( form ) ).not.toThrow();
60
+ expect( readCheckoutData( form ) ).toBeNull();
61
+ } );
62
+
63
+ it( 'returns null without throwing on malformed JSON', () => {
64
+ const root = render( '<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout="not json">x</form></div>' );
65
+ const form = root.querySelector( 'form' );
66
+ expect( () => readCheckoutData( form ) ).not.toThrow();
67
+ expect( readCheckoutData( form ) ).toBeNull();
68
+ } );
69
+
70
+ it( 'returns null for a null form', () => {
71
+ expect( readCheckoutData( null ) ).toBeNull();
72
+ } );
73
+ } );
74
+
75
+ describe( 'findCheckoutButtonForm', () => {
76
+ it( 'requires both product_id and variation_id to match when a variation is requested', () => {
77
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
78
+ const form = root.querySelector( 'form' );
79
+ expect( findCheckoutButtonForm( root, '1406', '1408' ) ).toBe( form );
80
+ } );
81
+
82
+ it( 'does NOT match a locked button for a different requested variation', () => {
83
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
84
+ // Request 1407 while only the 1408-locked button exists.
85
+ expect( findCheckoutButtonForm( root, '1406', '1407' ) ).toBeNull();
86
+ } );
87
+
88
+ it( 'matches by product_id only when no variation is requested', () => {
89
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
90
+ const form = root.querySelector( 'form' );
91
+ expect( findCheckoutButtonForm( root, '1406', null ) ).toBe( form );
92
+ } );
93
+
94
+ it( 'does not match a grouped button (no variation_id) for a variation request', () => {
95
+ const root = render( checkoutButton( { product_id: '1434' }, 'Buy grouped' ) );
96
+ expect( findCheckoutButtonForm( root, '1434', '158' ) ).toBeNull();
97
+ } );
98
+
99
+ it( 'skips forms with missing or invalid data-checkout without throwing', () => {
100
+ const root = render( variationPicker( '1406', [ '1408' ] ) + checkoutButton( { product_id: '1406', variation_id: '1408' } ) );
101
+ expect( () => findCheckoutButtonForm( root, '1406', '1408' ) ).not.toThrow();
102
+ expect( findCheckoutButtonForm( root, '1406', '1408' ) ).not.toBeNull();
103
+ } );
104
+ } );
105
+
106
+ describe( 'selectPickerForm', () => {
107
+ it( 'checks the radio matching the variation and returns the picker form', () => {
108
+ const root = render( variationPicker( '1406', [ '1407', '1408', '1409' ] ) );
109
+ const form = selectPickerForm( root, '1406', '1407', PICKER_OPTIONS );
110
+ expect( form ).toBe( root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` ) );
111
+ expect( root.querySelector( 'input[value="1407"]' ).checked ).toBe( true );
112
+ expect( root.querySelector( 'input[value="1408"]' ).checked ).toBe( false );
113
+ } );
114
+
115
+ it( 'returns null when no picker exists for the product', () => {
116
+ const root = render( variationPicker( '1434', [ '158' ] ) );
117
+ expect( selectPickerForm( root, '1406', '1407', PICKER_OPTIONS ) ).toBeNull();
118
+ } );
119
+
120
+ it( 'returns null when no radio matches the variation', () => {
121
+ const root = render( variationPicker( '1406', [ '1407', '1409' ] ) );
122
+ expect( selectPickerForm( root, '1406', '1408', PICKER_OPTIONS ) ).toBeNull();
123
+ } );
124
+
125
+ it( 'only selects radio inputs inside the checkout iframe form', () => {
126
+ const root = render(
127
+ `<div class="${ VARIATION_MODAL_CLASS_PREFIX }" data-product-id="1406">` +
128
+ '<form target="other_iframe"><input type="radio" name="product_id" value="1407"></form>' +
129
+ `<form target="${ IFRAME_NAME }">` +
130
+ '<input type="hidden" name="product_id" value="1407">' +
131
+ '<input type="radio" name="product_id" value="1408">' +
132
+ '</form>' +
133
+ '</div>'
134
+ );
135
+ const checkoutForm = root.querySelector( `form[target="${ IFRAME_NAME }"]` );
136
+
137
+ expect( selectPickerForm( root, '1406', '1407', PICKER_OPTIONS ) ).toBeNull();
138
+ expect( selectPickerForm( root, '1406', '1408', PICKER_OPTIONS ) ).toBe( checkoutForm );
139
+ expect( checkoutForm.querySelector( 'input[type="radio"][value="1408"]' ).checked ).toBe( true );
140
+ } );
141
+ } );
142
+
143
+ describe( 'resolveCheckoutButtonForm', () => {
144
+ it( 'returns the picker form for a non-locked variation rather than the locked checkout button', () => {
145
+ const root = render(
146
+ checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true }, 'Subscribe' ) +
147
+ variationPicker( '1406', [ '1407', '1408', '1409' ] )
148
+ );
149
+ const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
150
+ const result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
151
+ expect( result ).toBe( pickerForm );
152
+ expect( root.querySelector( 'input[value="1407"]' ).checked ).toBe( true );
153
+ } );
154
+
155
+ it( 'returns the exact checkout button form when the requested variation is the locked one', () => {
156
+ const root = render(
157
+ checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true }, 'Subscribe' ) +
158
+ variationPicker( '1406', [ '1407', '1408', '1409' ] )
159
+ );
160
+ const buttonForm = root.querySelector( '.wp-block-newspack-blocks-checkout-button form' );
161
+ expect( resolveCheckoutButtonForm( root, '1406', '1408', PICKER_OPTIONS ) ).toBe( buttonForm );
162
+ } );
163
+
164
+ it( 'drives the grouped picker without throwing on its data-checkout-less form', () => {
165
+ const root = render( checkoutButton( { product_id: '1434' }, 'Buy grouped' ) + variationPicker( '1434', [ '158' ] ) );
166
+ const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
167
+ let result;
168
+ expect( () => {
169
+ result = resolveCheckoutButtonForm( root, '1434', '158', PICKER_OPTIONS );
170
+ } ).not.toThrow();
171
+ expect( result ).toBe( pickerForm );
172
+ expect( root.querySelector( 'input[value="158"]' ).checked ).toBe( true );
173
+ } );
174
+
175
+ it( 'returns null for an invalid variation when product-only fallback is off (default)', () => {
176
+ const root = render( checkoutButton( { product_id: '158' }, 'Checkout' ) );
177
+ expect( resolveCheckoutButtonForm( root, '158', '160', PICKER_OPTIONS ) ).toBeNull();
178
+ } );
179
+
180
+ it( 'treats a variation_id equal to product_id as a strict variation request', () => {
181
+ const root = render( checkoutButton( { product_id: '158' }, 'Checkout' ) );
182
+ expect( resolveCheckoutButtonForm( root, '158', '158', PICKER_OPTIONS ) ).toBeNull();
183
+ } );
184
+
185
+ it( 'returns the product-only button for an invalid variation only when fallback is explicitly enabled', () => {
186
+ const root = render( checkoutButton( { product_id: '158' }, 'Checkout' ) );
187
+ const buttonForm = root.querySelector( 'form' );
188
+ expect( resolveCheckoutButtonForm( root, '158', '160', { ...PICKER_OPTIONS, allowProductOnlyFallback: true } ) ).toBe( buttonForm );
189
+ } );
190
+
191
+ it( 'matches a checkout button by product_id when no variation is requested', () => {
192
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
193
+ const buttonForm = root.querySelector( 'form' );
194
+ expect( resolveCheckoutButtonForm( root, '1406', null, PICKER_OPTIONS ) ).toBe( buttonForm );
195
+ } );
196
+
197
+ it( 'returns null without throwing when nothing matches', () => {
198
+ const root = render( checkoutButton( { product_id: '999' } ) );
199
+ let result;
200
+ expect( () => {
201
+ result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
202
+ } ).not.toThrow();
203
+ expect( result ).toBeNull();
204
+ } );
205
+
206
+ it( 'copies block context from the source button into the picker form without submitting the locked button', () => {
207
+ const button = `<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout='${ JSON.stringify( {
208
+ product_id: '1406',
209
+ variation_id: '1408',
210
+ is_variable: true,
211
+ } ) }'><input type="hidden" name="after_success_button_label" value="Thanks!"><input type="hidden" name="after_success_url" value="/welcome/"><button type="submit">Subscribe</button></form></div>`;
212
+ const root = render( button + variationPicker( '1406', [ '1407', '1408', '1409' ] ) );
213
+ const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
214
+ const buttonForm = root.querySelector( '.wp-block-newspack-blocks-checkout-button form' );
215
+
216
+ const result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
217
+
218
+ expect( result ).toBe( pickerForm );
219
+ expect( result ).not.toBe( buttonForm );
220
+ expect( pickerForm.querySelector( 'input[name="after_success_button_label"]' ).value ).toBe( 'Thanks!' );
221
+ expect( pickerForm.querySelector( 'input[name="after_success_url"]' ).value ).toBe( '/welcome/' );
222
+ } );
223
+ } );
224
+
225
+ describe( 'copyContextFields', () => {
226
+ it( 'copies present source fields, skips missing ones, and does not overwrite existing target fields', () => {
227
+ const root = render(
228
+ `<form id="src"><input type="hidden" name="after_success_url" value="/welcome/"><input type="hidden" name="prompt_title" value="Join"></form>` +
229
+ `<form id="dst"><input type="hidden" name="prompt_title" value="Existing"></form>`
230
+ );
231
+ const source = root.querySelector( '#src' );
232
+ const target = root.querySelector( '#dst' );
233
+
234
+ copyContextFields( source, target );
235
+
236
+ // Copied from source.
237
+ expect( target.querySelector( 'input[name="after_success_url"]' ).value ).toBe( '/welcome/' );
238
+ // Not overwritten.
239
+ expect( target.querySelector( 'input[name="prompt_title"]' ).value ).toBe( 'Existing' );
240
+ // Missing on source -> not added.
241
+ expect( target.querySelector( 'input[name="gate_post_id"]' ) ).toBeNull();
242
+ } );
243
+
244
+ it( 'copies the last source value when a context field is duplicated', () => {
245
+ const root = render(
246
+ '<form id="src">' +
247
+ '<input type="hidden" name="after_success_url" value="/first/">' +
248
+ '<input type="hidden" name="after_success_url" value="/last/">' +
249
+ '</form>' +
250
+ '<form id="dst"></form>'
251
+ );
252
+ const source = root.querySelector( '#src' );
253
+ const target = root.querySelector( '#dst' );
254
+
255
+ copyContextFields( source, target );
256
+
257
+ expect( target.querySelector( 'input[name="after_success_url"]' ).value ).toBe( '/last/' );
258
+ } );
259
+
260
+ it( 'does not throw when source or target is null', () => {
261
+ const root = render( '<form id="dst"></form>' );
262
+ const target = root.querySelector( '#dst' );
263
+ expect( () => copyContextFields( null, target ) ).not.toThrow();
264
+ expect( () => copyContextFields( target, null ) ).not.toThrow();
265
+ } );
266
+ } );
@@ -24,6 +24,7 @@ import {
24
24
  getCheckoutData,
25
25
  getFormattedAmount,
26
26
  } from './utils';
27
+ import { resolveCheckoutButtonForm, readCheckoutData } from './checkout-button-trigger';
27
28
 
28
29
  const CLASS_PREFIX = newspackBlocksModal.newspack_class_prefix;
29
30
  const IFRAME_NAME = 'newspack_modal_checkout_iframe';
@@ -376,9 +377,8 @@ domReady( () => {
376
377
  } );
377
378
 
378
379
  // Append the product data hidden inputs.
379
- const variationData = singleVariationForm.dataset.checkout;
380
- if ( variationData ) {
381
- const data = JSON.parse( variationData );
380
+ const data = readCheckoutData( singleVariationForm );
381
+ if ( data ) {
382
382
  Object.keys( data ).forEach( key => {
383
383
  const existingInputs = singleVariationForm.querySelectorAll( 'input[name="' + key + '"]' );
384
384
  if ( 0 === existingInputs.length ) {
@@ -799,41 +799,29 @@ domReady( () => {
799
799
  };
800
800
 
801
801
  /**
802
- * Handle checkout button form triggers.
802
+ * Handle checkout button URL triggers.
803
803
  *
804
- * @param {number} productId The product ID.
805
- * @param {number|null} variationId Optional. The variation ID.
804
+ * @param {string} productId The product ID.
805
+ * @param {string|null} variationId Optional. The variation ID.
806
+ *
807
+ * @return {boolean} Whether a matching form was submitted.
806
808
  */
807
809
  const triggerCheckoutButtonForm = ( productId, variationId = null ) => {
808
- let form;
809
- if ( variationId && variationId !== productId ) {
810
- const variationModals = document.querySelectorAll( `.${ VARIATON_MODAL_CLASS_PREFIX }` );
811
- const variationModal = [ ...variationModals ].find( modal => modal.dataset.productId === productId );
812
- if ( variationModal ) {
813
- const forms = variationModal.querySelectorAll( `form[target="${ IFRAME_NAME }"]` );
814
- forms.forEach( variationForm => {
815
- const productData = JSON.parse( variationForm.dataset.checkout );
816
- if ( productData?.variation_id === Number( variationId ) ) {
817
- form = variationForm;
818
- }
819
- } );
820
- }
821
- } else {
822
- const checkoutButtons = document.querySelectorAll( '.wp-block-newspack-blocks-checkout-button' );
823
- checkoutButtons.forEach( button => {
824
- const checkoutButtonForm = button.querySelector( 'form' );
825
- if ( ! checkoutButtonForm ) {
826
- return;
827
- }
828
- const productData = JSON.parse( checkoutButtonForm.dataset.checkout );
829
- if ( productData?.product_id === productId ) {
830
- form = checkoutButtonForm;
831
- }
832
- } );
833
- }
810
+ const form = resolveCheckoutButtonForm( document, productId, variationId, {
811
+ variationModalClassPrefix: VARIATON_MODAL_CLASS_PREFIX,
812
+ iframeName: IFRAME_NAME,
813
+ } );
834
814
  if ( form ) {
835
815
  triggerFormSubmit( form );
836
- }
816
+ return true;
817
+ }
818
+ const message =
819
+ `Newspack modal checkout: no checkout form found for product_id "${ productId }"` +
820
+ ( variationId ? ` and variation_id "${ variationId }"` : '' ) +
821
+ '. The checkout was not triggered.';
822
+ // eslint-disable-next-line no-console
823
+ console.warn( message );
824
+ return false;
837
825
  };
838
826
 
839
827
  /**
@@ -844,6 +832,10 @@ domReady( () => {
844
832
  if ( ! urlParams.has( 'checkout' ) ) {
845
833
  return;
846
834
  }
835
+ // Default to stripping the params after handling. The checkout button
836
+ // trigger overrides this so a link that matches no form stays visible
837
+ // and diagnosable rather than being silently dropped.
838
+ let shouldStripParams = true;
847
839
  const type = urlParams.get( 'type' );
848
840
  if ( type === 'donate' ) {
849
841
  const layout = urlParams.get( 'layout' );
@@ -857,7 +849,13 @@ domReady( () => {
857
849
  const productId = urlParams.get( 'product_id' );
858
850
  const variationId = urlParams.get( 'variation_id' );
859
851
  if ( productId ) {
860
- triggerCheckoutButtonForm( productId, variationId );
852
+ shouldStripParams = triggerCheckoutButtonForm( productId, variationId );
853
+ } else {
854
+ // A checkout_button trigger with no product_id cannot resolve a
855
+ // form; keep the params visible rather than dropping them silently.
856
+ shouldStripParams = false;
857
+ // eslint-disable-next-line no-console
858
+ console.warn( 'Newspack modal checkout: checkout_button trigger is missing product_id. The checkout was not triggered.' );
861
859
  }
862
860
  } else {
863
861
  const url = window.newspackReaderActivation?.getPendingCheckout?.();
@@ -866,8 +864,11 @@ domReady( () => {
866
864
  triggerFormSubmit( form );
867
865
  }
868
866
  }
869
- // Remove the URL param to prevent re-triggering.
870
- window.history.replaceState( null, null, window.location.pathname );
867
+ // Remove the URL params to prevent re-triggering, but only when the
868
+ // trigger succeeded.
869
+ if ( shouldStripParams ) {
870
+ window.history.replaceState( null, null, window.location.pathname );
871
+ }
871
872
  };
872
873
  handleModalCheckoutUrlParams();
873
874
 
@@ -30,7 +30,7 @@
30
30
  z-index: 1;
31
31
  inset: 0;
32
32
  opacity: 0;
33
- background: rgba(np-colors.$neutral-1000, 0.5);
33
+ background: np-colors.$overlay-700;
34
34
  transition: opacity 0.1s linear;
35
35
  }
36
36
 
@@ -3,7 +3,7 @@
3
3
  'name' => 'automattic/newspack-blocks',
4
4
  'pretty_version' => 'dev-main',
5
5
  'version' => 'dev-main',
6
- 'reference' => 'ca1f2b8803452beb52cb6145bc5f23a20f99e699',
6
+ 'reference' => '19f5a2811b00a19f3c950a6436ac64309d3749a5',
7
7
  'type' => 'wordpress-plugin',
8
8
  'install_path' => __DIR__ . '/../../',
9
9
  'aliases' => array(),
@@ -13,7 +13,7 @@
13
13
  'automattic/newspack-blocks' => array(
14
14
  'pretty_version' => 'dev-main',
15
15
  'version' => 'dev-main',
16
- 'reference' => 'ca1f2b8803452beb52cb6145bc5f23a20f99e699',
16
+ 'reference' => '19f5a2811b00a19f3c950a6436ac64309d3749a5',
17
17
  'type' => 'wordpress-plugin',
18
18
  'install_path' => __DIR__ . '/../../',
19
19
  'aliases' => array(),