diff --git a/.jshintrc b/.jshintrc index d1ae5138ce..9018c83509 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1,5 +1,5 @@ { - "esversion": 6, + "esversion": 11, "boss": true, "curly": true, "eqeqeq": true, @@ -13,6 +13,9 @@ "browser": true, + "mocha": true, + "jasmine": true, + "globals": { "_": false, "Backbone": false, diff --git a/changelog.txt b/changelog.txt index 592358cd4c..b259359920 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,7 @@ *** Changelog *** = 8.8.0 - xxxx-xx-xx = +* Add - Add support for the new Stripe Checkout Element on the shortcode checkout page. * Dev - Introduces a new class with payment methods constants. * Dev - Introduces a new class with currency codes constants. * Dev - Improves the readability of the redirect URL generation code (UPE). diff --git a/client/api/index.js b/client/api/index.js index 9c1cf0271f..975fff1910 100644 --- a/client/api/index.js +++ b/client/api/index.js @@ -507,6 +507,17 @@ export default class WCStripeAPI { ); } + /** + * Get cart items and total amount. + * + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutGetCartDetails() { + return this.request( getExpressCheckoutAjaxURL( 'get_cart_details' ), { + security: getExpressCheckoutData( 'nonce' )?.get_cart_details, + } ); + } + /** * Creates order based on Express Checkout ECE payment method. * diff --git a/client/blocks/express-checkout/express-checkout.js b/client/blocks/express-checkout/express-checkout.js index 297fda1092..52bcabbdaf 100644 --- a/client/blocks/express-checkout/express-checkout.js +++ b/client/blocks/express-checkout/express-checkout.js @@ -13,7 +13,9 @@ export const ExpressCheckout = ( props ) => { const buttonOptions = { buttonType: { + // eslint-disable-next-line camelcase googlePay: wc_stripe_express_checkout_params.button.type, + // eslint-disable-next-line camelcase applePay: wc_stripe_express_checkout_params.button.type, }, }; diff --git a/client/entrypoints/express-checkout/index.js b/client/entrypoints/express-checkout/index.js index f69a9256ff..80064dc92b 100644 --- a/client/entrypoints/express-checkout/index.js +++ b/client/entrypoints/express-checkout/index.js @@ -1 +1,467 @@ -// express checkout element integration for shortcode goes here. +import { __ } from '@wordpress/i18n'; +import { debounce } from 'lodash'; +import jQuery from 'jquery'; +import WCStripeAPI from '../../api'; +import { + displayLoginConfirmation, + getExpressCheckoutButtonAppearance, + getExpressCheckoutButtonStyleSettings, + getExpressCheckoutData, + normalizeLineItems, +} from 'wcstripe/express-checkout/utils'; +import { + onAbortPaymentHandler, + onCancelHandler, + onClickHandler, + onCompletePaymentHandler, + onConfirmHandler, + onReadyHandler, + shippingAddressChangeHandler, + shippingRateChangeHandler, +} from 'wcstripe/express-checkout/event-handler'; +import { getStripeServerData } from 'wcstripe/stripe-utils'; +import { getAddToCartVariationParams } from 'wcstripe/utils'; + +jQuery( function ( $ ) { + // Don't load if blocks checkout is being loaded. + if ( + getExpressCheckoutData( 'has_block' ) && + ! getExpressCheckoutData( 'is_pay_for_order' ) + ) { + return; + } + + const publishableKey = getExpressCheckoutData( 'stripe' ).publishable_key; + if ( ! publishableKey ) { + // If no configuration is present, probably this is not the checkout page. + return; + } + + const api = new WCStripeAPI( + getStripeServerData(), + // A promise-based interface to jQuery.post. + ( url, args ) => { + return new Promise( ( resolve, reject ) => { + jQuery.post( url, args ).then( resolve ).fail( reject ); + } ); + } + ); + + let wcStripeECEError = ''; + const defaultErrorMessage = __( + 'There was an error getting the product information.', + 'woocommerce-gateway-stripe' + ); + const wcStripeECE = { + createButton: ( elements, options ) => + elements.create( 'expressCheckout', options ), + + getElements: () => $( '#wc-stripe-express-checkout-element' ), + + getButtonSeparator: () => + $( '#wc-stripe-express-checkout-button-separator' ), + + show: () => wcStripeECE.getElements().show(), + + hide: () => { + wcStripeECE.getElements().hide(); + wcStripeECE.getButtonSeparator().hide(); + }, + + renderButton: ( eceButton ) => { + if ( $( '#wc-stripe-express-checkout-element' ).length ) { + eceButton.mount( '#wc-stripe-express-checkout-element' ); + } + }, + + productHasDepositOption() { + return !! $( 'form' ).has( + 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]' + ).length; + }, + + /** + * Starts the Express Checkout Element + * + * @param {Object} options ECE options. + */ + startExpressCheckoutElement: ( options ) => { + const getShippingRates = () => { + if ( ! options.requestShipping ) { + return []; + } + + if ( getExpressCheckoutData( 'is_product_page' ) ) { + // Despite the name of the property, this seems to be just a single option that's not in an array. + const { + shippingOptions: shippingOption, + } = getExpressCheckoutData( 'product' ); + + return [ + { + id: shippingOption.id, + amount: shippingOption.amount, + displayName: shippingOption.label, + }, + ]; + } + + return options.displayItems + .filter( + ( i ) => + i.label === + __( 'Shipping', 'woocommerce-gateway-stripe' ) + ) + .map( ( i ) => ( { + id: `rate-${ i.label }`, + amount: i.amount, + displayName: i.label, + } ) ); + }; + + const shippingRates = getShippingRates(); + + // This is a bit of a hack, but we need some way to get the shipping information before rendering the button, and + // since we don't have any address information at this point it seems best to rely on what came with the cart response. + // Relying on what's provided in the cart response seems safest since it should always include a valid shipping + // rate if one is required and available. + // If no shipping rate is found we can't render the button so we just exit. + if ( options.requestShipping && ! shippingRates ) { + return; + } + + const elements = api.getStripe().elements( { + mode: options.mode ? options.mode : 'payment', + amount: options.total, + currency: options.currency, + paymentMethodCreation: 'manual', + appearance: getExpressCheckoutButtonAppearance(), + } ); + + const eceButton = wcStripeECE.createButton( + elements, + getExpressCheckoutButtonStyleSettings() + ); + + wcStripeECE.renderButton( eceButton ); + + eceButton.on( 'loaderror', () => { + wcStripeECEError = __( + 'The cart is incompatible with express checkout.', + 'woocommerce-gateway-stripe' + ); + } ); + + eceButton.on( 'click', function ( event ) { + // If login is required for checkout, display redirect confirmation dialog. + if ( getExpressCheckoutData( 'login_confirmation' ) ) { + displayLoginConfirmation( event.expressPaymentType ); + return; + } + + if ( getExpressCheckoutData( 'is_product_page' ) ) { + const addToCartButton = $( '.single_add_to_cart_button' ); + + // First check if product can be added to cart. + if ( addToCartButton.is( '.disabled' ) ) { + if ( + addToCartButton.is( '.wc-variation-is-unavailable' ) + ) { + // eslint-disable-next-line no-alert + window.alert( + // eslint-disable-next-line camelcase + getAddToCartVariationParams( + 'i18n_unavailable_text' + ) || + __( + 'Sorry, this product is unavailable. Please choose a different combination.', + 'woocommerce-gateway-stripe' + ) + ); + } else { + // eslint-disable-next-line no-alert + window.alert( + __( + 'Please select your product options before proceeding.', + 'woocommerce-gateway-stripe' + ) + ); + } + return; + } + + if ( wcStripeECEError ) { + // eslint-disable-next-line no-alert + window.alert( wcStripeECEError ); + return; + } + + // Add products to the cart if everything is right. + wcStripeECE.addToCart(); + } + + const clickOptions = { + lineItems: normalizeLineItems( options.displayItems ), + emailRequired: true, + shippingAddressRequired: options.requestShipping, + phoneNumberRequired: options.requestPhone, + shippingRates, + }; + + onClickHandler( event ); + event.resolve( clickOptions ); + } ); + + eceButton.on( + 'shippingaddresschange', + async ( event ) => + await shippingAddressChangeHandler( api, event, elements ) + ); + + eceButton.on( + 'shippingratechange', + async ( event ) => + await shippingRateChangeHandler( api, event, elements ) + ); + + eceButton.on( 'confirm', async ( event ) => { + const order = options.order ? options.order : 0; + + return await onConfirmHandler( + api, + api.getStripe(), + elements, + wcStripeECE.completePayment, + wcStripeECE.abortPayment, + event, + order + ); + } ); + + eceButton.on( 'cancel', () => { + wcStripeECE.paymentAborted = true; + onCancelHandler(); + } ); + + eceButton.on( 'ready', ( onReadyParams ) => { + onReadyHandler( onReadyParams ); + + if ( + onReadyParams.availablePaymentMethods && + Object.values( + onReadyParams.availablePaymentMethods + ).filter( Boolean ).length + ) { + wcStripeECE.show(); + wcStripeECE.getButtonSeparator().show(); + } + } ); + + if ( getExpressCheckoutData( 'is_product_page' ) ) { + wcStripeECE.attachProductPageEventListeners( elements ); + } + }, + + /** + * Initialize event handlers and UI state + */ + init: () => { + if ( getExpressCheckoutData( 'is_pay_for_order' ) ) { + // Pay for order page specific initialization. + } else if ( getExpressCheckoutData( 'is_product_page' ) ) { + // Product page specific initialization. + } else { + // Cart and Checkout page specific initialization. + api.expressCheckoutGetCartDetails().then( ( cart ) => { + wcStripeECE.startExpressCheckoutElement( { + mode: 'payment', + total: cart.order_data.total.amount, + currency: getExpressCheckoutData( 'checkout' ) + ?.currency_code, + requestShipping: cart.shipping_required === true, + requestPhone: getExpressCheckoutData( 'checkout' ) + ?.needs_payer_phone, + displayItems: cart.order_data.displayItems, + } ); + } ); + } + + // After initializing a new express checkout button, we need to reset the paymentAborted flag. + wcStripeECE.paymentAborted = false; + }, + + /** + * Complete payment. + * + * @param {string} url Order thank you page URL. + */ + completePayment: ( url ) => { + onCompletePaymentHandler( url ); + window.location = url; + }, + + /** + * Abort the payment and display error messages. + * + * @param {PaymentResponse} payment Payment response instance. + * @param {string} message Error message to display. + */ + abortPayment: ( payment, message ) => { + payment.paymentFailed( { reason: 'fail' } ); + onAbortPaymentHandler( payment, message ); + + $( '.woocommerce-error' ).remove(); + + const $container = $( '.woocommerce-notices-wrapper' ).first(); + + if ( $container.length ) { + $container.append( + $( '
' ).text( message ) + ); + + $( 'html, body' ).animate( + { + scrollTop: $container + .find( '.woocommerce-error' ) + .offset().top, + }, + 600 + ); + } + }, + + attachProductPageEventListeners: ( elements ) => { + // WooCommerce Deposits support. + // Trigger the "woocommerce_variation_has_changed" event when the deposit option is changed. + // Needs to be defined before the `woocommerce_variation_has_changed` event handler is set. + $( + 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]' + ) + .off( 'change' ) + .on( 'change', () => { + $( 'form' ) + .has( + 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]' + ) + .trigger( 'woocommerce_variation_has_changed' ); + } ); + + $( document.body ) + .off( 'woocommerce_variation_has_changed' ) + .on( 'woocommerce_variation_has_changed', () => { + wcStripeECE.blockExpressCheckoutButton(); + + $.when( wcStripeECE.getSelectedProductData() ) + .then( ( response ) => { + const isDeposits = wcStripeECE.productHasDepositOption(); + /** + * If the customer aborted the express checkout, + * we need to re init the express checkout button to ensure the shipping + * options are refetched. If the customer didn't abort the express checkout, + * and the product's shipping status is consistent, + * we can simply update the express checkout button with the new total and display items. + */ + const needsShipping = + ! wcStripeECE.paymentAborted && + getExpressCheckoutData( 'product' ) + .needs_shipping === response.needs_shipping; + + if ( ! isDeposits && needsShipping ) { + elements.update( { + amount: response.total.amount, + } ); + } else { + wcStripeECE.reInitExpressCheckoutElement( + response + ); + } + } ) + .catch( () => { + wcStripeECE.hide(); + } ) + .always( () => { + wcStripeECE.unblockExpressCheckoutButton(); + } ); + } ); + + $( '.quantity' ) + .off( 'input', '.qty' ) + .on( + 'input', + '.qty', + debounce( () => { + wcStripeECE.blockExpressCheckoutButton(); + wcStripeECEError = ''; + + $.when( wcStripeECE.getSelectedProductData() ) + .then( + ( response ) => { + // In case the server returns an unexpected response + if ( typeof response !== 'object' ) { + wcStripeECEError = defaultErrorMessage; + } + + if ( + ! wcStripeECE.paymentAborted && + getExpressCheckoutData( 'product' ) + .needs_shipping === + response.needs_shipping + ) { + elements.update( { + amount: response.total.amount, + } ); + } else { + wcStripeECE.reInitExpressCheckoutElement( + response + ); + } + }, + ( response ) => { + if ( response.responseJSON ) { + wcStripeECEError = + response.responseJSON.error; + } else { + wcStripeECEError = defaultErrorMessage; + } + } + ) + .always( function () { + wcStripeECE.unblockExpressCheckoutButton(); + } ); + }, 250 ) + ); + }, + + reInitExpressCheckoutElement: ( response ) => { + getExpressCheckoutData( 'product' ).requestShipping = + response.requestShipping; + getExpressCheckoutData( 'product' ).total = response.total; + getExpressCheckoutData( 'product' ).displayItems = + response.displayItems; + wcStripeECE.init(); + }, + + blockExpressCheckoutButton: () => { + // check if element isn't already blocked before calling block() to avoid blinking overlay issues + // blockUI.isBlocked is either undefined or 0 when element is not blocked + if ( + $( '#wc-stripe-express-checkout-element' ).data( + 'blockUI.isBlocked' + ) + ) { + return; + } + + $( '#wc-stripe-express-checkout-element' ).block( { + message: null, + } ); + }, + + unblockExpressCheckoutButton: () => { + wcStripeECE.show(); + $( '#wc-stripe-express-checkout-element' ).unblock(); + }, + }; + + wcStripeECE.init(); +} ); diff --git a/client/settings/display-order-customization-notice/index.js b/client/settings/display-order-customization-notice/index.js index 2c51ab6e32..780e343050 100644 --- a/client/settings/display-order-customization-notice/index.js +++ b/client/settings/display-order-customization-notice/index.js @@ -26,6 +26,7 @@ const NoticeContent = styled.div` const DisplayOrderCustomizationNotice = () => { const { isUpeEnabled } = useContext( UpeToggleContext ); const [ showNotice, setShowNotice ] = useState( + // eslint-disable-next-line camelcase wc_stripe_settings_params.show_customization_notice ); diff --git a/client/settings/index.js b/client/settings/index.js index ae7b15f568..0b9b00ce12 100644 --- a/client/settings/index.js +++ b/client/settings/index.js @@ -23,6 +23,7 @@ if ( settingsContainer ) { ReactDOM.render( @@ -36,6 +37,7 @@ if ( paymentGatewayContainer ) { ReactDOM.render( @@ -48,8 +50,14 @@ if ( paymentGatewayContainer ) { if ( newAccountContainer ) { ReactDOM.render( , newAccountContainer ); diff --git a/client/settings/payment-methods/index.js b/client/settings/payment-methods/index.js index 186dafe2d1..0b0a3ce55e 100644 --- a/client/settings/payment-methods/index.js +++ b/client/settings/payment-methods/index.js @@ -68,8 +68,12 @@ const PaymentMethodsPanel = ( { onSaveChanges } ) => { isUpeEnabled={ isUpeEnabled } setIsUpeEnabled={ setIsUpeEnabled } isConnectedViaOAuth={ oauthConnected } - oauthUrl={ wc_stripe_settings_params.stripe_oauth_url } + oauthUrl={ + // eslint-disable-next-line camelcase + wc_stripe_settings_params.stripe_oauth_url + } testOauthUrl={ + // eslint-disable-next-line camelcase wc_stripe_settings_params.stripe_test_oauth_url } /> diff --git a/client/settings/payment-settings/index.js b/client/settings/payment-settings/index.js index 2bea487963..b98cc53215 100644 --- a/client/settings/payment-settings/index.js +++ b/client/settings/payment-settings/index.js @@ -110,9 +110,11 @@ const PaymentSettingsPanel = () => { setIsUpeEnabled={ setIsUpeEnabled } isConnectedViaOAuth={ oauthConnected } oauthUrl={ + // eslint-disable-next-line camelcase wc_stripe_settings_params.stripe_oauth_url } testOauthUrl={ + // eslint-disable-next-line camelcase wc_stripe_settings_params.stripe_test_oauth_url } /> diff --git a/client/settings/stripe-auth-account/account-status-panel.js b/client/settings/stripe-auth-account/account-status-panel.js index a9dc877809..d7f8702be8 100644 --- a/client/settings/stripe-auth-account/account-status-panel.js +++ b/client/settings/stripe-auth-account/account-status-panel.js @@ -119,8 +119,8 @@ const getAccountStatus = ( accountKeys, data, testMode ) => { // eslint-disable-next-line camelcase const { oauth_connections } = data; - const oauthStatus = testMode - ? oauth_connections?.test + const oauthStatus = testMode // eslint-disable-next-line camelcase + ? oauth_connections?.test // eslint-disable-next-line camelcase : oauth_connections?.live; const hasKeys = secretKey && publishableKey; diff --git a/client/settings/stripe-auth-account/stripe-auth-actions.js b/client/settings/stripe-auth-account/stripe-auth-actions.js index ffc4d0cc5a..6a73eadd72 100644 --- a/client/settings/stripe-auth-account/stripe-auth-actions.js +++ b/client/settings/stripe-auth-account/stripe-auth-actions.js @@ -16,8 +16,8 @@ import InlineNotice from 'wcstripe/components/inline-notice'; * @return {JSX.Element} The rendered StripeAuthActions component. */ const StripeAuthActions = ( { testMode, displayWebhookConfigure } ) => { - const oauthUrl = testMode - ? wc_stripe_settings_params.stripe_test_oauth_url + const oauthUrl = testMode // eslint-disable-next-line camelcase + ? wc_stripe_settings_params.stripe_test_oauth_url // eslint-disable-next-line camelcase : wc_stripe_settings_params.stripe_oauth_url; return oauthUrl ? ( diff --git a/client/stripe-utils/utils.js b/client/stripe-utils/utils.js index 6eea1b78b5..72835b2f17 100644 --- a/client/stripe-utils/utils.js +++ b/client/stripe-utils/utils.js @@ -306,7 +306,7 @@ export const appendSetupIntentToForm = ( form, setupIntent ) => { * * @param {string} paymentMethodType The payment method type ('card', 'ideal', etc.). * - * @return {boolean} Boolean indicating whether or not a saved payment method is being used. + * @return {boolean} Boolean indicating whether a saved payment method is being used. */ export const isUsingSavedPaymentMethod = ( paymentMethodType ) => { const paymentMethod = getPaymentMethodName( paymentMethodType ); diff --git a/client/tracking/index.js b/client/tracking/index.js index 122444a350..eed961a1d0 100644 --- a/client/tracking/index.js +++ b/client/tracking/index.js @@ -54,7 +54,9 @@ export function recordEvent( eventName, eventProperties ) { Object.assign( eventProperties, { // The value for test mode is localized from the server on page load, // thus it will only be updated after reloading the page. + // eslint-disable-next-line camelcase is_test_mode: wc_stripe_settings_params.is_test_mode ? 'yes' : 'no', + // eslint-disable-next-line camelcase stripe_version: wc_stripe_settings_params.plugin_version, } ); diff --git a/client/utils/index.js b/client/utils/index.js new file mode 100644 index 0000000000..d007d1e794 --- /dev/null +++ b/client/utils/index.js @@ -0,0 +1,10 @@ +/*global wc_add_to_cart_variation_params */ +export const getAddToCartVariationParams = ( key ) => { + // eslint-disable-next-line camelcase + const wcAddToCartVariationParams = wc_add_to_cart_variation_params; + if ( ! wcAddToCartVariationParams || ! wcAddToCartVariationParams[ key ] ) { + return null; + } + + return wcAddToCartVariationParams[ key ]; +}; diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php b/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php index ade4b01326..136cef217f 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php @@ -43,7 +43,7 @@ public function init() { * Get cart details. */ public function ajax_get_cart_details() { - check_ajax_referer( 'wc-stripe-express-checkout', 'security' ); + check_ajax_referer( 'wc-stripe-get-cart-details', 'security' ); if ( ! defined( 'WOOCOMMERCE_CART' ) ) { define( 'WOOCOMMERCE_CART', true ); @@ -131,7 +131,7 @@ public function ajax_clear_cart() { * @see WC_Shipping::get_packages(). */ public function ajax_get_shipping_options() { - check_ajax_referer( 'wc-stripe-express-checkout-shipping', 'security' ); + check_ajax_referer( 'wc-stripe-express-checkout-element-shipping', 'security' ); $shipping_address = filter_input_array( INPUT_POST, diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-element.php b/includes/payment-methods/class-wc-stripe-express-checkout-element.php index 7e41cf8cf3..4dd5788d14 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-element.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-element.php @@ -1,4 +1,10 @@ WC_AJAX::get_endpoint( '%%endpoint%%' ), 'stripe' => [ + 'publishable_key' => 'yes' === $this->stripe_settings['testmode'] ? $this->stripe_settings['test_publishable_key'] : $this->stripe_settings['publishable_key'], 'allow_prepaid_card' => apply_filters( 'wc_stripe_allow_prepaid_card', true ) ? 'yes' : 'no', 'locale' => WC_Stripe_Helper::convert_wc_locale_to_stripe_locale( get_locale() ), 'is_link_enabled' => WC_Stripe_UPE_Payment_Method_Link::is_link_enabled(), 'is_express_checkout_enabled' => $this->express_checkout_helper->is_express_checkout_enabled(), ], 'nonce' => [ + 'get_cart_details' => wp_create_nonce( 'wc-stripe-get-cart-details' ), 'payment' => wp_create_nonce( 'wc-stripe-express-checkout-element' ), 'shipping' => wp_create_nonce( 'wc-stripe-express-checkout-element-shipping' ), 'update_shipping' => wp_create_nonce( 'wc-stripe-update-shipping-method' ), @@ -191,8 +199,11 @@ public function javascript_params() { 'needs_payer_phone' => 'required' === get_option( 'woocommerce_checkout_phone_field', 'required' ), ], 'button' => $this->express_checkout_helper->get_button_settings(), + 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), + 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), 'login_confirmation' => $this->express_checkout_helper->get_login_confirmation_settings(), 'is_product_page' => $this->express_checkout_helper->is_product(), + 'is_checkout_page' => $this->express_checkout_helper->is_checkout(), 'product' => $this->express_checkout_helper->get_product_data(), ]; } @@ -324,10 +335,8 @@ public function display_express_checkout_button_html() { } ?> -