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 && (
-