Commit b422f5609dc for woocommerce

commit b422f5609dc8c92d8e5512c5e932499bce226063
Author: Cem Ünalan <raicem@users.noreply.github.com>
Date:   Wed Oct 7 21:19:01 2026 +0300

    Run the Marketplace product preview as an A/B test (#69141)

    * WOOMKT-830: Run the marketplace product preview as an A/B test

    * WOOMKT-830: Add changelog entry for the product preview A/B test

    * Simplify the product preview experiment wiring

    * WOOMKT-830: Address review feedback on the product preview A/B test

    Stores without an assignment follow the product_previews setting again, so the preview can be turned on after the test without a release. The small cards in install modals stay out of the experiment, preview links are tagged before sanitizing, and protocol-relative WooCommerce.com links are tagged too.

    * WOOMKT-830: Drop the cached preview variation when tracking is off

    useExperiment keeps returning a cached assignment after a store turns
    tracking off, so opted-out stores still got the preview and sent
    utm_term to WooCommerce.com. They now follow the product_previews
    setting like other stores without an assignment.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/WOOMKT-830 b/plugins/woocommerce/changelog/WOOMKT-830
new file mode 100644
index 00000000000..c2f31c23800
--- /dev/null
+++ b/plugins/woocommerce/changelog/WOOMKT-830
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Show a product preview when clicking an extension in the in-app Marketplace, for stores in an A/B test.
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 fbf93835341..d390ddb5bc9 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
@@ -26,6 +26,10 @@ import {
 import { appendURLParams } from '../../utils/functions';
 import ProductPreviewModal from '../product-preview-modal/product-preview-modal';
 import { MarketplaceContext } from '../../contexts/marketplace-context';
+import {
+	PRODUCT_PREVIEW_TREATMENT,
+	PRODUCT_PREVIEW_VARIATION_PARAM,
+} from '../../utils/product-preview-experiment';
 export interface ProductCardProps {
 	type?: string;
 	product?: Product;
@@ -79,11 +83,17 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {

 	const isTheme = type === ProductType.theme;
 	const isBusinessService = type === ProductType.businessService;
-	const { iamSettings } = useContext( MarketplaceContext );
+	const { iamSettings, productPreviewVariation } =
+		useContext( MarketplaceContext );
+	// Themes, business services and the small cards in install modals never show the preview, so their clicks stay out of the experiment.
+	const canShowPreview = ! isTheme && ! isBusinessService && ! props.small;
+	const previewVariation = canShowPreview ? productPreviewVariation : null;
+	// Stores outside the experiment follow the WooCommerce.com setting.
 	const shouldShowPreview =
-		iamSettings?.product_previews === 'modal' &&
-		! isTheme &&
-		! isBusinessService;
+		canShowPreview &&
+		( previewVariation
+			? previewVariation === PRODUCT_PREVIEW_TREATMENT
+			: iamSettings?.product_previews === 'modal' );
 	// Business service cards use a layout with no slot for the badge.
 	const showsQualityBadge =
 		! isLoading &&
@@ -185,12 +195,19 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
 	);

 	const productUrl = () => {
+		const params: Array< [ string, string ] > = [];
 		if ( query.ref ) {
-			return appendURLParams( product.url, [
-				[ 'utm_content', query.ref ],
+			params.push( [ 'utm_content', query.ref ] );
+		}
+		if ( previewVariation ) {
+			params.push( [
+				PRODUCT_PREVIEW_VARIATION_PARAM,
+				previewVariation,
 			] );
 		}
-		return product.url;
+		return params.length
+			? appendURLParams( product.url, params )
+			: product.url;
 	};

 	const handleCardClick = () => {
@@ -200,6 +217,7 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
 			vendor: product.vendorName,
 			product_type: type,
 			has_quality_badge: showsQualityBadge,
+			...( previewVariation && { preview_variation: previewVariation } ),
 		} );

 		if ( shouldShowPreview ) {
@@ -431,6 +449,7 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
 					onClose={ handleModalClose }
 					productId={ product.id as number }
 					triggerRef={ linkRef }
+					variation={ previewVariation }
 				/>
 			) }
 		</>
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 faac2fdf821..24320f305a9 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
@@ -32,7 +32,7 @@ jest.mock( '@woocommerce/data', () => ( {
 /**
  * Internal dependencies
  */
-import ProductCard from '../product-card';
+import ProductCard, { ProductCardProps } from '../product-card';
 import { MarketplaceContext } from '../../../contexts/marketplace-context';
 import { MarketplaceContextType } from '../../../contexts/types';
 import {
@@ -51,6 +51,7 @@ const context = {
 			tooltip: 'Verified against WooCommerce standards.',
 		},
 	},
+	productPreviewVariation: null,
 } as unknown as MarketplaceContextType;

 const product: Product = {
@@ -76,7 +77,8 @@ const product: Product = {
 function renderCard(
 	cardType: ProductCardType,
 	productOverrides: Partial< Product > = {},
-	contextOverrides: Partial< MarketplaceContextType > = {}
+	contextOverrides: Partial< MarketplaceContextType > = {},
+	cardProps: Partial< ProductCardProps > = {}
 ) {
 	return render(
 		<MarketplaceContext.Provider
@@ -87,6 +89,7 @@ function renderCard(
 				product={ { ...product, ...productOverrides } }
 				cardType={ cardType }
 				tracksData={ { position: 1 } }
+				{ ...cardProps }
 			/>
 		</MarketplaceContext.Provider>
 	);
@@ -290,16 +293,107 @@ describe( 'ProductCard click tracking', () => {
 	} );

 	it( 'records the click once when the card opens a preview modal', () => {
-		clickCard(
-			{},
-			{
-				iamSettings: {
-					...context.iamSettings,
-					product_previews: 'modal',
-				},
-			}
-		);
+		clickCard( {}, { productPreviewVariation: 'treatment' } );

 		expect( cardClickEvents() ).toHaveLength( 1 );
 	} );
 } );
+
+describe( 'ProductCard product preview experiment', () => {
+	const productUrl = 'https://woocommerce.com/products/test-extension/';
+
+	beforeEach( () => {
+		jest.mocked( queueRecordEvent ).mockClear();
+	} );
+
+	it.each( [
+		{
+			name: 'opens the preview instead of the product page in the treatment arm',
+			variation: 'treatment',
+			opensProductPage: false,
+			tag: 'treatment',
+		},
+		{
+			name: 'opens the product page in the control arm',
+			variation: 'control',
+			opensProductPage: true,
+			tag: 'control',
+		},
+		{
+			name: 'leaves the card untagged until the assignment loads',
+			variation: null,
+			opensProductPage: true,
+			tag: undefined,
+		},
+		{
+			name: 'follows the WooCommerce.com setting when the store has no assignment',
+			variation: null,
+			productPreviews: 'modal' as const,
+			opensProductPage: false,
+			tag: undefined,
+		},
+		{
+			name: 'ignores the WooCommerce.com setting in the control arm',
+			variation: 'control',
+			productPreviews: 'modal' as const,
+			opensProductPage: true,
+			tag: 'control',
+		},
+		{
+			name: 'keeps business service cards out of the experiment',
+			variation: 'treatment',
+			type: ProductType.businessService,
+			opensProductPage: true,
+			tag: undefined,
+		},
+		{
+			name: 'keeps theme cards out of the experiment',
+			variation: 'treatment',
+			cardProps: { type: ProductType.theme },
+			opensProductPage: true,
+			tag: undefined,
+		},
+		{
+			name: 'keeps the small cards in install modals out of the experiment',
+			variation: 'treatment',
+			cardProps: { small: true },
+			opensProductPage: true,
+			tag: undefined,
+		},
+	] )(
+		'$name',
+		( {
+			variation,
+			productPreviews,
+			type = ProductType.extension,
+			cardProps = {},
+			opensProductPage,
+			tag,
+		} ) => {
+			const view = renderCard(
+				ProductCardType.regular,
+				{ url: productUrl, type },
+				{
+					productPreviewVariation: variation,
+					iamSettings: {
+						...context.iamSettings,
+						product_previews: productPreviews,
+					},
+				},
+				cardProps
+			);
+			const link = view.getByRole( 'link' );
+
+			// fireEvent returns false when the click's default action was prevented.
+			expect( fireEvent.click( link ) ).toBe( opensProductPage );
+			expect(
+				jest.mocked( queueRecordEvent ).mock.calls[ 0 ][ 1 ]
+					?.preview_variation
+			).toBe( tag );
+			expect( link ).toHaveAttribute(
+				'href',
+				tag ? `${ productUrl }?utm_term=${ tag }` : productUrl
+			);
+		}
+	);
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-preview-modal/product-preview-modal.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-preview-modal/product-preview-modal.tsx
index e24fc2c5128..a9c5342cd0e 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-preview-modal/product-preview-modal.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-preview-modal/product-preview-modal.tsx
@@ -10,6 +10,7 @@ import { Spinner } from '@woocommerce/components';
  * Internal dependencies
  */
 import { fetchProductPreview } from '../../utils/functions';
+import { addVariationToPreviewLinks } from '../../utils/product-preview-experiment';
 import sanitizeHtmlExtended from '~/lib/sanitize-html/sanitize-html-extended.js';
 import sanitizeHtmlConfig from './product-preview-sanitize-html-config';
 import './product-preview-modal.scss';
@@ -20,6 +21,7 @@ interface ProductPreviewModalProps {
 	productIcon: string;
 	productId: number;
 	triggerRef: React.RefObject< HTMLAnchorElement | null >;
+	variation?: string | null;
 	onOpen?: () => void;
 	onClose?: ( closeType?: string ) => void;
 }
@@ -30,6 +32,7 @@ export default function ProductPreviewModal( {
 	productIcon,
 	productId,
 	triggerRef,
+	variation,
 	onOpen,
 	onClose,
 }: ProductPreviewModalProps ) {
@@ -99,8 +102,11 @@ export default function ProductPreviewModal( {
 					throw new Error( 'Invalid preview data structure' );
 				}

+				const previewHtml = variation
+					? addVariationToPreviewLinks( previewData.html, variation )
+					: previewData.html;
 				const sanitizedHtmlObj = sanitizeHtmlExtended(
-					previewData.html,
+					previewHtml,
 					sanitizeHtmlConfig
 				) as { __html?: string };
 				const sanitizedHtml = sanitizedHtmlObj?.__html ?? '';
@@ -123,7 +129,7 @@ export default function ProductPreviewModal( {
 		if ( onOpen ) {
 			onOpen();
 		}
-	}, [ onOpen, productId ] );
+	}, [ onOpen, productId, variation ] );

 	const productHeader = (
 		<div className="woocommerce-marketplace__product-preview-modal__header">
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx b/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
index 9865c0f9de3..12d2018e27a 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
@@ -7,6 +7,7 @@ import {
 	useCallback,
 	createContext,
 } from '@wordpress/element';
+import { useExperiment } from '@woocommerce/explat';

 /**
  * Internal dependencies
@@ -18,6 +19,7 @@ import {
 	MARKETPLACE_HOST,
 	MARKETPLACE_IAM_SETTINGS_API_PATH,
 } from '../components/constants';
+import { PRODUCT_PREVIEW_EXPERIMENT_NAME } from '../utils/product-preview-experiment';

 // Create storage utils with 24h expiration
 const iamSettingsStorage = createStorageUtils< {
@@ -39,6 +41,7 @@ export const MarketplaceContext = createContext< MarketplaceContextType >( {
 	},
 	setSearchResultsCount: () => {},
 	iamSettings: {},
+	productPreviewVariation: null,
 } );

 export function MarketplaceContextProvider( props: {
@@ -47,6 +50,14 @@ export function MarketplaceContextProvider( props: {
 	const [ isLoading, setIsLoading ] = useState( true );
 	const [ selectedTab, setSelectedTab ] = useState( '' );
 	const [ iamSettings, setIamSettings ] = useState( {} );
+	// Loaded up front so product cards already know whether to open the preview modal when clicked.
+	const [ , productPreviewAssignment ] = useExperiment(
+		PRODUCT_PREVIEW_EXPERIMENT_NAME
+	);
+	// useExperiment keeps returning a cached assignment after tracking is turned off, so drop it here.
+	const productPreviewVariation = window.wcTracks?.isEnabled
+		? productPreviewAssignment?.variationName ?? null
+		: null;
 	const [ installedPlugins, setInstalledPlugins ] = useState< string[] >(
 		[]
 	);
@@ -136,6 +147,7 @@ export function MarketplaceContextProvider( props: {
 		searchResultsCount,
 		setSearchResultsCount,
 		iamSettings,
+		productPreviewVariation,
 	};

 	return (
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/test/marketplace-context.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/contexts/test/marketplace-context.test.tsx
new file mode 100644
index 00000000000..3203b1071aa
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/test/marketplace-context.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { useContext } from '@wordpress/element';
+import { useExperiment } from '@woocommerce/explat';
+
+jest.mock( '@woocommerce/explat', () => ( {
+	useExperiment: jest.fn(),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import {
+	MarketplaceContext,
+	MarketplaceContextProvider,
+} from '../marketplace-context';
+
+function VariationProbe() {
+	const { productPreviewVariation } = useContext( MarketplaceContext );
+	return <span>{ productPreviewVariation ?? 'none' }</span>;
+}
+
+describe( 'MarketplaceContextProvider', () => {
+	const originalTracks = window.wcTracks;
+
+	beforeEach( () => {
+		( useExperiment as jest.Mock ).mockReturnValue( [
+			false,
+			{ variationName: 'treatment' },
+		] );
+		global.fetch = jest.fn( () => new Promise( () => {} ) ) as jest.Mock;
+	} );
+
+	afterEach( () => {
+		window.wcTracks = originalTracks;
+	} );
+
+	it( 'passes the assigned variation when tracking is on', () => {
+		window.wcTracks = { ...originalTracks, isEnabled: true };
+		const { getByText } = render(
+			<MarketplaceContextProvider>
+				<VariationProbe />
+			</MarketplaceContextProvider>
+		);
+		expect( getByText( 'treatment' ) ).toBeInTheDocument();
+	} );
+
+	it( 'ignores a cached variation once tracking is turned off', () => {
+		window.wcTracks = { ...originalTracks, isEnabled: false };
+		const { getByText } = render(
+			<MarketplaceContextProvider>
+				<VariationProbe />
+			</MarketplaceContextProvider>
+		);
+		expect( getByText( 'none' ) ).toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
index 3e2560eb0f4..818b69bdc86 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
@@ -41,6 +41,11 @@ export type MarketplaceContextType = {
 			docs_url?: string;
 		};
 	};
+	/**
+	 * Variation of the product preview experiment, or null when it hasn't
+	 * loaded or the store isn't in the experiment.
+	 */
+	productPreviewVariation: string | null;
 };

 export type SubscriptionsContextType = {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/product-preview-experiment.ts b/plugins/woocommerce/client/admin/client/marketplace/utils/product-preview-experiment.ts
new file mode 100644
index 00000000000..79044a63f5b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/product-preview-experiment.ts
@@ -0,0 +1,51 @@
+/**
+ * Internal dependencies
+ */
+import { MARKETPLACE_HOST } from '../components/constants';
+
+export const PRODUCT_PREVIEW_EXPERIMENT_NAME =
+	'woocommerce_marketplace_product_preview_202610';
+
+export const PRODUCT_PREVIEW_TREATMENT = 'treatment';
+
+/**
+ * WooCommerce.com order attribution stores this parameter on orders, so
+ * purchases can be split by variation.
+ */
+export const PRODUCT_PREVIEW_VARIATION_PARAM = 'utm_term';
+
+/**
+ * Adds the variation to the WooCommerce.com links in the preview HTML, such as
+ * "Buy now" and "See more". Other links are left as they are.
+ */
+export function addVariationToPreviewLinks(
+	html: string,
+	variation: string
+): string {
+	const { hostname: marketplaceHostname } = new URL( MARKETPLACE_HOST );
+	const template = document.createElement( 'template' );
+	template.innerHTML = html;
+
+	template.content
+		.querySelectorAll< HTMLAnchorElement >( 'a[href]' )
+		.forEach( ( link ) => {
+			let url: URL;
+			try {
+				url = new URL(
+					link.getAttribute( 'href' ) ?? '',
+					document.baseURI
+				);
+			} catch {
+				return;
+			}
+
+			if ( url.hostname !== marketplaceHostname ) {
+				return;
+			}
+
+			url.searchParams.set( PRODUCT_PREVIEW_VARIATION_PARAM, variation );
+			link.setAttribute( 'href', url.toString() );
+		} );
+
+	return template.innerHTML;
+}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/test/product-preview-experiment.test.ts b/plugins/woocommerce/client/admin/client/marketplace/utils/test/product-preview-experiment.test.ts
new file mode 100644
index 00000000000..3a1abe955b0
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/test/product-preview-experiment.test.ts
@@ -0,0 +1,53 @@
+/**
+ * Internal dependencies
+ */
+import { addVariationToPreviewLinks } from '../product-preview-experiment';
+
+describe( 'addVariationToPreviewLinks', () => {
+	function hrefs( html: string ) {
+		const template = document.createElement( 'template' );
+		template.innerHTML = html;
+		return Array.from( template.content.querySelectorAll( 'a' ) ).map(
+			( link ) => link.getAttribute( 'href' )
+		);
+	}
+
+	it( 'adds the variation to WooCommerce.com links and keeps their parameters', () => {
+		const html = addVariationToPreviewLinks(
+			'<p>Intro</p>' +
+				'<a href="https://woocommerce.com/cart/?add-to-cart=1&utm_source=previewscreen" data-iam-tracks="buy_now">Buy now</a>' +
+				'<a href="https://woocommerce.com/products/test/?utm_source=previewscreen">See more</a>',
+			'treatment'
+		);
+
+		expect( hrefs( html ) ).toEqual( [
+			'https://woocommerce.com/cart/?add-to-cart=1&utm_source=previewscreen&utm_term=treatment',
+			'https://woocommerce.com/products/test/?utm_source=previewscreen&utm_term=treatment',
+		] );
+		expect( html ).toContain( '<p>Intro</p>' );
+		expect( html ).toContain( 'data-iam-tracks="buy_now"' );
+	} );
+
+	it( 'adds the variation to protocol-relative WooCommerce.com links', () => {
+		const html = addVariationToPreviewLinks(
+			'<a href="//woocommerce.com/cart/">Buy now</a>',
+			'treatment'
+		);
+
+		expect( hrefs( html ) ).toEqual( [
+			`${ window.location.protocol }//woocommerce.com/cart/?utm_term=treatment`,
+		] );
+	} );
+
+	it( 'leaves links to other sites and relative links unchanged', () => {
+		const html = addVariationToPreviewLinks(
+			'<a href="https://example.com/docs/">Docs</a><a href="#details">Details</a>',
+			'treatment'
+		);
+
+		expect( hrefs( html ) ).toEqual( [
+			'https://example.com/docs/',
+			'#details',
+		] );
+	} );
+} );