Skip to content

Commit a38e1c2

Browse files
authored
Stats: show a pricing grid instead of the dashboard for new sites without a plan (#113366)
* Stats: show a pricing grid instead of the dashboard for new sites without a plan In Odyssey, the traffic page controller is wrapped with a gate: a site that first connected on or after 2026-08-07 and holds no Stats plan sees a Free vs Paid choice in place of the dashboard. Picking either plan reveals the dashboard immediately and records a dismissal through the existing stats notices endpoint (new `pricing_grid` id), so the grid stays away on later visits. The id defaults to hidden until the server reports it — the same ship-ahead treatment `free_site_upgrade` gets — so the grid cannot render without a working dismissal round-trip. The grid replicates the Jetpack Search upsell's PricingTable rendering — DOM structure and styles ported from @automattic/jetpack-components, which Calypso does not ship — using @wordpress/components primitives, with colors mapped to the studio palette tokens the jetpack theme is built from. The connection date reads the `created_at` site option: for a Jetpack site that is the wpcom shadow blog's `wp_blogs.registered`, which matches the first-connection moment when registration created the row; a reused pre-existing row keeps its older date, so the check can only withhold the grid from a new connection, never show it to an established site. Eligibility defers to `useStatsPurchases`, so bundled plans (Complete, Growth, Business) count as having Stats. The date check is synchronous against site options, so established sites never wait on the purchase and notice lookups; the grid component itself stays in an async chunk. Calypso is untouched apart from the shared component directory. * Stats: give the pricing grid top priority among dashboard notices While the grid is undismissed it replaces the dashboard outright, so no other dashboard notice should fire alongside it. * Stats pricing grid: record dismissal reliably from both CTAs The wp-admin shim intercepts anchor clicks inside #wpcom with a jQuery handler registered before React mounts, so an onClick on a link Button never ran and clicking Get Paid Stats never recorded the dismissal. The paid CTA now navigates programmatically after dismissing. The dismissal mutation also never touched the notices query cache, so returning from the purchase page via 'I will do it later' re-rendered the grid from the stale cached visibility. Dismissing now patches the cached notices in place, which the gate re-reads on SPA route changes. Verified end to end in Odyssey on a local Jetpack site: both CTAs POST the dismissal, 'I will do it later' lands on the dashboard, and the choice survives hard reloads. * Stats: keep the pricing grid out of the notices conflict group Conflict suppression runs on the server-reported visibility, not on whether the grid actually displays, and the server reports `pricing_grid` as visible until a dismissal is recorded. Sites that never meet the grid — everything connected before launch, everything holding a plan — would therefore have every other dashboard notice (GDPR consent, purchase-success, the upsells, tier upgrade) permanently suppressed. The grid still trumps every notice, structurally: it replaces the whole dashboard, so StatsNotices never mounts alongside it. * Stats pricing grid: dismiss on a plan decision, not on reaching checkout Clicking Get Paid Stats no longer dismisses the grid — merely reaching the purchase page is not a plan choice, so an abandoned checkout brings the visitor back to the grid to choose again. The decisions that dismiss are 'Start for free' on the grid and 'I will do it later' on the purchase page (both the commercial and PWYW flows, keyed off the pricing-grid referrer). Completing a purchase needs no dismissal: holding a plan makes the site ineligible for the grid. The dismissal (mutation + notices-cache patch) moves into a shared useDismissPricingGrid hook so all call sites stay in sync. * Stats pricing grid: dismiss on 'I will do it later' regardless of referrer Anyone clicking the skip button has seen the full paid pitch and deferred, so the grid shouldn't take over the dashboard afterwards no matter how they reached the purchase page. On sites where the grid never shows the dismissal is a harmless no-op.
1 parent 4a9ec74 commit a38e1c2

10 files changed

Lines changed: 818 additions & 2 deletions

File tree

apps/odyssey-stats/src/routes.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
emailSummary,
1616
redirectToDaySummary,
1717
} from 'calypso/my-sites/stats/controller';
18+
import { withPricingGridGate } from 'calypso/my-sites/stats/pricing-grid/gate';
1819
import config from './lib/config-api';
1920
import { makeLayout, render as clientRender } from './page-middleware/layout';
2021
import 'calypso/my-sites/stats/style.scss';
@@ -61,8 +62,10 @@ export default function ( pageBase = '/' ) {
6162
statsPage( '/stats/subscribers/:site', subscribers );
6263
statsPage( `/stats/subscribers/:period(${ validPeriods })/:site`, subscribers );
6364

64-
// Stat Site Pages
65-
statsPage( `/stats/:period(${ validTrafficPagePeriods })/:site`, site );
65+
// Stat Site Pages. The traffic page doubles as the landing page, so it carries
66+
// the pricing grid gate: eligible new sites see the plan choice instead of the
67+
// dashboard until they pick one.
68+
statsPage( `/stats/:period(${ validTrafficPagePeriods })/:site`, withPricingGridGate( site ) );
6669

6770
// Redirect this to default /stats/day/:module/:site view to
6871
// keep the paths and page view reporting consistent.

client/my-sites/stats/hooks/use-notice-visibility-query.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const DEFAULT_SERVER_NOTICES_VISIBILITY = {
1212
// Defaults to hidden until the server includes it in the notices response,
1313
// so the client can ship ahead of the WPCOM allow-list change.
1414
free_site_upgrade: false,
15+
// The server reports this id (true until a dismissal is in effect), so the
16+
// default only covers request failures: the grid stays hidden rather than
17+
// rendering without a working dismissal round-trip.
18+
pricing_grid: false,
1519
// TODO: Check if the site needs to be upgraded to a higher tier on the back end.
1620
tier_upgrade: true,
1721
gdpr_cookie_consent: false,
@@ -31,6 +35,11 @@ export type NoticeIdType = keyof Notices;
3135

3236
// These notices are mutually exclusive, so if one is active, the other should be hidden.
3337
// The IDs are sorted by priory from high to low.
38+
// `pricing_grid` is deliberately NOT in this group even though the grid trumps every
39+
// notice: it replaces the whole dashboard, so StatsNotices never mounts alongside it
40+
// and no suppression is needed. Listing it here would instead suppress every other
41+
// notice on all the sites that never see the grid (pre-launch sites, sites with
42+
// plans), since the server reports the id as visible until a dismissal is recorded.
3443
const CONFLICT_NOTICE_ID_GROUPS: Record< string, Array< NoticeIdType > > = {
3544
dashboard_notices: [
3645
// Set the highest priority to prevent blocking Stats under any circumstances.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useState } from 'react';
2+
import AsyncLoad from 'calypso/components/async-load';
3+
import QueryProductsList from 'calypso/components/data/query-products-list';
4+
import QuerySitePurchases from 'calypso/components/data/query-site-purchases';
5+
import { useNoticeVisibilityQuery } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query';
6+
import { useSelector } from 'calypso/state';
7+
import { getSelectedSiteId } from 'calypso/state/ui/selectors';
8+
import PageLoading from '../pages/shared/page-loading';
9+
import useIsPricingGridEligible from './hooks/use-eligibility';
10+
import type { Callback } from '@automattic/calypso-router';
11+
import type { ReactNode } from 'react';
12+
13+
const loadPricingGrid = () =>
14+
import(
15+
/* webpackChunkName: "async-load-calypso-my-sites-stats-pricing-grid" */ './pricing-grid'
16+
);
17+
18+
/**
19+
* Replaces the Stats dashboard with the pricing grid for newly connected sites
20+
* that haven't picked a plan yet. Everyone else falls straight through to the
21+
* dashboard: the connection-date check is synchronous against site options, so
22+
* established sites never wait on the purchase and notice lookups this gate
23+
* needs before it can decide.
24+
*/
25+
function PricingGridGate( { children }: { children: ReactNode } ) {
26+
const siteId = useSelector( getSelectedSiteId );
27+
// Choosing a plan swaps the dashboard in immediately; the server-side dismissal
28+
// catches up in the background and keeps the grid away on later visits.
29+
const [ hasChosen, setHasChosen ] = useState( false );
30+
31+
const { isEligible, isNewConnection, isLoading } = useIsPricingGridEligible( siteId );
32+
const { data: isVisible, isLoading: isLoadingVisibility } = useNoticeVisibilityQuery(
33+
siteId,
34+
'pricing_grid',
35+
isNewConnection
36+
);
37+
38+
if ( ! isNewConnection || hasChosen ) {
39+
return <>{ children }</>;
40+
}
41+
42+
return (
43+
<>
44+
<QuerySitePurchases siteId={ siteId } />
45+
{ ( () => {
46+
if ( isLoading || isLoadingVisibility ) {
47+
return PageLoading;
48+
}
49+
if ( ! isEligible || ! isVisible ) {
50+
return children;
51+
}
52+
return (
53+
<>
54+
<QueryProductsList type="jetpack" />
55+
<AsyncLoad
56+
require={ loadPricingGrid }
57+
placeholder={ PageLoading }
58+
onDismiss={ () => setHasChosen( true ) }
59+
/>
60+
</>
61+
);
62+
} )() }
63+
</>
64+
);
65+
}
66+
67+
/**
68+
* Route-controller wrapper: lets the Odyssey routes gate the traffic page
69+
* without pulling JSX into `routes.ts`.
70+
*/
71+
export function withPricingGridGate( controller: Callback ): Callback {
72+
return ( context, next ) => {
73+
controller( context, () => {
74+
context.primary = <PricingGridGate>{ context.primary }</PricingGridGate>;
75+
next();
76+
} );
77+
};
78+
}
79+
80+
export default PricingGridGate;
Lines changed: 17 additions & 0 deletions
Loading
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useQueryClient } from '@tanstack/react-query';
2+
import { useCallback } from 'react';
3+
import useNoticeVisibilityMutation from 'calypso/my-sites/stats/hooks/use-notice-visibility-mutation';
4+
import type { Notices } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query';
5+
6+
/** The `from` value the pricing grid's paid CTA sends to the purchase page. */
7+
export const PRICING_GRID_REFERRER = 'jetpack-stats-pricing-grid';
8+
9+
/**
10+
* Returns a function that records the pricing grid dismissal server-side and
11+
* patches the cached notices in place, so the gate sees the choice on SPA route
12+
* changes without waiting for a refetch.
13+
*
14+
* Only a plan decision dismisses: "Start for free" on the grid, or "I will do
15+
* it later" on the purchase page. Merely reaching the purchase page does not —
16+
* an abandoned checkout brings the visitor back to the grid.
17+
*/
18+
export default function useDismissPricingGrid( siteId: number | null ) {
19+
const queryClient = useQueryClient();
20+
const { mutate: recordDismissal } = useNoticeVisibilityMutation(
21+
siteId,
22+
'pricing_grid',
23+
'dismissed'
24+
);
25+
26+
return useCallback( () => {
27+
recordDismissal();
28+
queryClient.setQueryData(
29+
[ 'stats', 'notices-visibility', 'raw', siteId ],
30+
( notices: Notices | undefined ) => notices && { ...notices, pricing_grid: false }
31+
);
32+
}, [ recordDismissal, queryClient, siteId ] );
33+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useSelector } from 'calypso/state';
2+
import { getSiteOption } from 'calypso/state/sites/selectors';
3+
import useStatsPurchases from '../../hooks/use-stats-purchases';
4+
5+
/**
6+
* Sites connected before the pricing grid shipped were never offered this choice, so
7+
* showing it to them now would be a regression rather than onboarding.
8+
*/
9+
const LAUNCH_DATE = Date.parse( '2026-08-07T00:00:00Z' );
10+
11+
/**
12+
* Whether the pricing grid applies to this site: a newly connected site that hasn't
13+
* picked a Stats plan yet. Bundled plans (Complete, Growth, Business) count as having
14+
* one, which is why this defers to `useStatsPurchases` rather than scanning products.
15+
*/
16+
export default function useIsPricingGridEligible( siteId: number | null ) {
17+
const { hasAnyPlan, isLoading: isLoadingPurchases } = useStatsPurchases( siteId );
18+
19+
// `created_at` is the wpcom shadow blog's `wp_blogs.registered` — the closest thing
20+
// to a first-connection date the sites payload exposes. It matches the connection
21+
// moment when registration created the row, but a reused pre-existing row keeps its
22+
// older date and reconnects never update it, so this check can only withhold the
23+
// grid from a genuinely new connection — never show it to an established site.
24+
const connectedAt = useSelector( ( state ) => getSiteOption( state, siteId, 'created_at' ) );
25+
26+
// The API serves dates both as unix seconds and as ISO strings depending on the
27+
// field; accept either rather than betting on one and silently never matching.
28+
const connectedAtMs =
29+
typeof connectedAt === 'number'
30+
? connectedAt * 1000
31+
: Date.parse( String( connectedAt ?? '' ) );
32+
const isNewConnection = Number.isFinite( connectedAtMs ) && connectedAtMs >= LAUNCH_DATE;
33+
34+
return {
35+
isEligible: isNewConnection && ! hasAnyPlan,
36+
isNewConnection,
37+
// The date check needs no fetch, so only newly connected sites ever wait.
38+
isLoading: isNewConnection && isLoadingPurchases,
39+
};
40+
}

0 commit comments

Comments
 (0)