@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/blocks/checkout-button/block.json +3 -0
  3. package/dist/editor-rtl.css +16 -13
  4. package/dist/editor.asset.php +1 -1
  5. package/dist/editor.css +16 -13
  6. package/dist/editor.js +30 -25
  7. package/dist/modal.asset.php +1 -1
  8. package/dist/modal.js +1 -1
  9. package/dist/modalCheckout-rtl.css +1 -1
  10. package/dist/modalCheckout.asset.php +1 -1
  11. package/dist/modalCheckout.css +1 -1
  12. package/dist/modalCheckout.js +1 -1
  13. package/includes/class-modal-checkout.php +131 -168
  14. package/includes/class-newspack-blocks.php +1 -0
  15. package/includes/modal-checkout/class-checkout-data.php +23 -0
  16. package/newspack-blocks.php +2 -2
  17. package/package.json +5 -5
  18. package/src/blocks/checkout-button/block.json +3 -0
  19. package/src/blocks/checkout-button/coupon-control.js +150 -0
  20. package/src/blocks/checkout-button/edit.js +35 -2
  21. package/src/blocks/checkout-button/edit.scss +3 -2
  22. package/src/blocks/checkout-button/view.php +41 -2
  23. package/src/blocks/donate/frontend/class-newspack-blocks-donate-renderer-frequency-based.php +2 -2
  24. package/src/blocks/donate/frontend/class-newspack-blocks-donate-renderer-tiers-based.php +1 -1
  25. package/src/modal-checkout/checkout-button-trigger.js +91 -6
  26. package/src/modal-checkout/checkout-button-trigger.test.js +143 -1
  27. package/src/modal-checkout/checkout.scss +1 -53
  28. package/src/modal-checkout/index.js +7 -89
  29. package/src/modal-checkout/modal.js +5 -27
  30. package/src/modal-checkout/templates/form-checkout.php +10 -20
  31. package/src/modal-checkout/templates/form-coupon.php +9 -0
  32. package/src/modal-checkout/templates/thankyou.php +1 -1
  33. package/vendor/composer/installed.php +2 -2
  34. 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" style={ { marginBottom: '16px' } }>
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 data-checkout="%1$s">%2$s %3$s</form>',
157
- esc_attr( wp_json_encode( $checkout_data ) ),
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
  );
@@ -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
- data-checkout='<?php echo esc_attr( wp_json_encode( Checkout_Data::get_donation_checkout_data( $frequency_slug, $amount ) ) ); ?>'
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
- data-checkout='<?php echo esc_attr( wp_json_encode( Checkout_Data::get_donation_checkout_data( $frequency_slug, $amount ) ) ); ?>'
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
- data-checkout="<?php echo esc_attr( wp_json_encode( Checkout_Data::get_donation_checkout_data( $frequency_slug, $amount ) ) ); ?>"
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 source button may be locked to another variation. Use it only
177
- // for block context, then submit the picker. The picker is only reached
178
- // because no button matches the requested variation, so when several
179
- // buttons share this parent product there is no single correct one to
180
- // prefer: the first in DOM order supplies the context.
181
- copyContextFields( findCheckoutButtonForm( root, productId, null ), picker );
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 { readCheckoutData, findCheckoutButtonForm, selectPickerForm, resolveCheckoutButtonForm, copyContextFields } from './checkout-button-trigger';
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
  } );