Skip to content

Add PMaxImproveAssetsBanner component #2983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
133 changes: 133 additions & 0 deletions js/src/components/pmax-improve-assets-banner/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useDispatch } from '@wordpress/data';
import { Notice } from '@wordpress/components';
import { getHistory } from '@woocommerce/navigation';
import { store as preferencesStore } from '@wordpress/preferences';

/**
* Internal dependencies
*/
import {
CAMPAIGN_TYPE_PMAX,
PREFERENCES_STORE_NAMESPACE,
PMAX_IMPROVE_PERFORMANCE_MAX_AD_STRENGTH,
DAY_IN_SECONDS,
} from '~/constants';
import { getEditCampaignUrl } from '~/utils/urls';
import AppButton from '~/components/app-button';
import usePreference from '~/hooks/usePreference';
import useAdsCampaigns from '~/hooks/useAdsCampaigns';
import useAdsRecommendations from '~/hooks/useAdsRecommendations';
import './index.scss';

const PREFERENCE_BANNER_KEY = 'pmax-improve-assets-banner';

/**
* Displays a dismissible banner prompting users to improve assets for their highest-spending enabled Performance Max (PMAX) campaign.
*
* The banner is shown only if:
* - The preference expiry is undefined or expired.
* - There are enabled PMAX campaigns.
* - There are relevant asset improvement recommendations.
*
* When dismissed, the banner will not reappear until the expiry time elapses.
* Clicking "Improve Assets" navigates to the asset group edit page for the highest-spending PMAX campaign.
*
* @return {JSX.Element|null} The banner component, or null if not applicable.
*/
const PMaxImproveAssetsBanner = () => {
const { set } = useDispatch( preferencesStore );
const { expiry } = usePreference( PREFERENCE_BANNER_KEY ) || {};
const { data: adsCampaignsData } = useAdsCampaigns();
const { recommendations } = useAdsRecommendations(
PMAX_IMPROVE_PERFORMANCE_MAX_AD_STRENGTH
);

if ( expiry !== undefined ) {
if ( Date.now() < expiry ) {
// Do not render the banner if not expired
return null;
}

// If expired, reset the preference
set( PREFERENCES_STORE_NAMESPACE, PREFERENCE_BANNER_KEY, {
expiry: undefined, // Reset to undefined to show the banner again
} );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in a useEffect() hook to avoid unnecessary re-renders?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, just updated it. When I tested there were no re-renders but just to make sure, I've used useEffect

}

if ( ! adsCampaignsData || ! recommendations?.length ) {
return null;
}

const pmaxCampaigns = adsCampaignsData.filter(
( { type, status } ) =>
type === CAMPAIGN_TYPE_PMAX && status === 'enabled'
);

if ( ! pmaxCampaigns.length ) {
return null;
}

const highestAmountCampaign = pmaxCampaigns.reduce(
( max, campaign ) => ( campaign.amount > max.amount ? campaign : max ),
pmaxCampaigns[ 0 ]
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic could be sped up by wrapping it in a useMemo()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! Updated @joemcgill

const { id, name } = highestAmountCampaign;

const hasHighestSpendingCampaignRecommendation = recommendations.some(
( recommendation ) => recommendation.campaign_id === id
);

if ( ! hasHighestSpendingCampaignRecommendation ) {
return null;
}

const dismissBanner = () => {
set( PREFERENCES_STORE_NAMESPACE, PREFERENCE_BANNER_KEY, {
expiry: Date.now() + DAY_IN_SECONDS * 30 * 1000, // 30 days in ms
} );
};

const handleOnImproveAssets = () => {
dismissBanner();

// Navigate to the edit campaign page for the PMAX campaign with the highest spending.
const editCampaignUrl = getEditCampaignUrl( id, 'asset-group' );
getHistory().push( editCampaignUrl );
};

return (
<Notice
className="gla-pmax-improve-assets-banner"
status="info"
isDismissible={ true }
onRemove={ dismissBanner }
>
<p className="gla-pmax-improve-assets-banner__text">
{ sprintf(
// translators: %s: The PMAX campaign name with the highest spending.
__(
'Unlock more sales for your campaign, %s, by focusing on improving your campaign assets.Better assets directly increase your ad strength, allowing for a wider variety of ad combinations to be shown across Google.',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'Unlock more sales for your campaign, %s, by focusing on improving your campaign assets.Better assets directly increase your ad strength, allowing for a wider variety of ad combinations to be shown across Google.',
'Unlock more sales for your campaign, %s, by focusing on improving your campaign assets. Better assets directly increase your ad strength, allowing for a wider variety of ad combinations to be shown across Google.',

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦 Forgot to push my changes before to address this. Fixed @joemcgill

'google-listings-and-ads'
),
name
) }
</p>

<div className="gla-pmax-improve-assets-banner__actions">
<AppButton onClick={ handleOnImproveAssets } isSecondary>
{ __( 'Improve Assets', 'google-listings-and-ads' ) }
</AppButton>

<AppButton isTertiary onClick={ dismissBanner }>
{ __( 'Dismiss', 'google-listings-and-ads' ) }
</AppButton>
</div>
</Notice>
);
};

export default PMaxImproveAssetsBanner;
10 changes: 10 additions & 0 deletions js/src/components/pmax-improve-assets-banner/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.gla-pmax-improve-assets-banner {
.gla-pmax-improve-assets-banner__text {
margin: 0 0 $grid-unit-20;
}

.gla-pmax-improve-assets-banner__actions {
display: flex;
gap: $grid-unit-15;
}
}
215 changes: 215 additions & 0 deletions js/src/components/pmax-improve-assets-banner/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* External dependencies
*/
import '@testing-library/jest-dom';
import { getHistory } from '@woocommerce/navigation';
import { useDispatch } from '@wordpress/data';
import { render, screen, fireEvent } from '@testing-library/react';

/**
* Internal dependencies
*/
import { getEditCampaignUrl } from '~/utils/urls';
import { PREFERENCES_STORE_NAMESPACE, DAY_IN_SECONDS } from '~/constants';
import PMaxImproveAssetsBanner from './index';
import usePreference from '~/hooks/usePreference';
import useAdsCampaigns from '~/hooks/useAdsCampaigns';
import useAdsRecommendations from '~/hooks/useAdsRecommendations';

jest.mock( '@woocommerce/components', () => ( {
...jest.requireActual( '@woocommerce/components' ),
Spinner: jest.fn( () => <div>spinner</div> ),
} ) );

jest.mock( '@wordpress/components', () => ( {
Notice: ( { children, className } ) => (
<div className={ className }>{ children }</div>
),
} ) );

jest.mock( '@woocommerce/navigation', () => ( {
getHistory: jest.fn(),
} ) );

jest.mock( '~/components/app-button', () => ( { children, onClick } ) => (
<button onClick={ onClick }>{ children }</button>
) );

jest.mock( '@wordpress/data', () => ( {
__esModule: true,
useDispatch: jest.fn(),
} ) );

jest.mock( '~/hooks/usePreference', () =>
jest.fn().mockName( 'usePreference' )
);

jest.mock( '~/hooks/useAdsCampaigns', () =>
jest.fn().mockName( 'useAdsCampaigns' )
);

jest.mock( '~/hooks/useAdsRecommendations', () =>
jest.fn().mockName( 'useAdsRecommendations' )
);

jest.mock( '~/utils/urls', () => ( {
getEditCampaignUrl: jest.fn( () => '/edit/2/asset-group' ),
} ) );

const baseCampaigns = [
{
id: 1,
name: 'Campaign 1',
type: 'performance_max',
status: 'enabled',
amount: 100,
},
{
id: 2,
name: 'Campaign 2',
type: 'performance_max',
status: 'enabled',
amount: 200,
},
{
id: 3,
name: 'Campaign 3',
type: 'SEARCH',
status: 'enabled',
amount: 300,
},
];

const recommendations = [ { campaign_id: 2, campaign_name: 'Spring sale' } ];

describe( 'PMaxImproveAssetsBanner', () => {
beforeEach( () => {
useDispatch.mockReturnValue( { set: () => null } );
} );

it( 'renders nothing if expiry is not expired', () => {
usePreference.mockReturnValue( { expiry: Date.now() + 100000 } );
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } );
useAdsRecommendations.mockReturnValue( { recommendations } );
const { container } = render( <PMaxImproveAssetsBanner /> );
expect( container.firstChild ).toBeNull();
} );

it( 'resets expiry if expired and renders banner', () => {
usePreference.mockReturnValue( { expiry: Date.now() - 1000 } );
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } );
useAdsRecommendations.mockReturnValue( { recommendations } );
render( <PMaxImproveAssetsBanner /> );
expect(
screen.getByRole( 'button', { name: 'Improve Assets' } )
).toBeInTheDocument();
expect(
screen.getByRole( 'button', { name: 'Dismiss' } )
).toBeInTheDocument();
} );

it( 'renders nothing if no campaigns data', () => {
usePreference.mockReturnValue( {} );
useAdsCampaigns.mockReturnValue( { data: null } );
useAdsRecommendations.mockReturnValue( { recommendations } );
const { container } = render( <PMaxImproveAssetsBanner /> );
expect( container.firstChild ).toBeNull();
} );

it( 'renders nothing if no recommendations', () => {
usePreference.mockReturnValue( {} );
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } );
useAdsRecommendations.mockReturnValue( { recommendations: [] } );
const { container } = render( <PMaxImproveAssetsBanner /> );
expect( container.firstChild ).toBeNull();
} );

it( 'renders nothing if no enabled PMAX campaigns', () => {
usePreference.mockReturnValue( {} );
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } );
useAdsCampaigns.mockReturnValue( {
data: [
{
id: 3,
name: 'Campaign 3',
type: 'shopping',
status: 'enabled',
amount: 300,
},
],
} );
useAdsRecommendations.mockReturnValue( { recommendations } );
const { container } = render( <PMaxImproveAssetsBanner /> );
expect( container.firstChild ).toBeNull();
} );

it( 'renders nothing if no recommendation for highest-spending campaign', () => {
usePreference.mockReturnValue( {} );
useAdsCampaigns.mockReturnValue( {
data: baseCampaigns,
} );

useAdsRecommendations.mockReturnValue( {
recommendations: [
{ id: 1, campaign_id: 1, campaign_name: 'Spring Campaign' },
],
} );
const { container } = render( <PMaxImproveAssetsBanner /> );
expect( container.firstChild ).toBeNull();
} );

it( 'renders banner for highest-spending enabled PMAX campaign with recommendation', () => {
usePreference.mockReturnValue( {} );
useAdsCampaigns.mockReturnValue( {
data: baseCampaigns,
} );
useAdsRecommendations.mockReturnValue( { recommendations } );
render( <PMaxImproveAssetsBanner /> );
expect(
screen.getByText(
/Unlock more sales for your campaign, Campaign 2/
)
).toBeInTheDocument();
expect(
screen.getByRole( 'button', { name: 'Improve Assets' } )
).toBeInTheDocument();
expect(
screen.getByRole( 'button', { name: 'Dismiss' } )
).toBeInTheDocument();
} );

it( 'navigates to edit campaign and sets expiry when Improve Assets is clicked', () => {
const setMock = jest.fn();
useDispatch.mockReturnValue( { set: setMock } );

const historyPush = jest.fn().mockName( 'getHistory().push' );
getHistory.mockReturnValue( { push: historyPush } );
usePreference.mockReturnValue( {} );
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } );
useAdsRecommendations.mockReturnValue( { recommendations } );

const MOCK_NOW = 1_700_000_000_000;
jest.spyOn( Date, 'now' ).mockReturnValue( MOCK_NOW );

render( <PMaxImproveAssetsBanner /> );
const improveAssetsButton = screen.getByRole( 'button', {
name: 'Improve Assets',
} );

fireEvent.click( improveAssetsButton );

const expectedExpiry = MOCK_NOW + DAY_IN_SECONDS * 30 * 1000; // 30 days

expect( setMock ).toHaveBeenCalledWith(
PREFERENCES_STORE_NAMESPACE,
'pmax-improve-assets-banner',
{
expiry: expectedExpiry,
}
);

expect( setMock ).toHaveBeenCalled();
expect( getEditCampaignUrl ).toHaveBeenCalledWith( 2, 'asset-group' );
expect( historyPush ).toHaveBeenCalledWith( '/edit/2/asset-group' );
} );
} );
3 changes: 3 additions & 0 deletions js/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,6 @@ export const PRICE_BENCHMARK_CHART_COLORS = {
// @wordpress/preferences namespace
export const PREFERENCES_STORE_NAMESPACE =
'woocommerce/google-listings-and-ads';

export const PMAX_IMPROVE_PERFORMANCE_MAX_AD_STRENGTH =
'IMPROVE_PERFORMANCE_MAX_AD_STRENGTH';
1 change: 1 addition & 0 deletions js/src/data/action-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const TYPES = {
RECEIVE_PRICE_BENCHMARK_SUGGESTIONS: 'RECEIVE_PRICE_BENCHMARK_SUGGESTIONS',
RECEIVE_PRICE_BENCHMARK_SUGGESTIONS_PRODUCT_PRICE:
'RECEIVE_PRICE_BENCHMARK_SUGGESTIONS_PRODUCT_PRICE',
RECEIVE_ADS_RECOMMENDATIONS: 'RECEIVE_ADS_RECOMMENDATIONS',
};

export default TYPES;
11 changes: 11 additions & 0 deletions js/src/data/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1215,3 +1215,14 @@
},
};
}

export function* receiveAdsRecommendations(
recommendations,
recommendationType
) {
return {

Check warning on line 1223 in js/src/data/actions.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/actions.js#L1222-L1223

Added lines #L1222 - L1223 were not covered by tests
type: TYPES.RECEIVE_ADS_RECOMMENDATIONS,
recommendations,
recommendationType,
};
}
Loading