diff --git a/js/src/components/paid-ads/ads-campaign/clientSession.js b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/clientSession.js
similarity index 100%
rename from js/src/components/paid-ads/ads-campaign/clientSession.js
rename to js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/clientSession.js
diff --git a/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/index.js b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/index.js
new file mode 100644
index 0000000000..478d76b757
--- /dev/null
+++ b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/index.js
@@ -0,0 +1 @@
+export { default } from './paid-ads-setup-sections';
diff --git a/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections.js b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/paid-ads-setup-form.js
similarity index 88%
rename from js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections.js
rename to js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/paid-ads-setup-form.js
index c7a967ada1..386da41b30 100644
--- a/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections.js
+++ b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/paid-ads-setup-form.js
@@ -10,8 +10,6 @@ import { Form } from '@woocommerce/components';
import useGoogleAdsAccountBillingStatus from '.~/hooks/useGoogleAdsAccountBillingStatus';
import BudgetSection from '.~/components/paid-ads/budget-section';
import BillingCard from '.~/components/paid-ads/billing-card';
-import SpinnerCard from '.~/components/spinner-card';
-import Section from '.~/wcdl/section';
import validateCampaign from '.~/components/paid-ads/validateCampaign';
import clientSession from './clientSession';
import CampaignPreviewCard from '.~/components/paid-ads/campaign-preview/campaign-preview-card';
@@ -27,11 +25,7 @@ import { GOOGLE_ADS_BILLING_STATUS } from '.~/constants';
*/
/**
- *
- * @typedef {Object} PaidAdsData
- * @property {number|undefined} amount Daily average cost of the paid ads campaign.
- * @property {boolean} isValid Whether the campaign data are valid values.
- * @property {boolean} isReady Whether the campaign data and the billing setting are ready for completing the paid ads setup.
+ * @typedef {import('./paid-ads-setup-sections').PaidAdsData} PaidAdsData
*/
const defaultPaidAds = {
@@ -64,13 +58,15 @@ function resolveInitialPaidAds( paidAds ) {
* @param {Campaign} [props.campaign] Campaign data to be edited. If not provided, this component will show campaign creation UI.
* @param {boolean} [props.showCampaignPreviewCard=false] Whether to show the campaign preview card.
* @param {boolean} [props.loadCampaignFromClientSession=false] Whether to load the campaign data from the client session.
+ * @param {number} props.recommendedBudget The recommended budget.
*/
-export default function PaidAdsSetupSections( {
+export default function PaidAdsSetupForm( {
onStatesReceived,
countryCodes,
campaign,
loadCampaignFromClientSession,
showCampaignPreviewCard = false,
+ recommendedBudget,
} ) {
const isCreation = ! campaign;
const { billingStatus } = useGoogleAdsAccountBillingStatus();
@@ -79,17 +75,32 @@ export default function PaidAdsSetupSections( {
onStatesReceivedRef.current = onStatesReceived;
const [ paidAds, setPaidAds ] = useState( () => {
- // Resolve the starting paid ads data with the campaign data stored in the client session.
let startingPaidAds = {
...defaultPaidAds,
};
+ // If we are creating a new campaign, set the amount with the recommended daily amount.
+ if ( ! campaign ) {
+ startingPaidAds = {
+ ...startingPaidAds,
+ amount: recommendedBudget,
+ };
+ }
+
+ // Resolve the starting paid ads data with the campaign data stored in the client session if any.
if ( loadCampaignFromClientSession ) {
+ const initialAmount = Math.max(
+ clientSession.getCampaign()?.amount || 0,
+ recommendedBudget
+ );
+
startingPaidAds = {
...startingPaidAds,
...clientSession.getCampaign(),
+ amount: initialAmount,
};
}
+
return resolveInitialPaidAds( startingPaidAds );
} );
@@ -119,14 +130,6 @@ export default function PaidAdsSetupSections( {
clientSession.setCampaign( nextPaidAds );
}, [ paidAds, isBillingCompleted ] );
- if ( ! billingStatus ) {
- return (
-
-
-
- );
- }
-
const initialValues = {
amount: isCreation ? paidAds.amount : campaign.amount,
};
diff --git a/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/paid-ads-setup-sections.js b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/paid-ads-setup-sections.js
new file mode 100644
index 0000000000..16cfccddee
--- /dev/null
+++ b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections/paid-ads-setup-sections.js
@@ -0,0 +1,66 @@
+/**
+ * Internal dependencies
+ */
+import useGoogleAdsAccountBillingStatus from '.~/hooks/useGoogleAdsAccountBillingStatus';
+import SpinnerCard from '.~/components/spinner-card';
+import Section from '.~/wcdl/section';
+import PaidAdsSetupForm from './paid-ads-setup-form';
+import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation';
+import getHighestBudget from '.~/utils/getHighestBudget';
+
+/**
+ *
+ * @typedef {import('.~/data/actions').Campaign} Campaign
+ */
+
+/**
+ * @typedef {import('.~/data/actions').CountryCode} CountryCode
+ */
+
+/**
+ *
+ * @typedef {Object} PaidAdsData
+ * @property {number|undefined} amount Daily average cost of the paid ads campaign.
+ * @property {boolean} isValid Whether the campaign data are valid values.
+ * @property {boolean} isReady Whether the campaign data and the billing setting are ready for completing the paid ads setup.
+ */
+
+/**
+ * Renders sections of Google Ads account, budget and billing for setting up the paid ads.
+ *
+ * @param {Object} props React props.
+ * @param {(onStatesReceived: PaidAdsData)=>void} props.onStatesReceived Callback to receive the data for setting up paid ads when initial and also when the budget and billing are updated.
+ * @param {Array|undefined} props.countryCodes Country codes for the campaign.
+ * @param {Campaign} [props.campaign] Campaign data to be edited. If not provided, this component will show campaign creation UI.
+ * @param {boolean} [props.showCampaignPreviewCard=false] Whether to show the campaign preview card.
+ * @param {boolean} [props.loadCampaignFromClientSession=false] Whether to load the campaign data from the client session.
+ */
+export default function PaidAdsSetupSections( props ) {
+ const { billingStatus } = useGoogleAdsAccountBillingStatus();
+ const { hasFinishedResolution, data } = useFetchBudgetRecommendation(
+ props.countryCodes
+ );
+
+ let recommendedBudget;
+ if ( data ) {
+ const { recommendations } = data;
+ const { daily_budget: dailyBudget } =
+ getHighestBudget( recommendations );
+ recommendedBudget = dailyBudget;
+ }
+
+ if ( ! billingStatus || ! hasFinishedResolution ) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/js/src/components/paid-ads/budget-section/budget-recommendation/index.js b/js/src/components/paid-ads/budget-section/budget-recommendation/index.js
index 2492685c36..14613bb40b 100644
--- a/js/src/components/paid-ads/budget-section/budget-recommendation/index.js
+++ b/js/src/components/paid-ads/budget-section/budget-recommendation/index.js
@@ -10,36 +10,21 @@ import GridiconNoticeOutline from 'gridicons/dist/notice-outline';
* Internal dependencies
*/
import useCountryKeyNameMap from '.~/hooks/useCountryKeyNameMap';
-import useFetchBudgetRecommendationEffect from './useFetchBudgetRecommendationEffect';
+import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation';
+import getHighestBudget from '.~/utils/getHighestBudget';
import './index.scss';
-/*
- * If a merchant selects more than one country, the budget recommendation
- * takes the highest country out from the selected countries.
- *
- * For example, a merchant selected Brunei (20 USD) and Croatia (15 USD),
- * then the budget recommendation should be (20 USD).
- */
-function getHighestBudget( recommendations ) {
- return recommendations.reduce( ( defender, challenger ) => {
- if ( challenger.daily_budget > defender.daily_budget ) {
- return challenger;
- }
- return defender;
- } );
-}
-
function toRecommendationRange( isMultiple, ...values ) {
const conversionMap = { strong: , em: , br: };
const template = isMultiple
? // translators: it's a range of recommended budget amount. 1: the value of the budget, 2: the currency of amount.
__(
- 'Google will optimize your ads to maximize performance across the country/s you select. Tip: Most merchants targeting similar countries set a daily budget of %1$f %2$s',
+ 'We recommend running campaigns at least 1 month so it can learn to optimize for your business. Tip: Most merchants targeting similar countries set a daily budget of %1$f %2$s',
'google-listings-and-ads'
)
: // translators: it's a range of recommended budget amount. 1: the value of the budget, 2: the currency of amount 3: a country name selected by the merchant.
__(
- 'Google will optimize your ads to maximize performance across the country/s you select. Tip: Most merchants targeting %3$s set a daily budget of %1$f %2$s',
+ 'We recommend running campaigns at least 1 month so it can learn to optimize for your business. Tip: Most merchants targeting %3$s set a daily budget of %1$f %2$s',
'google-listings-and-ads'
);
@@ -51,7 +36,7 @@ function toRecommendationRange( isMultiple, ...values ) {
const BudgetRecommendation = ( props ) => {
const { countryCodes, dailyAverageCost = Infinity } = props;
- const { data } = useFetchBudgetRecommendationEffect( countryCodes );
+ const { data } = useFetchBudgetRecommendation( countryCodes );
const map = useCountryKeyNameMap();
if ( ! data ) {
diff --git a/js/src/components/paid-ads/budget-section/index.js b/js/src/components/paid-ads/budget-section/index.js
index 87f2b940fc..f905b7cf9f 100644
--- a/js/src/components/paid-ads/budget-section/index.js
+++ b/js/src/components/paid-ads/budget-section/index.js
@@ -2,7 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
-import { useEffect, useRef } from '@wordpress/element';
+import { useRef } from '@wordpress/element';
/**
* Internal dependencies
@@ -29,7 +29,7 @@ const nonInteractableProps = {
*
* @param {Object} props React props.
* @param {Object} props.formProps Form props forwarded from `Form` component.
- * @param {Array|undefined} props.countryCodes Country codes to fetch budget recommendations for.
+ * @param {Array} props.countryCodes Country codes to fetch budget recommendations for.
* @param {boolean} [props.disabled=false] Whether display the Card in disabled style.
* @param {JSX.Element} [props.children] Extra content to be rendered under the card of budget inputs.
*/
@@ -52,17 +52,6 @@ const BudgetSection = ( {
const setValueRef = useRef();
setValueRef.current = setValue;
- /**
- * In addition to the initial value setting during initialization, when `disabled` changes
- * - from false to true, then clear filled amount to `undefined` for showing a blank .
- * - from true to false, then reset amount to the initial value passed from the consumer side.
- */
- const initialAmountRef = useRef( amount );
- useEffect( () => {
- const nextAmount = disabled ? undefined : initialAmountRef.current;
- setValueRef.current( 'amount', nextAmount );
- }, [ disabled ] );
-
return (
{
.end();
}
+ case TYPES.RECEIVE_ADS_BUDGET_RECOMMENDATIONS: {
+ const { countryCodesKey, currency, recommendations } = action;
+
+ return setIn(
+ state,
+ [ 'ads', 'budgetRecommendations', countryCodesKey ],
+ {
+ currency,
+ recommendations,
+ }
+ );
+ }
+
// Page will be reloaded after all accounts have been disconnected, so no need to mutate state.
case TYPES.DISCONNECT_ACCOUNTS_ALL:
default:
diff --git a/js/src/data/resolvers.js b/js/src/data/resolvers.js
index c29cb52bdd..39eb137906 100644
--- a/js/src/data/resolvers.js
+++ b/js/src/data/resolvers.js
@@ -15,7 +15,7 @@ import {
} from '.~/constants';
import TYPES from './action-types';
import { API_NAMESPACE } from './constants';
-import { getReportKey } from './utils';
+import { getReportKey, getCountryCodesKey } from './utils';
import { handleApiError } from '.~/utils/handleError';
import { adaptAdsCampaign, adaptAssetGroup } from './adapters';
import { fetchWithHeaders, awaitPromise } from './controls';
@@ -48,6 +48,10 @@ import {
receiveTour,
} from './actions';
+/**
+ * @typedef {import('.~/data/actions').CountryCode} CountryCode
+ */
+
export function* getShippingRates() {
yield fetchShippingRates();
}
@@ -510,3 +514,50 @@ export function* getGoogleAdsAccountStatus() {
getGoogleAdsAccountStatus.shouldInvalidate = ( action ) => {
return action.type === TYPES.DISCONNECT_ACCOUNTS_GOOGLE_ADS;
};
+
+/**
+ * Fetch ad budget recommendations for the specified country codes.
+ *
+ * @param {Array} [countryCodes] An array of country codes for which to fetch budget recommendations.
+ */
+export function* getAdsBudgetRecommendations( countryCodes ) {
+ if ( ! countryCodes || ! countryCodes.length ) {
+ return;
+ }
+
+ const countryCodesKey = getCountryCodesKey( countryCodes );
+ const endpoint = `${ API_NAMESPACE }/ads/campaigns/budget-recommendation`;
+ const query = { country_codes: countryCodes };
+ const path = addQueryArgs( endpoint, query );
+
+ try {
+ const { data } = yield fetchWithHeaders( {
+ path,
+ } );
+
+ const { currency, recommendations } = data;
+
+ return {
+ type: TYPES.RECEIVE_ADS_BUDGET_RECOMMENDATIONS,
+ countryCodesKey,
+ currency,
+ recommendations,
+ };
+ } catch ( response ) {
+ // Intentionally silence the specific in case the no budget recommendations are found from the API.
+ if ( response.status === 404 ) {
+ return;
+ }
+
+ const bodyPromise = response?.json() || response?.text();
+ const error = yield awaitPromise( bodyPromise );
+
+ handleApiError(
+ error,
+ __(
+ 'There was an error getting the budget recommendation.',
+ 'google-listings-and-ads'
+ )
+ );
+ }
+}
diff --git a/js/src/data/selectors.js b/js/src/data/selectors.js
index 123b09f7d5..ea14cc3eb3 100644
--- a/js/src/data/selectors.js
+++ b/js/src/data/selectors.js
@@ -8,7 +8,12 @@ import createSelector from 'rememo';
* Internal dependencies
*/
import { STORE_KEY } from './constants';
-import { getReportQuery, getReportKey, getPerformanceQuery } from './utils';
+import {
+ getReportQuery,
+ getReportKey,
+ getPerformanceQuery,
+ getCountryCodesKey,
+} from './utils';
/**
* @typedef {import('.~/data/actions').CountryCode} CountryCode
@@ -406,3 +411,16 @@ export const getTour = ( state, tourId ) => {
export const getGoogleAdsAccountStatus = ( state ) => {
return state.ads.accountStatus;
};
+
+/**
+ * Retrieves ad budget recommendations for provided country codes.
+ * If no recommendations are found, it returns `null`.
+ *
+ * @param {Object} state The state
+ * @param {Array} [countryCodes] - An array of country code strings used to generate a unique key.
+ * @return {Object|null} The recommendations. It will be `null` if not yet fetched or fetched but doesn't exist.
+ */
+export const getAdsBudgetRecommendations = ( state, countryCodes = [] ) => {
+ const key = getCountryCodesKey( countryCodes );
+ return state.ads.budgetRecommendations[ key ] || null;
+};
diff --git a/js/src/data/test/reducer.test.js b/js/src/data/test/reducer.test.js
index b48277b36a..35416da5b3 100644
--- a/js/src/data/test/reducer.test.js
+++ b/js/src/data/test/reducer.test.js
@@ -72,6 +72,7 @@ describe( 'reducer', () => {
inviteLink: null,
step: null,
},
+ budgetRecommendations: {},
},
} );
@@ -865,6 +866,39 @@ describe( 'reducer', () => {
} );
} );
+ describe( 'Ads Budget Recommendations', () => {
+ const path = 'ads.budgetRecommendations';
+
+ it( 'should receive a budget recommendation', () => {
+ const recommendation = {
+ countryCodesKey: 'mu_sg',
+ currency: 'MUR',
+ recommendations: [
+ {
+ country: 'MU',
+ daily_budget: 15,
+ },
+ {
+ country: 'SG',
+ daily_budget: 10,
+ },
+ ],
+ };
+
+ const action = {
+ type: TYPES.RECEIVE_ADS_BUDGET_RECOMMENDATIONS,
+ ...recommendation,
+ };
+ const state = reducer( prepareState(), action );
+
+ state.assertConsistentRef();
+ expect( state ).toHaveProperty( `${ path }.mu_sg`, {
+ currency: recommendation.currency,
+ recommendations: recommendation.recommendations,
+ } );
+ } );
+ } );
+
describe( 'Remaining actions simply update the data payload to the specific path of state and return the updated state', () => {
// The readability is better than applying the formatting here.
/* eslint-disable prettier/prettier */
diff --git a/js/src/data/utils.js b/js/src/data/utils.js
index 2de2394f78..45b66cf8bc 100644
--- a/js/src/data/utils.js
+++ b/js/src/data/utils.js
@@ -9,6 +9,10 @@ import { getCurrentDates } from '@woocommerce/date';
*/
import round from '.~/utils/round';
+/**
+ * @typedef { import(".~/data/actions").CountryCode } CountryCode
+ */
+
export const freeFields = [ 'clicks', 'impressions' ];
export const paidFields = [ 'sales', 'conversions', 'spend', ...freeFields ];
/**
@@ -190,6 +194,20 @@ export function mapReportFieldsToPerformance(
);
}
+/**
+ * Generates a unique key (slug) from an array of country codes.
+ *
+ * This function sorts the array of country codes alphabetically,
+ * joins them into a single string with underscore (`_`), and converts
+ * the result to lowercase.
+ *
+ * @param {Array} countryCodes - An array of country code strings.
+ * @return {string} A underscore-separated, lowercase string representing the sorted country codes.
+ */
+export function getCountryCodesKey( countryCodes = [] ) {
+ return [ ...countryCodes ].sort().join( '_' ).toLowerCase();
+}
+
/**
* Report fields fetched from report API.
*
diff --git a/js/src/hooks/useFetchBudgetRecommendation.js b/js/src/hooks/useFetchBudgetRecommendation.js
new file mode 100644
index 0000000000..12184a0e87
--- /dev/null
+++ b/js/src/hooks/useFetchBudgetRecommendation.js
@@ -0,0 +1,40 @@
+/**
+ * External dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_KEY } from '.~/data/constants';
+
+/**
+ * @typedef { import(".~/data/actions").CountryCode } CountryCode
+ */
+
+/**
+ * Fetch the highest budget recommendation for countries in a side effect.
+ *
+ * @param {Array} [countryCodes] An array of country codes. If empty, the fetch will not be triggered.
+ * @return {Object} Budget recommendation.
+ */
+const useFetchBudgetRecommendation = ( countryCodes ) => {
+ return useSelect(
+ ( select ) => {
+ const { getAdsBudgetRecommendations, hasFinishedResolution } =
+ select( STORE_KEY );
+
+ const data = getAdsBudgetRecommendations( countryCodes );
+ return {
+ data,
+ hasFinishedResolution: hasFinishedResolution(
+ 'getAdsBudgetRecommendations',
+ [ countryCodes ]
+ ),
+ };
+ },
+ [ countryCodes ]
+ );
+};
+
+export default useFetchBudgetRecommendation;
diff --git a/js/src/components/paid-ads/budget-section/budget-recommendation/useFetchBudgetRecommendationEffect.js b/js/src/hooks/useFetchBudgetRecommendationEffect.js
similarity index 100%
rename from js/src/components/paid-ads/budget-section/budget-recommendation/useFetchBudgetRecommendationEffect.js
rename to js/src/hooks/useFetchBudgetRecommendationEffect.js
diff --git a/js/src/utils/getHighestBudget.js b/js/src/utils/getHighestBudget.js
new file mode 100644
index 0000000000..e2fef429e9
--- /dev/null
+++ b/js/src/utils/getHighestBudget.js
@@ -0,0 +1,19 @@
+/*
+ * If a merchant selects more than one country, the budget recommendation
+ * takes the highest country out from the selected countries.
+ *
+ * For example, a merchant selected Brunei (20 USD) and Croatia (15 USD),
+ * then the budget recommendation should be (20 USD).
+ */
+export default function getHighestBudget( recommendations ) {
+ if ( ! recommendations ) {
+ return null;
+ }
+
+ return recommendations.reduce( ( defender, challenger ) => {
+ if ( challenger.daily_budget > defender.daily_budget ) {
+ return challenger;
+ }
+ return defender;
+ } );
+}
diff --git a/tests/e2e/specs/setup-mc/step-4-complete-campaign.test.js b/tests/e2e/specs/setup-mc/step-4-complete-campaign.test.js
index adc31ea614..e41184030b 100644
--- a/tests/e2e/specs/setup-mc/step-4-complete-campaign.test.js
+++ b/tests/e2e/specs/setup-mc/step-4-complete-campaign.test.js
@@ -79,6 +79,24 @@ test.describe( 'Complete your campaign', () => {
[ 'GET' ]
),
+ completeCampaign.fulfillBudgetRecommendations( {
+ currency: 'USD',
+ recommendations: [
+ {
+ country: 'US',
+ daily_budget: 10,
+ },
+ {
+ country: 'TW',
+ daily_budget: 8,
+ },
+ {
+ country: 'GB',
+ daily_budget: 20,
+ },
+ ],
+ } ),
+
// The following mocks are requests will happen after completing the onboarding
completeCampaign.mockSuccessfulSettingsSyncRequest(),
@@ -195,6 +213,14 @@ test.describe( 'Complete your campaign', () => {
} );
test.describe( 'Set up budget', () => {
+ test( '"Daily average cost" input should have highest value set', async () => {
+ const dailyAverageCostInput =
+ setupBudgetPage.getBudgetInput();
+ await expect( dailyAverageCostInput ).toHaveValue(
+ '20.00'
+ );
+ } );
+
test( 'should see the low budget tip when the buget is set lower than the recommended value', async () => {
await setupBudgetPage.fillBudget( '1' );
const lowBudgetTip = setupBudgetPage.getLowerBudgetTip();
diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js
index ed0122e79f..4230c8c50d 100644
--- a/tests/e2e/utils/mock-requests.js
+++ b/tests/e2e/utils/mock-requests.js
@@ -365,6 +365,21 @@ export default class MockRequests {
);
}
+ /**
+ * Fulfill the budget recommendations request.
+ *
+ * @param {Object} payload
+ * @return {Promise}
+ */
+ async fulfillBudgetRecommendations( payload ) {
+ await this.fulfillRequest(
+ /\/wc\/gla\/ads\/campaigns\/budget-recommendation\b/,
+ payload,
+ 200,
+ [ 'GET' ]
+ );
+ }
+
/**
* Mock the request to connect Jetpack
*
diff --git a/tests/e2e/utils/pages/setup-ads/setup-budget.js b/tests/e2e/utils/pages/setup-ads/setup-budget.js
index afb0875017..c34983d2a4 100644
--- a/tests/e2e/utils/pages/setup-ads/setup-budget.js
+++ b/tests/e2e/utils/pages/setup-ads/setup-budget.js
@@ -12,6 +12,26 @@ export default class SetupBudget extends MockRequests {
this.page = page;
}
+ /**
+ * Get budget recommendation tip section.
+ *
+ * @return {import('@playwright/test').Locator} The budget recommendation tip.
+ */
+ getBudgetRecommendationTip() {
+ return this.page.locator(
+ '.gla-budget-recommendation > .components-tip'
+ );
+ }
+
+ /**
+ * Get budget recommendation text row.
+ *
+ * @return {import('@playwright/test').Locator} The budget recommendation text row.
+ */
+ getBudgetRecommendationTextRow() {
+ return this.page.locator( '.components-tip p > em > strong' );
+ }
+
/**
* Get budget input.
*