diff --git a/js/src/components/app-input-control/index.scss b/js/src/components/app-input-control/index.scss index 770d2ef41d..bd6f808a9a 100644 --- a/js/src/components/app-input-control/index.scss +++ b/js/src/components/app-input-control/index.scss @@ -29,11 +29,13 @@ color: $gray-700; } + &.has-error .components-input-control__backdrop, &--error-character-count .components-input-control .components-input-control__container .components-input-control__backdrop { border-color: $alert-red; box-shadow: none; } + &.has-error .components-base-control__help, &--error-character-count &__character-count { color: $alert-red; } 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.js index c7a967ada1..68247c7e73 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.js @@ -12,7 +12,7 @@ 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 useValidateCampaignWithCountryCodes from '.~/hooks/useValidateCampaignWithCountryCodes'; import clientSession from './clientSession'; import CampaignPreviewCard from '.~/components/paid-ads/campaign-preview/campaign-preview-card'; import { GOOGLE_ADS_BILLING_STATUS } from '.~/constants'; @@ -40,41 +40,26 @@ const defaultPaidAds = { isReady: false, }; -/** - * Resolve the initial paid ads data from the given paid ads data. - * Parts of the resolved data are used in the `initialValues` prop of `Form` component. - * - * @param {PaidAdsData} paidAds The paid ads data as the base to be resolved with other states. - * @return {PaidAdsData} The resolved paid ads data. - */ -function resolveInitialPaidAds( paidAds ) { - const nextPaidAds = { ...paidAds }; - nextPaidAds.isValid = ! Object.keys( validateCampaign( nextPaidAds ) ) - .length; - - return nextPaidAds; -} - /** * Renders sections of Google Ads account, budget and billing for setting up the paid ads. + * Waits for the validate campaign with country codes function to be loaded before rendering the form. * * @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( { onStatesReceived, - countryCodes, campaign, loadCampaignFromClientSession, showCampaignPreviewCard = false, } ) { + const { validateCampaignWithCountryCodes, loaded } = + useValidateCampaignWithCountryCodes(); const isCreation = ! campaign; const { billingStatus } = useGoogleAdsAccountBillingStatus(); - const onStatesReceivedRef = useRef(); onStatesReceivedRef.current = onStatesReceived; @@ -90,7 +75,7 @@ export default function PaidAdsSetupSections( { ...clientSession.getCampaign(), }; } - return resolveInitialPaidAds( startingPaidAds ); + return startingPaidAds; } ); const isBillingCompleted = @@ -111,15 +96,20 @@ export default function PaidAdsSetupSections( { For example, refresh page during onboarding flow after the billing setup is finished. */ useEffect( () => { + const isValid = ! Object.keys( + validateCampaignWithCountryCodes( paidAds ) + ).length; const nextPaidAds = { ...paidAds, - isReady: paidAds.isValid && isBillingCompleted, + isValid, + isReady: isValid && isBillingCompleted, }; + onStatesReceivedRef.current( nextPaidAds ); clientSession.setCampaign( nextPaidAds ); - }, [ paidAds, isBillingCompleted ] ); + }, [ paidAds, isBillingCompleted, validateCampaignWithCountryCodes ] ); - if ( ! billingStatus ) { + if ( ! billingStatus || ! loaded ) { return (
@@ -137,14 +127,11 @@ export default function PaidAdsSetupSections( { onChange={ ( _, values, isValid ) => { setPaidAds( { ...paidAds, ...values, isValid } ); } } - validate={ validateCampaign } + validate={ validateCampaignWithCountryCodes } > { ( formProps ) => { return ( - + { showCampaignPreviewCard && } 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..bdca6b0250 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,7 +10,7 @@ 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'; /* @@ -51,7 +51,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/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..7f54a042d8 100644 --- a/js/src/components/paid-ads/budget-section/index.js +++ b/js/src/components/paid-ads/budget-section/index.js @@ -13,6 +13,7 @@ import './index.scss'; import BudgetRecommendation from './budget-recommendation'; import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; import AppInputPriceControl from '.~/components/app-input-price-control'; +import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; /** * @typedef {import('.~/data/actions').CountryCode} CountryCode @@ -29,16 +30,11 @@ 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 {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. */ -const BudgetSection = ( { - formProps, - countryCodes, - disabled = false, - children, -} ) => { +const BudgetSection = ( { formProps, disabled = false, children } ) => { + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); const { getInputProps, setValue, values } = formProps; const { amount } = values; const { googleAdsAccount } = useGoogleAdsAccount(); diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js index 717045e894..9e1c8fcea5 100644 --- a/js/src/components/paid-ads/campaign-assets-form.js +++ b/js/src/components/paid-ads/campaign-assets-form.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useState, useMemo } from '@wordpress/element'; +import { useState, useMemo, useEffect, useRef } from '@wordpress/element'; import { isPlainObject } from 'lodash'; /** @@ -9,8 +9,8 @@ import { isPlainObject } from 'lodash'; */ import { ASSET_GROUP_KEY, ASSET_FORM_KEY } from '.~/constants'; import AdaptiveForm from '.~/components/adaptive-form'; -import validateCampaign from '.~/components/paid-ads/validateCampaign'; import validateAssetGroup from '.~/components/paid-ads/validateAssetGroup'; +import useValidateCampaignWithCountryCodes from '.~/hooks/useValidateCampaignWithCountryCodes'; /** * @typedef {import('.~/components/types.js').CampaignFormValues} CampaignFormValues @@ -65,6 +65,7 @@ function convertAssetEntityGroupToFormValues( assetEntityGroup = {} ) { * @augments AdaptiveForm * @param {Object} props React props. * @param {CampaignFormValues} props.initialCampaign Initial campaign values. + * @param {Function} [props.onChange] Callback when the form values change. * @param {AssetEntityGroup} [props.assetEntityGroup] The asset entity group to be used in initializing the form values for editing. */ export default function CampaignAssetsForm( { @@ -72,12 +73,25 @@ export default function CampaignAssetsForm( { assetEntityGroup, ...adaptiveFormProps } ) { + const formRef = useRef(); const initialAssetGroup = useMemo( () => { return convertAssetEntityGroupToFormValues( assetEntityGroup ); }, [ assetEntityGroup ] ); - const [ baseAssetGroup, setBaseAssetGroup ] = useState( initialAssetGroup ); const [ hasImportedAssets, setHasImportedAssets ] = useState( false ); + const { validateCampaignWithCountryCodes, dailyBudget, loaded } = + useValidateCampaignWithCountryCodes(); + + useEffect( () => { + if ( loaded ) { + const { setValue } = formRef.current; + + // Simulate a form value change to refresh the validation function once again with the new budget values + // If the validation function and values do not change, then the validation will not be triggerred since the `validate` + // function uses useCallback and will not be re-created. + setValue( 'dailyBudget', dailyBudget ); + } + }, [ dailyBudget, loaded ] ); const extendAdapter = ( formContext ) => { const assetGroupErrors = validateAssetGroup( formContext.values ); @@ -119,11 +133,12 @@ export default function CampaignAssetsForm( { return ( diff --git a/js/src/components/paid-ads/campaign-assets-form.test.js b/js/src/components/paid-ads/campaign-assets-form.test.js index 8eeed69502..dbf37b694d 100644 --- a/js/src/components/paid-ads/campaign-assets-form.test.js +++ b/js/src/components/paid-ads/campaign-assets-form.test.js @@ -10,6 +10,33 @@ import userEvent from '@testing-library/user-event'; */ import CampaignAssetsForm from './campaign-assets-form'; +jest.mock( '@wordpress/api-fetch', () => { + const impl = jest.fn().mockName( '@wordpress/api-fetch' ); + impl.use = jest.fn().mockName( 'apiFetch.use' ); + return impl; +} ); + +jest.mock( '@woocommerce/settings', () => ( { + getSetting: jest + .fn() + .mockName( "getSetting( 'currency' )" ) + .mockReturnValue( { + code: 'EUR', + symbol: '€', + precision: 2, + decimalSeparator: '.', + thousandSeparator: ',', + priceFormat: '%1$s %2$s', + } ), +} ) ); + +jest.mock( '.~/hooks/useFetchBudgetRecommendation', () => ( { + __esModule: true, + default: jest.fn().mockImplementation( () => { + return [ jest.fn(), null ]; + } ), +} ) ); + const alwaysValid = () => ( {} ); describe( 'CampaignAssetsForm', () => { diff --git a/js/src/components/paid-ads/validateCampaign.js b/js/src/components/paid-ads/validateCampaign.js index e058068014..fe3d595c24 100644 --- a/js/src/components/paid-ads/validateCampaign.js +++ b/js/src/components/paid-ads/validateCampaign.js @@ -1,26 +1,61 @@ /** * External dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; /** * @typedef {import('.~/components/types.js').CampaignFormValues} CampaignFormValues */ +/** + * @typedef {Object} ValidateCampaignOptions + * @property {number | undefined} dailyBudget Daily budget for the campaign. + * @property {Function} formatAmount A function to format the budget amount according to the currency settings. + */ + +// Minimum percentage of the recommended daily budget. +const BUDGET_MIN_PERCENT = 0.3; + /** * Validate campaign form. Accepts the form values object and returns errors object. * * @param {CampaignFormValues} values Campaign form values. + * @param {ValidateCampaignOptions} opts Extra form options. * @return {Object} errors. */ -const validateCampaign = ( values ) => { +const validateCampaign = ( values, opts ) => { const errors = {}; + if ( + Number.isFinite( values.amount ) && + Number.isFinite( opts?.dailyBudget ) + ) { + const { amount } = values; + const { dailyBudget, formatAmount } = opts; + + const minAmount = Math.ceil( dailyBudget * BUDGET_MIN_PERCENT ); + + if ( amount < parseFloat( minAmount ) ) { + return { + amount: sprintf( + /* translators: %1$s: minimum daily budget */ + __( + 'Please make sure daily average cost is at least %s', + 'google-listings-and-ads' + ), + formatAmount( minAmount ) + ), + }; + } + } + if ( ! Number.isFinite( values.amount ) || values.amount <= 0 ) { - errors.amount = __( - 'Please make sure daily average cost is greater than 0.', - 'google-listings-and-ads' - ); + return { + amount: __( + 'Please make sure daily average cost is greater than 0.', + 'google-listings-and-ads' + ), + }; } return errors; diff --git a/js/src/components/paid-ads/validateCampaign.test.js b/js/src/components/paid-ads/validateCampaign.test.js index efd4480dac..a9ca4edb24 100644 --- a/js/src/components/paid-ads/validateCampaign.test.js +++ b/js/src/components/paid-ads/validateCampaign.test.js @@ -9,6 +9,10 @@ import validateCampaign from './validateCampaign'; */ describe( 'validateCampaign', () => { let values; + const validateCampaignOptions = { + dailyBudget: undefined, + formatAmount: jest.mock(), + }; beforeEach( () => { // Initial values @@ -16,15 +20,18 @@ describe( 'validateCampaign', () => { } ); it( 'When all checks are passed, should return an empty object', () => { - const errors = validateCampaign( { - amount: 1, - } ); + const errors = validateCampaign( + { + amount: 1, + }, + validateCampaignOptions + ); expect( errors ).toStrictEqual( {} ); } ); it( 'should indicate multiple unpassed checks by setting properties in the returned object', () => { - const errors = validateCampaign( values ); + const errors = validateCampaign( values, validateCampaignOptions ); expect( errors ).toHaveProperty( 'amount' ); } ); @@ -33,25 +40,25 @@ describe( 'validateCampaign', () => { let errors; values.amount = ''; - errors = validateCampaign( values ); + errors = validateCampaign( values, validateCampaignOptions ); expect( errors ).toHaveProperty( 'amount' ); expect( errors.amount ).toMatchSnapshot(); values.amount = undefined; - errors = validateCampaign( values ); + errors = validateCampaign( values, validateCampaignOptions ); expect( errors ).toHaveProperty( 'amount' ); expect( errors.amount ).toMatchSnapshot(); values.amount = new Date(); - errors = validateCampaign( values ); + errors = validateCampaign( values, validateCampaignOptions ); expect( errors ).toHaveProperty( 'amount' ); expect( errors.amount ).toMatchSnapshot(); values.amount = NaN; - errors = validateCampaign( values ); + errors = validateCampaign( values, validateCampaignOptions ); expect( errors ).toHaveProperty( 'amount' ); expect( errors.amount ).toMatchSnapshot(); @@ -61,15 +68,56 @@ describe( 'validateCampaign', () => { let errors; values.amount = 0; - errors = validateCampaign( values ); + errors = validateCampaign( values, validateCampaignOptions ); expect( errors ).toHaveProperty( 'amount' ); expect( errors.amount ).toMatchSnapshot(); values.amount = -0.01; - errors = validateCampaign( values ); + errors = validateCampaign( values, validateCampaignOptions ); expect( errors ).toHaveProperty( 'amount' ); expect( errors.amount ).toMatchSnapshot(); } ); + + it( 'When a budget is provided and the amount is less than the minimum, should not pass', () => { + const mockFormatAmount = jest.fn().mockReturnValue( 'Rs 30' ); + values.amount = 10; + + const opts = { + dailyBudget: 100, + formatAmount: mockFormatAmount, + }; + + const errors = validateCampaign( values, opts ); + + expect( errors ).toHaveProperty( 'amount' ); + expect( errors.amount ).toContain( 'is at least Rs 30' ); + } ); + + it( 'When a budget is provided and the amount is same than the minimum, should pass', () => { + const mockFormatAmount = jest.fn().mockReturnValue( 'Rs 30' ); + values.amount = 30; + + const opts = { + dailyBudget: 100, + formatAmount: mockFormatAmount, + }; + + const errors = validateCampaign( values, opts ); + expect( errors ).not.toHaveProperty( 'amount' ); + } ); + + it( 'When a budget is provided and the amount is greater than the minimum, should pass', () => { + const mockFormatAmount = jest.fn().mockReturnValue( 'Rs 35' ); + values.amount = 35; + + const opts = { + dailyBudget: 100, + formatAmount: mockFormatAmount, + }; + + const errors = validateCampaign( values, opts ); + expect( errors ).not.toHaveProperty( 'amount' ); + } ); } ); diff --git a/js/src/components/types.js b/js/src/components/types.js index fa7214953f..50259022b1 100644 --- a/js/src/components/types.js +++ b/js/src/components/types.js @@ -4,7 +4,6 @@ /** * @typedef {Object} CampaignFormValues - * @property {Array} countryCodes Selected country codes for the paid ads campaign. * @property {number} amount The daily average cost amount. */ diff --git a/js/src/data/action-types.js b/js/src/data/action-types.js index 4d27fb8155..92afeceeef 100644 --- a/js/src/data/action-types.js +++ b/js/src/data/action-types.js @@ -49,6 +49,7 @@ const TYPES = { UPSERT_TOUR: 'UPSERT_TOUR', HYDRATE_PREFETCHED_DATA: 'HYDRATE_PREFETCHED_DATA', RECEIVE_GOOGLE_ADS_ACCOUNT_STATUS: 'RECEIVE_GOOGLE_ADS_ACCOUNT_STATUS', + RECEIVE_ADS_BUDGET_RECOMMENDATIONS: 'RECEIVE_ADS_BUDGET_RECOMMENDATIONS', }; export default TYPES; diff --git a/js/src/data/reducer.js b/js/src/data/reducer.js index a4c887025b..5f363d4b7e 100644 --- a/js/src/data/reducer.js +++ b/js/src/data/reducer.js @@ -69,6 +69,7 @@ const DEFAULT_STATE = { inviteLink: null, step: null, }, + budgetRecommendations: {}, }, }; @@ -504,6 +505,19 @@ const reducer = ( state = DEFAULT_STATE, action ) => { .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..81df485703 --- /dev/null +++ b/js/src/hooks/useFetchBudgetRecommendation.js @@ -0,0 +1,33 @@ +/** + * 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 } = select( STORE_KEY ); + + const data = getAdsBudgetRecommendations( countryCodes ); + return { data }; + }, + [ countryCodes ] + ); +}; + +export default useFetchBudgetRecommendation; diff --git a/js/src/hooks/useValidateCampaignWithCountryCodes.js b/js/src/hooks/useValidateCampaignWithCountryCodes.js new file mode 100644 index 0000000000..d1c06b8f53 --- /dev/null +++ b/js/src/hooks/useValidateCampaignWithCountryCodes.js @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from '.~/data/constants'; +import useAdsCurrency from './useAdsCurrency'; +import validateCampaign from '.~/components/paid-ads/validateCampaign'; +import getHighestBudget from '.~/utils/getHighestBudget'; +import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; + +/** + * @typedef {import('.~/components/types.js').CampaignFormValues} CampaignFormValues + */ + +/** + * @typedef { import(".~/data/actions").CountryCode } CountryCode + */ + +/** + * @typedef {Object} ValidateCampaignWithCountryCodesHook + * @property {(values: CampaignFormValues) => Object} validateCampaignWithCountryCodes A function to validate campaign form values. + * @property {number | undefined} dailyBudget The daily budget recommendation. + * @property {(number: string | number) => string} formatAmount A function to format an amount according to the user's currency settings. + * @property {boolean} loaded A boolean indicating whether the budget recommendation data has been resolved. + */ + +/** + * Validate campaign form. Accepts the form values object and returns errors object. + * + * @return {ValidateCampaignWithCountryCodesHook} The validate campaign with country codes hook. + */ +const useValidateCampaignWithCountryCodes = () => { + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const { formatAmount } = useAdsCurrency(); + + return useSelect( + ( select ) => { + // If country codes are yet resolved, return the default validateCampaign function. + if ( ! countryCodes ) { + return { + validateCampaignWithCountryCodes: validateCampaign, + dailyBudget: null, + formatAmount, + loaded: false, + }; + } + + const { getAdsBudgetRecommendations, hasFinishedResolution } = + select( STORE_KEY ); + const budgetData = getAdsBudgetRecommendations( countryCodes ); + const budget = getHighestBudget( budgetData?.recommendations ); + const loaded = hasFinishedResolution( + 'getAdsBudgetRecommendations', + [ countryCodes ] + ); + + /** + * Validate campaign form. Accepts the form values object and returns errors object. + * + * @param {CampaignFormValues} values Campaign form values. + * @return {Object} An object containing any validation errors. If no errors, the object will be empty. + */ + const validateCampaignWithCountryCodes = ( values ) => { + return validateCampaign( values, { + dailyBudget: budget?.daily_budget, + formatAmount, + } ); + }; + + return { + validateCampaignWithCountryCodes, + dailyBudget: budget?.daily_budget, + formatAmount, + loaded, + }; + }, + [ countryCodes, formatAmount ] + ); +}; + +export default useValidateCampaignWithCountryCodes; 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/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js index 297cd79d0d..f1e3e0d5de 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 @@ -2,6 +2,7 @@ * External dependencies */ import { expect, test } from '@playwright/test'; + /** * Internal dependencies */ @@ -351,24 +352,6 @@ test.describe( 'Set up Ads account', () => { await checkFAQExpandable( page ); } ); } ); - - test( 'Set the budget', async () => { - budget = '0'; - await setupBudgetPage.fillBudget( budget ); - - await expect( - page.getByRole( 'button', { name: 'Continue' } ) - ).toBeDisabled(); - - budget = '1'; - await setupBudgetPage.fillBudget( budget ); - } ); - - test( 'Budget Recommendation', async () => { - await expect( - page.getByText( 'set a daily budget of 15 USD' ) - ).toBeVisible(); - } ); } ); test.describe( 'Set up billing', () => { @@ -422,30 +405,65 @@ test.describe( 'Set up Ads account', () => { } ); test.describe( 'Create Ads with billing data already setup', () => { - test.beforeAll( async () => { - await setupBudgetPage.fulfillBillingStatusRequest( { - status: 'approved', + test.describe( 'Set the budget', async () => { + test( 'Continue button should be disabled if budget is 0', async () => { + //Reload the page + await page.reload(); + await setupBudgetPage.fulfillBillingStatusRequest( { + status: 'approved', + } ); + await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + + //Step 1 - Accounts are already set up. + await setupAdsAccounts.clickContinue(); + await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + + budget = '0'; + await setupBudgetPage.fillBudget( budget ); + + await expect( + page.getByRole( 'button', { name: 'Continue' } ) + ).toBeDisabled(); } ); - } ); - test( 'Launch paid campaign should be enabled', async () => { - //Reload the page - await page.reload(); - await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + test( 'Continue button should be disabled if budget is less than recommended value', async () => { + budget = '2'; + await setupBudgetPage.fillBudget( budget ); - //Step 1 - Accounts are already set up. - await setupAdsAccounts.clickContinue(); - await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + await expect( + page.getByRole( 'button', { name: 'Continue' } ) + ).toBeDisabled(); + } ); - //Step 2 - Fill the budget - await setupBudgetPage.fulfillBillingStatusRequest( { - status: 'approved', + test( 'User is notified of the minimum value', async () => { + budget = '4'; + await setupBudgetPage.fillBudget( budget ); + await setupBudgetPage.getBudgetInput().blur(); + + await expect( + page.getByText( + 'Please make sure daily average cost is at least €5.00' + ) + ).toBeVisible(); + } ); + + test( 'Continue button should be enabled if budget is above the recommended value', async () => { + budget = '6'; + await setupBudgetPage.fillBudget( budget ); + + await expect( + page.getByRole( 'button', { name: 'Continue' } ) + ).toBeEnabled(); } ); - await setupBudgetPage.fillBudget( '1' ); + } ); + test( 'Budget Recommendation', async () => { await expect( - page.getByRole( 'button', { name: 'Continue' } ) - ).toBeEnabled(); + page.getByText( 'set a daily budget of 15 USD' ) + ).toBeVisible(); + } ); + + test( 'Launch paid campaign should be enabled', async () => { await page.getByRole( 'button', { name: 'Continue' } ).click(); await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); @@ -462,7 +480,7 @@ test.describe( 'Set up Ads account', () => { const campaignCreation = setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion( - '1', + '6', [ 'US' ] ); await page 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..4c92200618 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 @@ -208,6 +208,54 @@ test.describe( 'Complete your campaign', () => { } ); } ); + test.describe( 'Validate budget percent', () => { + test.beforeAll( async () => { + await setupBudgetPage.mockBudgetRecommendation( { + currency: 'USD', + recommendations: [ + { + country: 'US', + daily_budget: 100, + }, + ], + } ); + + await completeCampaign.goto(); + } ); + + test( 'should see validation error if lower than the 30%', async () => { + await setupBudgetPage.fillBudget( '10' ); + await setupBudgetPage.getBudgetInput().blur(); + + const error = await page + .locator( '.components-base-control__help' ) + .textContent(); + await expect( error ).toBe( + 'Please make sure daily average cost is at least NT$30.00' + ); + } ); + + test( 'should not see validation error if exactly 30%', async () => { + await setupBudgetPage.fillBudget( '30' ); + await setupBudgetPage.getBudgetInput().blur(); + + const error = await page.locator( + '.components-base-control__help' + ); + await expect( error ).not.toBeVisible(); + } ); + + test( 'should not see validation error if greater than 30%', async () => { + await setupBudgetPage.fillBudget( '40' ); + await setupBudgetPage.getBudgetInput().blur(); + + const error = await page.locator( + '.components-base-control__help' + ); + await expect( error ).not.toBeVisible(); + } ); + } ); + test.describe( 'Set up billing', () => { let newPage; diff --git a/tests/e2e/utils/pages/setup-ads/setup-budget.js b/tests/e2e/utils/pages/setup-ads/setup-budget.js index afb0875017..86e27cf5fa 100644 --- a/tests/e2e/utils/pages/setup-ads/setup-budget.js +++ b/tests/e2e/utils/pages/setup-ads/setup-budget.js @@ -158,6 +158,20 @@ export default class SetupBudget extends MockRequests { ); } + /** + * Mock the budget recommendation. + * + * @param {Object} payload The payload. + * @return {Promise} + */ + async mockBudgetRecommendation( payload ) { + await this.fulfillRequest( + /\/wc\/gla\/ads\/campaigns\/budget-recommendation\b/, + payload, + 200 + ); + } + /** * Mock the campaign creation process and the Ads setup completion. *