diff --git a/js/src/product-feed/product-statistics/create-campaign-notice/index.js b/js/src/product-feed/product-statistics/create-campaign-notice/index.js new file mode 100644 index 0000000000..f194505b72 --- /dev/null +++ b/js/src/product-feed/product-statistics/create-campaign-notice/index.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Flex, FlexItem } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import AddPaidCampaignButton from '.~/components/paid-ads/add-paid-campaign-button'; +import useMCProductStatistics from '.~/hooks/useMCProductStatistics'; +import useAdsCampaigns from '.~/hooks/useAdsCampaigns'; +import './index.scss'; + +const CreateCampaignNotice = () => { + const { data: products } = useMCProductStatistics(); + const { loaded: campaignsLoaded, data: campaigns } = useAdsCampaigns(); + + if ( + ! products?.statistics?.active || + ! campaignsLoaded || + campaigns?.length > 0 + ) { + return null; + } + + return ( + + + + { __( + 'You have approved products. Create a Google Ads campaign to reach more customers and drive more sales.', + 'google-listings-and-ads' + ) } + + + { __( 'Create Campaign', 'google-listings-and-ads' ) } + + + + ); +}; + +export default CreateCampaignNotice; diff --git a/js/src/product-feed/product-statistics/create-campaign-notice/index.scss b/js/src/product-feed/product-statistics/create-campaign-notice/index.scss new file mode 100644 index 0000000000..9a71d5d38b --- /dev/null +++ b/js/src/product-feed/product-statistics/create-campaign-notice/index.scss @@ -0,0 +1,9 @@ +.gla-ads-inline-notice { + background-color: #f0f6fc; + margin-bottom: $grid-unit-20; + padding: $grid-unit-20 $grid-unit-30; + + p { + margin-top: 0; + } +} diff --git a/js/src/product-feed/product-statistics/index.js b/js/src/product-feed/product-statistics/index.js index 429313809d..e3e0f2cfa4 100644 --- a/js/src/product-feed/product-statistics/index.js +++ b/js/src/product-feed/product-statistics/index.js @@ -25,6 +25,7 @@ import SyncStatus from '.~/product-feed/product-statistics/status-box/sync-statu import SyncProductStatistics from '.~/product-feed/product-statistics/status-box/sync-product-statistics'; import FeedStatus from '.~/product-feed/product-statistics/status-box/feed-status'; import AccountStatus from '.~/product-feed/product-statistics/status-box/account-status'; +import CreateCampaignNotice from '.~/product-feed/product-statistics/create-campaign-notice'; import Text from '.~/components/app-text'; import AppSpinner from '.~/components/app-spinner'; import './index.scss'; @@ -133,7 +134,9 @@ const ProductStatistics = () => { ) } + + diff --git a/tests/e2e/specs/product-feed/product-feed-campaign-notice.test.js b/tests/e2e/specs/product-feed/product-feed-campaign-notice.test.js new file mode 100644 index 0000000000..4b57cbd87e --- /dev/null +++ b/tests/e2e/specs/product-feed/product-feed-campaign-notice.test.js @@ -0,0 +1,196 @@ +/** + * External dependencies + */ +import { expect, test } from '@playwright/test'; +/** + * Internal dependencies + */ +import { + clearOnboardedMerchant, + setOnboardedMerchant, + setCompletedAdsSetup, + clearCompletedAdsSetup, +} from '../../utils/api'; +import ProductFeedPage from '../../utils/pages/product-feed'; +import { LOAD_STATE } from '../../utils/constants'; + +test.use( { storageState: process.env.ADMINSTATE } ); + +test.describe.configure( { mode: 'serial' } ); + +/** + * @type {import('../../utils/pages/product-feed').default} productFeedPage + */ +let productFeedPage = null; + +/** + * @type {import('@playwright/test').Page} page + */ +let page = null; + +test.describe( 'Product Feed Page', () => { + test.beforeAll( async ( { browser } ) => { + page = await browser.newPage(); + productFeedPage = new ProductFeedPage( page ); + await Promise.all( [ + productFeedPage.mockRequests(), + setOnboardedMerchant(), + ] ); + } ); + + test.afterAll( async () => { + await clearOnboardedMerchant(); + await page.close(); + } ); + + test.describe( 'No campaign', () => { + test.beforeAll( async () => { + await productFeedPage.fulfillAdsCampaignsRequest( [] ); + } ); + + test( 'No active product and no campaign; Do not display campaign notice', async () => { + await productFeedPage.fulfillProductStatisticsRequest( { + timestamp: 1695011644, + statistics: { + active: 0, + expiring: 0, + pending: 0, + disapproved: 0, + not_synced: 1137, + }, + scheduled_sync: 0, + loading: false, + } ); + + await productFeedPage.goto(); + await expect( + page.getByRole( 'heading', { level: 1, name: 'Product Feed' } ) + ).toBeVisible(); + + await expect( + page.getByRole( 'heading', { + name: 'Overview', + } ) + ).toBeVisible(); + + await expect( + productFeedPage.getActiveProductValueElement() + ).toBeVisible(); + + await expect( + productFeedPage.getActiveProductValueElement() + ).toHaveText( /^0$/ ); + + await expect( + await productFeedPage.getCampaignNoticeSection() + ).not.toBeVisible(); + } ); + + test( 'Has active product but no campaign; Display campaign notice', async () => { + await productFeedPage.fulfillProductStatisticsRequest( { + timestamp: 1695011644, + statistics: { + active: 1, + expiring: 0, + pending: 0, + disapproved: 0, + not_synced: 1137, + }, + scheduled_sync: 0, + loading: false, + } ); + + await productFeedPage.goto(); + + await expect( + productFeedPage.getActiveProductValueElement() + ).toBeVisible(); + + await expect( + productFeedPage.getActiveProductValueElement() + ).toHaveText( /^1$/ ); + + const noticeSection = + await productFeedPage.getCampaignNoticeSection(); + const createCampaignButton = + productFeedPage.getInNoticeCreateCampaignButton(); + + await expect( noticeSection ).toBeVisible(); + await expect( createCampaignButton ).toBeVisible(); + await createCampaignButton.click(); + await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + await expect( + page.getByRole( 'heading', { + level: 1, + name: 'Set up your accounts', + } ) + ).toBeVisible(); + } ); + } ); + + test.describe( 'Has campaign', () => { + test.beforeAll( async () => { + await setCompletedAdsSetup(); + await productFeedPage.fulfillAdsCampaignsRequest( [ + { + id: 111111111, + name: 'Test Campaign', + status: 'enabled', + type: 'performance_max', + amount: 1, + country: 'US', + targeted_locations: [ 'US' ], + }, + ] ); + } ); + + test.afterAll( async () => { + await clearCompletedAdsSetup(); + await page.close(); + } ); + + test( 'Has active product and a campaign; Do not display campaign notice', async () => { + await productFeedPage.goto(); + + await expect( + productFeedPage.getActiveProductValueElement() + ).toBeVisible(); + + await expect( + productFeedPage.getActiveProductValueElement() + ).toHaveText( /^1$/ ); + + await expect( + await productFeedPage.getCampaignNoticeSection() + ).not.toBeVisible(); + } ); + + test( 'Has campaign but no active product; Do not display campaign notice', async () => { + await productFeedPage.fulfillProductStatisticsRequest( { + timestamp: 1695011644, + statistics: { + active: 0, + expiring: 0, + pending: 0, + disapproved: 0, + not_synced: 1137, + }, + scheduled_sync: 0, + loading: false, + } ); + await productFeedPage.goto(); + + await expect( + productFeedPage.getActiveProductValueElement() + ).toBeVisible(); + + await expect( + productFeedPage.getActiveProductValueElement() + ).toHaveText( /^0$/ ); + + await expect( + await productFeedPage.getCampaignNoticeSection() + ).not.toBeVisible(); + } ); + } ); +} ); diff --git a/tests/e2e/test-data/test-data.php b/tests/e2e/test-data/test-data.php index 242a117c73..7bc2ff8fee 100644 --- a/tests/e2e/test-data/test-data.php +++ b/tests/e2e/test-data/test-data.php @@ -51,6 +51,22 @@ function register_routes() { ], ], ); + register_rest_route( + 'wc/v3', + 'gla-test/ads-completed', + [ + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\set_ads_completed_at', + 'permission_callback' => __NAMESPACE__ . '\permissions', + ], + [ + 'methods' => 'DELETE', + 'callback' => __NAMESPACE__ . '\clear_ads_completed_at', + 'permission_callback' => __NAMESPACE__ . '\permissions', + ], + ], + ); register_rest_route( 'wc/v3', 'gla-test/notifications-ready', @@ -100,6 +116,26 @@ function clear_onboarded_merchant() { $options->delete( OptionsInterface::GOOGLE_CONNECTED ); } +/** + * Set the ADS_SETUP_COMPLETED_AT option. + */ +function set_ads_completed_at() { + /** @var OptionsInterface $options */ + $options = woogle_get_container()->get( OptionsInterface::class ); + $options->update( + OptionsInterface::ADS_SETUP_COMPLETED_AT, + 1693215209 + ); +} + +/** + * Clear a previously set ADS_SETUP_COMPLETED_AT option. + */ +function clear_ads_completed_at() { + /** @var OptionsInterface $options */ + $options = woogle_get_container()->get( OptionsInterface::class ); + $options->delete( OptionsInterface::ADS_SETUP_COMPLETED_AT ); +} /** * Set the Ads Conversion Action to test values. diff --git a/tests/e2e/utils/api.js b/tests/e2e/utils/api.js index 1dbe0d6fe7..c7f6eddc4f 100644 --- a/tests/e2e/utils/api.js +++ b/tests/e2e/utils/api.js @@ -113,6 +113,20 @@ export async function clearOnboardedMerchant() { await api().delete( 'gla-test/onboarded-merchant' ); } +/** + * Set Ads Completed At. + */ +export async function setCompletedAdsSetup() { + await api().post( 'gla-test/ads-completed' ); +} + +/** + * Clear Ads Completed At. + */ +export async function clearCompletedAdsSetup() { + await api().delete( 'gla-test/ads-completed' ); +} + /** * Set Notifications Ready. */ diff --git a/tests/e2e/utils/pages/product-feed.js b/tests/e2e/utils/pages/product-feed.js new file mode 100644 index 0000000000..ed1f9aa4dc --- /dev/null +++ b/tests/e2e/utils/pages/product-feed.js @@ -0,0 +1,92 @@ +/** + * Internal dependencies + */ +import { LOAD_STATE } from '../constants'; +import MockRequests from '../mock-requests'; + +/** + * ProductFeed page object class. + */ +export default class ProductFeedPage extends MockRequests { + /** + * @param {import('@playwright/test').Page} page + */ + constructor( page ) { + super( page ); + this.page = page; + } + + /** + * Go to the product feed page. + * + * @return {Promise} + */ + async goto() { + await this.page.goto( + '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fproduct-feed', + { waitUntil: LOAD_STATE.DOM_CONTENT_LOADED } + ); + } + + /** + * Mock all requests related to external accounts such as Merchant Center, Google, etc. + * + * @return {Promise} + */ + async mockRequests() { + await Promise.all( [ + this.fulfillMCReview( { + cooldown: 0, + issues: [], + reviewEligibleRegions: [], + status: 'ONBOARDING', + } ), + + this.fulfillAccountIssuesRequest( { + issues: [], + page: 1, + total: 0, + loading: false, + } ), + + this.mockJetpackConnected(), + this.mockGoogleConnected(), + this.mockAdsAccountConnected(), + ] ); + } + + /** + * Get the active product value element. + * + * @return {import('@playwright/test').Locator} The active product value element. + */ + getActiveProductValueElement() { + return this.page + .locator( '.woocommerce-summary__item-label span >> text=Active' ) + .locator( '../..' ) + .locator( '.woocommerce-summary__item-value span' ); + } + + /** + * Get the campaign notice section. + * + * @return {import('@playwright/test').Locator} The campaign notice section. + */ + async getCampaignNoticeSection() { + return this.page.locator( '.gla-ads-inline-notice' ).filter( { + hasText: + 'You have approved products. Create a Google Ads campaign to reach more customers and drive more sales.', + } ); + } + + /** + * Get the create campaign button in the notice section. + * + * @return {import('@playwright/test').Locator} The create campaign button. + */ + getInNoticeCreateCampaignButton() { + return this.page.getByRole( 'button', { + name: 'Create Campaign', + } ); + } +}
+ { __( + 'You have approved products. Create a Google Ads campaign to reach more customers and drive more sales.', + 'google-listings-and-ads' + ) } +