diff --git a/js/src/components/enhanced-conversion-tracking-settings/accept-terms.js b/js/src/components/enhanced-conversion-tracking-settings/accept-terms.js new file mode 100644 index 0000000000..832274b944 --- /dev/null +++ b/js/src/components/enhanced-conversion-tracking-settings/accept-terms.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { noop } from 'lodash'; +import { useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import { useAppDispatch } from '.~/data'; +import AppButton from '.~/components/app-button'; +import useGoogleAdsEnhancedConversionTermsURL from '.~/hooks/useGoogleAdsTermsURL'; + +const AcceptTerms = ( { + acceptTermsLabel = __( + 'Accept Terms & Conditions', + 'google-listings-and-ads' + ), + onAcceptTerms = noop, +} ) => { + const { url } = useGoogleAdsEnhancedConversionTermsURL(); + const { updateEnhancedAdsConversionStatus } = useAppDispatch(); + + const handleAcceptTerms = useCallback( + ( event ) => { + event.preventDefault(); + + window.open( url, '_blank' ); + updateEnhancedAdsConversionStatus( + ENHANCED_ADS_CONVERSION_STATUS.PENDING + ); + onAcceptTerms(); + }, + [ updateEnhancedAdsConversionStatus, url, onAcceptTerms ] + ); + + return ( + + { acceptTermsLabel } + + ); +}; + +export default AcceptTerms; diff --git a/js/src/components/enhanced-conversion-tracking-settings/cta.js b/js/src/components/enhanced-conversion-tracking-settings/cta.js new file mode 100644 index 0000000000..dc2ff94adf --- /dev/null +++ b/js/src/components/enhanced-conversion-tracking-settings/cta.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; +import { __ } from '@wordpress/i18n'; +import { useCallback, useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import { useAppDispatch } from '.~/data'; +import AppButton from '.~/components/app-button'; +import AcceptTerms from './accept-terms'; +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; +import useAllowEnhancedConversions from '.~/hooks/useAllowEnhancedConversions'; +import useTermsPolling from './useTermsPolling'; + +const CTA = ( { + disableLabel = __( 'Disable', 'google-listings-and-ads' ), + enableLabel = __( 'Enable', 'google-listings-and-ads' ), + onEnableClick = noop, + onDisableClick = noop, +} ) => { + const [ startBackgroundPoll, setStartBackgroundPoll ] = useState( false ); + const { updateEnhancedAdsConversionStatus } = useAppDispatch(); + const { acceptedCustomerDataTerms } = useAcceptedCustomerDataTerms(); + const { allowEnhancedConversions } = useAllowEnhancedConversions(); + useTermsPolling( startBackgroundPoll ); + + const handleDisable = useCallback( () => { + if ( ! acceptedCustomerDataTerms ) { + return; + } + + updateEnhancedAdsConversionStatus( + ENHANCED_ADS_CONVERSION_STATUS.DISABLED + ); + + onDisableClick(); + }, [ + updateEnhancedAdsConversionStatus, + acceptedCustomerDataTerms, + onDisableClick, + ] ); + + // Turn off polling when the user has accepted the terms. + useEffect( () => { + if ( acceptedCustomerDataTerms && startBackgroundPoll ) { + setStartBackgroundPoll( false ); + } + }, [ acceptedCustomerDataTerms, startBackgroundPoll ] ); + + const handleEnable = useCallback( () => { + if ( ! acceptedCustomerDataTerms ) { + return; + } + + updateEnhancedAdsConversionStatus( + ENHANCED_ADS_CONVERSION_STATUS.ENABLED + ); + + onEnableClick(); + }, [ + updateEnhancedAdsConversionStatus, + acceptedCustomerDataTerms, + onEnableClick, + ] ); + + const handleOnAcceptTerms = () => { + setStartBackgroundPoll( true ); + }; + + if ( startBackgroundPoll ) { + return ; + } + + if ( ! acceptedCustomerDataTerms ) { + return ; + } + + if ( allowEnhancedConversions === ENHANCED_ADS_CONVERSION_STATUS.ENABLED ) { + return ( + + { disableLabel } + + ); + } + + // User has accepted TOS or tracking is disabled. + return ( + + { enableLabel } + + ); +}; + +export default CTA; diff --git a/js/src/components/enhanced-conversion-tracking-settings/cta.test.js b/js/src/components/enhanced-conversion-tracking-settings/cta.test.js new file mode 100644 index 0000000000..614a4002a5 --- /dev/null +++ b/js/src/components/enhanced-conversion-tracking-settings/cta.test.js @@ -0,0 +1,221 @@ +jest.mock( '@woocommerce/components', () => ( { + ...jest.requireActual( '@woocommerce/components' ), + Spinner: jest + .fn( () =>
) + .mockName( 'Spinner' ), +} ) ); + +jest.mock( '.~/hooks/useAcceptedCustomerDataTerms', () => ( { + __esModule: true, + default: jest.fn().mockName( 'useAcceptedCustomerDataTerms' ), +} ) ); + +jest.mock( '.~/hooks/useAllowEnhancedConversions', () => ( { + __esModule: true, + default: jest.fn().mockName( 'useAllowEnhancedConversions' ), +} ) ); + +jest.mock( '.~/hooks/useAutoCheckEnhancedConversionTOS', () => ( { + __esModule: true, + default: jest + .fn() + .mockName( 'useAutoCheckEnhancedConversionTOS' ) + .mockImplementation( () => { + return { + startEnhancedConversionTOSPolling: jest.fn(), + stopEnhancedConversionTOSPolling: jest.fn(), + }; + } ), +} ) ); + +jest.mock( '.~/data/actions', () => ( { + ...jest.requireActual( '.~/data/actions' ), + updateEnhancedAdsConversionStatus: jest + .fn() + .mockName( 'updateEnhancedAdsConversionStatus' ) + .mockImplementation( () => { + return { type: 'test', response: 'enabled' }; + } ), +} ) ); + +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; + +/** + * Internal dependencies + */ +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; +import useAllowEnhancedConversions from '.~/hooks/useAllowEnhancedConversions'; +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import CTA from './cta'; + +describe( 'Enhanced Conversion CTA', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + test( 'Prompt the user to sign the TOS', () => { + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: false, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: null, + } ); + + render( ); + expect( + screen.getByText( 'Accept Terms & Conditions' ) + ).toBeInTheDocument(); + } ); + + test( 'Prompt the user to enable enhanced conversion tracking if the TOS has been accepted', () => { + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: true, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: ENHANCED_ADS_CONVERSION_STATUS.DISABLED, + } ); + + render( ); + expect( screen.getByText( 'Confirm' ) ).toBeInTheDocument(); + } ); + + test( 'Prompt the user to disable enhanced conversion tracking if enabled', () => { + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: true, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: ENHANCED_ADS_CONVERSION_STATUS.ENABLED, + } ); + + render( ); + expect( screen.getByText( 'Disable tracking' ) ).toBeInTheDocument(); + } ); + + test( 'Click on accept TOS button callback', () => { + window.open = jest.fn(); + + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: false, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: null, + } ); + + render( ); + + const button = screen.getByRole( 'button' ); + userEvent.click( button ); + + expect( window.open ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'Click on enable/confirm button callback', () => { + const handleOnEnable = jest.fn().mockName( 'On Enable click' ); + + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: true, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: null, + } ); + + render( ); + + const button = screen.getByRole( 'button' ); + userEvent.click( button ); + + expect( handleOnEnable ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'Confirm/enable button callback should not be called if TOS has not been accepted', () => { + const handleOnEnable = jest.fn().mockName( 'On Enable click' ); + + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: false, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: ENHANCED_ADS_CONVERSION_STATUS.ENABLED, + } ); + + render( ); + + const button = screen.getByRole( 'button' ); + userEvent.click( button ); + + expect( handleOnEnable ).not.toHaveBeenCalled(); + } ); + + test( 'Click on disable button callback', () => { + const handleOnDisable = jest.fn().mockName( 'On Disable click' ); + + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: true, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: ENHANCED_ADS_CONVERSION_STATUS.ENABLED, + } ); + + render( ); + + const button = screen.getByRole( 'button' ); + userEvent.click( button ); + + expect( handleOnDisable ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'Disable button callback should not be called if TOS has not been accepted', () => { + const handleOnDisable = jest.fn().mockName( 'On Disable click' ); + + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: false, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: ENHANCED_ADS_CONVERSION_STATUS.ENABLED, + } ); + + render( ); + + const button = screen.getByRole( 'button' ); + userEvent.click( button ); + + expect( handleOnDisable ).not.toHaveBeenCalled(); + } ); + + test( 'Should render the enable button if TOS has been accepted and the status is not enabled', () => { + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: true, + hasFinishedResolution: true, + } ); + + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: null, + } ); + + render( ); + + expect( + screen.getByRole( 'button', { name: 'Enable' } ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/js/src/components/enhanced-conversion-tracking-settings/index.js b/js/src/components/enhanced-conversion-tracking-settings/index.js new file mode 100644 index 0000000000..f9f95bcfc6 --- /dev/null +++ b/js/src/components/enhanced-conversion-tracking-settings/index.js @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; +import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; +import Section from '.~/wcdl/section'; +import PendingNotice from '.~/components/enhanced-conversion-tracking-settings/pending-notice'; +import useAllowEnhancedConversions from '.~/hooks/useAllowEnhancedConversions'; +import Toggle from './toggle'; +import AcceptTerms from './accept-terms'; + +const DESCRIPTION = ( +

+ { __( + 'Improve your conversion tracking accuracy and unlock more powerful bidding. This feature works alongside your existing conversion tags, sending secure, privacy-friendly conversion data from your website to Google.', + 'google-listings-and-ads' + ) } +

+); + +const TITLE = __( 'Enhanced Conversion Tracking', 'google-listings-and-ads' ); + +/** + * Renders the settings panel for enhanced conversion tracking + */ +const EnhancedConversionTrackingSettings = () => { + const { googleAdsAccount } = useGoogleAdsAccount(); + const { acceptedCustomerDataTerms } = useAcceptedCustomerDataTerms(); + const { allowEnhancedConversions } = useAllowEnhancedConversions(); + + // @todo: Remove condition once R1 PRs are merged since there should always be a connected Ads account. + if ( ! googleAdsAccount || ! googleAdsAccount.id ) { + return null; + } + + const getCardBody = () => { + if ( + ! acceptedCustomerDataTerms && + allowEnhancedConversions === ENHANCED_ADS_CONVERSION_STATUS.PENDING + ) { + return ; + } + + if ( ! acceptedCustomerDataTerms ) { + return ; + } + + return ; + }; + + return ( +
+ + { getCardBody() } + +
+ ); +}; + +export default EnhancedConversionTrackingSettings; diff --git a/js/src/components/enhanced-conversion-tracking-settings/pending-notice.js b/js/src/components/enhanced-conversion-tracking-settings/pending-notice.js new file mode 100644 index 0000000000..1fb49e8b8c --- /dev/null +++ b/js/src/components/enhanced-conversion-tracking-settings/pending-notice.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createInterpolateElement, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import { useAppDispatch } from '.~/data'; +import TrackableLink from '.~/components/trackable-link'; +import useTermsPolling from './useTermsPolling'; +import useGoogleAdsEnhancedConversionTermsURL from '.~/hooks/useGoogleAdsTermsURL'; + +const PendingNotice = () => { + const { updateEnhancedAdsConversionStatus } = useAppDispatch(); + const { url } = useGoogleAdsEnhancedConversionTermsURL(); + useTermsPolling(); + + const handleOnClick = useCallback( + ( event ) => { + event.preventDefault(); + + window.open( url, '_blank' ); + updateEnhancedAdsConversionStatus( + ENHANCED_ADS_CONVERSION_STATUS.PENDING + ); + }, + [ updateEnhancedAdsConversionStatus, url ] + ); + + return ( +

+ { createInterpolateElement( + __( + 'Enhanced Conversion Tracking will be enabled once you’ve agreed to the terms of service on Google Ads, which can be found in your Google Ads settings screen.', + 'google-listings-and-ads' + ), + { + link: ( + + ), + } + ) } +

+ ); +}; + +export default PendingNotice; diff --git a/js/src/components/enhanced-conversion-tracking-settings/toggle.js b/js/src/components/enhanced-conversion-tracking-settings/toggle.js new file mode 100644 index 0000000000..dd2d6e38a4 --- /dev/null +++ b/js/src/components/enhanced-conversion-tracking-settings/toggle.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useCallback, useEffect } from '@wordpress/element'; +import { Spinner } from '@woocommerce/components'; + +/** + * Internal dependencies + */ +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import { useAppDispatch } from '.~/data'; +import AppStandaloneToggleControl from '.~/components/app-standalone-toggle-control'; +import useAllowEnhancedConversions from '.~/hooks/useAllowEnhancedConversions'; +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; + +const TOGGLE_LABEL_MAP = { + [ ENHANCED_ADS_CONVERSION_STATUS.DISABLED ]: __( + 'Enable', + 'google-listings-and-ads' + ), + [ ENHANCED_ADS_CONVERSION_STATUS.ENABLED ]: __( + 'Disable', + 'google-listings-and-ads' + ), +}; + +const Toggle = () => { + const { updateEnhancedAdsConversionStatus, invalidateResolution } = + useAppDispatch(); + const { allowEnhancedConversions, hasFinishedResolution } = + useAllowEnhancedConversions(); + const { + acceptedCustomerDataTerms, + hasFinishedResolution: hasResolvedAcceptedCustomerDataTerms, + } = useAcceptedCustomerDataTerms(); + + useEffect( () => { + if ( + allowEnhancedConversions === ENHANCED_ADS_CONVERSION_STATUS.PENDING + ) { + invalidateResolution( 'getAcceptedCustomerDataTerms', [] ); + } + }, [ allowEnhancedConversions, invalidateResolution ] ); + + const handleOnChange = useCallback( + ( value ) => { + if ( ! acceptedCustomerDataTerms ) { + return; + } + + updateEnhancedAdsConversionStatus( + value + ? ENHANCED_ADS_CONVERSION_STATUS.ENABLED + : ENHANCED_ADS_CONVERSION_STATUS.DISABLED + ); + }, + [ updateEnhancedAdsConversionStatus, acceptedCustomerDataTerms ] + ); + + if ( ! hasFinishedResolution ) { + return ; + } + + return ( + + ); +}; + +export default Toggle; diff --git a/js/src/components/enhanced-conversion-tracking-settings/useTermsPolling.js b/js/src/components/enhanced-conversion-tracking-settings/useTermsPolling.js new file mode 100644 index 0000000000..268154ea26 --- /dev/null +++ b/js/src/components/enhanced-conversion-tracking-settings/useTermsPolling.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import { useAppDispatch } from '.~/data'; +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; +import useAllowEnhancedConversions from '.~/hooks/useAllowEnhancedConversions'; +import useAutoCheckEnhancedConversionTOS from '.~/hooks/useAutoCheckEnhancedConversionTOS'; + +const useTermsPolling = ( startBackgroundPoll = false ) => { + const { invalidateResolution } = useAppDispatch(); + const { + startEnhancedConversionTOSPolling, + stopEnhancedConversionTOSPolling, + } = useAutoCheckEnhancedConversionTOS(); + const { allowEnhancedConversions } = useAllowEnhancedConversions(); + const { acceptedCustomerDataTerms } = useAcceptedCustomerDataTerms(); + + useEffect( () => { + if ( + ! acceptedCustomerDataTerms && + allowEnhancedConversions === + ENHANCED_ADS_CONVERSION_STATUS.PENDING && + startBackgroundPoll + ) { + startEnhancedConversionTOSPolling(); + return; + } + + stopEnhancedConversionTOSPolling(); + }, [ + acceptedCustomerDataTerms, + allowEnhancedConversions, + startEnhancedConversionTOSPolling, + stopEnhancedConversionTOSPolling, + startBackgroundPoll, + ] ); + + useEffect( () => { + if ( + allowEnhancedConversions === ENHANCED_ADS_CONVERSION_STATUS.PENDING + ) { + invalidateResolution( 'getAcceptedCustomerDataTerms', [] ); + } + }, [ allowEnhancedConversions, invalidateResolution ] ); +}; + +export default useTermsPolling; diff --git a/js/src/constants.js b/js/src/constants.js index 0cce0f2c3d..46aa330a12 100644 --- a/js/src/constants.js +++ b/js/src/constants.js @@ -111,3 +111,9 @@ export const ASSET_FORM_KEY = { ...ASSET_KEY, ...ASSET_GROUP_KEY, }; + +export const ENHANCED_ADS_CONVERSION_STATUS = { + PENDING: 'pending', + ENABLED: 'enabled', + DISABLED: 'disabled', +}; diff --git a/js/src/data/action-types.js b/js/src/data/action-types.js index 86556c5fda..408b8d91ef 100644 --- a/js/src/data/action-types.js +++ b/js/src/data/action-types.js @@ -49,6 +49,9 @@ const TYPES = { RECEIVE_TOUR: 'RECEIVE_TOUR', UPSERT_TOUR: 'UPSERT_TOUR', HYDRATE_PREFETCHED_DATA: 'HYDRATE_PREFETCHED_DATA', + RECEIVE_ACCEPTED_CUSTOMER_DATA_TERMS: + 'RECEIVE_ACCEPTED_CUSTOMER_DATA_TERMS', + RECEIVE_ALLOW_ENHANCED_CONVERSIONS: 'RECEIVE_ALLOW_ENHANCED_CONVERSIONS', }; export default TYPES; diff --git a/js/src/data/actions.js b/js/src/data/actions.js index 8aba535605..3c23d168e7 100644 --- a/js/src/data/actions.js +++ b/js/src/data/actions.js @@ -1247,3 +1247,63 @@ export function* upsertTour( tour, upsertingClientStoreFirst = false ) { ); } } + +export function receiveAcceptedTerms( data ) { + return { + type: TYPES.RECEIVE_ACCEPTED_CUSTOMER_DATA_TERMS, + data, + }; +} + +/** + * Updates the enhanced ads conversion status. + * + * @param {string} status to enable/disable enhanced ads conversion. Possible values are pending, enabled and disabled. + * @return {boolean} String The updated status. + */ +export function* updateEnhancedAdsConversionStatus( status ) { + try { + const response = yield apiFetch( { + path: `${ API_NAMESPACE }/ads/enhanced-conversion-status`, + method: REQUEST_ACTIONS.POST, + data: { + status, + }, + } ); + + return receiveAllowEnhancedConversions( response ); + } catch ( error ) { + handleApiError( + error, + __( + 'There was an error updating the enhanced ads conversion status.', + 'google-listings-and-ads' + ) + ); + } +} + +export function receiveAllowEnhancedConversions( data ) { + return { + type: TYPES.RECEIVE_ALLOW_ENHANCED_CONVERSIONS, + data, + }; +} + +export function* fetchAcceptedCustomerDataTerms() { + try { + const data = yield apiFetch( { + path: `${ API_NAMESPACE }/ads/accepted-customer-data-terms`, + } ); + + return receiveAcceptedTerms( data ); + } catch ( error ) { + handleApiError( + error, + __( + 'Unable to complete request. Please try again later.', + 'google-listings-and-ads' + ) + ); + } +} diff --git a/js/src/data/reducer.js b/js/src/data/reducer.js index daf7b223e2..3660047c9e 100644 --- a/js/src/data/reducer.js +++ b/js/src/data/reducer.js @@ -64,6 +64,12 @@ const DEFAULT_STATE = { report: {}, store_categories: [], tours: {}, + ads: { + conversion_tracking_setting: { + accepted_customer_data_terms: null, + allow_enhanced_conversions: null, + }, + }, }; /** @@ -491,6 +497,34 @@ const reducer = ( state = DEFAULT_STATE, action ) => { return stateSetter.end(); } + case TYPES.RECEIVE_ACCEPTED_CUSTOMER_DATA_TERMS: { + const { + data: { status }, + } = action; + + return setIn( + state, + 'ads.conversion_tracking_setting.accepted_customer_data_terms', + status + ); + } + + case TYPES.RECEIVE_ALLOW_ENHANCED_CONVERSIONS: { + const { + data: { status }, + } = action; + + if ( status === null || status === undefined ) { + return state; + } + + return setIn( + state, + 'ads.conversion_tracking_setting.allow_enhanced_conversions', + status + ); + } + // 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 37f4a564d5..dae5577a60 100644 --- a/js/src/data/resolvers.js +++ b/js/src/data/resolvers.js @@ -34,6 +34,7 @@ import { receiveGoogleMCContactInformation, fetchTargetAudience, fetchMCSetup, + fetchAcceptedCustomerDataTerms, receiveGoogleAccountAccess, receiveReport, receiveMCProductStatistics, @@ -45,6 +46,7 @@ import { receiveMappingRules, receiveStoreCategories, receiveTour, + receiveAllowEnhancedConversions, } from './actions'; export function* getShippingRates() { @@ -519,3 +521,41 @@ export function* getTour( tourId ) { ); } } + +/** + * Resolver for getting the accepted customer data terms. + */ +export function* getAcceptedCustomerDataTerms() { + try { + yield fetchAcceptedCustomerDataTerms(); + } catch ( error ) { + handleApiError( + error, + __( + 'There was an error getting the accepted customer data terms.', + 'google-listings-and-ads' + ) + ); + } +} + +/** + * Resolver for getting the enhanced conversion status. + */ +export function* getAllowEnhancedConversions() { + try { + const response = yield apiFetch( { + path: `${ API_NAMESPACE }/ads/enhanced-conversion-status`, + } ); + + yield receiveAllowEnhancedConversions( response ); + } catch ( error ) { + handleApiError( + error, + __( + 'There was an error getting the enhance conversions status.', + 'google-listings-and-ads' + ) + ); + } +} diff --git a/js/src/data/selectors.js b/js/src/data/selectors.js index 181fb6c427..ed17eb5d72 100644 --- a/js/src/data/selectors.js +++ b/js/src/data/selectors.js @@ -400,3 +400,23 @@ export const getStoreCategories = ( state ) => { export const getTour = ( state, tourId ) => { return state.tours[ tourId ] || null; }; + +/** + * Return the customer accepted data terms. + * + * @param {Object} state The state + * @return {boolean|null} TRUE if the user signed the TOS. It will be `null` if not yet fetched or fetched but doesn't exist. + */ +export const getAcceptedCustomerDataTerms = ( state ) => { + return state.ads.conversion_tracking_setting.accepted_customer_data_terms; +}; + +/** + * Return whether the user allowed enhanced conversion tracking. + * + * @param {Object} state The state + * @return {string|null} Possible values are 'pending' | 'enabled' | 'disabled'. It will be `null` if not yet fetched or fetched but doesn't exist. + */ +export const getAllowEnhancedConversions = ( state ) => { + return state.ads.conversion_tracking_setting.allow_enhanced_conversions; +}; diff --git a/js/src/data/test/reducer.test.js b/js/src/data/test/reducer.test.js index 37eb44ba8a..aad87910cd 100644 --- a/js/src/data/test/reducer.test.js +++ b/js/src/data/test/reducer.test.js @@ -67,6 +67,12 @@ describe( 'reducer', () => { report: {}, store_categories: [], tours: {}, + ads: { + conversion_tracking_setting: { + accepted_customer_data_terms: null, + allow_enhanced_conversions: null, + }, + }, } ); prepareState = prepareImmutableStateWithRefCheck.bind( diff --git a/js/src/external-components/wordpress/guide/index.js b/js/src/external-components/wordpress/guide/index.js index de07985dfd..673a313eb9 100644 --- a/js/src/external-components/wordpress/guide/index.js +++ b/js/src/external-components/wordpress/guide/index.js @@ -20,12 +20,6 @@ import PageControl from './page-control'; import FinishButton from './finish-button'; import './index.scss'; -/** - * @callback renderFinishCallback - * @param {JSX.Element} finishButton The built-in finish button of this Guide component. - * @return {JSX.Element} React element for rendering. - */ - /** * `Guide` is a React component that renders a user guide in a modal. * The guide consists of several pages which the user can step through one by one. @@ -37,7 +31,6 @@ import './index.scss'; * It is required for accessibility reasons. * @param {string} [props.backButtonText] Use this to customize the label of the *Previous* button shown at the end of the guide. * @param {string} [props.finishButtonText] Use this to customize the label of the *Finish* button shown at the end of the guide. - * @param {renderFinishCallback} [props.renderFinish] A function for rendering custom finish block shown at the end of the guide. * @param {Function} props.onFinish A function which is called when the guide is finished. * The guide is finished when the modal is closed * or when the user clicks *Finish* on the last page of the guide. @@ -49,7 +42,6 @@ export default function Guide( { contentLabel, backButtonText, finishButtonText, - renderFinish = ( finishButton ) => finishButton, onFinish, pages, } ) { @@ -76,8 +68,8 @@ export default function Guide( { let finishBlock = null; - if ( ! canGoForward ) { - const finishButton = ( + if ( ! canGoForward && ! pages[ currentPage ].actions ) { + finishBlock = ( ); - - finishBlock = renderFinish( finishButton ); } const guideClassName = classnames( @@ -135,6 +125,7 @@ export default function Guide( { { backButtonText || __( 'Previous' ) } ) } + { canGoForward && (
diff --git a/js/src/hooks/useAcceptedCustomerDataTerms.js b/js/src/hooks/useAcceptedCustomerDataTerms.js new file mode 100644 index 0000000000..03cda88e56 --- /dev/null +++ b/js/src/hooks/useAcceptedCustomerDataTerms.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from '.~/data/constants'; +import useGoogleAdsAccount from './useGoogleAdsAccount'; + +const selectorName = 'getAcceptedCustomerDataTerms'; + +const useAcceptedCustomerDataTerms = () => { + const { googleAdsAccount, hasFinishedResolution } = useGoogleAdsAccount(); + + return useSelect( + ( select ) => { + if ( ! googleAdsAccount || ! googleAdsAccount.id ) { + return { + acceptedCustomerDataTerms: null, + hasFinishedResolution, + }; + } + + const selector = select( STORE_KEY ); + + return { + acceptedCustomerDataTerms: selector[ selectorName ](), + hasFinishedResolution: selector.hasFinishedResolution( + selectorName, + [] + ), + }; + }, + [ googleAdsAccount, hasFinishedResolution ] + ); +}; + +export default useAcceptedCustomerDataTerms; diff --git a/js/src/hooks/useAcceptedCustomerDataTerms.test.js b/js/src/hooks/useAcceptedCustomerDataTerms.test.js new file mode 100644 index 0000000000..d4d2928440 --- /dev/null +++ b/js/src/hooks/useAcceptedCustomerDataTerms.test.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-hooks'; + +/** + * Internal dependencies + */ +import { useAppDispatch } from '.~/data'; +import useAcceptedCustomerDataTerms from './useAcceptedCustomerDataTerms'; +import useGoogleAccount from '.~/hooks/useGoogleAccount'; +import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; + +jest.mock( '.~/hooks/useGoogleAdsAccount', () => + jest.fn().mockName( 'useGoogleAdsAccount' ).mockReturnValue( {} ) +); + +jest.mock( '.~/hooks/useGoogleAccount', () => + jest.fn().mockName( 'useGoogleAccount' ).mockReturnValue( {} ) +); + +const CONNECTED_GOOGLE_ADS_ACCOUNT = { + id: 777777, + currency: 'PLN', + symbol: 'zł', + status: 'connected', +}; + +describe( 'useAcceptedCustomerDataTerms', () => { + it( 'Returns the correct status when set to true', () => { + useGoogleAccount.mockReturnValue( { + hasFinishedResolution: true, + isResolving: false, + scope: { + adsRequired: true, + }, + google: true, + } ); + + useGoogleAdsAccount.mockReturnValue( { + googleAdsAccount: CONNECTED_GOOGLE_ADS_ACCOUNT, + hasFinishedResolution: true, + } ); + + const { result } = renderHook( () => { + const { receiveAcceptedTerms } = useAppDispatch(); + + receiveAcceptedTerms( { status: true } ); + return useAcceptedCustomerDataTerms(); + } ); + + expect( result.current.acceptedCustomerDataTerms ).toBe( true ); + } ); +} ); diff --git a/js/src/hooks/useAdsCampaigns.js b/js/src/hooks/useAdsCampaigns.js index 5975aec450..17c1e8f28f 100644 --- a/js/src/hooks/useAdsCampaigns.js +++ b/js/src/hooks/useAdsCampaigns.js @@ -7,7 +7,7 @@ import { useSelect } from '@wordpress/data'; * Internal dependencies */ import { STORE_KEY } from '.~/data'; -import { glaData } from '.~/constants'; +import { glaData, CAMPAIGN_TYPE_PMAX } from '.~/constants'; import useIsEqualRefValue from '.~/hooks/useIsEqualRefValue'; const selectorName = 'getAdsCampaigns'; @@ -19,6 +19,7 @@ const selectorName = 'getAdsCampaigns'; * @property {Array|null} data Current campaigns obtained from merchant's Google Ads account if connected. It will be `null` before load finished. * @property {boolean} loading Whether the `data` is loading. It's equal to `isResolving` state of wp-data selector. * @property {boolean} loaded Whether the `data` is finished loading. It's equal to `hasFinishedResolution` state of wp-data selector. + * @property {Array|null} pmaxCampaigns PMAX campaigns filtered from the data. */ /** @@ -44,6 +45,7 @@ const useAdsCampaigns = ( ...query ) => { loading: false, loaded: true, data: [], + pmaxCampaigns: [], }; } @@ -56,10 +58,19 @@ const useAdsCampaigns = ( ...query ) => { queryRefValue ); + let pmaxCampaigns = []; + + if ( loaded && data.length ) { + pmaxCampaigns = data.filter( + ( { type } ) => type === CAMPAIGN_TYPE_PMAX + ); + } + return { loading, loaded, data, + pmaxCampaigns, }; }, [ queryRefValue ] diff --git a/js/src/hooks/useAllowEnhancedConversions.js b/js/src/hooks/useAllowEnhancedConversions.js new file mode 100644 index 0000000000..c200541076 --- /dev/null +++ b/js/src/hooks/useAllowEnhancedConversions.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from '.~/data/constants'; + +const selectorName = 'getAllowEnhancedConversions'; + +const useAllowEnhancedConversions = () => { + return useSelect( ( select ) => { + const selector = select( STORE_KEY ); + + return { + allowEnhancedConversions: selector[ selectorName ](), + hasFinishedResolution: selector.hasFinishedResolution( + selectorName, + [] + ), + }; + }, [] ); +}; + +export default useAllowEnhancedConversions; diff --git a/js/src/hooks/useAllowEnhancedConversions.test.js b/js/src/hooks/useAllowEnhancedConversions.test.js new file mode 100644 index 0000000000..aae680ff7b --- /dev/null +++ b/js/src/hooks/useAllowEnhancedConversions.test.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-hooks'; + +/** + * Internal dependencies + */ +import { useAppDispatch } from '.~/data'; +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import useAllowEnhancedConversions from './useAllowEnhancedConversions'; + +describe( 'useAllowEnhancedConversions', () => { + it( 'Returns the correct status when enabled', () => { + const { result } = renderHook( () => { + const { receiveAllowEnhancedConversions } = useAppDispatch(); + receiveAllowEnhancedConversions( { + status: ENHANCED_ADS_CONVERSION_STATUS.ENABLED, + } ); + + return useAllowEnhancedConversions(); + } ); + + expect( result.current.allowEnhancedConversions ).toBe( + ENHANCED_ADS_CONVERSION_STATUS.ENABLED + ); + } ); + + it( 'Returns the correct status when disabled', () => { + const { result } = renderHook( () => { + const { receiveAllowEnhancedConversions } = useAppDispatch(); + receiveAllowEnhancedConversions( { + status: ENHANCED_ADS_CONVERSION_STATUS.DISABLED, + } ); + + return useAllowEnhancedConversions(); + } ); + + expect( result.current.allowEnhancedConversions ).toBe( + ENHANCED_ADS_CONVERSION_STATUS.DISABLED + ); + } ); +} ); diff --git a/js/src/hooks/useAutoCheckEnhancedConversionTOS.js b/js/src/hooks/useAutoCheckEnhancedConversionTOS.js new file mode 100644 index 0000000000..1a576165c9 --- /dev/null +++ b/js/src/hooks/useAutoCheckEnhancedConversionTOS.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { useCallback, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useAppDispatch } from '.~/data'; +import useWindowFocusCallbackIntervalEffect from '.~/hooks/useWindowFocusCallbackIntervalEffect'; + +const useAutoCheckEnhancedConversionTOS = () => { + const [ polling, setPolling ] = useState( false ); + const { fetchAcceptedCustomerDataTerms } = useAppDispatch(); + + const startEnhancedConversionTOSPolling = useCallback( () => { + setPolling( true ); + }, [] ); + + const stopEnhancedConversionTOSPolling = useCallback( () => { + setPolling( false ); + }, [] ); + + const checkStatus = useCallback( async () => { + if ( ! polling ) { + return; + } + + fetchAcceptedCustomerDataTerms(); + }, [ fetchAcceptedCustomerDataTerms, polling ] ); + + useWindowFocusCallbackIntervalEffect( checkStatus, 30 ); + + return { + startEnhancedConversionTOSPolling, + stopEnhancedConversionTOSPolling, + }; +}; + +export default useAutoCheckEnhancedConversionTOS; diff --git a/js/src/hooks/useGoogleAdsTermsURL.js b/js/src/hooks/useGoogleAdsTermsURL.js new file mode 100644 index 0000000000..408c5dc616 --- /dev/null +++ b/js/src/hooks/useGoogleAdsTermsURL.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from '.~/data/constants'; + +export const ENHANCED_CONVERSION_TERMS_BASE_URL = + 'https://ads.google.com/aw/conversions/customersettings'; + +const useGoogleAdsEnhancedConversionTermsURL = () => { + return useSelect( ( select ) => { + const adsAccount = select( STORE_KEY ).getGoogleAdsAccount(); + + const url = addQueryArgs( ENHANCED_CONVERSION_TERMS_BASE_URL, { + ocid: adsAccount?.ocid || 0, + eppn: 'customerDataTerms', + } ); + + return { url }; + }, [] ); +}; + +export default useGoogleAdsEnhancedConversionTermsURL; diff --git a/js/src/product-feed/submission-success-guide/constants.js b/js/src/product-feed/submission-success-guide/constants.js new file mode 100644 index 0000000000..dde7b88d45 --- /dev/null +++ b/js/src/product-feed/submission-success-guide/constants.js @@ -0,0 +1 @@ +export const GLA_MODAL_CLOSED_EVENT_NAME = 'gla_modal_closed'; diff --git a/js/src/product-feed/submission-success-guide/dynamic-screen-actions.js b/js/src/product-feed/submission-success-guide/dynamic-screen-actions.js new file mode 100644 index 0000000000..0ef61b01cb --- /dev/null +++ b/js/src/product-feed/submission-success-guide/dynamic-screen-actions.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { Fragment } from '@wordpress/element'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import LoadingLabel from '.~/components/loading-label'; +import useAdsCampaigns from '.~/hooks/useAdsCampaigns'; +import EnhancedConversionActions from './enhanced-conversion/actions'; +import GoogleCreditsActions from './google-credits/actions'; + +const DynamicScreenActions = ( { onModalClose = noop } ) => { + const { loaded, pmaxCampaigns } = useAdsCampaigns(); + + if ( ! loaded ) { + return ( + +
+ + + ); + } + + if ( pmaxCampaigns.length ) { + return ; + } + + return ; +}; + +export default DynamicScreenActions; diff --git a/js/src/product-feed/submission-success-guide/dynamic-screen-content.js b/js/src/product-feed/submission-success-guide/dynamic-screen-content.js new file mode 100644 index 0000000000..25353f3f88 --- /dev/null +++ b/js/src/product-feed/submission-success-guide/dynamic-screen-content.js @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import LoadingLabel from '.~/components/loading-label'; +import GuidePageContent from '.~/components/guide-page-content'; +import useAdsCampaigns from '.~/hooks/useAdsCampaigns'; +import EnhancedConversion from './enhanced-conversion'; +import GoogleCredits from './google-credits'; + +const DynamicScreenContent = () => { + const { loaded, pmaxCampaigns } = useAdsCampaigns(); + + if ( ! loaded ) { + return ( + + + + ); + } + + if ( pmaxCampaigns.length ) { + return ; + } + + return ; +}; + +export default DynamicScreenContent; diff --git a/js/src/product-feed/submission-success-guide/enhanced-conversion/actions.js b/js/src/product-feed/submission-success-guide/enhanced-conversion/actions.js new file mode 100644 index 0000000000..fca858d639 --- /dev/null +++ b/js/src/product-feed/submission-success-guide/enhanced-conversion/actions.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useCallback, Fragment } from '@wordpress/element'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import useDispatchCoreNotices from '.~/hooks/useDispatchCoreNotices'; +import AppButton from '.~/components/app-button'; +import CTA from '.~/components/enhanced-conversion-tracking-settings/cta'; + +const Actions = ( { onModalClose = noop } ) => { + const { createNotice } = useDispatchCoreNotices(); + + const handleEnableOrDisableClick = useCallback( () => { + createNotice( + 'info', + __( 'Status successfully set', 'google-listings-and-ads' ) + ); + + onModalClose(); + }, [ createNotice, onModalClose ] ); + + return ( + +
+ + + { __( 'Close', 'google-listings-and-ads' ) } + + + + + ); +}; + +export default Actions; diff --git a/js/src/product-feed/submission-success-guide/enhanced-conversion/actions.test.js b/js/src/product-feed/submission-success-guide/enhanced-conversion/actions.test.js new file mode 100644 index 0000000000..cca4d7e007 --- /dev/null +++ b/js/src/product-feed/submission-success-guide/enhanced-conversion/actions.test.js @@ -0,0 +1,106 @@ +jest.mock( '.~/hooks/useAcceptedCustomerDataTerms', () => ( { + __esModule: true, + default: jest.fn().mockName( 'useAcceptedCustomerDataTerms' ), +} ) ); + +jest.mock( '.~/hooks/useAllowEnhancedConversions', () => ( { + __esModule: true, + default: jest.fn().mockName( 'useAllowEnhancedConversions' ), +} ) ); + +jest.mock( '.~/hooks/useDispatchCoreNotices', () => ( { + __esModule: true, + default: jest + .fn() + .mockName( 'useDispatchCoreNotices' ) + .mockImplementation( () => { + return { + createNotice: jest.fn(), + }; + } ), +} ) ); + +jest.mock( '.~/data/actions', () => ( { + ...jest.requireActual( '.~/data/actions' ), + updateEnhancedAdsConversionStatus: jest + .fn() + .mockName( 'updateEnhancedAdsConversionStatus' ) + .mockImplementation( () => { + return { type: 'test', response: 'enabled' }; + } ), +} ) ); + +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; + +/** + * Internal dependencies + */ +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; +import useAllowEnhancedConversions from '.~/hooks/useAllowEnhancedConversions'; +import Actions from './actions'; + +describe( 'Enhanced Conversion Footer', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + test( 'Prompt the user to accept the TOS', () => { + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: false, + hasFinishedResolution: true, + } ); + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: null, + } ); + + render( ); + + expect( + screen.getByText( 'Accept Terms & Conditions' ) + ).toBeInTheDocument(); + } ); + + test( 'Click on enable button callback', () => { + const handleOnModalClose = jest.fn().mockName( 'On button click' ); + + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: true, + hasFinishedResolution: true, + } ); + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: ENHANCED_ADS_CONVERSION_STATUS.DISABLED, + } ); + + render( ); + + const button = screen.getByRole( 'button', { name: 'Confirm' } ); + userEvent.click( button ); + + expect( handleOnModalClose ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'Click on disable button callback', () => { + const handleOnModalClose = jest.fn().mockName( 'On button click' ); + + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: true, + hasFinishedResolution: true, + } ); + useAllowEnhancedConversions.mockReturnValue( { + allowEnhancedConversions: ENHANCED_ADS_CONVERSION_STATUS.ENABLED, + } ); + + render( ); + + const button = screen.getByRole( 'button', { name: 'Disable' } ); + userEvent.click( button ); + + expect( handleOnModalClose ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/js/src/product-feed/submission-success-guide/enhanced-conversion/index.js b/js/src/product-feed/submission-success-guide/enhanced-conversion/index.js new file mode 100644 index 0000000000..b0c5c750ee --- /dev/null +++ b/js/src/product-feed/submission-success-guide/enhanced-conversion/index.js @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createInterpolateElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ENHANCED_ADS_CONVERSION_STATUS } from '.~/constants'; +import GuidePageContent from '.~/components/guide-page-content'; +import PendingNotice from '.~/components/enhanced-conversion-tracking-settings/pending-notice'; +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; +import useAllowEnhancedConversions from '.~/hooks/useAllowEnhancedConversions'; + +const EnhancedConversion = () => { + const { acceptedCustomerDataTerms: hasAcceptedTerms } = + useAcceptedCustomerDataTerms(); + const { allowEnhancedConversions } = useAllowEnhancedConversions(); + + return ( + +

+ { createInterpolateElement( + __( + 'Enhance your conversion tracking accuracy and empower your bidding strategy with our latest feature: Enhanced Conversion Tracking. This feature seamlessly integrates with your existing conversion tags, ensuring the secure and privacy-conscious transmission of conversion data from your website to Google.', + 'google-listings-and-ads' + ), + { + strong: , + } + ) } +

+ { hasAcceptedTerms && + allowEnhancedConversions !== + ENHANCED_ADS_CONVERSION_STATUS.ENABLED ? ( +

+ { __( + 'Clicking confirm will enable Enhanced Conversions on your account and update your tags accordingly. This feature can also be managed from Google Listings & Ads > Settings', + 'google-listings-and-ads' + ) } +

+ ) : null } + { ! hasAcceptedTerms ? ( + <> +

+ { __( + 'Activating it is easy – just agree to the terms of service on Google Ads and we will make the tagging changes needed for you. This feature can also be managed from Google Listings & Ads > Settings', + 'google-listings-and-ads' + ) } +

+ { allowEnhancedConversions === + ENHANCED_ADS_CONVERSION_STATUS.PENDING ? ( + + ) : null } + + ) : null } +
+ ); +}; + +export default EnhancedConversion; diff --git a/js/src/product-feed/submission-success-guide/enhanced-conversion/index.test.js b/js/src/product-feed/submission-success-guide/enhanced-conversion/index.test.js new file mode 100644 index 0000000000..9f52cad697 --- /dev/null +++ b/js/src/product-feed/submission-success-guide/enhanced-conversion/index.test.js @@ -0,0 +1,50 @@ +jest.mock( '.~/hooks/useAcceptedCustomerDataTerms', () => ( { + __esModule: true, + default: jest.fn().mockName( 'useAcceptedCustomerDataTerms' ), +} ) ); + +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +/** + * Internal dependencies + */ +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; +import EnhancedConversion from './index'; + +describe( 'Enhanced Conversion', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + test( 'Render the correct text when TOS has not been accepted', () => { + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: false, + hasFinishedResolution: true, + } ); + + render( ); + expect( + screen.getByText( + 'Activating it is easy – just agree to the terms of service on Google Ads and we will make the tagging changes needed for you. This feature can also be managed from Google Listings & Ads > Settings' + ) + ).toBeInTheDocument(); + } ); + + test( 'Render the correct text when TOS has been accepted', () => { + useAcceptedCustomerDataTerms.mockReturnValue( { + acceptedCustomerDataTerms: true, + hasFinishedResolution: true, + } ); + + render( ); + expect( + screen.getByText( + 'Clicking confirm will enable Enhanced Conversions on your account and update your tags accordingly. This feature can also be managed from Google Listings & Ads > Settings' + ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/js/src/product-feed/submission-success-guide/google-credits/actions.js b/js/src/product-feed/submission-success-guide/google-credits/actions.js new file mode 100644 index 0000000000..49bfc70817 --- /dev/null +++ b/js/src/product-feed/submission-success-guide/google-credits/actions.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { GUIDE_NAMES } from '.~/constants'; +import { GLA_MODAL_CLOSED_EVENT_NAME } from '../constants'; +import AppButton from '.~/components/app-button'; +import AddPaidCampaignButton from '.~/components/paid-ads/add-paid-campaign-button'; + +const Actions = ( { onModalClose = noop } ) => { + return ( + +
+ + + { __( 'Maybe later', 'google-listings-and-ads' ) } + + + + { __( 'Create paid campaign', 'google-listings-and-ads' ) } + + + ); +}; + +export default Actions; diff --git a/js/src/product-feed/submission-success-guide/google-credits/index.js b/js/src/product-feed/submission-success-guide/google-credits/index.js new file mode 100644 index 0000000000..47781e27fd --- /dev/null +++ b/js/src/product-feed/submission-success-guide/google-credits/index.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { createInterpolateElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import GuidePageContent, { + ContentLink, +} from '.~/components/guide-page-content'; + +const GoogleCredits = () => { + return ( + +

+ { __( + 'New to Google Ads? Get $500 in ad credit when you spend $500 within your first 60 days* You can edit or cancel your campaign at any time.', + 'google-listings-and-ads' + ) } +

+ + { createInterpolateElement( + __( + '*Full terms and conditions here.', + 'google-listings-and-ads' + ), + { + link: ( + + ), + } + ) } + +
+ ); +}; + +export default GoogleCredits; diff --git a/js/src/product-feed/submission-success-guide/index.js b/js/src/product-feed/submission-success-guide/index.js index b462cc9941..923d053917 100644 --- a/js/src/product-feed/submission-success-guide/index.js +++ b/js/src/product-feed/submission-success-guide/index.js @@ -1,33 +1,29 @@ /** * External dependencies */ -import { getHistory } from '@woocommerce/navigation'; -import { - createInterpolateElement, - useEffect, - useCallback, -} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { useEffect } from '@wordpress/element'; +import { getHistory } from '@woocommerce/navigation'; /** * Internal dependencies */ +import { GUIDE_NAMES, LOCAL_STORAGE_KEYS } from '.~/constants'; +import { getProductFeedUrl } from '.~/utils/urls'; +import { recordGlaEvent } from '.~/utils/tracks'; +import { GLA_MODAL_CLOSED_EVENT_NAME } from './constants'; import Guide from '.~/external-components/wordpress/guide'; -import GuidePageContent, { - ContentLink, -} from '.~/components/guide-page-content'; -import AppButton from '.~/components/app-button'; -import AddPaidCampaignButton from '.~/components/paid-ads/add-paid-campaign-button'; -import { glaData, GUIDE_NAMES, LOCAL_STORAGE_KEYS } from '.~/constants'; import localStorage from '.~/utils/localStorage'; -import { getProductFeedUrl } from '.~/utils/urls'; +import DynamicScreenContent from './dynamic-screen-content'; +import DynamicScreenActions from './dynamic-screen-actions'; +import SetupSuccess from './setup-success'; import wooLogoURL from './woocommerce-logo.svg'; import googleLogoURL from '.~/images/google-logo.svg'; -import { recordGlaEvent } from '.~/utils/tracks'; +import useAcceptedCustomerDataTerms from '.~/hooks/useAcceptedCustomerDataTerms'; +import useAdsCampaigns from '.~/hooks/useAdsCampaigns'; +import useAllowEnhancedConversions from '.~/hooks/useAllowEnhancedConversions'; import './index.scss'; -const EVENT_NAME = 'gla_modal_closed'; - const image = (
@@ -50,86 +46,6 @@ const image = (
); -const pages = [ - { - image, - content: ( - -

- { __( - 'Your products are being synced and reviewed. Google reviews product listings in 3-5 days.', - 'google-listings-and-ads' - ) } -

-

- { glaData.adsSetupComplete - ? __( - 'No ads will launch yet and you won’t be charged until Google approves your listings. Updates are available in your WooCommerce dashboard.', - 'google-listings-and-ads' - ) - : createInterpolateElement( - __( - 'Manage and edit your product feed in WooCommerce. We will also notify you of any product feed issues to ensure your products get approved and perform well on Google.', - 'google-listings-and-ads' - ), - { - productFeedLink: ( - - ), - } - ) } -

-
- ), - }, - { - image, - content: ( - -

- { __( - 'New to Google Ads? Get $500 in ad credit when you spend $500 within your first 60 days* You can edit or cancel your campaign at any time.', - 'google-listings-and-ads' - ) } -

- - { createInterpolateElement( - __( - '*Full terms and conditions here.', - 'google-listings-and-ads' - ), - { - link: ( - - ), - } - ) } - -
- ), - }, -]; - -if ( glaData.adsSetupComplete ) { - pages.pop(); -} - const handleGuideFinish = ( e ) => { getHistory().replace( getProductFeedUrl() ); @@ -141,12 +57,25 @@ const handleGuideFinish = ( e ) => { const target = e.currentTarget || e.target; action = target.dataset.action || action; } - recordGlaEvent( EVENT_NAME, { + recordGlaEvent( GLA_MODAL_CLOSED_EVENT_NAME, { context: GUIDE_NAMES.SUBMISSION_SUCCESS, action, } ); }; +// There will always be at least 2 pages because we will require a connected Ads account during onboarding. +const pages = [ + { + image, + content: , + }, + { + image, + content: , + actions: , + }, +]; + /** * Modal window to greet the user at Product Feed, after successful completion of onboarding. * @@ -170,51 +99,16 @@ const SubmissionSuccessGuide = () => { ); }, [] ); - const renderFinish = useCallback( () => { - if ( glaData.adsSetupComplete ) { - return ( - - { __( 'View product feed', 'google-listings-and-ads' ) } - - ); - } - - return ( - <> -
- - { __( 'Maybe later', 'google-listings-and-ads' ) } - - - { __( 'Create paid campaign', 'google-listings-and-ads' ) } - - - ); - }, [] ); + // Side effects to try to get the data we need for the modals as soon as possible. + useAdsCampaigns(); + useAcceptedCustomerDataTerms(); + useAllowEnhancedConversions(); return ( ); diff --git a/js/src/product-feed/submission-success-guide/index.scss b/js/src/product-feed/submission-success-guide/index.scss index 6f048567fa..207ea73f77 100644 --- a/js/src/product-feed/submission-success-guide/index.scss +++ b/js/src/product-feed/submission-success-guide/index.scss @@ -1,6 +1,8 @@ .gla-submission-success-guide { &.components-guide { height: auto; + width: 100%; + @include break-small { max-width: 517px; max-height: none; diff --git a/js/src/product-feed/submission-success-guide/index.test.js b/js/src/product-feed/submission-success-guide/index.test.js new file mode 100644 index 0000000000..dde91edb04 --- /dev/null +++ b/js/src/product-feed/submission-success-guide/index.test.js @@ -0,0 +1,76 @@ +jest.mock( '.~/hooks/useAdsCampaigns', () => + jest.fn().mockName( 'useAdsCampaigns' ) +); + +jest.mock( '.~/hooks/useDispatchCoreNotices', () => ( { + __esModule: true, + default: jest + .fn() + .mockName( 'useDispatchCoreNotices' ) + .mockImplementation( () => { + return { + createNotice: jest.fn(), + }; + } ), +} ) ); + +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; + +/** + * Internal dependencies + */ +import SubmissionSuccessGuide from './index'; +import useAdsCampaigns from '.~/hooks/useAdsCampaigns'; +import { CAMPAIGN_TYPE_PMAX } from '.~/constants'; + +const PMAX_CAMPAIGN = { + id: 10, + name: 'PMax Campaign', + status: 'enabled', + type: CAMPAIGN_TYPE_PMAX, + amount: 20, + displayCountries: [ 'US' ], +}; + +describe( 'SubmissionSuccessGuide', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + test( 'Renders the Google Ads credit screen if there are no paid campaigns', () => { + useAdsCampaigns.mockReturnValue( { + pmaxCampaigns: [], + loaded: true, + } ); + + render( ); + + const button = screen.getByRole( 'button', { name: 'Next' } ); + userEvent.click( button ); + + expect( + screen.getByText( 'Spend $500 to get $500 in Google Ads credits' ) + ).toBeInTheDocument(); + } ); + + test( 'Renders the enhanced tracking screen if there are paid campaigns', () => { + useAdsCampaigns.mockReturnValue( { + pmaxCampaigns: [ PMAX_CAMPAIGN ], + loaded: true, + } ); + + render( ); + + const button = screen.getByRole( 'button', { name: 'Next' } ); + userEvent.click( button ); + + expect( + screen.getByText( 'Enhanced Conversion Tracking' ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/js/src/product-feed/submission-success-guide/setup-success/index.js b/js/src/product-feed/submission-success-guide/setup-success/index.js new file mode 100644 index 0000000000..fa1766a570 --- /dev/null +++ b/js/src/product-feed/submission-success-guide/setup-success/index.js @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { createInterpolateElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { getProductFeedUrl } from '.~/utils/urls'; +import { glaData } from '.~/constants'; +import GuidePageContent, { + ContentLink, +} from '.~/components/guide-page-content'; + +const SetupSuccess = () => { + return ( + +

+ { __( + 'Your products are being synced and reviewed. Google reviews product listings in 3-5 days.', + 'google-listings-and-ads' + ) } +

+

+ { glaData.adsSetupComplete + ? __( + 'No ads will launch yet and you won’t be charged until Google approves your listings. Updates are available in your WooCommerce dashboard.', + 'google-listings-and-ads' + ) + : createInterpolateElement( + __( + 'Manage and edit your product feed in WooCommerce. We will also notify you of any product feed issues to ensure your products get approved and perform well on Google.', + 'google-listings-and-ads' + ), + { + productFeedLink: ( + + ), + } + ) } +

+
+ ); +}; + +export default SetupSuccess; diff --git a/js/src/settings/index.js b/js/src/settings/index.js index 4189ca0059..96613b04da 100644 --- a/js/src/settings/index.js +++ b/js/src/settings/index.js @@ -12,6 +12,7 @@ import useLegacyMenuEffect from '.~/hooks/useLegacyMenuEffect'; import useGoogleAccount from '.~/hooks/useGoogleAccount'; import { subpaths, getReconnectAccountUrl } from '.~/utils/urls'; import { ContactInformationPreview } from '.~/components/contact-information'; +import EnhancedConversionTrackingSettings from '.~/components/enhanced-conversion-tracking-settings'; import LinkedAccounts from './linked-accounts'; import ReconnectWPComAccount from './reconnect-wpcom-account'; import ReconnectGoogleAccount from './reconnect-google-account'; @@ -62,6 +63,7 @@ const Settings = () => { +
); }; diff --git a/src/API/Google/Ads.php b/src/API/Google/Ads.php index 5f5f2ef54c..b3119cc519 100644 --- a/src/API/Google/Ads.php +++ b/src/API/Google/Ads.php @@ -323,4 +323,79 @@ private function get_merchant_link( int $merchant_id ): MerchantCenterLink { throw new Exception( __( 'Merchant link is not available to accept', 'google-listings-and-ads' ) ); } + + /** + * Check if the user has accepted the customer data terms for enhanced conversion tracking. + * Returns false for any account that fails. + * + * @return boolean + */ + public function get_accepted_customer_data_terms(): bool { + $ads_id = $this->options->get_ads_id(); + + // Return if no ads id present. + if ( ! $ads_id ) { + return false; + } + + try { + $accepted_terms = $this->options->get( OptionsInterface::ADS_CUSTOMER_DATA_TERMS, null ); + + // Retrieve the terms acceptance data from options. + if ( null !== $accepted_terms ) { + return (bool) apply_filters( 'woocommerce_gla_ads_enhanced_conversion_customer_data_terms', (bool) $accepted_terms ); + } + + $customer = ( new AdsAccountQuery() ) + ->set_client( $this->client, $ads_id ) + ->columns( [ 'customer.conversion_tracking_setting.accepted_customer_data_terms' ] ) + ->get_result() + ->getCustomer(); + + if ( ! $customer ) { + return false; + } + + $conversion_tracking_setting = $customer->getConversionTrackingSetting(); + + $accepted = $conversion_tracking_setting->getAcceptedCustomerDataTerms(); + + // Save the data terms in options as those cannot be reverted. + $this->options->update( OptionsInterface::ADS_CUSTOMER_DATA_TERMS, $accepted ); + + return (bool) apply_filters( 'woocommerce_gla_ads_enhanced_conversion_customer_data_terms', (bool) $accepted ); + } catch ( ApiException $e ) { + do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ ); + } + + return false; + } + + /** + * Updates the enhanced ads conversion status. + * + * @param string $status The status value + * + * @return string + */ + public function update_enhanced_conversion_status( string $status ): string { + $this->options->update( OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS, $status ); + + return $status; + } + + /** + * Retrieve the enhanced ads conversion status. Possible values are: enabled, disabled and pending + * + * @return string|null + */ + public function get_enhanced_conversion_status(): ?string { + $result = $this->options->get( OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS, null ); + + if ( ! is_scalar( $result ) ) { + return null; + } + + return strval( $result ); + } } diff --git a/src/API/Site/Controllers/Ads/AccountController.php b/src/API/Site/Controllers/Ads/AccountController.php index 59a88c8574..a44bdb3694 100644 --- a/src/API/Site/Controllers/Ads/AccountController.php +++ b/src/API/Site/Controllers/Ads/AccountController.php @@ -86,6 +86,42 @@ public function register_routes(): void { ], ] ); + + $this->register_route( + 'ads/accepted-customer-data-terms', + [ + [ + 'methods' => TransportMethods::READABLE, + 'callback' => $this->get_accepted_customer_data_terms_callback(), + 'permission_callback' => $this->get_permission_callback(), + ], + ] + ); + + $this->register_route( + 'ads/enhanced-conversion-status', + [ + [ + 'methods' => TransportMethods::EDITABLE, + 'callback' => $this->update_enhanced_ads_conversion_callback(), + 'permission_callback' => $this->get_permission_callback(), + 'args' => [ + 'status' => [ + 'description' => __( 'Enhanced Conversion status.', 'google-listings-and-ads' ), + 'type' => 'string', + 'enum' => [ 'enabled', 'disabled', 'pending' ], + 'validate_callback' => 'rest_validate_request_arg', + 'required' => true, + ], + ], + ], + [ + 'methods' => TransportMethods::READABLE, + 'callback' => $this->get_enhanced_conversion_status_callback(), + 'permission_callback' => $this->get_permission_callback(), + ], + ] + ); } /** @@ -162,6 +198,49 @@ protected function get_billing_status_callback(): callable { }; } + /** + * Get the callback function for retrieving the accepted customer data terms status. + * + * @return callable + */ + protected function get_accepted_customer_data_terms_callback(): callable { + return function () { + return $this->account->get_accepted_customer_data_terms(); + }; + } + + /** + * Get the callback function for updating the ads enhanced conversion status. + * + * @return callable + */ + protected function update_enhanced_ads_conversion_callback(): callable { + return function ( Request $request ) { + try { + $status = $request['status']; + + return $this->account->update_enhanced_conversion_status( $status ); + } catch ( Exception $e ) { + return $this->response_from_exception( $e ); + } + }; + } + + /** + * Get the callback function for retrieving the enhanced conversion status. + * + * @return callable + */ + protected function get_enhanced_conversion_status_callback(): callable { + return function ( Request $request ) { + try { + return $this->account->get_enhanced_conversion_status(); + } catch ( Exception $e ) { + return $this->response_from_exception( $e ); + } + }; + } + /** * Get the item schema for the controller. * diff --git a/src/Ads/AccountService.php b/src/Ads/AccountService.php index c0434e7f7b..bebf302f12 100644 --- a/src/Ads/AccountService.php +++ b/src/Ads/AccountService.php @@ -74,12 +74,15 @@ public function get_accounts(): array { * @return array */ public function get_connected_account(): array { - $id = $this->options->get_ads_id(); + $id = $this->options->get_ads_id(); + $ocid = $this->options->get( OptionsInterface::ADS_ACCOUNT_OCID, '' ); + $currency = $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ); $status = [ 'id' => $id, - 'currency' => $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ), - 'symbol' => html_entity_decode( get_woocommerce_currency_symbol( $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ) ) ), + 'currency' => $currency, + 'ocid' => $ocid, + 'symbol' => html_entity_decode( get_woocommerce_currency_symbol( $currency ) ), 'status' => $id ? 'connected' : 'disconnected', ]; @@ -215,6 +218,8 @@ public function disconnect() { $this->options->delete( OptionsInterface::ADS_ACCOUNT_STATE ); $this->options->delete( OptionsInterface::ADS_BILLING_URL ); $this->options->delete( OptionsInterface::ADS_CONVERSION_ACTION ); + $this->options->delete( OptionsInterface::ADS_CUSTOMER_DATA_TERMS ); + $this->options->delete( OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS ); $this->options->delete( OptionsInterface::ADS_ID ); $this->options->delete( OptionsInterface::ADS_SETUP_COMPLETED_AT ); $this->options->delete( OptionsInterface::CAMPAIGN_CONVERT_STATUS ); @@ -277,4 +282,45 @@ private function create_conversion_action(): void { $action = $this->container->get( AdsConversionAction::class )->create_conversion_action(); $this->options->update( OptionsInterface::ADS_CONVERSION_ACTION, $action ); } + + /** + * Gets the accepted customer data terms status. + * + * @return array + */ + public function get_accepted_customer_data_terms(): array { + $status = $this->container->get( Ads::class )->get_accepted_customer_data_terms(); + + return [ + 'status' => $status, + ]; + } + + /** + * Updates the enhanced ads conversion status. + * + * @param string $status Status which should be updated to. Possible values are: pending, enabled and disabled. + * + * @return array + */ + public function update_enhanced_conversion_status( string $status ): array { + $updated_status = $this->container->get( Ads::class )->update_enhanced_conversion_status( $status ); + + return [ + 'status' => $updated_status, + ]; + } + + /** + * Gets the enhanced conversion status. + * + * @return array + */ + public function get_enhanced_conversion_status(): array { + $status = $this->container->get( Ads::class )->get_enhanced_conversion_status(); + + return [ + 'status' => $status, + ]; + } } diff --git a/src/Google/GlobalSiteTag.php b/src/Google/GlobalSiteTag.php index b7d9ca4cf7..7ce4e3d440 100644 --- a/src/Google/GlobalSiteTag.php +++ b/src/Google/GlobalSiteTag.php @@ -24,6 +24,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP; use Automattic\WooCommerce\GoogleListingsAndAds\Value\BuiltScriptDependencyArray; use WC_Product; +use WC_Customer; defined( 'ABSPATH' ) || exit; @@ -224,12 +225,13 @@ public function activate_global_site_tag( string $ads_conversion_id ) { ); } else { // Legacy code to support Google Analytics for WooCommerce version < 2.0.0. + $config = wp_json_encode( $this->get_config_object() ); add_filter( 'woocommerce_gtag_snippet', - function ( $gtag_snippet ) use ( $ads_conversion_id ) { + function ( $gtag_snippet ) use ( $ads_conversion_id, $config ) { return preg_replace( '~(\s)~', - "\tgtag('config', '" . $ads_conversion_id . "', { 'groups': 'GLA', 'send_page_view': false });\n$1", + "\tgtag('config', '" . $ads_conversion_id . "', $config);\n$1", $gtag_snippet ); } @@ -277,9 +279,11 @@ function gtag() { dataLayer.push(arguments); } * @param string $ads_conversion_id Google Ads account conversion ID. */ protected function get_gtag_config( string $ads_conversion_id ) { + $config = $this->get_config_object(); return sprintf( - 'gtag("config", "%1$s", { "groups": "GLA", "send_page_view": false });', - esc_js( $ads_conversion_id ) + 'gtag("config", "%1$s", %2$s);', + esc_js( $ads_conversion_id ), + wp_json_encode( $config ) ); } @@ -344,6 +348,11 @@ public function maybe_display_conversion_and_purchase_event_snippets( string $ad $order->update_meta_data( self::ORDER_CONVERSION_META_KEY, 1 ); $order->save_meta_data(); + // Prepare and enqueue the enhanced conversion data, if enabled. + if ( $this->is_enhanced_conversion_enabled() ) { + $this->add_enhanced_conversion_data( $order ); + } + $conversion_gtag_info = sprintf( 'gtag("event", "conversion", { @@ -417,9 +426,81 @@ public function maybe_display_conversion_and_purchase_event_snippets( string $ad esc_js( $language ), join( ',', $item_info ), ); + $this->add_inline_event_script( $purchase_page_gtag ); } + /** + * Add enhanced conversion data to the page. + * + * @param WC_Order $order The order object. + */ + private function add_enhanced_conversion_data( $order ) { + // Enhanced conversion data. + $ec_data = []; + $email = $order->get_billing_email(); + $fname = $order->get_billing_first_name(); + $lname = $order->get_billing_last_name(); + $phone = $order->get_billing_phone(); + $billing_address = $order->get_billing_address_1(); + $postcode = $order->get_billing_postcode(); + $city = $order->get_billing_city(); + $region = $order->get_billing_state(); + $country = $order->get_billing_country(); + + // Add email in EC data. + if ( ! empty( $email ) ) { + $normalized_email = strtolower( $email ); + $email_parts = explode( '@', $normalized_email ); + + if ( count( $email_parts ) > 1 && preg_match( '/^(gmail|googlemail)\.com\s*/', $email_parts[1] ) ) { + $email_parts[0] = str_replace( '.', '', $email_parts[0] ); + $normalized_email = sprintf( '%s@%s', $email_parts[0], $email_parts[1] ); + } + + $ec_data['sha256_email_address'] = $this->normalize_and_hash( $normalized_email ); + } + + // Format phone number in IE64. + $phone = preg_replace( '/[^0-9]/', '', $phone ); + $phone_length = strlen( $phone ); + if ( $phone_length > 9 && $phone_length < 14 ) { + $phone = sprintf( '%s%d', '+', $phone ); + $ec_data['sha256_phone_number'] = $this->normalize_and_hash( $phone ); + } + + // Check for required address fields. + if ( ! empty( $fname ) && ! empty( $lname ) && ! empty( $postcode ) && ! empty( $country ) ) { + $ec_data['address']['sha256_first_name'] = $this->normalize_and_hash( $fname ); + $ec_data['address']['sha256_last_name'] = $this->normalize_and_hash( $lname ); + $ec_data['address']['postal_code'] = $postcode; + $ec_data['address']['country'] = $country; + + /** + * Add additional data, if present. + */ + + // Add street address. + if ( ! empty( $billing_address ) ) { + $ec_data['address']['sha256_street'] = $this->normalize_and_hash( $billing_address ); + } + + // Add city. + if ( ! empty( $city ) ) { + $ec_data['address']['city'] = $city; + } + + // Add region. + if ( ! empty( $region ) ) { + $ec_data['address']['region'] = $region; + } + } + + $purchase_user_data_gtag = sprintf( 'gtag("set", "user_data", %s)', wp_json_encode( $ec_data ) ); + + $this->add_inline_event_script( $purchase_user_data_gtag ); + } + /** * Display the JavaScript code to track the product view page. */ @@ -583,4 +664,62 @@ private function register_js_for_fast_refresh_dev() { false ); } + + /** + * Get the config object for Google tag. + * + * @return array + */ + private function get_config_object(): array { + // Standard config. + $config = [ + 'groups' => 'GLA', + 'send_page_view' => false, + ]; + + // Check if enhanced conversion is enabled. + if ( $this->is_enhanced_conversion_enabled() ) { + $config['allow_enhanced_conversions'] = true; + } + + return $config; + } + + /** + * Checks if enhanced conversion is enabled. + * + * @return bool + */ + private function is_enhanced_conversion_enabled(): bool { + // Check if enhanced conversion is enabled. + $enhanced_conversion_status = $this->options->get( OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS, null ); + return ( 'enabled' === $enhanced_conversion_status ); + } + + /** + * Return the hashed data to be sent to Google Ads for enhanced conversion. + * + * @param string $value Data that needs to be hashed. + * @param string $algo Algorithm for hashing. + * @param bool $trim_intermediate_spaces Whether to trim intermediate spaces in values. Default true. + * + * @return string + */ + private function normalize_and_hash( string $value, $algo = 'sha256', bool $trim_intermediate_spaces = true ): string { + if ( empty( $value ) ) { + return ''; + } + + // Convert case to lowercase. + $normalized_value = strtolower( $value ); + + if ( $trim_intermediate_spaces ) { + $normalized_value = str_replace( ' ', '', $normalized_value ); + } else { + // Remove leading and trailing whitespaces. + $normalized_value = trim( $normalized_value ); + } + + return hash( $algo, strtolower( trim( $normalized_value ) ) ); + } } diff --git a/src/Options/OptionsInterface.php b/src/Options/OptionsInterface.php index 3dafa5a93b..435e19231c 100644 --- a/src/Options/OptionsInterface.php +++ b/src/Options/OptionsInterface.php @@ -13,8 +13,11 @@ interface OptionsInterface { public const ADS_ACCOUNT_CURRENCY = 'ads_account_currency'; + public const ADS_ACCOUNT_OCID = 'ads_account_ocid'; public const ADS_ACCOUNT_STATE = 'ads_account_state'; public const ADS_BILLING_URL = 'ads_billing_url'; + public const ADS_CUSTOMER_DATA_TERMS = 'ads_customer_data_terms'; + public const ADS_ENHANCED_CONVERSION_STATUS = 'ads_enhanced_conversion_status'; public const ADS_ID = 'ads_id'; public const ADS_CONVERSION_ACTION = 'ads_conversion_action'; public const ADS_SETUP_COMPLETED_AT = 'ads_setup_completed_at'; @@ -44,8 +47,11 @@ interface OptionsInterface { public const VALID_OPTIONS = [ self::ADS_ACCOUNT_CURRENCY => true, + self::ADS_ACCOUNT_OCID => true, self::ADS_ACCOUNT_STATE => true, self::ADS_BILLING_URL => true, + self::ADS_CUSTOMER_DATA_TERMS => true, + self::ADS_ENHANCED_CONVERSION_STATUS => true, self::ADS_ID => true, self::ADS_CONVERSION_ACTION => true, self::ADS_SETUP_COMPLETED_AT => true, diff --git a/tests/Unit/API/Google/AdsTest.php b/tests/Unit/API/Google/AdsTest.php index 9a6fbaaf0f..8f9df7a8f0 100644 --- a/tests/Unit/API/Google/AdsTest.php +++ b/tests/Unit/API/Google/AdsTest.php @@ -282,4 +282,25 @@ public function test_update_billing_url() { ->willReturn( true ); $this->assertTrue( $this->ads->update_billing_url( self::TEST_BILLING_URL ) ); } + + public function test_get_enhanced_conversion_status() { + $value = $this->options->expects( $this->once() ) + ->method( 'get' ) + ->with( OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS ) + ->willReturn( 'enabled' ); + + $this->assertEquals( 'enabled', $this->ads->get_enhanced_conversion_status() ); + } + + public function test_update_enhanced_conversion_status_with_valid_status_returns_status() { + $this->options->expects( $this->once() ) + ->method( 'update' ) + ->with( OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS ) + ->willReturn( true ); + + // Call with valid status. + $status = $this->ads->update_enhanced_conversion_status( 'pending' ); + + $this->assertEquals( 'pending', $status ); + } } diff --git a/tests/Unit/API/Site/Controllers/Ads/AccountControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/AccountControllerTest.php index 3246e74710..377d0e4af5 100644 --- a/tests/Unit/API/Site/Controllers/Ads/AccountControllerTest.php +++ b/tests/Unit/API/Site/Controllers/Ads/AccountControllerTest.php @@ -23,13 +23,16 @@ class AccountControllerTest extends RESTControllerUnitTest { /** @var AccountController $controller */ protected $controller; - protected const ROUTE_ACCOUNTS = '/wc/gla/ads/accounts'; - protected const ROUTE_CONNECTION = '/wc/gla/ads/connection'; - protected const ROUTE_BILLING_STATUS = '/wc/gla/ads/billing-status'; - protected const TEST_ACCOUNT_ID = 1234567890; - protected const TEST_BILLING_URL = 'https://domain.test/billing/setup/'; - protected const TEST_BILLING_STATUS = 'pending'; - protected const TEST_ACCOUNTS = [ + protected const ROUTE_ACCOUNTS = '/wc/gla/ads/accounts'; + protected const ROUTE_CONNECTION = '/wc/gla/ads/connection'; + protected const ROUTE_BILLING_STATUS = '/wc/gla/ads/billing-status'; + protected const ROUTE_ACCEPTED_DATA_TERMS = '/wc/gla/ads/accepted-customer-data-terms'; + protected const ROUTE_UPDATED_EC_STATUS = '/wc/gla/ads/enhanced-conversion-status'; + protected const ROUTE_GET_EC_STATUS = '/wc/gla/ads/enhanced-conversion-status'; + protected const TEST_ACCOUNT_ID = 1234567890; + protected const TEST_BILLING_URL = 'https://domain.test/billing/setup/'; + protected const TEST_BILLING_STATUS = 'pending'; + protected const TEST_ACCOUNTS = [ [ 'id' => self::TEST_ACCOUNT_ID, 'name' => 'Ads Account', @@ -39,29 +42,29 @@ class AccountControllerTest extends RESTControllerUnitTest { 'name' => 'Other Account', ], ]; - protected const TEST_NO_ACCOUNTS = []; - protected const TEST_ACCOUNT_CREATE_DATA = [ + protected const TEST_NO_ACCOUNTS = []; + protected const TEST_ACCOUNT_CREATE_DATA = [ 'id' => self::TEST_ACCOUNT_ID, 'billing_url' => self::TEST_BILLING_URL, ]; - protected const TEST_ACCOUNT_LINK_ARGS = [ 'id' => self::TEST_ACCOUNT_ID ]; - protected const TEST_ACCOUNT_LINK_DATA = [ + protected const TEST_ACCOUNT_LINK_ARGS = [ 'id' => self::TEST_ACCOUNT_ID ]; + protected const TEST_ACCOUNT_LINK_DATA = [ 'id' => self::TEST_ACCOUNT_ID, 'billing_url' => null, ]; - protected const TEST_CONNECTED_DATA = [ + protected const TEST_CONNECTED_DATA = [ 'id' => self::TEST_ACCOUNT_ID, 'currency' => 'EUR', 'symbol' => '€', 'status' => 'connected', ]; - protected const TEST_DISCONNECTED_DATA = [ + protected const TEST_DISCONNECTED_DATA = [ 'id' => 0, 'currency' => null, 'symbol' => '€', 'status' => 'disconnected', ]; - protected const TEST_BILLING_STATUS_DATA = [ + protected const TEST_BILLING_STATUS_DATA = [ 'status' => self::TEST_BILLING_STATUS, 'billing_url' => self::TEST_BILLING_URL, ]; @@ -216,6 +219,54 @@ public function test_get_billing_status() { $this->assertEquals( 200, $response->get_status() ); } + public function test_get_accepted_customer_data_terms() { + $expected_response = [ 'status' => 'pending' ]; + $this->account->expects( $this->once() ) + ->method( 'get_accepted_customer_data_terms' ) + ->willReturn( $expected_response ); + + $response = $this->do_request( self::ROUTE_ACCEPTED_DATA_TERMS, 'GET' ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $expected_response, $response->get_data() ); + } + + public function test_update_enhanced_conversion_status() { + $expected_response = [ 'status' => 'pending' ]; + $this->account->expects( $this->once() ) + ->method( 'update_enhanced_conversion_status' ) + ->willReturn( $expected_response ); + + $response = $this->do_request( self::ROUTE_UPDATED_EC_STATUS, 'POST', [ 'status' => 'pending' ] ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $expected_response, $response->get_data() ); + } + + public function test_update_enhanced_conversion_status_with_invalid_status() { + $response = $this->do_request( self::ROUTE_UPDATED_EC_STATUS, 'POST', [ 'status' => 'invalid' ] ); + + $this->assertEquals( 400, $response->get_status() ); + } + + public function test_get_enhanced_conversion_status() { + $expected_response = [ 'status' => 'pending' ]; + $this->account->expects( $this->once() ) + ->method( 'get_enhanced_conversion_status' ) + ->willReturn( $expected_response ); + + $response = $this->do_request( self::ROUTE_GET_EC_STATUS, 'GET' ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $expected_response, $response->get_data() ); + } + + public function test_update_enhanced_conversion_status_only_accepts_lowercase_arg() { + $response = $this->do_request( self::ROUTE_UPDATED_EC_STATUS, 'POST', [ 'status' => 'PENDING' ] ); + + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( $data['code'], 'rest_invalid_param' ); + } + /** * Test a Google disconnected error since it's a dependency for a connected Ads account. */ diff --git a/tests/Unit/Ads/AccountServiceTest.php b/tests/Unit/Ads/AccountServiceTest.php index 8fc93eeda0..2703b08eb0 100644 --- a/tests/Unit/Ads/AccountServiceTest.php +++ b/tests/Unit/Ads/AccountServiceTest.php @@ -55,6 +55,7 @@ class AccountServiceTest extends UnitTest { protected $options; protected const TEST_ACCOUNT_ID = 1234567890; + protected const TEST_ADS_OCID = '123456789'; protected const TEST_OLD_ACCOUNT_ID = 2345678901; protected const TEST_MERCHANT_ID = 78123456; protected const TEST_BILLING_URL = 'https://domain.test/billing/setup/'; @@ -76,12 +77,14 @@ class AccountServiceTest extends UnitTest { protected const TEST_CONNECTED_DATA = [ 'id' => self::TEST_ACCOUNT_ID, 'currency' => 'EUR', + 'ocid' => self::TEST_ADS_OCID, 'symbol' => '€', 'status' => 'connected', ]; protected const TEST_INCOMPLETE_DATA = [ 'id' => self::TEST_ACCOUNT_ID, 'currency' => 'EUR', + 'ocid' => self::TEST_ADS_OCID, 'symbol' => '€', 'status' => 'incomplete', 'step' => 'billing', @@ -89,6 +92,7 @@ class AccountServiceTest extends UnitTest { protected const TEST_DISCONNECTED_DATA = [ 'id' => 0, 'currency' => null, + 'ocid' => null, 'symbol' => '$', 'status' => 'disconnected', ]; @@ -146,20 +150,39 @@ public function test_get_connected_account() { ->willReturn( self::TEST_ACCOUNT_ID ); $this->options->method( 'get' ) - ->with( OptionsInterface::ADS_ACCOUNT_CURRENCY ) - ->willReturn( self::TEST_CURRENCY ); + ->withConsecutive( + [ OptionsInterface::ADS_ACCOUNT_OCID, '' ], + [ OptionsInterface::ADS_ACCOUNT_CURRENCY ], + [ OptionsInterface::ADS_ACCOUNT_CURRENCY ] + ) + ->willReturnOnConsecutiveCalls( + self::TEST_ADS_OCID, + self::TEST_CURRENCY, + self::TEST_CURRENCY + ); $this->assertEquals( self::TEST_CONNECTED_DATA, $this->account->get_connected_account() ); } + /** + * @group failing + */ public function test_get_connected_account_incomplete() { $this->options->expects( $this->once() ) ->method( 'get_ads_id' ) ->willReturn( self::TEST_ACCOUNT_ID ); $this->options->method( 'get' ) - ->with( OptionsInterface::ADS_ACCOUNT_CURRENCY ) - ->willReturn( self::TEST_CURRENCY ); + ->withConsecutive( + [ OptionsInterface::ADS_ACCOUNT_OCID, '' ], + [ OptionsInterface::ADS_ACCOUNT_CURRENCY ], + [ OptionsInterface::ADS_ACCOUNT_CURRENCY ] + ) + ->willReturnOnConsecutiveCalls( + self::TEST_ADS_OCID, + self::TEST_CURRENCY, + self::TEST_CURRENCY + ); $this->state->expects( $this->once() ) ->method( 'last_incomplete_step' ) @@ -461,16 +484,18 @@ public function test_get_billing_status_pending() { } public function test_disconnect() { - $this->options->expects( $this->exactly( 7 ) ) + $this->options->expects( $this->exactly( 9 ) ) ->method( 'delete' ) ->withConsecutive( [ OptionsInterface::ADS_ACCOUNT_CURRENCY ], [ OptionsInterface::ADS_ACCOUNT_STATE ], [ OptionsInterface::ADS_BILLING_URL ], [ OptionsInterface::ADS_CONVERSION_ACTION ], + [ OptionsInterface::ADS_CUSTOMER_DATA_TERMS ], + [ OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS ], [ OptionsInterface::ADS_ID ], [ OptionsInterface::ADS_SETUP_COMPLETED_AT ], - [ OptionsInterface::CAMPAIGN_CONVERT_STATUS ] + [ OptionsInterface::CAMPAIGN_CONVERT_STATUS ], ); $this->transients->expects( $this->exactly( 1 ) ) diff --git a/tests/e2e/specs/gtag-events/gtag-events.test.js b/tests/e2e/specs/gtag-events/gtag-events.test.js index c6a9030e87..9e78c9434c 100644 --- a/tests/e2e/specs/gtag-events/gtag-events.test.js +++ b/tests/e2e/specs/gtag-events/gtag-events.test.js @@ -10,6 +10,8 @@ import { createSimpleProduct, setConversionID, clearConversionID, + enableEnhancedConversions, + disableEnhancedConversions, } from '../../utils/api'; import { blockProductAddToCart, @@ -18,7 +20,11 @@ import { singleProductAddToCart, } from '../../utils/customer'; import { createBlockShopPage } from '../../utils/block-page'; -import { getEventData, trackGtagEvent } from '../../utils/track-event'; +import { + getEventData, + trackGtagEvent, + getDataLayerValue, +} from '../../utils/track-event'; const config = require( '../../config/default' ); const productPrice = config.products.simple.regularPrice; @@ -28,6 +34,7 @@ let simpleProductID; test.describe( 'GTag events', () => { test.beforeAll( async () => { await setConversionID(); + await enableEnhancedConversions(); simpleProductID = await createSimpleProduct(); } ); @@ -192,4 +199,43 @@ test.describe( 'GTag events', () => { expect( data.country ).toEqual( 'US' ); } ); } ); + + test( 'User data for enhanced conversions are not sent when not enabled', async ( { + page, + } ) => { + await disableEnhancedConversions(); + await singleProductAddToCart( page, simpleProductID ); + + await checkout( page ); + + const dataConfig = await getDataLayerValue( page, { + type: 'config', + key: 'AW-123456', + } ); + + expect( dataConfig ).toBeDefined(); + expect( dataConfig.allow_enhanced_conversions ).toBeUndefined(); + } ); + + test( 'User data for enhanced conversions is sent when enabled', async ( { + page, + } ) => { + await enableEnhancedConversions(); + await singleProductAddToCart( page, simpleProductID ); + + await checkout( page ); + + const dataConfig = await getDataLayerValue( page, { + type: 'config', + key: 'AW-123456', + } ); + + const dataUserData = await getDataLayerValue( page, { + type: 'set', + key: 'user_data', + } ); + + expect( dataConfig.allow_enhanced_conversions ).toBeTruthy(); + expect( dataUserData.sha256_email_address ).toBeDefined(); + } ); } ); diff --git a/tests/e2e/test-data/test-data.php b/tests/e2e/test-data/test-data.php index 55de84dcaa..e116d5533d 100644 --- a/tests/e2e/test-data/test-data.php +++ b/tests/e2e/test-data/test-data.php @@ -49,6 +49,22 @@ function register_routes() { ], ], ); + register_rest_route( + 'wc/v3', + 'gla-test/enhanced-conversions', + [ + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\enable_enhanced_conversions', + 'permission_callback' => __NAMESPACE__ . '\permissions', + ], + [ + 'methods' => 'DELETE', + 'callback' => __NAMESPACE__ . '\disable_enhanced_conversions', + 'permission_callback' => __NAMESPACE__ . '\permissions', + ], + ], + ); } /** @@ -107,6 +123,30 @@ function clear_conversion_id() { $options->delete( OptionsInterface::ADS_CONVERSION_ACTION ); } +/** + * Enable enhanced conversions. + */ +function enable_enhanced_conversions() { + /** @var OptionsInterface $options */ + $options = woogle_get_container()->get( OptionsInterface::class ); + $options->update( + OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS, + 'enabled' + ); +} + +/** + * Disable enhanced conversions. + */ +function disable_enhanced_conversions() { + /** @var OptionsInterface $options */ + $options = woogle_get_container()->get( OptionsInterface::class ); + $options->update( + OptionsInterface::ADS_ENHANCED_CONVERSION_STATUS, + 'disabled' + ); +} + /** * Check permissions for API requests. */ diff --git a/tests/e2e/utils/api.js b/tests/e2e/utils/api.js index 4c1c2bca7c..ec8d1f0971 100644 --- a/tests/e2e/utils/api.js +++ b/tests/e2e/utils/api.js @@ -75,3 +75,17 @@ export async function setOnboardedMerchant() { export async function clearOnboardedMerchant() { await api().delete( 'gla-test/onboarded-merchant' ); } + +/** + * Enable Enhanced Conversions. + */ +export async function enableEnhancedConversions() { + await api().post( 'gla-test/enhanced-conversions' ); +} + +/** + * Disable Enhanced Conversions. + */ +export async function disableEnhancedConversions() { + await api().delete( 'gla-test/enhanced-conversions' ); +} diff --git a/tests/e2e/utils/track-event.js b/tests/e2e/utils/track-event.js index 84b6517b3b..5ade37a7fe 100644 --- a/tests/e2e/utils/track-event.js +++ b/tests/e2e/utils/track-event.js @@ -59,3 +59,19 @@ export function getEventData( request ) { return data; } + +/** + * Find a value in the dataLayer + * + * @param {Page} page + * @param {Object} args The type and key to search for. + */ +export function getDataLayerValue( page, args ) { + return page.evaluate( ( { type, key } ) => { + return ( + window.dataLayer.find( + ( item ) => item[ 0 ] === type && item[ 1 ] === key + )[ 2 ] ?? null + ); + }, args ); +}