Skip to content

Commit ff18e32

Browse files
committed
Stats admin: dismiss the pricing grid once the user picks a plan
Both CTAs now land on Odyssey views flagged with a view query param (traffic for free, purchase for paid); loading either view marks the grid dismissed so it doesn't reappear — including after Odyssey's 'I will do it later'. Dismissal is recorded in the wpcom stats notices (pricing_grid_dismissed) for connected sites, with a local option covering unconnected sites where that endpoint is unreachable. 'Start for free' no longer runs a checkout for the free product — it goes straight to the Stats dashboard, so the checkout workflow hook, the connection initial state, and the unused initial-state fields are removed. Also reword the paid price legend to 'from 10k monthly views'.
1 parent 9a0d4ea commit ff18e32

9 files changed

Lines changed: 156 additions & 94 deletions

File tree

projects/packages/stats-admin/src/class-dashboard.php

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
namespace Automattic\Jetpack\Stats_Admin;
99

1010
use Automattic\Jetpack\Assets;
11-
use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
1211
use Automattic\Jetpack\Stats\Options as Stats_Options;
1312

1413
/**
@@ -144,6 +143,12 @@ class="jp-stats-dashboard-loading-spinner"
144143
*/
145144
public function admin_init() {
146145
add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
146+
147+
// Landing on an Odyssey view means the user chose a plan from the
148+
// pricing grid; don't show the grid again on later visits.
149+
if ( $this->is_odyssey_view() ) {
150+
Pricing_Grid\Eligibility::dismiss();
151+
}
147152
}
148153

149154
/**
@@ -164,17 +169,27 @@ public function load_admin_scripts() {
164169
* @return bool True if the pricing grid should be shown.
165170
*/
166171
private function should_show_pricing_grid() {
167-
// The Odyssey purchase route (#!/stats/purchase/{blogId}) shares this
168-
// page; the pricing grid's paid CTA adds `view=purchase` so the
169-
// dashboard renders Odyssey there instead of the grid.
170-
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
171-
if ( isset( $_GET['view'] ) && 'purchase' === $_GET['view'] ) {
172+
// Odyssey shares this page; the pricing grid's CTAs add a `view` flag
173+
// (purchase for the paid tier route, traffic for the free dashboard)
174+
// so the dashboard renders Odyssey there instead of the grid.
175+
if ( $this->is_odyssey_view() ) {
172176
return false;
173177
}
174178

175179
return Pricing_Grid\Eligibility::should_show_pricing_grid();
176180
}
177181

182+
/**
183+
* Whether the current request targets an Odyssey view via the pricing
184+
* grid's `view` query flag.
185+
*
186+
* @return bool True for an Odyssey view.
187+
*/
188+
private function is_odyssey_view() {
189+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
190+
return isset( $_GET['view'] ) && in_array( $_GET['view'], array( 'purchase', 'traffic' ), true );
191+
}
192+
178193
/**
179194
* Load the pricing grid scripts and initial state.
180195
*/
@@ -196,8 +211,7 @@ private function load_pricing_grid_scripts() {
196211
);
197212
Assets::enqueue_script( $handle );
198213

199-
// Inject the initial states before the bundle runs.
214+
// Inject the initial state before the bundle runs.
200215
wp_add_inline_script( $handle, ( new Pricing_Grid\Initial_State() )->render(), 'before' );
201-
Connection_Initial_State::render_script( $handle );
202216
}
203217
}

projects/packages/stats-admin/src/class-main.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ private function __construct() {
5656
// The stats module normally registers the Stats menu, but modules only
5757
// load once the site is connected. Register the dashboard here too so
5858
// eligible new sites see the pricing grid before connecting.
59-
if ( is_admin() && Pricing_Grid\Eligibility::should_show_pricing_grid() ) {
59+
if ( is_admin() && Pricing_Grid\Eligibility::is_eligible_site() ) {
6060
Dashboard::init();
6161
}
6262

projects/packages/stats-admin/src/class-notices.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Notices {
2121
const NEW_STATS_FEEDBACK_NOTICE_ID = 'new_stats_feedback';
2222
const OPT_IN_NEW_STATS_NOTICE_ID = 'opt_in_new_stats';
2323
const GDPR_COOKIE_CONSENT_NOTICE_ID = 'gdpr_cookie_consent';
24+
const PRICING_GRID_DISMISSED_NOTICE_ID = 'pricing_grid_dismissed';
2425

2526
const VIEWS_TO_SHOW_FEEDBACK = 3;
2627
const POSTPONE_OPT_IN_NOTICE_DAYS = 30;

projects/packages/stats-admin/src/pricing-grid/class-eligibility.php

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
1111
use Automattic\Jetpack\Current_Plan;
12+
use Automattic\Jetpack\Stats_Admin\Notices;
1213

1314
/**
1415
* Determines whether to show the Stats pricing grid (for new sites without a plan).
@@ -92,14 +93,66 @@ public static function has_stats_plan() {
9293
return false;
9394
}
9495

96+
/**
97+
* Option marking the pricing grid as dismissed.
98+
*
99+
* Set once the user picks a plan from the grid, so it doesn't reappear
100+
* after "I will do it later" or an abandoned checkout. The wpcom notices
101+
* endpoint is the authoritative record for connected sites; this local
102+
* option covers unconnected sites, where that endpoint is unreachable.
103+
*
104+
* @var string
105+
*/
106+
const DISMISSED_OPTION = 'jetpack_stats_pricing_grid_dismissed';
107+
108+
/**
109+
* Check if the pricing grid has been dismissed.
110+
*
111+
* @return bool True if the pricing grid has been dismissed.
112+
*/
113+
public static function is_dismissed() {
114+
if ( get_option( self::DISMISSED_OPTION ) ) {
115+
return true;
116+
}
117+
118+
if ( ( new Connection_Manager() )->is_connected() ) {
119+
return ( new Notices() )->is_notice_hidden( Notices::PRICING_GRID_DISMISSED_NOTICE_ID );
120+
}
121+
122+
return false;
123+
}
124+
125+
/**
126+
* Mark the pricing grid as dismissed.
127+
*/
128+
public static function dismiss() {
129+
update_option( self::DISMISSED_OPTION, true );
130+
131+
if ( ( new Connection_Manager() )->is_connected() ) {
132+
( new Notices() )->update_notice( Notices::PRICING_GRID_DISMISSED_NOTICE_ID, 'dismissed' );
133+
}
134+
}
135+
136+
/**
137+
* Check if the site qualifies for the pricing grid: a new site without a
138+
* Stats plan. Dismissal is intentionally not considered here — this also
139+
* gates the Stats menu registration for unconnected sites, which must
140+
* survive dismissal.
141+
*
142+
* @return bool True if the site qualifies for the pricing grid.
143+
*/
144+
public static function is_eligible_site() {
145+
return self::is_new_site() && ! self::has_stats_plan();
146+
}
147+
95148
/**
96149
* Check if the pricing grid should be shown.
97150
*
98-
* The pricing grid is shown for new sites without a Stats plan.
151+
* The pricing grid is shown for new sites without a Stats plan, until dismissed.
99152
*
100153
* @return bool True if the pricing grid should be shown.
101154
*/
102155
public static function should_show_pricing_grid() {
103-
return self::is_new_site() && ! self::has_stats_plan();
156+
return self::is_eligible_site() && ! self::is_dismissed();
104157
}
105158
}

projects/packages/stats-admin/src/pricing-grid/class-initial-state.php

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,12 @@
77

88
namespace Automattic\Jetpack\Stats_Admin\Pricing_Grid;
99

10-
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
1110
use Jetpack_Options;
1211

1312
/**
1413
* Builds the initial state object for the pricing grid React component.
1514
*/
1615
class Initial_State {
17-
/**
18-
* The free Stats product slug.
19-
*
20-
* @var string
21-
*/
22-
const FREE_PRODUCT_SLUG = 'jetpack_stats_free_yearly';
23-
2416
/**
2517
* The paid Stats product slug.
2618
*
@@ -41,21 +33,13 @@ class Initial_State {
4133
* @return array
4234
*/
4335
public function get_data() {
44-
$connection_manager = new Connection_Manager();
45-
$blog_id = Jetpack_Options::get_option( 'id' );
46-
$is_registered = $connection_manager->is_connected();
47-
48-
// Get the site's domain/slug for checkout redirects.
49-
$domain = $this->get_site_domain();
36+
$blog_id = Jetpack_Options::get_option( 'id' );
5037

5138
return array(
52-
'blogId' => $blog_id,
53-
'siteSuffix' => $domain,
54-
'isConnected' => $is_registered,
55-
'adminUrl' => admin_url(),
56-
'freeProductSlug' => self::FREE_PRODUCT_SLUG,
57-
'paidProductSlug' => self::PAID_PRODUCT_SLUG,
58-
'paidPurchaseUrl' => $this->get_paid_purchase_url( $blog_id, $domain ),
39+
// The free choice needs no purchase: it goes straight to the
40+
// dashboard; the `view=traffic` flag also dismisses the grid.
41+
'freeStatsUrl' => admin_url( 'admin.php?page=stats&view=traffic' ),
42+
'paidPurchaseUrl' => $this->get_paid_purchase_url( $blog_id, $this->get_site_domain() ),
5943
'paidPricing' => $this->get_paid_pricing(),
6044
);
6145
}

projects/packages/stats-admin/src/pricing-grid/components/pricing-grid/index.jsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
ThemeProvider,
1313
} from '@automattic/jetpack-components';
1414
import { __ } from '@wordpress/i18n';
15-
import useStatsCheckoutWorkflow from '../../hooks/use-stats-checkout-workflow';
1615

1716
/* global JP_STATS_PRICING_GRID_INITIAL_STATE */
1817

@@ -51,13 +50,6 @@ export default function PricingGrid() {
5150
? JP_STATS_PRICING_GRID_INITIAL_STATE
5251
: {};
5352

54-
const { run: checkoutFree, hasCheckoutStarted: freeCheckoutStarted } = useStatsCheckoutWorkflow( {
55-
productSlug: state.freeProductSlug || 'jetpack_stats_free_yearly',
56-
redirectUrl: 'admin.php?page=stats',
57-
siteSuffix: state.siteSuffix,
58-
adminUrl: state.adminUrl,
59-
} );
60-
6153
const currency = state.paidPricing?.currency || 'USD';
6254
const monthlyPrice = state.paidPricing?.yearlyCost ? state.paidPricing.yearlyCost / 12 : null;
6355

@@ -81,7 +73,7 @@ export default function PricingGrid() {
8173
price={ monthlyPrice }
8274
currency={ currency }
8375
legend={ __(
84-
'per month for up to 10k monthly views, billed yearly',
76+
'per month, from 10k monthly views, billed yearly',
8577
'jetpack-stats-admin'
8678
) }
8779
/>
@@ -113,13 +105,7 @@ export default function PricingGrid() {
113105
<PricingTableColumn>
114106
<PricingTableHeader>
115107
<ProductPrice price={ 0 } legend="" currency={ currency } hidePriceFraction />
116-
<Button
117-
onClick={ checkoutFree }
118-
isLoading={ freeCheckoutStarted }
119-
disabled={ freeCheckoutStarted }
120-
variant="secondary"
121-
fullWidth
122-
>
108+
<Button href={ state.freeStatsUrl || '#' } variant="secondary" fullWidth>
123109
{ __( 'Start for free', 'jetpack-stats-admin' ) }
124110
</Button>
125111
</PricingTableHeader>

projects/packages/stats-admin/src/pricing-grid/hooks/use-stats-checkout-workflow.jsx

Lines changed: 0 additions & 46 deletions
This file was deleted.

projects/packages/stats-admin/tests/php/Dashboard_Test.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function test_init_sets_initialized() {
3131
public function tearDown(): void {
3232
parent::tearDown();
3333
unset( $_GET['view'] );
34+
delete_option( Pricing_Grid\Eligibility::DISMISSED_OPTION );
3435
}
3536

3637
/**
@@ -64,4 +65,22 @@ public function test_render_odyssey_for_purchase_view_on_eligible_new_site() {
6465
$this->expectOutputRegex( '/<div id="wpcom" class="jp-stats-dashboard".*>/i' );
6566
( new Dashboard() )->render();
6667
}
68+
69+
/**
70+
* Test that loading the purchase view dismisses the pricing grid, so later
71+
* visits (e.g. after "I will do it later") render Odyssey instead.
72+
*/
73+
public function test_purchase_view_dismisses_pricing_grid() {
74+
set_transient( 'jetpack_assumed_site_creation_date', '2036-01-01 00:00:00' );
75+
( new Connection_Manager() )->reset_connection_status();
76+
$dashboard = new Dashboard();
77+
78+
$_GET['view'] = 'purchase';
79+
$dashboard->admin_init();
80+
unset( $_GET['view'] );
81+
82+
$this->assertTrue( Pricing_Grid\Eligibility::is_dismissed() );
83+
$this->expectOutputRegex( '/<div id="wpcom" class="jp-stats-dashboard".*>/i' );
84+
$dashboard->render();
85+
}
6786
}

projects/packages/stats-admin/tests/php/Eligibility_Test.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public function tearDown(): void {
3232
parent::tearDown();
3333
delete_option( Current_Plan::PLAN_OPTION );
3434
delete_option( Current_Plan::SITE_PRODUCTS_OPTION );
35+
delete_option( Eligibility::DISMISSED_OPTION );
3536
$this->reset_current_plan_cache();
3637
( new Connection_Manager() )->reset_connection_status();
3738
}
@@ -86,6 +87,56 @@ public function test_connected_site_created_after_launch_shows_grid() {
8687
$this->assertTrue( Eligibility::should_show_pricing_grid() );
8788
}
8889

90+
/**
91+
* A connected site whose pricing_grid_dismissed notice is hidden on wpcom
92+
* should not see the grid, even without the local option.
93+
*/
94+
public function test_wpcom_dismissed_notice_hides_grid_on_connected_site() {
95+
set_transient( 'jetpack_assumed_site_creation_date', '2036-01-01 00:00:00' );
96+
delete_transient( Notices::STATS_DASHBOARD_NOTICES_CACHE_KEY );
97+
add_filter( 'pre_http_request', array( $this, 'wpcom_notices_dismissed_fixture' ), 11, 3 );
98+
99+
$this->assertTrue( Eligibility::is_eligible_site() );
100+
$this->assertTrue( Eligibility::is_dismissed() );
101+
$this->assertFalse( Eligibility::should_show_pricing_grid() );
102+
103+
remove_filter( 'pre_http_request', array( $this, 'wpcom_notices_dismissed_fixture' ), 11 );
104+
delete_transient( Notices::STATS_DASHBOARD_NOTICES_CACHE_KEY );
105+
}
106+
107+
/**
108+
* Fixture marking the pricing_grid_dismissed notice as hidden on wpcom.
109+
*
110+
* @param array|false $response Existing response.
111+
* @param array $parsed_args Request args.
112+
* @param string $url Request URL.
113+
* @return array|false
114+
*/
115+
public function wpcom_notices_dismissed_fixture( $response, $parsed_args, $url ) {
116+
if ( strpos( $url, '/jetpack-stats-dashboard/notices' ) !== false ) {
117+
return array(
118+
'response' => array(
119+
'code' => 200,
120+
'message' => 'ok',
121+
),
122+
'body' => '{"pricing_grid_dismissed":false}',
123+
);
124+
}
125+
return $response;
126+
}
127+
128+
/**
129+
* A dismissed pricing grid stays hidden while the site remains eligible
130+
* (so the Stats menu keeps registering).
131+
*/
132+
public function test_dismissed_grid_stays_hidden_on_eligible_site() {
133+
$this->disconnect_site();
134+
Eligibility::dismiss();
135+
136+
$this->assertTrue( Eligibility::is_eligible_site() );
137+
$this->assertFalse( Eligibility::should_show_pricing_grid() );
138+
}
139+
89140
/**
90141
* A site with a Stats product should not see the pricing grid.
91142
*/

0 commit comments

Comments
 (0)