Skip to content

Commit e49d13c

Browse files
committed
Add Stats pricing grid with dedicated route
- Create pricing grid component with eligibility, pricing data, and dismissal logic - Add dedicated route at /stats/pricing/:site with async loading - Use existing notices endpoint for server-side dismissal tracking - Fetch localized pricing from public WordPress.com products API - Implement responsive design with dark mode support - Use PlanPrice component for currency formatting - Support for both connected and pre-connection sites (via Jetpack integration) Files: - client/my-sites/stats/pricing-grid/: Main component and hooks - client/my-sites/stats/pages/pricing-grid/: Route controller and page - Updated client/my-sites/stats/controller.jsx and index.js for routing
1 parent 669b1bb commit e49d13c

10 files changed

Lines changed: 482 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';
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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { __experimentalVStack as VStack } from '@wordpress/components';
2+
import { __ } from '@wordpress/i18n';
3+
import DocumentHead from 'calypso/components/data/document-head';
4+
import Main from 'calypso/my-sites/stats/components/stats-main';
5+
import PricingGrid from 'calypso/my-sites/stats/pricing-grid';
6+
7+
/**
8+
* Dedicated page for the Stats pricing grid.
9+
* Displays the pricing comparison table for new sites without a paid Stats plan.
10+
*/
11+
export default function PricingGridPage() {
12+
const title = __( 'Jetpack Stats Pricing', 'jetpack-stats-admin' );
13+
14+
return (
15+
<Main wideLayout>
16+
<DocumentHead title={ title } />
17+
<VStack spacing={ 4 }>
18+
<PricingGrid />
19+
</VStack>
20+
</Main>
21+
);
22+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Notice ID for the pricing grid dismissal.
3+
* Used with the existing stats notices endpoint for server-side persistence.
4+
*/
5+
export const PRICING_GRID_NOTICE_ID = 'pricing_grid_dismissed';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useSelector } from 'react-redux';
2+
import { isJetpackStatsPaidProductSlug } from 'calypso/dashboard/utils/purchase';
3+
import { getPurchases } from 'calypso/state/purchases/selectors';
4+
import getSiteOption from 'calypso/state/sites/selectors/get-site-option';
5+
import { isJetpackSite } from 'calypso/state/sites/selectors';
6+
import { getSelectedSiteId } from 'calypso/state/ui/selectors';
7+
8+
const FEATURE_LAUNCH_DATE = new Date( '2026-08-07' );
9+
10+
/**
11+
* Determines if a site is eligible for the pricing grid.
12+
*
13+
* Eligible if:
14+
* - Site is connected to Jetpack
15+
* - Site does NOT have a paid Stats product
16+
* - Site was registered/connected on or after the feature launch date
17+
*
18+
* @returns boolean - True if site is eligible for pricing grid
19+
*/
20+
export function useIsPricingGridEligible(): boolean {
21+
const siteId = useSelector( getSelectedSiteId );
22+
23+
// Check if site is Jetpack connected
24+
const isJetpack = useSelector( ( state ) => isJetpackSite( state, siteId ) );
25+
26+
// Check connection date
27+
const connectionDate = useSelector( ( state ) =>
28+
getSiteOption( state, siteId, 'jetpack_site_registered' )
29+
);
30+
const isNewSite = connectionDate
31+
? new Date( ( connectionDate as number ) * 1000 ) >= FEATURE_LAUNCH_DATE
32+
: false;
33+
34+
// Check if site has paid stats plan
35+
const purchases = useSelector( ( state ) => getPurchases( state, siteId ) );
36+
const hasStatsPlan = purchases.some( ( p ) => isJetpackStatsPaidProductSlug( p.product_slug ) );
37+
38+
return isJetpack && isNewSite && ! hasStatsPlan;
39+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { PRODUCT_JETPACK_STATS_YEARLY } from '@automattic/calypso-products';
2+
import { useSelector } from 'react-redux';
3+
import { getProductBySlug } from 'calypso/state/products-list/selectors';
4+
5+
interface PricingData {
6+
yearlyCost: number;
7+
currency: string;
8+
}
9+
10+
/**
11+
* Get pricing data for Jetpack Stats from the products list.
12+
* Returns the yearly cost and currency code for the Stats paid plan.
13+
*
14+
* @returns Pricing data object or null if product not available
15+
*/
16+
export function usePricingData(): {
17+
data: PricingData | null;
18+
isLoading: boolean;
19+
isError: boolean;
20+
} {
21+
const product = useSelector( ( state ) =>
22+
getProductBySlug( state, PRODUCT_JETPACK_STATS_YEARLY )
23+
);
24+
25+
const isLoading = ! product;
26+
const isError = ! product;
27+
28+
if ( ! product || ! product.cost || ! product.currency_code ) {
29+
return {
30+
data: null,
31+
isLoading,
32+
isError,
33+
};
34+
}
35+
36+
return {
37+
data: {
38+
yearlyCost: parseFloat( String( product.cost ) ),
39+
currency: product.currency_code,
40+
},
41+
isLoading: false,
42+
isError: false,
43+
};
44+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { default } from './pricing-grid';
2+
export { useIsPricingGridEligible } from './hooks/use-eligibility';
3+
export { usePricingData } from './hooks/use-pricing-data';
4+
export { PRICING_GRID_NOTICE_ID } from './hooks/use-dismissal';
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
@import 'calypso/assets/stylesheets/shared/mixins/clear-fix';
2+
@import 'calypso/assets/stylesheets/shared/mixins/when-dark-theme';
3+
4+
.pricing-grid {
5+
margin: 2rem 0;
6+
padding: 0 1rem;
7+
8+
.pricing-grid__header {
9+
text-align: center;
10+
margin-bottom: 2rem;
11+
12+
h2 {
13+
margin: 0 0 0.5rem;
14+
font-size: 1.75rem;
15+
font-weight: 600;
16+
color: var(--color-text);
17+
}
18+
}
19+
20+
.pricing-grid__tagline {
21+
margin: 0;
22+
color: var(--color-text-secondary);
23+
font-size: 1rem;
24+
}
25+
26+
.pricing-grid__columns {
27+
display: grid;
28+
grid-template-columns: 1fr 1fr;
29+
gap: 2rem;
30+
margin-bottom: 2rem;
31+
32+
@media (max-width: 782px) {
33+
grid-template-columns: 1fr;
34+
gap: 1.5rem;
35+
}
36+
}
37+
38+
.pricing-grid__column {
39+
border: 1px solid var(--color-border);
40+
border-radius: 8px;
41+
overflow: hidden;
42+
display: flex;
43+
flex-direction: column;
44+
background: var(--color-surface);
45+
46+
@include when-dark-theme {
47+
background: var(--color-surface-variant);
48+
}
49+
50+
&--primary {
51+
border: 2px solid var(--color-primary);
52+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
53+
54+
@include when-dark-theme {
55+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
56+
}
57+
}
58+
}
59+
60+
.pricing-grid__column-header {
61+
padding: 2rem;
62+
border-bottom: 1px solid var(--color-border-subtle);
63+
text-align: center;
64+
65+
h3 {
66+
margin: 0 0 1rem;
67+
font-size: 1.25rem;
68+
font-weight: 600;
69+
color: var(--color-text);
70+
}
71+
}
72+
73+
.pricing-grid__price {
74+
margin: 1rem 0 0.5rem;
75+
font-size: 2rem;
76+
font-weight: 600;
77+
color: var(--color-text);
78+
line-height: 1;
79+
}
80+
81+
.pricing-grid__billing-info {
82+
margin: 0.5rem 0 0;
83+
font-size: 0.875rem;
84+
color: var(--color-text-secondary);
85+
}
86+
87+
.pricing-grid__features {
88+
flex: 1;
89+
padding: 0;
90+
display: flex;
91+
flex-direction: column;
92+
}
93+
94+
.pricing-grid__feature-row {
95+
display: grid;
96+
grid-template-columns: 1fr auto;
97+
gap: 1rem;
98+
padding: 1rem 2rem;
99+
border-bottom: 1px solid var(--color-border-subtle);
100+
align-items: center;
101+
102+
&:last-child {
103+
border-bottom: none;
104+
}
105+
106+
@media (max-width: 782px) {
107+
grid-template-columns: 1fr auto;
108+
gap: 0.5rem;
109+
padding: 0.75rem 1.5rem;
110+
}
111+
}
112+
113+
.pricing-grid__feature-name {
114+
font-size: 0.875rem;
115+
color: var(--color-text);
116+
font-weight: 500;
117+
}
118+
119+
.pricing-grid__feature-value {
120+
font-size: 0.875rem;
121+
color: var(--color-text-secondary);
122+
text-align: right;
123+
min-width: 3rem;
124+
125+
&--paid {
126+
color: var(--color-text);
127+
font-weight: 500;
128+
}
129+
}
130+
131+
.pricing-grid__checkmark {
132+
color: var(--color-success);
133+
font-weight: 700;
134+
}
135+
136+
.pricing-grid__cross {
137+
color: var(--color-error);
138+
font-weight: 700;
139+
opacity: 0.6;
140+
}
141+
142+
.pricing-grid__column-footer {
143+
padding: 2rem;
144+
border-top: 1px solid var(--color-border-subtle);
145+
146+
button {
147+
margin: 0;
148+
}
149+
}
150+
151+
&--loading {
152+
display: flex;
153+
justify-content: center;
154+
align-items: center;
155+
min-height: 400px;
156+
}
157+
}

0 commit comments

Comments
 (0)