diff --git a/client/a8c-for-agencies/sections/purchases/licenses/create-site-button/index.tsx b/client/a8c-for-agencies/sections/purchases/licenses/create-site-button/index.tsx new file mode 100644 index 000000000000..c124c38ac59c --- /dev/null +++ b/client/a8c-for-agencies/sections/purchases/licenses/create-site-button/index.tsx @@ -0,0 +1,46 @@ +import { Button } from '@automattic/components'; +import { Spinner } from '@wordpress/components'; +import { useTranslate } from 'i18n-calypso'; + +import './style.scss'; + +type Props = { + isProvisioning: boolean; + isLoading: boolean; + onCreateSite: () => void; + className?: string; + primary?: boolean; + borderless?: boolean; +}; + +export default function CreateSiteButton( { + isProvisioning, + isLoading, + onCreateSite, + className, + primary, + borderless, +}: Props ) { + const translate = useTranslate(); + + return ( + + ); +} diff --git a/client/a8c-for-agencies/sections/purchases/licenses/create-site-button/style.scss b/client/a8c-for-agencies/sections/purchases/licenses/create-site-button/style.scss new file mode 100644 index 000000000000..eaba992ba69e --- /dev/null +++ b/client/a8c-for-agencies/sections/purchases/licenses/create-site-button/style.scss @@ -0,0 +1,9 @@ +.licenses-create-site-button-provisioning { + display: inline-flex; + align-items: center; + gap: 4px; + + .components-spinner { + margin: 0; + } +} diff --git a/client/a8c-for-agencies/sections/purchases/licenses/hooks/use-create-site-from-license.tsx b/client/a8c-for-agencies/sections/purchases/licenses/hooks/use-create-site-from-license.tsx new file mode 100644 index 000000000000..80a2e7aefe96 --- /dev/null +++ b/client/a8c-for-agencies/sections/purchases/licenses/hooks/use-create-site-from-license.tsx @@ -0,0 +1,74 @@ +import page from '@automattic/calypso-router'; +import { useCallback, useState } from 'react'; +import { + A4A_LICENSES_LINK, + A4A_SITES_LINK_NEEDS_SETUP, +} from 'calypso/a8c-for-agencies/components/sidebar-menu/lib/constants'; +import useFetchPendingSites from 'calypso/a8c-for-agencies/data/sites/use-fetch-pending-sites'; +import { useDispatch } from 'calypso/state'; +import { recordTracksEvent } from 'calypso/state/analytics/actions'; +import { findPendingSiteIdByLicenseKey, hasProvisioningSite } from '../lib/pending-sites'; +import LicenseSiteConfigurationsModal from '../license-site-configurations-modal'; +import usePaymentMethodGate from './use-payment-method-gate'; + +type CreateSiteFromLicense = { + onCreateSite: () => void; + isProvisioning: boolean; + isLoading: boolean; + modal: JSX.Element | null; +}; + +/** + * Lets an unassigned WordPress.com license be turned into a site without + * leaving the licenses page, using the same configuration modal the Needs + * setup page opens. Falls back to that page when the license has no pending + * site to configure. + */ +export default function useCreateSiteFromLicense( + licenseKey: string, + isClientLicense?: boolean +): CreateSiteFromLicense { + const dispatch = useDispatch(); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + + const { data: pendingSites, isLoading } = useFetchPendingSites(); + const isBlockedByMissingPaymentMethod = usePaymentMethodGate( isClientLicense ); + + const pendingSiteId = findPendingSiteIdByLicenseKey( pendingSites, licenseKey ); + + const onCreateSite = useCallback( () => { + if ( isBlockedByMissingPaymentMethod( A4A_LICENSES_LINK ) ) { + return; + } + + if ( ! pendingSiteId ) { + dispatch( + recordTracksEvent( 'calypso_a4a_licenses_create_site_redirect_needs_setup', { + license_key: licenseKey, + } ) + ); + page( A4A_SITES_LINK_NEEDS_SETUP ); + return; + } + + dispatch( + recordTracksEvent( 'calypso_a4a_licenses_create_site_modal_open', { + license_key: licenseKey, + } ) + ); + setIsModalOpen( true ); + }, [ dispatch, isBlockedByMissingPaymentMethod, licenseKey, pendingSiteId ] ); + + return { + onCreateSite, + isProvisioning: hasProvisioningSite( pendingSites ), + isLoading, + modal: + isModalOpen && pendingSiteId ? ( + setIsModalOpen( false ) } + /> + ) : null, + }; +} diff --git a/client/a8c-for-agencies/sections/purchases/licenses/hooks/use-payment-method-gate.tsx b/client/a8c-for-agencies/sections/purchases/licenses/hooks/use-payment-method-gate.tsx new file mode 100644 index 000000000000..f36fa67574bb --- /dev/null +++ b/client/a8c-for-agencies/sections/purchases/licenses/hooks/use-payment-method-gate.tsx @@ -0,0 +1,47 @@ +import { useTranslate } from 'i18n-calypso'; +import { useCallback } from 'react'; +import { addQueryArgs } from 'calypso/lib/url'; +import { useDispatch } from 'calypso/state'; +import { errorNotice } from 'calypso/state/notices/actions'; +import usePaymentMethod from '../../payment-methods/hooks/use-payment-method'; + +/** + * Guards license actions that cost money. Returns a predicate that reports + * whether the action can't proceed, having told the agency why and where to + * come back to. Client licenses are billed to the client, so they skip it. + */ +export default function usePaymentMethodGate( isClientLicense?: boolean ) { + const dispatch = useDispatch(); + const translate = useTranslate(); + + const { paymentMethodRequired } = usePaymentMethod(); + + return useCallback( + ( returnUrl: string ) => { + if ( ! paymentMethodRequired || isClientLicense ) { + return false; + } + + const noticeLinkHref = addQueryArgs( + { + return: returnUrl, + }, + '/purchases/payment-methods/add' + ); + const errorMessage = translate( + 'A primary payment method is required.{{br/}} ' + + '{{a}}Try adding a new payment method{{/a}} or contact support.', + { + components: { + a: , + br:
, + }, + } + ); + + dispatch( errorNotice( errorMessage ) ); + return true; + }, + [ dispatch, isClientLicense, paymentMethodRequired, translate ] + ); +} diff --git a/client/a8c-for-agencies/sections/purchases/licenses/lib/pending-sites.ts b/client/a8c-for-agencies/sections/purchases/licenses/lib/pending-sites.ts new file mode 100644 index 000000000000..a6a1e8580ff4 --- /dev/null +++ b/client/a8c-for-agencies/sections/purchases/licenses/lib/pending-sites.ts @@ -0,0 +1,38 @@ +export type PendingSite = { + id: number; + features?: { + wpcom_atomic?: { + license_key: string; + state: string; + }; + }; +}; + +/** + * The pending site a license will become once the agency configures it. Only + * sites still in the `pending` state can be created; anything further along is + * already on its way to the sites dashboard. + */ +export function findPendingSiteIdByLicenseKey( + pendingSites: PendingSite[] | undefined, + licenseKey: string +): number | null { + return ( + pendingSites?.find( + ( { features }: PendingSite ) => + features?.wpcom_atomic?.license_key === licenseKey && + features?.wpcom_atomic?.state === 'pending' + )?.id ?? null + ); +} + +/** + * Mirrors the Needs setup page: provisioning is treated as an account-wide + * state, so no other site can be created while one is being built. + */ +export function hasProvisioningSite( pendingSites: PendingSite[] | undefined ): boolean { + return !! pendingSites?.some( + ( { features }: PendingSite ) => + features?.wpcom_atomic?.state === 'provisioning' && !! features?.wpcom_atomic?.license_key + ); +} diff --git a/client/a8c-for-agencies/sections/purchases/licenses/lib/test/pending-sites.test.ts b/client/a8c-for-agencies/sections/purchases/licenses/lib/test/pending-sites.test.ts new file mode 100644 index 000000000000..a98192efdb81 --- /dev/null +++ b/client/a8c-for-agencies/sections/purchases/licenses/lib/test/pending-sites.test.ts @@ -0,0 +1,59 @@ +import { findPendingSiteIdByLicenseKey, hasProvisioningSite } from '../pending-sites'; +import type { PendingSite } from '../pending-sites'; + +const pendingSite = ( id: number, license_key: string, state: string ): PendingSite => ( { + id, + features: { wpcom_atomic: { license_key, state } }, +} ); + +describe( 'findPendingSiteIdByLicenseKey', () => { + it( 'returns the id of the pending site behind the license', () => { + const sites = [ + pendingSite( 1, 'wpcom-hosting-business_aaa', 'pending' ), + pendingSite( 2, 'wpcom-hosting-business_bbb', 'pending' ), + ]; + + expect( findPendingSiteIdByLicenseKey( sites, 'wpcom-hosting-business_bbb' ) ).toBe( 2 ); + } ); + + it( 'ignores sites that are no longer pending', () => { + const sites = [ pendingSite( 1, 'wpcom-hosting-business_aaa', 'provisioning' ) ]; + + expect( findPendingSiteIdByLicenseKey( sites, 'wpcom-hosting-business_aaa' ) ).toBeNull(); + } ); + + it( 'returns null when no site matches the license', () => { + const sites = [ pendingSite( 1, 'wpcom-hosting-business_aaa', 'pending' ) ]; + + expect( findPendingSiteIdByLicenseKey( sites, 'wpcom-hosting-business_zzz' ) ).toBeNull(); + } ); + + it( 'returns null while pending sites are still loading', () => { + expect( findPendingSiteIdByLicenseKey( undefined, 'wpcom-hosting-business_aaa' ) ).toBeNull(); + } ); +} ); + +describe( 'hasProvisioningSite', () => { + it( 'reports provisioning across any site, not just the one being looked at', () => { + const sites = [ + pendingSite( 1, 'wpcom-hosting-business_aaa', 'pending' ), + pendingSite( 2, 'wpcom-hosting-business_bbb', 'provisioning' ), + ]; + + expect( hasProvisioningSite( sites ) ).toBe( true ); + } ); + + it( 'is false when every site is still pending', () => { + expect( + hasProvisioningSite( [ pendingSite( 1, 'wpcom-hosting-business_aaa', 'pending' ) ] ) + ).toBe( false ); + } ); + + it( 'ignores provisioning sites without a license key', () => { + expect( hasProvisioningSite( [ pendingSite( 1, '', 'provisioning' ) ] ) ).toBe( false ); + } ); + + it( 'is false while pending sites are still loading', () => { + expect( hasProvisioningSite( undefined ) ).toBe( false ); + } ); +} ); diff --git a/client/a8c-for-agencies/sections/purchases/licenses/license-details/actions.tsx b/client/a8c-for-agencies/sections/purchases/licenses/license-details/actions.tsx index 222f6a763745..62ed82f77b61 100644 --- a/client/a8c-for-agencies/sections/purchases/licenses/license-details/actions.tsx +++ b/client/a8c-for-agencies/sections/purchases/licenses/license-details/actions.tsx @@ -7,7 +7,6 @@ import { A4A_MARKETPLACE_ASSIGN_LICENSE_LINK, A4A_MARKETPLACE_HOSTING_PRESSABLE_LINK, A4A_MARKETPLACE_HOSTING_WPCOM_LINK, - A4A_SITES_LINK_NEEDS_SETUP, EXTERNAL_PRESSABLE_AUTH_URL, } from 'calypso/a8c-for-agencies/components/sidebar-menu/lib/constants'; import { @@ -23,6 +22,8 @@ import { hasAgencyCapability } from 'calypso/state/a8c-for-agencies/agency/selec import { A4AStore } from 'calypso/state/a8c-for-agencies/types'; import { recordTracksEvent } from 'calypso/state/analytics/actions'; import { errorNotice } from 'calypso/state/notices/actions'; +import CreateSiteButton from '../create-site-button'; +import useCreateSiteFromLicense from '../hooks/use-create-site-from-license'; import useLicenseDownloadUrlMutation from '../revoke-license-dialog/hooks/use-license-download-url-mutation'; import type { LicenseSubscription } from 'calypso/state/partner-portal/types'; @@ -71,9 +72,14 @@ export default function LicenseDetailsActions( { const debugUrl = siteUrl ? `https://jptools.wordpress.com/debug/?url=${ siteUrl }` : null; const downloadUrl = useLicenseDownloadUrlMutation( licenseKey ); - const redirectUrl = isWPCOMHostingLicense - ? A4A_SITES_LINK_NEEDS_SETUP - : addQueryArgs( { key: licenseKey }, A4A_MARKETPLACE_ASSIGN_LICENSE_LINK ); + const assignLicenseUrl = addQueryArgs( { key: licenseKey }, A4A_MARKETPLACE_ASSIGN_LICENSE_LINK ); + + const { + onCreateSite, + isProvisioning, + isLoading: isLoadingPendingSites, + modal: siteConfigurationsModal, + } = useCreateSiteFromLicense( licenseKey, isClientLicense ); const openRevokeDialog = useCallback( () => { setRevokeDialog( true ); @@ -182,11 +188,27 @@ export default function LicenseDetailsActions( { { ! isPressableAddonLicense && licenseState === LicenseState.Detached && - licenseType === LicenseType.Partner && ( - - ) } + ) ) } + + { siteConfigurationsModal } { revokeDialog && ( { - const redirectUrl = isWPCOMLicense - ? A4A_SITES_LINK_NEEDS_SETUP - : addQueryArgs( { key: licenseKey }, '/marketplace/assign-license' ); - if ( paymentMethodRequired && ! referral ) { - const noticeLinkHref = addQueryArgs( - { - return: redirectUrl, - }, - '/purchases/payment-methods/add' - ); - const errorMessage = translate( - 'A primary payment method is required.{{br/}} ' + - '{{a}}Try adding a new payment method{{/a}} or contact support.', - { - components: { - a:
, - br:
, - }, - } - ); + const redirectUrl = addQueryArgs( { key: licenseKey }, '/marketplace/assign-license' ); - dispatch( errorNotice( errorMessage ) ); + if ( isBlockedByMissingPaymentMethod( redirectUrl ) ) { return; } page( redirectUrl ); - }, [ isWPCOMLicense, licenseKey, paymentMethodRequired, referral, translate, dispatch ] ); + }, [ isBlockedByMissingPaymentMethod, licenseKey ] ); useEffect( () => { if ( isHighlighted ) { @@ -316,16 +304,26 @@ export default function LicensePreview( { { ! domain && licenseState === LicenseState.Detached && ! isPressableAddonLicense && ( { translate( 'Unassigned' ) } - { licenseType === LicenseType.Partner && ! isPressableAddonLicense && ( - - ) } + { licenseType === LicenseType.Partner && + ! isPressableAddonLicense && + ( isWPCOMLicense ? ( + + ) : ( + + ) ) } ) } { revokedAt && ( @@ -431,6 +429,7 @@ export default function LicensePreview( { isDevSite={ isDevelopmentSite } /> ) ) } + { siteConfigurationsModal } ); } diff --git a/client/a8c-for-agencies/sections/purchases/licenses/license-site-configurations-modal.tsx b/client/a8c-for-agencies/sections/purchases/licenses/license-site-configurations-modal.tsx new file mode 100644 index 000000000000..9f30f385c607 --- /dev/null +++ b/client/a8c-for-agencies/sections/purchases/licenses/license-site-configurations-modal.tsx @@ -0,0 +1,28 @@ +import SiteConfigurationsModal from 'calypso/a8c-for-agencies/components/site-configurations-modal'; +import { useRandomSiteName } from 'calypso/a8c-for-agencies/components/site-configurations-modal/use-random-site-name'; +import useSiteCreatedCallback from 'calypso/a8c-for-agencies/hooks/use-site-created-callback'; + +type Props = { + siteId: number; + closeModal: () => void; +}; + +/** + * Wraps the site configuration modal so `useRandomSiteName` — two uncached + * requests on mount — only runs once the modal opens, rather than once per + * license row on the licenses page. + */ +export default function LicenseSiteConfigurationsModal( { siteId, closeModal }: Props ) { + const { randomSiteName, isRandomSiteNameLoading, refetchRandomSiteName } = useRandomSiteName(); + const onCreateSiteSuccess = useSiteCreatedCallback( refetchRandomSiteName ); + + return ( + + ); +}