Skip to content

Commit

Permalink
Merge pull request #2552 from woocommerce/update/2502-budget-setup-card
Browse files Browse the repository at this point in the history
Update Budget Setup Card.
  • Loading branch information
asvinb authored Oct 22, 2024
2 parents 425c6e3 + a560e90 commit 1626f8a
Show file tree
Hide file tree
Showing 21 changed files with 407 additions and 204 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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: <strong />, em: <em />, br: <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.<br /><em>Tip: Most merchants who sell products in similar countries <strong>set a daily budget of %1$f %2$s</strong></em>',
'We recommend running campaigns at least 1 month so it can learn to optimize for your business.<br /><em>Tip: Most merchants targeting similar countries <strong>set a daily budget of %1$f %2$s</strong></em>',
'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.<br /><em>Tip: Most merchants targeting <strong>%3$s set a daily budget of %1$f %2$s</strong></em>',
'We recommend running campaigns at least 1 month so it can learn to optimize for your business.<br /><em>Tip: Most merchants targeting <strong>%3$s set a daily budget of %1$f %2$s</strong></em>',
'google-listings-and-ads'
);

Expand All @@ -51,26 +35,25 @@ function toRecommendationRange( isMultiple, ...values ) {

const BudgetRecommendation = ( props ) => {
const { countryCodes, dailyAverageCost = Infinity } = props;
const { data } = useFetchBudgetRecommendationEffect( countryCodes );
const { data, highestDailyBudgetCountryCode, highestDailyBudget } =
useFetchBudgetRecommendation( countryCodes );

Check warning on line 39 in js/src/components/paid-ads/budget-section/budget-recommendation/index.js

View check run for this annotation

Codecov / codecov/patch

js/src/components/paid-ads/budget-section/budget-recommendation/index.js#L39

Added line #L39 was not covered by tests

const map = useCountryKeyNameMap();

if ( ! data ) {
return null;
}

const { currency, recommendations } = data;
const { daily_budget: dailyBudget, country } =
getHighestBudget( recommendations );

const countryName = map[ country ];
const countryName = map[ highestDailyBudgetCountryCode ];

Check warning on line 48 in js/src/components/paid-ads/budget-section/budget-recommendation/index.js

View check run for this annotation

Codecov / codecov/patch

js/src/components/paid-ads/budget-section/budget-recommendation/index.js#L48

Added line #L48 was not covered by tests
const recommendationRange = toRecommendationRange(
recommendations.length > 1,
dailyBudget,
highestDailyBudget,
currency,
countryName
);

const showLowerBudgetNotice = dailyAverageCost < dailyBudget;
const showLowerBudgetNotice = dailyAverageCost < highestDailyBudget;

Check warning on line 56 in js/src/components/paid-ads/budget-section/budget-recommendation/index.js

View check run for this annotation

Codecov / codecov/patch

js/src/components/paid-ads/budget-section/budget-recommendation/index.js#L56

Added line #L56 was not covered by tests

return (
<div className="gla-budget-recommendation">
Expand Down

This file was deleted.

20 changes: 1 addition & 19 deletions js/src/components/paid-ads/budget-section/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEffect, useRef } from '@wordpress/element';

/**
* Internal dependencies
Expand Down Expand Up @@ -39,30 +38,13 @@ const BudgetSection = ( {
disabled = false,

Check warning on line 38 in js/src/components/paid-ads/budget-section/index.js

View check run for this annotation

Codecov / codecov/patch

js/src/components/paid-ads/budget-section/index.js#L38

Added line #L38 was not covered by tests
children,
} ) => {
const { getInputProps, setValue, values } = formProps;
const { getInputProps, values } = formProps;
const { amount } = values;

Check warning on line 42 in js/src/components/paid-ads/budget-section/index.js

View check run for this annotation

Codecov / codecov/patch

js/src/components/paid-ads/budget-section/index.js#L40-L42

Added lines #L40 - L42 were not covered by tests
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 <input>.
* - 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 (
<div className="gla-budget-section">
<Section
Expand Down
1 change: 1 addition & 0 deletions js/src/data/action-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
14 changes: 14 additions & 0 deletions js/src/data/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const DEFAULT_STATE = {
inviteLink: null,
step: null,
},
budgetRecommendations: {},
},
};

Expand Down Expand Up @@ -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:
Expand Down
57 changes: 56 additions & 1 deletion js/src/data/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,6 +48,10 @@ import {
receiveTour,
} from './actions';

/**
* @typedef {import('.~/data/actions').CountryCode} CountryCode
*/

export function* getShippingRates() {
yield fetchShippingRates();
}
Expand Down Expand Up @@ -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<CountryCode>} [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 );

Check warning on line 531 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L528-L531

Added lines #L528 - L531 were not covered by tests

try {
const { data } = yield fetchWithHeaders( {

Check warning on line 534 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L533-L534

Added lines #L533 - L534 were not covered by tests
path,
} );

const { currency, recommendations } = data;

Check warning on line 538 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L538

Added line #L538 was not covered by tests

return {

Check warning on line 540 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L540

Added line #L540 was not covered by tests
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;

Check warning on line 549 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L549

Added line #L549 was not covered by tests
}

const bodyPromise = response?.json() || response?.text();
const error = yield awaitPromise( bodyPromise );

Check warning on line 553 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L553

Added line #L553 was not covered by tests

handleApiError(

Check warning on line 555 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L555

Added line #L555 was not covered by tests
error,
__(
'There was an error getting the budget recommendation.',
'google-listings-and-ads'
)
);
}
}

getAdsBudgetRecommendations.shouldInvalidate = ( action ) => {
return action.type === TYPES.DISCONNECT_ACCOUNTS_GOOGLE_ADS;

Check warning on line 566 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L566

Added line #L566 was not covered by tests
};
20 changes: 19 additions & 1 deletion js/src/data/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CountryCode>} [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;
};
34 changes: 34 additions & 0 deletions js/src/data/test/reducer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe( 'reducer', () => {
inviteLink: null,
step: null,
},
budgetRecommendations: {},
},
} );

Expand Down Expand Up @@ -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 */
Expand Down
18 changes: 18 additions & 0 deletions js/src/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
/**
Expand Down Expand Up @@ -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<CountryCode>} [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.
*
Expand Down
Loading

0 comments on commit 1626f8a

Please sign in to comment.