Skip to content

Commit

Permalink
update: tokenized ECE update on price change (#10388)
Browse files Browse the repository at this point in the history
Co-authored-by: Guilherme Pressutto <[email protected]>
  • Loading branch information
frosso and gpressutto5 authored Feb 24, 2025
1 parent 7199fbb commit d7f79a9
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 111 deletions.
4 changes: 4 additions & 0 deletions changelog/fix-tokenized-ece-updates-on-total-changes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: update

update: tokenize ECE initialization and update flow on pricing change.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import debounce from '../debounce';
* External dependencies
*/
import { addFilter, doAction } from '@wordpress/hooks';
import { getExpressCheckoutData } from 'wcpay/tokenized-express-checkout/utils';

jQuery( ( $ ) => {
$( document.body ).on( 'woocommerce_variation_has_changed', async () => {
Expand All @@ -19,6 +20,10 @@ jQuery( ( $ ) => {
// Block the payment request button as soon as an "input" event is fired, to avoid sync issues
// when the customer clicks on the button before the debounced event is processed.
jQuery( ( $ ) => {
if ( getExpressCheckoutData( 'button_context' ) !== 'product' ) {
return;
}

const $quantityInput = $( '.quantity' );
const handleQuantityChange = () => {
expressCheckoutButtonUi.blockButton();
Expand Down
254 changes: 143 additions & 111 deletions client/tokenized-express-checkout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,25 +64,60 @@ const fetchNewCartData = async () => {
return cartData;
};

const getServerSideExpressCheckoutProductData = () => {
const displayItems = (
getExpressCheckoutData( 'product' )?.displayItems ?? []
).map( ( { label, amount } ) => ( {
name: label,
amount,
} ) );

return {
total: getExpressCheckoutData( 'product' )?.total.amount,
currency: getExpressCheckoutData( 'product' )?.currency,
requestShipping:
getExpressCheckoutData( 'product' )?.needs_shipping ?? false,
requestPhone:
getExpressCheckoutData( 'checkout' )?.needs_payer_phone ?? false,
displayItems,
};
const getTotalAmount = () => {
if ( cachedCartData ) {
return transformPrice(
parseInt( cachedCartData.totals.total_price, 10 ) -
parseInt( cachedCartData.totals.total_refund || 0, 10 ),
cachedCartData.totals
);
}

if (
getExpressCheckoutData( 'button_context' ) === 'product' &&
getExpressCheckoutData( 'product' )
) {
return getExpressCheckoutData( 'product' )?.total.amount;
}
};

const getOnClickOptions = () => {
if ( cachedCartData ) {
return {
// pay-for-order should never display the shipping selection.
shippingAddressRequired:
getExpressCheckoutData( 'button_context' ) !==
'pay_for_order' && cachedCartData.needs_shipping,
shippingRates: transformCartDataForShippingRates( cachedCartData ),
phoneNumberRequired:
getExpressCheckoutData( 'checkout' )?.needs_payer_phone ??
false,
lineItems: transformCartDataForDisplayItems( cachedCartData ),
};
}

if (
getExpressCheckoutData( 'button_context' ) === 'product' &&
getExpressCheckoutData( 'product' )
) {
return {
shippingAddressRequired:
getExpressCheckoutData( 'product' )?.needs_shipping ?? false,
phoneNumberRequired:
getExpressCheckoutData( 'checkout' )?.needs_payer_phone ??
false,
lineItems: (
getExpressCheckoutData( 'product' )?.displayItems ?? []
).map( ( { label, amount } ) => ( {
name: label,
amount,
} ) ),
};
}
};

let elements;

jQuery( ( $ ) => {
// Don't load if blocks checkout is being loaded.
if (
Expand Down Expand Up @@ -173,15 +208,16 @@ jQuery( ( $ ) => {
/**
* Starts the Express Checkout Element
*
* @param {Object} options ECE options.
* @param {Object} creationOptions ECE initialization options.
*/
startExpressCheckoutElement: async ( options ) => {
startExpressCheckoutElement: async ( creationOptions ) => {
let addToCartPromise = Promise.resolve();
const stripe = await api.getStripe();
const elements = stripe.elements( {
// https://docs.stripe.com/js/elements_object/create_without_intent
elements = stripe.elements( {
mode: 'payment',
amount: options.total,
currency: options.currency,
amount: creationOptions.total,
currency: creationOptions.currency,
paymentMethodCreation: 'manual',
appearance: getExpressCheckoutButtonAppearance(),
locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en',
Expand Down Expand Up @@ -247,9 +283,12 @@ jQuery( ( $ ) => {
} );
}

const options = getOnClickOptions();
const shippingOptionsWithFallback =
! options.shippingRates || // server-side data on the product page initialization doesn't provide any shipping rates.
options.shippingRates.length === 0 // but it can also happen that there are no rates in the array.
// server-side data on the product page initialization doesn't provide any shipping rates.
! options.shippingRates ||
// but it can also happen that there are no rates in the array.
options.shippingRates.length === 0
? [
// fallback for initialization (and initialization _only_), before an address is provided by the ECE.
{
Expand All @@ -263,29 +302,25 @@ jQuery( ( $ ) => {
]
: options.shippingRates;

const clickOptions = {
// `options.displayItems`, `options.requestShipping`, `options.requestPhone`, `options.shippingRates`,
onClickHandler( event );
event.resolve( {
// `options.displayItems`, `options.shippingAddressRequired`, `options.requestPhone`, `options.shippingRates`,
// are all coming from prior of the initialization.
// The "real" values will be updated once the button loads.
// They are preemptively initialized because the `event.resolve({})`
// needs to be called within 1 second of the `click` event.
business: {
name: getExpressCheckoutData( 'store_name' ),
},
lineItems: options.displayItems,
emailRequired: true,
shippingAddressRequired: options.requestShipping,
phoneNumberRequired: options.requestPhone,
shippingRates: options.requestShipping
...options,
shippingRates: options.shippingAddressRequired
? shippingOptionsWithFallback
: undefined,
allowedShippingCountries: getExpressCheckoutData(
'checkout'
).allowed_shipping_countries,
};

onClickHandler( event );
event.resolve( clickOptions );
} );
} );

eceButton.on( 'shippingaddresschange', async ( event ) => {
Expand Down Expand Up @@ -333,54 +368,17 @@ jQuery( ( $ ) => {
expressCheckoutButtonUi.getButtonSeparator().show();
}
} );

removeAction(
'wcpay.express-checkout.update-button-data',
'automattic/wcpay/express-checkout'
);
addAction(
'wcpay.express-checkout.update-button-data',
'automattic/wcpay/express-checkout',
async () => {
// if the product cannot be added to cart (because of missing variation selection, etc),
// don't try to add it to the cart to get new data - the call will likely fail.
if (
getExpressCheckoutData( 'button_context' ) === 'product'
) {
const addToCartButton = $(
'.single_add_to_cart_button'
);

// First check if product can be added to cart.
if ( addToCartButton.is( '.disabled' ) ) {
return;
}
}

try {
expressCheckoutButtonUi.blockButton();

cachedCartData = await fetchNewCartData();

// We need to re init the payment request button to ensure the shipping options & taxes are re-fetched.
// The cachedCartData from the Store API will be used from now on,
// instead of the `product` attributes.
wcpayExpressCheckoutParams.product = null;

await wcpayECE.init();

expressCheckoutButtonUi.unblockButton();
} catch ( e ) {
expressCheckoutButtonUi.hideContainer();
}
}
);
},

/**
* Initialize event handlers and UI state
*/
init: async () => {
removeAction(
'wcpay.express-checkout.update-button-data',
'automattic/wcpay/express-checkout'
);

// on product pages, we should be able to have `getExpressCheckoutData( 'product' )` from the backend,
// which saves us some AJAX calls.
if (
Expand All @@ -394,9 +392,7 @@ jQuery( ( $ ) => {
if ( ! getExpressCheckoutData( 'product' ) && ! cachedCartData ) {
try {
cachedCartData = await fetchNewCartData();
} catch ( e ) {
// if something fails here, we can likely fall back on `getExpressCheckoutData( 'product' )`.
}
} catch ( e ) {}
}

// once (and if) cart data has been fetched, we can safely clear product data from the backend.
Expand All @@ -411,48 +407,84 @@ jQuery( ( $ ) => {
getCartApiHandler().useSeparateCart();
}

if ( cachedCartData ) {
const total = getTotalAmount();
if ( total === 0 ) {
expressCheckoutButtonUi.hideContainer();
expressCheckoutButtonUi.getButtonSeparator().hide();
} else if ( cachedCartData ) {
// If this is the cart page, or checkout page, or pay-for-order page, we need to request the cart details.
// but if the data is not available, we can't render the button.
const total = transformPrice(
parseInt( cachedCartData.totals.total_price, 10 ) -
parseInt( cachedCartData.totals.total_refund || 0, 10 ),
cachedCartData.totals
);
if ( total === 0 ) {
expressCheckoutButtonUi.hideContainer();
expressCheckoutButtonUi.getButtonSeparator().hide();
} else {
await wcpayECE.startExpressCheckoutElement( {
total,
currency: cachedCartData.totals.currency_code.toLowerCase(),
// pay-for-order should never display the shipping selection.
requestShipping:
getExpressCheckoutData( 'button_context' ) !==
'pay_for_order' &&
cachedCartData.needs_shipping,
shippingRates: transformCartDataForShippingRates(
cachedCartData
),
requestPhone:
getExpressCheckoutData( 'checkout' )
?.needs_payer_phone ?? false,
displayItems: transformCartDataForDisplayItems(
cachedCartData
),
} );
}
await wcpayECE.startExpressCheckoutElement( {
total,
currency: cachedCartData.totals.currency_code.toLowerCase(),
} );
} else if (
getExpressCheckoutData( 'button_context' ) === 'product' &&
getExpressCheckoutData( 'product' )
) {
await wcpayECE.startExpressCheckoutElement(
getServerSideExpressCheckoutProductData()
);
await wcpayECE.startExpressCheckoutElement( {
total,
currency: getExpressCheckoutData( 'product' )?.currency,
} );
} else {
expressCheckoutButtonUi.hideContainer();
expressCheckoutButtonUi.getButtonSeparator().hide();
}

addAction(
'wcpay.express-checkout.update-button-data',
'automattic/wcpay/express-checkout',
async () => {
// if the product cannot be added to cart (because of missing variation selection, etc),
// don't try to add it to the cart to get new data - the call will likely fail.
if (
getExpressCheckoutData( 'button_context' ) === 'product'
) {
const addToCartButton = $(
'.single_add_to_cart_button'
);

// First check if product can be added to cart.
if ( addToCartButton.is( '.disabled' ) ) {
return;
}
}

try {
expressCheckoutButtonUi.blockButton();

const prevTotal = getTotalAmount();

cachedCartData = await fetchNewCartData();

// We need to re init the payment request button to ensure the shipping options & taxes are re-fetched.
// The cachedCartData from the Store API will be used from now on,
// instead of the `product` attributes.
wcpayExpressCheckoutParams.product = null;

expressCheckoutButtonUi.unblockButton();

// since the "total" is part of the initialization of the Stripe elements (and not part of the ECE button),
// if the totals change, we might need to update it on the element itself.
const newTotal = getTotalAmount();
if ( ! elements ) {
wcpayECE.init();
} else if ( newTotal !== prevTotal && newTotal > 0 ) {
elements.update( { amount: newTotal } );
}

if ( newTotal === 0 ) {
expressCheckoutButtonUi.hideContainer();
expressCheckoutButtonUi.getButtonSeparator().hide();
} else {
expressCheckoutButtonUi.showContainer();
expressCheckoutButtonUi.getButtonSeparator().show();
}
} catch ( e ) {
expressCheckoutButtonUi.hideContainer();
}
}
);
},
};

Expand Down

0 comments on commit d7f79a9

Please sign in to comment.