Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.93% covered (success)
98.93%
278 / 281
94.44% covered (success)
94.44%
17 / 18
CRAP
n/a
0 / 0
Automattic\Jetpack\PremiumAnalytics\get_stats_plan_tier
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\PremiumAnalytics\get_wpcom_stats_plan_tier
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
Automattic\Jetpack\PremiumAnalytics\get_jetpack_stats_plan_tier
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\has_cached_jetpack_plan
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\has_jetpack_stats_commercial_purchase
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
Automattic\Jetpack\PremiumAnalytics\has_jetpack_stats_product
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\PremiumAnalytics\offers_free_site_upgrade
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\PremiumAnalytics\get_locked_stats_features
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
Automattic\Jetpack\PremiumAnalytics\get_locked_stats_sections
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\get_stats_section_copy
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\get_stats_feature_copy
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
1 / 1
17
Automattic\Jetpack\PremiumAnalytics\localize_stats_support_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\PremiumAnalytics\get_stats_upgrade_url
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\PremiumAnalytics\get_stats_plan_gates
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
8
Automattic\Jetpack\PremiumAnalytics\configure_stats_plan_gates
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\inject_stats_plan_gates_script_data
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
Automattic\Jetpack\PremiumAnalytics\maybe_invalidate_stats_caches
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
Automattic\Jetpack\PremiumAnalytics\maybe_refresh_jetpack_plan
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * Which paid Stats features the site's plan lacks, following classic Stats' gates.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10use Automattic\Jetpack\Current_Plan;
11use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller;
12use Automattic\Jetpack\Stats\Options as Stats_Options;
13use Automattic\Jetpack\Status;
14use Automattic\Jetpack\Status\Host;
15
16// Guarded on a symbol the file declares; see the include block in Analytics::load_dashboard_components().
17if ( ! function_exists( __NAMESPACE__ . '\\ensure_widget_registry_ready' ) ) {
18    require_once __DIR__ . '/widget-modules.php';
19}
20
21/**
22 * Filter overriding the Stats plan tier.
23 *
24 * @var string
25 */
26const STATS_PLAN_TIER_FILTER = 'jetpack_premium_analytics_stats_plan_tier';
27
28const STATS_TIER_COMMERCIAL   = 'commercial';
29const STATS_TIER_LEGACY_FREE  = 'legacy-free';
30const STATS_TIER_PAID         = 'paid';
31const STATS_TIER_LEGACY_BASIC = 'legacy-basic';
32const STATS_TIER_FREE         = 'free';
33const STATS_TIER_JETPACK_FREE = 'jetpack-free';
34const STATS_TIER_UNKNOWN      = 'unknown';
35
36const STATS_TIERS = array( STATS_TIER_COMMERCIAL, STATS_TIER_LEGACY_FREE, STATS_TIER_PAID, STATS_TIER_LEGACY_BASIC, STATS_TIER_FREE, STATS_TIER_JETPACK_FREE, STATS_TIER_UNKNOWN );
37
38/**
39 * Jetpack products that support commercial use. PWYW and Free Stats do not.
40 */
41const JETPACK_STATS_COMMERCIAL_PRODUCTS = array( 'jetpack_stats_monthly', 'jetpack_stats_yearly', 'jetpack_stats_bi_yearly' );
42
43/**
44 * Jetpack plan families that bundle commercial Stats.
45 */
46const JETPACK_STATS_COMMERCIAL_PLAN_PREFIXES = array( 'jetpack_business', 'jetpack_complete', 'jetpack_growth' );
47
48/**
49 * The Stats plan tier the dashboard is gated by.
50 *
51 * @since 0.13.0
52 *
53 * @return string One of the STATS_TIER_* values.
54 */
55function get_stats_plan_tier() {
56    $host = new Host();
57
58    if ( $host->is_wpcom_platform() ) {
59        $tier = get_wpcom_stats_plan_tier( $host->is_woa_site() );
60    } elseif ( $host->is_vip_site() ) {
61        $tier = STATS_TIER_COMMERCIAL;
62    } else {
63        $tier = get_jetpack_stats_plan_tier();
64    }
65
66    /**
67     * Filters the Stats plan tier Premium Analytics gates the dashboard by.
68     *
69     * @since 0.13.0
70     *
71     * @param string $tier One of `commercial`, `legacy-free`, `paid`, `legacy-basic`, `free`, `jetpack-free` or
72     *                     `unknown`, which locks nothing. Any other value is ignored.
73     */
74    $filtered = apply_filters( STATS_PLAN_TIER_FILTER, $tier );
75
76    // A misspelled tier would otherwise fall through to `unknown` and unlock every paid feature.
77    if ( ! in_array( $filtered, STATS_TIERS, true ) ) {
78        // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
79        _doing_it_wrong( __FUNCTION__, esc_html( sprintf( /* translators: %s: Filter name. */ __( 'The %s filter must return one of the STATS_TIER_* values.', 'jetpack-premium-analytics-pkg' ), STATS_PLAN_TIER_FILTER ) ), 'jetpack-premium-analytics-0.13.0' );
80        return $tier;
81    }
82
83    return $filtered;
84}
85
86/**
87 * The tier of a WordPress.com site, from its stats feature.
88 *
89 * @since 0.13.0
90 *
91 * @param bool $is_atomic Whether the site is Atomic.
92 * @return string One of the STATS_TIER_* values.
93 */
94function get_wpcom_stats_plan_tier( $is_atomic ) {
95    if ( ! function_exists( 'wpcom_site_has_feature' ) ) {
96        return STATS_TIER_UNKNOWN;
97    }
98
99    // Unsynced Atomic data reads like a site that bought nothing; see Current_Plan::get_wpcom_site_specific_features().
100    if ( $is_atomic && function_exists( 'wpcom_get_site_purchases' ) && ! \wpcom_get_site_purchases() ) {
101        return STATS_TIER_UNKNOWN;
102    }
103
104    // Calypso's shouldGateStats() order: legacy free outranks paid.
105    $tiers = array(
106        'stats-commercial' => STATS_TIER_COMMERCIAL,
107        'stats-free'       => STATS_TIER_LEGACY_FREE,
108        'stats-paid'       => STATS_TIER_PAID,
109        'stats-basic'      => STATS_TIER_LEGACY_BASIC,
110    );
111    foreach ( $tiers as $feature => $tier ) {
112        if ( \wpcom_site_has_feature( $feature ) ) {
113            return $tier;
114        }
115    }
116
117    return STATS_TIER_FREE;
118}
119
120/**
121 * The tier of a self-hosted site, from its cached plan.
122 *
123 * @since 0.13.0
124 *
125 * @return string One of the STATS_TIER_* values.
126 */
127function get_jetpack_stats_plan_tier() {
128    if ( ! has_cached_jetpack_plan() ) {
129        return STATS_TIER_UNKNOWN;
130    }
131
132    return has_jetpack_stats_commercial_purchase() ? STATS_TIER_COMMERCIAL : STATS_TIER_JETPACK_FREE;
133}
134
135/**
136 * Whether the plan cache holds a plan. An empty one cannot tell a paid site from a free one (STATS-475).
137 *
138 * @since 0.13.0
139 *
140 * @return bool
141 */
142function has_cached_jetpack_plan() {
143    $plan = get_option( Current_Plan::PLAN_OPTION );
144
145    return is_array( $plan ) && is_array( $plan['features']['active'] ?? null ) && $plan['features']['active'];
146}
147
148/**
149 * Whether a self-hosted site's cached plan or products support commercial use.
150 *
151 * @since 0.13.0
152 *
153 * @return bool
154 */
155function has_jetpack_stats_commercial_purchase() {
156    $plan     = get_option( Current_Plan::PLAN_OPTION );
157    $products = get_option( Current_Plan::SITE_PRODUCTS_OPTION, array() );
158    $products = is_array( $products ) ? $products : array();
159
160    $slugs = array_column( $products, 'product_slug' );
161    if ( isset( $plan['product_slug'] ) ) {
162        $slugs[] = $plan['product_slug'];
163    }
164
165    foreach ( $slugs as $slug ) {
166        if ( in_array( $slug, JETPACK_STATS_COMMERCIAL_PRODUCTS, true ) ) {
167            return true;
168        }
169        foreach ( JETPACK_STATS_COMMERCIAL_PLAN_PREFIXES as $prefix ) {
170            if ( 0 === strpos( $slug, $prefix ) ) {
171                return true;
172            }
173        }
174    }
175
176    return in_array( 'stats-commercial', $plan['features']['active'] ?? array(), true );
177}
178
179/**
180 * Whether a self-hosted site's cached plan or products include any Jetpack Stats product, free or PWYW included.
181 *
182 * @since $$next-version$$
183 *
184 * @return bool
185 */
186function has_jetpack_stats_product() {
187    $plan     = get_option( Current_Plan::PLAN_OPTION );
188    $products = get_option( Current_Plan::SITE_PRODUCTS_OPTION, array() );
189    $products = is_array( $products ) ? $products : array();
190
191    $slugs = array_column( $products, 'product_slug' );
192    if ( isset( $plan['product_slug'] ) ) {
193        $slugs[] = $plan['product_slug'];
194    }
195
196    foreach ( $slugs as $slug ) {
197        if ( is_string( $slug ) && 0 === strpos( $slug, 'jetpack_stats' ) ) {
198            return true;
199        }
200    }
201
202    return false;
203}
204
205/**
206 * Tiers without paid Stats that get the upgrade banner. `free` gets a section upgrade card instead, as in classic Stats,
207 * and `unknown` may be a paid site whose plan has not synced.
208 */
209const STATS_UPGRADE_BANNER_TIERS = array( STATS_TIER_JETPACK_FREE, STATS_TIER_LEGACY_FREE, STATS_TIER_LEGACY_BASIC );
210
211/**
212 * Whether the dashboard offers a paid Stats plan: the tier gets the upgrade banner, the site has no Stats PWYW, and the site first opened the dashboard at least 30 days ago.
213 *
214 * @since $$next-version$$
215 *
216 * @param string $tier One of the STATS_TIER_* values.
217 * @return bool
218 */
219function offers_free_site_upgrade( $tier ) {
220    // Stats offers Simple sites this banner under rules of their own, which are not ported here.
221    if ( ! in_array( $tier, STATS_UPGRADE_BANNER_TIERS, true ) || ( new Host() )->is_wpcom_simple() ) {
222        return false;
223    }
224
225    // Stats treats PWYW as paid and stops its upgrade prompts.
226    $products = get_option( Current_Plan::SITE_PRODUCTS_OPTION, array() );
227    if ( is_array( $products ) && in_array( 'jetpack_stats_pwyw_yearly', array_column( $products, 'product_slug' ), true ) ) {
228        return false;
229    }
230
231    $first_visit = get_dashboard_first_visit();
232
233    return null !== $first_visit && time() - $first_visit >= 30 * DAY_IN_SECONDS;
234}
235
236/**
237 * The features classic Stats gates on WordPress.com Personal: the newer, commercial modules.
238 */
239const STATS_COMMERCIAL_FEATURES = array( 'authors', 'search-terms', 'videos', 'utm', 'devices', 'regions-cities' );
240
241/**
242 * The features gated on legacy-free sites and on self-hosted sites without a commercial purchase.
243 */
244const STATS_ADVANCED_FEATURES = array( 'utm', 'devices', 'regions-cities' );
245
246/**
247 * The features a tier locks.
248 *
249 * @since 0.13.0
250 *
251 * @param string $tier One of the STATS_TIER_* values.
252 * @return string[] Feature keys.
253 */
254function get_locked_stats_features( $tier ) {
255    switch ( $tier ) {
256        case STATS_TIER_LEGACY_FREE:
257        case STATS_TIER_JETPACK_FREE:
258            return STATS_ADVANCED_FEATURES;
259        case STATS_TIER_PAID:
260            return STATS_COMMERCIAL_FEATURES;
261        case STATS_TIER_LEGACY_BASIC:
262            return array_merge( STATS_COMMERCIAL_FEATURES, array( 'referrers', 'clicks', 'csv', 'intervals', 'long-date-presets' ) );
263        case STATS_TIER_FREE:
264            return array_merge(
265                STATS_COMMERCIAL_FEATURES,
266                array( 'top-posts', 'referrers', 'countries', 'clicks', 'file-downloads', 'insights', 'tags', 'csv', 'intervals', 'long-date-presets', 'recent-7-days' )
267            );
268        default:
269            return array();
270    }
271}
272
273/**
274 * The dashboard sections a tier replaces with one upgrade prompt, as classic Stats replaces its Insights and Traffic pages.
275 *
276 * @since $$next-version$$
277 *
278 * @param string $tier One of the STATS_TIER_* values.
279 * @return string[] Section slugs.
280 */
281function get_locked_stats_sections( $tier ) {
282    return STATS_TIER_FREE === $tier ? array( 'traffic', 'insights' ) : array();
283}
284
285/**
286 * What a locked section's upgrade prompt says: classic Stats' upsell, without its price.
287 *
288 * @since $$next-version$$
289 *
290 * @param string $section Section slug.
291 * @return array{title:string, description:string, features:string[], upgrade_label:string, expand_label:string, support_url:string} The copy.
292 */
293function get_stats_section_copy( $section ) {
294    $plan_features = array(
295        __( 'Keep your data private and GDPR-compliant', 'jetpack-premium-analytics-pkg' ),
296        __( '14-day money-back guarantee', 'jetpack-premium-analytics-pkg' ),
297        __( '6 GB storage', 'jetpack-premium-analytics-pkg' ),
298        __( 'Unlimited pages, posts, users, and visitors', 'jetpack-premium-analytics-pkg' ),
299        __( 'Free domain for one year', 'jetpack-premium-analytics-pkg' ),
300        __( 'Ad-free browsing experience for your visitors', 'jetpack-premium-analytics-pkg' ),
301        __( 'All premium themes', 'jetpack-premium-analytics-pkg' ),
302        __( 'Fast support from our expert team', 'jetpack-premium-analytics-pkg' ),
303        __( 'Customize fonts and colors sitewide', 'jetpack-premium-analytics-pkg' ),
304    );
305
306    if ( 'insights' === $section ) {
307        $title    = __( 'Unlock site insights', 'jetpack-premium-analytics-pkg' );
308        $features = array(
309            __( 'Monitor your posting activity', 'jetpack-premium-analytics-pkg' ),
310            __( 'Track your all-time highlights and insights', 'jetpack-premium-analytics-pkg' ),
311        );
312    } else {
313        $title    = __( 'Unlock site growth analytics', 'jetpack-premium-analytics-pkg' );
314        $features = array(
315            __( 'View trends and data from any time period', 'jetpack-premium-analytics-pkg' ),
316            __( 'Detailed stats about posts, referrers, clicks and more', 'jetpack-premium-analytics-pkg' ),
317        );
318    }
319
320    return array(
321        'title'         => $title,
322        'description'   => __( 'Get detailed insights into your site’s performance with a Personal plan.', 'jetpack-premium-analytics-pkg' ),
323        'features'      => array_merge( $features, $plan_features ),
324        'upgrade_label' => __( 'Upgrade to Personal', 'jetpack-premium-analytics-pkg' ),
325        'expand_label'  => __( 'Show all Personal features', 'jetpack-premium-analytics-pkg' ),
326        'support_url'   => 'https://wordpress.com/support/stats/',
327    );
328}
329
330/**
331 * What a locked feature says, worded as classic Stats' upsell; `<link>` wraps the words its support page explains.
332 *
333 * @since 0.13.0
334 *
335 * @param string $feature Feature key.
336 * @return array{description:string, support_url:string} The copy, generic for a feature without its own.
337 */
338function get_stats_feature_copy( $feature ) {
339    $traffic_support = 'https://jetpack.com/support/jetpack-stats/traffic-dashboard/';
340
341    switch ( $feature ) {
342        case 'top-posts':
343            return array(
344                'description' => __( 'Discover your <link>post and pages</link> traffic in detail and learn what content resonates the most.', 'jetpack-premium-analytics-pkg' ),
345                'support_url' => $traffic_support . '#analyzing-popular-posts-pages-and-archives',
346            );
347        case 'referrers':
348            return array(
349                'description' => __( 'Find out where your <link>visitors come from</link> to optimize your content strategy.', 'jetpack-premium-analytics-pkg' ),
350                'support_url' => $traffic_support . '#referrers',
351            );
352        case 'countries':
353            return array(
354                'description' => __( 'Discover where your <link>visitors are located</link> and identify where your traffic is coming from.', 'jetpack-premium-analytics-pkg' ),
355                'support_url' => $traffic_support . '#views-by-locations',
356            );
357        case 'regions-cities':
358            return array(
359                'description' => __( 'Access to <link>region and city stats</link> for more accurate analysis.', 'jetpack-premium-analytics-pkg' ),
360                'support_url' => $traffic_support . '#regions-and-cities',
361            );
362        case 'clicks':
363            return array(
364                'description' => __( 'Learn what <link>external links</link> your visitors click on your site to reveal their areas of interest.', 'jetpack-premium-analytics-pkg' ),
365                'support_url' => $traffic_support . '#clicks',
366            );
367        case 'file-downloads':
368            return array(
369                'description' => __( 'Discover the most <link>downloaded files</link> by your visitors.', 'jetpack-premium-analytics-pkg' ),
370                'support_url' => 'https://wordpress.com/support/stats/analyze-content-performance/#track-file-downloads',
371            );
372        case 'authors':
373            return array(
374                'description' => __( 'Identify your audience’s <link>favorite writers</link> and perspectives.', 'jetpack-premium-analytics-pkg' ),
375                'support_url' => $traffic_support . '#authors',
376            );
377        case 'search-terms':
378            return array(
379                'description' => __( 'Discover the <link>terms and phrases</link> your visitors use to find your site.', 'jetpack-premium-analytics-pkg' ),
380                'support_url' => $traffic_support . '#search-terms',
381            );
382        case 'videos':
383            return array(
384                'description' => __( 'Discover your <link>most popular videos</link> and find out how they performed.', 'jetpack-premium-analytics-pkg' ),
385                'support_url' => 'https://jetpack.com/support/jetpack-videopress/add-video-block-editor/video-stats/',
386            );
387        case 'utm':
388            return array(
389                'description' => __( 'Generate UTM parameters and track your <link>campaign performance data</link>.', 'jetpack-premium-analytics-pkg' ),
390                'support_url' => $traffic_support . '#harnessing-utm-stats-for-precision-tracking',
391            );
392        case 'devices':
393            return array(
394                'description' => __( 'See which <link>devices, browsers and OS</link> your visitors are using.', 'jetpack-premium-analytics-pkg' ),
395                'support_url' => $traffic_support . '#devices-stats',
396            );
397        case 'tags':
398            return array(
399                'description' => __( 'Track the most viewed <link>tags & categories</link>.', 'jetpack-premium-analytics-pkg' ),
400                'support_url' => 'https://wordpress.com/support/stats/learn-insights-about-your-website/#all-time-insights',
401            );
402        case 'recent-7-days':
403            return array(
404                'description' => __( 'See more than the last 7 days of your stats.', 'jetpack-premium-analytics-pkg' ),
405                'support_url' => '',
406            );
407        case 'long-date-presets':
408            return array(
409                'description' => __( 'See periods beyond the last 30 days in one click.', 'jetpack-premium-analytics-pkg' ),
410                'support_url' => '',
411            );
412        case 'intervals':
413            return array(
414                'description' => __( 'Group your charts by week, month or year.', 'jetpack-premium-analytics-pkg' ),
415                'support_url' => '',
416            );
417        default:
418            return array(
419                'description' => __( 'Upgrade your plan to unlock Jetpack Stats.', 'jetpack-premium-analytics-pkg' ),
420                'support_url' => '',
421            );
422    }
423}
424
425/**
426 * Locales with translated WordPress.com support articles: Calypso's `supportSiteLocales`, which classic Stats links with.
427 */
428const WPCOM_SUPPORT_LOCALES = array( 'ar', 'de', 'es', 'fr', 'he', 'id', 'it', 'ja', 'ko', 'nl', 'pt-br', 'ru', 'sv', 'tr', 'zh-cn', 'zh-tw' );
429
430/**
431 * A WordPress.com support URL in the current user's language, where that support site has one.
432 *
433 * Leaves jetpack.com URLs in English: its localized support docs are missing for most locales.
434 *
435 * @since 0.13.0
436 *
437 * @param string $url Support URL, in English.
438 * @return string
439 */
440function localize_stats_support_url( $url ) {
441    $prefix = 'https://wordpress.com/support/';
442    if ( ! str_starts_with( $url, $prefix ) ) {
443        return $url;
444    }
445
446    $locale = strtolower( str_replace( '_', '-', get_user_locale() ) );
447    if ( ! in_array( $locale, WPCOM_SUPPORT_LOCALES, true ) ) {
448        $locale = strtok( $locale, '-' );
449    }
450    if ( ! in_array( $locale, WPCOM_SUPPORT_LOCALES, true ) ) {
451        return $url;
452    }
453
454    return 'https://wordpress.com/' . $locale . '/support/' . substr( $url, strlen( $prefix ) );
455}
456
457/**
458 * Where to buy a locked feature: classic Stats' purchase destinations.
459 *
460 * @since 0.13.0
461 *
462 * @param string $feature Feature key, or the slug of a locked section, which no commercial feature shares.
463 * @param string $tier    One of the STATS_TIER_* values.
464 * @param string $from    Where the purchase started, for the Odyssey purchase flow.
465 * @return string The URL, or an empty string when there is nowhere to buy it.
466 */
467function get_stats_upgrade_url( $feature, $tier, $from = 'jetpack-premium-analytics' ) {
468    $return_path = 'admin.php?page=' . Analytics::MENU_PAGE_SLUG;
469
470    if ( STATS_TIER_JETPACK_FREE === $tier ) {
471        // The purchase flow lives in Odyssey Stats; with it off, `page=stats` shows only an opt-in notice.
472        $stats_page = function_exists( 'menu_page_url' ) ? menu_page_url( 'stats', false ) : '';
473        if ( ! $stats_page || ! Stats_Options::get_option( 'enable_odyssey_stats' ) ) {
474            return '';
475        }
476
477        $query = http_build_query(
478            array(
479                'from'         => $from,
480                'productType'  => 'commercial',
481                'redirect_uri' => $return_path,
482            ),
483            '',
484            '&',
485            PHP_QUERY_RFC3986
486        );
487
488        return $stats_page . '#!/stats/purchase/' . (int) \Jetpack_Options::get_option( 'id' ) . '?' . $query;
489    }
490
491    $plan      = in_array( $feature, STATS_COMMERCIAL_FEATURES, true ) ? 'premium' : 'personal';
492    $site_slug = ( new Status() )->get_site_suffix();
493
494    return 'https://wordpress.com/checkout/' . rawurlencode( $site_slug ) . '/' . $plan . '?redirect_to=' . rawurlencode( admin_url( $return_path ) );
495}
496
497/**
498 * The gates the client applies: each locked feature's copy and upgrade URL, the feature of each widget type it locks, and each locked section's prompt.
499 *
500 * @since 0.13.0
501 *
502 * @return array{tier:string, locked_features:array<string,array{description:string, support_url:string, upgrade_url:string}>, locked_widget_types:array<string,string>, locked_sections:array<string,array{title:string, description:string, features:string[], upgrade_label:string, expand_label:string, support_url:string, upgrade_url:string, open_widget_types:string[]}>}
503 */
504function get_stats_plan_gates() {
505    $tier     = get_stats_plan_tier();
506    $features = get_locked_stats_features( $tier );
507
508    $locked_features = array();
509    foreach ( $features as $feature ) {
510        $copy                        = get_stats_feature_copy( $feature );
511        $copy['support_url']         = localize_stats_support_url( $copy['support_url'] );
512        $locked_features[ $feature ] = $copy + array( 'upgrade_url' => get_stats_upgrade_url( $feature, $tier ) );
513    }
514
515    $locked_widget_types = array();
516    if ( $locked_features ) {
517        ensure_widget_registry_ready();
518
519        foreach ( get_available_widget_types() as $name => $widget_type ) {
520            if ( isset( $widget_type->plan_feature ) && isset( $locked_features[ $widget_type->plan_feature ] ) ) {
521                $locked_widget_types[ $name ] = $widget_type->plan_feature;
522            }
523        }
524    }
525
526    $locked_sections = array();
527    foreach ( get_locked_stats_sections( $tier ) as $section ) {
528        $copy                        = get_stats_section_copy( $section );
529        $copy['support_url']         = localize_stats_support_url( $copy['support_url'] );
530        $locked_sections[ $section ] = $copy + array(
531            'upgrade_url'       => get_stats_upgrade_url( $section, $tier ),
532            // An allowlist, so a widget type no plan feature names is hidden too.
533            'open_widget_types' => 'traffic' === $section ? array( 'jpa/traffic-chart' ) : array(),
534        );
535    }
536
537    return array(
538        'tier'                => $tier,
539        'locked_features'     => $locked_features,
540        'locked_widget_types' => $locked_widget_types,
541        'locked_sections'     => $locked_sections,
542    );
543}
544
545/**
546 * Configures the plan gates script data.
547 *
548 * @since 0.13.0
549 *
550 * @return void
551 */
552function configure_stats_plan_gates() {
553    add_filter( 'jetpack_admin_js_script_data', __NAMESPACE__ . '\\inject_stats_plan_gates_script_data', 20 );
554    add_action( 'admin_init', __NAMESPACE__ . '\\maybe_refresh_jetpack_plan' );
555    add_action( 'admin_init', __NAMESPACE__ . '\\maybe_invalidate_stats_caches' );
556}
557
558/**
559 * Injects the plan gates into JetpackScriptData.
560 *
561 * @since 0.13.0
562 *
563 * @param array $data The script data passed by the assets package.
564 * @return array
565 */
566function inject_stats_plan_gates_script_data( array $data ): array {
567    // Script data prints on every Jetpack admin page; only the dashboard reads the gates.
568    if ( ! Analytics::is_dashboard_request() ) {
569        return $data;
570    }
571
572    if ( ! isset( $data['premium_analytics'] ) || ! is_array( $data['premium_analytics'] ) ) {
573        $data['premium_analytics'] = array();
574    }
575
576    $gates = get_stats_plan_gates();
577
578    // Objects, so an empty map reaches JS as `{}` rather than `[]`.
579    $data['premium_analytics']['plan_gates'] = array(
580        'tier'                => $gates['tier'],
581        'locked_features'     => (object) $gates['locked_features'],
582        'locked_widget_types' => (object) $gates['locked_widget_types'],
583        'locked_sections'     => (object) $gates['locked_sections'],
584        // The banner sells the commercial features, so `utm` picks the plan that unlocks them.
585        'free_site_upgrade'   => offers_free_site_upgrade( $gates['tier'] )
586            ? array(
587                'upgrade_url'       => get_stats_upgrade_url( 'utm', $gates['tier'], 'jetpack-premium-analytics-free-site-upgrade-notice' ),
588                'has_stats_product' => has_jetpack_stats_product(),
589            )
590            : null,
591    );
592
593    return $data;
594}
595
596/**
597 * Transient throttling the refresh of an empty plan cache.
598 */
599const EMPTY_PLAN_REFRESH_TRANSIENT = 'jetpack_premium_analytics_empty_plan_refresh';
600
601/**
602 * Transient throttling the refresh after a purchase.
603 */
604const PURCHASE_PLAN_REFRESH_TRANSIENT = 'jetpack_premium_analytics_purchase_plan_refresh';
605
606/**
607 * Transient throttling the cache invalidation after a purchase.
608 */
609const PURCHASE_CACHE_INVALIDATION_TRANSIENT = 'jetpack_premium_analytics_purchase_cache_invalidation';
610
611/**
612 * Drops the cached Stats replies a checkout made stale, before the dashboard it returns to asks for them.
613 *
614 * @since 0.13.0
615 *
616 * @return void
617 */
618function maybe_invalidate_stats_caches() {
619    // Simple serves the dashboard from WordPress.com, with neither cache.
620    if ( ! Analytics::is_dashboard_request() || '' === get_stats_purchase_success_type() || ( new Host() )->is_wpcom_simple() ) {
621        return;
622    }
623
624    // The marker is a plain GET parameter, so a crafted link could otherwise drop the caches on every visit.
625    if ( get_transient( PURCHASE_CACHE_INVALIDATION_TRANSIENT ) ) {
626        return;
627    }
628    set_transient( PURCHASE_CACHE_INVALIDATION_TRANSIENT, 1, MINUTE_IN_SECONDS );
629
630    Api_Proxy_Controller::invalidate_cache();
631    delete_transient( Notices::NOTICES_CACHE_KEY );
632}
633
634/**
635 * Refreshes a self-hosted site's cached plan on the dashboard when it is empty or a checkout just returned.
636 *
637 * Script data is final once printed, so the gates can only change before it.
638 *
639 * @since 0.13.0
640 *
641 * @return void
642 */
643function maybe_refresh_jetpack_plan() {
644    if ( ! Analytics::is_dashboard_request() || ( new Host() )->is_wpcom_platform() ) {
645        return;
646    }
647
648    if ( '' !== get_stats_purchase_success_type() ) {
649        $transient = PURCHASE_PLAN_REFRESH_TRANSIENT;
650        $ttl       = MINUTE_IN_SECONDS;
651    } elseif ( ! has_cached_jetpack_plan() ) {
652        $transient = EMPTY_PLAN_REFRESH_TRANSIENT;
653        $ttl       = 15 * MINUTE_IN_SECONDS;
654    } else {
655        return;
656    }
657
658    if ( get_transient( $transient ) ) {
659        return;
660    }
661    set_transient( $transient, 1, $ttl );
662
663    Current_Plan::refresh_from_wpcom( array( 'timeout' => 5 ) );
664}