Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.47% covered (success)
93.47%
186 / 199
80.00% covered (warning)
80.00%
20 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Search
93.91% covered (success)
93.91%
185 / 197
80.00% covered (warning)
80.00%
20 / 25
72.14
0.00% covered (danger)
0.00%
0 / 1
 get_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_description
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_long_description
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_features
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_pricing_for_ui
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 get_post_checkout_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_wpcom_product_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_wpcom_free_product_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_new_pricing_202208
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 get_status
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_pricing_from_wpcom
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
8.08
 has_trial_support
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_paid_plan_product_slugs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 has_free_plan_for_product
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
7.90
 activate_free_product
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
13
 wpcom_activation_error
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 error_status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 activation_error
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 sync_local_state_after_free_activation
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 set_default_experience_if_unset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 do_product_specific_activation
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 get_post_activation_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_manage_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_upgradable_by_bundle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Search product
4 *
5 * @package my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack\Products;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Constants;
13use Automattic\Jetpack\My_Jetpack\Hybrid_Product;
14use Automattic\Jetpack\My_Jetpack\Wpcom_Products;
15use Automattic\Jetpack\Search\Module_Control as Search_Module_Control;
16use Automattic\Jetpack\Status\Host;
17use WP_Error;
18
19if ( ! defined( 'ABSPATH' ) ) {
20    exit( 0 );
21}
22
23/**
24 * Class responsible for handling the Search product
25 */
26class Search extends Hybrid_Product {
27    /**
28     * Fallback starting price (USD, billed yearly) for the entry record tier, used when
29     * the WPCOM pricing fetch fails so the dashboard still shows a price, not "$0".
30     *
31     * @var float
32     */
33    const FALLBACK_STARTING_PRICE_USD = 100;
34
35    /**
36     * Search "new pricing" version identifier (introduced 202208).
37     *
38     * Intentionally duplicated from Automattic\Jetpack\Search\Plan::JETPACK_SEARCH_NEW_PRICING_VERSION
39     * rather than referencing that class. My Jetpack is bundled into standalone plugins (e.g. Jetpack
40     * Boost) that do NOT ship the jetpack-search package, so referencing Search\Plan here fatals with
41     * "Class not found" the moment this product builds its pricing data (introduced by PR #48892).
42     *
43     * @var string
44     */
45    const SEARCH_NEW_PRICING_VERSION = '202208';
46
47    /**
48     * The product slug
49     *
50     * @var string
51     */
52    public static $slug = 'search';
53
54    /**
55     * The Jetpack module name
56     *
57     * @var string
58     */
59    public static $module_name = 'search';
60
61    /**
62     * The slug of the plugin associated with this product.
63     *
64     * @var string
65     */
66    public static $plugin_slug = 'jetpack-search';
67
68    /**
69     * The category of the product
70     *
71     * @var string
72     */
73    public static $category = 'performance';
74
75    /**
76     * Search has a standalone plugin
77     *
78     * @var bool
79     */
80    public static $has_standalone_plugin = true;
81
82    /**
83     * Whether this product has a free offering
84     *
85     * @var bool
86     */
87    public static $has_free_offering = true;
88
89    /**
90     * Whether this product requires a plan to work at all
91     *
92     * @var bool
93     */
94    public static $requires_plan = true;
95
96    /**
97     * The filename (id) of the plugin associated with this product.
98     *
99     * @var string
100     */
101    public static $plugin_filename = array(
102        'jetpack-search/jetpack-search.php',
103        'search/jetpack-search.php',
104        'jetpack-search-dev/jetpack-search.php',
105    );
106
107    /**
108     * Search only requires site connection
109     *
110     * @var boolean
111     */
112    public static $requires_user_connection = true;
113
114    /**
115     * The feature slug that identifies the paid plan
116     *
117     * @var string
118     */
119    public static $feature_identifying_paid_plan = 'search';
120
121    /**
122     * Get the product name
123     *
124     * @return string
125     */
126    public static function get_name() {
127        return 'Search';
128    }
129
130    /**
131     * Get the product title
132     *
133     * @return string
134     */
135    public static function get_title() {
136        return 'Jetpack Search';
137    }
138
139    /**
140     * Get the internationalized product description
141     *
142     * @return string
143     */
144    public static function get_description() {
145        return __( 'Instantly deliver the most relevant results to your visitors.', 'jetpack-my-jetpack' );
146    }
147
148    /**
149     * Get the internationalized product long description
150     *
151     * @return string
152     */
153    public static function get_long_description() {
154        return __( 'Help your site visitors find answers instantly so they keep reading and buying. Great for sites with a lot of content.', 'jetpack-my-jetpack' );
155    }
156
157    /**
158     * Get the internationalized features list
159     *
160     * @return array Boost features list
161     */
162    public static function get_features() {
163        return array(
164            __( 'Instant search and indexing', 'jetpack-my-jetpack' ),
165            __( 'Powerful filtering', 'jetpack-my-jetpack' ),
166            __( 'Supports 38 languages', 'jetpack-my-jetpack' ),
167            __( 'Spelling correction', 'jetpack-my-jetpack' ),
168        );
169    }
170
171    /**
172     * Get the product princing details
173     *
174     * @return array Pricing details
175     */
176    public static function get_pricing_for_ui() {
177        // Basic pricing info.
178        $pricing = array_merge(
179            array(
180                'available'               => true,
181                'trial_available'         => static::has_trial_support(),
182                'wpcom_product_slug'      => static::get_wpcom_product_slug(),
183                'wpcom_free_product_slug' => static::get_wpcom_free_product_slug(),
184            ),
185            Wpcom_Products::get_product_pricing( static::get_wpcom_product_slug() )
186        );
187
188        $record_count   = intval( Search_Stats::estimate_count() );
189        $search_pricing = static::get_pricing_from_wpcom( $record_count );
190
191        if ( is_wp_error( $search_pricing ) ) {
192            // Default to the current pricing experience when the WPCOM fetch fails so the
193            // dashboard degrades to the production default, not the legacy single-card view.
194            $pricing['pricing_version'] = self::SEARCH_NEW_PRICING_VERSION;
195
196            // If the generic product pricing was also unavailable, fall back to a USD yearly
197            // starting price so the pricing grid shows a monthly amount instead of "$0".
198            if ( empty( $pricing['full_price'] ) ) {
199                $pricing['currency_code']  = 'USD';
200                $pricing['full_price']     = self::FALLBACK_STARTING_PRICE_USD;
201                $pricing['discount_price'] = self::FALLBACK_STARTING_PRICE_USD;
202                $pricing['product_term']   = 'year';
203            }
204
205            return $pricing;
206        }
207
208        $pricing['estimated_record_count'] = $record_count;
209
210        return array_merge( $pricing, $search_pricing );
211    }
212
213    /**
214     * Get the URL the user is taken after purchasing the product through the checkout
215     *
216     * @return ?string
217     */
218    public static function get_post_checkout_url() {
219        return self::get_manage_url();
220    }
221
222    /**
223     * Get the WPCOM product slug used to make the purchase
224     *
225     * @return ?string
226     */
227    public static function get_wpcom_product_slug() {
228        return 'jetpack_search';
229    }
230
231    /**
232     * Get the WPCOM free product slug
233     *
234     * @return ?string
235     */
236    public static function get_wpcom_free_product_slug() {
237        return 'jetpack_search_free';
238    }
239
240    /**
241     * Returns true if the new_pricing_202208 is set to not empty in URL for testing purpose, or it's active.
242     */
243    public static function is_new_pricing_202208() {
244        // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
245        if ( isset( $_GET['new_pricing_202208'] ) && $_GET['new_pricing_202208'] ) {
246            return true;
247        }
248
249        $record_count   = intval( Search_Stats::estimate_count() );
250        $search_pricing = static::get_pricing_from_wpcom( $record_count );
251        if ( is_wp_error( $search_pricing ) ) {
252            // Default to the current pricing experience when the WPCOM fetch fails.
253            return true;
254        }
255
256        return self::SEARCH_NEW_PRICING_VERSION === $search_pricing['pricing_version'];
257    }
258
259    /**
260     * Override status to `needs_activation` when status is `needs_plan`.
261     */
262    public static function get_status() {
263        $status = parent::get_status();
264        return $status;
265    }
266
267    /**
268     * Use centralized Search pricing API.
269     *
270     * The function is also used by the search package, as a result it could be called before site connection - i.e. blog token might not be available.
271     *
272     * @param int $record_count Record count to estimate pricing.
273     *
274     * @return array|WP_Error
275     */
276    public static function get_pricing_from_wpcom( $record_count ) {
277        static $pricings = array();
278        $connection      = new Connection_Manager();
279        $blog_id         = \Jetpack_Options::get_option( 'id' );
280
281        if ( isset( $pricings[ $record_count ] ) ) {
282            return $pricings[ $record_count ];
283        }
284
285        // If the site is connected, request pricing with the blog token
286        if ( $blog_id ) {
287            $endpoint = sprintf( '/jetpack-search/pricing?record_count=%1$d&locale=%2$s', $record_count, get_user_locale() );
288
289            // If available in the user data, set the user's currency as one of the params
290            if ( $connection->is_user_connected() ) {
291                $user_details = $connection->get_connected_user_data();
292                if ( ! empty( $user_details['user_currency'] ) && $user_details['user_currency'] !== 'USD' ) {
293                    $endpoint .= sprintf( '&currency=%s', $user_details['user_currency'] );
294                }
295            }
296
297            $response = Client::wpcom_json_api_request_as_blog(
298                $endpoint,
299                '2',
300                array( 'timeout' => 5 ),
301                null,
302                'wpcom'
303            );
304        } else {
305            $response = wp_remote_get(
306                sprintf( Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ) . '/wpcom/v2/jetpack-search/pricing?record_count=%1$d&locale=%2$s', $record_count, get_user_locale() ),
307                array( 'timeout' => 5 )
308            );
309        }
310
311        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
312            // Cache the failure too: get_pricing_for_ui() reaches this twice per request
313            // (once via has_trial_support(), once directly), and each miss is a 5s timeout.
314            $pricings[ $record_count ] = new WP_Error( 'search_pricing_fetch_failed' );
315            return $pricings[ $record_count ];
316        }
317
318        $body                      = wp_remote_retrieve_body( $response );
319        $pricings[ $record_count ] = json_decode( $body, true );
320        return $pricings[ $record_count ];
321    }
322
323    /**
324     * Checks whether the product supports trial or not
325     *
326     * Returns true if it supports. Return false otherwise.
327     *
328     * Free products will always return false.
329     *
330     * @return boolean
331     */
332    public static function has_trial_support() {
333        return static::is_new_pricing_202208();
334    }
335
336    /**
337     * Get the product-slugs of the paid plans for this product (not including bundles)
338     *
339     * @return array
340     */
341    public static function get_paid_plan_product_slugs() {
342        return array(
343            'jetpack_search',
344            'jetpack_search_monthly',
345            'jetpack_search_bi_yearly',
346        );
347    }
348
349    /**
350     * Checks if the site purchases contain a free search plan
351     *
352     * @return bool
353     */
354    public static function has_free_plan_for_product() {
355        $purchases_data = Wpcom_Products::get_site_current_purchases();
356        if ( is_wp_error( $purchases_data ) ) {
357            return false;
358        }
359        if ( is_array( $purchases_data ) && ! empty( $purchases_data ) ) {
360            foreach ( $purchases_data as $purchase ) {
361                if ( str_contains( $purchase->product_slug, 'jetpack_search_free' ) ) {
362                    return true;
363                }
364            }
365        }
366        return false;
367    }
368
369    /**
370     * Grant this site the free Search product without sending the user through a $0 checkout.
371     *
372     * Safe to call on every activate click: WordPress.com re-pushes its configuration to this
373     * site before answering `already_entitled`, which is how a half-finished activation repairs.
374     *
375     * @since 6.8.0
376     *
377     * @param string|null $source Where the activation was requested from, for reporting.
378     * @return array|WP_Error Decoded response body on success; on failure a WP_Error whose data
379     *                        carries `checkout_fallback` â€” true when the $0 checkout could still
380     *                        succeed, false when it would refuse the product too.
381     */
382    public static function activate_free_product( $source = null ) {
383        /*
384         * Both REST routes reach this, and their permission callbacks disagree on multisite,
385         * so the bar for creating the subscription is enforced here instead.
386         */
387        if ( ! current_user_can( 'activate_plugins' ) || ( is_multisite() && ! current_user_can( 'manage_network' ) ) ) {
388            return self::activation_error(
389                'rest_cannot_activate',
390                __( 'You are not allowed to activate Jetpack Search Free on this site.', 'jetpack-my-jetpack' ),
391                403,
392                false
393            );
394        }
395
396        /*
397         * A Simple site has no Jetpack connection to sign the request with, and buys the
398         * product from within WordPress.com anyway.
399         */
400        if ( ( new Host() )->is_wpcom_simple() ) {
401            return self::activation_error(
402                'not_supported_on_wpcom_simple',
403                __( 'Jetpack Search Free is activated through WordPress.com on this site.', 'jetpack-my-jetpack' ),
404                400,
405                true
406            );
407        }
408
409        $blog_id = \Jetpack_Options::get_option( 'id' );
410        if ( ! $blog_id ) {
411            return self::activation_error(
412                'site_not_registered',
413                __( 'Connect your site to WordPress.com to activate Jetpack Search.', 'jetpack-my-jetpack' ),
414                403,
415                true
416            );
417        }
418
419        /*
420         * Checked before the request because the subscription needs a WordPress.com user to own
421         * it and a blog token names nobody. Checkout signs the user in itself, so this is a
422         * fallback rather than a failure.
423         */
424        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
425            return self::activation_error(
426                'no_connected_user',
427                __( 'Activating Jetpack Search Free requires a connected WordPress.com user.', 'jetpack-my-jetpack' ),
428                403,
429                true
430            );
431        }
432
433        // WordPress.com records an unfamiliar source as `unknown` rather than refusing, so pass
434        // through whatever the caller sent instead of dropping values this copy predates.
435        $body = array();
436        if ( is_string( $source ) && $source !== '' ) {
437            $body['source'] = sanitize_key( $source );
438        }
439
440        $response = Client::wpcom_json_api_request_as_user(
441            '/sites/' . $blog_id . '/jetpack-search/activate-free',
442            '2',
443            // Well over the 10s default: the grant re-pushes Search configuration to this site
444            // inside the request, so a timeout here can abandon a subscription already created.
445            array(
446                'method'  => 'POST',
447                'timeout' => 45,
448            ),
449            $body,
450            'wpcom'
451        );
452
453        // Never reached WordPress.com, so checkout may still work.
454        if ( is_wp_error( $response ) ) {
455            return self::activation_error(
456                'jetpack_search_free_activation_failed',
457                $response->get_error_message(),
458                500,
459                true
460            );
461        }
462
463        $status = wp_remote_retrieve_response_code( $response );
464        $result = json_decode( wp_remote_retrieve_body( $response ), true );
465
466        if ( 200 !== $status || ! is_array( $result ) || empty( $result['success'] ) ) {
467            return self::wpcom_activation_error( $result, $status );
468        }
469
470        $result['local_activation'] = self::sync_local_state_after_free_activation();
471
472        return $result;
473    }
474
475    /**
476     * Rebuild a refusal from WordPress.com as a local WP_Error.
477     *
478     * Only a body carrying `checkout_fallback` came from the endpoint. Anything else â€” a 404
479     * before it deploys, a proxy error page â€” means it was never reached, and checkout still
480     * works, so those fall back rather than dead-ending the user.
481     *
482     * @param array|null $result Decoded response body, when it parsed.
483     * @param int        $status HTTP status code.
484     * @return WP_Error
485     */
486    private static function wpcom_activation_error( $result, $status ) {
487        $data = isset( $result['data'] ) && is_array( $result['data'] ) ? $result['data'] : array();
488        $code = isset( $result['code'] ) && is_string( $result['code'] ) ? $result['code'] : '';
489
490        /*
491         * The flag, not the code, is what marks an answer as the endpoint's own: WordPress
492         * raises `rest_no_route` with a code too, and that one means checkout still works.
493         */
494        $is_endpoint_refusal = array_key_exists( 'checkout_fallback', $data );
495
496        if ( ! $is_endpoint_refusal ) {
497            /*
498             * Always 502, never the upstream status. This route exists; WordPress.com answering
499             * 404 before the endpoint deploys is not our 404 â€” and relaying it would have the
500             * REST client throw a bare Api404Error with no body, losing the `checkout_fallback`
501             * that sends the user to checkout in exactly that window.
502             */
503            return self::activation_error(
504                'jetpack_search_free_activation_failed',
505                __( 'Jetpack Search Free could not be activated for this site.', 'jetpack-my-jetpack' ),
506                502,
507                true
508            );
509        }
510
511        return self::activation_error(
512            $code !== '' ? $code : 'jetpack_search_free_activation_failed',
513            $result['message'] ?? __( 'Jetpack Search Free could not be activated for this site.', 'jetpack-my-jetpack' ),
514            self::error_status( $data, $status ),
515            ! empty( $data['checkout_fallback'] ),
516            $data
517        );
518    }
519
520    /**
521     * Pick an error status for an enumerated refusal, never a 2xx.
522     *
523     * A success status on a WP_Error reaches the browser as a 200, which both REST clients read
524     * as a granted product.
525     *
526     * @param array $data   Error data from WordPress.com.
527     * @param int   $status HTTP status of the response.
528     * @return int
529     */
530    private static function error_status( $data, $status ) {
531        foreach ( array( $data['status'] ?? 0, $status ) as $candidate ) {
532            if ( (int) $candidate >= 400 ) {
533                return (int) $candidate;
534            }
535        }
536
537        return 502;
538    }
539
540    /**
541     * Build a refusal carrying the `checkout_fallback` flag the UI branches on.
542     *
543     * @param string $code              Machine readable error code.
544     * @param string $message           Human readable message.
545     * @param int    $status            HTTP status.
546     * @param bool   $checkout_fallback Whether the caller should fall back to checkout.
547     * @param array  $extra_data        Extra data from WordPress.com to preserve.
548     * @return WP_Error
549     */
550    private static function activation_error( $code, $message, $status, $checkout_fallback, $extra_data = array() ) {
551        return new WP_Error(
552            $code,
553            $message,
554            array_merge(
555                $extra_data,
556                array(
557                    'status'            => $status,
558                    'checkout_fallback' => (bool) $checkout_fallback,
559                )
560            )
561        );
562    }
563
564    /**
565     * Bring this site's Search state in line after WordPress.com grants the product.
566     *
567     * WordPress.com writes the plan option from a separate request, so the copy cached in this
568     * process predates it and has to be re-read. A site already entitled through a plan or
569     * bundle gets no such request at all, and this is the only thing that turns Search on.
570     *
571     * @return string `activated`, `unavailable` when this plugin does not ship jetpack-search,
572     *                or an error code the caller can surface â€” re-running the activation repairs it.
573     */
574    private static function sync_local_state_after_free_activation() {
575        if ( ! class_exists( 'Automattic\Jetpack\Search\Plan' ) ) {
576            return 'unavailable';
577        }
578
579        $plan_info = ( new \Automattic\Jetpack\Search\Plan() )->get_plan_info_from_wpcom();
580        if ( is_wp_error( $plan_info ) ) {
581            return $plan_info->get_error_code();
582        }
583
584        if ( ! class_exists( 'Automattic\Jetpack\Search\Module_Control' ) ) {
585            return 'unavailable';
586        }
587
588        $module_control = new Search_Module_Control();
589        $activated      = $module_control->activate();
590
591        if ( is_wp_error( $activated ) ) {
592            return $activated->get_error_code();
593        }
594
595        self::set_default_experience_if_unset( $module_control );
596
597        return $activated ? 'activated' : 'already_active';
598    }
599
600    /**
601     * Give a site with no experience of its own the same one WordPress.com would have pushed.
602     *
603     * Only fires when nothing is stored: deactivating Search deliberately keeps the choice for
604     * the next re-activation, so a site that picked Embedded must not be flipped to Overlay.
605     *
606     * @param Search_Module_Control $module_control Module control for this site.
607     */
608    private static function set_default_experience_if_unset( $module_control ) {
609        if ( get_option( Search_Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ) ) {
610            return;
611        }
612
613        // Mirrors Jetpack_Plans_Search::DEFAULT_SEARCH_EXPERIENCE on the WordPress.com side.
614        $module_control->update_experience( Search_Module_Control::EXPERIENCE_OVERLAY );
615    }
616
617    /**
618     * Activates the product. Try to enable instant search after the Search module was enabled.
619     *
620     * @param bool|WP_Error $product_activation Is the result of the top level activation actions. You probably won't do anything if it is an WP_Error.
621     * @param bool          $local              Skip connected Search setup.
622     * @return bool|WP_Error
623     */
624    public static function do_product_specific_activation( $product_activation, $local = false ) {
625        $product_activation = parent::do_product_specific_activation( $product_activation, $local );
626        if ( is_wp_error( $product_activation ) || $local ) {
627            return $product_activation;
628        }
629
630        if ( class_exists( 'Automattic\Jetpack\Search\Module_Control' ) ) {
631            ( new Search_Module_Control() )->enable_instant_search();
632        }
633
634        // we don't want to change the success of the activation if we fail to activate instant search. That's not mandatory.
635        return $product_activation;
636    }
637
638    /**
639     * Get the URL the user is taken after activating the product
640     *
641     * @return ?string
642     */
643    public static function get_post_activation_url() {
644        return ''; // stay in My Jetpack page or continue the purchase flow if needed.
645    }
646
647    /**
648     * Get the URL where the user manages the product
649     *
650     * @return ?string
651     */
652    public static function get_manage_url() {
653        return admin_url( 'admin.php?page=jetpack-search' );
654    }
655
656    /**
657     * Return product bundles list
658     * that supports the product.
659     *
660     * @return boolean|array Products bundle list.
661     */
662    public static function is_upgradable_by_bundle() {
663        return array( 'complete' );
664    }
665}