Skip to content

Commit ba5e9d2

Browse files
committed
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.
1 parent 669b1bb commit ba5e9d2

7 files changed

Lines changed: 757 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ 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+
// Same ship-ahead treatment: the pricing grid stays hidden until the server
16+
// reports this id, so it can't render without a working dismissal round-trip.
17+
pricing_grid: false,
1518
// TODO: Check if the site needs to be upgraded to a higher tier on the back end.
1619
tier_upgrade: true,
1720
gdpr_cookie_consent: false,
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: 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)