diff --git a/changelog/add-woopay-direct-checkout-to-minicart b/changelog/add-woopay-direct-checkout-to-minicart new file mode 100644 index 00000000000..031f5132dfa --- /dev/null +++ b/changelog/add-woopay-direct-checkout-to-minicart @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add the WooPay Direct Checkout flow to the blocks mini cart widget. diff --git a/client/checkout/woopay/direct-checkout/index.js b/client/checkout/woopay/direct-checkout/index.js index b8795cfa3ea..c7db345ab9f 100644 --- a/client/checkout/woopay/direct-checkout/index.js +++ b/client/checkout/woopay/direct-checkout/index.js @@ -9,61 +9,94 @@ import { debounce } from 'lodash'; * Internal dependencies */ import { WC_STORE_CART } from 'wcpay/checkout/constants'; -import { waitMilliseconds } from 'wcpay/checkout/woopay/direct-checkout/utils'; +import { + waitMilliseconds, + waitForSelector, +} from 'wcpay/checkout/woopay/direct-checkout/utils'; import WooPayDirectCheckout from 'wcpay/checkout/woopay/direct-checkout/woopay-direct-checkout'; import { shouldSkipWooPay } from 'wcpay/checkout/woopay/utils'; let isThirdPartyCookieEnabled = false; -window.addEventListener( 'load', async () => { - if ( - ! WooPayDirectCheckout.isWooPayDirectCheckoutEnabled() || - shouldSkipWooPay() - ) { +/** + * Handle the WooPay direct checkout for the given checkout buttons. + * + * @param {HTMLElement[]} checkoutButtons An array of checkout button elements. + */ +const handleWooPayDirectCheckout = async ( checkoutButtons ) => { + if ( ! checkoutButtons ) { return; } - WooPayDirectCheckout.init(); - - isThirdPartyCookieEnabled = await WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled(); - const checkoutElements = WooPayDirectCheckout.getCheckoutRedirectElements(); if ( isThirdPartyCookieEnabled ) { if ( await WooPayDirectCheckout.isUserLoggedIn() ) { WooPayDirectCheckout.maybePrefetchEncryptedSessionData(); - WooPayDirectCheckout.redirectToWooPay( checkoutElements, true ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + checkoutButtons, + true + ); } return; } // Pass false to indicate we are not sure if the user is logged in or not. - WooPayDirectCheckout.redirectToWooPay( checkoutElements, false ); -} ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + checkoutButtons, + false + ); +}; -jQuery( ( $ ) => { - $( document.body ).on( 'updated_cart_totals', async () => { - if ( - ! WooPayDirectCheckout.isWooPayDirectCheckoutEnabled() || - shouldSkipWooPay() - ) { - return; - } +/** + * Add an event listener to the mini cart checkout button. + */ +const addMiniCartEventListener = () => { + const checkoutButton = WooPayDirectCheckout.getMiniCartProceedToCheckoutButton(); + handleWooPayDirectCheckout( [ checkoutButton ] ); +}; - // When "updated_cart_totals" is triggered, the classic 'Proceed to Checkout' button is - // re-rendered. So, the click-event listener needs to be re-attached to the new button. - const checkoutButton = WooPayDirectCheckout.getClassicProceedToCheckoutButton(); - if ( isThirdPartyCookieEnabled ) { - if ( await WooPayDirectCheckout.isUserLoggedIn() ) { - WooPayDirectCheckout.maybePrefetchEncryptedSessionData(); - WooPayDirectCheckout.redirectToWooPay( [ checkoutButton ] ); - } +/** + * If the mini cart widget is available on the page, observe when the drawer element gets added to the DOM. + * + * As of today, no window events are triggered when the mini cart is opened or closed, + * nor there are attribute changes to the "open" button, so we have to rely on a MutationObserver + * attached to the `document.body`, which is where the mini cart drawer element is added. + */ +const maybeObserveMiniCart = () => { + // Check if the widget is available on the page. + if ( + ! document.querySelector( '[data-block-name="woocommerce/mini-cart"]' ) + ) { + return; + } - return; + // Create a MutationObserver to check when the mini cart drawer is added to the DOM. + const observer = new MutationObserver( ( mutations ) => { + for ( const mutation of mutations ) { + if ( mutation?.addedNodes?.length > 0 ) { + for ( const node of mutation.addedNodes ) { + // Check if the mini cart drawer parent selector was added to the DOM. + if ( + node.nodeType === 1 && + node.matches( + '.wc-block-components-drawer__screen-overlay' + ) + ) { + // Wait until the button is rendered and add the event listener to it. + waitForSelector( + WooPayDirectCheckout.redirectElements + .BLOCKS_MINI_CART_PROCEED_BUTTON, + addMiniCartEventListener + ); + return; + } + } + } } - - WooPayDirectCheckout.redirectToWooPay( [ checkoutButton ], true ); } ); -} ); + + observer.observe( document.body, { childList: true } ); +}; /** * Determines whether the encrypted session data should be prefetched. @@ -173,22 +206,51 @@ const removeItemCallback = async ( { product } ) => { } }; -// Note, although the following hooks are prefixed with 'experimental__', they will be -// graduated to stable in the near future (it'll include the 'experimental__' prefix). -addAction( - 'experimental__woocommerce_blocks-cart-add-item', - 'wcpay_woopay_direct_checkout', - addItemCallback -); - -addAction( - 'experimental__woocommerce_blocks-cart-set-item-quantity', - 'wcpay_woopay_direct_checkout', - debounceSetItemQtyCallback -); - -addAction( - 'experimental__woocommerce_blocks-cart-remove-item', - 'wcpay_woopay_direct_checkout', - removeItemCallback -); +window.addEventListener( 'load', async () => { + if ( shouldSkipWooPay() ) { + return; + } + + WooPayDirectCheckout.init(); + + isThirdPartyCookieEnabled = await WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled(); + + // Note, although the following hooks are prefixed with 'experimental__', they will be + // graduated to stable in the near future (it'll include the 'experimental__' prefix). + addAction( + 'experimental__woocommerce_blocks-cart-add-item', + 'wcpay_woopay_direct_checkout', + addItemCallback + ); + + addAction( + 'experimental__woocommerce_blocks-cart-set-item-quantity', + 'wcpay_woopay_direct_checkout', + debounceSetItemQtyCallback + ); + + addAction( + 'experimental__woocommerce_blocks-cart-remove-item', + 'wcpay_woopay_direct_checkout', + removeItemCallback + ); + + // If the mini cart is available, check when it's opened so we can add the event listener to the mini cart's checkout button. + maybeObserveMiniCart(); + + const checkoutButtons = WooPayDirectCheckout.getCheckoutButtonElements(); + handleWooPayDirectCheckout( checkoutButtons ); +} ); + +jQuery( ( $ ) => { + $( document.body ).on( 'updated_cart_totals', async () => { + if ( shouldSkipWooPay() ) { + return; + } + + // When "updated_cart_totals" is triggered, the classic 'Proceed to Checkout' button is + // re-rendered. So, the click-event listener needs to be re-attached to the new button. + const checkoutButton = WooPayDirectCheckout.getClassicProceedToCheckoutButton(); + handleWooPayDirectCheckout( [ checkoutButton ] ); + } ); +} ); diff --git a/client/checkout/woopay/direct-checkout/test/index.test.js b/client/checkout/woopay/direct-checkout/test/index.test.js index 1cf516709cc..245c747a1e2 100644 --- a/client/checkout/woopay/direct-checkout/test/index.test.js +++ b/client/checkout/woopay/direct-checkout/test/index.test.js @@ -20,14 +20,13 @@ jest.mock( '@wordpress/hooks', () => ( { jest.mock( 'wcpay/checkout/woopay/direct-checkout/woopay-direct-checkout', () => ( { - isWooPayDirectCheckoutEnabled: jest.fn(), init: jest.fn(), isWooPayThirdPartyCookiesEnabled: jest.fn(), - getCheckoutRedirectElements: jest.fn(), + getCheckoutButtonElements: jest.fn(), isUserLoggedIn: jest.fn(), maybePrefetchEncryptedSessionData: jest.fn(), getClassicProceedToCheckoutButton: jest.fn(), - redirectToWooPay: jest.fn(), + addRedirectToWooPayEventListener: jest.fn(), setEncryptedSessionDataAsNotPrefetched: jest.fn(), } ) ); @@ -53,30 +52,12 @@ describe( 'WooPay direct checkout window "load" event listener', () => { jest.clearAllMocks(); } ); - it( 'does not initialize WooPay direct checkout if not enabled', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - false - ); - - fireEvent.load( window ); - - await new Promise( ( resolve ) => setImmediate( resolve ) ); - - expect( - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled - ).toHaveBeenCalled(); - expect( WooPayDirectCheckout.init ).not.toHaveBeenCalled(); - } ); - - it( 'calls `redirectToWooPay` method if third-party cookies are enabled and user is logged-in', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - true - ); + it( 'calls `addRedirectToWooPayEventListener` method if third-party cookies are enabled and user is logged-in', async () => { WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled.mockResolvedValue( true ); WooPayDirectCheckout.isUserLoggedIn.mockResolvedValue( true ); - WooPayDirectCheckout.getCheckoutRedirectElements.mockReturnValue( [] ); + WooPayDirectCheckout.getCheckoutButtonElements.mockReturnValue( [] ); fireEvent.load( window ); @@ -90,20 +71,16 @@ describe( 'WooPay direct checkout window "load" event listener', () => { expect( WooPayDirectCheckout.maybePrefetchEncryptedSessionData ).toHaveBeenCalled(); - expect( WooPayDirectCheckout.redirectToWooPay ).toHaveBeenCalledWith( - expect.any( Array ), - true - ); + expect( + WooPayDirectCheckout.addRedirectToWooPayEventListener + ).toHaveBeenCalledWith( expect.any( Array ), true ); } ); - it( 'calls `redirectToWooPay` method with "checkout_redirect" if third-party cookies are disabled', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - true - ); + it( 'calls `addRedirectToWooPayEventListener` method with "checkout_redirect" if third-party cookies are disabled', async () => { WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled.mockResolvedValue( false ); - WooPayDirectCheckout.getCheckoutRedirectElements.mockReturnValue( [] ); + WooPayDirectCheckout.getCheckoutButtonElements.mockReturnValue( [] ); fireEvent.load( window ); @@ -117,10 +94,9 @@ describe( 'WooPay direct checkout window "load" event listener', () => { expect( WooPayDirectCheckout.maybePrefetchEncryptedSessionData ).not.toHaveBeenCalled(); - expect( WooPayDirectCheckout.redirectToWooPay ).toHaveBeenCalledWith( - expect.any( Array ), - false - ); + expect( + WooPayDirectCheckout.addRedirectToWooPayEventListener + ).toHaveBeenCalledWith( expect.any( Array ), false ); } ); } ); @@ -129,34 +105,12 @@ describe( 'WooPay direct checkout "updated_cart_totals" jQuery event listener', jest.clearAllMocks(); } ); - it( 'should not proceed if direct checkout is not enabled', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - false - ); - - fireEvent.load( window ); - - await new Promise( ( resolve ) => setImmediate( resolve ) ); - - $( document.body ).trigger( 'updated_cart_totals' ); - - expect( - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled - ).toHaveBeenCalled(); - expect( - WooPayDirectCheckout.getClassicProceedToCheckoutButton - ).not.toHaveBeenCalled(); - } ); - - it( 'calls `redirectToWooPay` method if third-party cookies are enabled and user is logged-in', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - true - ); + it( 'calls `addRedirectToWooPayEventListener` method if third-party cookies are enabled and user is logged-in', async () => { WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled.mockResolvedValue( true ); WooPayDirectCheckout.isUserLoggedIn.mockResolvedValue( true ); - WooPayDirectCheckout.getCheckoutRedirectElements.mockReturnValue( [] ); + WooPayDirectCheckout.getCheckoutButtonElements.mockReturnValue( [] ); fireEvent.load( window ); @@ -172,16 +126,12 @@ describe( 'WooPay direct checkout "updated_cart_totals" jQuery event listener', expect( WooPayDirectCheckout.maybePrefetchEncryptedSessionData ).toHaveBeenCalled(); - expect( WooPayDirectCheckout.redirectToWooPay ).toHaveBeenCalledWith( - expect.any( Array ), - true - ); + expect( + WooPayDirectCheckout.addRedirectToWooPayEventListener + ).toHaveBeenCalledWith( expect.any( Array ), true ); } ); - it( 'calls `redirectToWooPay` method with "checkout_redirect" if third-party cookies are disabled', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - true - ); + it( 'calls `addRedirectToWooPayEventListener` method with "checkout_redirect" if third-party cookies are disabled', async () => { WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled.mockResolvedValue( false ); @@ -203,10 +153,9 @@ describe( 'WooPay direct checkout "updated_cart_totals" jQuery event listener', expect( WooPayDirectCheckout.maybePrefetchEncryptedSessionData ).not.toHaveBeenCalled(); - expect( WooPayDirectCheckout.redirectToWooPay ).toHaveBeenCalledWith( - expect.any( Array ), - false - ); + expect( + WooPayDirectCheckout.addRedirectToWooPayEventListener + ).toHaveBeenCalledWith( expect.any( Array ), false ); } ); } ); diff --git a/client/checkout/woopay/direct-checkout/test/woopay-direct-checkout.test.js b/client/checkout/woopay/direct-checkout/test/woopay-direct-checkout.test.js index c2f8fbfe147..6d84377cd4b 100644 --- a/client/checkout/woopay/direct-checkout/test/woopay-direct-checkout.test.js +++ b/client/checkout/woopay/direct-checkout/test/woopay-direct-checkout.test.js @@ -4,7 +4,7 @@ import WooPayDirectCheckout from '../woopay-direct-checkout'; describe( 'WooPayDirectCheckout', () => { - describe( 'redirectToWooPay', () => { + describe( 'addRedirectToWooPayEventListener', () => { const originalLocation = window.location; let elements; @@ -44,7 +44,7 @@ describe( 'WooPayDirectCheckout', () => { element.addEventListener = jest.fn(); } ); - WooPayDirectCheckout.redirectToWooPay( elements ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( elements ); elements.forEach( ( element ) => { expect( element.addEventListener ).toHaveBeenCalledWith( @@ -55,7 +55,10 @@ describe( 'WooPayDirectCheckout', () => { } ); it( 'should add loading spinner when shortcode cart button is clicked', () => { - WooPayDirectCheckout.redirectToWooPay( elements, false ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + elements, + false + ); elements[ 0 ].click(); @@ -71,7 +74,10 @@ describe( 'WooPayDirectCheckout', () => { 'https://woopay.test/woopay?checkout_redirect=1&blog_id=1&session=1&iv=1&hash=1' ); - WooPayDirectCheckout.redirectToWooPay( elements, false ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + elements, + false + ); elements[ 0 ].click(); @@ -91,7 +97,10 @@ describe( 'WooPayDirectCheckout', () => { 'https://woopay.test/woopay?platform_checkout_key=1234567890' ); - WooPayDirectCheckout.redirectToWooPay( elements, true ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + elements, + true + ); elements[ 0 ].click(); @@ -112,7 +121,10 @@ describe( 'WooPayDirectCheckout', () => { new Error( 'Could not retrieve WooPay checkout URL.' ) ); - WooPayDirectCheckout.redirectToWooPay( elements, true ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + elements, + true + ); elements[ 0 ].click(); diff --git a/client/checkout/woopay/direct-checkout/utils.js b/client/checkout/woopay/direct-checkout/utils.js index ceaf0f24c7d..e06f4efcc4c 100644 --- a/client/checkout/woopay/direct-checkout/utils.js +++ b/client/checkout/woopay/direct-checkout/utils.js @@ -9,3 +9,31 @@ export const waitMilliseconds = ( ms ) => { setTimeout( resolve, ms ); } ); }; + +/** + * Wait for a selector to be available in the DOM. + * + * In the context of the direct checkout flow, we use this to wait for + * a button to render, that's why the default timeout is set to 2000ms. + * + * @param {string} selector The CSS selector to wait for. + * @param {Function} callback The callback function to be called when the selector is available. + * @param {integer} timeout The timeout in milliseconds. + */ +export const waitForSelector = ( selector, callback, timeout = 2000 ) => { + const startTime = Date.now(); + const checkElement = () => { + if ( Date.now() - startTime > timeout ) { + return; + } + + const element = document.querySelector( selector ); + if ( element ) { + callback( element ); + } else { + requestAnimationFrame( checkElement ); + } + }; + + requestAnimationFrame( checkElement ); +}; diff --git a/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js b/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js index 7847bd7de74..d0968cd680b 100644 --- a/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js +++ b/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js @@ -19,6 +19,8 @@ class WooPayDirectCheckout { CLASSIC_CART_PROCEED_BUTTON: '.wc-proceed-to-checkout .checkout-button', BLOCKS_CART_PROCEED_BUTTON: '.wp-block-woocommerce-proceed-to-checkout-block', + BLOCKS_MINI_CART_PROCEED_BUTTON: + 'a.wp-block-woocommerce-mini-cart-checkout-button-block', }; /** @@ -195,7 +197,7 @@ class WooPayDirectCheckout { * * @return {*[]} The checkout redirect elements. */ - static getCheckoutRedirectElements() { + static getCheckoutButtonElements() { const elements = []; const addElementBySelector = ( selector ) => { const element = document.querySelector( selector ); @@ -226,13 +228,27 @@ class WooPayDirectCheckout { ); } + /** + * Gets the mini cart 'Go to checkout' button. + * + * @return {Element} The mini cart 'Go to checkout' button. + */ + static getMiniCartProceedToCheckoutButton() { + return document.querySelector( + this.redirectElements.BLOCKS_MINI_CART_PROCEED_BUTTON + ); + } + /** * Adds a click-event listener to the given elements that redirects to the WooPay checkout page. * * @param {*[]} elements The elements to add a click-event listener to. * @param {boolean} userIsLoggedIn True if we determined the user is already logged in, false otherwise. */ - static redirectToWooPay( elements, userIsLoggedIn = false ) { + static addRedirectToWooPayEventListener( + elements, + userIsLoggedIn = false + ) { /** * Adds a loading spinner to the given element. * @@ -258,13 +274,23 @@ class WooPayDirectCheckout { }; /** - * Checks if the given element is the checkout button in the cart shortcode. + * Checks if a loading spinner should be added to the given element. * * @param {Element} element The element to check. * - * @return {boolean} True if the element is a checkout button in the cart shortcode. + * @return {boolean} True if a loading spinner should be added. */ - const isCheckoutButtonInCartShortCode = ( element ) => { + const shouldAddLoadingSpinner = ( element ) => { + // If the button is in the mini cart, add a spinner. + if ( + element.classList.contains( + 'wp-block-woocommerce-mini-cart-checkout-button-block' + ) + ) { + return true; + } + + // If the button is in the classic cart, add a spinner. const isCheckoutButton = element.classList.contains( 'checkout-button' ); @@ -288,7 +314,7 @@ class WooPayDirectCheckout { elementState.is_loading = true; - if ( isCheckoutButtonInCartShortCode( element ) ) { + if ( shouldAddLoadingSpinner( element ) ) { addLoadingSpinner( element ); } diff --git a/includes/class-wc-payments-woopay-direct-checkout.php b/includes/class-wc-payments-woopay-direct-checkout.php index 012851bc002..1cf3fbd35cb 100644 --- a/includes/class-wc-payments-woopay-direct-checkout.php +++ b/includes/class-wc-payments-woopay-direct-checkout.php @@ -20,7 +20,7 @@ class WC_Payments_WooPay_Direct_Checkout { * @return void */ public function init() { - add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] ); + add_action( 'wp_footer', [ $this, 'scripts' ] ); add_filter( 'woocommerce_create_order', [ $this, 'maybe_use_store_api_draft_order_id' ] ); } @@ -67,8 +67,7 @@ public function maybe_use_store_api_draft_order_id( $order_id ) { * @return void */ public function scripts() { - // Only enqueue the script on the cart page, for now. - if ( ! $this->is_cart_page() ) { + if ( ! $this->should_enqueue_scripts() ) { return; } @@ -88,12 +87,26 @@ public function scripts() { wp_enqueue_script( 'WCPAY_WOOPAY_DIRECT_CHECKOUT' ); } + /** + * Check if the direct checkout scripts should be enqueued on the page. + * + * Scripts should be enqueued if: + * - The current page is the cart page. + * - The current page has a cart block. + * - The current page has the blocks mini cart widget, i.e 'woocommerce_blocks_cart_enqueue_data' has been fired. + * + * @return bool True if the scripts should be enqueued, false otherwise. + */ + private function should_enqueue_scripts(): bool { + return $this->is_cart_page() || did_action( 'woocommerce_blocks_cart_enqueue_data' ) > 0; + } + /** * Check if the current page is the cart page. * * @return bool True if the current page is the cart page, false otherwise. */ - public function is_cart_page(): bool { + private function is_cart_page(): bool { return is_cart() || has_block( 'woocommerce/cart' ); } @@ -102,7 +115,7 @@ public function is_cart_page(): bool { * * @return bool True if the current page is the product page, false otherwise. */ - public function is_product_page() { + private function is_product_page() { return is_product() || wc_post_content_has_shortcode( 'product_page' ); } }