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/components/paid-ads/ads-campaign.js b/js/src/components/paid-ads/ads-campaign.js deleted file mode 100644 index c65ca371da..0000000000 --- a/js/src/components/paid-ads/ads-campaign.js +++ /dev/null @@ -1,117 +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..0ff2497eac --- /dev/null +++ b/js/src/components/paid-ads/ads-campaign/ads-campaign.js @@ -0,0 +1,189 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + createInterpolateElement, + useState, + useEffect, +} from '@wordpress/element'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; +import StepContent from '.~/components/stepper/step-content'; +import StepContentHeader from '.~/components/stepper/step-content-header'; +import StepContentActions from '.~/components/stepper/step-content-actions'; +import StepContentFooter from '.~/components/stepper/step-content-footer'; +import AppDocumentationLink from '.~/components/app-documentation-link'; +import AppButton from '.~/components/app-button'; +import PaidAdsFaqsPanel from './faqs-panel'; +import PaidAdsFeaturesSection from './paid-ads-features-section'; +import PaidAdsSetupSections from './paid-ads-setup-sections'; +import SkipButton from './skip-button'; +import useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; +import { ACTION_SKIP, ACTION_COMPLETE } from './constants'; + +/** + * @typedef {import('.~/data/actions').Campaign} Campaign + */ + +/** + * 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 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' }` + * @fires gla_onboarding_complete_with_paid_ads_button_click + * @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 {string} props.headerTitle The title of the step. + * @param {() => void} props.onContinue Callback called once continue button is clicked. + * @param {() => void} [props.onSkip] Callback called once skip button is clicked. + * @param {boolean} [props.hasError=false] Whether there's an error to reset the completing state. + * @param {boolean} [props.isOnboardingFlow=false] Whether this component is used in onboarding flow. + * @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, + headerTitle, + onContinue, + onSkip = noop, + hasError = false, + isOnboardingFlow = false, + trackingContext, +} ) { + const formContext = useAdaptiveFormContext(); + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const { isValidForm, setValue } = formContext; + const [ completing, setCompleting ] = useState( null ); + const [ paidAds, setPaidAds ] = useState( {} ); + + useEffect( () => { + if ( hasError ) { + setCompleting( null ); + } + }, [ hasError ] ); + + const handleOnStatesReceived = ( paidAdsValues ) => { + setPaidAds( paidAdsValues ); + + const { amount } = paidAdsValues; + setValue( 'amount', amount ); + }; + + const handleSkipCreateAds = () => { + setCompleting( ACTION_SKIP ); + + onSkip( paidAds ); + }; + + const handleCompleteClick = ( event ) => { + setCompleting( event.target.dataset.action ); + + onContinue( paidAds ); + }; + + // 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 || ! isValidForm; + + let continueButtonProps = { + text: __( 'Continue', 'google-listings-and-ads' ), + }; + + if ( isOnboardingFlow ) { + continueButtonProps = { + 'data-action': ACTION_COMPLETE, + text: __( 'Complete setup', 'google-listings-and-ads' ), + eventName: 'gla_onboarding_complete_with_paid_ads_button_click', + eventProps: { + budget: paidAds.amount, + audiences: countryCodes?.join( ',' ), + }, + }; + } + + 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 && } + + + + + + { isOnboardingFlow && ( + + ) } + + + + + + + + ); +} diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/clientSession.js b/js/src/components/paid-ads/ads-campaign/clientSession.js similarity index 100% rename from js/src/setup-mc/setup-stepper/setup-paid-ads/clientSession.js rename to js/src/components/paid-ads/ads-campaign/clientSession.js diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/constants.js b/js/src/components/paid-ads/ads-campaign/constants.js similarity index 100% rename from js/src/setup-mc/setup-stepper/setup-paid-ads/constants.js rename to js/src/components/paid-ads/ads-campaign/constants.js 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 100% 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 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/setup-mc/setup-stepper/setup-paid-ads/paid-ads-setup-sections.js b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections.js similarity index 81% rename from js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-setup-sections.js rename to js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections.js index 7072eab023..c7a967ada1 100644 --- a/js/src/setup-mc/setup-stepper/setup-paid-ads/paid-ads-setup-sections.js +++ b/js/src/components/paid-ads/ads-campaign/paid-ads-setup-sections.js @@ -14,13 +14,20 @@ import SpinnerCard from '.~/components/spinner-card'; import Section from '.~/wcdl/section'; import validateCampaign from '.~/components/paid-ads/validateCampaign'; import clientSession from './clientSession'; +import CampaignPreviewCard from '.~/components/paid-ads/campaign-preview/campaign-preview-card'; import { GOOGLE_ADS_BILLING_STATUS } from '.~/constants'; +/** + * + * @typedef {import('.~/data/actions').Campaign} Campaign + */ + /** * @typedef {import('.~/data/actions').CountryCode} CountryCode */ /** + * * @typedef {Object} PaidAdsData * @property {number|undefined} amount Daily average cost of the paid ads campaign. * @property {boolean} isValid Whether the campaign data are valid values. @@ -54,11 +61,18 @@ function resolveInitialPaidAds( paidAds ) { * @param {Object} props React props. * @param {(onStatesReceived: PaidAdsData)=>void} props.onStatesReceived Callback to receive the data for setting up paid ads when initial and also when the budget and billing are updated. * @param {Array|undefined} props.countryCodes Country codes for the campaign. + * @param {Campaign} [props.campaign] Campaign data to be edited. If not provided, this component will show campaign creation UI. + * @param {boolean} [props.showCampaignPreviewCard=false] Whether to show the campaign preview card. + * @param {boolean} [props.loadCampaignFromClientSession=false] Whether to load the campaign data from the client session. */ export default function PaidAdsSetupSections( { onStatesReceived, countryCodes, + campaign, + loadCampaignFromClientSession, + showCampaignPreviewCard = false, } ) { + const isCreation = ! campaign; const { billingStatus } = useGoogleAdsAccountBillingStatus(); const onStatesReceivedRef = useRef(); @@ -66,10 +80,16 @@ export default function PaidAdsSetupSections( { const [ paidAds, setPaidAds ] = useState( () => { // Resolve the starting paid ads data with the campaign data stored in the client session. - const startingPaidAds = { + let startingPaidAds = { ...defaultPaidAds, - ...clientSession.getCampaign(), }; + + if ( loadCampaignFromClientSession ) { + startingPaidAds = { + ...startingPaidAds, + ...clientSession.getCampaign(), + }; + } return resolveInitialPaidAds( startingPaidAds ); } ); @@ -108,7 +128,7 @@ export default function PaidAdsSetupSections( { } const initialValues = { - amount: paidAds.amount, + amount: isCreation ? paidAds.amount : campaign.amount, }; return ( @@ -126,6 +146,7 @@ export default function PaidAdsSetupSections( { countryCodes={ countryCodes } > + { showCampaignPreviewCard && } ); } } diff --git a/js/src/components/paid-ads/ads-campaign/skip-button.js b/js/src/components/paid-ads/ads-campaign/skip-button.js new file mode 100644 index 0000000000..85a6048d23 --- /dev/null +++ b/js/src/components/paid-ads/ads-campaign/skip-button.js @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +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'; +import { ACTION_COMPLETE, ACTION_SKIP } from './constants'; + +/** + * 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( { + text, + paidAds, + onSkipCreatePaidAds = noop, + completing, +} ) { + const [ + showSkipPaidAdsConfirmationModal, + setShowSkipPaidAdsConfirmationModal, + ] = useState( false ); + const { googleAdsAccount, hasGoogleAdsConnection } = 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: paidAds?.isValid ? 'valid' : 'invalid', + }; + recordGlaEvent( 'gla_onboarding_complete_button_click', eventProps ); + + onSkipCreatePaidAds(); + }; + + const disabledSkip = + completing === ACTION_COMPLETE || ! hasGoogleAdsConnection; + + return ( + <> + + + { showSkipPaidAdsConfirmationModal && ( + + ) } + + ); +} diff --git a/js/src/setup-mc/setup-stepper/setup-paid-ads/skip-paid-ads-confirmation-modal.js b/js/src/components/paid-ads/ads-campaign/skip-paid-ads-confirmation-modal.js similarity index 100% rename from js/src/setup-mc/setup-stepper/setup-paid-ads/skip-paid-ads-confirmation-modal.js rename to js/src/components/paid-ads/ads-campaign/skip-paid-ads-confirmation-modal.js 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/billing-card/billing-card.js b/js/src/components/paid-ads/billing-card/billing-card.js index 78f84d066b..f05ec0ca7f 100644 --- a/js/src/components/paid-ads/billing-card/billing-card.js +++ b/js/src/components/paid-ads/billing-card/billing-card.js @@ -2,6 +2,7 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; +import { useState, useEffect } from '@wordpress/element'; import Gridiconcheckmark from 'gridicons/dist/checkmark'; import { Flex, FlexBlock } from '@wordpress/components'; @@ -15,21 +16,33 @@ import fallbackBillingUrl from './fallbackBillingUrl'; import { GOOGLE_ADS_BILLING_STATUS } from '.~/constants'; import './billing-card.scss'; -const { APPROVED } = GOOGLE_ADS_BILLING_STATUS; +const { APPROVED, PENDING } = GOOGLE_ADS_BILLING_STATUS; /** * Renders a success notice card or a setup card according to the billing status * of the connected Google Ads account. */ export default function BillingCard() { + const [ showNotice, setShowNotice ] = useState( false ); const { billingStatus, hasFinishedResolution } = useGoogleAdsAccountBillingStatus(); + useEffect( () => { + // only show notice if we are in pending state before + if ( billingStatus.status === PENDING && ! showNotice ) { + setShowNotice( true ); + } + }, [ billingStatus, showNotice ] ); + if ( ! hasFinishedResolution ) { return ; } if ( billingStatus.status === APPROVED ) { + if ( ! showNotice ) { + return null; + } + return ( diff --git a/js/src/pages/create-paid-ads-campaign/index.js b/js/src/pages/create-paid-ads-campaign/index.js index e076243e6a..534c75bcb7 100644 --- a/js/src/pages/create-paid-ads-campaign/index.js +++ b/js/src/pages/create-paid-ads-campaign/index.js @@ -145,6 +145,10 @@ const CreatePaidAdsCampaign = () => { ), content: ( handleContinueClick( STEP.ASSET_GROUP ) diff --git a/js/src/pages/edit-paid-ads-campaign/index.js b/js/src/pages/edit-paid-ads-campaign/index.js index 1d23f45666..676d1d39c7 100644 --- a/js/src/pages/edit-paid-ads-campaign/index.js +++ b/js/src/pages/edit-paid-ads-campaign/index.js @@ -200,6 +200,10 @@ const EditPaidAdsCampaign = () => { onContinue={ () => handleContinueClick( STEP.ASSET_GROUP ) } + headerTitle={ __( + 'Edit your paid campaign', + 'google-listings-and-ads' + ) } /> ), onClick: handleStepperClick, diff --git a/js/src/setup-ads/ads-stepper/index.js b/js/src/setup-ads/ads-stepper/index.js index 3979e0dcba..60b5e95534 100644 --- a/js/src/setup-ads/ads-stepper/index.js +++ b/js/src/setup-ads/ads-stepper/index.js @@ -91,6 +91,10 @@ const AdsStepper = ( { formProps } ) => { ), content: ( 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..1fc492a495 --- /dev/null +++ b/js/src/setup-mc/setup-stepper/setup-paid-ads.js @@ -0,0 +1,89 @@ +/** + * 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 { getProductFeedUrl } from '.~/utils/urls'; +import { API_NAMESPACE } from '.~/data/constants'; +import { GUIDE_NAMES } from '.~/constants'; + +/** + * 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. + */ +export default function SetupPaidAds() { + const adminUrl = useAdminUrl(); + const [ hasError, setHasError ] = useState( false ); + const { createNotice } = useDispatchCoreNotices(); + const { data: countryCodes } = useTargetAudienceFinalCountryCodes(); + const [ handleSetupComplete ] = useAdsSetupCompleteCallback(); + + const finishOnboardingSetup = async ( onBeforeFinish = noop ) => { + try { + await onBeforeFinish(); + await apiFetch( { + path: `${ API_NAMESPACE }/mc/settings/sync`, + method: 'POST', + } ); + } catch ( e ) { + setHasError( true ); + + 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 () => { + await finishOnboardingSetup(); + }; + + const handleCompleteClick = async ( paidAdsData ) => { + const onBeforeFinish = handleSetupComplete.bind( + null, + paidAdsData.amount, + countryCodes + ); + + await finishOnboardingSetup( onBeforeFinish ); + }; + + return ( + + + + ); +} 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/setup-paid-ads.js b/js/src/setup-mc/setup-stepper/setup-paid-ads/setup-paid-ads.js deleted file mode 100644 index 76b6381fb9..0000000000 --- a/js/src/setup-mc/setup-stepper/setup-paid-ads/setup-paid-ads.js +++ /dev/null @@ -1,216 +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 } 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 useTargetAudienceFinalCountryCodes from '.~/hooks/useTargetAudienceFinalCountryCodes'; -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 SkipPaidAdsConfirmationModal from './skip-paid-ads-confirmation-modal'; -import { getProductFeedUrl } from '.~/utils/urls'; -import { API_NAMESPACE, STORE_KEY } from '.~/data/constants'; -import { GUIDE_NAMES } from '.~/constants'; -import { ACTION_COMPLETE, ACTION_SKIP } from './constants'; -import { recordGlaEvent } from '.~/utils/tracks'; - -/** - * 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 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' - */ - -/** - * 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 { data: countryCodes } = useTargetAudienceFinalCountryCodes(); - const { googleAdsAccount, hasGoogleAdsConnection } = useGoogleAdsAccount(); - const [ handleSetupComplete ] = useAdsSetupCompleteCallback(); - const [ paidAds, setPaidAds ] = useState( {} ); - const [ completing, setCompleting ] = useState( null ); - const [ - showSkipPaidAdsConfirmationModal, - setShowSkipPaidAdsConfirmationModal, - ] = useState( false ); - - 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, - countryCodes - ); - await finishOnboardingSetup( event, onBeforeFinish ); - }; - - const handleSkipCreatePaidAds = async ( event ) => { - const selector = select( STORE_KEY ); - const billing = selector.getGoogleAdsAccountBillingStatus(); - - setShowSkipPaidAdsConfirmationModal( false ); - - const eventProps = { - google_ads_account_status: googleAdsAccount?.status, - billing_method_status: billing?.status || 'unknown', - campaign_form_validation: paidAds.isValid ? 'valid' : 'invalid', - }; - - recordGlaEvent( 'gla_onboarding_complete_button_click', eventProps ); - - await finishOnboardingSetup( event ); - }; - - const handleShowSkipPaidAdsConfirmationModal = () => { - setShowSkipPaidAdsConfirmationModal( true ); - }; - - const handleCancelSkipPaidAdsClick = () => { - setShowSkipPaidAdsConfirmationModal( false ); - }; - - // 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 disabledSkip = - completing === ACTION_COMPLETE || ! hasGoogleAdsConnection; - - return ( - - ); - } - - return ( - - - - - - { showSkipPaidAdsConfirmationModal && ( - - ) } - - - - - { createSkipButton( - __( - 'Skip paid ads creation', - 'google-listings-and-ads' - ) - ) } - - - - - - - ); -} diff --git a/package-lock.json b/package-lock.json index 646f4851a0..7aa1b22f18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "google-listings-and-ads", - "version": "2.8.4", + "version": "2.8.5", "license": "GPL-3.0-or-later", "dependencies": { "@woocommerce/components": "^12.3.0", 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..297cd79d0d 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 @@ -11,8 +11,6 @@ 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, @@ -282,6 +280,9 @@ test.describe( 'Set up Ads account', () => { test.describe( 'Create your paid campaign', () => { test.beforeAll( async () => { setupBudgetPage = new SetupBudgetPage( page ); + await setupBudgetPage.fulfillBillingStatusRequest( { + status: 'pending', + } ); } ); test( 'Continue to create paid campaign', async () => { @@ -294,10 +295,6 @@ test.describe( 'Set up Ads account', () => { } ) ).toBeVisible(); - await expect( - page.getByRole( 'heading', { name: 'Ads audience' } ) - ).toBeVisible(); - await expect( page.getByRole( 'heading', { name: 'Set your budget' } ) ).toBeVisible(); @@ -355,17 +352,6 @@ test.describe( 'Set up Ads account', () => { } ); } ); - 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 ); @@ -376,10 +362,6 @@ test.describe( 'Set up Ads account', () => { budget = '1'; await setupBudgetPage.fillBudget( budget ); - - await expect( - page.getByRole( 'button', { name: 'Continue' } ) - ).toBeEnabled(); } ); test( 'Budget Recommendation', async () => { @@ -391,13 +373,7 @@ test.describe( 'Set up Ads account', () => { 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 ); await expect( @@ -409,99 +385,52 @@ test.describe( 'Set up Ads account', () => { 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.' + 'You do not have billing information set up in your Google Ads account. Once you have set up billing, you can start running ads.' ) ).toBeVisible(); - - await expect( - page.getByRole( 'link', { - name: 'click here instead', - } ) - ).toBeVisible(); } ); // 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 () => { + test( 'should see billing has been set up successfully when billing status API returns approved', async () => { + const newPagePromise = page.waitForEvent( 'popup' ); + await setupBudgetPage.clickSetUpBillingLink(); + const newPage = await newPagePromise; + await newPage.waitForLoadState(); + await setupBudgetPage.fulfillBillingStatusRequest( { - status: 'approved', + status: 'pending', } ); - await setupAdsAccounts.mockAdsAccountsResponse( { - id: ADS_ACCOUNTS[ 1 ], - billing_url: null, + await newPage.close(); + await setupBudgetPage.focusBudget(); + await setupBudgetPage.fulfillBillingStatusRequest( { + status: 'approved', } ); - - // 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' ] - ); - - await expect( - page.getByText( - 'Great! You already have billing information saved for this' - ) - ).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, - } + const billingSetupSuccessSection = + setupBudgetPage.getBillingSetupSuccessSection(); + await expect( billingSetupSuccessSection ).toContainText( + 'Billing method for Google Ads added successfully' ); } ); - - 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(); - - await expect( - page.getByRole( 'button', { - name: 'Create another campaign', - } ) - ).toBeEnabled(); - - await expect( - page.getByRole( 'button', { - name: 'Got It', - } ) - ).toBeEnabled(); - - await page - .getByRole( 'button', { - name: 'Got It', - } ) - .click(); - } ); } ); } ); test.describe( 'Create Ads with billing data already setup', () => { + test.beforeAll( async () => { + await setupBudgetPage.fulfillBillingStatusRequest( { + status: 'approved', + } ); + } ); + test( 'Launch paid campaign should be enabled', async () => { - //Click Add paid Campaign - await adsConnectionButton.click(); + //Reload the page + await page.reload(); await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); //Step 1 - Accounts are already set up. @@ -509,7 +438,14 @@ test.describe( 'Set up Ads account', () => { await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); //Step 2 - Fill the budget + await setupBudgetPage.fulfillBillingStatusRequest( { + status: 'approved', + } ); await setupBudgetPage.fillBudget( '1' ); + + await expect( + page.getByRole( 'button', { name: 'Continue' } ) + ).toBeEnabled(); await page.getByRole( 'button', { name: 'Continue' } ).click(); await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); @@ -534,5 +470,31 @@ test.describe( 'Set up Ads account', () => { .click(); await campaignCreation; } ); + + 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(); + + await expect( + page.getByRole( 'button', { + name: 'Create another campaign', + } ) + ).toBeEnabled(); + + await expect( + page.getByRole( 'button', { + name: 'Got It', + } ) + ).toBeEnabled(); + + await page + .getByRole( 'button', { + name: 'Got It', + } ) + .click(); + } ); } ); } ); 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 92c3fb3206..adc31ea614 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 @@ -247,12 +247,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(); diff --git a/tests/e2e/utils/page.js b/tests/e2e/utils/page.js index 54912b00f8..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. * diff --git a/tests/e2e/utils/pages/setup-ads/setup-budget.js b/tests/e2e/utils/pages/setup-ads/setup-budget.js index a94232393c..afb0875017 100644 --- a/tests/e2e/utils/pages/setup-ads/setup-budget.js +++ b/tests/e2e/utils/pages/setup-ads/setup-budget.js @@ -87,6 +87,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. *