@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.
- package/CHANGELOG.md +7 -0
- package/dist/editor-rtl.css +4 -3
- package/dist/editor.asset.php +1 -1
- package/dist/editor.css +4 -3
- package/dist/editor.js +14 -14
- package/dist/modal.asset.php +1 -1
- package/dist/modal.js +1 -1
- package/includes/class-modal-checkout.php +248 -6
- package/includes/class-newspack-blocks-api.php +8 -0
- package/includes/class-newspack-blocks.php +38 -0
- package/languages/newspack-blocks-de_DE.po +124 -119
- package/languages/newspack-blocks-es_ES.po +124 -119
- package/languages/newspack-blocks-fr_BE.po +124 -119
- package/languages/newspack-blocks-nb_NO.po +124 -119
- package/languages/newspack-blocks-pt_PT.po +124 -119
- package/languages/newspack-blocks.pot +129 -125
- package/newspack-blocks.php +2 -2
- package/package.json +2 -2
- package/src/blocks/author-list/class-wp-rest-newspack-author-list-controller.php +6 -1
- package/src/blocks/author-profile/class-wp-rest-newspack-authors-controller.php +196 -48
- package/src/blocks/author-profile/edit.js +23 -65
- package/src/blocks/author-profile/single-author.js +7 -12
- package/src/blocks/author-profile/social-links.js +25 -0
- package/src/blocks/author-profile/social-links.test.js +42 -0
- package/src/blocks/checkout-button/view.php +4 -2
- package/src/blocks/homepage-articles/templates/article.php +1 -18
- package/src/modal-checkout/checkout-button-trigger.js +162 -24
- package/src/modal-checkout/checkout-button-trigger.test.js +149 -9
- package/src/modal-checkout/modal.js +31 -1
- package/src/shared/js/author-fetch.js +217 -0
- package/src/shared/js/author-fetch.test.js +230 -0
- 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
|
|
32
|
-
* @param {string} productId
|
|
33
|
-
* @param {string|null} variationId
|
|
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
|
|
70
|
-
* @param {string} productId
|
|
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
|
-
*
|
|
262
|
-
*
|
|
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)
|
|
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
|
|
278
|
-
// parent product, the first in DOM order is used
|
|
279
|
-
// context); the
|
|
280
|
-
|
|
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
|
|
284
|
-
if (
|
|
285
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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( '
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|