@automattic/newspack-blocks 4.33.1 → 4.34.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 (32) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/editor-rtl.css +4 -3
  3. package/dist/editor.asset.php +1 -1
  4. package/dist/editor.css +4 -3
  5. package/dist/editor.js +14 -14
  6. package/dist/modal.asset.php +1 -1
  7. package/dist/modal.js +1 -1
  8. package/includes/class-modal-checkout.php +248 -6
  9. package/includes/class-newspack-blocks-api.php +8 -0
  10. package/includes/class-newspack-blocks.php +38 -0
  11. package/languages/newspack-blocks-de_DE.po +124 -119
  12. package/languages/newspack-blocks-es_ES.po +124 -119
  13. package/languages/newspack-blocks-fr_BE.po +124 -119
  14. package/languages/newspack-blocks-nb_NO.po +124 -119
  15. package/languages/newspack-blocks-pt_PT.po +124 -119
  16. package/languages/newspack-blocks.pot +129 -125
  17. package/newspack-blocks.php +2 -2
  18. package/package.json +2 -2
  19. package/src/blocks/author-list/class-wp-rest-newspack-author-list-controller.php +6 -1
  20. package/src/blocks/author-profile/class-wp-rest-newspack-authors-controller.php +196 -48
  21. package/src/blocks/author-profile/edit.js +23 -65
  22. package/src/blocks/author-profile/single-author.js +7 -12
  23. package/src/blocks/author-profile/social-links.js +25 -0
  24. package/src/blocks/author-profile/social-links.test.js +42 -0
  25. package/src/blocks/checkout-button/view.php +4 -2
  26. package/src/blocks/homepage-articles/templates/article.php +1 -18
  27. package/src/modal-checkout/checkout-button-trigger.js +162 -24
  28. package/src/modal-checkout/checkout-button-trigger.test.js +149 -9
  29. package/src/modal-checkout/modal.js +31 -1
  30. package/src/shared/js/author-fetch.js +217 -0
  31. package/src/shared/js/author-fetch.test.js +230 -0
  32. package/vendor/composer/installed.php +2 -2
@@ -22,19 +22,45 @@ export function readCheckoutData( form ) {
22
22
  }
23
23
  }
24
24
 
25
+ /**
26
+ * Container Modal_Checkout::render_url_triggered_block() wraps a synthesized
27
+ * block in. Buttons inside it exist only to serve the URL trigger, so the
28
+ * resolver considers them after every block the page itself carries.
29
+ *
30
+ * @type {string}
31
+ */
32
+ export const SYNTHESIZED_CONTAINER_SELECTOR = '.newspack-blocks__url-triggered-checkout';
33
+
34
+ /**
35
+ * Whether an element belongs to a synthesized (URL-triggered) footer block.
36
+ *
37
+ * @param {HTMLElement} element The element to test.
38
+ *
39
+ * @return {boolean} Whether the element is inside the synthesized container.
40
+ */
41
+ const isSynthesized = element => Boolean( element.closest( SYNTHESIZED_CONTAINER_SELECTOR ) );
42
+
25
43
  /**
26
44
  * Find a checkout button form matching the requested product.
27
45
  *
28
46
  * Variation requests are never served by a button locked to a different
29
- * variation.
47
+ * variation, and a request without a variation is never served by a locked
48
+ * button at all: submitting one checks the reader out on its variation, where
49
+ * the request meant for the reader to pick one.
30
50
  *
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.
51
+ * @param {Document|HTMLElement} root The DOM root to search.
52
+ * @param {string} productId The requested product ID.
53
+ * @param {string|null} variationId Optional. The requested variation ID.
54
+ * @param {Object} options Options.
55
+ * @param {boolean|null} options.synthesized Restrict the search: false for
56
+ * page-authored buttons only, true
57
+ * for synthesized ones only, null
58
+ * (default) for both.
34
59
  *
35
60
  * @return {HTMLFormElement|null} The matching form, or null.
36
61
  */
37
- export function findCheckoutButtonForm( root, productId, variationId = null ) {
62
+ export function findCheckoutButtonForm( root, productId, variationId = null, options = {} ) {
63
+ const { synthesized = null } = options;
38
64
  const buttons = root.querySelectorAll( '.wp-block-newspack-blocks-checkout-button' );
39
65
  const hasVariation = variationId !== null && variationId !== undefined && String( variationId ) !== '';
40
66
  let match = null;
@@ -42,6 +68,9 @@ export function findCheckoutButtonForm( root, productId, variationId = null ) {
42
68
  if ( match ) {
43
69
  return;
44
70
  }
71
+ if ( synthesized !== null && isSynthesized( button ) !== synthesized ) {
72
+ return;
73
+ }
45
74
  const form = button.querySelector( 'form' );
46
75
  const data = readCheckoutData( form );
47
76
  if ( ! data ) {
@@ -53,6 +82,9 @@ export function findCheckoutButtonForm( root, productId, variationId = null ) {
53
82
  if ( hasVariation && String( data.variation_id ) !== String( variationId ) ) {
54
83
  return;
55
84
  }
85
+ if ( ! hasVariation && data.variation_id ) {
86
+ return;
87
+ }
56
88
  match = form;
57
89
  } );
58
90
  return match;
@@ -66,12 +98,15 @@ export function findCheckoutButtonForm( root, productId, variationId = null ) {
66
98
  * whichever variation the reader picks. A locked button was configured for one
67
99
  * specific variation and is only used when nothing better exists.
68
100
  *
69
- * @param {Document|HTMLElement} root The DOM root to search.
70
- * @param {string} productId The requested product ID.
101
+ * @param {Document|HTMLElement} root The DOM root to search.
102
+ * @param {string} productId The requested product ID.
103
+ * @param {Object} options Options.
104
+ * @param {boolean|null} options.synthesized See findCheckoutButtonForm().
71
105
  *
72
106
  * @return {HTMLFormElement|null} The donor form, or null.
73
107
  */
74
- function findContextDonorForm( root, productId ) {
108
+ function findContextDonorForm( root, productId, options = {} ) {
109
+ const { synthesized = null } = options;
75
110
  const buttons = root.querySelectorAll( '.wp-block-newspack-blocks-checkout-button' );
76
111
  let fallback = null;
77
112
  let unlocked = null;
@@ -79,6 +114,9 @@ function findContextDonorForm( root, productId ) {
79
114
  if ( unlocked ) {
80
115
  return;
81
116
  }
117
+ if ( synthesized !== null && isSynthesized( button ) !== synthesized ) {
118
+ return;
119
+ }
82
120
  const form = button.querySelector( 'form' );
83
121
  const data = readCheckoutData( form );
84
122
  if ( ! data || String( data.product_id ) !== String( productId ) ) {
@@ -255,34 +293,138 @@ export function copyContextFields( sourceForm, targetForm, fields = PICKER_CONTE
255
293
  } );
256
294
  }
257
295
 
296
+ /**
297
+ * Read utm params from a query string, mirroring the server-side prefix match
298
+ * (Modal_Checkout::merge_request_utm_params()).
299
+ *
300
+ * @param {string} search The query string (e.g. window.location.search).
301
+ *
302
+ * @return {Object} Map of utm param name → value. Empty values are dropped.
303
+ */
304
+ export function readUtmParams( search ) {
305
+ const params = {};
306
+ new URLSearchParams( search ).forEach( ( value, key ) => {
307
+ if ( key.startsWith( 'utm' ) && value ) {
308
+ params[ key ] = value;
309
+ }
310
+ } );
311
+ return params;
312
+ }
313
+
314
+ /**
315
+ * Append utm params to a checkout form as hidden fields.
316
+ *
317
+ * The modal checkout form GET-submits into its iframe, replacing the landing
318
+ * URL's query string, and the URL-trigger path strips the params from the
319
+ * address bar after it fires — so the form's own fields are the only carrier
320
+ * the checkout request can rely on. A field already on the form wins.
321
+ *
322
+ * @param {HTMLFormElement|null} form The form about to submit.
323
+ * @param {Object} params Map of utm param name → value.
324
+ *
325
+ * @return {void}
326
+ */
327
+ export function appendUtmFields( form, params ) {
328
+ if ( ! form || ! params ) {
329
+ return;
330
+ }
331
+ const doc = form.ownerDocument;
332
+ Object.keys( params ).forEach( name => {
333
+ // The name comes from the landing URL, so it must never be interpolated
334
+ // into a selector — a key carrying selector syntax would throw and abort
335
+ // the submission. The form's own controls collection checks it safely.
336
+ if ( ! params[ name ] || form.elements.namedItem( name ) ) {
337
+ return;
338
+ }
339
+ const input = doc.createElement( 'input' );
340
+ input.type = 'hidden';
341
+ input.name = name;
342
+ input.value = params[ name ];
343
+ form.prepend( input );
344
+ } );
345
+ }
346
+
347
+ /**
348
+ * Link params that only reach the checkout as fields on the submitted form.
349
+ *
350
+ * A promotional URL carries these for the block the server synthesizes; when a
351
+ * page-authored form wins the resolution instead, whichever of them that form
352
+ * does not carry never reaches the checkout.
353
+ *
354
+ * @type {string[]}
355
+ */
356
+ export const LINK_CONTEXT_PARAMS = [ 'coupon', 'after_success_behavior', 'after_success_url', 'after_success_button_label' ];
357
+
358
+ /**
359
+ * Name the link params the resolved form has no field for.
360
+ *
361
+ * The trigger submits the form as-is, so a param without a matching field is
362
+ * dropped — the caller warns instead of letting that happen silently.
363
+ *
364
+ * @param {HTMLFormElement|null} form The form about to be submitted.
365
+ * @param {string} search The landing page query string.
366
+ *
367
+ * @return {string[]} Names of params the form will not carry.
368
+ */
369
+ export function getDroppedLinkContext( form, search ) {
370
+ const params = new URLSearchParams( search );
371
+ return LINK_CONTEXT_PARAMS.filter( name => params.get( name ) && ! ( form && form.elements.namedItem( name ) ) );
372
+ }
373
+
258
374
  /**
259
375
  * Resolve which form a `checkout_button` URL trigger should submit.
260
376
  *
261
- * Strict order: exact button, picker, then explicit product-only fallback.
262
- * Returning null prevents silent substitution.
377
+ * Page-authored forms outrank the synthesized footer form at every step, so a
378
+ * block an editor configured — with its coupon and after-checkout context —
379
+ * always wins over the copy rendered to serve the trigger. Strict order: exact
380
+ * page button, picker fed by a page button's context, exact synthesized button,
381
+ * picker fed by the synthesized context. Returning null prevents silent
382
+ * substitution.
263
383
  *
264
384
  * @param {Document|HTMLElement} root The DOM root to search.
265
385
  * @param {string} productId The requested product ID.
266
386
  * @param {string|null} variationId Optional. The requested variation ID.
267
- * @param {Object} options Options (see selectPickerForm) plus
268
- * `allowProductOnlyFallback` (default false).
387
+ * @param {Object} options Options (see selectPickerForm).
269
388
  *
270
389
  * @return {HTMLFormElement|null} The form to submit, or null.
271
390
  */
272
391
  export function resolveCheckoutButtonForm( root, productId, variationId, options = {} ) {
273
- const { allowProductOnlyFallback = false } = options;
274
392
  const hasVariation = variationId !== null && variationId !== undefined && String( variationId ) !== '';
275
393
 
276
394
  if ( ! hasVariation ) {
277
- // No variation requested. If several buttons on the page share this
278
- // parent product, the first in DOM order is used (along with its
279
- // context); the URL gives no signal to prefer one over another.
280
- return findCheckoutButtonForm( root, productId, null );
395
+ // No variation requested. If several unlocked buttons on the page share
396
+ // this parent product, the first page-authored one in DOM order is used
397
+ // (along with its context); the synthesized form serves when the page
398
+ // carries none, including when its only buttons are locked to a
399
+ // variation — a locked button would check the reader out on that
400
+ // variation instead of opening the picker the link asks for.
401
+ return (
402
+ findCheckoutButtonForm( root, productId, null, { synthesized: false } ) ||
403
+ findCheckoutButtonForm( root, productId, null, { synthesized: true } )
404
+ );
281
405
  }
282
406
 
283
- const exact = findCheckoutButtonForm( root, productId, variationId );
284
- if ( exact ) {
285
- return exact;
407
+ const exactPage = findCheckoutButtonForm( root, productId, variationId, { synthesized: false } );
408
+ if ( exactPage ) {
409
+ return exactPage;
410
+ }
411
+
412
+ // A page button for this product exists but none is locked to the requested
413
+ // variation: let the picker serve it with that page button's context. This
414
+ // deliberately outranks a synthesized exact match — the page block's coupon
415
+ // and after-checkout settings are the editor's, and they keep applying.
416
+ const pageDonor = findContextDonorForm( root, productId, { synthesized: false } );
417
+ if ( pageDonor ) {
418
+ const pagePicker = selectPickerForm( root, productId, variationId, options );
419
+ if ( pagePicker ) {
420
+ copyContextFields( pageDonor, pagePicker );
421
+ return pagePicker;
422
+ }
423
+ }
424
+
425
+ const exactSynthesized = findCheckoutButtonForm( root, productId, variationId, { synthesized: true } );
426
+ if ( exactSynthesized ) {
427
+ return exactSynthesized;
286
428
  }
287
429
 
288
430
  const picker = selectPickerForm( root, productId, variationId, options );
@@ -298,9 +440,5 @@ export function resolveCheckoutButtonForm( root, productId, variationId, options
298
440
  return picker;
299
441
  }
300
442
 
301
- if ( allowProductOnlyFallback ) {
302
- return findCheckoutButtonForm( root, productId, null );
303
- }
304
-
305
443
  return null;
306
444
  }
@@ -9,7 +9,11 @@ import {
9
9
  resolveCheckoutButtonForm,
10
10
  copyContextFields,
11
11
  applyContextFields,
12
+ readUtmParams,
13
+ appendUtmFields,
14
+ getDroppedLinkContext,
12
15
  PICKER_CONTEXT_FIELDS,
16
+ SYNTHESIZED_CONTAINER_SELECTOR,
13
17
  } from './checkout-button-trigger';
14
18
 
15
19
  const VARIATION_MODAL_CLASS_PREFIX = 'newspack-blocks__modal-variation';
@@ -93,8 +97,16 @@ describe( 'findCheckoutButtonForm', () => {
93
97
  expect( findCheckoutButtonForm( root, '1406', '1407' ) ).toBeNull();
94
98
  } );
95
99
 
96
- it( 'matches by product_id only when no variation is requested', () => {
100
+ // A request without a variation means the reader picks one, so a button locked
101
+ // to a single variation cannot serve it: submitting that form checks the
102
+ // reader out on the locked variation instead of opening the picker.
103
+ it( 'does not match a variation-locked button when no variation is requested', () => {
97
104
  const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
105
+ expect( findCheckoutButtonForm( root, '1406', null ) ).toBeNull();
106
+ } );
107
+
108
+ it( 'matches an unlocked button by product_id when no variation is requested', () => {
109
+ const root = render( checkoutButton( { product_id: '1406', is_variable: true } ) );
98
110
  const form = root.querySelector( 'form' );
99
111
  expect( findCheckoutButtonForm( root, '1406', null ) ).toBe( form );
100
112
  } );
@@ -180,7 +192,9 @@ describe( 'resolveCheckoutButtonForm', () => {
180
192
  expect( root.querySelector( 'input[value="158"]' ).checked ).toBe( true );
181
193
  } );
182
194
 
183
- it( 'returns null for an invalid variation when product-only fallback is off (default)', () => {
195
+ // Substituting the product-only button would check the reader out on
196
+ // something other than what the link asked for.
197
+ it( 'returns null for a variation no button or picker can serve', () => {
184
198
  const root = render( checkoutButton( { product_id: '158' }, 'Checkout' ) );
185
199
  expect( resolveCheckoutButtonForm( root, '158', '160', PICKER_OPTIONS ) ).toBeNull();
186
200
  } );
@@ -190,16 +204,20 @@ describe( 'resolveCheckoutButtonForm', () => {
190
204
  expect( resolveCheckoutButtonForm( root, '158', '158', PICKER_OPTIONS ) ).toBeNull();
191
205
  } );
192
206
 
193
- it( 'returns the product-only button for an invalid variation only when fallback is explicitly enabled', () => {
194
- const root = render( checkoutButton( { product_id: '158' }, 'Checkout' ) );
195
- const buttonForm = root.querySelector( 'form' );
196
- expect( resolveCheckoutButtonForm( root, '158', '160', { ...PICKER_OPTIONS, allowProductOnlyFallback: true } ) ).toBe( buttonForm );
207
+ // A "let the reader choose" link carries the parent id only. A page button
208
+ // locked to one variation must not catch it, or the reader is checked out on
209
+ // that variation with no picker.
210
+ it( 'serves an unlocked page button for a no-variation link even when a locked one precedes it', () => {
211
+ const locked = checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true }, 'Annual' );
212
+ const unlocked = checkoutButton( { product_id: '1406', is_variable: true }, 'Subscribe' );
213
+ const root = render( locked + unlocked );
214
+ const unlockedForm = root.querySelectorAll( '.wp-block-newspack-blocks-checkout-button form' )[ 1 ];
215
+ expect( resolveCheckoutButtonForm( root, '1406', null, PICKER_OPTIONS ) ).toBe( unlockedForm );
197
216
  } );
198
217
 
199
- it( 'matches a checkout button by product_id when no variation is requested', () => {
218
+ it( 'returns null for a no-variation link when the page carries only locked buttons and nothing is synthesized', () => {
200
219
  const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
201
- const buttonForm = root.querySelector( 'form' );
202
- expect( resolveCheckoutButtonForm( root, '1406', null, PICKER_OPTIONS ) ).toBe( buttonForm );
220
+ expect( resolveCheckoutButtonForm( root, '1406', null, PICKER_OPTIONS ) ).toBeNull();
203
221
  } );
204
222
 
205
223
  it( 'returns null without throwing when nothing matches', () => {
@@ -270,6 +288,59 @@ describe( 'resolveCheckoutButtonForm', () => {
270
288
  } );
271
289
  } );
272
290
 
291
+ describe( 'resolveCheckoutButtonForm — synthesized form demotion', () => {
292
+ const synthesized = html => `<div class="${ SYNTHESIZED_CONTAINER_SELECTOR.slice( 1 ) }" style="display:none">${ html }</div>`;
293
+
294
+ it( 'prefers a page-authored button over an earlier synthesized one', () => {
295
+ // The synthesized form is rendered first to prove the preference is not
296
+ // DOM order.
297
+ const root = render( synthesized( checkoutButton( { product_id: '1406' }, 'Synth' ) ) + checkoutButton( { product_id: '1406' }, 'Page' ) );
298
+ const pageForm = root.querySelectorAll( '.wp-block-newspack-blocks-checkout-button form' )[ 1 ];
299
+ expect( resolveCheckoutButtonForm( root, '1406', null, PICKER_OPTIONS ) ).toBe( pageForm );
300
+ } );
301
+
302
+ // The page button is locked to Annual; the link asks for the parent. The
303
+ // synthesized parent button is the one that opens the picker, so it wins
304
+ // even though a page-authored button for the product exists.
305
+ it( 'serves the synthesized parent button for a no-variation link when the page offers only locked buttons', () => {
306
+ const pageLocked = checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true }, 'Annual' );
307
+ const synthParent = synthesized( checkoutButton( { product_id: '1406', is_variable: true }, 'Subscribe' ) );
308
+ const root = render( pageLocked + synthParent + variationPicker( '1406', [ '1407', '1408' ] ) );
309
+ const synthForm = root.querySelector( `${ SYNTHESIZED_CONTAINER_SELECTOR } form` );
310
+ expect( resolveCheckoutButtonForm( root, '1406', null, PICKER_OPTIONS ) ).toBe( synthForm );
311
+ } );
312
+
313
+ // The page block's coupon and after-checkout settings are the editor's, so a
314
+ // synthesized form locked to the requested variation must not outrank them.
315
+ it( 'lets the picker with page context outrank a synthesized exact variation match', () => {
316
+ const pageUnlocked = `<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout='${ JSON.stringify( {
317
+ product_id: '1406',
318
+ is_variable: true,
319
+ } ) }'><input type="hidden" name="coupon" value="PAGE20"><button type="submit">Subscribe</button></form></div>`;
320
+ const synthLocked = synthesized(
321
+ `<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout='${ JSON.stringify( {
322
+ product_id: '1406',
323
+ variation_id: '1407',
324
+ is_variable: true,
325
+ } ) }'><input type="hidden" name="coupon" value="URL5"><button type="submit">Complete</button></form></div>`
326
+ );
327
+ const root = render( pageUnlocked + synthLocked + variationPicker( '1406', [ '1407', '1408' ] ) );
328
+ const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
329
+
330
+ const result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
331
+
332
+ expect( result ).toBe( pickerForm );
333
+ expect( pickerForm.querySelector( 'input[name="coupon"]' ).value ).toBe( 'PAGE20' );
334
+ } );
335
+
336
+ it( 'serves the synthesized exact match when the page has no button for the product', () => {
337
+ const synthLocked = synthesized( checkoutButton( { product_id: '1406', variation_id: '1407', is_variable: true } ) );
338
+ const root = render( synthLocked + variationPicker( '1406', [ '1407', '1408' ] ) );
339
+ const synthForm = root.querySelector( `${ SYNTHESIZED_CONTAINER_SELECTOR } form` );
340
+ expect( resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS ) ).toBe( synthForm );
341
+ } );
342
+ } );
343
+
273
344
  describe( 'copyContextFields', () => {
274
345
  it( 'copies present source fields, skips missing ones, and does not overwrite existing target fields', () => {
275
346
  const root = render(
@@ -443,3 +514,72 @@ describe( 'PICKER_CONTEXT_FIELDS', () => {
443
514
  );
444
515
  } );
445
516
  } );
517
+
518
+ describe( 'readUtmParams', () => {
519
+ it( 'keeps utm-prefixed params with values, mirroring the server-side match', () => {
520
+ expect( readUtmParams( '?utm_source=newsletter&utm_campaign=spring&coupon=NOPE&utm_medium=' ) ).toEqual( {
521
+ utm_source: 'newsletter',
522
+ utm_campaign: 'spring',
523
+ } );
524
+ } );
525
+
526
+ it( 'returns an empty map for an empty query string', () => {
527
+ expect( readUtmParams( '' ) ).toEqual( {} );
528
+ } );
529
+ } );
530
+
531
+ describe( 'appendUtmFields', () => {
532
+ it( 'appends a hidden field per utm param', () => {
533
+ const root = render( checkoutButton( { product_id: '1406' } ) );
534
+ const form = root.querySelector( 'form' );
535
+ appendUtmFields( form, { utm_source: 'newsletter', utm_campaign: 'spring' } );
536
+ expect( form.querySelector( 'input[name="utm_source"]' ).value ).toBe( 'newsletter' );
537
+ expect( form.querySelector( 'input[name="utm_campaign"]' ).value ).toBe( 'spring' );
538
+ } );
539
+
540
+ it( 'never overwrites a field the form already carries', () => {
541
+ const root = render( checkoutButton( { product_id: '1406' } ) );
542
+ const form = root.querySelector( 'form' );
543
+ form.insertAdjacentHTML( 'beforeend', '<input type="hidden" name="utm_source" value="block-value">' );
544
+ appendUtmFields( form, { utm_source: 'url-value' } );
545
+ expect( form.querySelectorAll( 'input[name="utm_source"]' ) ).toHaveLength( 1 );
546
+ expect( form.querySelector( 'input[name="utm_source"]' ).value ).toBe( 'block-value' );
547
+ } );
548
+
549
+ it( 'does not throw on a null form or missing params', () => {
550
+ expect( () => appendUtmFields( null, { utm_source: 'x' } ) ).not.toThrow();
551
+ expect( () => appendUtmFields( document.createElement( 'form' ), null ) ).not.toThrow();
552
+ } );
553
+
554
+ // Param names come straight from the landing URL, so one carrying selector
555
+ // syntax must not break the submission.
556
+ it( 'tolerates a field name carrying selector syntax', () => {
557
+ const root = render( checkoutButton( { product_id: '1406' } ) );
558
+ const form = root.querySelector( 'form' );
559
+ expect( () => appendUtmFields( form, { 'utm"]': 'x' } ) ).not.toThrow();
560
+ expect( form.elements.namedItem( 'utm"]' ).value ).toBe( 'x' );
561
+ } );
562
+ } );
563
+
564
+ describe( 'getDroppedLinkContext', () => {
565
+ it( 'names the link params the resolved form has no field for', () => {
566
+ const root = render( checkoutButton( { product_id: '1406' } ) );
567
+ const form = root.querySelector( 'form' );
568
+ expect( getDroppedLinkContext( form, '?checkout=1&coupon=SPRING20&after_success_url=https%3A%2F%2Fsite.test%2Fwelcome' ) ).toEqual( [
569
+ 'coupon',
570
+ 'after_success_url',
571
+ ] );
572
+ } );
573
+
574
+ it( 'is empty when the form carries the fields or the URL names none', () => {
575
+ const root = render( checkoutButton( { product_id: '1406' } ) );
576
+ const form = root.querySelector( 'form' );
577
+ form.insertAdjacentHTML( 'beforeend', '<input type="hidden" name="coupon" value="PAGE20">' );
578
+ expect( getDroppedLinkContext( form, '?checkout=1&coupon=SPRING20' ) ).toEqual( [] );
579
+ expect( getDroppedLinkContext( form, '?checkout=1&product_id=1406' ) ).toEqual( [] );
580
+ } );
581
+
582
+ it( 'counts every named param as dropped without a form', () => {
583
+ expect( getDroppedLinkContext( null, '?coupon=X&after_success_behavior=custom' ) ).toEqual( [ 'coupon', 'after_success_behavior' ] );
584
+ } );
585
+ } );
@@ -24,7 +24,14 @@ import {
24
24
  getCheckoutData,
25
25
  getFormattedAmount,
26
26
  } from './utils';
27
- import { resolveCheckoutButtonForm, readCheckoutData, applyContextFields } from './checkout-button-trigger';
27
+ import {
28
+ resolveCheckoutButtonForm,
29
+ readCheckoutData,
30
+ applyContextFields,
31
+ appendUtmFields,
32
+ readUtmParams,
33
+ getDroppedLinkContext,
34
+ } from './checkout-button-trigger';
28
35
  import { resolveDonationTrigger } from './donate-trigger';
29
36
  import { TIERS_BASED_READY_EVENT } from '../shared/js/tiers-based-ready';
30
37
  import { applyCtaAttribution } from '../shared/js/cta-attribution';
@@ -285,6 +292,12 @@ domReady( () => {
285
292
  newspackBlocksModal?.is_registration_required &&
286
293
  window?.newspackReaderActivation?.openAuthModal;
287
294
 
295
+ // Snapshot the landing page's utm params: the form's own GET submission
296
+ // replaces the query string entirely, so the request the checkout sees
297
+ // carries only what rides the form. Captured once, applied to every form
298
+ // (direct, donate, picker) at submit time.
299
+ const landingUtmParams = readUtmParams( window.location.search );
300
+
288
301
  /**
289
302
  * Handle checkout form submit.
290
303
  *
@@ -305,6 +318,11 @@ domReady( () => {
305
318
  // inside a gate. Must run BEFORE getCheckoutData(), which snapshots the form.
306
319
  applyCtaAttribution( form );
307
320
 
321
+ // Carry the landing page's utm params into the checkout request itself, so
322
+ // Modal_Checkout::merge_request_utm_params() reads them from $_GET instead
323
+ // of depending on the referer.
324
+ appendUtmFields( form, landingUtmParams );
325
+
308
326
  const checkoutData = getCheckoutData( form );
309
327
 
310
328
  const isDonateBlock = checkoutData.newspack_donate;
@@ -887,6 +905,18 @@ domReady( () => {
887
905
  iframeName: IFRAME_NAME,
888
906
  } );
889
907
  if ( form ) {
908
+ // A page-authored form wins with its own context; say so when that
909
+ // drops something the link carried, instead of applying list price
910
+ // or the default thank-you behavior with no trace.
911
+ const dropped = getDroppedLinkContext( form, window.location.search );
912
+ if ( dropped.length ) {
913
+ // eslint-disable-next-line no-console
914
+ console.warn(
915
+ `Newspack modal checkout: the resolved checkout form does not carry ${ dropped.join(
916
+ ', '
917
+ ) } from the URL. The page block's own settings apply instead.`
918
+ );
919
+ }
890
920
  triggerFormSubmit( form );
891
921
  return true;
892
922
  }