Skip to content
7 changes: 5 additions & 2 deletions apps/odyssey-stats/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
emailSummary,
redirectToDaySummary,
} from 'calypso/my-sites/stats/controller';
import { withPricingGridGate } from 'calypso/my-sites/stats/pricing-grid/gate';
import config from './lib/config-api';
import { makeLayout, render as clientRender } from './page-middleware/layout';
import 'calypso/my-sites/stats/style.scss';
Expand Down Expand Up @@ -61,8 +62,10 @@ export default function ( pageBase = '/' ) {
statsPage( '/stats/subscribers/:site', subscribers );
statsPage( `/stats/subscribers/:period(${ validPeriods })/:site`, subscribers );

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

// Redirect this to default /stats/day/:module/:site view to
// keep the paths and page view reporting consistent.
Expand Down
9 changes: 9 additions & 0 deletions client/my-sites/stats/hooks/use-notice-visibility-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const DEFAULT_SERVER_NOTICES_VISIBILITY = {
// Defaults to hidden until the server includes it in the notices response,
// so the client can ship ahead of the WPCOM allow-list change.
free_site_upgrade: false,
// The server reports this id (true until a dismissal is in effect), so the
// default only covers request failures: the grid stays hidden rather than
// rendering without a working dismissal round-trip.
pricing_grid: false,
// TODO: Check if the site needs to be upgraded to a higher tier on the back end.
tier_upgrade: true,
gdpr_cookie_consent: false,
Expand All @@ -31,6 +35,11 @@ export type NoticeIdType = keyof Notices;

// These notices are mutually exclusive, so if one is active, the other should be hidden.
// The IDs are sorted by priory from high to low.
// `pricing_grid` is deliberately NOT in this group even though the grid trumps every
// notice: it replaces the whole dashboard, so StatsNotices never mounts alongside it
// and no suppression is needed. Listing it here would instead suppress every other
// notice on all the sites that never see the grid (pre-launch sites, sites with
// plans), since the server reports the id as visible until a dismissal is recorded.
const CONFLICT_NOTICE_ID_GROUPS: Record< string, Array< NoticeIdType > > = {
dashboard_notices: [
// Set the highest priority to prevent blocking Stats under any circumstances.
Expand Down
80 changes: 80 additions & 0 deletions client/my-sites/stats/pricing-grid/gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useState } from 'react';
import AsyncLoad from 'calypso/components/async-load';
import QueryProductsList from 'calypso/components/data/query-products-list';
import QuerySitePurchases from 'calypso/components/data/query-site-purchases';
import { useNoticeVisibilityQuery } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query';
import { useSelector } from 'calypso/state';
import { getSelectedSiteId } from 'calypso/state/ui/selectors';
import PageLoading from '../pages/shared/page-loading';
import useIsPricingGridEligible from './hooks/use-eligibility';
import type { Callback } from '@automattic/calypso-router';
import type { ReactNode } from 'react';

const loadPricingGrid = () =>
import(
/* webpackChunkName: "async-load-calypso-my-sites-stats-pricing-grid" */ './pricing-grid'
);

/**
* Replaces the Stats dashboard with the pricing grid for newly connected sites
* that haven't picked a plan yet. Everyone else falls straight through to the
* dashboard: the connection-date check is synchronous against site options, so
* established sites never wait on the purchase and notice lookups this gate
* needs before it can decide.
*/
function PricingGridGate( { children }: { children: ReactNode } ) {
const siteId = useSelector( getSelectedSiteId );
// Choosing a plan swaps the dashboard in immediately; the server-side dismissal
// catches up in the background and keeps the grid away on later visits.
const [ hasChosen, setHasChosen ] = useState( false );

const { isEligible, isNewConnection, isLoading } = useIsPricingGridEligible( siteId );
const { data: isVisible, isLoading: isLoadingVisibility } = useNoticeVisibilityQuery(
siteId,
'pricing_grid',
isNewConnection
);

if ( ! isNewConnection || hasChosen ) {
return <>{ children }</>;
}

return (
<>
<QuerySitePurchases siteId={ siteId } />
{ ( () => {
if ( isLoading || isLoadingVisibility ) {
return PageLoading;
}
if ( ! isEligible || ! isVisible ) {
return children;
}
return (
<>
<QueryProductsList type="jetpack" />
<AsyncLoad
require={ loadPricingGrid }
placeholder={ PageLoading }
onDismiss={ () => setHasChosen( true ) }
/>
</>
);
} )() }
</>
);
}

/**
* Route-controller wrapper: lets the Odyssey routes gate the traffic page
* without pulling JSX into `routes.ts`.
*/
export function withPricingGridGate( controller: Callback ): Callback {
return ( context, next ) => {
controller( context, () => {
context.primary = <PricingGridGate>{ context.primary }</PricingGridGate>;
next();
} );
};
}

export default PricingGridGate;
17 changes: 17 additions & 0 deletions client/my-sites/stats/pricing-grid/gradient.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import useNoticeVisibilityMutation from 'calypso/my-sites/stats/hooks/use-notice-visibility-mutation';
import type { Notices } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query';

/** The `from` value the pricing grid's paid CTA sends to the purchase page. */
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.
*
* 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(
siteId,
'pricing_grid',
'dismissed'
);

return useCallback( () => {
recordDismissal();
queryClient.setQueryData(
[ 'stats', 'notices-visibility', 'raw', siteId ],
( notices: Notices | undefined ) => notices && { ...notices, pricing_grid: false }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the purchase page was reached without first rendering the pricing-grid gate, this raw notices query may not exist in the cache. In that case the updater returns undefined, so setQueryData does not create a local pricing_grid: false value. Both “I will do it later” handlers then navigate after a fixed 250 ms without awaiting the dismissal mutation; on a slow request, the newly mounted gate can issue its GET before the POST finishes and show the grid again. Could we either seed a normalized notices value when notices is undefined, or expose/await mutateAsync before navigating?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid on both counts — fixed in 57d1978 on #113372. The dismiss hook now returns the mutation promise (mutateAsync), and both skip handlers await it before navigating (still navigating if the request fails), so the gate's GET on the destination route can never read the pre-dismissal state. That closes the empty-cache case and also a variant this comment didn't cover: with the cache present but stale, the gate's background refetch could overwrite the patch with the pre-POST server value.

Went with awaiting rather than seeding: the raw notices entry is the full object shared by every notices consumer, so fabricating a normalized value on miss would feed StatsNotices made-up server state (e.g. the default tier_upgrade: true) as fresh data. The grid's own "Start for free" stays unawaited since its reveal is same-route (gate local state + the cache patch, which is guaranteed present there).

);
}, [ recordDismissal, queryClient, siteId ] );
}
40 changes: 40 additions & 0 deletions client/my-sites/stats/pricing-grid/hooks/use-eligibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useSelector } from 'calypso/state';
import { getSiteOption } from 'calypso/state/sites/selectors';
import useStatsPurchases from '../../hooks/use-stats-purchases';

/**
* Sites connected before the pricing grid shipped were never offered this choice, so
* showing it to them now would be a regression rather than onboarding.
*/
const LAUNCH_DATE = Date.parse( '2026-08-07T00:00:00Z' );

/**
* Whether the pricing grid applies to this site: a newly connected site that hasn't
* picked a Stats plan yet. Bundled plans (Complete, Growth, Business) count as having
* one, which is why this defers to `useStatsPurchases` rather than scanning products.
*/
export default function useIsPricingGridEligible( siteId: number | null ) {
const { hasAnyPlan, isLoading: isLoadingPurchases } = useStatsPurchases( siteId );

// `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
// older date and reconnects never update it, so this check can only withhold the
// grid from a genuinely new connection — never show it to an established site.
const connectedAt = useSelector( ( state ) => getSiteOption( state, siteId, 'created_at' ) );

// The API serves dates both as unix seconds and as ISO strings depending on the
// field; accept either rather than betting on one and silently never matching.
const connectedAtMs =
typeof connectedAt === 'number'
? connectedAt * 1000
: Date.parse( String( connectedAt ?? '' ) );
const isNewConnection = Number.isFinite( connectedAtMs ) && connectedAtMs >= LAUNCH_DATE;

return {
isEligible: isNewConnection && ! hasAnyPlan,
isNewConnection,
// The date check needs no fetch, so only newly connected sites ever wait.
isLoading: isNewConnection && isLoadingPurchases,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PURCHASES_SITE_FETCH_FAILED sets hasLoadedSitePurchasesFromServer: true and leaves the purchase list empty, so a failed lookup reads here as isLoading: false with hasAnyPlan: false. A newly connected site that already holds a plan would then get the grid instead of its dashboard. Could this fall back to ineligible when the purchases request errors?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc90308 on #113372 — a purchases fetch error now reads as "plan state unknown" and eligibility falls back to the dashboard instead of treating the empty list as "no plan".

};
}
Loading
Loading