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/audience-country-select.js b/js/src/components/audience-country-select.js deleted file mode 100644 index 02a39ef4bc..0000000000 --- a/js/src/components/audience-country-select.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Internal dependencies - */ -import SupportedCountrySelect from '.~/components/supported-country-select'; -import AppSpinner from '.~/components/app-spinner'; -import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; - -/** - * @typedef {import('.~/data/actions').CountryCode} CountryCode - */ - -/** - * Returns a SupportedCountrySelect component with list of countries grouped by continents. - * And SupportedCountrySelect will be rendered via TreeSelectControl component. - * - * This component is for selecting countries under the merchant selected targeting audiences. - * - * @param {Object} props React props. - * @param {Array} [props.additionalCountryCodes] Additional countries that are not in the target audience countries and need to be selectable. - * @param {Object} props.restProps Props to be forwarded to SupportedCountrySelect. - */ -const AudienceCountrySelect = ( { additionalCountryCodes, ...restProps } ) => { - let { data: countryCodes } = useTargetAudienceFinalCountryCodes(); - - if ( ! countryCodes ) { - return ; - } - - if ( additionalCountryCodes ) { - countryCodes = Array.from( - new Set( countryCodes.concat( additionalCountryCodes ) ) - ); - } - - return ( - - ); -}; - -export default AudienceCountrySelect; diff --git a/js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/country-modal/country-amount.js b/js/src/components/free-ad-credit/country-modal/country-amount.js similarity index 100% rename from js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/country-modal/country-amount.js rename to js/src/components/free-ad-credit/country-modal/country-amount.js diff --git a/js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/country-modal/index.js b/js/src/components/free-ad-credit/country-modal/index.js similarity index 100% rename from js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/country-modal/index.js rename to js/src/components/free-ad-credit/country-modal/index.js diff --git a/js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/country-modal/index.scss b/js/src/components/free-ad-credit/country-modal/index.scss similarity index 100% rename from js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/country-modal/index.scss rename to js/src/components/free-ad-credit/country-modal/index.scss diff --git a/js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/index.js b/js/src/components/free-ad-credit/index.js similarity index 100% rename from js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/index.js rename to js/src/components/free-ad-credit/index.js diff --git a/js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/index.scss b/js/src/components/free-ad-credit/index.scss similarity index 100% rename from js/src/setup-ads/ads-stepper/setup-accounts/free-ad-credit/index.scss rename to js/src/components/free-ad-credit/index.scss diff --git a/js/src/components/paid-ads/__snapshots__/validateCampaign.test.js.snap b/js/src/components/paid-ads/__snapshots__/validateCampaign.test.js.snap index 8e5ebb2814..38bbccb4fd 100644 --- a/js/src/components/paid-ads/__snapshots__/validateCampaign.test.js.snap +++ b/js/src/components/paid-ads/__snapshots__/validateCampaign.test.js.snap @@ -11,5 +11,3 @@ exports[`validateCampaign When the amount is not a number, should not pass 4`] = exports[`validateCampaign When the amount is ≤ 0, should not pass 1`] = `"Please make sure daily average cost is greater than 0."`; exports[`validateCampaign When the amount is ≤ 0, should not pass 2`] = `"Please make sure daily average cost is greater than 0."`; - -exports[`validateCampaign When the country codes array is empty, should not pass 1`] = `"Please select at least one country for your ads campaign."`; diff --git a/js/src/components/paid-ads/ads-campaign.js b/js/src/components/paid-ads/ads-campaign.js deleted file mode 100644 index e3ea8111f6..0000000000 --- a/js/src/components/paid-ads/ads-campaign.js +++ /dev/null @@ -1,116 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { createInterpolateElement } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import StepContent from '.~/components/stepper/step-content'; -import StepContentHeader from '.~/components/stepper/step-content-header'; -import StepContentFooter from '.~/components/stepper/step-content-footer'; -import StepContentActions from '.~/components/stepper/step-content-actions'; -import AppDocumentationLink from '.~/components/app-documentation-link'; -import AppButton from '.~/components/app-button'; -import { useAdaptiveFormContext } from '.~/components/adaptive-form'; -import AudienceSection from './audience-section'; -import BudgetSection from './budget-section'; -import { CampaignPreviewCard } from './campaign-preview'; -import PaidAdsFaqsPanel from './faqs-panel'; - -/** - * @typedef {import('.~/data/actions').Campaign} Campaign - */ - -/** - * Renders the container of the form content for campaign management. - * - * Please note that this component relies on an CampaignAssetsForm's context and custom adapter, - * so it expects a `CampaignAssetsForm` to existing in its parents. - * - * @fires gla_documentation_link_click with `{ context: 'create-ads' | 'edit-ads' | 'setup-ads', link_id: 'see-what-ads-look-like', href: 'https://support.google.com/google-ads/answer/6275294' }` - * - * @param {Object} props React props. - * @param {Campaign} [props.campaign] Campaign data to be edited. If not provided, this component will show campaign creation UI. - * @param {() => void} props.onContinue Callback called once continue button is clicked. - * @param {'create-ads'|'edit-ads'|'setup-ads'} props.trackingContext A context indicating which page this component is used on. This will be the value of `context` in the track event properties. - */ -export default function AdsCampaign( { - campaign, - onContinue, - trackingContext, -} ) { - const isCreation = ! campaign; - const formContext = useAdaptiveFormContext(); - const { isValidForm } = formContext; - - const disabledBudgetSection = ! formContext.values.countryCodes.length; - const helperText = isCreation - ? __( - 'You can only choose from countries you’ve selected during product listings configuration.', - 'google-listings-and-ads' - ) - : __( - 'Once a campaign has been created, you cannot change the target country(s).', - 'google-listings-and-ads' - ); - - return ( - - See what your ads will look like.', - 'google-listings-and-ads' - ), - { - link: ( - - ), - } - ) } - /> - - - - - - - - - { __( 'Continue', 'google-listings-and-ads' ) } - - - - - - ); -} diff --git a/js/src/components/paid-ads/ads-campaign/ads-campaign.js b/js/src/components/paid-ads/ads-campaign/ads-campaign.js new file mode 100644 index 0000000000..34324fd40c --- /dev/null +++ b/js/src/components/paid-ads/ads-campaign/ads-campaign.js @@ -0,0 +1,122 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createInterpolateElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import StepContent from '.~/components/stepper/step-content'; +import StepContentHeader from '.~/components/stepper/step-content-header'; +import StepContentFooter from '.~/components/stepper/step-content-footer'; +import StepContentActions from '.~/components/stepper/step-content-actions'; +import AppDocumentationLink from '.~/components/app-documentation-link'; +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; +import BillingCard from '.~/components/paid-ads/billing-card'; +import BudgetSection from '../budget-section'; +import { CampaignPreviewCard } from '../campaign-preview'; +import PaidAdsFaqsPanel from './faqs-panel'; +import PaidAdsFeaturesSection from './paid-ads-features-section'; +import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; + +/** + * @typedef {import('.~/components/adaptive-form/adaptive-form-context').AdaptiveFormContext} AdaptiveFormContext + */ + +/** + * @typedef {import('.~/data/actions').Campaign} Campaign + */ + +/** + * Renders the container of the form content for campaign management. + * + * Please note that this component relies on a CampaignAssetsForm's context and custom adapter, + * so it expects a `CampaignAssetsForm` to exist in its parents. + * + * @fires gla_documentation_link_click with `{ context: 'create-ads' | 'edit-ads' | 'setup-ads', link_id: 'see-what-ads-look-like', href: 'https://support.google.com/google-ads/answer/6275294' }` + * @param {Object} props React props. + * @param {Campaign} [props.campaign] Campaign data to be edited. The displayCountries property will be used to fetch budget recommendation data. + * @param {string} props.headerTitle The title of the step. + * @param {'create-ads'|'edit-ads'|'setup-ads'|'setup-mc'} props.context A context indicating which page this component is used on. This will be the value of `context` in the track event properties. + * @param {(formContext: AdaptiveFormContext) => JSX.Element | JSX.Element} [props.skipButton] A React element or function to render the "Skip" button. If a function is passed, it receives the form context and returns the button element. + * @param {(formContext: AdaptiveFormContext) => JSX.Element | JSX.Element} [props.continueButton] A React element or function to render the "Continue" button. If a function is passed, it receives the form context and returns the button element. + */ +export default function AdsCampaign( { + campaign, + headerTitle, + context, + skipButton, + continueButton, +} ) { + const formContext = useAdaptiveFormContext(); + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const isOnboardingFlow = context === 'setup-mc'; + const showCampaignPreviewCard = + context === 'setup-ads' || + context === 'create-ads' || + context === 'edit-ads'; + // only show the billing card during onboarding or setup Ads flow. + // For creating/editing a campaign, we assume billing is already set up. + const showBillingCard = context === 'setup-mc' || context === 'setup-ads'; + + let description = createInterpolateElement( + __( + 'Paid Performance Max campaigns are automatically optimized for you by Google. See what your ads will look like.', + 'google-listings-and-ads' + ), + { + link: ( + + ), + } + ); + + if ( isOnboardingFlow ) { + description = __( + 'You’re ready to set up a Performance Max campaign to drive more sales with ads. Your products will be included in the campaign after they’re approved.', + 'google-listings-and-ads' + ); + } + + return ( + + + + { isOnboardingFlow && } + + + { showBillingCard && } + + { showCampaignPreviewCard && } + + + + + { typeof skipButton === 'function' + ? skipButton( formContext ) + : skipButton } + + { typeof continueButton === 'function' + ? continueButton( formContext ) + : continueButton } + + + + + ); +} diff --git a/js/src/components/paid-ads/faqs-panel.js b/js/src/components/paid-ads/ads-campaign/faqs-panel.js similarity index 100% rename from js/src/components/paid-ads/faqs-panel.js rename to js/src/components/paid-ads/ads-campaign/faqs-panel.js diff --git a/js/src/components/paid-ads/ads-campaign/index.js b/js/src/components/paid-ads/ads-campaign/index.js new file mode 100644 index 0000000000..19cf67b1b5 --- /dev/null +++ b/js/src/components/paid-ads/ads-campaign/index.js @@ -0,0 +1 @@ +export { default } from './ads-campaign'; diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-features-section.js b/js/src/components/paid-ads/ads-campaign/paid-ads-features-section.js similarity index 56% rename from js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-features-section.js rename to js/src/components/paid-ads/ads-campaign/paid-ads-features-section.js index 3be1cd6664..8abdde4e33 100644 --- a/js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-features-section.js +++ b/js/src/components/paid-ads/ads-campaign/paid-ads-features-section.js @@ -5,7 +5,6 @@ import { __ } from '@wordpress/i18n'; import { Flex, FlexItem, FlexBlock } from '@wordpress/components'; import { Pill } from '@woocommerce/components'; import GridiconCheckmark from 'gridicons/dist/checkmark'; -import GridiconGift from 'gridicons/dist/gift'; /** * Internal dependencies @@ -13,9 +12,12 @@ import GridiconGift from 'gridicons/dist/gift'; import Section from '.~/wcdl/section'; import AppDocumentationLink from '.~/components/app-documentation-link'; import CampaignPreview from '.~/components/paid-ads/campaign-preview'; +import FreeAdCredit from '.~/components/free-ad-credit'; +import VerticalGapLayout from '.~/components/vertical-gap-layout'; +import useFreeAdCredit from '.~/hooks/useFreeAdCredit'; import './paid-ads-features-section.scss'; -function FeatureList( { hideBudgetContent } ) { +function FeatureList() { const featuresItems = [ { Icon: GridiconCheckmark, @@ -26,16 +28,6 @@ function FeatureList( { hideBudgetContent } ) { }, ]; - if ( ! hideBudgetContent ) { - featuresItems.push( { - Icon: GridiconGift, - content: __( - 'Claim $500 in ads credit when you spend your first $500 with Google Ads. Terms and conditions apply.', - 'google-listings-and-ads' - ), - } ); - } - return (
{ featuresItems.map( ( { Icon, content }, idx ) => ( @@ -55,19 +47,10 @@ function FeatureList( { hideBudgetContent } ) { /** * Renders a section layout to elaborate on the features of paid ads and show the buttons * for the next actions: skip or continue the paid ads setup. - * - * @param {Object} props React props. - * @param {boolean} props.hideBudgetContent Whether to hide the content about the ad budget. - * @param {boolean} props.hideFooterButtons Whether to hide the buttons at the card footer. - * @param {JSX.Element} props.skipButton Button to skip paid ads setup. - * @param {JSX.Element} props.continueButton Button to continue paid ads setup. */ -export default function PaidAdsFeaturesSection( { - hideBudgetContent, - hideFooterButtons, - skipButton, - continueButton, -} ) { +export default function PaidAdsFeaturesSection() { + const hasFreeAdCredit = useFreeAdCredit(); + return (
- - - - { __( - 'Drive more sales with Performance Max', - 'google-listings-and-ads' - ) } - -
- { __( - 'Reach more customers by advertising your products across Google Ads channels like Search, YouTube and Discover. Set up your campaign now so your products are included as soon as they’re approved.', - 'google-listings-and-ads' - ) } -
- -
- - - -
+ + + + + { __( + 'Drive more sales with Performance Max', + 'google-listings-and-ads' + ) } + +
+ { __( + 'Reach more customers by advertising your products across Google Ads channels like Search, YouTube and Discover. Set up your campaign now so your products are included as soon as they’re approved.', + 'google-listings-and-ads' + ) } +
+ +
+ + + +
+ + { hasFreeAdCredit && } +
-
); diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-features-section.scss b/js/src/components/paid-ads/ads-campaign/paid-ads-features-section.scss similarity index 100% rename from js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-features-section.scss rename to js/src/components/paid-ads/ads-campaign/paid-ads-features-section.scss diff --git a/js/src/components/paid-ads/asset-group/asset-group.js b/js/src/components/paid-ads/asset-group/asset-group.js index bbceda7497..205f79cd29 100644 --- a/js/src/components/paid-ads/asset-group/asset-group.js +++ b/js/src/components/paid-ads/asset-group/asset-group.js @@ -16,6 +16,7 @@ import AppButton from '.~/components/app-button'; import AssetGroupFaqsPanel from './faqs-panel'; import AssetGroupSection from './asset-group-section'; import { recordGlaEvent } from '.~/utils/tracks'; +import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; export const ACTION_SUBMIT_CAMPAIGN_AND_ASSETS = 'submit-campaign-and-assets'; export const ACTION_SUBMIT_CAMPAIGN_ONLY = 'submit-campaign-only'; @@ -62,15 +63,17 @@ export default function AssetGroup( { campaign } ) { const isCreation = ! campaign; const { isValidForm, handleSubmit, adapter, values } = useAdaptiveFormContext(); + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); const { isValidAssetGroup, isSubmitting, isSubmitted, submitter } = adapter; const currentAction = submitter?.dataset.action; function recordSubmissionClickEvent( event ) { + const audiences = isCreation ? countryCodes : campaign.displayCountries; const finalUrl = values[ ASSET_FORM_KEY.FINAL_URL ]; const eventProps = { context: isCreation ? 'campaign-creation' : 'campaign-editing', action: event.target.dataset.action, - audiences: values.countryCodes.join( ',' ), + audiences: audiences.join( ',' ), budget: values.amount.toString(), assets_validation: isValidAssetGroup ? 'valid' : 'invalid', }; diff --git a/js/src/components/paid-ads/audience-section.js b/js/src/components/paid-ads/audience-section.js deleted file mode 100644 index f5ce860dc4..0000000000 --- a/js/src/components/paid-ads/audience-section.js +++ /dev/null @@ -1,84 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { SelectControl } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import useCountryKeyNameMap from '.~/hooks/useCountryKeyNameMap'; -import Section from '.~/wcdl/section'; -import AudienceCountrySelect from '.~/components/audience-country-select'; -import './audience-section.scss'; - -function toCountryOptions( countryCodes, countryNameMap ) { - return countryCodes.map( ( code ) => ( { - label: countryNameMap[ code ], - value: code, - } ) ); -} - -/** - * Renders
and UI with country(s) selector. - * - * @param {Object} props React props. - * @param {Object} props.formProps Form props forwarded from `Form` component. - * @param {boolean} [props.multiple=true] Whether the selector is multi-selected. - * @param {boolean} [props.disabled=false] Whether the selector is disabled. - * @param {JSX.Element} [props.countrySelectHelperText] Helper text to be displayed under the selector. - */ -const AudienceSection = ( props ) => { - const { - formProps: { getInputProps }, - multiple = true, - disabled = false, - countrySelectHelperText, - } = props; - - const countryNameMap = useCountryKeyNameMap(); - const inputProps = getInputProps( 'countryCodes' ); - - const selector = multiple ? ( - - ) : ( - - ); - - return ( -
- { __( - 'Choose where you want your product ads to appear', - 'google-listings-and-ads' - ) } -

- } - > - - { selector } - -
- ); -}; - -export default AudienceSection; diff --git a/js/src/components/paid-ads/audience-section.scss b/js/src/components/paid-ads/audience-section.scss deleted file mode 100644 index 565a4bc70f..0000000000 --- a/js/src/components/paid-ads/audience-section.scss +++ /dev/null @@ -1,15 +0,0 @@ -.gla-audience-section { - // Adjust imported from @wordpress/components. - // Repeat selector to make it higher priority. - .components-input-control__container.components-input-control__container { - .components-select-control__input { - padding-left: $grid-unit-20; - } - } - - // Adjust help text of imported from @wordpress/components. - .components-base-control__help { - margin: 0; - font-style: italic; - } -} diff --git a/js/src/components/paid-ads/audienceSection.test.js b/js/src/components/paid-ads/audienceSection.test.js deleted file mode 100644 index a7eb440a84..0000000000 --- a/js/src/components/paid-ads/audienceSection.test.js +++ /dev/null @@ -1,129 +0,0 @@ -/** - * External dependencies - */ -import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -/** - * Internal dependencies - */ -import AudienceSection from '.~/components/paid-ads/audience-section'; - -jest.mock( '.~/hooks/useAppSelectDispatch' ); -jest.mock( '.~/hooks/useCountryKeyNameMap' ); - -jest.mock( '.~/hooks/useTargetAudienceFinalCountryCodes', () => - jest.fn( () => ( { data: [ 'GB', 'US', 'ES' ] } ) ) -); - -describe( 'AudienceSection with multiple countries selector', () => { - let defaultProps; - let onChange; - - beforeEach( () => { - onChange = jest.fn(); - defaultProps = { - formProps: { - getInputProps: () => ( { onChange } ), - }, - }; - } ); - - test( 'If Audience section is disabled the country field should be disabled', async () => { - const user = userEvent.setup(); - - render( ); - - const dropdown = await screen.findByRole( 'combobox' ); - expect( dropdown ).toBeDisabled(); - - //Test that input is not editable - expect( dropdown ).toHaveValue( '' ); - await user.type( dropdown, 'spa' ); - expect( dropdown ).toHaveValue( '' ); - - const options = screen.queryAllByRole( 'checkbox' ); - expect( options.length ).toBe( 0 ); - expect( onChange ).toHaveBeenCalledTimes( 0 ); - } ); - - test( 'If Audience section is enable the country field should be enable & editable', async () => { - const user = userEvent.setup(); - - render( ); - - const dropdown = await screen.findByRole( 'combobox' ); - expect( dropdown ).not.toBeDisabled(); - - //Test that input is editable - expect( dropdown ).toHaveValue( '' ); - await user.type( dropdown, 'spa' ); - expect( dropdown ).toHaveValue( 'spa' ); - - const options = await screen.findAllByRole( 'checkbox' ); - expect( options.length ).toBeGreaterThan( 0 ); - - const firstOption = options[ 0 ]; - await user.click( firstOption ); - expect( onChange ).toHaveBeenCalledTimes( 1 ); - } ); -} ); - -describe( 'AudienceSection with single country selector', () => { - let defaultProps; - let onChange; - - beforeEach( () => { - onChange = jest.fn(); - defaultProps = { - multiple: false, - formProps: { - getInputProps: () => ( { - value: [ 'US', 'ES', 'GB' ], - selected: [ 'ES' ], - onChange, - } ), - }, - }; - } ); - - test( 'When AudienceSection is disabled, the country field should be disabled', () => { - render( ); - const dropdown = screen.queryByRole( 'combobox' ); - - expect( dropdown ).toBeDisabled(); - } ); - - test( 'When AudienceSection is enable, the country field should be enable', () => { - render( ); - const dropdown = screen.queryByRole( 'combobox' ); - - expect( dropdown ).not.toBeDisabled(); - } ); - - test( 'When selecting another option, the country field should trigger `onChange` callback', async () => { - const user = userEvent.setup(); - - render( ); - - const dropdown = screen.queryByRole( 'combobox' ); - await user.selectOptions( dropdown, 'GB' ); - - expect( onChange ).toHaveBeenCalledTimes( 1 ); - } ); - - test( 'The country field should have the given options', () => { - render( ); - const options = screen.queryAllByRole( 'option' ); - - expect( options.length ).toBe( 3 ); - } ); - - test( 'The country field should select the option by given value', () => { - render( ); - const option = screen.queryByRole( 'option', { selected: true } ); - - expect( option.value ).toBe( 'ES' ); - } ); -} ); 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..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 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 +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 5c461602e6..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 @@ -14,6 +13,10 @@ import BudgetRecommendation from './budget-recommendation'; import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; import AppInputPriceControl from '.~/components/app-input-price-control'; +/** + * @typedef {import('.~/data/actions').CountryCode} CountryCode + */ + const nonInteractableProps = { noPointerEvents: true, readOnly: true, @@ -25,34 +28,23 @@ 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, disabled = false, children } ) => { - const { getInputProps, setValue, values } = formProps; - const { countryCodes, amount } = values; +const BudgetSection = ( { + formProps, + countryCodes, + disabled = false, + children, +} ) => { + 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 (
{ value={ monthlyMaxEstimated } />
- { countryCodes.length > 0 && ( + { countryCodes?.length > 0 && ( { @@ -78,6 +81,7 @@ export default function CampaignAssetsForm( { const [ baseAssetGroup, setBaseAssetGroup ] = useState( initialAssetGroup ); const [ hasImportedAssets, setHasImportedAssets ] = useState( false ); + const { formatAmount } = useAdsCurrency(); const extendAdapter = ( formContext ) => { const assetGroupErrors = validateAssetGroup( formContext.values ); @@ -117,13 +121,20 @@ export default function CampaignAssetsForm( { }; }; + const validateCampaignWithMinimumAmount = ( values ) => { + return validateCampaign( values, { + dailyBudget: recommendedDailyBudget, + formatAmount, + } ); + }; + 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..f793807f63 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,12 @@ 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; +} ); + const alwaysValid = () => ( {} ); describe( 'CampaignAssetsForm', () => { diff --git a/js/src/components/paid-ads/continue-button.js b/js/src/components/paid-ads/continue-button.js new file mode 100644 index 0000000000..53788766f3 --- /dev/null +++ b/js/src/components/paid-ads/continue-button.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import AppButton from '.~/components/app-button'; + +/** + * Renders Continue button on paid ad campaign create and edit page. + * + * @param {Object} props Props + * @param {Object} props.formProps Form props forwarded from `Form` component. + * @param {Function} props.onClick Function to handle the continue button click. + * @return {JSX.Element} The component. + */ +const ContinueButton = ( { formProps, onClick } ) => { + return ( + + ); +}; + +export default ContinueButton; diff --git a/js/src/components/paid-ads/validateCampaign.js b/js/src/components/paid-ads/validateCampaign.js index d2b2e60106..d6e0a8083a 100644 --- a/js/src/components/paid-ads/validateCampaign.js +++ b/js/src/components/paid-ads/validateCampaign.js @@ -1,33 +1,62 @@ /** * 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 ( values.countryCodes.length === 0 ) { - errors.countryCodes = __( - 'Please select at least one country for your ads campaign.', - 'google-listings-and-ads' - ); + if ( + Number.isFinite( values.amount ) && + Number.isFinite( opts.dailyBudget ) && + opts.dailyBudget > 0 + ) { + const { amount } = values; + const { dailyBudget, formatAmount } = opts; + + const minAmount = Math.ceil( dailyBudget * BUDGET_MIN_PERCENT ); + + if ( amount < 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 afad100326..39e33186f6 100644 --- a/js/src/components/paid-ads/validateCampaign.test.js +++ b/js/src/components/paid-ads/validateCampaign.test.js @@ -3,64 +3,66 @@ */ import validateCampaign from './validateCampaign'; +const mockFormatAmount = jest + .fn() + .mockImplementation( ( amount ) => `Rs ${ amount }` ); + /** * `validateCampaign` function returns an object, and if any checks are not passed, * set properties respectively with an error message to indicate it. */ describe( 'validateCampaign', () => { let values; + const validateCampaignOptions = { + dailyBudget: undefined, + formatAmount: mockFormatAmount, + }; beforeEach( () => { // Initial values - values = { countryCodes: [], amount: 0 }; + values = { amount: 0 }; } ); it( 'When all checks are passed, should return an empty object', () => { - const errors = validateCampaign( { - countryCodes: [ 'US' ], - 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( 'countryCodes' ); expect( errors ).toHaveProperty( 'amount' ); } ); - it( 'When the country codes array is empty, should not pass', () => { - const errors = validateCampaign( values ); - - expect( errors ).toHaveProperty( 'countryCodes' ); - expect( errors.countryCodes ).toMatchSnapshot(); - } ); - it( 'When the amount is not a number, should not pass', () => { 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(); @@ -70,15 +72,73 @@ 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', () => { + 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 as the minimum, should pass', () => { + 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', () => { + values.amount = 35; + + const opts = { + dailyBudget: 100, + formatAmount: mockFormatAmount, + }; + + const errors = validateCampaign( values, opts ); + expect( errors ).not.toHaveProperty( 'amount' ); + } ); + + it( 'The minimum amount should be rounded up to the nearest integer', () => { + values.amount = 30.99; + + let opts = { + dailyBudget: 101, + formatAmount: mockFormatAmount, + }; + const errors = validateCampaign( values, opts ); + + opts = { + dailyBudget: 102, + formatAmount: mockFormatAmount, + }; + const errorsNext = validateCampaign( values, opts ); + + expect( errors ).toEqual( errorsNext ); + expect( errors ).toHaveProperty( 'amount' ); + expect( errors.amount ).toContain( 'is at least Rs 31' ); + } ); } ); diff --git a/js/src/components/title-button-layout/index.js b/js/src/components/title-button-layout/index.js deleted file mode 100644 index 8c71608853..0000000000 --- a/js/src/components/title-button-layout/index.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Internal dependencies - */ -import Subsection from '.~/wcdl/subsection'; -import ContentButtonLayout from '../content-button-layout'; -import './index.scss'; - -const TitleButtonLayout = ( props ) => { - const { title, button } = props; - - return ( - - { title } - { button } - - ); -}; - -export default TitleButtonLayout; diff --git a/js/src/components/title-button-layout/index.scss b/js/src/components/title-button-layout/index.scss deleted file mode 100644 index 88dfb1284d..0000000000 --- a/js/src/components/title-button-layout/index.scss +++ /dev/null @@ -1,5 +0,0 @@ -.gla-title-button-layout { - .title { - margin-bottom: 0; - } -} 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..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 e076243e6a..da0656a0d1 100644 --- a/js/src/pages/create-paid-ads-campaign/index.js +++ b/js/src/pages/create-paid-ads-campaign/index.js @@ -17,6 +17,7 @@ import { useAppDispatch } from '.~/data'; import { getDashboardUrl } from '.~/utils/urls'; import convertToAssetGroupUpdateBody from '.~/components/paid-ads/convertToAssetGroupUpdateBody'; import TopBar from '.~/components/stepper/top-bar'; +import ContinueButton from '.~/components/paid-ads/continue-button'; import HelpIconButton from '.~/components/help-icon-button'; import CampaignAssetsForm from '.~/components/paid-ads/campaign-assets-form'; import AdsCampaign from '.~/components/paid-ads/ads-campaign'; @@ -32,6 +33,7 @@ import { recordStepperChangeEvent, recordStepContinueEvent, } from '.~/utils/tracks'; +import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation'; const eventName = 'gla_paid_campaign_step'; const eventContext = 'create-ads'; @@ -50,7 +52,9 @@ const CreatePaidAdsCampaign = () => { const createdCampaignIdRef = useRef( null ); const { createAdsCampaign, updateCampaignAssetGroup } = useAppDispatch(); const { createNotice } = useDispatchCoreNotices(); - const { data: initialCountryCodes } = useTargetAudienceFinalCountryCodes(); + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const { highestDailyBudget, hasFinishedResolution } = + useFetchBudgetRecommendation( countryCodes ); const handleStepperClick = ( nextStep ) => { recordStepperChangeEvent( @@ -75,7 +79,7 @@ const CreatePaidAdsCampaign = () => { const { action } = enhancer.submitter.dataset; try { - const { amount, countryCodes } = values; + const { amount } = values; // Avoid re-creating a new campaign if the subsequent asset group update is failed. if ( createdCampaignIdRef.current === null ) { @@ -113,7 +117,7 @@ const CreatePaidAdsCampaign = () => { getHistory().push( getDashboardUrl( { campaign: 'saved' } ) ); }; - if ( ! initialCountryCodes ) { + if ( ! countryCodes || ! hasFinishedResolution ) { return null; } @@ -129,9 +133,9 @@ const CreatePaidAdsCampaign = () => { /> { ), content: ( - handleContinueClick( STEP.ASSET_GROUP ) - } + headerTitle={ __( + 'Create your paid campaign', + 'google-listings-and-ads' + ) } + context={ eventContext } + continueButton={ ( formContext ) => ( + { + handleContinueClick( + STEP.ASSET_GROUP + ); + } } + /> + ) } /> ), onClick: handleStepperClick, diff --git a/js/src/pages/edit-paid-ads-campaign/index.js b/js/src/pages/edit-paid-ads-campaign/index.js index 1d23f45666..10c3ca959b 100644 --- a/js/src/pages/edit-paid-ads-campaign/index.js +++ b/js/src/pages/edit-paid-ads-campaign/index.js @@ -19,6 +19,7 @@ import TopBar from '.~/components/stepper/top-bar'; import HelpIconButton from '.~/components/help-icon-button'; import CampaignAssetsForm from '.~/components/paid-ads/campaign-assets-form'; import AdsCampaign from '.~/components/paid-ads/ads-campaign'; +import ContinueButton from '.~/components/paid-ads/continue-button'; import AppSpinner from '.~/components/app-spinner'; import AssetGroup, { ACTION_SUBMIT_CAMPAIGN_AND_ASSETS, @@ -32,6 +33,7 @@ import { recordStepperChangeEvent, recordStepContinueEvent, } from '.~/utils/tracks'; +import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation'; const eventName = 'gla_paid_campaign_step'; const eventContext = 'edit-ads'; @@ -70,7 +72,8 @@ const EditPaidAdsCampaign = () => { } = useAppSelectDispatch( 'getCampaignAssetGroups', id ); const campaign = campaigns?.find( ( el ) => el.id === id ); const assetEntityGroup = assetEntityGroups?.at( 0 ); - + const { highestDailyBudget, hasFinishedResolution } = + useFetchBudgetRecommendation( campaign?.displayCountries ); useEffect( () => { if ( campaign && campaign.type !== CAMPAIGN_TYPE_PMAX ) { getHistory().replace( dashboardURL ); @@ -83,7 +86,11 @@ const EditPaidAdsCampaign = () => { getHistory().push( url ); }; - if ( ! loaded || ! hasResolvedAssetEntityGroups ) { + if ( + ! loaded || + ! hasResolvedAssetEntityGroups || + ! hasFinishedResolution + ) { return ( <> { @@ -196,10 +203,21 @@ const EditPaidAdsCampaign = () => { content: ( - handleContinueClick( STEP.ASSET_GROUP ) - } + context={ eventContext } + headerTitle={ __( + 'Edit your paid campaign', + 'google-listings-and-ads' + ) } + continueButton={ ( formContext ) => ( + + handleContinueClick( + STEP.ASSET_GROUP + ) + } + /> + ) } /> ), onClick: handleStepperClick, diff --git a/js/src/setup-ads/ads-stepper/index.js b/js/src/setup-ads/ads-stepper/index.js index 3979e0dcba..614132033f 100644 --- a/js/src/setup-ads/ads-stepper/index.js +++ b/js/src/setup-ads/ads-stepper/index.js @@ -3,36 +3,63 @@ */ import { Stepper } from '@woocommerce/components'; import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; +import { useState, useRef } from '@wordpress/element'; /** * Internal dependencies */ import SetupAccounts from './setup-accounts'; -import AdsCampaign from '.~/components/paid-ads/ads-campaign'; -import SetupBilling from './setup-billing'; +import AppSpinner from '.~/components/app-spinner'; import useEventPropertiesFilter from '.~/hooks/useEventPropertiesFilter'; +import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; +import useGoogleAdsAccountStatus from '.~/hooks/useGoogleAdsAccountStatus'; import { recordStepperChangeEvent, recordStepContinueEvent, FILTER_ONBOARDING, CONTEXT_ADS_ONBOARDING, } from '.~/utils/tracks'; +import SetupPaidAds from './setup-paid-ads'; /** - * @param {Object} props React props - * @param {Object} props.formProps Form props forwarded from `Form` component. - * @fires gla_setup_ads with `{ triggered_by: 'step1-continue-button' | 'step2-continue-button' , action: 'go-to-step2' | 'go-to-step3' }`. - * @fires gla_setup_ads with `{ triggered_by: 'stepper-step1-button' | 'stepper-step2-button', action: 'go-to-step1' | 'go-to-step2' }`. + * @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 = ( { formProps } ) => { +const AdsStepper = () => { const [ step, setStep ] = useState( '1' ); + const initHasAdsConnectionRef = useRef( null ); + + const { + hasFinishedResolution: hasResolvedGoogleAdsAccount, + hasGoogleAdsConnection, + } = useGoogleAdsAccount(); + + const { + hasAccess, + hasFinishedResolution: hasResolvedAdsAccountStatus, + step: adsAccountSetupStep, + } = useGoogleAdsAccountStatus(); useEventPropertiesFilter( FILTER_ONBOARDING, { context: CONTEXT_ADS_ONBOARDING, step, } ); + if ( initHasAdsConnectionRef.current === null ) { + if ( + ! ( hasResolvedGoogleAdsAccount && hasResolvedAdsAccountStatus ) + ) { + return ; + } + + const isGoogleAdsReady = + hasGoogleAdsConnection && + hasAccess === true && + adsAccountSetupStep !== 'conversion_action'; + + initHasAdsConnectionRef.current = isGoogleAdsReady; + } + // Allow the users to go backward only, not forward. // Users can only go forward by clicking on the Continue button. const handleStepClick = ( value ) => { @@ -58,9 +85,34 @@ const AdsStepper = ( { formProps } ) => { continueStep( '2' ); }; - const handleCreateCampaignContinue = () => { - continueStep( '3' ); - }; + let steps = [ + { + key: '1', + label: __( 'Set up your accounts', 'google-listings-and-ads' ), + content: ( + + ), + onClick: handleStepClick, + }, + { + key: '2', + label: __( 'Create your paid campaign', 'google-listings-and-ads' ), + content: , + onClick: handleStepClick, + }, + ]; + + if ( initHasAdsConnectionRef.current ) { + // Remove first step if the initial connection state of Ads account is connected. + steps.shift(); + + steps = steps.map( ( singleStep, index ) => { + return { + ...singleStep, + key: ( index + 1 ).toString(), + }; + } ); + } return ( // This Stepper with this class name @@ -69,41 +121,7 @@ const AdsStepper = ( { formProps } ) => { - ), - onClick: handleStepClick, - }, - { - key: '2', - label: __( - 'Create your paid campaign', - 'google-listings-and-ads' - ), - content: ( - - ), - onClick: handleStepClick, - }, - { - key: '3', - label: __( 'Set up billing', 'google-listings-and-ads' ), - content: , - onClick: handleStepClick, - }, - ] } + steps={ steps } /> ); }; diff --git a/js/src/setup-ads/ads-stepper/index.test.js b/js/src/setup-ads/ads-stepper/index.test.js index a286bf47b5..72d39541d6 100644 --- a/js/src/setup-ads/ads-stepper/index.test.js +++ b/js/src/setup-ads/ads-stepper/index.test.js @@ -3,12 +3,10 @@ jest.mock( '@woocommerce/tracks', () => { recordEvent: jest.fn().mockName( 'recordEvent' ), }; } ); - jest.mock( './setup-accounts', () => jest.fn().mockName( 'SetupAccounts' ) ); jest.mock( '.~/components/paid-ads/ads-campaign', () => jest.fn().mockName( 'AdsCampaign' ) ); -jest.mock( './setup-billing', () => jest.fn().mockName( 'SetupBilling' ) ); /** * External dependencies @@ -22,57 +20,36 @@ import { recordEvent } from '@woocommerce/tracks'; */ import AdsStepper from './'; import SetupAccounts from './setup-accounts'; -import AdsCampaign from '.~/components/paid-ads/ads-campaign'; -import SetupBilling from './setup-billing'; describe( 'AdsStepper', () => { let continueToStep2; - let continueToStep3; beforeEach( () => { SetupAccounts.mockImplementation( ( { onContinue } ) => { continueToStep2 = onContinue; return null; } ); - - AdsCampaign.mockImplementation( ( { onContinue } ) => { - continueToStep3 = onContinue; - return null; - } ); - - SetupBilling.mockReturnValue( null ); } ); afterEach( () => { jest.clearAllMocks(); } ); - async function continueUntilStep3() { - continueToStep2(); - - // Wait for stepper content to be rendered. - await waitFor( () => { - expect( continueToStep3 ).toBeDefined(); - } ); - - continueToStep3(); - } - describe( 'tracks', () => { it( 'Should record events after calling back to `onContinue`', async () => { render( ); - await continueUntilStep3(); + await waitFor( () => { + expect( continueToStep2 ).toBeDefined(); + } ); + + await continueToStep2(); - expect( recordEvent ).toHaveBeenCalledTimes( 2 ); + expect( recordEvent ).toHaveBeenCalledTimes( 1 ); expect( recordEvent ).toHaveBeenNthCalledWith( 1, 'gla_setup_ads', { action: 'go-to-step2', triggered_by: 'step1-continue-button', } ); - expect( recordEvent ).toHaveBeenNthCalledWith( 2, 'gla_setup_ads', { - action: 'go-to-step3', - triggered_by: 'step2-continue-button', - } ); } ); it( 'Should record events after clicking step navigation buttons', async () => { @@ -80,29 +57,12 @@ describe( 'AdsStepper', () => { render( ); - const step1 = screen.getByRole( 'button', { name: /accounts/ } ); - const step2 = screen.getByRole( 'button', { name: /campaign/ } ); - - // Step 3 -> Step 2 -> Step 1 - await continueUntilStep3(); - recordEvent.mockClear(); - expect( recordEvent ).toHaveBeenCalledTimes( 0 ); - - await user.click( step2 ); - await user.click( step1 ); - - expect( recordEvent ).toHaveBeenCalledTimes( 2 ); - expect( recordEvent ).toHaveBeenNthCalledWith( 1, 'gla_setup_ads', { - action: 'go-to-step2', - triggered_by: 'stepper-step2-button', - } ); - expect( recordEvent ).toHaveBeenNthCalledWith( 2, 'gla_setup_ads', { - action: 'go-to-step1', - triggered_by: 'stepper-step1-button', + const step1 = await screen.findByRole( 'button', { + name: /accounts/, } ); - // Step 3 -> Step 1 - await continueUntilStep3(); + // Step 2 -> Step 1 + await continueToStep2(); recordEvent.mockClear(); expect( recordEvent ).toHaveBeenCalledTimes( 0 ); diff --git a/js/src/setup-ads/ads-stepper/setup-accounts/index.js b/js/src/setup-ads/ads-stepper/setup-accounts/index.js index bab89e5cc1..17ee5995b6 100644 --- a/js/src/setup-ads/ads-stepper/setup-accounts/index.js +++ b/js/src/setup-ads/ads-stepper/setup-accounts/index.js @@ -14,7 +14,7 @@ import StepContentFooter from '.~/components/stepper/step-content-footer'; import VerticalGapLayout from '.~/components/vertical-gap-layout'; import { ConnectedGoogleAccountCard } from '.~/components/google-account-card'; import GoogleAdsAccountCard from '.~/components/google-ads-account-card'; -import FreeAdCredit from './free-ad-credit'; +import FreeAdCredit from '.~/components/free-ad-credit'; import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; import useGoogleAdsAccountStatus from '.~/hooks/useGoogleAdsAccountStatus'; import useGoogleAccount from '.~/hooks/useGoogleAccount'; diff --git a/js/src/setup-ads/ads-stepper/setup-billing/billing-saved-card/index.js b/js/src/setup-ads/ads-stepper/setup-billing/billing-saved-card/index.js deleted file mode 100644 index 89f2bf01f2..0000000000 --- a/js/src/setup-ads/ads-stepper/setup-billing/billing-saved-card/index.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import GridiconCreditCard from 'gridicons/dist/credit-card'; -import { createInterpolateElement } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import toAccountText from '.~/utils/toAccountText'; -import AppSpinner from '.~/components/app-spinner'; -import TitleButtonLayout from '.~/components/title-button-layout'; -import TrackableLink from '.~/components/trackable-link'; -import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; -import Section from '.~/wcdl/section'; -import './index.scss'; - -/** - * Clicking on a Google Ads account text link. - * - * @event gla_google_ads_account_link_click - * @property {string} context Indicates which page / module the link is in - * @property {string} href Where the user is redirected - * @property {string} link_id A unique ID for the link within the page / module - */ - -/** - * @fires gla_google_ads_account_link_click with `{ context: 'setup-ads', link_id: 'google-ads-account' }` - */ -const BillingSavedCard = () => { - const { googleAdsAccount } = useGoogleAdsAccount(); - - if ( ! googleAdsAccount ) { - return ; - } - - return ( -
- - -
- -
-
- -
- { createInterpolateElement( - __( - 'Great! You already have billing information saved for this Google Ads account.', - 'google-listings-and-ads' - ), - { - link: ( - - ), - } - ) } -
-
-
-
-
- ); -}; - -export default BillingSavedCard; diff --git a/js/src/setup-ads/ads-stepper/setup-billing/billing-saved-card/index.scss b/js/src/setup-ads/ads-stepper/setup-billing/billing-saved-card/index.scss deleted file mode 100644 index 642ce39455..0000000000 --- a/js/src/setup-ads/ads-stepper/setup-billing/billing-saved-card/index.scss +++ /dev/null @@ -1,17 +0,0 @@ -.gla-google-ads-billing-saved-card { - &__account-number { - margin-bottom: calc(var(--main-gap) / 2); - } - - &__description { - display: flex; - gap: calc(var(--main-gap) / 3); - align-items: center; - font-style: italic; - - svg { - fill: $alert-green; - flex: 0 0 auto; - } - } -} diff --git a/js/src/setup-ads/ads-stepper/setup-billing/index.js b/js/src/setup-ads/ads-stepper/setup-billing/index.js deleted file mode 100644 index 6e610944d4..0000000000 --- a/js/src/setup-ads/ads-stepper/setup-billing/index.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import StepContent from '.~/components/stepper/step-content'; -import StepContentHeader from '.~/components/stepper/step-content-header'; -import AppSpinner from '.~/components/app-spinner'; -import useGoogleAdsAccountBillingStatus from '.~/hooks/useGoogleAdsAccountBillingStatus'; -import Section from '.~/wcdl/section'; -import { - BillingSetupCard, - fallbackBillingUrl, -} from '.~/components/paid-ads/billing-card'; -import BillingSavedCard from './billing-saved-card'; -import StepContentActions from '.~/components/stepper/step-content-actions'; -import StepContentFooter from '.~/components/stepper/step-content-footer'; -import AppButton from '.~/components/app-button'; -import { GOOGLE_ADS_BILLING_STATUS } from '.~/constants'; - -const SetupBilling = ( props ) => { - const { - formProps: { isSubmitting, handleSubmit }, - } = props; - - const { billingStatus } = useGoogleAdsAccountBillingStatus(); - - if ( ! billingStatus ) { - return ; - } - - const isApproved = - billingStatus.status === GOOGLE_ADS_BILLING_STATUS.APPROVED; - - return ( - - -
- { isApproved ? ( - - ) : ( - - ) } -
- { isApproved && ( - - - - { __( - 'Launch paid campaign', - 'google-listings-and-ads' - ) } - - - - ) } -
- ); -}; - -export default SetupBilling; diff --git a/js/src/setup-ads/setup-ads-form.js b/js/src/setup-ads/ads-stepper/setup-paid-ads.js similarity index 57% rename from js/src/setup-ads/setup-ads-form.js rename to js/src/setup-ads/ads-stepper/setup-paid-ads.js index 609c915d93..c19d4ef139 100644 --- a/js/src/setup-ads/setup-ads-form.js +++ b/js/src/setup-ads/ads-stepper/setup-paid-ads.js @@ -1,36 +1,46 @@ /** * External dependencies */ -import { isEqual } from 'lodash'; import { __ } from '@wordpress/i18n'; +import { isEqual } from 'lodash'; import { useState, useEffect } from '@wordpress/element'; import { getNewPath } from '@woocommerce/navigation'; /** * Internal dependencies */ +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 AdsStepper from './ads-stepper'; -import SetupAdsTopBar from './top-bar'; 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; /** + * Renders the step to setup paid ads + * * @fires gla_launch_paid_campaign_button_click on submit */ -const SetupAdsForm = () => { +const SetupPaidAds = () => { + const { billingStatus } = useGoogleAdsAccountBillingStatus(); const [ didFormChanged, setFormChanged ] = useState( false ); const [ isSubmitted, setSubmitted ] = useState( false ); const [ handleSetupComplete, isSubmitting ] = useAdsSetupCompleteCallback(); const adminUrl = useAdminUrl(); - const { data: targetAudience } = useTargetAudienceFinalCountryCodes(); + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const { highestDailyBudget, hasFinishedResolution } = + useFetchBudgetRecommendation( countryCodes ); const initialValues = { - amount: 0, - countryCodes: targetAudience, + amount: highestDailyBudget, }; useEffect( () => { @@ -55,7 +65,7 @@ const SetupAdsForm = () => { ); const handleSubmit = ( values ) => { - const { amount, countryCodes } = values; + const { amount } = values; recordGlaEvent( 'gla_launch_paid_campaign_button_click', { audiences: countryCodes.join( ',' ), @@ -68,18 +78,11 @@ const SetupAdsForm = () => { }; const handleChange = ( _, values ) => { - const args = [ initialValues, values ].map( - ( { countryCodes, ...v } ) => { - v.countrySet = new Set( countryCodes ); - return v; - } - ); - - setFormChanged( ! isEqual( ...args ) ); + setFormChanged( ! isEqual( initialValues, values ) ); }; - if ( ! targetAudience ) { - return null; + if ( ! countryCodes || ! hasFinishedResolution ) { + return ; } return ( @@ -87,22 +90,32 @@ const SetupAdsForm = () => { initialCampaign={ initialValues } onChange={ handleChange } onSubmit={ handleSubmit } + recommendedDailyBudget={ highestDailyBudget } > - { ( formProps ) => { - const mixedFormProps = { - ...formProps, - // TODO: maybe move all API calls in useSetupCompleteCallback to ~./data - isSubmitting, - }; - return ( - <> - - - - ); - } } + ( + + ) } + />
); }; -export default SetupAdsForm; +export default SetupPaidAds; 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-mc/setup-stepper/clientSession.js b/js/src/setup-mc/setup-stepper/clientSession.js new file mode 100644 index 0000000000..7e75702484 --- /dev/null +++ b/js/src/setup-mc/setup-stepper/clientSession.js @@ -0,0 +1,29 @@ +/** + * @typedef {Object} CampaignData + * @property {number|undefined} amount Daily average cost of the paid ads campaign. + */ + +const KEY_PAID_ADS = 'gla-onboarding-paid-ads'; + +const { sessionStorage } = window; + +const clientSession = { + /** + * @param {CampaignData} data Campaign data to be stored. + * @param {number|undefined} data.amount Daily average cost of the campaign. + */ + setCampaign( { amount } ) { + const json = JSON.stringify( { amount } ); + sessionStorage.setItem( KEY_PAID_ADS, json ); + }, + + /** + * @return {CampaignData|null} The stored campaign data. It will return `null` if stored data is not available. + */ + getCampaign() { + const json = sessionStorage.getItem( KEY_PAID_ADS ); + return json === null ? null : JSON.parse( json ); + }, +}; + +export default clientSession; diff --git a/js/src/setup-mc/setup-stepper/constants.js b/js/src/setup-mc/setup-stepper/constants.js new file mode 100644 index 0000000000..05f0b5bb55 --- /dev/null +++ b/js/src/setup-mc/setup-stepper/constants.js @@ -0,0 +1,2 @@ +export const ACTION_COMPLETE = 'complete-ads'; +export const ACTION_SKIP = 'skip-ads'; diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads.js b/js/src/setup-mc/setup-stepper/setup-paid-ads.js new file mode 100644 index 0000000000..c5ed1ac08a --- /dev/null +++ b/js/src/setup-mc/setup-stepper/setup-paid-ads.js @@ -0,0 +1,161 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import { useState } from '@wordpress/element'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import useAdminUrl from '.~/hooks/useAdminUrl'; +import useDispatchCoreNotices from '.~/hooks/useDispatchCoreNotices'; +import useAdsSetupCompleteCallback from '.~/hooks/useAdsSetupCompleteCallback'; +import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; +import AdsCampaign from '.~/components/paid-ads/ads-campaign'; +import CampaignAssetsForm from '.~/components/paid-ads/campaign-assets-form'; +import AppButton from '.~/components/app-button'; +import useGoogleAdsAccountBillingStatus from '.~/hooks/useGoogleAdsAccountBillingStatus'; +import { getProductFeedUrl } from '.~/utils/urls'; +import { API_NAMESPACE } from '.~/data/constants'; +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. + * + * @event gla_onboarding_complete_with_paid_ads_button_click + * @property {number} budget The budget for the campaign + * @property {string} audiences The targeted audiences for the campaign + */ + +/** + * Renders the onboarding step for setting up the paid ads (Google Ads account and paid campaign) + * or skipping it, and then completing the onboarding flow. + * @fires gla_onboarding_complete_with_paid_ads_button_click + */ +export default function SetupPaidAds() { + const adminUrl = useAdminUrl(); + const [ completing, setCompleting ] = useState( null ); + const { createNotice } = useDispatchCoreNotices(); + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const { highestDailyBudget, hasFinishedResolution } = + useFetchBudgetRecommendation( countryCodes ); + const [ handleSetupComplete ] = useAdsSetupCompleteCallback(); + const { billingStatus } = useGoogleAdsAccountBillingStatus(); + + const isBillingCompleted = + billingStatus?.status === GOOGLE_ADS_BILLING_STATUS.APPROVED; + + const finishOnboardingSetup = async ( onBeforeFinish = noop ) => { + try { + await onBeforeFinish(); + await apiFetch( { + path: `${ API_NAMESPACE }/mc/settings/sync`, + method: 'POST', + } ); + } catch ( e ) { + setCompleting( null ); + + createNotice( + 'error', + __( + 'Unable to complete your setup.', + 'google-listings-and-ads' + ) + ); + } + + // Force reload WC admin page to initiate the relevant dependencies of the Dashboard page. + const query = { guide: GUIDE_NAMES.SUBMISSION_SUCCESS }; + window.location.href = adminUrl + getProductFeedUrl( query ); + }; + + const handleSkipCreatePaidAds = async () => { + setCompleting( ACTION_SKIP ); + await finishOnboardingSetup(); + }; + + const createSkipButton = ( formContext ) => { + const { isValidForm } = formContext; + + return ( + + ); + }; + + const createContinueButton = ( formContext ) => { + const { isValidForm, values } = formContext; + const { amount } = values; + + const disabled = + completing === ACTION_SKIP || ! isValidForm || ! isBillingCompleted; + + const handleCompleteClick = async () => { + setCompleting( ACTION_COMPLETE ); + const onBeforeFinish = handleSetupComplete.bind( + null, + amount, + countryCodes + ); + + await finishOnboardingSetup( onBeforeFinish ); + }; + + return ( + + ); + }; + + const paidAds = { + amount: highestDailyBudget, + ...clientSession.getCampaign(), + }; + + if ( ! hasFinishedResolution || ! countryCodes ) { + return ; + } + + return ( + { + if ( values.amount >= highestDailyBudget ) { + clientSession.setCampaign( values ); + } + } } + > + + + ); +} diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/clientSession.js b/js/src/setup-mc/setup-stepper/setup-paid-ads/clientSession.js deleted file mode 100644 index 6cd0ca8a9a..0000000000 --- a/js/src/setup-mc/setup-stepper/setup-paid-ads/clientSession.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @typedef { import(".~/data/actions").CountryCode } CountryCode - * - * @typedef {Object} CampaignData - * @property {number|undefined} amount Daily average cost of the paid ads campaign. - * @property {Array} countryCodes Audience country codes of the paid ads campaign. Example: 'US'. - */ - -const KEY_SHOW_PAID_ADS_SETUP = 'gla-onboarding-show-paid-ads-setup'; -const KEY_PAID_ADS = 'gla-onboarding-paid-ads'; - -const { sessionStorage } = window; - -const clientSession = { - /** - * @param {boolean} isShowing Whether the paid ads setup is showing. - */ - setShowPaidAdsSetup( isShowing ) { - const showing = Boolean( isShowing ).toString(); - sessionStorage.setItem( KEY_SHOW_PAID_ADS_SETUP, showing ); - }, - - /** - * @param {boolean} defaultValue The default value to be returned if stored value is not available. - * @return {boolean} Returns the stored value. It will return `defaultValue` if stored value is not available. - */ - getShowPaidAdsSetup( defaultValue ) { - const showing = sessionStorage.getItem( KEY_SHOW_PAID_ADS_SETUP ); - return showing === null ? defaultValue : showing === 'true'; - }, - - /** - * @param {CampaignData} data Campaign data to be stored. - * @param {number|undefined} data.amount Daily average cost of the campaign. - * @param {Array} data.countryCodes Country codes of the campaign. - */ - setCampaign( { amount, countryCodes } ) { - const json = JSON.stringify( { amount, countryCodes } ); - sessionStorage.setItem( KEY_PAID_ADS, json ); - }, - - /** - * @return {CampaignData|null} The stored campaign data. It will return `null` if stored data is not available. - */ - getCampaign() { - const json = sessionStorage.getItem( KEY_PAID_ADS ); - return json === null ? null : JSON.parse( json ); - }, -}; - -export default clientSession; diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/index.js b/js/src/setup-mc/setup-stepper/setup-paid-ads/index.js deleted file mode 100644 index b949d024c1..0000000000 --- a/js/src/setup-mc/setup-stepper/setup-paid-ads/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './setup-paid-ads'; diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-setup-sections.js b/js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-setup-sections.js deleted file mode 100644 index 86c3739bf7..0000000000 --- a/js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-setup-sections.js +++ /dev/null @@ -1,183 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { useState, useRef, useEffect } from '@wordpress/element'; -import { Form } from '@woocommerce/components'; - -/** - * Internal dependencies - */ -import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; -import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; -import useGoogleAdsAccountBillingStatus from '.~/hooks/useGoogleAdsAccountBillingStatus'; -import AudienceSection from '.~/components/paid-ads/audience-section'; -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 { GOOGLE_ADS_BILLING_STATUS } from '.~/constants'; - -/** - * @typedef { import(".~/data/actions").CountryCode } CountryCode - * - * @typedef {Object} PaidAdsData - * @property {number|undefined} amount Daily average cost of the paid ads campaign. - * @property {Array} countryCodes Audience country codes of the paid ads campaign. Example: 'US'. - * @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. - */ - -const defaultPaidAds = { - amount: 0, - countryCodes: [], - isValid: false, - isReady: false, -}; - -/** - * Resolve the initial paid ads data from the given paid ads data with the loaded target audience. - * 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. - * @param {Array} targetAudience Country codes of selected target audience. - * @return {PaidAdsData} The resolved paid ads data. - */ -function resolveInitialPaidAds( paidAds, targetAudience ) { - const nextPaidAds = { ...paidAds }; - - if ( targetAudience ) { - if ( nextPaidAds.countryCodes === defaultPaidAds.countryCodes ) { - // Replace the country codes with the loaded target audience only if the reference is - // the same as the default because the given country codes might be the restored ones. - nextPaidAds.countryCodes = targetAudience; - } else { - // The selected target audience may be changed back and forth during the onboarding flow. - // Remove countries if any don't exist in the latest state. - nextPaidAds.countryCodes = nextPaidAds.countryCodes.filter( - ( code ) => targetAudience.includes( code ) - ); - } - } - - nextPaidAds.isValid = ! Object.keys( validateCampaign( nextPaidAds ) ) - .length; - - return nextPaidAds; -} - -/** - * Renders sections of Google Ads account, audience, 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 audience, budget, and billing are updated. - */ -export default function PaidAdsSetupSections( { onStatesReceived } ) { - const { hasGoogleAdsConnection } = useGoogleAdsAccount(); - const { data: targetAudience } = useTargetAudienceFinalCountryCodes(); - const { billingStatus } = useGoogleAdsAccountBillingStatus(); - - const onStatesReceivedRef = useRef(); - onStatesReceivedRef.current = onStatesReceived; - - const [ paidAds, setPaidAds ] = useState( () => { - // Resolve the starting paid ads data with the campaign data stored in the client session. - const startingPaidAds = { - ...defaultPaidAds, - ...clientSession.getCampaign(), - }; - return resolveInitialPaidAds( startingPaidAds, targetAudience ); - } ); - - const isBillingCompleted = - billingStatus?.status === GOOGLE_ADS_BILLING_STATUS.APPROVED; - - /* - If a merchant has not yet finished the billing setup, the billing status will be - updated by `useAutoCheckBillingStatusEffect` hook in `BillingSetupCard` component - till it gets completed. - - Or, if the billing setup is already finished, the loaded `billingStatus.status` - will already be 'approved' without passing through the above hook and component. - - Therefore, in order to ensure the parent component can continue the setup from - any billing status, it only needs to watch the `isBillingCompleted` eventually - to wait for the fulfilled 'approved' status, and then propagate it to the parent. - - For example, refresh page during onboarding flow after the billing setup is finished. - */ - useEffect( () => { - const nextPaidAds = { - ...paidAds, - isReady: paidAds.isValid && isBillingCompleted, - }; - onStatesReceivedRef.current( nextPaidAds ); - clientSession.setCampaign( nextPaidAds ); - }, [ paidAds, isBillingCompleted ] ); - - /* - Resolve the initial states after the `targetAudience` is loaded. - - Please note that the loaded `targetAudience` is NOT expected to have further changes - in the runtime. If it happens one day and it will need to update
's internal state - with the changed `targetAudience`, please refer to the following practice. - - https://github.com/woocommerce/google-listings-and-ads/blob/5b6522ca10ad75556e6b2de7c120cc712aab70b1/js/src/components/free-listings/setup-free-listings/index.js#L120-L134 - - https://github.com/woocommerce/google-listings-and-ads/blob/5b6522ca10ad75556e6b2de7c120cc712aab70b1/js/src/components/free-listings/setup-free-listings/index.js#L172-L186 - */ - useEffect( () => { - setPaidAds( ( currentPaidAds ) => - resolveInitialPaidAds( currentPaidAds, targetAudience ) - ); - }, [ targetAudience ] ); - - if ( ! targetAudience || ! billingStatus ) { - return ( -
- -
- ); - } - - const initialValues = { - countryCodes: paidAds.countryCodes, - amount: paidAds.amount, - }; - - return ( - { - setPaidAds( { ...paidAds, ...values, isValid } ); - } } - validate={ validateCampaign } - > - { ( formProps ) => { - const { countryCodes } = formProps.values; - const disabledAudience = ! hasGoogleAdsConnection; - const disabledBudget = - disabledAudience || countryCodes.length === 0; - - return ( - <> - - - { ! disabledBudget && } - - - ); - } } - - ); -} diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/setup-paid-ads.js b/js/src/setup-mc/setup-stepper/setup-paid-ads/setup-paid-ads.js deleted file mode 100644 index c28bc2585e..0000000000 --- a/js/src/setup-mc/setup-stepper/setup-paid-ads/setup-paid-ads.js +++ /dev/null @@ -1,229 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import apiFetch from '@wordpress/api-fetch'; -import { select } from '@wordpress/data'; -import { useState } from '@wordpress/element'; -import { Flex } from '@wordpress/components'; -import { noop, merge } from 'lodash'; - -/** - * Internal dependencies - */ -import useAdminUrl from '.~/hooks/useAdminUrl'; -import useDispatchCoreNotices from '.~/hooks/useDispatchCoreNotices'; -import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; -import useAdsSetupCompleteCallback from '.~/hooks/useAdsSetupCompleteCallback'; -import StepContent from '.~/components/stepper/step-content'; -import StepContentHeader from '.~/components/stepper/step-content-header'; -import StepContentFooter from '.~/components/stepper/step-content-footer'; -import StepContentActions from '.~/components/stepper/step-content-actions'; -import AppButton from '.~/components/app-button'; -import PaidAdsFaqsPanel from '.~/components/paid-ads/faqs-panel'; -import PaidAdsFeaturesSection from './paid-ads-features-section'; -import PaidAdsSetupSections from './paid-ads-setup-sections'; -import { getProductFeedUrl } from '.~/utils/urls'; -import clientSession from './clientSession'; -import { API_NAMESPACE, STORE_KEY } from '.~/data/constants'; -import { GUIDE_NAMES } from '.~/constants'; - -const ACTION_COMPLETE = 'complete-ads'; -const ACTION_SKIP = 'skip-ads'; - -/** - * Clicking on the "Create a paid ad campaign" button to open the paid ads setup in the onboarding flow. - * - * @event gla_onboarding_open_paid_ads_setup_button_click - */ - -/** - * Clicking on the "Complete setup" button to complete the onboarding flow with paid ads. - * - * @event gla_onboarding_complete_with_paid_ads_button_click - * @property {number} budget The budget for the campaign - * @property {string} audiences The targeted audiences for the campaign - */ - -/** - * Clicking on the skip paid ads button to complete the onboarding flow. - * The 'unknown' value of properties may means: - * - the paid ads setup is not opened - * - the final status has not yet been resolved when recording this event - * - the status is not available, for example, the billing status is unknown if Google Ads account is not yet connected - * - * @event gla_onboarding_complete_button_click - * @property {string} opened_paid_ads_setup Whether the paid ads setup is opened, e.g. 'yes', 'no' - * @property {string} google_ads_account_status The connection status of merchant's Google Ads addcount, e.g. 'connected', 'disconnected', 'incomplete' - * @property {string} billing_method_status aaa, The status of billing method of merchant's Google Ads addcount e.g. 'unknown', 'pending', 'approved', 'cancelled' - * @property {string} campaign_form_validation Whether the entered paid campaign form data are valid, e.g. 'unknown', 'valid', 'invalid' - */ - -/** - * Renders the onboarding step for setting up the paid ads (Google Ads account and paid campaign) - * or skipping it, and then completing the onboarding flow. - * - * @fires gla_onboarding_open_paid_ads_setup_button_click - * @fires gla_onboarding_complete_with_paid_ads_button_click - * @fires gla_onboarding_complete_button_click - */ -export default function SetupPaidAds() { - const adminUrl = useAdminUrl(); - const { createNotice } = useDispatchCoreNotices(); - const { googleAdsAccount, hasGoogleAdsConnection } = useGoogleAdsAccount(); - const [ handleSetupComplete ] = useAdsSetupCompleteCallback(); - const [ showPaidAdsSetup, setShowPaidAdsSetup ] = useState( () => - clientSession.getShowPaidAdsSetup( false ) - ); - const [ paidAds, setPaidAds ] = useState( {} ); - const [ completing, setCompleting ] = useState( null ); - - const handleContinuePaidAdsSetupClick = () => { - setShowPaidAdsSetup( true ); - clientSession.setShowPaidAdsSetup( true ); - }; - - const finishOnboardingSetup = async ( event, onBeforeFinish = noop ) => { - setCompleting( event.target.dataset.action ); - - try { - await onBeforeFinish(); - await apiFetch( { - path: `${ API_NAMESPACE }/mc/settings/sync`, - method: 'POST', - } ); - } catch ( e ) { - setCompleting( null ); - - createNotice( - 'error', - __( - 'Unable to complete your setup.', - 'google-listings-and-ads' - ) - ); - } - - // Force reload WC admin page to initiate the relevant dependencies of the Dashboard page. - const query = { guide: GUIDE_NAMES.SUBMISSION_SUCCESS }; - window.location.href = adminUrl + getProductFeedUrl( query ); - }; - - const handleCompleteClick = async ( event ) => { - const onBeforeFinish = handleSetupComplete.bind( - null, - paidAds.amount, - paidAds.countryCodes - ); - await finishOnboardingSetup( event, onBeforeFinish ); - }; - - // The status check of Google Ads account connection is included in `paidAds.isReady`, - // because when there is no connected account, it will disable the budget section and set the `amount` to `undefined`. - const disabledComplete = completing === ACTION_SKIP || ! paidAds.isReady; - - function createSkipButton( text ) { - const eventProps = { - opened_paid_ads_setup: 'no', - google_ads_account_status: googleAdsAccount?.status, - billing_method_status: 'unknown', - campaign_form_validation: 'unknown', - }; - - if ( showPaidAdsSetup ) { - const selector = select( STORE_KEY ); - const billing = selector.getGoogleAdsAccountBillingStatus(); - - merge( eventProps, { - opened_paid_ads_setup: 'yes', - billing_method_status: billing?.status, - campaign_form_validation: paidAds.isValid ? 'valid' : 'invalid', - } ); - } - - const disabledSkip = - completing === ACTION_COMPLETE || ! hasGoogleAdsConnection; - - return ( - - ); - } - - return ( - - - - } - /> - { showPaidAdsSetup && ( - - ) } - - - - - - - ); -} diff --git a/js/src/setup-mc/setup-stepper/skip-button.js b/js/src/setup-mc/setup-stepper/skip-button.js new file mode 100644 index 0000000000..c5a62f2d54 --- /dev/null +++ b/js/src/setup-mc/setup-stepper/skip-button.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; +import useGoogleAdsAccountBillingStatus from '.~/hooks/useGoogleAdsAccountBillingStatus'; +import AppButton from '.~/components/app-button'; +import SkipPaidAdsConfirmationModal from './skip-paid-ads-confirmation-modal'; +import { recordGlaEvent } from '.~/utils/tracks'; + +/** + * Clicking on the skip paid ads button to complete the onboarding flow. + * The 'unknown' value of properties may means: + * - the final status has not yet been resolved when recording this event + * - the status is not available, for example, the billing status is unknown if Google Ads account is not yet connected + * + * @event gla_onboarding_complete_button_click + * @property {string} google_ads_account_status The connection status of merchant's Google Ads addcount, e.g. 'connected', 'disconnected', 'incomplete' + * @property {string} billing_method_status The status of billing method of merchant's Google Ads addcount e.g. 'unknown', 'pending', 'approved', 'cancelled' + * @property {string} campaign_form_validation Whether the entered paid campaign form data are valid, e.g. 'unknown', 'valid', 'invalid' + */ + +export default function SkipButton( { + isValidForm, + onSkipCreatePaidAds = noop, + loading, + disabled, +} ) { + const [ + showSkipPaidAdsConfirmationModal, + setShowSkipPaidAdsConfirmationModal, + ] = useState( false ); + const { googleAdsAccount } = useGoogleAdsAccount(); + const { billingStatus } = useGoogleAdsAccountBillingStatus(); + + const handleOnSkipClick = () => { + setShowSkipPaidAdsConfirmationModal( true ); + }; + + const handleCancelSkipPaidAdsClick = () => { + setShowSkipPaidAdsConfirmationModal( false ); + }; + + const handleSkipCreatePaidAds = () => { + setShowSkipPaidAdsConfirmationModal( false ); + + const eventProps = { + google_ads_account_status: googleAdsAccount?.status, + billing_method_status: billingStatus?.status || 'unknown', + campaign_form_validation: isValidForm ? 'valid' : 'invalid', + }; + recordGlaEvent( 'gla_onboarding_complete_button_click', eventProps ); + + onSkipCreatePaidAds(); + }; + + return ( + <> + + + { showSkipPaidAdsConfirmationModal && ( + + ) } + + ); +} diff --git a/js/src/setup-mc/setup-stepper/skip-paid-ads-confirmation-modal.js b/js/src/setup-mc/setup-stepper/skip-paid-ads-confirmation-modal.js new file mode 100644 index 0000000000..b2ebb0a4de --- /dev/null +++ b/js/src/setup-mc/setup-stepper/skip-paid-ads-confirmation-modal.js @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import AppModal from '.~/components/app-modal'; +import AppButton from '.~/components/app-button'; +import AppDocumentationLink from '.~/components/app-documentation-link'; + +/** + * @fires gla_documentation_link_click with `{ context: 'skip-paid-ads-modal', link_id: 'paid-ads-with-performance-max-campaigns-learn-more', href: 'https://support.google.com/google-ads/answer/10724817' }` + */ + +/** + * Renders a modal dialog that confirms whether the user wants to skip setting up paid ads. + * It provides information about the benefits of enabling Performance Max and includes a link to learn more. + * + * @param {Object} props React props. + * @param {Function} props.onRequestClose Function to be called when the modal should be closed. + * @param {Function} props.onSkipCreatePaidAds Function to be called when the user confirms skipping the paid ads setup. + */ +const SkipPaidAdsConfirmationModal = ( { + onRequestClose, + onSkipCreatePaidAds, +} ) => { + return ( + + { __( 'Cancel', 'google-listings-and-ads' ) } + , + + { __( + 'Complete setup without setting up ads', + 'google-listings-and-ads' + ) } + , + ] } + onRequestClose={ onRequestClose } + > +

+ { __( + 'Enabling Performance Max is highly recommended to drive more sales and reach new audiences across Google channels like Search, YouTube and Discover.', + 'google-listings-and-ads' + ) } +

+

+ { __( + 'Performance Max uses the best of Google’s AI to show the most impactful ads for your products at the right time and place. Google will use your product data to create ads for this campaign.', + 'google-listings-and-ads' + ) } +

+

+ + { __( + 'Learn more about Performance Max.', + 'google-listings-and-ads' + ) } + +

+ + ); +}; + +export default SkipPaidAdsConfirmationModal; 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 805f77e798..f9803e0538 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 */ @@ -11,12 +12,9 @@ import SetupAdsAccountsPage from '../../utils/pages/setup-ads/setup-ads-accounts import SetupBudgetPage from '../../utils/pages/setup-ads/setup-budget'; import { LOAD_STATE } from '../../utils/constants'; import { - getCountryInputSearchBoxContainer, - getCountryTagsFromInputSearchBoxContainer, getFAQPanelTitle, getFAQPanelRow, checkFAQExpandable, - checkBillingAdsPopup, } from '../../utils/page'; const ADS_ACCOUNTS = [ @@ -67,8 +65,21 @@ test.describe( 'Set up Ads account', () => { page = await browser.newPage(); dashboardPage = new DashboardPage( page ); setupAdsAccounts = new SetupAdsAccountsPage( page ); + setupBudgetPage = new SetupBudgetPage( page ); await setOnboardedMerchant(); await setupAdsAccounts.mockAdsAccountsResponse( [] ); + await setupBudgetPage.fulfillBillingStatusRequest( { + status: 'approved', + } ); + await setupBudgetPage.fulfillBudgetRecommendations( { + currency: 'EUR', + recommendations: [ + { + country: 'FR', + daily_budget: 15, + }, + ], + } ); await dashboardPage.mockRequests(); await dashboardPage.goto(); } ); @@ -215,7 +226,8 @@ test.describe( 'Set up Ads account', () => { await setupAdsAccounts.mockAdsStatusClaimed(); - await page.reload(); + await page.dispatchEvent( 'body', 'blur' ); + await page.dispatchEvent( 'body', 'focus' ); await expect( setupAdsAccounts.getContinueButton() ).toBeEnabled(); @@ -280,32 +292,19 @@ test.describe( 'Set up Ads account', () => { } ); test.describe( 'Create your paid campaign', () => { - test.beforeAll( async () => { - setupBudgetPage = new SetupBudgetPage( page ); - } ); - - test( 'Continue to create paid campaign', async () => { + test( 'Continue to create paid ad campaign', async () => { await setupAdsAccounts.clickContinue(); await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); - await expect( page.getByRole( 'heading', { name: 'Create your paid campaign', } ) ).toBeVisible(); - await expect( - page.getByRole( 'heading', { name: 'Ads audience' } ) - ).toBeVisible(); - await expect( page.getByRole( 'heading', { name: 'Set your budget' } ) ).toBeVisible(); - await expect( - page.getByRole( 'button', { name: 'Continue' } ) - ).toBeDisabled(); - await expect( page.getByRole( 'link', { name: 'See what your ads will look like.', @@ -354,185 +353,100 @@ test.describe( 'Set up Ads account', () => { await checkFAQExpandable( page ); } ); } ); - - test( 'Audience should be United States', async () => { - const countrySearchBoxContainer = - getCountryInputSearchBoxContainer( page ); - const countryTags = - getCountryTagsFromInputSearchBoxContainer( page ); - await expect( countryTags ).toHaveCount( 1 ); - await expect( countrySearchBoxContainer ).toContainText( - 'United States' - ); - } ); - - 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 ); - - await expect( - page.getByRole( 'button', { name: 'Continue' } ) - ).toBeEnabled(); - } ); - - test( 'Budget Recommendation', async () => { - await expect( - page.getByText( 'set a daily budget of 15 USD' ) - ).toBeVisible(); - } ); } ); - test.describe( 'Set up billing', () => { - test.describe( 'Billing status is not approved', () => { - test.beforeAll( async () => { - await setupBudgetPage.fulfillBillingStatusRequest( { - status: 'pending', - } ); - } ); - test( 'It should say that the billing is not setup', async () => { - await page.getByRole( 'button', { name: 'Continue' } ).click(); - await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + test.describe( 'Create Ads with billing data already setup', () => { + test.describe( 'Set the budget', async () => { + test( 'Continue button should be disabled if budget is 0', async () => { + budget = '0'; + await setupBudgetPage.fillBudget( budget ); await expect( - page.getByRole( 'button', { - name: 'Set up billing', - exact: true, - } ) - ).toBeEnabled(); + setupBudgetPage.getLaunchPaidCampaignButton() + ).toBeDisabled(); + } ); - await expect( - page.getByText( - 'In order to launch your paid campaign, your billing information is required. You will be billed directly by Google and only pay when someone clicks on your ad.' - ) - ).toBeVisible(); + test( 'Continue button should be disabled if budget is less than recommended value', async () => { + budget = '2'; + await setupBudgetPage.fillBudget( budget ); await expect( - page.getByRole( 'link', { - name: 'click here instead', - } ) - ).toBeVisible(); + setupBudgetPage.getLaunchPaidCampaignButton() + ).toBeDisabled(); } ); - // eslint-disable-next-line jest/expect-expect - test( 'should open a popup when clicking set up billing button', async () => { - await checkBillingAdsPopup( page ); - } ); - } ); - - test.describe( 'Billing status is approved', async () => { - test.beforeAll( async () => { - await setupBudgetPage.fulfillBillingStatusRequest( { - status: 'approved', - } ); - - await setupAdsAccounts.mockAdsAccountsResponse( { - id: ADS_ACCOUNTS[ 1 ], - billing_url: null, - } ); - - // Simulate a bit of delay when creating the Ads campaign so we have enough time to test the content in the page before the redirect. - await page.route( - /\/wc\/gla\/ads\/campaigns\b/, - async ( route ) => { - await new Promise( ( f ) => setTimeout( f, 500 ) ); - await route.continue(); - } - ); - } ); - test( 'It should say that the billing is setup', async () => { - //Every 30s the page will check if the billing status is approved and it will trigger the campaign creation. - await setupBudgetPage.awaitForBillingStatusRequest(); - await setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion( - budget, - [ 'US' ] - ); + test( 'User is notified of the minimum value', async () => { + budget = '4'; + await setupBudgetPage.fillBudget( budget ); + await setupBudgetPage.getBudgetInput().blur(); await expect( page.getByText( - 'Great! You already have billing information saved for this' + 'Please make sure daily average cost is at least €5.00' ) ).toBeVisible(); - - //It should redirect to the dashboard page - await page.waitForURL( - '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success', - { - timeout: 30000, - waitUntil: LOAD_STATE.DOM_CONTENT_LOADED, - } - ); } ); - test( 'It should show the campaign creation success message', async () => { - await expect( - page.getByRole( 'heading', { - name: "You've set up a paid Performance Max Campaign!", - } ) - ).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: 'Create another campaign', - } ) + setupBudgetPage.getLaunchPaidCampaignButton() ).toBeEnabled(); + } ); + test( 'Budget Recommendation should be visible', async () => { await expect( - page.getByRole( 'button', { - name: 'Got It', - } ) - ).toBeEnabled(); - - await page - .getByRole( 'button', { - name: 'Got It', - } ) - .click(); + page.getByText( 'set a daily budget of 15 EUR' ) + ).toBeVisible(); } ); } ); - } ); - test.describe( 'Create Ads with billing data already setup', () => { - test( 'Launch paid campaign should be enabled', async () => { - //Click Add paid Campaign - await adsConnectionButton.click(); - await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + test( 'It should show the campaign creation success message', async () => { + // Mock the campaign creation request. + const campaignCreation = + setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion( + '6', + [ 'US' ] + ); - //Step 1 - Accounts are already set up. - await setupAdsAccounts.clickContinue(); - await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + await setupBudgetPage.getLaunchPaidCampaignButton().click(); - //Step 2 - Fill the budget - await setupBudgetPage.fillBudget( '1' ); - await page.getByRole( 'button', { name: 'Continue' } ).click(); - await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + await campaignCreation; + + //It should redirect to the dashboard page + await page.waitForURL( + '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success', + { + timeout: 30000, + waitUntil: LOAD_STATE.DOM_CONTENT_LOADED, + } + ); - //Step 3 - Billing is already setup await expect( - page.getByText( - 'Great! You already have billing information saved for this' - ) + page.getByRole( 'heading', { + name: "You've set up a paid Performance Max Campaign!", + } ) ).toBeVisible(); await expect( - page.getByRole( 'button', { name: 'Launch paid campaign' } ) + page.getByRole( 'button', { + name: 'Create another campaign', + } ) + ).toBeEnabled(); + + await expect( + page.getByRole( 'button', { + name: 'Got It', + } ) ).toBeEnabled(); - const campaignCreation = - setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion( - '1', - [ 'US' ] - ); await page - .getByRole( 'button', { name: 'Launch paid campaign' } ) + .getByRole( 'button', { + name: 'Got It', + } ) .click(); - await campaignCreation; } ); } ); } ); 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 53b2a4a497..e85e6fd755 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 @@ -11,13 +11,8 @@ import CompleteCampaign from '../../utils/pages/setup-mc/step-4-complete-campaig import SetupAdsAccountPage from '../../utils/pages/setup-ads/setup-ads-accounts'; import { checkFAQExpandable, - fillCountryInSearchBox, - getCountryInputSearchBoxContainer, - getCountryTagsFromInputSearchBoxContainer, getFAQPanelTitle, getFAQPanelRow, - getTreeSelectMenu, - removeCountryFromSearchBox, checkBillingAdsPopup, } from '../../utils/page'; @@ -84,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(), @@ -159,15 +172,6 @@ test.describe( 'Complete your campaign', () => { test.describe( 'Set up paid ads', () => { test.describe( 'Click "Create a paid ad campaign" button', () => { - test.beforeAll( async () => { - await completeCampaign.clickCreatePaidAdButton(); - } ); - - test( 'should not see the "Create a paid ad campaign" button after this section is shown', async () => { - const button = completeCampaign.getCreatePaidAdButton(); - await expect( button ).toBeHidden(); - } ); - test( 'should see "Complete setup" button is disabled', async () => { const completeSetupButton = completeCampaign.getCompleteSetupButton(); @@ -183,17 +187,6 @@ test.describe( 'Complete your campaign', () => { } ); test.describe( 'Setup up ads to a Google Ads account', () => { - test( 'should see "Ads audience" section is enabled', async () => { - const adsAudienceSection = - completeCampaign.getAdsAudienceSection(); - await expect( adsAudienceSection ).toBeVisible(); - - // Confirm that the section title contains the correct text. - await expect( - adsAudienceSection.locator( 'h1' ) - ).toContainText( 'Ads audience' ); - } ); - test( 'should see "Set your budget" section is enabled', async () => { const budgetSection = completeCampaign.getBudgetSection(); await expect( budgetSection ).toBeVisible(); @@ -219,89 +212,95 @@ test.describe( 'Complete your campaign', () => { await completeCampaign.goto(); } ); - test.describe( 'Select audience', () => { - test( 'should see only three country tags in country input search box', async () => { - const countrySearchBoxContainer = - getCountryInputSearchBoxContainer( page ); - const countryTags = - getCountryTagsFromInputSearchBoxContainer( page ); - await expect( countryTags ).toHaveCount( 3 ); - await expect( countrySearchBoxContainer ).toContainText( - 'United States' - ); - await expect( countrySearchBoxContainer ).toContainText( - 'Taiwan' - ); - await expect( countrySearchBoxContainer ).toContainText( - 'United Kingdom' + 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 only allow searching for the same set of the countries selected in step 2, which is returned by target audience API', async () => { - const treeSelectMenu = getTreeSelectMenu( page ); - - await fillCountryInSearchBox( page, 'United States' ); - await expect( treeSelectMenu ).toBeVisible(); - - await fillCountryInSearchBox( page, 'United Kingdom' ); - await expect( treeSelectMenu ).toBeVisible(); + 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(); + await expect( lowBudgetTip ).toBeVisible(); + } ); - await fillCountryInSearchBox( page, 'Taiwan' ); - await expect( treeSelectMenu ).toBeVisible(); + test( 'should not see the low budget tip when the buget is set higher than the recommended value', async () => { + await setupBudgetPage.fillBudget( '99999' ); + const lowBudgetTip = setupBudgetPage.getLowerBudgetTip(); + await expect( lowBudgetTip ).not.toBeVisible(); + } ); + } ); - await fillCountryInSearchBox( page, 'Japan' ); - await expect( treeSelectMenu ).not.toBeVisible(); + test.describe( 'Validate budget percent', () => { + test.beforeAll( async () => { + await setupBudgetPage.fulfillBudgetRecommendations( { + currency: 'TWD', + recommendations: [ + { + country: 'US', + daily_budget: 100, + }, + ], + } ); - await fillCountryInSearchBox( page, 'Spain' ); - await expect( treeSelectMenu ).not.toBeVisible(); + await completeCampaign.goto(); } ); - test( 'should see the budget recommendation value changed, and see the budget recommendation request is triggered when changing the ads audience', async () => { - let textContent = await setupBudgetPage - .getBudgetRecommendationTextRow() - .textContent(); + test( 'should see validation error if lower than the 30%', async () => { + await setupBudgetPage.fillBudget( '10' ); + await setupBudgetPage.getBudgetInput().blur(); + const error = page.locator( + '.components-base-control__help' + ); - const textBeforeRemoveCountry = - setupBudgetPage.extractBudgetRecommendationValue( - textContent - ); + await expect( error ).toHaveText( + 'Please make sure daily average cost is at least NT$30.00' + ); + } ); - const responsePromise = - setupBudgetPage.registerBudgetRecommendationResponse(); + test( 'should see validation error if slightly less than the 30%', async () => { + await setupBudgetPage.fillBudget( '29.99' ); + await setupBudgetPage.getBudgetInput().blur(); + const error = page.locator( + '.components-base-control__help' + ); - await removeCountryFromSearchBox( - page, - 'United Kingdom (UK)' + await expect( error ).toHaveText( + 'Please make sure daily average cost is at least NT$30.00' ); + } ); - await responsePromise; + test( 'should not see validation error if exactly 30%', async () => { + await setupBudgetPage.fillBudget( '30' ); + await setupBudgetPage.getBudgetInput().blur(); - textContent = await setupBudgetPage - .getBudgetRecommendationTextRow() - .textContent(); + const error = page.locator( + '.components-base-control__help' + ); + await expect( error ).not.toBeVisible(); + } ); - const textAfterRemoveCountry = - setupBudgetPage.extractBudgetRecommendationValue( - textContent - ); + test( 'should not see validation error if slightly greater than 30%', async () => { + await setupBudgetPage.fillBudget( '30.5' ); + await setupBudgetPage.getBudgetInput().blur(); - await expect( textBeforeRemoveCountry ).not.toBe( - textAfterRemoveCountry + const error = page.locator( + '.components-base-control__help' ); + await expect( error ).not.toBeVisible(); } ); - } ); - test.describe( 'Set up budget', () => { - 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(); - await expect( lowBudgetTip ).toBeVisible(); - } ); + test( 'should not see validation error if greater than 30%', async () => { + await setupBudgetPage.fillBudget( '40' ); + await setupBudgetPage.getBudgetInput().blur(); - test( 'should not see the low budget tip when the buget is set higher than the recommended value', async () => { - await setupBudgetPage.fillBudget( '99999' ); - const lowBudgetTip = setupBudgetPage.getLowerBudgetTip(); - await expect( lowBudgetTip ).not.toBeVisible(); + const error = page.locator( + '.components-base-control__help' + ); + await expect( error ).not.toBeVisible(); } ); } ); @@ -344,12 +343,21 @@ test.describe( 'Complete your campaign', () => { } ); test( 'should see billing has been set up successfully when billing status API returns approved', async () => { + await setupBudgetPage.mockAdsAccountsResponse( { + id: 12345, + billing_url: null, + } ); await setupBudgetPage.fulfillBillingStatusRequest( { - status: 'approved', + status: 'pending', } ); await newPage.close(); - await page.reload(); + // return focus to the page. + await setupBudgetPage.focusBudget(); + await setupBudgetPage.fulfillBillingStatusRequest( { + status: 'approved', + } ); + await setupBudgetPage.awaitForBillingStatusRequest(); const billingSetupSuccessSection = setupBudgetPage.getBillingSetupSuccessSection(); @@ -370,77 +378,114 @@ test.describe( 'Complete your campaign', () => { await completeCampaign.clickCompleteSetupButton(); await requestsPromises; - const setupSuccessModal = page - .locator( '.components-modal__content' ) - .filter( { - hasText: - 'You’ve successfully set up Google for WooCommerce!', - } ); + const setupSuccessModal = + completeCampaign.getSetupSuccessModal(); await expect( setupSuccessModal ).toBeVisible(); } ); } ); } ); } ); - test.describe( 'Complete onboarding by "Skip this step for now"', () => { - test.beforeAll( async () => { - // Reset the showing status for the "Set up paid ads" section. - await page.evaluate( () => window.sessionStorage.clear() ); - await setupAdsAccountPage.mockAdsAccountIncomplete(); - await completeCampaign.goto(); - await completeCampaign.clickSkipStepButton(); - } ); + test.describe( 'Ask user for confirmation when clicking "Skip this step for now"', () => { + test.describe( 'User skips paid ads creation', () => { + test.beforeAll( async () => { + // Reset the showing status for the "Set up paid ads" section. + await page.evaluate( () => window.sessionStorage.clear() ); + await setupAdsAccountPage.mockAdsAccountIncomplete(); + await completeCampaign.goto(); + await completeCampaign.clickSkipPaidAdsCreationButton(); + } ); - test( 'should see the setup success modal', async () => { - const setupSuccessModal = page - .locator( '.components-modal__content' ) - .filter( { - hasText: - 'You’ve successfully set up Google for WooCommerce!', - } ); - await expect( setupSuccessModal ).toBeVisible(); - } ); + test( 'should see the modal', async () => { + const skipPaidAdsModal = + completeCampaign.getSkipPaidAdsCreationModal(); + await expect( skipPaidAdsModal ).toBeVisible(); + } ); - test( 'should see the url contains product-feed', async () => { - expect( page.url() ).toMatch( /path=%2Fgoogle%2Fproduct-feed/ ); - } ); - } ); + test( 'should see the url contains product-feed if the user skips', async () => { + await completeCampaign.clickCompleteSetupModalButton(); + await page.waitForURL( /path=%2Fgoogle%2Fproduct-feed/ ); + expect( page.url() ).toMatch( /path=%2Fgoogle%2Fproduct-feed/ ); + } ); - test.describe( 'Complete onboarding by "Skip paid ads creation"', () => { - test.beforeAll( async () => { - await setupAdsAccountPage.mockAdsAccountIncomplete(); - await completeCampaign.goto(); - await completeCampaign.clickCreatePaidAdButton(); - await completeCampaign.clickSkipPaidAdsCreationButon(); - } ); + test( 'should see the setup success modal', async () => { + const setupSuccessModal = + completeCampaign.getSetupSuccessModal(); + await expect( setupSuccessModal ).toBeVisible(); + } ); + + test( 'should see buttons on Dashboard for Google Ads onboarding', async () => { + await page.keyboard.press( 'Escape' ); + await page.getByRole( 'tab', { name: 'Dashboard' } ).click(); - test( 'should also see the setup success modal', async () => { - const setupSuccessModal = page - .locator( '.components-modal__content' ) - .filter( { - hasText: - 'You’ve successfully set up Google for WooCommerce!', + const buttons = page.getByRole( 'button', { + name: 'Add paid campaign', } ); - await expect( setupSuccessModal ).toBeVisible(); + + await expect( buttons ).toHaveCount( 2 ); + for ( const button of await buttons.all() ) { + await expect( button ).toBeVisible(); + await expect( button ).toBeEnabled(); + } + } ); } ); - test( 'should also see the url contains product-feed', async () => { - expect( page.url() ).toMatch( /path=%2Fgoogle%2Fproduct-feed/ ); + test.describe( 'User does not skip paid ads creation', () => { + test.beforeAll( async () => { + // Reset the showing status for the "Set up paid ads" section. + await page.evaluate( () => window.sessionStorage.clear() ); + await setupAdsAccountPage.mockAdsAccountIncomplete(); + await completeCampaign.goto(); + await completeCampaign.clickSkipPaidAdsCreationButton(); + } ); + + test( 'should no longer see the confirmation modal', async () => { + await completeCampaign.clickCancelModalButton(); + + const skipPaidAdsModal = + completeCampaign.getSkipPaidAdsCreationModal(); + await expect( skipPaidAdsModal ).not.toBeVisible(); + } ); + + test( 'user should stay on the same page', async () => { + await expect( page.url() ).toMatch( + /path=%2Fgoogle%2Fsetup-mc&google-mc=connected/ + ); + } ); } ); + } ); - test( 'should see buttons on Dashboard for Google Ads onboarding', async () => { - await page.keyboard.press( 'Escape' ); - await page.getByRole( 'tab', { name: 'Dashboard' } ).click(); + test.describe( 'Free Ad Credit', () => { + test( 'should not see the Free Ad Credit section if the account is not eligible', async () => { + await setupAdsAccountPage.mockAdsAccountConnected(); + await completeCampaign.goto(); + await setupAdsAccountPage.awaitAdsConnectionResponse(); + + // Check we are on the correct page. + await expect( + page.getByText( 'Create a campaign to advertise your products' ) + ).toBeVisible(); + + await expect( + page.getByText( + 'Spend $500 to get $500 in Google Ads credits!' + ) + ).not.toBeVisible(); + } ); - const buttons = page.getByRole( 'button', { - name: 'Add paid campaign', + test( 'should see the Free Ad Credit section if the account is eligible', async () => { + await setupAdsAccountPage.mockAdsAccountConnected( 12345, { + sub_account: true, + created_timestamp: Math.floor( Date.now() / 1000 ), } ); + await completeCampaign.goto(); + await setupAdsAccountPage.awaitAdsConnectionResponse(); - await expect( buttons ).toHaveCount( 2 ); - for ( const button of await buttons.all() ) { - await expect( button ).toBeVisible(); - await expect( button ).toBeEnabled(); - } + await expect( + page.getByText( + 'Spend $500 to get $500 in Google Ads credits!' + ) + ).toBeVisible(); } ); } ); } ); diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js index 2cd4b45222..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 * @@ -503,14 +518,16 @@ export default class MockRequests { * Mock Google Ads account as connected. * * @param {number} [id=12345] + * @param {Object} [args={}] - Additional properties to customize the account connection details. * @return {Promise} */ - async mockAdsAccountConnected( id = 12345 ) { + async mockAdsAccountConnected( id = 12345, args = {} ) { await this.fulfillAdsConnection( { id, currency: 'TWD', symbol: 'NT$', status: 'connected', + ...args, } ); } diff --git a/tests/e2e/utils/page.js b/tests/e2e/utils/page.js index e68078d053..51713bd110 100644 --- a/tests/e2e/utils/page.js +++ b/tests/e2e/utils/page.js @@ -54,19 +54,6 @@ export function getCountryInputSearchBoxContainer( page ) { ); } -/** - * Get country tags from input search box container. - * - * @param {import('@playwright/test').Page} page The current page. - * - * @return {import('@playwright/test').Locator} Get country tags from input search box container. - */ -export function getCountryTagsFromInputSearchBoxContainer( page ) { - return getCountryInputSearchBoxContainer( page ).locator( - '.woocommerce-tag' - ); -} - /** * Get country input search box. * @@ -80,17 +67,6 @@ export function getCountryInputSearchBox( page ) { ); } -/** - * Get tree select menu. - * - * @param {import('@playwright/test').Page} page The current page. - * - * @return {import('@playwright/test').Locator} Get tree select menu. - */ -export function getTreeSelectMenu( page ) { - return page.locator( '.woocommerce-tree-select-control__main' ); -} - /** * Get tree item by country name. * diff --git a/tests/e2e/utils/pages/setup-ads/setup-ads-accounts.js b/tests/e2e/utils/pages/setup-ads/setup-ads-accounts.js index a84a70f63f..8577686b5a 100644 --- a/tests/e2e/utils/pages/setup-ads/setup-ads-accounts.js +++ b/tests/e2e/utils/pages/setup-ads/setup-ads-accounts.js @@ -228,4 +228,18 @@ export default class SetupAdsAccount extends MockRequests { const button = this.getConnectDifferentAccountButton(); await button.click(); } + + /** + * Await the response for ads connection. + * + * @return {Promise} The response. + */ + async awaitAdsConnectionResponse() { + return this.page.waitForResponse( ( response ) => { + return ( + response.url().includes( '/gla/ads/connection' ) && + response.status() === 200 + ); + } ); + } } diff --git a/tests/e2e/utils/pages/setup-ads/setup-budget.js b/tests/e2e/utils/pages/setup-ads/setup-budget.js index dca6843f64..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,17 @@ 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. * @@ -85,33 +96,15 @@ export default class SetupBudget extends MockRequests { } /** - * Extract budget recommendation value. - * - * @param {string} text + * Get the Launch paid campaign button. * - * @return {string} The budget recommendation value. + * @return {import('@playwright/test').Locator} Launch paid campaign button. */ - extractBudgetRecommendationValue( text ) { - const match = text.match( /set a daily budget of (\d+)/ ); - if ( match ) { - return match[ 1 ]; - } - return ''; - } - - /** - * Register the responses when removing an ads audience. - * - * @return {Promise} The response. - */ - registerBudgetRecommendationResponse() { - return this.page.waitForResponse( - ( response ) => - response - .url() - .includes( '/gla/ads/campaigns/budget-recommendation' ) && - response.status() === 200 - ); + getLaunchPaidCampaignButton() { + return this.page.getByRole( 'button', { + name: 'Launch paid campaign', + exact: true, + } ); } /** @@ -126,6 +119,16 @@ export default class SetupBudget extends MockRequests { await input.fill( budget ); } + /** + * Focus the budget input. + * + * @return {Promise} + */ + async focusBudget() { + const input = this.getBudgetInput(); + await input.focus(); + } + /** * Click set up billing button. * diff --git a/tests/e2e/utils/pages/setup-mc/step-4-complete-campaign.js b/tests/e2e/utils/pages/setup-mc/step-4-complete-campaign.js index 6fc934fa88..909f1ea8cd 100644 --- a/tests/e2e/utils/pages/setup-mc/step-4-complete-campaign.js +++ b/tests/e2e/utils/pages/setup-mc/step-4-complete-campaign.js @@ -46,15 +46,6 @@ export default class CompleteCampaign extends MockRequests { return this.page.locator( '.wcdl-section' ); } - /** - * Get ads audience section. - * - * @return {import('@playwright/test').Locator} Get ads audience section. - */ - getAdsAudienceSection() { - return this.getSections().nth( 1 ); - } - /** * Get budget section. * @@ -64,30 +55,6 @@ export default class CompleteCampaign extends MockRequests { return this.page.locator( '.gla-budget-section' ).nth( 0 ); } - /** - * Get skip this step for now button. - * - * @return {import('@playwright/test').Locator} Get skip this step for now button. - */ - getSkipStepButton() { - return this.page.getByRole( 'button', { - name: 'Skip this step for now', - exact: true, - } ); - } - - /** - * Get create a paid ad button. - * - * @return {import('@playwright/test').Locator} Get create a paid ad button. - */ - getCreatePaidAdButton() { - return this.page.getByRole( 'button', { - name: 'Create campaign', - exact: true, - } ); - } - /** * Get complete setup button. * @@ -112,39 +79,17 @@ export default class CompleteCampaign extends MockRequests { } ); } - /** - * Click skip this step for now button. - * - * @return {Promise} - */ - async clickSkipStepButton() { - const button = this.getSkipStepButton(); - await button.click(); - await this.page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); - } - /** * Click skip paid ads creation button. * * @return {Promise} */ - async clickSkipPaidAdsCreationButon() { + async clickSkipPaidAdsCreationButton() { const button = this.getSkipPaidAdsCreationButton(); await button.click(); await this.page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); } - /** - * Click create a paid ad campaign button. - * - * @return {Promise} - */ - async clickCreatePaidAdButton() { - const button = this.getCreatePaidAdButton(); - await button.click(); - await this.page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); - } - /** * Click complete setup button. * @@ -179,4 +124,71 @@ export default class CompleteCampaign extends MockRequests { mcSettingsSyncRequestPromise, ] ); } + + /** + * Retrieves the "Complete setup without setting up ads" button from the skip paid ads creation modal. + * + * @return {import('@playwright/test').Locator} Locator for the "Complete setup without setting up ads" button. + */ + getCompleteSetupModalButton() { + return this.page.getByRole( 'button', { + name: 'Complete setup without setting up ads', + exact: true, + } ); + } + + /** + * Clicks the "Complete setup without setting up ads" button in the skip paid ads creation modal. + * + * @return {Promise} Resolves when the click action is completed and the page has loaded. + */ + async clickCompleteSetupModalButton() { + const button = this.getCompleteSetupModalButton(); + await button.click(); + await this.page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + } + + /** + * Retrieves the "Cancel" button from the skip paid ads creation modal. + * + * @return {import('@playwright/test').Locator} Locator for the "Cancel" button. + */ + getCancelModalButton() { + return this.page.getByRole( 'button', { + name: 'Cancel', + exact: true, + } ); + } + + /** + * Clicks the "Cancel" button in the skip paid ads creation modal. + * + * @return {Promise} Resolves when the click action is completed. + */ + async clickCancelModalButton() { + const button = this.getCancelModalButton(); + await button.click(); + } + + /** + * Retrieves the skip paid ads creation modal element. + * + * @return {import('@playwright/test').Locator} Locator for the modal containing the text "Skip setting up ads?". + */ + getSkipPaidAdsCreationModal() { + return this.page.locator( '.components-modal__content' ).filter( { + hasText: 'Skip setting up ads?', + } ); + } + + /** + * Retrieves the setup success modal element. + * + * @return {import('@playwright/test').Locator} Locator for the modal containing the text "You’ve successfully set up Google for WooCommerce!". + */ + getSetupSuccessModal() { + return this.page.locator( '.components-modal__content' ).filter( { + hasText: 'You’ve successfully set up Google for WooCommerce!', + } ); + } }