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