Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.67% covered (warning)
89.67%
191 / 213
78.95% covered (warning)
78.95%
15 / 19
CRAP
n/a
0 / 0
wpcom_themes_get_purchases
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
wpcom_themes_tab_register_flag
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
wpcom_themes_tab_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_enqueue_script
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_is_default_view
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_redirect_to_default_view
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
wpcom_themes_tab_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_themes_tab_checkout_url
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
wpcom_themes_tab_add_plan
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
wpcom_themes_tab_is_included
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
wpcom_themes_tab_add_subscription
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
wpcom_themes_tab_subscription
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
wpcom_themes_tab_api
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_store
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_mapper
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_catalog
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
wpcom_themes_tab_serve_themes_api
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
13
wpcom_themes_tab_search_partners
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
wpcom_themes_tab_splice_search_partners
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
17
1<?php
2/**
3 * Lists WordPress.com themes as a tab on the core Add Themes screen.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8use Automattic\Jetpack\Feature_Flags\Feature_Flags;
9use Automattic\Jetpack\Jetpack_Mu_Wpcom\Common;
10
11// @codeCoverageIgnoreStart -- shadowed by the test stub in tests/lib/functions-wordpress.php.
12if ( ! function_exists( 'wpcom_themes_get_purchases' ) ) {
13    /**
14     * Source of purchase data for the themes tab, pre-definable by a test as
15     * wpcom_expiry_get_purchases() is, and for the reason given there.
16     *
17     * @return array
18     *
19     * @phan-suppress PhanRedefineFunction -- phan sees both this and the test stub as definitions even though only one loads at runtime.
20     */
21    function wpcom_themes_get_purchases() {
22        if ( function_exists( 'wpcom_get_site_purchases' ) ) {
23            return wpcom_get_site_purchases();
24        }
25
26        return array();
27    }
28}
29// @codeCoverageIgnoreEnd
30
31/**
32 * The tab's `data-sort` value, which core's theme.js sends to themes_api() as `browse`.
33 */
34const WPCOM_THEMES_TAB = 'wpcom';
35
36/**
37 * Feature flag gating the tab.
38 */
39const WPCOM_THEMES_TAB_FLAG = 'wpcom-themes-marketplace-tab';
40
41/**
42 * Themes per request, which is also the grid's page size.
43 */
44const WPCOM_THEMES_TAB_PER_PAGE = 100;
45
46/**
47 * The `wpcom_pricing` value that lists only the themes this site can install with nothing to buy.
48 */
49const WPCOM_THEMES_TAB_INCLUDED = 'included';
50
51/**
52 * Registers the feature flag.
53 *
54 * @return void
55 */
56function wpcom_themes_tab_register_flag() {
57    Feature_Flags::register(
58        WPCOM_THEMES_TAB_FLAG,
59        array(
60            'default'     => false,
61            'description' => 'Show WordPress.com themes as a tab on the Add Themes screen.',
62            'owner'       => 'jetpack-mu-wpcom',
63        )
64    );
65}
66wpcom_themes_tab_register_flag();
67
68/**
69 * Whether to show the tab on this site.
70 *
71 * Atomic only for now: Simple has no Add Themes screen to add a tab to, and this package ships
72 * there too, so the host is checked rather than left to the screen's absence.
73 *
74 * @return bool
75 */
76function wpcom_themes_tab_enabled() {
77    return is_woa_site() && Feature_Flags::is_enabled( WPCOM_THEMES_TAB_FLAG );
78}
79
80/**
81 * Loads the script that adds the tab.
82 *
83 * Core hard-codes this screen's tabs with no filter (trac #65979), so the script inserts ours
84 * into the markup before core's theme.js reads it on DOM ready.
85 *
86 * @return void
87 */
88function wpcom_themes_tab_enqueue_script() {
89    if ( ! wpcom_themes_tab_enabled() ) {
90        return;
91    }
92
93    // Core's theme.js owns the views this decorates, and routes on DOM ready rather than on load.
94    wp_enqueue_script(
95        'wpcom-themes-tab',
96        plugins_url( 'js/themes-tab.js', __FILE__ ),
97        array( 'theme' ),
98        // The package version does not change between builds of it, and these are served for a year.
99        (string) filemtime( __DIR__ . '/js/themes-tab.js' ),
100        array( 'in_footer' => true )
101    );
102    wp_localize_script(
103        'wpcom-themes-tab',
104        'wpcomThemesTab',
105        array(
106            'sort'           => WPCOM_THEMES_TAB,
107            'label'          => _x( 'Marketplace', 'Theme Installer', 'jetpack-mu-wpcom' ),
108            'upgrade'        => __( 'Upgrade', 'jetpack-mu-wpcom' ),
109            /* translators: %s: Theme name. */
110            'upgradeLabel'   => __( 'Upgrade for %s', 'jetpack-mu-wpcom' ),
111            'subscribe'      => __( 'Subscribe', 'jetpack-mu-wpcom' ),
112            /* translators: %s: Theme name. */
113            'subscribeLabel' => __( 'Subscribe to %s', 'jetpack-mu-wpcom' ),
114            'pricingLabel'   => __( 'Filter themes by plan', 'jetpack-mu-wpcom' ),
115            'allPlans'       => __( 'All plans', 'jetpack-mu-wpcom' ),
116            'included'       => WPCOM_THEMES_TAB_INCLUDED,
117            'includedLabel'  => __( 'Included with my plan', 'jetpack-mu-wpcom' ),
118            'products'       => WPCom_Themes_Store::PRODUCTS_URL,
119            /* translators: %s: Price per month, for example $7.20. */
120            'price'          => __( '%s/mo, billed annually', 'jetpack-mu-wpcom' ),
121            /* translators: 1: Price per month, for example $7.20. 2: Plan name, for example Personal plan. */
122            'priceWithPlan'  => __( '%1$s/mo, billed annually + %2$s', 'jetpack-mu-wpcom' ),
123        )
124    );
125    wp_enqueue_style(
126        'wpcom-themes-tab',
127        plugins_url( 'css/themes-tab.css', __FILE__ ),
128        array(),
129        (string) filemtime( __DIR__ . '/css/themes-tab.css' )
130    );
131
132    Common\wpcom_enqueue_tracking_scripts( jetpack_mu_wpcom_enqueue_assets( 'wpcom-themes-tab-tracks', array( 'js' ) ) );
133}
134add_action( 'load-theme-install.php', 'wpcom_themes_tab_enqueue_script' );
135
136/**
137 * Whether a request to the Add Themes screen should land on this tab.
138 *
139 * @param array $query The request's query args.
140 * @return bool
141 */
142function wpcom_themes_tab_is_default_view( array $query ) {
143    if ( ! wpcom_themes_tab_enabled() ) {
144        return false;
145    }
146
147    // An explicit view, a search, an upload or a preview is left as asked for.
148    return ! array_intersect( array( 'browse', 'theme', 'search', 'upload' ), array_keys( $query ) );
149}
150
151/**
152 * Sends a bare Add Themes request to this tab.
153 *
154 * Core's theme.js routes a bare `theme-install.php` to `popular` and rewrites the URL to match, so
155 * the view has to be chosen before that runs rather than by ordering the tabs.
156 *
157 * @return void
158 */
159function wpcom_themes_tab_redirect_to_default_view() {
160    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads which view was asked for, nothing more.
161    if ( ! wpcom_themes_tab_is_default_view( $_GET ) ) {
162        return;
163    }
164
165    wp_safe_redirect( wpcom_themes_tab_url() );
166    exit;
167}
168add_action( 'load-theme-install.php', 'wpcom_themes_tab_redirect_to_default_view' );
169/**
170 * This tab's own URL.
171 *
172 * @return string
173 */
174function wpcom_themes_tab_url() {
175    return admin_url( 'theme-install.php?browse=' . WPCOM_THEMES_TAB );
176}
177
178/**
179 * Where to buy the plan a theme needs.
180 *
181 * Checkout returns to the theme itself, where the plan now allows an install, and back out to
182 * the tab if it is abandoned instead.
183 *
184 * @param string|array $products One product slug, or the several checkout should charge for at once.
185 * @param string       $slug     Theme slug.
186 * @return string
187 */
188function wpcom_themes_tab_checkout_url( $products, string $slug ) {
189    $url = sprintf(
190        'https://wordpress.com/checkout/%s/%s',
191        rawurlencode( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ),
192        // Checkout splits this segment on commas, so each slug is encoded but the separator is not.
193        implode( ',', array_map( 'rawurlencode', (array) $products ) )
194    );
195
196    // Checkout honours these on the site's own host, and add_query_arg() does not encode values.
197    return add_query_arg(
198        array(
199            'checkoutBackUrl' => rawurlencode( wpcom_themes_tab_url() ),
200            'redirect_to'     => rawurlencode( admin_url( 'theme-install.php?theme=' . rawurlencode( $slug ) ) ),
201        ),
202        $url
203    );
204}
205
206/**
207 * Says what a theme costs this site, for the badge every card carries.
208 *
209 * Core's grid offers Install for whatever it is given, so a theme the plan excludes also has to
210 * carry the upgrade that belongs in its place.
211 *
212 * @param stdClass $theme A theme in the themes API shape.
213 * @return stdClass The theme, carrying its badge and, when the plan excludes it, an upgrade.
214 */
215function wpcom_themes_tab_add_plan( $theme ) {
216    $theme->wpcom_marketplace_tab = true;
217
218    $tier = isset( $theme->theme_tier ) ? (string) $theme->theme_tier : '';
219
220    // A partner theme is sold rather than granted, so its badge is a price and not a plan.
221    if ( WPCom_Themes_Tiers::PARTNER === $tier ) {
222        return wpcom_themes_tab_add_subscription( $theme );
223    }
224
225    if ( wpcom_themes_tab_is_included( $tier, (string) ( $theme->slug ?? '' ) ) ) {
226        $theme->plan_badge = __( 'Included with plan', 'jetpack-mu-wpcom' );
227
228        return $theme;
229    }
230
231    $plan                 = WPCom_Themes_Tiers::plan_for( $tier );
232    $theme->plan_badge    = WPCom_Themes_Tiers::plan_name_for( $tier );
233    $theme->required_plan = $plan;
234    $theme->upgrade_url   = wpcom_themes_tab_checkout_url( $plan, (string) ( $theme->slug ?? '' ) );
235
236    return $theme;
237}
238
239/**
240 * Whether this site can install a theme with nothing more to buy.
241 *
242 * @param string $tier Tier slug.
243 * @param string $slug Theme slug.
244 * @return bool
245 */
246function wpcom_themes_tab_is_included( string $tier, string $slug ) {
247    // No plan includes a partner theme, so one counts only once its own subscription is paid for.
248    if ( WPCom_Themes_Tiers::PARTNER === $tier ) {
249        $subscription = wpcom_themes_tab_subscription( $slug );
250
251        return array() !== $subscription && $subscription['owned'];
252    }
253
254    return '' === WPCom_Themes_Tiers::plan_for( $tier ) || WPCom_Themes_Tiers::site_can_use( $tier );
255}
256
257/**
258 * Offers the subscription a partner theme needs, which no plan includes.
259 *
260 * The badge is left for the browser to write, because the store prices a request where it comes
261 * from: read here it would quote the web server's currency rather than the reader's.
262 *
263 * @param stdClass $theme A theme in the themes API shape.
264 * @return stdClass The theme, carrying what to price and where to buy it.
265 */
266function wpcom_themes_tab_add_subscription( $theme ) {
267    $slug         = (string) ( $theme->slug ?? '' );
268    $subscription = wpcom_themes_tab_subscription( $slug );
269
270    // A theme already paid for is core's to install, and one the store does not sell is not ours
271    // to sell. Both are left to core's own button, which a partner theme still defeats. DOTCOM-18722.
272    if ( array() === $subscription || $subscription['owned'] ) {
273        return $theme;
274    }
275
276    // Every paid plan grants the tier, so this is only ever the cheapest one.
277    $plan = WPCom_Themes_Tiers::site_can_use( WPCom_Themes_Tiers::PARTNER )
278        ? ''
279        : WPCom_Themes_Tiers::plan_for( WPCom_Themes_Tiers::PARTNER );
280
281    $theme->price_product = $subscription['product'];
282    $theme->price_plan    = '' === $plan ? '' : WPCom_Themes_Tiers::plan_name_for( WPCom_Themes_Tiers::PARTNER );
283    $theme->required_plan = $plan;
284    // Checkout charges for the plan and the theme together, so one purchase is enough to install.
285    $theme->purchase_url = wpcom_themes_tab_checkout_url(
286        '' === $plan ? $subscription['product'] : array( $plan, $subscription['product'] ),
287        $slug
288    );
289
290    return $theme;
291}
292
293/**
294 * How a partner theme is sold, and whether this site has already bought it.
295 *
296 * @param string $slug Theme slug.
297 * @return array{product: string, owned: bool}|array{} Empty when the store does not sell it.
298 */
299function wpcom_themes_tab_subscription( string $slug ) {
300    if ( '' === $slug ) {
301        return array();
302    }
303
304    $store    = wpcom_themes_tab_store();
305    $products = $store->products();
306
307    foreach ( wpcom_themes_tab_api()->fetch_partner_products()[ $slug ] ?? array() as $sold_as ) {
308        $product_slug = (string) ( $sold_as->product_slug ?? '' );
309        $product      = $products[ $product_slug ] ?? null;
310
311        if ( null === $product || WPCom_Themes_Store::TERM !== $product['term'] ) {
312            continue;
313        }
314
315        return array(
316            'product' => $product_slug,
317            'owned'   => in_array( $product['billing_slug'], $store->subscriptions(), true ),
318        );
319    }
320
321    return array();
322}
323
324/**
325 * The shared WordPress.com themes reader, which wpcomsh also uses for core's own tabs.
326 *
327 * @return WPCom_Themes_Api
328 */
329function wpcom_themes_tab_api() {
330    static $api;
331    if ( ! $api ) {
332        $api = new WPCom_Themes_Api( new WPCom_Themes_Cache() );
333    }
334
335    return $api;
336}
337
338/**
339 * The store reader, which prices the themes no plan includes.
340 *
341 * @return WPCom_Themes_Store
342 */
343function wpcom_themes_tab_store() {
344    static $store;
345    if ( ! $store ) {
346        $store = new WPCom_Themes_Store( new WPCom_Themes_Cache() );
347    }
348
349    return $store;
350}
351
352/**
353 * The shared mapper, which shapes a WordPress.com theme the way core's grid reads it.
354 *
355 * @return WPCom_Themes_Mapper
356 */
357function wpcom_themes_tab_mapper() {
358    static $mapper;
359    if ( ! $mapper ) {
360        $mapper = new WPCom_Themes_Mapper();
361    }
362
363    return $mapper;
364}
365
366/**
367 * Every theme the tab lists, in the order WordPress.com returns them.
368 *
369 * The shared reader fetches and caches the whole catalog, so the tab pages through it here rather
370 * than asking for a page at a time.
371 *
372 * @return array<stdClass>
373 */
374function wpcom_themes_tab_catalog() {
375    return array_values(
376        array_filter(
377            wpcom_themes_tab_api()->fetch_all_non_delisted_themes(),
378            static function ( $theme ) {
379                return is_object( $theme ) && ! empty( $theme->id ) && is_string( $theme->id );
380            }
381        )
382    );
383}
384
385/**
386 * Answers the tab's query in place of the WordPress.org themes API.
387 *
388 * @param false|object|array $result The result object or array. Default false.
389 * @param string             $action The type of information being requested.
390 * @param object             $args   Theme API arguments.
391 * @return false|object|array
392 */
393function wpcom_themes_tab_serve_themes_api( $result, $action, $args ) {
394    if ( false !== $result || 'query_themes' !== $action || ! wpcom_themes_tab_enabled() ) {
395        return $result;
396    }
397
398    // A `?theme=<slug>` preview link, loaded directly, looks its theme up by slug rather than by tab.
399    if ( ! isset( $args->browse ) && isset( $args->theme ) && is_string( $args->theme ) ) {
400        $theme = wpcom_themes_tab_api()->fetch_theme( $args->theme );
401
402        return null === $theme ? $result : (object) array(
403            'info'   => array(
404                'page'    => 1,
405                'pages'   => 1,
406                'results' => 1,
407            ),
408            'themes' => array( wpcom_themes_tab_add_plan( wpcom_themes_tab_mapper()->map_wpcom_to_wporg( $theme ) ) ),
409        );
410    }
411
412    if ( ! isset( $args->browse ) || WPCOM_THEMES_TAB !== $args->browse ) {
413        return $result;
414    }
415
416    $themes = wpcom_themes_tab_catalog();
417
418    // Filtered before paging, so the count and the pages match what the grid shows.
419    if ( isset( $args->wpcom_pricing ) && WPCOM_THEMES_TAB_INCLUDED === $args->wpcom_pricing ) {
420        $themes = array_values(
421            array_filter(
422                $themes,
423                static function ( $theme ) {
424                    // Read as the mapper reads it, so the filter agrees with the card's badge.
425                    return wpcom_themes_tab_is_included( (string) ( $theme->theme_tier->slug ?? '' ), $theme->id );
426                }
427            )
428        );
429    }
430
431    $page   = isset( $args->page ) ? max( 1, (int) $args->page ) : 1;
432    $mapper = wpcom_themes_tab_mapper();
433
434    // Only the page core asked for is mapped: mapping reads each theme from disk to say whether it
435    // is installed.
436    $shown = array_slice( $themes, ( $page - 1 ) * WPCOM_THEMES_TAB_PER_PAGE, WPCOM_THEMES_TAB_PER_PAGE );
437
438    return (object) array(
439        'info'   => array(
440            'page'    => $page,
441            'pages'   => (int) ceil( count( $themes ) / WPCOM_THEMES_TAB_PER_PAGE ),
442            'results' => count( $themes ),
443        ),
444        'themes' => array_values( array_map( 'wpcom_themes_tab_add_plan', array_map( array( $mapper, 'map_wpcom_to_wporg' ), $shown ) ) ),
445    );
446}
447add_filter( 'themes_api', 'wpcom_themes_tab_serve_themes_api', 10, 3 );
448
449/**
450 * How many partner themes a search can bring to the top, as on the plugins Marketplace tab.
451 */
452const WPCOM_THEMES_TAB_SEARCH_PARTNERS = 2;
453
454/**
455 * Partner themes that match a search term, best matches first.
456 *
457 * A match in the name ranks above one WordPress.com found elsewhere in the theme. Only themes the
458 * store sells are offered.
459 *
460 * @param string $term What was searched for.
461 * @return array<stdClass> Themes in the WordPress.com shape.
462 */
463function wpcom_themes_tab_search_partners( string $term ) {
464    $needle = mb_strtolower( trim( $term ) );
465
466    // Anything shorter matches half the catalog.
467    if ( mb_strlen( $needle ) < 3 ) {
468        return array();
469    }
470
471    $in_name   = array();
472    $elsewhere = array();
473    foreach ( wpcom_themes_tab_api()->search_themes( $term ) as $theme ) {
474        $slug = $theme->id ?? null;
475
476        if (
477            ! is_string( $slug )
478            || WPCom_Themes_Tiers::PARTNER !== ( $theme->theme_tier->slug ?? '' )
479            || array() === wpcom_themes_tab_subscription( $slug )
480        ) {
481            continue;
482        }
483
484        if ( false !== mb_strpos( mb_strtolower( (string) ( $theme->name ?? '' ) ), $needle ) ) {
485            $in_name[] = $theme;
486        } else {
487            $elsewhere[] = $theme;
488        }
489    }
490
491    return array_slice( array_merge( $in_name, $elsewhere ), 0, WPCOM_THEMES_TAB_SEARCH_PARTNERS );
492}
493
494/**
495 * Adds matching partner themes to the top of a keyword search's first page.
496 *
497 * Runs after wpcomsh merges in the WordPress.com themes a plan includes, which leaves partner
498 * themes out because they are sold rather than included.
499 *
500 * @param object|WP_Error $result Themes API response.
501 * @param string          $action Themes API action.
502 * @param object          $args   Themes API arguments.
503 * @return object|WP_Error
504 */
505function wpcom_themes_tab_splice_search_partners( $result, $action, $args ) {
506    $term = is_object( $args ) && isset( $args->search ) && is_string( $args->search ) ? $args->search : '';
507    $page = is_object( $args ) && isset( $args->page ) ? (int) $args->page : 1;
508
509    if (
510        'query_themes' !== $action
511        || '' === $term
512        || $page > 1
513        || ! is_object( $result )
514        || ! isset( $result->themes )
515        || ! is_array( $result->themes )
516        || ! wpcom_themes_tab_enabled()
517    ) {
518        return $result;
519    }
520
521    $back     = admin_url( 'theme-install.php?search=' . rawurlencode( $term ) );
522    $partners = array();
523    foreach ( wpcom_themes_tab_search_partners( $term ) as $theme ) {
524        $theme = wpcom_themes_tab_add_plan( wpcom_themes_tab_mapper()->map_wpcom_to_wporg( $theme ) );
525
526        // Listed by the search, which installs and purchase clicks are attributed to.
527        $theme->wpcom_marketplace_tab = false;
528        $theme->wpcom_search_slot     = true;
529
530        if ( isset( $theme->purchase_url ) ) {
531            $theme->purchase_url = add_query_arg( 'checkoutBackUrl', rawurlencode( $back ), $theme->purchase_url );
532        }
533
534        $partners[] = $theme;
535    }
536
537    if ( array() === $partners ) {
538        return $result;
539    }
540
541    // A WordPress.org theme with a partner theme's slug is another author's theme, so both stay listed.
542    $result->themes = array_merge( $partners, $result->themes );
543
544    if ( isset( $result->info['results'] ) ) {
545        $result->info['results'] += count( $partners );
546    }
547
548    return $result;
549}
550add_filter( 'themes_api_result', 'wpcom_themes_tab_splice_search_partners', 10, 3 );