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. *