Commit a795131dbb9 for woocommerce
commit a795131dbb92bef5d76868ed71a5507e99b0d074
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Fri Oct 16 09:18:34 2026 +0200
Add extension recommendations to the add products task for new stores (#69764)
* Add marketplace suggestions to the add products task page
Expose products-list-empty-body feed items as the productsListEmptySuggestions
admin setting when the empty products list redirects to the add products task.
* Add extension recommendations to the add products task page
Render the marketplace suggestions with the same List cards as the product
types, in place of the Marketplace link, and keep the legacy Tracks events.
* Match the recommendations heading and footer to the design
Use the page title style for an "Expand your store" heading and link the
footer to the Marketplace, as in the Figma option the designer proposed.
* Simplify marketplace suggestions filtering and the recommendations component
Trust the WooCommerce.com feed shape like the legacy script did, drop the
unused show-if-active rule, and inline single-use components.
* Link the recommendations footer to the same Marketplace category as before
* Use a more specific recommendations heading and the page's subtitle colour
* Clamp recommendation descriptions to two lines
Feed descriptions can be full product excerpts; keep the cards as tall as
the product type cards.
* Respect show-if-active in marketplace suggestions for the add products page
The static fallback feed still uses it, for example Deposits only shows
when Bookings is active.
* Use shared spacing, font and focus tokens in the recommendations styles
Follows the task-lists convention ($gap scale), with $grid-unit for the
32px and 64px design values and base-styles font, radius and focus tokens.
Compiled CSS is unchanged apart from the focus ring dropping its custom
property fallbacks.
* Make the marketplace suggestions context helper private
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Build recommendation cards like the Printful card
Each card now opens its product from onClick and renders through Stack, so the
bubbled-click tracking workaround and its hand-built wrapper go away.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Cap recommendations-count at 50 and restore $_GET in suggestion tests
Matches the legacy marketplace suggestions script's limit for the remote
feed value, and keeps request state from leaking between tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the recommendation card focus ring regardless of stylesheet order
List's own :focus style and stack.scss use the same specificity, so the
ring depended on which stylesheet loaded last.
* Skip incomplete suggestions and count network-activated plugins
Drop feed items without a title or a URL that survives esc_url_raw(), treat
network-activated plugins as installed on multisite, and describe the fixed
order (pinned first) in the docblock.
* Drop @since from the private and internal suggestion methods
The backend docblock rules only require it on public and protected methods.
* Record TrackedLink events on modifier-key clicks
Link skips onClick on Cmd/Ctrl/Alt/Shift-click so the browser can open a new
tab, so the event was never recorded. Track in the capture phase instead.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Herman <KokkieH@users.noreply.github.com>
Co-authored-by: Thilina Pituwala <thilina.hasantha@gmail.com>
diff --git a/plugins/woocommerce/changelog/wccom-2945-products-empty-suggestions b/plugins/woocommerce/changelog/wccom-2945-products-empty-suggestions
new file mode 100644
index 00000000000..ccc18754224
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-2945-products-empty-suggestions
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Show extension recommendations on the add products page when the store has no products yet.
diff --git a/plugins/woocommerce/client/admin/client/components/tracked-link/test/tracked-link.tsx b/plugins/woocommerce/client/admin/client/components/tracked-link/test/tracked-link.tsx
new file mode 100644
index 00000000000..0ba85b264ba
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/components/tracked-link/test/tracked-link.tsx
@@ -0,0 +1,81 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { TrackedLink } from '../tracked-link';
+
+jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
+
+const mockLocation = { href: '' };
+Object.defineProperty( window, 'location', {
+ value: mockLocation,
+ writable: true,
+} );
+
+// Stop jsdom from following the link when Link leaves a modified click to the browser.
+const preventNavigation = ( event: Event ) => event.preventDefault();
+
+const renderLink = ( props = {} ) =>
+ render(
+ <TrackedLink
+ message="Visit {{Link}}the Marketplace{{/Link}}"
+ eventName="test_link_click"
+ eventProperties={ { source: 'test' } }
+ targetUrl="admin.php?page=wc-admin&path=/extensions"
+ { ...props }
+ />
+ );
+
+describe( 'TrackedLink', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockLocation.href = '';
+ document.addEventListener( 'click', preventNavigation );
+ } );
+
+ afterEach( () => {
+ document.removeEventListener( 'click', preventNavigation );
+ } );
+
+ it( 'records the event once and navigates on a plain click', () => {
+ renderLink();
+
+ fireEvent.click( screen.getByText( 'the Marketplace' ) );
+
+ expect( recordEvent ).toHaveBeenCalledTimes( 1 );
+ expect( recordEvent ).toHaveBeenCalledWith( 'test_link_click', {
+ source: 'test',
+ } );
+ expect( mockLocation.href ).toBe(
+ 'admin.php?page=wc-admin&path=/extensions'
+ );
+ } );
+
+ it( 'records the event once on a Cmd-click and leaves the navigation to the browser', () => {
+ renderLink();
+
+ fireEvent.click( screen.getByText( 'the Marketplace' ), {
+ metaKey: true,
+ } );
+
+ expect( recordEvent ).toHaveBeenCalledTimes( 1 );
+ expect( mockLocation.href ).toBe( '' );
+ } );
+
+ it( 'calls onClickCallback instead of recording the event, also on a Cmd-click', () => {
+ const onClickCallback = jest.fn();
+ renderLink( { onClickCallback } );
+
+ fireEvent.click( screen.getByText( 'the Marketplace' ), {
+ metaKey: true,
+ } );
+
+ expect( onClickCallback ).toHaveBeenCalledTimes( 1 );
+ expect( recordEvent ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/components/tracked-link/tracked-link.tsx b/plugins/woocommerce/client/admin/client/components/tracked-link/tracked-link.tsx
index 7dc01c76fca..bba3559804b 100644
--- a/plugins/woocommerce/client/admin/client/components/tracked-link/tracked-link.tsx
+++ b/plugins/woocommerce/client/admin/client/components/tracked-link/tracked-link.tsx
@@ -58,12 +58,15 @@ export const TrackedLink = ( {
components: {
Link: (
<Link
- onClick={ () => {
+ // Link skips onClick on Cmd/Ctrl/Alt/Shift-click so the browser can open a new tab, so track in the capture phase.
+ onClickCapture={ () => {
if ( onClickCallback ) {
onClickCallback();
} else {
recordEvent( eventName, eventProperties );
}
+ } }
+ onClick={ () => {
if ( linkType !== 'external' ) {
window.location.href = targetUrl;
return false;
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/constants.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/constants.tsx
index 38cd3f4bf97..8e7977e3f99 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/constants.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/constants.tsx
@@ -8,6 +8,7 @@ import TypesIcon from 'gridicons/dist/types';
import { Icon, chevronRight, chevronLeft } from '@wordpress/icons';
import { recordEvent } from '@woocommerce/tracks';
import { getAdminLink } from '@woocommerce/settings';
+import type { ReactNode } from 'react';
/**
* Internal dependencies
@@ -139,6 +140,17 @@ export const ImportCSVItem = {
export type SponsoredProductPlacementType =
typeof PrintfulAdvertProductPlacement | typeof ImportCSVItem;
+/** A card built from the marketplace suggestions feed. Kept out of ProductType so its string key doesn't widen ProductTypeKey. */
+export type MarketplaceSuggestionPlacementType = {
+ key: string;
+ title: string;
+ content: ReactNode;
+ before: ReactNode;
+ after: ReactNode;
+ className: string;
+ onClick: () => void;
+};
+
export type ProductType =
| ( typeof productTypes )[ number ]
| typeof LoadSampleProductType
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/index.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/index.tsx
index 732bad4cc7d..5653d30faba 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/index.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/index.tsx
@@ -33,6 +33,10 @@ import {
SponsoredProductPlacementType,
} from './constants';
import { TrackedLink } from '~/components/tracked-link/tracked-link';
+import {
+ ProductRecommendations,
+ ProductRecommendation,
+} from './product-recommendations';
const getOnboardingProductType = (): string[] => {
const onboardingData = getAdminSetting( 'onboarding' );
@@ -86,6 +90,11 @@ export const Products = () => {
);
const { recordCompletionTime } = useRecordCompletionTime( 'products' );
+ const recommendations: ProductRecommendation[] = getAdminSetting(
+ 'productsListEmptySuggestions',
+ []
+ );
+
const productTypesWithTimeRecord = useMemo(
() =>
productTypes.map( ( productType ) => ( {
@@ -188,21 +197,26 @@ export const Products = () => {
showOtherOptions={ false }
isTaskListItemClicked={ isRequesting }
/>
- <TrackedLink
- textProps={ {
- className: 'woocommerce-products-marketplace-link',
- } }
- message={ __(
- // translators: {{Link}} is a placeholder for a html element.
- 'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to enhance your store with additional options such as Subscriptions, Gift Cards, and more.',
- 'woocommerce'
- ) }
- eventName="tasklist_add_product_visit_marketplace_click"
- targetUrl={ getAdminLink(
- 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=merchandising'
- ) }
- linkType="wc-admin"
- />
+ { Array.isArray( recommendations ) &&
+ recommendations.length > 0 ? (
+ <ProductRecommendations items={ recommendations } />
+ ) : (
+ <TrackedLink
+ textProps={ {
+ className: 'woocommerce-products-marketplace-link',
+ } }
+ message={ __(
+ // translators: {{Link}} is a placeholder for a html element.
+ 'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to enhance your store with additional options such as Subscriptions, Gift Cards, and more.',
+ 'woocommerce'
+ ) }
+ eventName="tasklist_add_product_visit_marketplace_click"
+ targetUrl={ getAdminLink(
+ 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=merchandising'
+ ) }
+ linkType="wc-admin"
+ />
+ ) }
</div>
{ isLoadingSampleProducts ? (
<LoadSampleProductModal />
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/product-recommendations.scss b/plugins/woocommerce/client/admin/client/task-lists/fills/products/product-recommendations.scss
new file mode 100644
index 00000000000..e15b47af3a9
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/product-recommendations.scss
@@ -0,0 +1,61 @@
+// Rows reuse the product type cards from stack.scss.
+.woocommerce-product-recommendations {
+ width: 100%;
+ // Same width as .woocommerce-products-stack in stack.scss.
+ max-width: 550px;
+ margin-block-start: $grid-unit-80;
+
+ .woocommerce-product-recommendations__intro {
+ display: flex;
+ flex-direction: column;
+ gap: $gap;
+ text-align: center;
+
+ > * {
+ margin: 0;
+ }
+ }
+
+ .woocommerce-product-recommendations__description {
+ color: $gray-700;
+ }
+
+ .woocommerce-products-stack {
+ margin-block-start: $grid-unit-40;
+ }
+
+ .woocommerce-product-recommendations__icon {
+ display: block;
+ width: $gap-largest;
+ height: $gap-largest;
+ border-radius: $radius-medium;
+ object-fit: cover;
+ }
+
+ // Feed descriptions can be any length; keep the cards as tall as the product type cards.
+ .woocommerce-list__item-content {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ }
+
+ // Two-line descriptions otherwise run into the external-link icon.
+ .woocommerce-list__item-after {
+ padding-inline-start: $gap;
+ }
+
+ // stack.scss and List both style card focus at the same specificity; outrank them so the order stylesheets load in doesn't matter.
+ .woocommerce-list__item > .woocommerce-list__item-inner:focus-visible {
+ @include button-style__focus();
+ }
+
+ .woocommerce-product-recommendations__browse-all {
+ display: block;
+ margin-block-start: $gap-large;
+ font-size: $font-size-medium;
+ line-height: $font-line-height-small;
+ text-align: center;
+ }
+}
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/product-recommendations.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/product-recommendations.tsx
new file mode 100644
index 00000000000..13fea4b04e8
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/product-recommendations.tsx
@@ -0,0 +1,157 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useEffect } from '@wordpress/element';
+import { VisuallyHidden } from '@wordpress/components';
+import { Icon, external } from '@wordpress/icons';
+import { Text } from '@woocommerce/experimental';
+import { getAdminLink } from '@woocommerce/settings';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import './product-recommendations.scss';
+import Stack from './stack';
+import { MarketplaceSuggestionPlacementType } from './constants';
+import { getInAppPurchaseUrl } from '~/lib/in-app-purchase';
+import { TrackedLink } from '~/components/tracked-link/tracked-link';
+
+/** An item of the `productsListEmptySuggestions` admin setting. */
+export type ProductRecommendation = {
+ slug: string;
+ product: string;
+ title: string;
+ copy: string;
+ icon: string;
+ url: string;
+ promoted: string;
+};
+
+const CONTEXT = 'products-list-empty-body';
+const HEADING_ID = 'woocommerce-product-recommendations-heading';
+const BROWSE_ALL_URL = getAdminLink(
+ 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=merchandising'
+);
+
+const getItemUrl = ( url: string ): string =>
+ getInAppPurchaseUrl( url, {
+ utm_source: 'productsempty',
+ utm_campaign: 'marketplacesuggestions',
+ utm_medium: 'product',
+ } );
+
+const getTrackProps = ( item: ProductRecommendation, target: string ) => ( {
+ suggestion_slug: item.slug,
+ context: CONTEXT,
+ product: item.product,
+ promoted: item.promoted,
+ target,
+} );
+
+/** Extension recommendations shown on the add-product task when reached from the empty products list. */
+export const ProductRecommendations = ( {
+ items,
+}: {
+ items: ProductRecommendation[];
+} ) => {
+ useEffect( () => {
+ items.forEach( ( item ) =>
+ recordEvent(
+ 'marketplace_suggestion_displayed',
+ getTrackProps( item, item.url )
+ )
+ );
+ }, [ items ] );
+
+ // The Printful card's class lets feed logos fill the icon slot.
+ const stackItems: MarketplaceSuggestionPlacementType[] = items.map(
+ ( item ) => ( {
+ key: item.slug,
+ title: item.title,
+ content: (
+ <>
+ { item.copy }
+ <VisuallyHidden as="span">
+ { __( '(opens in a new tab)', 'woocommerce' ) }
+ </VisuallyHidden>
+ </>
+ ),
+ before: item.icon && (
+ <img
+ className="woocommerce-product-recommendations__icon"
+ src={ item.icon }
+ alt=""
+ width={ 40 }
+ height={ 40 }
+ />
+ ),
+ after: <Icon icon={ external } />,
+ className: 'woocommerce-products-list__item-advert',
+ onClick: () => {
+ recordEvent(
+ 'marketplace_suggestion_clicked',
+ getTrackProps( item, getItemUrl( item.url ) )
+ );
+ window.open(
+ getItemUrl( item.url ),
+ '_blank',
+ 'noopener,noreferrer'
+ );
+ },
+ } )
+ );
+
+ return (
+ <section
+ className="woocommerce-product-recommendations"
+ aria-labelledby={ HEADING_ID }
+ >
+ <div className="woocommerce-product-recommendations__intro">
+ <Text
+ variant="title"
+ as="h3"
+ id={ HEADING_ID }
+ className="woocommerce-task-products__title"
+ >
+ { __(
+ 'Expand your store with more product options',
+ 'woocommerce'
+ ) }
+ </Text>
+ <Text
+ as="p"
+ lineHeight="20px"
+ className="woocommerce-product-recommendations__description"
+ >
+ { __(
+ 'Discover extensions to do more with your store.',
+ 'woocommerce'
+ ) }
+ </Text>
+ </div>
+ <Stack items={ stackItems } showOtherOptions={ false } />
+ <TrackedLink
+ textProps={ {
+ className:
+ 'woocommerce-product-recommendations__browse-all',
+ } }
+ message={ __(
+ // translators: {{Link}} is a placeholder for a html element.
+ 'Browse all the extensions. {{Link}}Visit the WooCommerce Marketplace{{/Link}}',
+ 'woocommerce'
+ ) }
+ eventName="marketplace_suggestion_clicked"
+ eventProperties={ {
+ suggestion_slug: 'products-empty-footer-browse-all',
+ context: 'products-list-empty-footer',
+ product: '',
+ promoted: 'category-woocommerce-extensions',
+ target: BROWSE_ALL_URL,
+ } }
+ targetUrl={ BROWSE_ALL_URL }
+ />
+ </section>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.tsx
index 9a0b45c96bd..1c0905d9015 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.tsx
@@ -11,25 +11,25 @@ import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
-import { ProductType } from './constants';
+import { MarketplaceSuggestionPlacementType, ProductType } from './constants';
import './stack.scss';
import useRecordCompletionTime from '../use-record-completion-time';
type StackProps = StackWithLoadSampleBlurb | StackWithoutText;
+type StackItem = ( ProductType | MarketplaceSuggestionPlacementType ) & {
+ onClick: () => void;
+};
+
type StackWithLoadSampleBlurb = {
- items: ( ProductType & {
- onClick: () => void;
- } )[];
+ items: StackItem[];
onClickLoadSampleProduct: () => void;
showOtherOptions?: boolean;
isTaskListItemClicked?: boolean;
};
type StackWithoutText = {
- items: ( ProductType & {
- onClick: () => void;
- } )[];
+ items: StackItem[];
showOtherOptions: false;
onClickLoadSampleProduct?: () => void;
isTaskListItemClicked?: boolean;
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/index.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/index.tsx
index 88334b46499..0587de38a36 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/index.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/index.tsx
@@ -82,11 +82,37 @@ describe( 'Products', () => {
} ) );
const { queryByText } = render( <Products /> );
- productTypes.forEach( ( { key, title } ) => {
- if ( defaultSurfacedProductTypes.includes( key ) ) {
- expect( queryByText( title ) ).toBeInTheDocument();
- }
- } );
+ productTypes
+ .filter( ( { key } ) =>
+ defaultSurfacedProductTypes.includes( key )
+ )
+ .forEach( ( { title } ) =>
+ expect( queryByText( title ) ).toBeInTheDocument()
+ );
+ } );
+
+ it( 'should replace the marketplace link with recommendations when the setting has items', () => {
+ ( getAdminSetting as jest.Mock ).mockImplementation( ( key ) =>
+ key === 'productsListEmptySuggestions'
+ ? [
+ {
+ slug: 'products-list-empty-body-bookings',
+ product: 'woocommerce-bookings',
+ title: 'Bookings',
+ copy: 'Take bookings.',
+ icon: '',
+ url: 'https://woocommerce.com/products/woocommerce-bookings/',
+ promoted: '',
+ },
+ ]
+ : { profile: { product_types: null } }
+ );
+ const { queryByText } = render( <Products /> );
+
+ expect( queryByText( 'Bookings' ) ).toBeInTheDocument();
+ expect(
+ queryByText( /to enhance your store with additional options/ )
+ ).not.toBeInTheDocument();
} );
it( 'should render digital products type with view more button', () => {
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/product-recommendations.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/product-recommendations.tsx
new file mode 100644
index 00000000000..c642f4ae3f3
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/product-recommendations.tsx
@@ -0,0 +1,119 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { ProductRecommendations } from '../product-recommendations';
+
+jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
+jest.mock( '~/lib/in-app-purchase', () => ( {
+ getInAppPurchaseUrl: ( url: string ) => `${ url }?utm=1`,
+} ) );
+
+const items = [
+ {
+ slug: 'products-list-empty-body-bookings',
+ product: 'woocommerce-bookings',
+ title: 'Bookings',
+ copy: 'Take bookings.',
+ icon: 'https://example.com/bookings.png',
+ url: 'https://woocommerce.com/products/woocommerce-bookings/',
+ promoted: '',
+ },
+ {
+ slug: 'products-list-empty-body-gift-cards',
+ product: 'gift-cards',
+ title: 'Gift Cards',
+ copy: 'Sell gift cards.',
+ icon: '',
+ url: 'https://woocommerce.com/products/gift-cards/',
+ promoted: 'promo',
+ },
+];
+
+const bookingsClick = {
+ suggestion_slug: 'products-list-empty-body-bookings',
+ context: 'products-list-empty-body',
+ product: 'woocommerce-bookings',
+ promoted: '',
+ target: 'https://woocommerce.com/products/woocommerce-bookings/?utm=1',
+};
+
+describe( 'ProductRecommendations', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ window.open = jest.fn();
+ } );
+
+ it( 'tracks each item as displayed', () => {
+ render( <ProductRecommendations items={ items } /> );
+
+ expect( recordEvent ).toHaveBeenCalledTimes( 2 );
+ expect( recordEvent ).toHaveBeenCalledWith(
+ 'marketplace_suggestion_displayed',
+ {
+ suggestion_slug: 'products-list-empty-body-gift-cards',
+ context: 'products-list-empty-body',
+ product: 'gift-cards',
+ promoted: 'promo',
+ target: 'https://woocommerce.com/products/gift-cards/',
+ }
+ );
+ } );
+
+ it( 'tracks a card click and opens the product in a new tab', async () => {
+ render( <ProductRecommendations items={ items } /> );
+ ( recordEvent as jest.Mock ).mockClear();
+
+ await userEvent.click( screen.getByText( 'Bookings' ) );
+
+ expect( recordEvent ).toHaveBeenCalledTimes( 1 );
+ expect( recordEvent ).toHaveBeenCalledWith(
+ 'marketplace_suggestion_clicked',
+ bookingsClick
+ );
+ expect( window.open ).toHaveBeenCalledWith(
+ bookingsClick.target,
+ '_blank',
+ 'noopener,noreferrer'
+ );
+ } );
+
+ it( 'handles Enter on a focused card once', async () => {
+ render( <ProductRecommendations items={ items } /> );
+ ( recordEvent as jest.Mock ).mockClear();
+
+ screen.getByRole( 'menuitem', { name: /Bookings/ } ).focus();
+ await userEvent.keyboard( '{Enter}' );
+
+ expect( recordEvent ).toHaveBeenCalledTimes( 1 );
+ expect( recordEvent ).toHaveBeenCalledWith(
+ 'marketplace_suggestion_clicked',
+ bookingsClick
+ );
+ expect( window.open ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'tracks the browse all link', async () => {
+ render( <ProductRecommendations items={ items } /> );
+ ( recordEvent as jest.Mock ).mockClear();
+
+ await userEvent.click(
+ screen.getByRole( 'link', {
+ name: 'Visit the WooCommerce Marketplace',
+ } )
+ );
+ expect( recordEvent ).toHaveBeenLastCalledWith(
+ 'marketplace_suggestion_clicked',
+ expect.objectContaining( {
+ suggestion_slug: 'products-empty-footer-browse-all',
+ context: 'products-list-empty-footer',
+ } )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/includes/admin/marketplace-suggestions/class-wc-marketplace-suggestions.php b/plugins/woocommerce/includes/admin/marketplace-suggestions/class-wc-marketplace-suggestions.php
index 0b57dc520cd..e33e8a9d6bb 100644
--- a/plugins/woocommerce/includes/admin/marketplace-suggestions/class-wc-marketplace-suggestions.php
+++ b/plugins/woocommerce/includes/admin/marketplace-suggestions/class-wc-marketplace-suggestions.php
@@ -35,6 +35,9 @@ class WC_Marketplace_Suggestions {
// Trigger suggestions fetch on wc-admin dashboard.
add_action( 'current_screen', array( __CLASS__, 'maybe_trigger_suggestions_fetch' ) );
+
+ // Suggestions for the add products task page, when reached from the empty products list.
+ add_filter( 'woocommerce_admin_shared_settings', array( __CLASS__, 'add_products_list_empty_suggestions_setting' ) );
}
/**
@@ -210,6 +213,81 @@ class WC_Marketplace_Suggestions {
return ! empty( $data['suggestions'] ) ? $data['suggestions'] : array();
}
+
+ /**
+ * Get feed suggestions for a display context in feed order (pinned items first), without dismissed items, active plugins or items missing a title or URL.
+ *
+ * @param string $context Display context, for example products-list-empty-body.
+ * @return array[] Feed items.
+ */
+ private static function get_suggestions_for_context( $context ) {
+ $feed = self::get_suggestions_api_data();
+ $dismissed = self::get_dismissed_suggestions();
+
+ // Network-activated plugins are stored separately on multisite; the option is empty on a single site.
+ $active_plugins = array_map(
+ 'dirname',
+ array_merge(
+ (array) get_option( 'active_plugins', array() ),
+ array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) )
+ )
+ );
+
+ $suggestions = array_filter(
+ $feed,
+ function ( $item ) use ( $context, $dismissed, $active_plugins ) {
+ return isset( $item['slug'], $item['context'] )
+ && ! empty( $item['title'] )
+ && '' !== esc_url_raw( $item['url'] ?? '' )
+ && in_array( $context, (array) $item['context'], true )
+ && ! in_array( $item['slug'], $dismissed, true )
+ && ! in_array( $item['product'] ?? '', $active_plugins, true )
+ && ( empty( $item['show-if-active'] ) || array() !== array_intersect( (array) $item['show-if-active'], $active_plugins ) );
+ }
+ );
+
+ $count = (int) ( $feed[0]['recommendations-count'] ?? 5 );
+
+ return array_slice( array_values( $suggestions ), 0, $count > 0 && $count <= 50 ? $count : 5 );
+ }
+
+ /**
+ * Add the productsListEmptySuggestions admin setting on the add products task page when it was reached from the empty products list.
+ *
+ * @internal
+ *
+ * @param mixed $settings Admin shared settings.
+ * @return mixed
+ */
+ public static function add_products_list_empty_suggestions_setting( $settings ) {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only check of the current admin page.
+ $is_target_page = isset( $_GET['page'], $_GET['path'], $_GET['from'] )
+ && 'wc-admin' === $_GET['page']
+ && '/add-product' === $_GET['path']
+ && 'products-list' === $_GET['from'];
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ if ( ! $is_target_page || ! is_array( $settings ) ) {
+ return $settings;
+ }
+
+ $settings['productsListEmptySuggestions'] = array_map(
+ function ( $item ) {
+ return array(
+ 'slug' => $item['slug'],
+ 'product' => $item['product'] ?? '',
+ 'title' => $item['title'] ?? '',
+ 'copy' => $item['copy'] ?? '',
+ 'icon' => esc_url_raw( $item['icon'] ?? '' ),
+ 'url' => esc_url_raw( $item['url'] ?? '' ),
+ 'promoted' => $item['promoted'] ?? '',
+ );
+ },
+ self::get_suggestions_for_context( 'products-list-empty-body' )
+ );
+
+ return $settings;
+ }
}
WC_Marketplace_Suggestions::init();
diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Products.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Products.php
index e040331b017..420a147b0f0 100644
--- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Products.php
+++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Products.php
@@ -330,7 +330,7 @@ class Products extends Task {
return;
}
- wp_safe_redirect( admin_url( 'admin.php?page=wc-admin&path=/add-product&task=products' ) );
+ wp_safe_redirect( admin_url( 'admin.php?page=wc-admin&path=/add-product&task=products&from=products-list' ) );
exit;
}
}
diff --git a/plugins/woocommerce/tests/php/includes/admin/marketplace-suggestions/class-wc-marketplace-suggestions-test.php b/plugins/woocommerce/tests/php/includes/admin/marketplace-suggestions/class-wc-marketplace-suggestions-test.php
new file mode 100644
index 00000000000..02707bce6e6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/marketplace-suggestions/class-wc-marketplace-suggestions-test.php
@@ -0,0 +1,166 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Marketplace_Suggestions feed filtering and the products list empty state setting.
+ */
+class WC_Marketplace_Suggestions_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Request query vars before the test, restored in tearDown().
+ *
+ * @var array
+ */
+ private $original_get;
+
+ /**
+ * Set up an admin user, one active extension and a fresh feed.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Snapshot for tearDown(), not form handling.
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ update_option( 'active_plugins', array( 'woocommerce/woocommerce.php', 'woocommerce-subscriptions/woocommerce-subscriptions.php' ) );
+ $this->set_feed(
+ array(
+ $this->item( 'bookings', 'woocommerce-bookings' ),
+ $this->item( 'subscriptions', 'woocommerce-subscriptions' ),
+ $this->item( 'dismissed', 'product-bundles' ),
+ array_merge( $this->item( 'orders', 'shipstation' ), array( 'context' => 'orders-list-empty-body' ) ),
+ array_merge( $this->item( 'memberships', 'woocommerce-memberships' ), array( 'context' => array( 'products-list-empty-body', 'product-edit-meta-tab-body' ) ) ),
+ array_merge( $this->item( 'deposits', 'woocommerce-deposits' ), array( 'show-if-active' => array( 'woocommerce-bookings' ) ) ),
+ array_merge( $this->item( 'gifting', 'gifting-for-subscriptions' ), array( 'show-if-active' => array( 'woocommerce-subscriptions' ) ) ),
+ array_merge( $this->item( 'untitled', 'plugin-untitled' ), array( 'title' => '' ) ),
+ array_merge( $this->item( 'bad-url', 'plugin-bad-url' ), array( 'url' => 'javascript:alert(1)' ) ),
+ )
+ );
+ update_user_meta( get_current_user_id(), 'wc_marketplace_suggestions_dismissed_suggestions', array( 'dismissed' ) );
+ }
+
+ /**
+ * Restore the request query vars.
+ */
+ public function tearDown(): void {
+ $_GET = $this->original_get;
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should return items for the context, skipping dismissed ones, active plugins, unmet show-if-active rules and items without a title or valid URL.
+ */
+ public function test_filters_suggestions_for_context(): void {
+ $slugs = array_column( $this->get_products_list_suggestions(), 'slug' );
+
+ $this->assertSame( array( 'bookings', 'memberships', 'gifting' ), $slugs );
+ }
+
+ /**
+ * @testdox Should treat network-activated plugins as active.
+ */
+ public function test_treats_network_activated_plugins_as_active(): void {
+ update_site_option( 'active_sitewide_plugins', array( 'woocommerce-bookings/woocommerce-bookings.php' => time() ) );
+
+ $slugs = array_column( $this->get_products_list_suggestions(), 'slug' );
+
+ $this->assertSame( array( 'memberships', 'deposits', 'gifting' ), $slugs );
+ }
+
+ /**
+ * @testdox Should cap results at a recommendations-count between 1 and 50, and at 5 otherwise.
+ *
+ * @testWith [1, 1]
+ * [51, 5]
+ *
+ * @param int $count Feed recommendations-count.
+ * @param int $expected Expected number of suggestions.
+ */
+ public function test_caps_at_recommendations_count( int $count, int $expected ): void {
+ $items = array();
+ for ( $i = 0; $i < 7; $i++ ) {
+ $items[] = $this->item( "item-$i", "plugin-$i" );
+ }
+ $this->set_feed( $items, $count );
+
+ $this->assertCount( $expected, $this->get_products_list_suggestions() );
+ }
+
+ /**
+ * @testdox Should add the setting on the add products page reached from the products list.
+ */
+ public function test_adds_setting_from_products_list(): void {
+ $this->assertSame(
+ array(
+ 'slug' => 'bookings',
+ 'product' => 'woocommerce-bookings',
+ 'title' => 'bookings',
+ 'copy' => 'Copy.',
+ 'icon' => 'https://woocommerce.com/icon.png',
+ 'url' => 'https://woocommerce.com/products/woocommerce-bookings/',
+ 'promoted' => '',
+ ),
+ $this->get_products_list_suggestions()[0]
+ );
+ }
+
+ /**
+ * @testdox Should leave settings unchanged when the page wasn't reached from the products list.
+ */
+ public function test_leaves_settings_on_other_pages(): void {
+ $_GET = array(
+ 'page' => 'wc-admin',
+ 'path' => '/add-product',
+ );
+
+ $this->assertSame( array(), WC_Marketplace_Suggestions::add_products_list_empty_suggestions_setting( array() ) );
+ }
+
+ /**
+ * Get the productsListEmptySuggestions setting as added on the add products page reached from the products list.
+ *
+ * @return array[]
+ */
+ private function get_products_list_suggestions(): array {
+ $_GET = array(
+ 'page' => 'wc-admin',
+ 'path' => '/add-product',
+ 'from' => 'products-list',
+ );
+
+ return WC_Marketplace_Suggestions::add_products_list_empty_suggestions_setting( array() )['productsListEmptySuggestions'];
+ }
+
+ /**
+ * Store a feed in the suggestions option, marked fresh so no update gets queued.
+ *
+ * @param array $items Feed items.
+ * @param int $count Feed recommendations-count.
+ */
+ private function set_feed( array $items, int $count = 5 ): void {
+ update_option(
+ 'woocommerce_marketplace_suggestions',
+ array(
+ 'suggestions' => array_merge( array( array( 'recommendations-count' => $count ) ), $items ),
+ 'updated' => time(),
+ )
+ );
+ }
+
+ /**
+ * Build a products-list-empty-body feed item.
+ *
+ * @param string $slug Suggestion slug.
+ * @param string $product Plugin slug.
+ * @return array
+ */
+ private function item( string $slug, string $product ): array {
+ return array(
+ 'slug' => $slug,
+ 'context' => 'products-list-empty-body',
+ 'product' => $product,
+ 'title' => $slug,
+ 'copy' => 'Copy.',
+ 'icon' => 'https://woocommerce.com/icon.png',
+ 'url' => 'https://woocommerce.com/products/' . $product . '/',
+ );
+ }
+}