From 21f69b0c1644e05592b1e5f5cfb9b94d5127bdce Mon Sep 17 00:00:00 2001 From: Dylan Munson <65001528+CodeyGuyDylan@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:23:13 -0600 Subject: [PATCH] Fix/interstitial button loading states (#39356) * Fix interstitial button loading states * changelog --- .../components/product-detail-card/index.jsx | 82 +++++++++++++++---- .../components/product-detail-table/index.jsx | 32 ++++++-- .../components/product-interstitial/index.jsx | 5 +- .../_inc/data/products/use-activate.ts | 7 +- .../fix-interstitial-button-loading-states | 4 + 5 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 projects/packages/my-jetpack/changelog/fix-interstitial-button-loading-states diff --git a/projects/packages/my-jetpack/_inc/components/product-detail-card/index.jsx b/projects/packages/my-jetpack/_inc/components/product-detail-card/index.jsx index 97156dabd0fb4..81d8be3b6be23 100644 --- a/projects/packages/my-jetpack/_inc/components/product-detail-card/index.jsx +++ b/projects/packages/my-jetpack/_inc/components/product-detail-card/index.jsx @@ -13,7 +13,7 @@ import { ExternalLink } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { Icon, check, plus } from '@wordpress/icons'; import clsx from 'clsx'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import useProduct from '../../data/products/use-product'; import { getMyJetpackWindowInitialState } from '../../data/utils/get-my-jetpack-window-state'; import useAnalytics from '../../hooks/use-analytics'; @@ -68,7 +68,8 @@ function Price( { value, currency, isOld } ) { * @param {boolean} [props.hideTOS] - Whether to hide the Terms of Service text * @param {number} [props.quantity] - The quantity of the product to purchase * @param {boolean} [props.highlightLastFeature] - Whether to highlight the last feature of the list of features - * @param {boolean} [props.isFetching] - Whether the product is being fetched + * @param {boolean} [props.isFetching] - Whether the product is being activated + * @param {boolean} [props.isFetchingSuccess] - Whether the product was activated successfully * @return {object} ProductDetailCard react component. */ const ProductDetailCard = ( { @@ -83,6 +84,7 @@ const ProductDetailCard = ( { quantity = null, highlightLastFeature = false, isFetching = false, + isFetchingSuccess = false, } ) => { const { fileSystemWriteAccess = 'no', @@ -364,31 +366,31 @@ const ProductDetailCard = ( { ) } { ( ! isBundle || ( isBundle && ! hasPaidPlanForProduct ) ) && ( - - { ctaLabel } - + label={ ctaLabel } + /> ) } { ! isBundle && trialAvailable && ! hasPaidPlanForProduct && ( - - { __( 'Start for free', 'jetpack-my-jetpack' ) } - + label={ __( 'Start for free', 'jetpack-my-jetpack' ) } + /> ) } { disclaimers.length > 0 && ( @@ -434,4 +436,52 @@ const ProductDetailCard = ( { ); }; +const ProductDetailCardButton = ( { + component, + onClick, + hasMainCheckoutStarted, + isFetching, + isFetchingSuccess, + cantInstallPlugin, + isPrimary, + className, + label, +} ) => { + const [ isButtonLoading, setIsButtonLoading ] = useState( false ); + + useEffect( () => { + // If activation was successful, we will be redirecting the user + // so we don't want them to be able to click the button again. + if ( ! isFetching && ! isFetchingSuccess ) { + setIsButtonLoading( false ); + } + }, [ isFetching, isFetchingSuccess ] ); + + // If a button was clicked, we should only show the loading state for that button. + const shouldShowLoadingState = hasMainCheckoutStarted || isButtonLoading; + // If the any buttons are loading, or we are in the process + // of rediredcting the user, we should disable all buttons. + const shouldDisableButton = + hasMainCheckoutStarted || cantInstallPlugin || isFetching || isFetchingSuccess; + + const handleClick = () => { + setIsButtonLoading( true ); + onClick(); + }; + + return ( + + { label } + + ); +}; + export default ProductDetailCard; diff --git a/projects/packages/my-jetpack/_inc/components/product-detail-table/index.jsx b/projects/packages/my-jetpack/_inc/components/product-detail-table/index.jsx index 18a70e2e7ac2f..96882dd816d17 100644 --- a/projects/packages/my-jetpack/_inc/components/product-detail-table/index.jsx +++ b/projects/packages/my-jetpack/_inc/components/product-detail-table/index.jsx @@ -11,7 +11,7 @@ import { import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; import { sprintf, __ } from '@wordpress/i18n'; import PropTypes from 'prop-types'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState, useEffect } from 'react'; import useProduct from '../../data/products/use-product'; import { getMyJetpackWindowInitialState } from '../../data/utils/get-my-jetpack-window-state'; import { useRedirectToReferrer } from '../../hooks/use-redirect-to-referrer'; @@ -25,7 +25,8 @@ import { useRedirectToReferrer } from '../../hooks/use-redirect-to-referrer'; * @param {boolean} props.cantInstallPlugin - True when the plugin cannot be automatically installed. * @param {Function} props.onProductButtonClick - Click handler for the product button. * @param {object} props.detail - Product detail object. - * @param {boolean} props.isFetching - True if there is a pending request to load the product. + * @param {boolean} props.isFetching - True if there is a pending request to activate the product. + * @param {boolean} props.isFetchingSuccess - True if the product activation has been successful. * @param {string} props.tier - Product tier slug, i.e. 'free' or 'upgraded'. * @param {Function} props.trackProductButtonClick - Tracks click event for the product button. * @param {boolean} props.preferProductName - Whether to show the product name instead of the title. @@ -37,11 +38,13 @@ const ProductDetailTableColumn = ( { onProductButtonClick, detail, isFetching, + isFetchingSuccess, tier, trackProductButtonClick, preferProductName, feature, } ) => { + const [ isButtonLoading, setIsButtonLoading ] = useState( false ); const { siteSuffix = '', myJetpackCheckoutUri = '' } = getMyJetpackWindowInitialState(); // Extract the product details. @@ -67,6 +70,14 @@ const ProductDetailTableColumn = ( { quantity = null, } = tiersPricingForUi[ tier ]; + useEffect( () => { + // If activation was successful, we will be redirecting the user + // so we don't want them to be able to click the button again. + if ( ! isFetching && ! isFetchingSuccess ) { + setIsButtonLoading( false ); + } + }, [ isFetching, isFetchingSuccess ] ); + // Redirect to the referrer URL when the `redirect_to_referrer` query param is present. const referrerURL = useRedirectToReferrer(); @@ -145,6 +156,7 @@ const ProductDetailTableColumn = ( { // Register the click handler for the product button. const onClick = useCallback( () => { + setIsButtonLoading( true ); trackProductButtonClick( { is_free_plan: isFree, cta_text: callToAction } ); onProductButtonClick?.( runCheckout, detail, tier ); }, [ @@ -157,6 +169,13 @@ const ProductDetailTableColumn = ( { callToAction, ] ); + // If a button was clicked, we should only show the loading state for that button. + const shouldShowLoadingState = hasCheckoutStarted || isButtonLoading; + // If the any buttons are loading, or we are in the process + // of rediredcting the user, we should disable all buttons. + const shouldDisableButton = + hasCheckoutStarted || cantInstallPlugin || isFetching || isFetchingSuccess; + return ( @@ -178,8 +197,8 @@ const ProductDetailTableColumn = ( { fullWidth variant={ isFree ? 'secondary' : 'primary' } onClick={ onClick } - isLoading={ hasCheckoutStarted || isFetching } - disabled={ hasCheckoutStarted || cantInstallPlugin || isFetching } + isLoading={ shouldShowLoadingState } + disabled={ shouldDisableButton } > { callToAction } @@ -240,7 +259,8 @@ ProductDetailTableColumn.propTypes = { * @param {string} props.slug - Product slug. * @param {Function} props.onProductButtonClick - Click handler for the product button. * @param {Function} props.trackProductButtonClick - Tracks click event for the product button. - * @param {boolean} props.isFetching - True if there is a pending request to load the product. + * @param {boolean} props.isFetching - True if there is a pending request to activate the product. + * @param {boolean} props.isFetchingSuccess - True if the product activation has been successful. * @param {boolean} props.preferProductName - Whether to show the product name instead of the title. * @param {string} props.feature - The slug of a specific product feature to highlight. * @return {object} - ProductDetailTable react component. @@ -250,6 +270,7 @@ const ProductDetailTable = ( { onProductButtonClick, trackProductButtonClick, isFetching, + isFetchingSuccess, preferProductName, feature, } ) => { @@ -334,6 +355,7 @@ const ProductDetailTable = ( { feature={ feature } detail={ detail } isFetching={ isFetching } + isFetchingSuccess={ isFetchingSuccess } onProductButtonClick={ onProductButtonClick } trackProductButtonClick={ trackProductButtonClick } primary={ index === 0 } diff --git a/projects/packages/my-jetpack/_inc/components/product-interstitial/index.jsx b/projects/packages/my-jetpack/_inc/components/product-interstitial/index.jsx index 6dac803921a02..52177cf8c2e05 100644 --- a/projects/packages/my-jetpack/_inc/components/product-interstitial/index.jsx +++ b/projects/packages/my-jetpack/_inc/components/product-interstitial/index.jsx @@ -77,7 +77,7 @@ export default function ProductInterstitial( { } ) { const { detail } = useProduct( slug ); const { detail: bundleDetail } = useProduct( bundle ); - const { activate, isPending: isActivating } = useActivate( slug ); + const { activate, isPending: isActivating, isSuccess } = useActivate( slug ); // Get the post activation URL for the product. let redirectUri = detail?.postActivationUrl || null; @@ -242,6 +242,7 @@ export default function ProductInterstitial( { trackProductButtonClick={ trackProductOrBundleClick } preferProductName={ preferProductName } isFetching={ isActivating || siteIsRegistering } + isFetchingSuccess={ isSuccess } feature={ feature } /> ) : ( @@ -264,6 +265,7 @@ export default function ProductInterstitial( { quantity={ quantity } highlightLastFeature={ highlightLastFeature } isFetching={ isActivating || siteIsRegistering } + isFetchingSuccess={ isSuccess } /> ) : ( children diff --git a/projects/packages/my-jetpack/_inc/data/products/use-activate.ts b/projects/packages/my-jetpack/_inc/data/products/use-activate.ts index caa548cb6eb3d..7c6c463644a39 100644 --- a/projects/packages/my-jetpack/_inc/data/products/use-activate.ts +++ b/projects/packages/my-jetpack/_inc/data/products/use-activate.ts @@ -33,7 +33,11 @@ const useActivate = ( productId: string ) => { const { detail, refetch } = useProduct( productId ); const { recordEvent } = useAnalytics(); - const { mutate: activate, isPending } = useSimpleMutation( { + const { + mutate: activate, + isPending, + isSuccess, + } = useSimpleMutation( { name: QUERY_ACTIVATE_PRODUCT_KEY, query: { path: `${ REST_API_SITE_PRODUCTS_ENDPOINT }/${ productId }`, @@ -64,6 +68,7 @@ const useActivate = ( productId: string ) => { return { activate, isPending, + isSuccess, }; }; diff --git a/projects/packages/my-jetpack/changelog/fix-interstitial-button-loading-states b/projects/packages/my-jetpack/changelog/fix-interstitial-button-loading-states new file mode 100644 index 0000000000000..e0e8442e9ed8c --- /dev/null +++ b/projects/packages/my-jetpack/changelog/fix-interstitial-button-loading-states @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fix issue on interstitials show both buttons loading when only one is pressed