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