-
Notifications
You must be signed in to change notification settings - Fork 2k
Stats: show a pricing grid instead of the dashboard for new sites without a plan #113366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b1f14b2
038d6f5
78f1ccf
c8d1acf
4801666
88be92f
b8b2172
14156b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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 } | ||
| ); | ||
| }, [ recordDismissal, queryClient, siteId ] ); | ||
| } | ||
| 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, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }; | ||
| } | ||
There was a problem hiding this comment.
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, sosetQueryDatadoes not create a localpricing_grid: falsevalue. 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 whennoticesis undefined, or expose/awaitmutateAsyncbefore navigating?There was a problem hiding this comment.
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
StatsNoticesmade-up server state (e.g. the defaulttier_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).