Skip to content

Commit c8a4e1b

Browse files
committed
Stats: add a pricing grid page for new sites without a plan
Adds `/stats/pricing/:site` in Odyssey, offering newly connected sites that haven't picked a Stats plan a Free vs Paid comparison before they land on the dashboard. Eligibility defers to `useStatsPurchases`, so a site covered by a bundled plan (Complete, Growth, Business) counts as having Stats and is not shown the grid. Dismissal goes through the existing stats notices endpoint under a new `pricing_grid` id, which defaults to hidden until the server reports it — the same ship-ahead treatment `free_site_upgrade` gets — so the grid can't render without a working dismissal round-trip.
1 parent 669b1bb commit c8a4e1b

9 files changed

Lines changed: 360 additions & 0 deletions

File tree

apps/odyssey-stats/src/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
redirectToDefaultModulePage,
1212
redirectToDefaultWordAdsPeriod,
1313
purchase,
14+
pricingGrid,
1415
emailStats,
1516
emailSummary,
1617
redirectToDaySummary,
@@ -89,6 +90,9 @@ export default function ( pageBase = '/' ) {
8990
statsPage( '/stats/wordads/(.*)', redirectToDefaultWordAdsPeriod );
9091
statsPage( '/stats/ads/(.*)', redirectToDefaultWordAdsPeriod );
9192

93+
// Stat Pricing Grid Page (Odyssey only)
94+
statsPage( '/stats/pricing/:site', pricingGrid );
95+
9296
// Stat Purchase Page
9397
statsPage( '/stats/purchase/:site', purchase );
9498

client/my-sites/stats/controller.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,3 +603,4 @@ export { default as insights } from './pages/insights/controller';
603603
export { default as realtime } from './pages/realtime/controller';
604604
export { default as subscribers } from './pages/subscribers/controller';
605605
export { default as purchase } from './pages/purchase/controller';
606+
export { default as pricingGrid } from './pages/pricing-grid/controller';

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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import AsyncLoad from 'calypso/components/async-load';
2+
import PageLoading from '../shared/page-loading';
3+
import type { Context } from '@automattic/calypso-router';
4+
5+
const loadPricingGrid = () =>
6+
import( /* webpackChunkName: "async-load-calypso-my-sites-stats-pages-pricing-grid" */ '.' );
7+
8+
function pricingGrid( context: Context, next: () => void ) {
9+
context.primary = <AsyncLoad require={ loadPricingGrid } placeholder={ PageLoading } />;
10+
next();
11+
}
12+
13+
export default pricingGrid;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import DocumentHead from 'calypso/components/data/document-head';
2+
import QueryProductsList from 'calypso/components/data/query-products-list';
3+
import QuerySitePurchases from 'calypso/components/data/query-site-purchases';
4+
import Main from 'calypso/my-sites/stats/components/stats-main';
5+
import { STATS_PRODUCT_NAME } from 'calypso/my-sites/stats/constants';
6+
import { useSelector } from 'calypso/state';
7+
import { getSelectedSiteId } from 'calypso/state/ui/selectors';
8+
import PricingGrid from '../../pricing-grid';
9+
import PageViewTracker from '../../stats-page-view-tracker';
10+
11+
export default function StatsPricingGridPage() {
12+
const siteId = useSelector( getSelectedSiteId );
13+
14+
return (
15+
<Main fullWidthLayout>
16+
<DocumentHead title={ STATS_PRODUCT_NAME } />
17+
<PageViewTracker path="/stats/pricing/:site" title="Stats > Pricing" />
18+
{ /* The grid reads both from the store, and hides itself until they resolve. */ }
19+
<QuerySitePurchases siteId={ siteId } />
20+
<QueryProductsList type="jetpack" />
21+
<div className="stats">
22+
<PricingGrid />
23+
</div>
24+
</Main>
25+
);
26+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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. The option is
8+
* only recorded from this date onwards, so an absent value means "connected earlier".
9+
*/
10+
const LAUNCH_DATE = Date.parse( '2026-08-07T00:00:00Z' );
11+
12+
/**
13+
* Whether the pricing grid applies to this site: a newly connected site that hasn't
14+
* picked a Stats plan yet. Bundled plans (Complete, Growth, Business) count as having
15+
* one, which is why this defers to `useStatsPurchases` rather than scanning products.
16+
*/
17+
export default function useIsPricingGridEligible( siteId: number | null ) {
18+
const { hasAnyPlan, isLoading: isLoadingPurchases } = useStatsPurchases( siteId );
19+
20+
// Recorded by Jetpack on first successful connection; reconnects keep the original value.
21+
const connectedAt = useSelector( ( state ) =>
22+
getSiteOption( state, siteId, 'jetpack_site_registered' )
23+
);
24+
25+
// The API serves dates both as unix seconds and as ISO strings depending on the
26+
// field; accept either rather than betting on one and silently never matching.
27+
const connectedAtMs =
28+
typeof connectedAt === 'number'
29+
? connectedAt * 1000
30+
: Date.parse( String( connectedAt ?? '' ) );
31+
const isNewConnection = Number.isFinite( connectedAtMs ) && connectedAtMs >= LAUNCH_DATE;
32+
33+
return {
34+
isEligible: isNewConnection && ! hasAnyPlan,
35+
isLoading: isLoadingPurchases,
36+
};
37+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './pricing-grid';
2+
export { default as useIsPricingGridEligible } from './hooks/use-eligibility';
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { PRODUCT_JETPACK_STATS_YEARLY } from '@automattic/calypso-products';
2+
import { PlanPrice } from '@automattic/components';
3+
import { ProductsList } from '@automattic/data-stores';
4+
import { Button } from '@wordpress/components';
5+
import { useTranslate } from 'i18n-calypso';
6+
import { useMemo } from 'react';
7+
import useNoticeVisibilityMutation from 'calypso/my-sites/stats/hooks/use-notice-visibility-mutation';
8+
import { useNoticeVisibilityQuery } from 'calypso/my-sites/stats/hooks/use-notice-visibility-query';
9+
import { useSelector } from 'calypso/state';
10+
import { getProductBySlug } from 'calypso/state/products-list/selectors';
11+
import { getSiteSlug } from 'calypso/state/sites/selectors';
12+
import { getSelectedSiteId } from 'calypso/state/ui/selectors';
13+
import useIsPricingGridEligible from './hooks/use-eligibility';
14+
import './style.scss';
15+
16+
const NOTICE_ID = 'pricing_grid';
17+
const TRACKS_REFERRER = 'jetpack-stats-pricing-grid';
18+
19+
/** A feature is either present, absent, or present with a qualifier to compare against. */
20+
type FeatureValue = boolean | string;
21+
22+
interface Feature {
23+
name: string;
24+
free: FeatureValue;
25+
paid: FeatureValue;
26+
}
27+
28+
export default function PricingGrid() {
29+
const translate = useTranslate();
30+
const siteId = useSelector( getSelectedSiteId );
31+
const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) );
32+
33+
const { isEligible, isLoading: isLoadingEligibility } = useIsPricingGridEligible( siteId );
34+
35+
// The server only reports this id while a dismissal is *not* in effect, so it reads
36+
// as visibility rather than dismissal.
37+
const { data: isVisible, isLoading: isLoadingVisibility } = useNoticeVisibilityQuery(
38+
siteId,
39+
NOTICE_ID
40+
);
41+
const { mutate: dismiss } = useNoticeVisibilityMutation( siteId, NOTICE_ID, 'dismissed' );
42+
43+
const product = useSelector( ( state ) =>
44+
getProductBySlug( state, PRODUCT_JETPACK_STATS_YEARLY )
45+
) as ProductsList.RawAPIProduct | null;
46+
47+
// The four paid differentiators lead; everything below them is shared by both plans.
48+
const features: Feature[] = useMemo(
49+
() => [
50+
{
51+
name: translate( 'UTM tracking' ),
52+
free: false,
53+
paid: translate( 'Included' ),
54+
},
55+
{
56+
name: translate( 'Device stats' ),
57+
free: false,
58+
paid: translate( 'Included' ),
59+
},
60+
{
61+
name: translate( 'Locations' ),
62+
free: translate( 'Country-level' ),
63+
paid: translate( 'Region and city' ),
64+
},
65+
{
66+
name: translate( 'Priority support' ),
67+
free: false,
68+
paid: translate( 'Included' ),
69+
},
70+
{ name: translate( 'Views and visitors' ), free: true, paid: true },
71+
{ name: translate( 'Top posts and pages' ), free: true, paid: true },
72+
{ name: translate( 'Referrers and clicks' ), free: true, paid: true },
73+
{ name: translate( 'Search terms' ), free: true, paid: true },
74+
{ name: translate( 'Authors' ), free: true, paid: true },
75+
{ name: translate( 'Downloads and video plays' ), free: true, paid: true },
76+
{ name: translate( 'Insights and subscribers' ), free: true, paid: true },
77+
{ name: translate( 'Full history' ), free: true, paid: true },
78+
{ name: translate( 'GDPR-compliant' ), free: true, paid: true },
79+
],
80+
[ translate ]
81+
);
82+
83+
if ( isLoadingEligibility || isLoadingVisibility || ! isEligible || ! isVisible || ! product ) {
84+
return null;
85+
}
86+
87+
// Priced yearly, shown per month: the tier the price belongs to is spelled out below it.
88+
const monthlyPrice = product.cost / 12;
89+
const currencyCode = product.currency_code;
90+
91+
const renderValue = ( value: FeatureValue ) => {
92+
if ( typeof value === 'string' ) {
93+
return value;
94+
}
95+
return (
96+
<>
97+
<span aria-hidden="true" className={ value ? 'is-included' : 'is-excluded' }>
98+
{ value ? '✓' : '✕' }
99+
</span>
100+
<span className="stats-pricing-grid__sr-only">
101+
{ value ? translate( 'Included' ) : translate( 'Not included' ) }
102+
</span>
103+
</>
104+
);
105+
};
106+
107+
const renderFeatures = ( plan: 'free' | 'paid' ) => (
108+
<dl className="stats-pricing-grid__features">
109+
{ features.map( ( feature ) => (
110+
<div className="stats-pricing-grid__feature" key={ feature.name }>
111+
<dt>{ feature.name }</dt>
112+
<dd>{ renderValue( feature[ plan ] ) }</dd>
113+
</div>
114+
) ) }
115+
</dl>
116+
);
117+
118+
return (
119+
<div className="stats-pricing-grid">
120+
<div className="stats-pricing-grid__intro">
121+
<h2>{ translate( 'Choose your Stats plan' ) }</h2>
122+
<p>{ translate( 'Clear, concise, and actionable analysis of your site performance.' ) }</p>
123+
</div>
124+
125+
<div className="stats-pricing-grid__plans">
126+
<section className="stats-pricing-grid__plan is-primary">
127+
<h3>{ translate( 'Paid' ) }</h3>
128+
<PlanPrice rawPrice={ monthlyPrice } currencyCode={ currencyCode } />
129+
<p className="stats-pricing-grid__billing">
130+
{ translate( 'per month, from 10k monthly views, billed yearly' ) }
131+
</p>
132+
<Button
133+
className="stats-pricing-grid__cta"
134+
variant="primary"
135+
__next40pxDefaultSize
136+
href={ `/stats/purchase/${ siteSlug }?from=${ TRACKS_REFERRER }` }
137+
onClick={ () => dismiss() }
138+
>
139+
{ translate( 'Get Paid Stats' ) }
140+
</Button>
141+
{ renderFeatures( 'paid' ) }
142+
</section>
143+
144+
<section className="stats-pricing-grid__plan">
145+
<h3>{ translate( 'Free' ) }</h3>
146+
<PlanPrice rawPrice={ 0 } currencyCode={ currencyCode } />
147+
<p className="stats-pricing-grid__billing">
148+
{ translate( 'Free forever, with the basics covered' ) }
149+
</p>
150+
<Button
151+
className="stats-pricing-grid__cta"
152+
variant="secondary"
153+
__next40pxDefaultSize
154+
href={ `/stats/day/${ siteSlug }` }
155+
onClick={ () => dismiss() }
156+
>
157+
{ translate( 'Start for free' ) }
158+
</Button>
159+
{ renderFeatures( 'free' ) }
160+
</section>
161+
</div>
162+
</div>
163+
);
164+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
@import '@automattic/typography/styles/variables';
2+
@import '@wordpress/base-styles/breakpoints';
3+
@import '@wordpress/base-styles/mixins';
4+
@import 'calypso/assets/stylesheets/shared/mixins/hide-content-accessibly';
5+
6+
.stats-pricing-grid {
7+
margin-block: 32px;
8+
}
9+
10+
.stats-pricing-grid__intro {
11+
text-align: center;
12+
margin-block-end: 32px;
13+
14+
h2 {
15+
font-size: $font-title-medium;
16+
font-weight: 600;
17+
color: var(--color-text);
18+
margin-block-end: 8px;
19+
}
20+
21+
p {
22+
font-size: $font-body;
23+
color: var(--color-text-subtle);
24+
margin: 0;
25+
}
26+
}
27+
28+
.stats-pricing-grid__plans {
29+
display: grid;
30+
grid-template-columns: minmax(0, 1fr);
31+
gap: 24px;
32+
align-items: start;
33+
34+
@include break-medium {
35+
grid-template-columns: repeat(2, minmax(0, 1fr));
36+
}
37+
}
38+
39+
.stats-pricing-grid__plan {
40+
border: 1px solid var(--color-border-subtle);
41+
border-radius: 4px;
42+
padding: 24px;
43+
background: var(--color-surface);
44+
45+
&.is-primary {
46+
border-color: var(--color-primary);
47+
box-shadow: 0 0 0 1px var(--color-primary);
48+
}
49+
50+
h3 {
51+
font-size: $font-title-small;
52+
font-weight: 600;
53+
color: var(--color-text);
54+
margin-block-end: 8px;
55+
}
56+
}
57+
58+
.stats-pricing-grid__cta.components-button {
59+
width: 100%;
60+
justify-content: center;
61+
margin-block-start: 16px;
62+
}
63+
64+
.stats-pricing-grid__billing {
65+
font-size: $font-body-extra-small;
66+
color: var(--color-text-subtle);
67+
margin-block: 8px 0;
68+
min-height: 32px;
69+
}
70+
71+
.stats-pricing-grid__features {
72+
margin-block: 24px 0;
73+
border-block-start: 1px solid var(--color-border-subtle);
74+
}
75+
76+
.stats-pricing-grid__feature {
77+
display: flex;
78+
justify-content: space-between;
79+
align-items: baseline;
80+
gap: 16px;
81+
padding-block: 12px;
82+
border-block-end: 1px solid var(--color-border-subtle);
83+
font-size: $font-body-small;
84+
85+
&:last-child {
86+
border-block-end: none;
87+
}
88+
89+
dt {
90+
color: var(--color-text);
91+
}
92+
93+
dd {
94+
margin: 0;
95+
text-align: end;
96+
color: var(--color-text-subtle);
97+
}
98+
99+
.is-included {
100+
color: var(--color-success);
101+
}
102+
103+
.is-excluded {
104+
color: var(--color-neutral-30);
105+
}
106+
}
107+
108+
.stats-pricing-grid__sr-only {
109+
@include hide-content-accessibly;
110+
}

0 commit comments

Comments
 (0)