Commit 9067cc4d18d for woocommerce
commit 9067cc4d18da420a1be4d643c684e8fd19fd2d09
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Fri Sep 18 14:29:38 2026 +0200
Add has_quality_badge to the marketplace product card click event (#68857)
The card click event could not tell whether the card the merchant clicked
showed the quality badge, so there was no way to compare click-through on
badged and unbadged cards.
`getVisibleQualityBadge()` now holds the decision the badge chip was making
inline, and both the chip and the click event read it, so the property can
never claim a badge the card did not render. Business service cards use a
layout with no slot for the badge, so they always report false.
The card link recorded the same event from two branches of its click
handler; both now go through `handleCardClick`.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wccom-2941-product-card-click-quality-badge b/plugins/woocommerce/changelog/wccom-2941-product-card-click-quality-badge
new file mode 100644
index 00000000000..3c961ae00a7
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-2941-product-card-click-quality-badge
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Marketplace: report whether a product card shows the quality badge in the card click tracking event.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
index 57e881c4166..fbf93835341 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
@@ -14,7 +14,9 @@ import { useState, useContext, useRef } from '@wordpress/element';
*/
import './product-card.scss';
import ProductCardFooter from './product-card-footer';
-import QualityBadge from '../quality-badge/quality-badge';
+import QualityBadge, {
+ getVisibleQualityBadge,
+} from '../quality-badge/quality-badge';
import {
Product,
ProductCardType,
@@ -82,6 +84,11 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
iamSettings?.product_previews === 'modal' &&
! isTheme &&
! isBusinessService;
+ // Business service cards use a layout with no slot for the badge.
+ const showsQualityBadge =
+ ! isLoading &&
+ ! isBusinessService &&
+ getVisibleQualityBadge( props.product, iamSettings ) !== null;
const showVendor = ! isCompact && ! isLoading;
const showVendorLoading = ! isCompact && isLoading;
@@ -192,6 +199,7 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
product_name: product.title,
vendor: product.vendorName,
product_type: type,
+ has_quality_badge: showsQualityBadge,
} );
if ( shouldShowPreview ) {
@@ -247,15 +255,8 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
onClick={ ( e ) => {
if ( shouldShowPreview ) {
e.preventDefault();
- handleCardClick();
- } else {
- recordTracksEvent( 'marketplace_product_card_clicked', {
- product_id: product.id,
- product_name: product.title,
- vendor: product.vendorName,
- product_type: type,
- } );
}
+ handleCardClick();
} }
>
{ isLoading ? ' ' : product.title }
@@ -298,7 +299,7 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
};
const qualityBadge =
- ! isLoading && props.product ? (
+ showsQualityBadge && props.product ? (
<QualityBadge product={ props.product } />
) : null;
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card.test.tsx
index e7f02c2038c..faac2fdf821 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card.test.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card.test.tsx
@@ -1,8 +1,9 @@
/**
* External dependencies
*/
-import { render } from '@testing-library/react';
+import { fireEvent, render } from '@testing-library/react';
import React from 'react';
+import { queueRecordEvent } from '@woocommerce/tracks';
jest.mock( '@woocommerce/navigation', () => ( {
getNewPath: jest.fn( () => '/new-path' ),
@@ -15,6 +16,12 @@ jest.mock( '@woocommerce/tracks', () => ( {
queueRecordEvent: jest.fn(),
} ) );
+// The preview modal loads its own data; these tests only care about the card.
+jest.mock(
+ '../../product-preview-modal/product-preview-modal',
+ () => () => null
+);
+
jest.mock( '@woocommerce/data', () => ( {
useUser: jest.fn( () => ( {
user: null,
@@ -66,12 +73,18 @@ const product: Product = {
hasQualityBadge: true,
};
-function renderCard( cardType: ProductCardType ) {
+function renderCard(
+ cardType: ProductCardType,
+ productOverrides: Partial< Product > = {},
+ contextOverrides: Partial< MarketplaceContextType > = {}
+) {
return render(
- <MarketplaceContext.Provider value={ context }>
+ <MarketplaceContext.Provider
+ value={ { ...context, ...contextOverrides } }
+ >
<ProductCard
type={ ProductType.extension }
- product={ product }
+ product={ { ...product, ...productOverrides } }
cardType={ cardType }
tracksData={ { position: 1 } }
/>
@@ -122,16 +135,9 @@ describe( 'ProductCard quality badge placement', () => {
} );
it( 'renders no badge when the product does not have one', () => {
- const { container } = render(
- <MarketplaceContext.Provider value={ context }>
- <ProductCard
- type={ ProductType.extension }
- product={ { ...product, hasQualityBadge: false } }
- cardType={ ProductCardType.compact }
- tracksData={ {} }
- />
- </MarketplaceContext.Provider>
- );
+ const { container } = renderCard( ProductCardType.compact, {
+ hasQualityBadge: false,
+ } );
expect( getBadge( container ) ).toBeNull();
} );
@@ -150,16 +156,7 @@ describe( 'ProductCard sponsored label', () => {
cardType: ProductCardType,
overrides: Partial< Product > = {}
) {
- return render(
- <MarketplaceContext.Provider value={ context }>
- <ProductCard
- type={ ProductType.extension }
- product={ { ...sponsoredProduct, ...overrides } }
- cardType={ cardType }
- tracksData={ {} }
- />
- </MarketplaceContext.Provider>
- );
+ return renderCard( cardType, { ...sponsoredProduct, ...overrides } );
}
function getSponsoredLabel( view: ReturnType< typeof render > ) {
@@ -218,3 +215,91 @@ describe( 'ProductCard sponsored label', () => {
).toBeNull();
} );
} );
+
+describe( 'ProductCard click tracking', () => {
+ beforeEach( () => {
+ jest.mocked( queueRecordEvent ).mockClear();
+ } );
+
+ function clickCard(
+ productOverrides: Partial< Product > = {},
+ contextOverrides: Partial< MarketplaceContextType > = {}
+ ) {
+ const view = renderCard(
+ ProductCardType.regular,
+ productOverrides,
+ contextOverrides
+ );
+
+ fireEvent.click( view.getByRole( 'link' ) );
+ }
+
+ // A click records exactly one event, and the mock is cleared before each test.
+ function cardClickEvents() {
+ return jest.mocked( queueRecordEvent ).mock.calls;
+ }
+
+ it( 'reports the badge when the card shows one', () => {
+ clickCard();
+
+ expect( cardClickEvents() ).toEqual( [
+ [
+ 'marketplace_product_card_clicked',
+ expect.objectContaining( {
+ product_id: 1,
+ has_quality_badge: true,
+ } ),
+ ],
+ ] );
+ } );
+
+ it( 'reports no badge when the product does not have one', () => {
+ clickCard( { hasQualityBadge: false } );
+
+ expect( cardClickEvents()[ 0 ][ 1 ] ).toMatchObject( {
+ has_quality_badge: false,
+ } );
+ } );
+
+ it( 'reports no badge when the badge is disabled for the store', () => {
+ clickCard(
+ {},
+ {
+ iamSettings: {
+ ...context.iamSettings,
+ quality_badge: {
+ ...context.iamSettings.quality_badge,
+ enabled: false,
+ },
+ },
+ }
+ );
+
+ expect( cardClickEvents()[ 0 ][ 1 ] ).toMatchObject( {
+ has_quality_badge: false,
+ } );
+ } );
+
+ it( 'reports no badge on business service cards, which cannot show one', () => {
+ clickCard( { type: ProductType.businessService } );
+
+ expect( cardClickEvents()[ 0 ][ 1 ] ).toMatchObject( {
+ product_type: ProductType.businessService,
+ has_quality_badge: false,
+ } );
+ } );
+
+ it( 'records the click once when the card opens a preview modal', () => {
+ clickCard(
+ {},
+ {
+ iamSettings: {
+ ...context.iamSettings,
+ product_previews: 'modal',
+ },
+ }
+ );
+
+ expect( cardClickEvents() ).toHaveLength( 1 );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.tsx
index 9a13da0e5de..2e5944f7ced 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.tsx
@@ -13,6 +13,7 @@ import { recordEvent } from '@woocommerce/tracks';
import './quality-badge.scss';
import { Product } from '../product-list/types';
import { MarketplaceContext } from '../../contexts/marketplace-context';
+import { MarketplaceContextType } from '../../contexts/types';
/**
* Seal-with-checkmark icon, kept identical to the badge icon on WooCommerce.com.
@@ -147,6 +148,24 @@ export function QualityBadgePopover( props: {
);
}
+/**
+ * The badge settings when a product card shows the badge, otherwise null.
+ * Product card tracking reads this too, so an event never reports a badge the
+ * merchant could not see.
+ */
+export function getVisibleQualityBadge(
+ product: Product | undefined,
+ iamSettings: MarketplaceContextType[ 'iamSettings' ] | undefined
+) {
+ const badge = iamSettings?.quality_badge;
+
+ if ( ! badge?.enabled || ! badge.label || ! product?.hasQualityBadge ) {
+ return null;
+ }
+
+ return { ...badge, label: badge.label };
+}
+
/**
* Quality badge chip shown on product cards. Whether it renders and with what
* copy is fully driven by the WooCommerce.com API: the per-product flag comes
@@ -158,13 +177,9 @@ export default function QualityBadge( props: { product: Product } ) {
const [ isOpen, setIsOpen ] = useState( false );
const [ anchor, setAnchor ] = useState< HTMLButtonElement | null >( null );
- const badge = iamSettings?.quality_badge;
+ const badge = getVisibleQualityBadge( props.product, iamSettings );
- if (
- ! badge?.enabled ||
- ! badge.label ||
- ! props.product.hasQualityBadge
- ) {
+ if ( ! badge ) {
return null;
}