Skip to content

Commit dd33990

Browse files
Nikschavanchihsuan
andauthored
STATS-523: Premium Analytics: Offer a paid Stats plan to free sites after 30 days (#53647)
Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
1 parent 20d4c48 commit dd33990

20 files changed

Lines changed: 845 additions & 35 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Offer a paid Stats plan to free sites 30 days after their first visit, and show one Stats notice at a time.

‎projects/packages/premium-analytics/docs/dashboard-widgets.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ A rename keeps the attributes as they are. One that changes them needs a migrati
161161

162162
A widget that shows nothing but a paid Stats feature declares that feature in `plan_feature`: in the `$args['plan_features']` map of `register_widget_types_from_manifest()`, or in the arguments of `register_widget_type()`. wp-build drops unknown `widget.json` keys, so the manifest cannot carry it. `register()` refuses one that is not a string. The package declares its own in `WIDGET_TYPE_PLAN_FEATURES`; a widget every plan includes declares none.
163163

164-
`stats-plan-gates.php` owns the features and which plan locks each. On the dashboard it publishes `premium_analytics.plan_gates`: `locked_features` maps each feature the site's plan lacks to its copy (`description`, with `<link>` around the words `support_url` explains) and `upgrade_url`, empty when there is nowhere to buy it, and `locked_widget_types` maps each available type whose feature is locked to that feature. The `jetpack_premium_analytics_stats_plan_tier` filter overrides the tier both come from. Declaring a plan feature needs `WIDGET_API_VERSION` 1.12.0; an older registry ignores it, and the widget stays open.
164+
`stats-plan-gates.php` owns the features and which plan locks each. On the dashboard it publishes `premium_analytics.plan_gates`: `locked_features` maps each feature the site's plan lacks to its copy (`description`, with `<link>` around the words `support_url` explains) and `upgrade_url`, empty when there is nowhere to buy it, and `locked_widget_types` maps each available type whose feature is locked to that feature. `free_site_upgrade` is the paid plan offer the dashboard banner shows: its `upgrade_url`, and `has_stats_product`, which picks the banner title for a site on the free Stats product. It is `null` unless the tier gets the banner, the site has no Stats PWYW, the site is not on WordPress.com Simple, and the site first opened the dashboard at least 30 days ago. The `jetpack_premium_analytics_stats_plan_tier` filter overrides the tier all three come from. Declaring a plan feature needs `WIDGET_API_VERSION` 1.12.0; an older registry ignores it, and the widget stays open.
165165

166166
The dashboard points a locked type at `LockedWidget` instead of its render module, which it never imports, so the widget requests nothing. A feature locked inside a widget that stays open, like the Regions and Cities of **Top locations**, is the widget's to check: it reads `isStatsFeatureLocked()`, renders `LockedWidget` for that part and skips its request.
167167

‎projects/packages/premium-analytics/packages/data/src/queries/stats-app-notices-query.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type StatsAppNoticeId =
99
| 'client_paid_plan_purchase_success'
1010
| 'commercial_site_upgrade'
1111
| 'do_you_love_jetpack_stats'
12+
| 'free_site_upgrade'
1213
| 'gdpr_cookie_consent'
1314
| 'new_stats_feedback'
1415
| 'opt_in_new_stats'

‎projects/packages/premium-analytics/packages/site-sync/src/jetpack-script-data.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ declare module '@automattic/jetpack-script-data' {
5050
open_widget_types: string[];
5151
}
5252
>;
53+
// The paid plan offer the banner shows, or null when the site does not qualify (see docs/dashboard-widgets.md). The URL is empty when there is nowhere to buy it, and `has_stats_product` says whether the site has the free Jetpack Stats product.
54+
free_site_upgrade?: { upgrade_url: string; has_stats_product: boolean } | null;
5355
};
5456
// The Stats purchase this page load returned from checkout with; absent on every other load.
5557
purchase_success?: { type: 'paid' | 'free' };
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { AnalyticsQueryClientProvider } from '@jetpack-premium-analytics/data';
5+
/**
6+
* Internal dependencies
7+
*/
8+
import {
9+
FreeSiteUpgradeNotice,
10+
useFreeSiteUpgradeNoticeVisibility,
11+
} from '../free-site-upgrade-notice';
12+
import { PlanLimitNotice } from '../plan-limit-notice';
13+
import { hasPurchaseSuccess, PurchaseSuccessNotice } from '../purchase-success-notice';
14+
import type { JSX } from 'react';
15+
16+
/**
17+
* Picks the first notice that applies, in Stats' priority order. A notice waits while a
18+
* higher-priority one is still loading, so the reader never sees one replaced by another.
19+
*
20+
* @return The notice, or nothing.
21+
*/
22+
function ConnectedNotices(): JSX.Element | null {
23+
const showFreeSiteUpgrade = useFreeSiteUpgradeNoticeVisibility();
24+
25+
// A checkout return holds the other notices back for the whole load, even once dismissed.
26+
if ( hasPurchaseSuccess() ) {
27+
return <PurchaseSuccessNotice />;
28+
}
29+
30+
if ( showFreeSiteUpgrade === undefined ) {
31+
return null;
32+
}
33+
34+
if ( showFreeSiteUpgrade ) {
35+
return <FreeSiteUpgradeNotice />;
36+
}
37+
38+
return <PlanLimitNotice />;
39+
}
40+
41+
/**
42+
* The Stats notices above a section, one at a time. Brings its own query provider,
43+
* since the dashboard stage has none.
44+
*
45+
* @return The notice, or nothing.
46+
*/
47+
export function DashboardNotices(): JSX.Element {
48+
return (
49+
<AnalyticsQueryClientProvider>
50+
<ConnectedNotices />
51+
</AnalyticsQueryClientProvider>
52+
);
53+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { DashboardNotices } from './dashboard-notices';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.notice {
2+
margin-block-end: var(--wpds-dimension-gap-lg);
3+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import analytics from '@automattic/jetpack-analytics';
5+
import { queryClient } from '@jetpack-premium-analytics/data';
6+
import { act, render, screen } from '@testing-library/react';
7+
import userEvent from '@testing-library/user-event';
8+
import apiFetch from '@wordpress/api-fetch';
9+
import { RegistryProvider } from '@wordpress/data';
10+
/**
11+
* Internal dependencies
12+
*/
13+
import { createNoticesRegistry } from '../../../../tests/js/notice-test-utils';
14+
import { DashboardNotices } from '../dashboard-notices';
15+
import { resetPlanLimitNoticeForTesting } from '../plan-limit-notice/plan-limit-notice';
16+
import { resetPurchaseSuccessNoticeForTesting } from '../purchase-success-notice/purchase-success-notice';
17+
import { resetFreeSiteUpgradeNoticeForTesting } from './free-site-upgrade-notice';
18+
19+
jest.mock(
20+
'@automattic/jetpack-analytics',
21+
() => jest.requireActual( '../../../../tests/js/analytics-test-utils' ).mockJetpackAnalytics
22+
);
23+
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
24+
25+
const mockApiFetch = apiFetch as unknown as jest.Mock;
26+
const recordEvent = jest.mocked( analytics.tracks.recordEvent );
27+
28+
const UPGRADE_URL = 'https://wordpress.com/checkout/example.com/premium?redirect_to=back';
29+
30+
type Site = {
31+
offered?: boolean;
32+
upgradeUrl?: string;
33+
hasStatsProduct?: boolean;
34+
purchaseSuccess?: 'paid' | 'free';
35+
};
36+
37+
let notices = createNoticesRegistry();
38+
39+
/**
40+
* Seeds the script data the banner reads.
41+
*
42+
* @param site - The site to seed.
43+
* @param site.offered - Whether the server offers the site a paid plan.
44+
* @param site.upgradeUrl - Where the server sends the upgrade, empty when there is nowhere to buy it.
45+
* @param site.hasStatsProduct - Whether the site has a Jetpack Stats product.
46+
* @param site.purchaseSuccess - The checkout this load returned from, if any.
47+
*/
48+
function setSite( {
49+
offered = true,
50+
upgradeUrl = UPGRADE_URL,
51+
hasStatsProduct = false,
52+
purchaseSuccess,
53+
}: Site = {} ) {
54+
window.JetpackScriptData = {
55+
site: {
56+
admin_url: 'https://example.com/wp-admin/',
57+
host: 'jetpack',
58+
wpcom: { blog_id: 123456789 },
59+
},
60+
premium_analytics: {
61+
plan_gates: {
62+
tier: 'jetpack-free',
63+
locked_features: {},
64+
locked_widget_types: {},
65+
free_site_upgrade: offered
66+
? { upgrade_url: upgradeUrl, has_stats_product: hasStatsProduct }
67+
: null,
68+
},
69+
purchase_success: purchaseSuccess ? { type: purchaseSuccess } : undefined,
70+
},
71+
} as unknown as typeof window.JetpackScriptData;
72+
}
73+
74+
/**
75+
* Answers the notices and plan usage requests.
76+
*
77+
* @param state - The Stats notice visibility state, over a banner WordPress.com reports visible; a dismissal updates it.
78+
* @param viewsUsed - This cycle's views against a 10,000 limit.
79+
*/
80+
function mockEndpoints( state: Record< string, boolean | undefined > = {}, viewsUsed = 0 ) {
81+
let saved = { free_site_upgrade: true, ...state };
82+
mockApiFetch.mockImplementation( ( { path, method }: { path: string; method?: string } ) => {
83+
if ( path.includes( 'jetpack-stats/usage' ) ) {
84+
return Promise.resolve( {
85+
current_usage: { views_count: viewsUsed },
86+
views_limit: 10000,
87+
} );
88+
}
89+
if ( path.includes( 'notices' ) ) {
90+
if ( method === 'POST' ) {
91+
saved = { ...saved, free_site_upgrade: false };
92+
}
93+
return Promise.resolve( saved );
94+
}
95+
return Promise.reject( new Error( `Unexpected request: ${ path }` ) );
96+
} );
97+
}
98+
99+
/**
100+
* Renders the notices and settles their requests.
101+
*
102+
* @return The render result.
103+
*/
104+
async function renderNotices() {
105+
const view = render(
106+
<RegistryProvider value={ notices.registry }>
107+
<DashboardNotices />
108+
</RegistryProvider>
109+
);
110+
await act( () => jest.runAllTimersAsync() );
111+
await act( () => jest.runAllTimersAsync() );
112+
return view;
113+
}
114+
115+
describe( 'FreeSiteUpgradeNotice', () => {
116+
beforeEach( () => {
117+
jest.useFakeTimers();
118+
queryClient.clear();
119+
resetFreeSiteUpgradeNoticeForTesting();
120+
resetPlanLimitNoticeForTesting();
121+
resetPurchaseSuccessNoticeForTesting();
122+
notices = createNoticesRegistry();
123+
recordEvent.mockClear();
124+
mockApiFetch.mockReset();
125+
setSite();
126+
} );
127+
128+
afterEach( () => {
129+
jest.useRealTimers();
130+
} );
131+
132+
it( 'offers the upgrade to a free site 30 days after its first visit', async () => {
133+
mockEndpoints();
134+
135+
const { container } = await renderNotices();
136+
137+
expect( container ).toHaveTextContent( 'Unlock more Stats with a paid plan' );
138+
expect( screen.getByRole( 'link', { name: 'Upgrade' } ) ).toHaveAttribute(
139+
'href',
140+
UPGRADE_URL
141+
);
142+
} );
143+
144+
it( 'keeps only Learn more when the server has nowhere to sell the plan', async () => {
145+
setSite( { upgradeUrl: '' } );
146+
mockEndpoints();
147+
148+
await renderNotices();
149+
150+
expect( screen.queryByText( 'Upgrade' ) ).not.toBeInTheDocument();
151+
expect( screen.getByRole( 'link', { name: /Learn more/ } ) ).toBeInTheDocument();
152+
} );
153+
154+
it( 'asks a site on the free Stats product whether it wants more', async () => {
155+
setSite( { hasStatsProduct: true } );
156+
mockEndpoints();
157+
158+
const { container } = await renderNotices();
159+
160+
expect( container ).toHaveTextContent( 'Want to get the most out of Jetpack Stats?' );
161+
} );
162+
163+
it( 'says nothing to a site the server does not offer a paid plan', async () => {
164+
setSite( { offered: false } );
165+
mockEndpoints();
166+
167+
const { container } = await renderNotices();
168+
169+
expect( container ).toBeEmptyDOMElement();
170+
} );
171+
172+
it( 'stays away when the notices omit its state, as they do when WordPress.com fails', async () => {
173+
mockEndpoints( { free_site_upgrade: undefined } );
174+
175+
const { container } = await renderNotices();
176+
177+
expect( container ).toBeEmptyDOMElement();
178+
} );
179+
180+
it.each( [ 'free_site_upgrade', 'do_you_love_jetpack_stats', 'commercial_site_upgrade' ] )(
181+
'stays away once Stats has %s dismissed',
182+
async id => {
183+
mockEndpoints( { [ id ]: false } );
184+
185+
const { container } = await renderNotices();
186+
187+
expect( container ).toBeEmptyDOMElement();
188+
}
189+
);
190+
191+
it( 'postpones the banner for ten years when dismissed', async () => {
192+
mockEndpoints();
193+
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
194+
const { container } = await renderNotices();
195+
196+
await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) );
197+
await act( () => jest.runAllTimersAsync() );
198+
199+
expect( container ).toBeEmptyDOMElement();
200+
expect( mockApiFetch ).toHaveBeenCalledWith(
201+
expect.objectContaining( {
202+
method: 'POST',
203+
data: { id: 'free_site_upgrade', status: 'postponed', postponed_for: 315360000 },
204+
} )
205+
);
206+
} );
207+
208+
it( 'stays in place after a failed dismissal, though the refetch fails too', async () => {
209+
mockEndpoints();
210+
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
211+
const { container } = await renderNotices();
212+
mockApiFetch.mockRejectedValue( new Error( 'offline' ) );
213+
214+
await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) );
215+
await act( () => jest.runAllTimersAsync() );
216+
217+
expect( container ).toHaveTextContent( 'Unlock more Stats with a paid plan' );
218+
} );
219+
220+
it( 'shows instead of the plan limit notice when both apply', async () => {
221+
mockEndpoints( {}, 9500 );
222+
223+
const { container } = await renderNotices();
224+
225+
expect( container ).toHaveTextContent( 'Unlock more Stats with a paid plan' );
226+
expect( container ).not.toHaveTextContent( 'You are nearing your monthly limit' );
227+
} );
228+
229+
it( 'gives way to the thank-you notice when a checkout returns', async () => {
230+
setSite( { purchaseSuccess: 'free' } );
231+
mockEndpoints( {}, 9500 );
232+
233+
const { container } = await renderNotices();
234+
235+
expect( container ).toHaveTextContent( 'Thank you for using Jetpack Stats!' );
236+
expect( container ).not.toHaveTextContent( 'Unlock more Stats with a paid plan' );
237+
expect( container ).not.toHaveTextContent( 'You are nearing your monthly limit' );
238+
} );
239+
240+
it( 'leaves the plan limit notice in place once dismissed', async () => {
241+
mockEndpoints( { free_site_upgrade: false }, 9500 );
242+
243+
const { container } = await renderNotices();
244+
245+
expect( container ).toHaveTextContent( 'You are nearing your monthly limit' );
246+
} );
247+
248+
it( 'records one view per page load, though every section tab mounts the notices', async () => {
249+
mockEndpoints();
250+
251+
const { unmount } = await renderNotices();
252+
unmount();
253+
await renderNotices();
254+
255+
const views = recordEvent.mock.calls.filter(
256+
( [ name ] ) => name === 'jetpack_premium_analytics_free_site_upgrade_notice_view'
257+
);
258+
expect( views ).toHaveLength( 1 );
259+
} );
260+
} );

0 commit comments

Comments
 (0)