Skip to content
12 changes: 11 additions & 1 deletion client/my-sites/stats/hooks/use-notice-visibility-mutation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import wpcom from 'calypso/lib/wp';
import { Notices } from './use-notice-visibility-query';

Expand Down Expand Up @@ -27,10 +27,20 @@ export default function useNoticeVisibilityMutation(
status: Status = 'dismissed',
postponedFor = 0
) {
const queryClient = useQueryClient();
return useMutation( {
mutationKey: [ 'stats', 'notices-visibility', 'raw', siteId ],
mutationFn: () => dismissNotice( siteId, noticeId, status, postponedFor ),
retry: 1,
retryDelay: 3 * 1000, // 3 seconds
// Mutation-level rather than per-call: query-core only runs mutate()'s own
// callbacks while the calling component is still mounted, and consumers may
// navigate away before the retry succeeds. Not awaited, so callers chaining
// on mutateAsync() don't also wait out the refetch.
onSuccess: () => {
queryClient.invalidateQueries( {
queryKey: [ 'stats', 'notices-visibility', 'raw', siteId ],
} );
},
} );
}
108 changes: 108 additions & 0 deletions client/my-sites/stats/pricing-grid/hooks/test/use-eligibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @jest-environment jsdom
*/

import { renderHook } from '@testing-library/react';
import { getPurchasesError } from 'calypso/state/purchases/selectors';
import { getSiteOption } from 'calypso/state/sites/selectors';
import useStatsPurchases from '../../../hooks/use-stats-purchases';
import useIsPricingGridEligible from '../use-eligibility';

jest.mock( 'calypso/state', () => ( {
useSelector: jest.fn( ( selector ) => selector( {} ) ),
} ) );
jest.mock( 'calypso/state/purchases/selectors' );
jest.mock( 'calypso/state/sites/selectors' );
jest.mock( '../../../hooks/use-stats-purchases' );

const SITE_ID = 123;
const POST_LAUNCH_DATE = '2026-09-01T00:00:00+00:00';
const PRE_LAUNCH_DATE = '2026-01-01T00:00:00+00:00';

function mockSite( {
connectedAt = POST_LAUNCH_DATE as string | number | null,
hasAnyPlan = false,
isLoadingPurchases = false,
purchasesError = null as object | null,
} = {} ) {
( getSiteOption as jest.Mock ).mockReturnValue( connectedAt );
( getPurchasesError as unknown as jest.Mock ).mockReturnValue( purchasesError );
( useStatsPurchases as jest.Mock ).mockReturnValue( {
hasAnyPlan,
isLoading: isLoadingPurchases,
} );
}

describe( 'useIsPricingGridEligible', () => {
beforeEach( () => {
jest.clearAllMocks();
} );

it( 'is eligible for a newly connected site without a plan', () => {
mockSite();

const { result } = renderHook( () => useIsPricingGridEligible( SITE_ID ) );

expect( result.current ).toEqual( {
isEligible: true,
isNewConnection: true,
isLoading: false,
} );
} );

it( 'is not eligible for a site connected before launch', () => {
mockSite( { connectedAt: PRE_LAUNCH_DATE } );

const { result } = renderHook( () => useIsPricingGridEligible( SITE_ID ) );

expect( result.current.isEligible ).toBe( false );
expect( result.current.isNewConnection ).toBe( false );
} );

it( 'is not eligible when the site already holds a Stats plan', () => {
mockSite( { hasAnyPlan: true } );

const { result } = renderHook( () => useIsPricingGridEligible( SITE_ID ) );

expect( result.current.isEligible ).toBe( false );
} );

it( 'fails closed when the purchases fetch errored', () => {
mockSite( { purchasesError: { error: 'fetch_failed' } } );

const { result } = renderHook( () => useIsPricingGridEligible( SITE_ID ) );

expect( result.current.isEligible ).toBe( false );
} );

it( 'accepts a connection date given as unix seconds', () => {
mockSite( { connectedAt: Date.parse( POST_LAUNCH_DATE ) / 1000 } );

const { result } = renderHook( () => useIsPricingGridEligible( SITE_ID ) );

expect( result.current.isNewConnection ).toBe( true );
} );

it( 'is not eligible when the connection date is missing', () => {
mockSite( { connectedAt: null } );

const { result } = renderHook( () => useIsPricingGridEligible( SITE_ID ) );

expect( result.current.isEligible ).toBe( false );
expect( result.current.isLoading ).toBe( false );
} );

it( 'only reports loading for newly connected sites', () => {
mockSite( { isLoadingPurchases: true } );

const { result } = renderHook( () => useIsPricingGridEligible( SITE_ID ) );

expect( result.current.isLoading ).toBe( true );

mockSite( { connectedAt: PRE_LAUNCH_DATE, isLoadingPurchases: true } );

const { result: preLaunch } = renderHook( () => useIsPricingGridEligible( SITE_ID ) );

expect( preLaunch.current.isLoading ).toBe( false );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,27 @@ export const PRICING_GRID_REFERRER = 'jetpack-stats-pricing-grid';
/**
* Returns a function that records the pricing grid dismissal server-side and
* patches the cached notices in place, so the gate sees the choice on SPA route
* changes without waiting for a refetch.
* changes without waiting for a refetch. The patch can't cover every path — the
* raw notices entry may be absent when the purchase page was reached directly —
* but the round-trip needn't be awaited: the mutation invalidates the notices
* query on success, so a gate that fetched pre-dismissal state self-corrects
* once the POST lands. A rejection (after the mutation's own retry) is
* swallowed here — a dismissal that ultimately fails just re-shows the grid.
*
* Only a plan decision dismisses: "Start for free" on the grid, or "I will do
* it later" on the purchase page. Merely reaching the purchase page does not —
* an abandoned checkout brings the visitor back to the grid.
*/
export default function useDismissPricingGrid( siteId: number | null ) {
const queryClient = useQueryClient();
const { mutate: recordDismissal } = useNoticeVisibilityMutation(
const { mutateAsync: recordDismissal } = useNoticeVisibilityMutation(
siteId,
'pricing_grid',
'dismissed'
);

return useCallback( () => {
recordDismissal();
recordDismissal().catch( () => null );
queryClient.setQueryData(
[ 'stats', 'notices-visibility', 'raw', siteId ],
( notices: Notices | undefined ) => notices && { ...notices, pricing_grid: false }
Expand Down
12 changes: 11 additions & 1 deletion client/my-sites/stats/pricing-grid/hooks/use-eligibility.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useSelector } from 'calypso/state';
import { getPurchasesError } from 'calypso/state/purchases/selectors';
import { getSiteOption } from 'calypso/state/sites/selectors';
import useStatsPurchases from '../../hooks/use-stats-purchases';

Expand All @@ -16,6 +17,15 @@ const LAUNCH_DATE = Date.parse( '2026-08-07T00:00:00Z' );
export default function useIsPricingGridEligible( siteId: number | null ) {
const { hasAnyPlan, isLoading: isLoadingPurchases } = useStatsPurchases( siteId );

// A failed purchases fetch reads as "loaded, no plan" upstream (FETCH_FAILED marks
// the store loaded with an empty list), which would show the grid to a site that
// does hold a plan. Treat the error as "plan state unknown" and fall back to the
// dashboard instead. The field is shared across all purchases actions (user-level
// fetches and removals set it too, and it only clears on the next successful
// fetch), so this occasionally withholds the grid on an unrelated error — still
// fail-closed, never the reverse.
const purchasesError = useSelector( getPurchasesError );

// `created_at` is the wpcom shadow blog's `wp_blogs.registered` — the closest thing
// to a first-connection date the sites payload exposes. It matches the connection
// moment when registration created the row, but a reused pre-existing row keeps its
Expand All @@ -32,7 +42,7 @@ export default function useIsPricingGridEligible( siteId: number | null ) {
const isNewConnection = Number.isFinite( connectedAtMs ) && connectedAtMs >= LAUNCH_DATE;

return {
isEligible: isNewConnection && ! hasAnyPlan,
isEligible: isNewConnection && ! hasAnyPlan && ! purchasesError,
isNewConnection,
// The date check needs no fetch, so only newly connected sites ever wait.
isLoading: isNewConnection && isLoadingPurchases,
Expand Down
34 changes: 26 additions & 8 deletions client/my-sites/stats/pricing-grid/pricing-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { createInterpolateElement } from '@wordpress/element';
import { Icon, check, closeSmall } from '@wordpress/icons';
import clsx from 'clsx';
import { useTranslate } from 'i18n-calypso';
import { useEffect } from 'react';
import DocumentHead from 'calypso/components/data/document-head';
import Main from 'calypso/my-sites/stats/components/stats-main';
import { STATS_PRODUCT_NAME } from 'calypso/my-sites/stats/constants';
import { trackStatsAnalyticsEvent } from 'calypso/my-sites/stats/utils';
import { useSelector } from 'calypso/state';
import { getProductBySlug } from 'calypso/state/products-list/selectors';
import { getSiteSlug } from 'calypso/state/sites/selectors';
Expand Down Expand Up @@ -51,21 +53,26 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) {
const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) );
const dismissPricingGrid = useDismissPricingGrid( siteId );

useEffect( () => {
trackStatsAnalyticsEvent( 'stats_pricing_grid_view', { blog_id: siteId } );
Comment thread
kangzj marked this conversation as resolved.
}, [ siteId ] );

const product = useSelector( ( state ) =>
getProductBySlug( state, PRODUCT_JETPACK_STATS_YEARLY )
) as ProductsList.RawAPIProduct | null;

const includedLabel = String( translate( 'Included' ) );
// The four paid differentiators lead; everything below them is shared by both plans.
// The four paid differentiators lead (bolded via `strong`, with the default
// Included / feature-name labels so the mobile fallback still names the feature);
// everything below them is shared by both plans.
const features: Feature[] = [
{
name: String( translate( 'UTM tracking' ) ),
paid: { isIncluded: true, label: includedLabel, strong: true },
paid: { isIncluded: true, strong: true },
free: { isIncluded: false },
},
{
name: String( translate( 'Device stats' ) ),
paid: { isIncluded: true, label: includedLabel, strong: true },
paid: { isIncluded: true, strong: true },
free: { isIncluded: false },
},
{
Expand All @@ -75,7 +82,7 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) {
},
{
name: String( translate( 'Priority support' ) ),
paid: { isIncluded: true, label: includedLabel, strong: true },
paid: { isIncluded: true, strong: true },
free: { isIncluded: false },
},
{
Expand Down Expand Up @@ -130,6 +137,10 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) {

// Starting for free is a plan choice: record the dismissal and reveal the dashboard.
const startForFree = () => {
trackStatsAnalyticsEvent( 'stats_pricing_grid_free_cta_clicked', {
blog_id: siteId,
cta: 'free',
} );
dismissPricingGrid();
onDismiss?.();
};
Expand All @@ -140,21 +151,28 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) {
// programmatically rather than via href so the link also works under Odyssey's
// hashbang routing when the wp-admin click shim doesn't apply (e.g. middle-click).
const goToPurchase = () => {
trackStatsAnalyticsEvent( 'stats_pricing_grid_paid_cta_clicked', {
blog_id: siteId,
cta: 'paid',
} );
page( `/stats/purchase/${ siteSlug }?from=${ PRICING_GRID_REFERRER }` );
};

const renderPrice = ( value: number, currency: string, hidePriceFraction: boolean ) => {
const { symbol, integer, fraction } = getCurrencyObject( value, currency );
const { symbol, symbolPosition, integer, fraction } = getCurrencyObject( value, currency );
const showPriceFraction = ! hidePriceFraction || ! fraction.endsWith( '00' );
// Some locales put the currency symbol after the amount (e.g. de-DE EUR).
const symbolElement = <sup className="stats-pricing-grid__price-symbol">{ symbol }</sup>;
return (
<p className="stats-pricing-grid__price">
<sup className="stats-pricing-grid__price-symbol">{ symbol }</sup>
{ symbolPosition === 'before' && symbolElement }
{ integer }
{ showPriceFraction && (
<sup className="stats-pricing-grid__price-fraction">
<strong>{ fraction }</strong>
</sup>
) }
{ symbolPosition === 'after' && symbolElement }
</p>
);
};
Expand Down Expand Up @@ -292,7 +310,7 @@ export default function PricingGrid( { onDismiss }: PricingGridProps ) {
{ createInterpolateElement(
String(
translate(
'By clicking <strong>%(paid)s</strong> or <strong>%(free)s</strong>, you agree to our <tosLink>Terms of Service</tosLink> and to <shareDetailsLink>sync your sites data</shareDetailsLink> with us.',
'By clicking <strong>%(paid)s</strong> or <strong>%(free)s</strong>, you agree to our <tosLink>Terms of Service</tosLink> and to <shareDetailsLink>sync your sites data</shareDetailsLink> with us.',
{ args: { paid: paidLabel, free: freeLabel } }
)
),
Expand Down
5 changes: 3 additions & 2 deletions client/my-sites/stats/pricing-grid/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,11 @@
line-height: 24px;

// Same threshold the is-viewport-large class flips at (WP `large` breakpoint).
// Diverges from the jetpack component's `nowrap; overflow: hidden` here: this is
// the Terms of Service disclosure, and locales that run longer than English (DE,
// PT) must wrap rather than lose the end of the sentence.
@media (min-width: 960px) {
padding-left: var(--padding);
padding-right: var(--padding);
white-space: nowrap;
overflow: hidden;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const PersonalPurchase = ( {
const event_from = isOdysseyStats ? 'jetpack_odyssey' : 'calypso';
recordTracksEvent( `${ event_from }_stats_purchase_flow_skip_button_clicked`, {
blog_id: siteId,
from,
} );

// Skipping is the visitor's plan decision — made on a page that shows the full
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ const StatsCommercialPurchase = ( {
const event_from = isOdysseyStats ? 'jetpack_odyssey' : 'calypso';
recordTracksEvent( `${ event_from }_stats_purchase_commercial_skip_button_clicked`, {
blog_id: siteId,
from,
} );

// Skipping is the visitor's plan decision — made on a page that shows the full
Comment thread
kangzj marked this conversation as resolved.
Expand Down
Loading