@automattic/newspack-blocks 4.28.0 → 4.29.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/blocks/checkout-button/block.json +3 -0
- package/dist/editor-rtl.css +16 -13
- package/dist/editor.asset.php +1 -1
- package/dist/editor.css +16 -13
- package/dist/editor.js +30 -25
- 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/includes/class-modal-checkout.php +131 -168
- package/includes/class-newspack-blocks.php +1 -0
- package/includes/modal-checkout/class-checkout-data.php +23 -0
- package/newspack-blocks.php +2 -2
- package/package.json +5 -5
- package/src/blocks/checkout-button/block.json +3 -0
- package/src/blocks/checkout-button/coupon-control.js +150 -0
- package/src/blocks/checkout-button/edit.js +35 -2
- package/src/blocks/checkout-button/edit.scss +3 -2
- package/src/blocks/checkout-button/view.php +41 -2
- package/src/blocks/donate/frontend/class-newspack-blocks-donate-renderer-frequency-based.php +2 -2
- package/src/blocks/donate/frontend/class-newspack-blocks-donate-renderer-tiers-based.php +1 -1
- package/src/modal-checkout/checkout-button-trigger.js +91 -6
- package/src/modal-checkout/checkout-button-trigger.test.js +143 -1
- package/src/modal-checkout/checkout.scss +1 -53
- package/src/modal-checkout/index.js +7 -89
- package/src/modal-checkout/modal.js +5 -27
- package/src/modal-checkout/templates/form-checkout.php +10 -20
- package/src/modal-checkout/templates/form-coupon.php +9 -0
- package/src/modal-checkout/templates/thankyou.php +1 -1
- package/vendor/composer/installed.php +2 -2
- package/webpack.config.js +6 -12
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { debounce } from 'lodash';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WordPress dependencies
|
|
8
|
+
*/
|
|
9
|
+
import { __ } from '@wordpress/i18n';
|
|
10
|
+
import { BaseControl, TextControl, FormTokenField, Button, Spinner } from '@wordpress/components';
|
|
11
|
+
import { useState, useEffect, useMemo } from '@wordpress/element';
|
|
12
|
+
import apiFetch from '@wordpress/api-fetch';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Searchable WooCommerce coupon picker for the Checkout Button block.
|
|
16
|
+
*
|
|
17
|
+
* Type to search existing coupons; selecting one stores its code in the block's
|
|
18
|
+
* `coupon` attribute, clearing it removes the coupon. Mirrors the product picker.
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} props
|
|
21
|
+
* @param {string} props.value Currently selected coupon code.
|
|
22
|
+
* @param {Function} props.onChange Called with the selected coupon code (or '').
|
|
23
|
+
* @param {boolean} props.disabled Whether a coupon can be set. A stored code stays
|
|
24
|
+
* visible and removable so it can't get stranded.
|
|
25
|
+
* @return {JSX.Element} The control.
|
|
26
|
+
*/
|
|
27
|
+
export default function CouponControl( { value, onChange, disabled = false } ) {
|
|
28
|
+
const [ inFlight, setInFlight ] = useState( false );
|
|
29
|
+
const [ suggestions, setSuggestions ] = useState( [] );
|
|
30
|
+
const [ selected, setSelected ] = useState( false );
|
|
31
|
+
const [ isChanging, setIsChanging ] = useState( false );
|
|
32
|
+
|
|
33
|
+
function fetchSuggestions( search ) {
|
|
34
|
+
setInFlight( true );
|
|
35
|
+
return apiFetch( {
|
|
36
|
+
path: `/wc/v3/coupons?search=${ encodeURIComponent( search ) }`,
|
|
37
|
+
} )
|
|
38
|
+
.then( coupons => {
|
|
39
|
+
setSuggestions( coupons.map( coupon => coupon.code ) );
|
|
40
|
+
} )
|
|
41
|
+
.finally( () => setInFlight( false ) );
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fetchSaved() {
|
|
45
|
+
setInFlight( true );
|
|
46
|
+
return apiFetch( {
|
|
47
|
+
path: `/wc/v3/coupons?code=${ encodeURIComponent( value ) }`,
|
|
48
|
+
} )
|
|
49
|
+
.then( coupons => {
|
|
50
|
+
// Keep the stored value even if the lookup returns nothing.
|
|
51
|
+
setSelected( coupons.length ? coupons[ 0 ].code : value );
|
|
52
|
+
} )
|
|
53
|
+
.catch( () => setSelected( value ) )
|
|
54
|
+
.finally( () => setInFlight( false ) );
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
useEffect( () => {
|
|
58
|
+
setIsChanging( false );
|
|
59
|
+
if ( value ) {
|
|
60
|
+
fetchSaved();
|
|
61
|
+
} else {
|
|
62
|
+
setSelected( false );
|
|
63
|
+
}
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, [ value ] );
|
|
66
|
+
|
|
67
|
+
function onTokenChange( tokens ) {
|
|
68
|
+
setIsChanging( false );
|
|
69
|
+
onChange( tokens[ 0 ] || '' );
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const debouncedFetch = useMemo(
|
|
73
|
+
() => debounce( fetchSuggestions, 200 ),
|
|
74
|
+
// fetchSuggestions only closes over stable state setters, so an empty
|
|
75
|
+
// dependency array is intentional.
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
[]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Cancel a pending debounced fetch if the control unmounts, so it can't
|
|
81
|
+
// fire and update state after the component is gone.
|
|
82
|
+
useEffect( () => () => debouncedFetch.cancel(), [ debouncedFetch ] );
|
|
83
|
+
|
|
84
|
+
function handleInputChange( search ) {
|
|
85
|
+
if ( search.length > 2 ) {
|
|
86
|
+
setInFlight( true );
|
|
87
|
+
debouncedFetch( search );
|
|
88
|
+
} else {
|
|
89
|
+
// Cancel any fetch queued for a longer previous query so it can't
|
|
90
|
+
// fire after the input was shortened or cleared.
|
|
91
|
+
debouncedFetch.cancel();
|
|
92
|
+
setInFlight( false );
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if ( value && ! selected && inFlight ) {
|
|
97
|
+
return <Spinner />;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ( selected && ! isChanging ) {
|
|
101
|
+
return (
|
|
102
|
+
<div className="newspack-checkout-button__coupon-field">
|
|
103
|
+
<BaseControl label={ __( 'Coupon', 'newspack-blocks' ) } id="newspack-checkout-button-coupon">
|
|
104
|
+
<TextControl value={ selected } __next40pxDefaultSize disabled />
|
|
105
|
+
{ ! disabled && (
|
|
106
|
+
<>
|
|
107
|
+
<Button variant="link" onClick={ () => setIsChanging( true ) }>
|
|
108
|
+
{ __( 'Edit', 'newspack-blocks' ) }
|
|
109
|
+
</Button>{ ' ' }
|
|
110
|
+
</>
|
|
111
|
+
) }
|
|
112
|
+
<Button
|
|
113
|
+
variant="link"
|
|
114
|
+
isDestructive
|
|
115
|
+
onClick={ () => {
|
|
116
|
+
setSelected( false );
|
|
117
|
+
onChange( '' );
|
|
118
|
+
} }
|
|
119
|
+
>
|
|
120
|
+
{ __( 'Remove', 'newspack-blocks' ) }
|
|
121
|
+
</Button>
|
|
122
|
+
</BaseControl>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="newspack-checkout-button__coupon-field">
|
|
129
|
+
<FormTokenField
|
|
130
|
+
placeholder={
|
|
131
|
+
disabled ? __( 'Coupons are not available', 'newspack-blocks' ) : __( 'Type to search for a coupon…', 'newspack-blocks' )
|
|
132
|
+
}
|
|
133
|
+
label={ __( 'Coupon', 'newspack-blocks' ) }
|
|
134
|
+
maxLength={ 1 }
|
|
135
|
+
value={ [] }
|
|
136
|
+
onChange={ onTokenChange }
|
|
137
|
+
onInputChange={ handleInputChange }
|
|
138
|
+
suggestions={ disabled ? [] : suggestions }
|
|
139
|
+
disabled={ disabled }
|
|
140
|
+
__next40pxDefaultSize
|
|
141
|
+
/>
|
|
142
|
+
{ inFlight && <Spinner /> }
|
|
143
|
+
{ selected && (
|
|
144
|
+
<Button variant="link" onClick={ () => setIsChanging( false ) }>
|
|
145
|
+
{ __( 'Cancel', 'newspack-blocks' ) }
|
|
146
|
+
</Button>
|
|
147
|
+
) }
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
SelectControl,
|
|
28
28
|
FormTokenField,
|
|
29
29
|
Button,
|
|
30
|
+
Notice,
|
|
30
31
|
Spinner,
|
|
31
32
|
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
|
|
32
33
|
__experimentalToggleGroupControl as ToggleGroupControl,
|
|
@@ -41,6 +42,7 @@ import apiFetch from '@wordpress/api-fetch';
|
|
|
41
42
|
*/
|
|
42
43
|
import './edit.scss';
|
|
43
44
|
import RedirectAfterSuccess from '../../components/redirect-after-success';
|
|
45
|
+
import CouponControl from './coupon-control';
|
|
44
46
|
|
|
45
47
|
function getVariationName( variation ) {
|
|
46
48
|
const attributes = [];
|
|
@@ -137,6 +139,10 @@ function ProductControl( props ) {
|
|
|
137
139
|
fetchSaved();
|
|
138
140
|
} else {
|
|
139
141
|
setSelected( false );
|
|
142
|
+
// Clear the parent's copy too, otherwise product-derived state (e.g.
|
|
143
|
+
// whether this is a donation) keeps describing the product that was
|
|
144
|
+
// just removed.
|
|
145
|
+
props.onProduct( {} );
|
|
140
146
|
}
|
|
141
147
|
}, [ props.value ] );
|
|
142
148
|
function onChange( tokens ) {
|
|
@@ -158,7 +164,7 @@ function ProductControl( props ) {
|
|
|
158
164
|
return <Spinner />;
|
|
159
165
|
}
|
|
160
166
|
return (
|
|
161
|
-
<div className="newspack-checkout-button__product-field"
|
|
167
|
+
<div className="newspack-checkout-button__product-field">
|
|
162
168
|
{ selected && ! isChanging ? (
|
|
163
169
|
<>
|
|
164
170
|
<BaseControl label={ __( 'Product', 'newspack-blocks' ) } id="selected-product-control">
|
|
@@ -201,12 +207,17 @@ function ProductControl( props ) {
|
|
|
201
207
|
|
|
202
208
|
function CheckoutButtonEdit( props ) {
|
|
203
209
|
const { attributes, setAttributes, className } = props;
|
|
204
|
-
const { placeholder, style, text, product, price, variation, width } = attributes;
|
|
210
|
+
const { placeholder, style, text, product, price, variation, width, coupon } = attributes;
|
|
205
211
|
|
|
206
212
|
const [ productData, setProductData ] = useState( {} );
|
|
207
213
|
const [ variations, setVariations ] = useState( [] );
|
|
208
214
|
const [ nyp, setNYP ] = useState( false );
|
|
209
215
|
|
|
216
|
+
// Resolved server-side, since a donation is not always identifiable from the
|
|
217
|
+
// product meta. Variations inherit it from their parent, which is the product
|
|
218
|
+
// fetched here, so the selected variation needs no separate check.
|
|
219
|
+
const isDonation = !! productData?.newspack_is_donation;
|
|
220
|
+
|
|
210
221
|
function handleProduct( data ) {
|
|
211
222
|
setProductData( data );
|
|
212
223
|
// Handle product variation data.
|
|
@@ -337,6 +348,28 @@ function CheckoutButtonEdit( props ) {
|
|
|
337
348
|
</>
|
|
338
349
|
) }
|
|
339
350
|
</ProductControl>
|
|
351
|
+
{ newspack_blocks_data?.coupons_enabled && (
|
|
352
|
+
<>
|
|
353
|
+
{ /*
|
|
354
|
+
* Coupons are not supported on donation products. The control is
|
|
355
|
+
* disabled rather than hidden so a code stored before the product
|
|
356
|
+
* was swapped stays visible — and removable, since the block emits
|
|
357
|
+
* it regardless of donation status.
|
|
358
|
+
*/ }
|
|
359
|
+
{ isDonation && (
|
|
360
|
+
<Notice status={ coupon ? 'warning' : 'info' } isDismissible={ false }>
|
|
361
|
+
{ coupon
|
|
362
|
+
? sprintf(
|
|
363
|
+
// translators: %s: the coupon code stored on the block.
|
|
364
|
+
__( 'Donation products do not support coupons. "%s" will not be applied.', 'newspack-blocks' ),
|
|
365
|
+
coupon
|
|
366
|
+
)
|
|
367
|
+
: __( 'Donation products do not support coupons.', 'newspack-blocks' ) }
|
|
368
|
+
</Notice>
|
|
369
|
+
) }
|
|
370
|
+
<CouponControl value={ coupon } onChange={ value => setAttributes( { coupon: value } ) } disabled={ isDonation } />
|
|
371
|
+
</>
|
|
372
|
+
) }
|
|
340
373
|
<WidthControl selectedWidth={ width } setAttributes={ setAttributes } />
|
|
341
374
|
</PanelBody>
|
|
342
375
|
<PanelBody title={ __( 'After purchase', 'newspack-blocks' ) }>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
@use "newspack-colors" as np-colors;
|
|
2
2
|
|
|
3
3
|
.newspack-checkout-button {
|
|
4
|
-
&__product-field
|
|
4
|
+
&__product-field,
|
|
5
|
+
&__coupon-field {
|
|
6
|
+
margin-bottom: 16px;
|
|
5
7
|
&__error {
|
|
6
8
|
color: np-colors.$error-500;
|
|
7
9
|
font-size: 0.9em;
|
|
@@ -64,4 +66,3 @@
|
|
|
64
66
|
width: auto;
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
|
-
|
|
@@ -24,6 +24,38 @@ function register_block() {
|
|
|
24
24
|
}
|
|
25
25
|
add_action( 'init', __NAMESPACE__ . '\\register_block' );
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Expose whether a product is a donation to the products REST response.
|
|
29
|
+
*
|
|
30
|
+
* The block editor needs this to tell the publisher that an attached coupon
|
|
31
|
+
* will not be applied: Newspack disables coupons for any cart containing a
|
|
32
|
+
* donation (see Newspack\Donations::disable_coupons()). It cannot be derived
|
|
33
|
+
* from the product meta alone, because the legacy donation product and its
|
|
34
|
+
* children are matched by ID rather than by the donation flag, so the answer
|
|
35
|
+
* is delegated to Donations::is_donation_product().
|
|
36
|
+
*/
|
|
37
|
+
function register_donation_rest_field() {
|
|
38
|
+
if ( ! class_exists( '\Newspack\Donations' ) || ! method_exists( '\Newspack\Donations', 'is_donation_product' ) ) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
register_rest_field(
|
|
42
|
+
'product',
|
|
43
|
+
'newspack_is_donation',
|
|
44
|
+
[
|
|
45
|
+
'get_callback' => function ( $product ) {
|
|
46
|
+
return (bool) \Newspack\Donations::is_donation_product( $product['id'] );
|
|
47
|
+
},
|
|
48
|
+
'schema' => [
|
|
49
|
+
'description' => __( 'Whether the product is treated as a donation by Newspack.', 'newspack-blocks' ),
|
|
50
|
+
'type' => 'boolean',
|
|
51
|
+
'context' => [ 'view', 'edit' ],
|
|
52
|
+
'readonly' => true,
|
|
53
|
+
],
|
|
54
|
+
]
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
add_action( 'rest_api_init', __NAMESPACE__ . '\\register_donation_rest_field' );
|
|
58
|
+
|
|
27
59
|
/**
|
|
28
60
|
* Render the block.
|
|
29
61
|
*
|
|
@@ -62,6 +94,7 @@ function render_callback( $attributes ) {
|
|
|
62
94
|
$after_success_behavior = $attributes['afterSuccessBehavior'] ?? '';
|
|
63
95
|
$after_success_button_label = $attributes['afterSuccessButtonLabel'] ?? '';
|
|
64
96
|
$after_success_url = $attributes['afterSuccessURL'] ?? '';
|
|
97
|
+
$coupon = $attributes['coupon'] ?? '';
|
|
65
98
|
$is_variable = $attributes['is_variable'];
|
|
66
99
|
|
|
67
100
|
if ( $is_variable && $variation_id ) {
|
|
@@ -114,6 +147,12 @@ function render_callback( $attributes ) {
|
|
|
114
147
|
$hidden_fields .= $after_success_button_label ? '<input type="hidden" name="after_success_button_label" value="' . esc_attr( $after_success_button_label ) . '" />' : '';
|
|
115
148
|
$hidden_fields .= $after_success_url ? '<input type="hidden" name="after_success_url" value="' . esc_attr( $after_success_url ) . '" />' : '';
|
|
116
149
|
}
|
|
150
|
+
// Always emit the coupon field (not gated on the gateway check): it is
|
|
151
|
+
// applied server-side for both the modal and the redirect checkout flows.
|
|
152
|
+
// Strict check so a coupon code of "0" is still emitted.
|
|
153
|
+
if ( '' !== $coupon ) {
|
|
154
|
+
$hidden_fields .= '<input type="hidden" name="coupon" value="' . esc_attr( $coupon ) . '" />';
|
|
155
|
+
}
|
|
117
156
|
|
|
118
157
|
ob_start();
|
|
119
158
|
/**
|
|
@@ -153,8 +192,8 @@ function render_callback( $attributes ) {
|
|
|
153
192
|
$checkout_data = Checkout_Data::get_checkout_data( $product );
|
|
154
193
|
|
|
155
194
|
$form = sprintf(
|
|
156
|
-
'<form
|
|
157
|
-
|
|
195
|
+
'<form %1$s>%2$s %3$s</form>',
|
|
196
|
+
Checkout_Data::data_checkout_attr( $checkout_data ),
|
|
158
197
|
$button,
|
|
159
198
|
$hidden_fields
|
|
160
199
|
);
|
package/src/blocks/donate/frontend/class-newspack-blocks-donate-renderer-frequency-based.php
CHANGED
|
@@ -177,7 +177,7 @@ class Newspack_Blocks_Donate_Renderer_Frequency_Based extends Newspack_Blocks_Do
|
|
|
177
177
|
id='tab-panel-<?php echo esc_attr( $frequency_slug . '-' . $uid ); ?>'
|
|
178
178
|
role='tabpanel'
|
|
179
179
|
aria-labelledby='tab-newspack-donate-<?php echo esc_attr( $frequency_slug . '-' . $uid ); ?>'
|
|
180
|
-
|
|
180
|
+
<?php Checkout_Data::print_data_checkout_attr( Checkout_Data::get_donation_checkout_data( $frequency_slug, $amount ) ); ?>
|
|
181
181
|
<?php ( $frequency_slug === $configuration['defaultFrequency'] ? 'tabindex="0"' : '' ); ?>
|
|
182
182
|
>
|
|
183
183
|
<?php echo self::render_frequency_selection( $frequency_slug, $frequency_name, $uid, $configuration ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
|
@@ -262,7 +262,7 @@ class Newspack_Blocks_Donate_Renderer_Frequency_Based extends Newspack_Blocks_Do
|
|
|
262
262
|
?>
|
|
263
263
|
<div
|
|
264
264
|
class='wp-block-newspack-blocks-donate__tier donation-tier__<?php echo esc_attr( $frequency_slug ); ?>'
|
|
265
|
-
|
|
265
|
+
<?php Checkout_Data::print_data_checkout_attr( Checkout_Data::get_donation_checkout_data( $frequency_slug, $amount ) ); ?>
|
|
266
266
|
>
|
|
267
267
|
<?php if ( 3 === $index ) : // The "other" tier. ?>
|
|
268
268
|
<input
|
|
@@ -64,7 +64,7 @@ class Newspack_Blocks_Donate_Renderer_Tiers_Based extends Newspack_Blocks_Donate
|
|
|
64
64
|
data-frequency-slug="<?php echo esc_attr( $frequency_slug ); ?>"
|
|
65
65
|
data-amount="<?php echo esc_attr( $amount ); ?>"
|
|
66
66
|
data-tier-index="<?php echo esc_attr( $index ); ?>"
|
|
67
|
-
|
|
67
|
+
<?php Checkout_Data::print_data_checkout_attr( Checkout_Data::get_donation_checkout_data( $frequency_slug, $amount ) ); ?>
|
|
68
68
|
>
|
|
69
69
|
<?php echo wp_kses_post( Newspack_Blocks::get_formatted_amount( $amount, $frequency_slug ) ); ?>
|
|
70
70
|
</span>
|
|
@@ -58,6 +58,41 @@ export function findCheckoutButtonForm( root, productId, variationId = null ) {
|
|
|
58
58
|
return match;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Find the button whose context a picker submission should inherit.
|
|
63
|
+
*
|
|
64
|
+
* Prefers a button that is not locked to a single variation: it stands for the
|
|
65
|
+
* whole product, so its context — including any attached coupon — applies to
|
|
66
|
+
* whichever variation the reader picks. A locked button was configured for one
|
|
67
|
+
* specific variation and is only used when nothing better exists.
|
|
68
|
+
*
|
|
69
|
+
* @param {Document|HTMLElement} root The DOM root to search.
|
|
70
|
+
* @param {string} productId The requested product ID.
|
|
71
|
+
*
|
|
72
|
+
* @return {HTMLFormElement|null} The donor form, or null.
|
|
73
|
+
*/
|
|
74
|
+
function findContextDonorForm( root, productId ) {
|
|
75
|
+
const buttons = root.querySelectorAll( '.wp-block-newspack-blocks-checkout-button' );
|
|
76
|
+
let fallback = null;
|
|
77
|
+
let unlocked = null;
|
|
78
|
+
buttons.forEach( button => {
|
|
79
|
+
if ( unlocked ) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const form = button.querySelector( 'form' );
|
|
83
|
+
const data = readCheckoutData( form );
|
|
84
|
+
if ( ! data || String( data.product_id ) !== String( productId ) ) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if ( ! data.variation_id ) {
|
|
88
|
+
unlocked = form;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
fallback = fallback || form;
|
|
92
|
+
} );
|
|
93
|
+
return unlocked || fallback;
|
|
94
|
+
}
|
|
95
|
+
|
|
61
96
|
/**
|
|
62
97
|
* Select the requested variation in a product picker.
|
|
63
98
|
* Picker forms use the selected radio value instead of `data-checkout`.
|
|
@@ -98,6 +133,12 @@ export function selectPickerForm( root, productId, variationId, options = {} ) {
|
|
|
98
133
|
/**
|
|
99
134
|
* Hidden fields copied from a source checkout button to a picker submission.
|
|
100
135
|
*
|
|
136
|
+
* The picker form is rendered once per variable product in the footer and is
|
|
137
|
+
* submitted instead of the button's own form, so anything the block attached to
|
|
138
|
+
* that button — after-success behavior, attribution, the auto-applied coupon —
|
|
139
|
+
* is lost unless it is listed here. Shared with modal.js so the click path and
|
|
140
|
+
* the URL-trigger path carry the same context.
|
|
141
|
+
*
|
|
101
142
|
* @type {string[]}
|
|
102
143
|
*/
|
|
103
144
|
export const PICKER_CONTEXT_FIELDS = [
|
|
@@ -107,12 +148,54 @@ export const PICKER_CONTEXT_FIELDS = [
|
|
|
107
148
|
'gate_post_id',
|
|
108
149
|
'newspack_popup_id',
|
|
109
150
|
'prompt_title',
|
|
151
|
+
'coupon',
|
|
110
152
|
];
|
|
111
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Stamp context fields onto a picker form from the button that opened it.
|
|
156
|
+
*
|
|
157
|
+
* Authoritative, unlike copyContextFields(): the picker is rendered once per
|
|
158
|
+
* parent product and shared by every button targeting it, and nothing clears it
|
|
159
|
+
* when the modal closes, so each open must overwrite whatever the previous one
|
|
160
|
+
* left behind. An absent value removes the field rather than preserving a stale
|
|
161
|
+
* one — for `coupon` that is the difference between the reader's discount and
|
|
162
|
+
* a discount attached to a different button.
|
|
163
|
+
*
|
|
164
|
+
* @param {HTMLFormElement|null} targetForm Picker form to stamp.
|
|
165
|
+
* @param {Object|null} data Checkout data read from the clicked button's form.
|
|
166
|
+
* @param {string[]} fields Field names to stamp.
|
|
167
|
+
*
|
|
168
|
+
* @return {void}
|
|
169
|
+
*/
|
|
170
|
+
export function applyContextFields( targetForm, data, fields = PICKER_CONTEXT_FIELDS ) {
|
|
171
|
+
if ( ! targetForm || ! data ) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const doc = targetForm.ownerDocument;
|
|
175
|
+
fields.forEach( name => {
|
|
176
|
+
// Drop whatever a previous open left behind before writing this one's value.
|
|
177
|
+
targetForm.querySelectorAll( `input[name="${ name }"]` ).forEach( input => input.remove() );
|
|
178
|
+
const raw = data[ name ];
|
|
179
|
+
const value = raw === undefined || raw === null ? '' : String( raw );
|
|
180
|
+
if ( ! value ) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const input = doc.createElement( 'input' );
|
|
184
|
+
input.type = 'hidden';
|
|
185
|
+
input.name = name;
|
|
186
|
+
input.value = value;
|
|
187
|
+
targetForm.prepend( input );
|
|
188
|
+
} );
|
|
189
|
+
}
|
|
190
|
+
|
|
112
191
|
/**
|
|
113
192
|
* Copy context fields. Target values are preserved, empty source values are
|
|
114
193
|
* skipped, and null forms are ignored.
|
|
115
194
|
*
|
|
195
|
+
* Used by the URL-trigger path, which runs once on load before any click. The
|
|
196
|
+
* click path uses applyContextFields() instead and overwrites anything left
|
|
197
|
+
* here, so preserving existing values cannot strand a stale coupon.
|
|
198
|
+
*
|
|
116
199
|
* @param {HTMLFormElement|null} sourceForm Checkout button form to read from.
|
|
117
200
|
* @param {HTMLFormElement|null} targetForm Picker form to copy into.
|
|
118
201
|
* @param {string[]} fields Field names to copy.
|
|
@@ -173,12 +256,14 @@ export function resolveCheckoutButtonForm( root, productId, variationId, options
|
|
|
173
256
|
|
|
174
257
|
const picker = selectPickerForm( root, productId, variationId, options );
|
|
175
258
|
if ( picker ) {
|
|
176
|
-
// The
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
259
|
+
// The picker is only reached because no button matches the requested
|
|
260
|
+
// variation, so the context has to come from some other button for this
|
|
261
|
+
// product. Since PICKER_CONTEXT_FIELDS now carries `coupon`, that choice
|
|
262
|
+
// decides a discount: a button locked to a variation was configured for
|
|
263
|
+
// that variation, so its coupon should not follow the reader to a
|
|
264
|
+
// different one. Prefer an unlocked button, which stands for the whole
|
|
265
|
+
// product, and fall back to DOM order only when there isn't one.
|
|
266
|
+
copyContextFields( findContextDonorForm( root, productId ), picker );
|
|
182
267
|
return picker;
|
|
183
268
|
}
|
|
184
269
|
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
* Tests for the checkout-button URL trigger resolution helpers.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
readCheckoutData,
|
|
7
|
+
findCheckoutButtonForm,
|
|
8
|
+
selectPickerForm,
|
|
9
|
+
resolveCheckoutButtonForm,
|
|
10
|
+
copyContextFields,
|
|
11
|
+
applyContextFields,
|
|
12
|
+
PICKER_CONTEXT_FIELDS,
|
|
13
|
+
} from './checkout-button-trigger';
|
|
6
14
|
|
|
7
15
|
const VARIATION_MODAL_CLASS_PREFIX = 'newspack-blocks__modal-variation';
|
|
8
16
|
const IFRAME_NAME = 'newspack_modal_checkout_iframe';
|
|
@@ -220,6 +228,46 @@ describe( 'resolveCheckoutButtonForm', () => {
|
|
|
220
228
|
expect( pickerForm.querySelector( 'input[name="after_success_button_label"]' ).value ).toBe( 'Thanks!' );
|
|
221
229
|
expect( pickerForm.querySelector( 'input[name="after_success_url"]' ).value ).toBe( '/welcome/' );
|
|
222
230
|
} );
|
|
231
|
+
|
|
232
|
+
// A button locked to one variation was configured for that variation, so its
|
|
233
|
+
// coupon should not follow the reader to a different one when an unlocked
|
|
234
|
+
// button for the same product is available to supply the context instead.
|
|
235
|
+
it( 'prefers an unlocked button over a variation-locked one for the picker context', () => {
|
|
236
|
+
const locked = `<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout='${ JSON.stringify( {
|
|
237
|
+
product_id: '1406',
|
|
238
|
+
variation_id: '1408',
|
|
239
|
+
is_variable: true,
|
|
240
|
+
} ) }'><input type="hidden" name="coupon" value="LOCKED20"><button type="submit">Annual</button></form></div>`;
|
|
241
|
+
const unlocked = `<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout='${ JSON.stringify( {
|
|
242
|
+
product_id: '1406',
|
|
243
|
+
is_variable: true,
|
|
244
|
+
} ) }'><input type="hidden" name="coupon" value="ANYTIER5"><button type="submit">Subscribe</button></form></div>`;
|
|
245
|
+
const root = render( locked + unlocked + variationPicker( '1406', [ '1407', '1408', '1409' ] ) );
|
|
246
|
+
const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
|
|
247
|
+
|
|
248
|
+
const result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
|
|
249
|
+
|
|
250
|
+
expect( result ).toBe( pickerForm );
|
|
251
|
+
expect( pickerForm.querySelector( 'input[name="coupon"]' ).value ).toBe( 'ANYTIER5' );
|
|
252
|
+
} );
|
|
253
|
+
|
|
254
|
+
// With nothing but locked buttons there is no correct donor, so the first in
|
|
255
|
+
// DOM order supplies the context — the other half of findContextDonorForm.
|
|
256
|
+
it( 'falls back to the first locked button when no unlocked one exists', () => {
|
|
257
|
+
const locked = ( variationId, coupon ) =>
|
|
258
|
+
`<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout='${ JSON.stringify( {
|
|
259
|
+
product_id: '1406',
|
|
260
|
+
variation_id: variationId,
|
|
261
|
+
is_variable: true,
|
|
262
|
+
} ) }'><input type="hidden" name="coupon" value="${ coupon }"><button type="submit">Buy</button></form></div>`;
|
|
263
|
+
const root = render( locked( '1408', 'FIRST20' ) + locked( '1409', 'SECOND10' ) + variationPicker( '1406', [ '1407', '1408', '1409' ] ) );
|
|
264
|
+
const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
|
|
265
|
+
|
|
266
|
+
const result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
|
|
267
|
+
|
|
268
|
+
expect( result ).toBe( pickerForm );
|
|
269
|
+
expect( pickerForm.querySelector( 'input[name="coupon"]' ).value ).toBe( 'FIRST20' );
|
|
270
|
+
} );
|
|
223
271
|
} );
|
|
224
272
|
|
|
225
273
|
describe( 'copyContextFields', () => {
|
|
@@ -263,4 +311,98 @@ describe( 'copyContextFields', () => {
|
|
|
263
311
|
expect( () => copyContextFields( null, target ) ).not.toThrow();
|
|
264
312
|
expect( () => copyContextFields( target, null ) ).not.toThrow();
|
|
265
313
|
} );
|
|
314
|
+
|
|
315
|
+
// The picker form replaces the button's own form, so a coupon attached to the
|
|
316
|
+
// Checkout Button block is only auto-applied if it is carried across.
|
|
317
|
+
it( 'copies the auto-applied coupon to the picker form', () => {
|
|
318
|
+
const root = render( '<form id="src"><input type="hidden" name="coupon" value="MEMBER10"></form><form id="dst"></form>' );
|
|
319
|
+
|
|
320
|
+
copyContextFields( root.querySelector( '#src' ), root.querySelector( '#dst' ) );
|
|
321
|
+
|
|
322
|
+
expect( root.querySelector( '#dst input[name="coupon"]' ).value ).toBe( 'MEMBER10' );
|
|
323
|
+
} );
|
|
324
|
+
} );
|
|
325
|
+
|
|
326
|
+
describe( 'applyContextFields', () => {
|
|
327
|
+
// The picker is rendered once per parent product and shared by every button
|
|
328
|
+
// targeting it, and nothing clears it when the modal closes. These two cases
|
|
329
|
+
// are what the click path gets wrong if the stamp is not authoritative.
|
|
330
|
+
const picker = () => render( '<form id="picker"></form>' ).querySelector( '#picker' );
|
|
331
|
+
const couponValue = form => {
|
|
332
|
+
const input = form.querySelector( 'input[name="coupon"]' );
|
|
333
|
+
return input ? input.value : null;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
it( 'replaces a coupon left behind by a previously clicked button', () => {
|
|
337
|
+
const form = picker();
|
|
338
|
+
|
|
339
|
+
applyContextFields( form, { coupon: 'SAVE20' } );
|
|
340
|
+
applyContextFields( form, { coupon: 'MEMBER10' } );
|
|
341
|
+
|
|
342
|
+
expect( couponValue( form ) ).toBe( 'MEMBER10' );
|
|
343
|
+
expect( form.querySelectorAll( 'input[name="coupon"]' ) ).toHaveLength( 1 );
|
|
344
|
+
} );
|
|
345
|
+
|
|
346
|
+
it( 'clears the coupon when the clicked button has none', () => {
|
|
347
|
+
const form = picker();
|
|
348
|
+
|
|
349
|
+
applyContextFields( form, { coupon: 'SAVE20' } );
|
|
350
|
+
applyContextFields( form, {} );
|
|
351
|
+
|
|
352
|
+
expect( couponValue( form ) ).toBeNull();
|
|
353
|
+
} );
|
|
354
|
+
|
|
355
|
+
it( 'does not leave an empty coupon input that would block a later button', () => {
|
|
356
|
+
const form = picker();
|
|
357
|
+
|
|
358
|
+
applyContextFields( form, {} );
|
|
359
|
+
applyContextFields( form, { coupon: 'SAVE20' } );
|
|
360
|
+
|
|
361
|
+
expect( couponValue( form ) ).toBe( 'SAVE20' );
|
|
362
|
+
} );
|
|
363
|
+
|
|
364
|
+
it( 'stamps the after-success and attribution context', () => {
|
|
365
|
+
const form = picker();
|
|
366
|
+
|
|
367
|
+
applyContextFields( form, { after_success_behavior: 'custom', gate_post_id: 12, prompt_title: '' } );
|
|
368
|
+
|
|
369
|
+
expect( form.querySelector( 'input[name="after_success_behavior"]' ).value ).toBe( 'custom' );
|
|
370
|
+
expect( form.querySelector( 'input[name="gate_post_id"]' ).value ).toBe( '12' );
|
|
371
|
+
expect( form.querySelector( 'input[name="prompt_title"]' ) ).toBeNull();
|
|
372
|
+
} );
|
|
373
|
+
|
|
374
|
+
it( 'leaves the picker’s own fields alone', () => {
|
|
375
|
+
const form = render(
|
|
376
|
+
'<form id="picker"><input type="hidden" name="newspack_checkout" value="1"><input type="radio" name="product_id" value="9"></form>'
|
|
377
|
+
).querySelector( '#picker' );
|
|
378
|
+
|
|
379
|
+
applyContextFields( form, { coupon: 'SAVE20' } );
|
|
380
|
+
|
|
381
|
+
expect( form.querySelector( 'input[name="newspack_checkout"]' ).value ).toBe( '1' );
|
|
382
|
+
expect( form.querySelector( 'input[name="product_id"]' ).value ).toBe( '9' );
|
|
383
|
+
} );
|
|
384
|
+
|
|
385
|
+
it( 'does not throw on a null form or missing data', () => {
|
|
386
|
+
const form = picker();
|
|
387
|
+
expect( () => applyContextFields( null, { coupon: 'X' } ) ).not.toThrow();
|
|
388
|
+
expect( () => applyContextFields( form, null ) ).not.toThrow();
|
|
389
|
+
} );
|
|
390
|
+
} );
|
|
391
|
+
|
|
392
|
+
describe( 'PICKER_CONTEXT_FIELDS', () => {
|
|
393
|
+
// modal.js reads this same list for the click path, so a field missing here is
|
|
394
|
+
// dropped by both the URL trigger and the variation picker.
|
|
395
|
+
it( 'carries the coupon alongside the after-success and attribution context', () => {
|
|
396
|
+
expect( PICKER_CONTEXT_FIELDS ).toEqual(
|
|
397
|
+
expect.arrayContaining( [
|
|
398
|
+
'after_success_behavior',
|
|
399
|
+
'after_success_url',
|
|
400
|
+
'after_success_button_label',
|
|
401
|
+
'gate_post_id',
|
|
402
|
+
'newspack_popup_id',
|
|
403
|
+
'prompt_title',
|
|
404
|
+
'coupon',
|
|
405
|
+
] )
|
|
406
|
+
);
|
|
407
|
+
} );
|
|
266
408
|
} );
|