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