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 562ddcf272..573ecad293 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,20 @@ import GridiconNoticeOutline from 'gridicons/dist/notice-outline'; * Internal dependencies */ import useCountryKeyNameMap from '.~/hooks/useCountryKeyNameMap'; -import useFetchBudgetRecommendationEffect from './useFetchBudgetRecommendationEffect'; +import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation'; 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.
Tip: Most merchants who sell products in 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.
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 +35,9 @@ function toRecommendationRange( isMultiple, ...values ) { const BudgetRecommendation = ( props ) => { const { countryCodes, dailyAverageCost = Infinity } = props; - const { data } = useFetchBudgetRecommendationEffect( countryCodes ); + const { data, highestDailyBudgetCountryCode, highestDailyBudget } = + useFetchBudgetRecommendation( countryCodes ); + const map = useCountryKeyNameMap(); if ( ! data ) { @@ -59,18 +45,15 @@ const BudgetRecommendation = ( props ) => { } const { currency, recommendations } = data; - const { daily_budget: dailyBudget, country } = - getHighestBudget( recommendations ); - - const countryName = map[ country ]; + const countryName = map[ highestDailyBudgetCountryCode ]; const recommendationRange = toRecommendationRange( recommendations.length > 1, - dailyBudget, + highestDailyBudget, currency, countryName ); - const showLowerBudgetNotice = dailyAverageCost < dailyBudget; + const showLowerBudgetNotice = dailyAverageCost < highestDailyBudget; return (
diff --git a/js/src/components/paid-ads/budget-section/budget-recommendation/useFetchBudgetRecommendationEffect.js b/js/src/components/paid-ads/budget-section/budget-recommendation/useFetchBudgetRecommendationEffect.js deleted file mode 100644 index 6d63fad522..0000000000 --- a/js/src/components/paid-ads/budget-section/budget-recommendation/useFetchBudgetRecommendationEffect.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * External dependencies - */ -import { addQueryArgs } from '@wordpress/url'; - -/** - * Internal dependencies - */ -import { API_NAMESPACE } from '.~/data/constants'; -import useApiFetchEffect from '.~/hooks/useApiFetchEffect'; - -/** - * @typedef { import(".~/data/actions").CountryCode } CountryCode - */ - -/** - * Fetch the budget recommendation for a country in a side effect. - * - * @param {Array} countryCodes Country code array. - * @return {Object} Budget recommendation. - */ -const useFetchBudgetRecommendationEffect = ( countryCodes ) => { - const url = `${ API_NAMESPACE }/ads/campaigns/budget-recommendation`; - const query = { country_codes: countryCodes }; - const path = addQueryArgs( url, query ); - return useApiFetchEffect( { path } ); -}; - -export default useFetchBudgetRecommendationEffect; diff --git a/js/src/components/paid-ads/budget-section/index.js b/js/src/components/paid-ads/budget-section/index.js index 87f2b940fc..121c74976a 100644 --- a/js/src/components/paid-ads/budget-section/index.js +++ b/js/src/components/paid-ads/budget-section/index.js @@ -2,7 +2,6 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { useEffect, useRef } from '@wordpress/element'; /** * Internal dependencies @@ -39,30 +38,13 @@ const BudgetSection = ( { disabled = false, children, } ) => { - const { getInputProps, setValue, values } = formProps; + const { getInputProps, values } = formProps; const { amount } = values; const { googleAdsAccount } = useGoogleAdsAccount(); const monthlyMaxEstimated = getMonthlyMaxEstimated( amount ); // Display the currency code that will be used by Google Ads, but still use the store's currency formatting settings. const currency = googleAdsAccount?.currency; - // Wrapping `useRef` is because since WC 6.9, the reference of `setValue` may be changed - // after calling itself and further leads to an infinite re-rendering loop if used in a - // `useEffect`. - 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..25a1cd99fd 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,54 @@ 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' + ) + ); + } +} + +getAdsBudgetRecommendations.shouldInvalidate = ( action ) => { + return action.type === TYPES.DISCONNECT_ACCOUNTS_GOOGLE_ADS; +}; diff --git a/js/src/data/selectors.js b/js/src/data/selectors.js index 123b09f7d5..4240c3e0a9 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 to retrieve the budget recommendations for. + * @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..a42b7bd171 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..bea96033ce --- /dev/null +++ b/js/src/hooks/useFetchBudgetRecommendation.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from '.~/data/constants'; +import getHighestBudget from '.~/utils/getHighestBudget'; + +/** + * @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 ); + let highestDailyBudget = 0; + let highestDailyBudgetCountryCode; + + if ( data ) { + const { recommendations } = data; + ( { + daily_budget: highestDailyBudget, + country: highestDailyBudgetCountryCode, + } = getHighestBudget( recommendations ) ); + } + + return { + data, + highestDailyBudget, + highestDailyBudgetCountryCode, + hasFinishedResolution: hasFinishedResolution( + 'getAdsBudgetRecommendations', + [ countryCodes ] + ), + }; + }, + [ countryCodes ] + ); +}; + +export default useFetchBudgetRecommendation; diff --git a/js/src/pages/create-paid-ads-campaign/index.js b/js/src/pages/create-paid-ads-campaign/index.js index ae16722d76..3a2596af86 100644 --- a/js/src/pages/create-paid-ads-campaign/index.js +++ b/js/src/pages/create-paid-ads-campaign/index.js @@ -33,6 +33,7 @@ import { recordStepperChangeEvent, recordStepContinueEvent, } from '.~/utils/tracks'; +import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation'; const eventName = 'gla_paid_campaign_step'; const eventContext = 'create-ads'; @@ -52,6 +53,8 @@ const CreatePaidAdsCampaign = () => { const { createAdsCampaign, updateCampaignAssetGroup } = useAppDispatch(); const { createNotice } = useDispatchCoreNotices(); const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const { highestDailyBudget, hasFinishedResolution } = + useFetchBudgetRecommendation( countryCodes ); const handleStepperClick = ( nextStep ) => { recordStepperChangeEvent( @@ -114,7 +117,7 @@ const CreatePaidAdsCampaign = () => { getHistory().push( getDashboardUrl( { campaign: 'saved' } ) ); }; - if ( ! countryCodes ) { + if ( ! countryCodes || ! hasFinishedResolution ) { return null; } @@ -130,7 +133,7 @@ const CreatePaidAdsCampaign = () => { /> diff --git a/js/src/setup-ads/ads-stepper/index.js b/js/src/setup-ads/ads-stepper/index.js index 51fe990ffb..5a7d70cdc3 100644 --- a/js/src/setup-ads/ads-stepper/index.js +++ b/js/src/setup-ads/ads-stepper/index.js @@ -19,12 +19,10 @@ import { import SetupPaidAds from './setup-paid-ads'; /** - * @param {Object} props React props - * @param {boolean} props.isSubmitting When the form in the parent component, i.e SetupAdsForm, is currently being submitted via the useAdsSetupCompleteCallback hook. * @fires gla_setup_ads with `{ triggered_by: 'step1-continue-button', action: 'go-to-step2' }`. * @fires gla_setup_ads with `{ triggered_by: 'stepper-step1-button', action: 'go-to-step1'}`. */ -const AdsStepper = ( { isSubmitting } ) => { +const AdsStepper = () => { const [ step, setStep ] = useState( '1' ); useEventPropertiesFilter( FILTER_ONBOARDING, { @@ -84,7 +82,7 @@ const AdsStepper = ( { isSubmitting } ) => { 'Create your paid campaign', 'google-listings-and-ads' ), - content: , + content: , onClick: handleStepClick, }, ] } diff --git a/js/src/setup-ads/ads-stepper/setup-paid-ads.js b/js/src/setup-ads/ads-stepper/setup-paid-ads.js index 6e38e09c92..905d51bc2d 100644 --- a/js/src/setup-ads/ads-stepper/setup-paid-ads.js +++ b/js/src/setup-ads/ads-stepper/setup-paid-ads.js @@ -2,6 +2,9 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; +import { isEqual } from 'lodash'; +import { useState, useEffect } from '@wordpress/element'; +import { getNewPath } from '@woocommerce/navigation'; /** * Internal dependencies @@ -9,6 +12,14 @@ import { __ } from '@wordpress/i18n'; import AppButton from '.~/components/app-button'; import AdsCampaign from '.~/components/paid-ads/ads-campaign'; import useGoogleAdsAccountBillingStatus from '.~/hooks/useGoogleAdsAccountBillingStatus'; +import useAdminUrl from '.~/hooks/useAdminUrl'; +import useNavigateAwayPromptEffect from '.~/hooks/useNavigateAwayPromptEffect'; +import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; +import useAdsSetupCompleteCallback from '.~/hooks/useAdsSetupCompleteCallback'; +import CampaignAssetsForm from '.~/components/paid-ads/campaign-assets-form'; +import { recordGlaEvent } from '.~/utils/tracks'; +import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation'; +import AppSpinner from '.~/components/app-spinner'; import { GOOGLE_ADS_BILLING_STATUS } from '.~/constants'; const { APPROVED } = GOOGLE_ADS_BILLING_STATUS; @@ -16,35 +27,93 @@ const { APPROVED } = GOOGLE_ADS_BILLING_STATUS; /** * Renders the step to setup paid ads * - * @param {Object} props Component props. - * @param {boolean} props.isSubmitting Indicates if the form is currently being submitted. + * @fires gla_launch_paid_campaign_button_click on submit */ -const SetupPaidAds = ( { isSubmitting } ) => { +const SetupPaidAds = () => { const { billingStatus } = useGoogleAdsAccountBillingStatus(); + const [ didFormChanged, setFormChanged ] = useState( false ); + const [ isSubmitted, setSubmitted ] = useState( false ); + const [ handleSetupComplete, isSubmitting ] = useAdsSetupCompleteCallback(); + const adminUrl = useAdminUrl(); + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const { highestDailyBudget, hasFinishedResolution } = + useFetchBudgetRecommendation( countryCodes ); + + const initialValues = { + amount: highestDailyBudget, + }; + + useEffect( () => { + if ( isSubmitted ) { + // Force reload WC admin page to initiate the relevant dependencies of the Dashboard page. + const nextPath = getNewPath( + { guide: 'campaign-creation-success' }, + '/google/dashboard' + ); + window.location.href = adminUrl + nextPath; + } + }, [ isSubmitted, adminUrl ] ); + + const shouldPreventLeave = didFormChanged && ! isSubmitted; + + useNavigateAwayPromptEffect( + __( + 'You have unsaved campaign data. Are you sure you want to leave?', + 'google-listings-and-ads' + ), + shouldPreventLeave + ); + + const handleSubmit = ( values ) => { + const { amount } = values; + + recordGlaEvent( 'gla_launch_paid_campaign_button_click', { + audiences: countryCodes.join( ',' ), + budget: amount, + } ); + + handleSetupComplete( amount, countryCodes, () => { + setSubmitted( true ); + } ); + }; + + const handleChange = ( _, values ) => { + setFormChanged( ! isEqual( initialValues, values ) ); + }; + + if ( ! countryCodes || ! hasFinishedResolution ) { + return ; + } return ( - ( - - ) } - /> + + ( + + ) } + /> + ); }; diff --git a/js/src/setup-ads/index.js b/js/src/setup-ads/index.js index f191ada062..2f05026b64 100644 --- a/js/src/setup-ads/index.js +++ b/js/src/setup-ads/index.js @@ -2,12 +2,18 @@ * Internal dependencies */ import useLayout from '.~/hooks/useLayout'; -import SetupAdsForm from './setup-ads-form'; +import SetupAdsTopBar from './top-bar'; +import AdsStepper from './ads-stepper'; const SetupAds = () => { useLayout( 'full-page' ); - return ; + return ( + <> + + + + ); }; export default SetupAds; diff --git a/js/src/setup-ads/setup-ads-form.js b/js/src/setup-ads/setup-ads-form.js deleted file mode 100644 index 74b298db71..0000000000 --- a/js/src/setup-ads/setup-ads-form.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * External dependencies - */ -import { isEqual } from 'lodash'; -import { __ } from '@wordpress/i18n'; -import { useState, useEffect } from '@wordpress/element'; -import { getNewPath } from '@woocommerce/navigation'; - -/** - * Internal dependencies - */ -import useAdminUrl from '.~/hooks/useAdminUrl'; -import useNavigateAwayPromptEffect from '.~/hooks/useNavigateAwayPromptEffect'; -import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; -import useAdsSetupCompleteCallback from '.~/hooks/useAdsSetupCompleteCallback'; -import CampaignAssetsForm from '.~/components/paid-ads/campaign-assets-form'; -import AdsStepper from './ads-stepper'; -import SetupAdsTopBar from './top-bar'; -import { recordGlaEvent } from '.~/utils/tracks'; - -/** - * @fires gla_launch_paid_campaign_button_click on submit - */ -const SetupAdsForm = () => { - const [ didFormChanged, setFormChanged ] = useState( false ); - const [ isSubmitted, setSubmitted ] = useState( false ); - const [ handleSetupComplete, isSubmitting ] = useAdsSetupCompleteCallback(); - const adminUrl = useAdminUrl(); - const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); - - const initialValues = { - amount: 0, - }; - - useEffect( () => { - if ( isSubmitted ) { - // Force reload WC admin page to initiate the relevant dependencies of the Dashboard page. - const nextPath = getNewPath( - { guide: 'campaign-creation-success' }, - '/google/dashboard' - ); - window.location.href = adminUrl + nextPath; - } - }, [ isSubmitted, adminUrl ] ); - - const shouldPreventLeave = didFormChanged && ! isSubmitted; - - useNavigateAwayPromptEffect( - __( - 'You have unsaved campaign data. Are you sure you want to leave?', - 'google-listings-and-ads' - ), - shouldPreventLeave - ); - - const handleSubmit = ( values ) => { - const { amount } = values; - - recordGlaEvent( 'gla_launch_paid_campaign_button_click', { - audiences: countryCodes.join( ',' ), - budget: amount, - } ); - - handleSetupComplete( amount, countryCodes, () => { - setSubmitted( true ); - } ); - }; - - const handleChange = ( _, values ) => { - setFormChanged( ! isEqual( initialValues, values ) ); - }; - - if ( ! countryCodes ) { - return null; - } - - return ( - - - - - ); -}; - -export default SetupAdsForm; diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads.js b/js/src/setup-mc/setup-stepper/setup-paid-ads.js index f5911f282d..2bb83b2e9f 100644 --- a/js/src/setup-mc/setup-stepper/setup-paid-ads.js +++ b/js/src/setup-mc/setup-stepper/setup-paid-ads.js @@ -23,6 +23,8 @@ import { GUIDE_NAMES, GOOGLE_ADS_BILLING_STATUS } from '.~/constants'; import { ACTION_COMPLETE, ACTION_SKIP } from './constants'; import SkipButton from './skip-button'; import clientSession from './clientSession'; +import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation'; +import AppSpinner from '.~/components/app-spinner'; /** * Clicking on the "Complete setup" button to complete the onboarding flow with paid ads. @@ -42,6 +44,8 @@ export default function SetupPaidAds() { const [ completing, setCompleting ] = useState( null ); const { createNotice } = useDispatchCoreNotices(); const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const { highestDailyBudget, hasFinishedResolution } = + useFetchBudgetRecommendation( countryCodes ); const [ handleSetupComplete ] = useAdsSetupCompleteCallback(); const { billingStatus } = useGoogleAdsAccountBillingStatus(); @@ -125,15 +129,21 @@ export default function SetupPaidAds() { }; const paidAds = { - amount: 0, + amount: highestDailyBudget, ...clientSession.getCampaign(), }; + if ( ! hasFinishedResolution || ! countryCodes ) { + return ; + } + return ( { - clientSession.setCampaign( { ...values } ); + if ( values.amount >= highestDailyBudget ) { + clientSession.setCampaign( values ); + } } } > { + if ( challenger.daily_budget > defender.daily_budget ) { + return challenger; + } + return defender; + } ); +} diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js index 987a7111ae..48ec190682 100644 --- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js +++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js @@ -294,10 +294,6 @@ test.describe( 'Set up Ads account', () => { page.getByRole( 'heading', { name: 'Set your budget' } ) ).toBeVisible(); - await expect( - setupBudgetPage.getLaunchPaidCampaignButton() - ).toBeDisabled(); - await expect( page.getByRole( 'link', { name: 'See what your ads will look like.', 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 bb164ff592..86eeab1af5 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. *