Commit 7d1d2248020 for woocommerce
commit 7d1d22480201b31f296989d8deb54fe1cf784448
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Thu Oct 29 10:33:46 2026 +0100
Show free trial copy instead of the price on Marketplace cards (#70290)
* Show free trial copy instead of the price on Marketplace cards
When WooCommerce.com sends a positive trial length (free_trial_length_days on
search, freeTrialLengthDays on featured), the card shows "14 day free trial"
in place of the price, sale price and billing period. Missing or non-positive
values keep the price as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Clarify the free trial comment and translator note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Check the hidden price details with separate assertions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Cover Add to Store taking precedence over the trial copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the free and freemium labels when a trial length is sent
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-1064-in-app-free-trial-copy b/plugins/woocommerce/changelog/woomkt-1064-in-app-free-trial-copy
new file mode 100644
index 00000000000..a210cde0364
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomkt-1064-in-app-free-trial-copy
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Show the free trial length instead of the price on Marketplace product cards when WooCommerce.com offers a trial.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card-footer.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card-footer.tsx
index d038e9df7a5..cf652216bb0 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card-footer.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card-footer.tsx
@@ -2,7 +2,7 @@
* External dependencies
*/
import { Button, Icon } from '@wordpress/components';
-import { __, sprintf } from '@wordpress/i18n';
+import { __, _n, sprintf } from '@wordpress/i18n';
import { useContext } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
import { navigateTo, getNewPath } from '@woocommerce/navigation';
@@ -76,7 +76,31 @@ function ProductCardFooter( props: { product: Product } ) {
return currencyFormats[ currencyCode ] || '%s';
};
+ // The trial replaces only a paid price, so free and freemium cards keep their label. Anything but a positive day count means no trial.
+ const freeTrialDays = product.freeTrialLengthDays;
+ const freeTrialLabel =
+ product.price > 0 &&
+ product.freemium_type !== 'primary' &&
+ typeof freeTrialDays === 'number' &&
+ Number.isInteger( freeTrialDays ) &&
+ freeTrialDays > 0
+ ? sprintf(
+ /* translators: %d: free trial length in days. English uses the same form for singular and plural. */
+ _n(
+ '%d day free trial',
+ '%d day free trial',
+ freeTrialDays,
+ 'woocommerce'
+ ),
+ freeTrialDays
+ )
+ : '';
+
function getPriceLabel(): string {
+ if ( freeTrialLabel ) {
+ return freeTrialLabel;
+ }
+
if ( product.price === 0 ) {
return __( 'Free download', 'woocommerce' );
}
@@ -134,7 +158,7 @@ function ProductCardFooter( props: { product: Product } ) {
}
function getBillingText(): string {
- if ( product.freemium_type === 'primary' ) {
+ if ( freeTrialLabel || product.freemium_type === 'primary' ) {
return '';
}
@@ -146,6 +170,10 @@ function ProductCardFooter( props: { product: Product } ) {
}
const getReaderPriceLabel = () => {
+ if ( freeTrialLabel ) {
+ return freeTrialLabel;
+ }
+
if ( product.isOnSale ) {
return sprintf(
//translators: %1$s is the sale price of the product, %2$s is the regular price of the product, %3$s is the billing period
@@ -212,7 +240,7 @@ function ProductCardFooter( props: { product: Product } ) {
<span aria-hidden>{ getPriceLabel() }</span>
</span>
- { product.isOnSale && (
+ { product.isOnSale && ! freeTrialLabel && (
<span
className="woocommerce-marketplace__product-card__on-sale"
aria-hidden
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card-footer.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card-footer.test.tsx
index 966f719cda1..020562d838d 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card-footer.test.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card-footer.test.tsx
@@ -3,6 +3,7 @@
*/
import { render } from '@testing-library/react';
import React from 'react';
+import { useUser } from '@woocommerce/data';
jest.mock( '@woocommerce/navigation', () => ( {
getNewPath: jest.fn( () => '/new-path' ),
@@ -111,3 +112,109 @@ describe( 'ProductCardFooter rating', () => {
).toBeNull();
} );
} );
+
+describe( 'ProductCardFooter free trial', () => {
+ const paidProduct: Product = {
+ ...product,
+ price: 79,
+ regularPrice: 99,
+ isOnSale: true,
+ billingPeriod: 'year',
+ billingPeriodInterval: 1,
+ };
+
+ function renderPaidFooter( freeTrialLengthDays?: number | null ) {
+ return render(
+ <MarketplaceContext.Provider value={ context }>
+ <ProductCardFooter
+ product={ { ...paidProduct, freeTrialLengthDays } }
+ />
+ </MarketplaceContext.Provider>
+ );
+ }
+
+ it( 'shows the trial copy instead of the price, sale price and billing period', () => {
+ const { container, getAllByText } = renderPaidFooter( 14 );
+
+ // Once visible, once for screen readers.
+ expect( getAllByText( '14 day free trial' ) ).toHaveLength( 2 );
+ expect( container.textContent ).not.toContain( '$' );
+ expect( container.textContent ).not.toContain( 'annually' );
+ } );
+
+ it.each( [ undefined, null, 0, -7, 1.5 ] )(
+ 'shows the price when the trial length is %p',
+ ( freeTrialLengthDays ) => {
+ const { container, getByText } =
+ renderPaidFooter( freeTrialLengthDays );
+
+ expect( container.textContent ).not.toContain( 'free trial' );
+ expect( getByText( '$79' ) ).toBeInTheDocument();
+ expect( getByText( '$99' ) ).toBeInTheDocument();
+ expect( getByText( 'annually' ) ).toBeInTheDocument();
+ }
+ );
+
+ it.each( [
+ [ 'Free download', { price: 0, isOnSale: false } ],
+ [ 'Free plan available', { freemium_type: 'primary' } ],
+ ] as const )( 'keeps "%s" over the trial copy', ( label, overrides ) => {
+ const { getAllByText, queryByText } = render(
+ <MarketplaceContext.Provider value={ context }>
+ <ProductCardFooter
+ product={ {
+ ...paidProduct,
+ ...overrides,
+ freeTrialLengthDays: 14,
+ } }
+ />
+ </MarketplaceContext.Provider>
+ );
+
+ expect( getAllByText( label ).length ).toBeGreaterThan( 0 );
+ expect( queryByText( '14 day free trial' ) ).toBeNull();
+ } );
+
+ it( 'shows "Installed" over the trial copy', () => {
+ const { getByText, queryByText } = render(
+ <MarketplaceContext.Provider
+ value={ { ...context, isProductInstalled: () => true } }
+ >
+ <ProductCardFooter
+ product={ {
+ ...paidProduct,
+ slug: 'test-extension',
+ freeTrialLengthDays: 14,
+ } }
+ />
+ </MarketplaceContext.Provider>
+ );
+
+ expect( getByText( 'Installed' ) ).toBeInTheDocument();
+ expect( queryByText( '14 day free trial' ) ).toBeNull();
+ } );
+
+ it( 'shows "Add to Store" over the trial copy', () => {
+ ( useUser as jest.Mock ).mockReturnValueOnce( {
+ user: { id: 1 },
+ currentUserCan: () => true,
+ } );
+
+ const { getByRole, queryByText } = render(
+ <MarketplaceContext.Provider value={ context }>
+ <ProductCardFooter
+ product={ {
+ ...paidProduct,
+ isInstallable: true,
+ freeTrialLengthDays: 14,
+ } }
+ />
+ </MarketplaceContext.Provider>
+ );
+
+ expect(
+ getByRole( 'button', { name: 'Add to Store' } )
+ ).toBeInTheDocument();
+ expect( queryByText( '14 day free trial' ) ).toBeNull();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/product-list-content.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/product-list-content.tsx
index a5872d5d12d..2be344389ad 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/product-list-content.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/product-list-content.tsx
@@ -160,6 +160,8 @@ export default function ProductListContent( props: {
isOnSale: product.isOnSale,
regularPrice: product.regularPrice,
hasQualityBadge: product.hasQualityBadge,
+ freeTrialLengthDays:
+ product.freeTrialLengthDays,
} }
tracksData={ {
position: index + 1,
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-list/types.ts b/plugins/woocommerce/client/admin/client/marketplace/components/product-list/types.ts
index cc10014ce3d..8eea5f10b70 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-list/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-list/types.ts
@@ -32,6 +32,7 @@ export type SearchAPIProductType = {
is_on_sale: boolean;
regular_price: number;
has_quality_badge?: boolean;
+ free_trial_length_days?: number | null;
};
export interface Product {
@@ -66,6 +67,7 @@ export interface Product {
isOnSale: boolean;
regularPrice: number;
hasQualityBadge?: boolean;
+ freeTrialLengthDays?: number | null;
}
export interface ProductTracksData {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
index d63543e00d3..b928fd46feb 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
@@ -178,6 +178,8 @@ async function fetchSearchResults(
product.billing_period_interval,
currency: product.currency,
hasQualityBadge: product.has_quality_badge ?? false,
+ freeTrialLengthDays:
+ product.free_trial_length_days ?? null,
};
}
);