Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.41% covered (success)
94.41%
152 / 161
94.12% covered (success)
94.12%
16 / 17
CRAP
n/a
0 / 0
wpcom_marketplace_register_flag
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
wpcom_marketplace_tab_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_marketplace_add_tab
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
wpcom_marketplace_serve_plugins_api
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
wpcom_marketplace_table_args
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
wpcom_marketplace_on_plugin_install_screen
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
wpcom_marketplace_is_installed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
wpcom_marketplace_needs_plan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
wpcom_marketplace_term_noun
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
wpcom_marketplace_card_description
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
wpcom_marketplace_price_rows
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
wpcom_marketplace_card_button
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
9.03
wpcom_marketplace_tab_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_marketplace_render_tab
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
wpcom_marketplace_body_class
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_marketplace_intro
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
wpcom_marketplace_render_table
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Lists the plugins WordPress.com sells as a tab on the core Add Plugins screen.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8use Automattic\Jetpack\Feature_Flags\Feature_Flags;
9use Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices\Expiry_Owner;
10use Automattic\Jetpack\Jetpack_Mu_Wpcom\Marketplace_Catalog;
11
12/**
13 * The tab's slug, which is also its `?tab=` value.
14 */
15const WPCOM_MARKETPLACE_TAB = 'wpcom-marketplace';
16
17/**
18 * Feature flag gating the tab.
19 */
20const WPCOM_MARKETPLACE_TAB_FLAG = 'wpcom-plugins-marketplace-tab';
21
22/**
23 * Registers the feature flag.
24 *
25 * Runs as this file loads, so the flag is listed wherever flags are read or toggled.
26 *
27 * @return void
28 */
29function wpcom_marketplace_register_flag() {
30    Feature_Flags::register(
31        WPCOM_MARKETPLACE_TAB_FLAG,
32        array(
33            // On for every site. Kept as an off switch, per site or for all, until the tab has settled.
34            'default'     => true,
35            'description' => 'Show WordPress.com partner and premium plugins as a Marketplace tab on the Add Plugins screen.',
36            'owner'       => 'jetpack-mu-wpcom',
37        )
38    );
39}
40wpcom_marketplace_register_flag();
41
42/**
43 * Whether to show the tab on this site.
44 *
45 * @return bool
46 */
47function wpcom_marketplace_tab_enabled() {
48    return Feature_Flags::is_enabled( WPCOM_MARKETPLACE_TAB_FLAG );
49}
50
51/**
52 * Adds the tab to the Add Plugins screen, ahead of Featured.
53 *
54 * Core lands on whichever tab comes first when none is requested, so this also
55 * makes Marketplace the screen's default view. Placed before Featured rather than
56 * at the very front, which leaves core's own Search Results and Beta Testing tabs
57 * first on the screens that add them.
58 *
59 * @param string[] $tabs Tabs shown on the Add Plugins screen.
60 * @return string[]
61 */
62function wpcom_marketplace_add_tab( $tabs ) {
63    if ( ! wpcom_marketplace_tab_enabled() || ! is_array( $tabs ) ) {
64        return $tabs;
65    }
66
67    $label = _x( 'Marketplace', 'Plugin Installer', 'jetpack-mu-wpcom' );
68
69    $position = array_search( 'featured', array_keys( $tabs ), true );
70    if ( false === $position ) {
71        return array_merge( array( WPCOM_MARKETPLACE_TAB => $label ), $tabs );
72    }
73
74    return array_merge(
75        array_slice( $tabs, 0, $position, true ),
76        array( WPCOM_MARKETPLACE_TAB => $label ),
77        array_slice( $tabs, $position, null, true )
78    );
79}
80add_filter( 'install_plugins_tabs', 'wpcom_marketplace_add_tab' );
81
82/**
83 * Answers the plugin API from the marketplace catalog.
84 *
85 * Serves the tab's listing, asked for by wpcom_marketplace_table_args(), and the details
86 * modal for any of our products.
87 *
88 * @param false|object|WP_Error $result The result object or array. Default false.
89 * @param string                $action The type of information being requested.
90 * @param object                $args   Plugin API arguments.
91 * @return false|object|WP_Error
92 */
93function wpcom_marketplace_serve_plugins_api( $result, $action, $args ) {
94    if ( false !== $result || ! wpcom_marketplace_tab_enabled() ) {
95        return $result;
96    }
97
98    if ( 'query_plugins' === $action && ! empty( $args->wpcom_marketplace ) ) {
99        $products = Marketplace_Catalog::get_products();
100
101        // Core's list table shows this with a Try Again button.
102        if ( empty( $products ) ) {
103            return new WP_Error( 'wpcom_marketplace_unavailable', __( 'These plugins could not be loaded right now. Please try again in a few minutes.', 'jetpack-mu-wpcom' ) );
104        }
105
106        return (object) array(
107            'info'    => array(
108                'page'    => 1,
109                'pages'   => 1,
110                'results' => count( $products ),
111            ),
112            'plugins' => array_values( $products ),
113        );
114    }
115
116    // Scoped to the plugin screens: plugins_api( 'plugin_information' ) is called from
117    // unrelated admin pages too, and none of those should pay for a catalog fetch.
118    if ( 'plugin_information' === $action && ! empty( $args->slug ) && wpcom_marketplace_on_plugin_install_screen() ) {
119        $product = Marketplace_Catalog::get_product_details( (string) $args->slug );
120
121        if ( null !== $product ) {
122            return (object) $product;
123        }
124    }
125
126    return $result;
127}
128add_filter( 'plugins_api', 'wpcom_marketplace_serve_plugins_api', 10, 3 );
129
130/**
131 * Has core's plugin list table ask for the whole catalog on this tab, on one page.
132 *
133 * Core has no query for a tab it does not know and passes false. The marker is what
134 * wpcom_marketplace_serve_plugins_api() answers.
135 *
136 * @param array|false $args Plugin API arguments core built for this tab.
137 * @return array|false
138 */
139function wpcom_marketplace_table_args( $args ) {
140    if ( ! wpcom_marketplace_tab_enabled() ) {
141        return $args;
142    }
143
144    return array(
145        'page'              => 1,
146        'per_page'          => max( 1, count( Marketplace_Catalog::get_products() ) ),
147        'locale'            => get_user_locale(),
148        'wpcom_marketplace' => true,
149    );
150}
151add_filter( 'install_plugins_table_api_args_' . WPCOM_MARKETPLACE_TAB, 'wpcom_marketplace_table_args' );
152
153/**
154 * Whether this request is the Add Plugins screen, the details modal included.
155 *
156 * Simple sites draw that screen from their own page, which calls itself core's screen.
157 *
158 * @return bool
159 */
160function wpcom_marketplace_on_plugin_install_screen() {
161    if ( isset( $GLOBALS['pagenow'] ) && 'plugin-install.php' === $GLOBALS['pagenow'] ) {
162        return true;
163    }
164
165    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
166
167    return null !== $screen && 'plugin-install' === $screen->id;
168}
169
170/**
171 * Whether a plugin is on this site already.
172 *
173 * Simple sites draw the Add Plugins screen without core's plugin install functions, and have
174 * nothing installed.
175 *
176 * @param array $plugin Plugin data.
177 * @return bool
178 */
179function wpcom_marketplace_is_installed( array $plugin ) {
180    return function_exists( 'install_plugin_install_status' ) && 'install' !== install_plugin_install_status( $plugin )['status'];
181}
182
183/**
184 * Whether buying a product here also means buying a plan, as on a free Simple site.
185 *
186 * @return bool
187 */
188function wpcom_marketplace_needs_plan() {
189    return function_exists( 'wpcom_site_has_feature' ) && ! wpcom_site_has_feature( 'install-purchased-plugins' );
190}
191
192/**
193 * The billing term the tab sells at.
194 *
195 * Yearly only. Every product has both variations, but a switcher for the whole
196 * screen was a lot of furniture for a choice that belongs to one purchase, and
197 * checkout lets people change the term there with the product in front of them.
198 */
199const WPCOM_MARKETPLACE_TERM = 'yearly';
200
201/**
202 * The noun a price is read with, as in "$10.00/month".
203 *
204 * @param string $term 'yearly' or 'monthly'.
205 * @return string
206 */
207function wpcom_marketplace_term_noun( $term ) {
208    return 'monthly' === $term
209        ? __( 'month', 'jetpack-mu-wpcom' )
210        : __( 'year', 'jetpack-mu-wpcom' );
211}
212
213/**
214 * A card's description, as plain text cut to fit core's card.
215 *
216 * WordPress.org caps short descriptions at 150 characters, which is what core's card is sized for.
217 *
218 * @param array $card Normalized product data.
219 * @return string
220 */
221function wpcom_marketplace_card_description( array $card ) {
222    $description = wp_strip_all_tags( (string) ( $card['short_description'] ?? '' ) );
223    if ( mb_strlen( $description ) <= 150 ) {
224        return $description;
225    }
226
227    $cut   = mb_substr( $description, 0, 150 );
228    $space = mb_strrpos( $cut, ' ' );
229
230    return rtrim( false === $space ? $cut : mb_substr( $cut, 0, $space ), ' .,;:' ) . '…';
231}
232
233/**
234 * The price as its two rows of markup: the headline, and the line under it.
235 *
236 * The headline is the yearly price, which is what the button charges, with the saving
237 * beside it. The monthly price follows so the saving can be checked rather than taken on
238 * trust. Both are prices a buyer can really be charged: neither is the year divided by twelve.
239 *
240 * @param array $card Normalized product data.
241 * @return array{headline: string, note: string} Escaped markup, empty when there is nothing to show.
242 */
243function wpcom_marketplace_price_rows( array $card ) {
244    // The vendor sets a referral's price, so it gets Calypso's list-card wording, not the store's figures.
245    if ( Marketplace_Catalog::is_referral( $card ) ) {
246        return array(
247            'headline' => '<span class="wpcom-marketplace-card__amount">' . esc_html__( 'Start for free', 'jetpack-mu-wpcom' ) . '</span>',
248            'note'     => '',
249        );
250    }
251
252    $pricing = $card['wpcom_pricing'] ?? array();
253    $yearly  = (string) ( $pricing[ WPCOM_MARKETPLACE_TERM ]['price'] ?? '' );
254    $monthly = (string) ( $pricing['monthly']['price'] ?? '' );
255    $saving  = (int) ( $card['wpcom_saving'] ?? 0 );
256
257    if ( '' === $yearly && '' === $monthly ) {
258        return array(
259            'headline' => '',
260            'note'     => '',
261        );
262    }
263
264    // Only a product we cannot sell by the year falls back to pricing by the month.
265    $has_yearly = '' !== $yearly;
266
267    $headline = sprintf(
268        '<span class="wpcom-marketplace-card__amount">%s</span> <span class="wpcom-marketplace-card__per">/%s</span>',
269        esc_html( $has_yearly ? $yearly : $monthly ),
270        esc_html( wpcom_marketplace_term_noun( $has_yearly ? 'yearly' : 'monthly' ) )
271    );
272    if ( $has_yearly && $saving >= 5 ) {
273        /* translators: %d: Percentage saved, for example 31. */
274        $headline .= ' <span class="wpcom-marketplace-card__saving">' . esc_html( sprintf( __( 'Save %d%%', 'jetpack-mu-wpcom' ), $saving ) ) . '</span>';
275    }
276
277    $note = '';
278    if ( wpcom_marketplace_needs_plan() ) {
279        /**
280         * Filters the line under a card's price on a site that buys a plan along with the product.
281         *
282         * It replaces the monthly price, since the plan is the other thing the buyer pays for.
283         *
284         * @since $$next-version$$
285         *
286         * @param string $note Plain text.
287         */
288        $note = '<span class="wpcom-marketplace-card__note">' . esc_html( (string) apply_filters( 'wpcom_marketplace_plan_note', __( 'Needs a paid plan', 'jetpack-mu-wpcom' ) ) ) . '</span>';
289    } elseif ( $has_yearly && '' !== $monthly ) {
290        // Worded as a condition, not "or": the button buys the year, so this is only what it is measured against.
291        /* translators: %s: Price per month, for example $9.90. */
292        $note = '<span class="wpcom-marketplace-card__note">' . esc_html( sprintf( __( '%s/month if billed monthly', 'jetpack-mu-wpcom' ), $monthly ) ) . '</span>';
293    }
294
295    return array(
296        'headline' => $headline,
297        'note'     => $note,
298    );
299}
300
301/**
302 * The card's action, which is a purchase unless the plugin is already here.
303 *
304 * Installed products keep core's button: Marketplace_Products_Updater already gives
305 * core the right package URL, so Activate, Update and Active all behave.
306 *
307 * @param array  $card     Normalized product data.
308 * @param string $back_url Where checkout's Back link returns to. Defaults to the Marketplace tab.
309 * @return string Button markup.
310 */
311function wpcom_marketplace_card_button( array $card, $back_url = '' ) {
312    $name = (string) ( $card['name'] ?? $card['slug'] ?? '' );
313
314    if ( wpcom_marketplace_is_installed( $card ) ) {
315        return function_exists( 'wp_get_plugin_action_button' )
316            ? wp_get_plugin_action_button( $name, $card, true, true )
317            : '';
318    }
319
320    /*
321     * Checkout cannot complete a referral: the subscription is the vendor's to sell.
322     * Sending someone there would take payment for the wrong thing.
323     */
324    if ( Marketplace_Catalog::is_referral( $card ) ) {
325        if ( '' === (string) ( $card['wpcom_referral_url'] ?? '' ) ) {
326            return '';
327        }
328
329        $referral = Marketplace_Catalog::referral_url( $card, (int) Expiry_Owner::current_user_wpcom_id() );
330        /* translators: %s: Plugin name. */
331        $label = __( 'Get started with %s on the vendor site', 'jetpack-mu-wpcom' );
332
333        // With no WordPress.com account to refer, Calypso's product page can sign them in first.
334        if ( '' === $referral ) {
335            $referral = Marketplace_Catalog::product_url( $card['wpcom_product_slug'] ?? $card['slug'] );
336            /* translators: %s: Plugin name. */
337            $label = __( 'Get started with %s', 'jetpack-mu-wpcom' );
338        }
339
340        // No noreferrer: Calypso's link lets the vendor see where the visit came from, and so does this one.
341        return sprintf(
342            '<a class="button button-compact" href="%s" target="_blank" rel="noopener" data-wpcom-marketplace-track="get_started" aria-label="%s">%s</a>',
343            esc_url( $referral ),
344            esc_attr( sprintf( $label, $name ) ),
345            esc_html__( 'Get started', 'jetpack-mu-wpcom' )
346        );
347    }
348
349    $checkout = Marketplace_Catalog::checkout_url( $card, WPCOM_MARKETPLACE_TERM, '' === $back_url ? wpcom_marketplace_tab_url() : $back_url );
350
351    /**
352     * Filters where a Marketplace card's Purchase button leads.
353     *
354     * Simple sites add the plan the purchase needs, or send it through Calypso when the purchase
355     * cannot go ahead.
356     *
357     * @since 6.11.0
358     *
359     * @param string $checkout Checkout URL, or empty when there is no store product.
360     * @param array  $card     The product's card.
361     */
362    $checkout = (string) apply_filters( 'wpcom_marketplace_checkout_url', $checkout, $card );
363
364    // Without a store product there is nothing to buy, so fall back to the product page.
365    if ( '' === $checkout ) {
366        return sprintf(
367            '<a class="button button-compact" href="%s" data-wpcom-marketplace-track="get_started" aria-label="%s">%s</a>',
368            esc_url( Marketplace_Catalog::product_url( $card['wpcom_product_slug'] ?? $card['slug'] ) ),
369            /* translators: %s: Plugin name. */
370            esc_attr( sprintf( __( 'Get started with %s', 'jetpack-mu-wpcom' ), $name ) ),
371            esc_html__( 'Get started', 'jetpack-mu-wpcom' )
372        );
373    }
374
375    // A site whose plan does not include it buys one in the same checkout, through the filter above.
376    if ( wpcom_marketplace_needs_plan() ) {
377        return sprintf(
378            '<a class="button button-compact wpcom-marketplace-card__upgrade" href="%s" data-wpcom-marketplace-track="purchase" aria-label="%s">%s</a>',
379            esc_url( $checkout ),
380            /* translators: %s: Plugin name. */
381            esc_attr( sprintf( __( 'Upgrade your plan and purchase %s', 'jetpack-mu-wpcom' ), $name ) ),
382            esc_html__( 'Upgrade and purchase', 'jetpack-mu-wpcom' )
383        );
384    }
385
386    return sprintf(
387        '<a class="button button-compact" href="%s" data-wpcom-marketplace-track="purchase" aria-label="%s">%s</a>',
388        esc_url( $checkout ),
389        /* translators: %s: Plugin name. */
390        esc_attr( sprintf( __( 'Purchase and activate %s', 'jetpack-mu-wpcom' ), $name ) ),
391        esc_html__( 'Purchase', 'jetpack-mu-wpcom' )
392    );
393}
394
395/**
396 * The tab's own URL, which is where checkout's Back link should return to.
397 *
398 * @return string
399 */
400function wpcom_marketplace_tab_url() {
401    return add_query_arg( 'tab', WPCOM_MARKETPLACE_TAB, self_admin_url( 'plugin-install.php' ) );
402}
403
404/**
405 * Loads the tab's styles.
406 *
407 * @return void
408 */
409function wpcom_marketplace_render_tab() {
410    add_filter( 'admin_body_class', 'wpcom_marketplace_body_class' );
411
412    wp_enqueue_style(
413        'wpcom-marketplace-tab',
414        plugins_url( 'css/marketplace-tab.css', __FILE__ ),
415        array(),
416        (string) filemtime( __DIR__ . '/css/marketplace-tab.css' )
417    );
418
419    \Automattic\Jetpack\Jetpack_Mu_Wpcom\Common\wpcom_enqueue_tracking_scripts(
420        jetpack_mu_wpcom_enqueue_assets( 'wpcom-marketplace-tab', array( 'js' ) )
421    );
422}
423add_action( 'install_plugins_pre_' . WPCOM_MARKETPLACE_TAB, 'wpcom_marketplace_render_tab' );
424
425/**
426 * Flags the screen so the stylesheet can scope itself to this tab.
427 *
428 * @param string $classes Space-separated admin body classes.
429 * @return string
430 */
431function wpcom_marketplace_body_class( $classes ) {
432    return $classes . ' wpcom-marketplace-tab ';
433}
434
435/**
436 * Says what these plugins are, above the cards.
437 *
438 * @return void
439 */
440function wpcom_marketplace_intro() {
441    printf(
442        '<p class="wpcom-marketplace-intro">%s</p>',
443        esc_html__( 'Premium plugins from WordPress.com and our partners. Each one comes with a subscription, and is installed, updated, and supported for you.', 'jetpack-mu-wpcom' )
444    );
445}
446
447/**
448 * Draws the tab with core's plugin list table, as core draws its own tabs.
449 *
450 * The cards are core's, shaped by the same filters as search results (see
451 * wpcom-marketplace-cards.php). Mirrors display_plugins_table(), except the intro sits
452 * inside #plugin-filter, which core's live search empties before showing its results.
453 *
454 * @return void
455 */
456function wpcom_marketplace_render_table() {
457    global $wp_list_table;
458
459    echo '<form id="plugin-filter" method="post">';
460    wpcom_marketplace_intro();
461    if ( is_object( $wp_list_table ) && method_exists( $wp_list_table, 'display' ) ) {
462        $wp_list_table->display();
463    }
464    echo '</form>';
465}
466add_action( 'install_plugins_' . WPCOM_MARKETPLACE_TAB, 'wpcom_marketplace_render_table' );