Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
85.00% |
85 / 100 |
|
75.00% |
6 / 8 |
CRAP | n/a |
0 / 0 |
|
| wpcom_marketplace_cards_start | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
6 | |||
| wpcom_marketplace_search_matches | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| wpcom_marketplace_splice_search_results | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
13.02 | |||
| wpcom_marketplace_card_action_links | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| wpcom_marketplace_back_url | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| wpcom_marketplace_card_description_markup | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| wpcom_marketplace_card_strip | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
3 | |||
| wpcom_marketplace_card_assets | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Makes Marketplace plugins in core's plugin list table into Marketplace cards. |
| 4 | * |
| 5 | * The same cards on the Marketplace tab and at the top of matching search results. Follows |
| 6 | * Jetpack's plugin search hint (`modules/plugin-search.php`), which splices a card into the |
| 7 | * same results and swaps the same parts of it. |
| 8 | * |
| 9 | * @package automattic/jetpack-mu-wpcom |
| 10 | */ |
| 11 | |
| 12 | use Automattic\Jetpack\Jetpack_Mu_Wpcom\Marketplace_Catalog; |
| 13 | |
| 14 | /** |
| 15 | * How many of our plugins a search can bring to the top. |
| 16 | */ |
| 17 | const WPCOM_MARKETPLACE_SEARCH_LIMIT = 2; |
| 18 | |
| 19 | /** |
| 20 | * Hooks the cards in on the first page of the Add Plugins screen. |
| 21 | * |
| 22 | * Core's live search runs through admin-ajax.php but sets this same screen first, so one |
| 23 | * check covers the page and the Ajax results. |
| 24 | * |
| 25 | * @param WP_Screen $screen The current screen. |
| 26 | * @return void |
| 27 | */ |
| 28 | function wpcom_marketplace_cards_start( $screen ) { |
| 29 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only reads which page of results this is. |
| 30 | $page = isset( $_GET['paged'] ) ? (int) $_GET['paged'] : 1; |
| 31 | |
| 32 | if ( ! isset( $screen->base ) || 'plugin-install' !== $screen->base || $page > 1 || ! wpcom_marketplace_tab_enabled() ) { |
| 33 | return; |
| 34 | } |
| 35 | |
| 36 | add_filter( 'plugins_api_result', 'wpcom_marketplace_splice_search_results', 10, 3 ); |
| 37 | add_filter( 'plugin_install_action_links', 'wpcom_marketplace_card_action_links', 10, 2 ); |
| 38 | add_filter( 'plugin_install_description', 'wpcom_marketplace_card_description_markup', 10, 2 ); |
| 39 | add_action( 'admin_enqueue_scripts', 'wpcom_marketplace_card_assets' ); |
| 40 | } |
| 41 | add_action( 'current_screen', 'wpcom_marketplace_cards_start' ); |
| 42 | |
| 43 | /** |
| 44 | * Our plugins that match a search term, best matches first. |
| 45 | * |
| 46 | * A match in the name ranks above one in the category or description. Within each, the |
| 47 | * catalog's own order holds, which is by sales. |
| 48 | * |
| 49 | * @param string $term What was searched for. |
| 50 | * @param int $limit How many to return at most. |
| 51 | * @return array[] Normalized product data. |
| 52 | */ |
| 53 | function wpcom_marketplace_search_matches( $term, $limit ) { |
| 54 | $needle = mb_strtolower( trim( wp_strip_all_tags( (string) $term ) ) ); |
| 55 | |
| 56 | // Anything shorter matches half the catalog. |
| 57 | if ( mb_strlen( $needle ) < 3 ) { |
| 58 | return array(); |
| 59 | } |
| 60 | |
| 61 | $in_name = array(); |
| 62 | $elsewhere = array(); |
| 63 | |
| 64 | foreach ( Marketplace_Catalog::get_products() as $card ) { |
| 65 | $about = ( $card['wpcom_category'] ?? '' ) . ' ' . wp_strip_all_tags( (string) ( $card['short_description'] ?? '' ) ); |
| 66 | |
| 67 | if ( false !== mb_strpos( mb_strtolower( (string) ( $card['name'] ?? '' ) ), $needle ) ) { |
| 68 | $in_name[] = $card; |
| 69 | } elseif ( false !== mb_strpos( mb_strtolower( $about ), $needle ) ) { |
| 70 | $elsewhere[] = $card; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | return array_slice( array_merge( $in_name, $elsewhere ), 0, $limit ); |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Adds matching Marketplace plugins to the top of a keyword search's first page. |
| 79 | * |
| 80 | * @param object|WP_Error $result Plugin API response. |
| 81 | * @param string $action Plugin API action. |
| 82 | * @param object $args Plugin API arguments. |
| 83 | * @return object|WP_Error |
| 84 | */ |
| 85 | function wpcom_marketplace_splice_search_results( $result, $action, $args ) { |
| 86 | if ( 'query_plugins' !== $action || ! is_object( $result ) || is_wp_error( $result ) || ! isset( $result->plugins ) ) { |
| 87 | return $result; |
| 88 | } |
| 89 | |
| 90 | // Tag and author searches, and the browse tabs, name no term to match. |
| 91 | $term = is_object( $args ) && isset( $args->search ) ? (string) $args->search : ''; |
| 92 | $page = is_object( $args ) && isset( $args->page ) ? (int) $args->page : 1; |
| 93 | if ( '' === $term || $page > 1 ) { |
| 94 | return $result; |
| 95 | } |
| 96 | |
| 97 | $matches = wpcom_marketplace_search_matches( $term, WPCOM_MARKETPLACE_SEARCH_LIMIT ); |
| 98 | if ( empty( $matches ) ) { |
| 99 | return $result; |
| 100 | } |
| 101 | |
| 102 | $ours = array_column( $matches, 'slug' ); |
| 103 | $others = array_filter( |
| 104 | (array) $result->plugins, |
| 105 | function ( $plugin ) use ( $ours ) { |
| 106 | $slug = is_array( $plugin ) ? ( $plugin['slug'] ?? '' ) : ( $plugin->slug ?? '' ); |
| 107 | return ! in_array( $slug, $ours, true ); |
| 108 | } |
| 109 | ); |
| 110 | |
| 111 | $result->plugins = array_merge( $matches, array_values( $others ) ); |
| 112 | |
| 113 | return $result; |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * Swaps core's Install Now on our cards for the action the Marketplace tab shows. |
| 118 | * |
| 119 | * Core's would install the slug from WordPress.org, which has none of these. |
| 120 | * |
| 121 | * @param string[] $links Card action links, the install button first. |
| 122 | * @param array $plugin Plugin data. |
| 123 | * @return string[] |
| 124 | */ |
| 125 | function wpcom_marketplace_card_action_links( $links, $plugin ) { |
| 126 | if ( empty( $plugin['wpcom_marketplace'] ) || ! is_array( $links ) ) { |
| 127 | return $links; |
| 128 | } |
| 129 | |
| 130 | $links[0] = wpcom_marketplace_card_button( $plugin, wpcom_marketplace_back_url() ); |
| 131 | |
| 132 | return array_values( array_filter( $links ) ); |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Where checkout's Back link should return to: the search being shown, or the tab. |
| 137 | * |
| 138 | * Core reads the term from the request for both the page and its Ajax live search. |
| 139 | * |
| 140 | * @return string Search results URL, or the Marketplace tab when there is no term. |
| 141 | */ |
| 142 | function wpcom_marketplace_back_url() { |
| 143 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Core's handler verifies the Ajax search; this only echoes the term back. |
| 144 | $term = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : ''; |
| 145 | if ( '' === $term ) { |
| 146 | return wpcom_marketplace_tab_url(); |
| 147 | } |
| 148 | |
| 149 | // add_query_arg() does not encode values. |
| 150 | return add_query_arg( |
| 151 | array( |
| 152 | 's' => rawurlencode( $term ), |
| 153 | 'tab' => 'search', |
| 154 | 'type' => 'term', |
| 155 | ), |
| 156 | self_admin_url( 'plugin-install.php' ) |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * Labels our cards, and carries the bottom strip and data attributes they should have. |
| 162 | * |
| 163 | * Core has no filter for the strip and fills it with WordPress.org ratings and install |
| 164 | * counts, which these plugins do not have. Ours rides in a template, which is valid inside |
| 165 | * core's <p>, and js/marketplace-cards.js moves it into place. |
| 166 | * |
| 167 | * @param string $description Card description. |
| 168 | * @param array $plugin Plugin data. |
| 169 | * @return string |
| 170 | */ |
| 171 | function wpcom_marketplace_card_description_markup( $description, $plugin ) { |
| 172 | if ( empty( $plugin['wpcom_marketplace'] ) ) { |
| 173 | return $description; |
| 174 | } |
| 175 | |
| 176 | $installed = 'install' !== install_plugin_install_status( $plugin )['status']; |
| 177 | |
| 178 | // The data attributes are what the tab's Tracks and kept details modals read off a card. |
| 179 | return sprintf( |
| 180 | '<span class="wpcom-marketplace-label">%s</span>%s<template class="wpcom-marketplace-strip" data-plugin="%s" data-saas="%s" data-installed="%s">%s</template>', |
| 181 | esc_html__( 'WordPress.com Marketplace', 'jetpack-mu-wpcom' ), |
| 182 | esc_html( wpcom_marketplace_card_description( $plugin ) ), |
| 183 | esc_attr( (string) ( $plugin['wpcom_product_slug'] ?? $plugin['slug'] ?? '' ) ), |
| 184 | Marketplace_Catalog::is_referral( $plugin ) ? 'true' : 'false', |
| 185 | $installed ? 'true' : 'false', |
| 186 | wpcom_marketplace_card_strip( $plugin ) // Built from escaped parts. |
| 187 | ); |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * A card's bottom strip, in core's own columns so it lines up with the cards around it. |
| 192 | * |
| 193 | * The price takes the left column, where core shows ratings and installs, as the tab's flex |
| 194 | * rows so its mixed sizes keep core's row heights. The right keeps core's last updated date |
| 195 | * and compatibility, which WordPress.com manages for these plugins. |
| 196 | * |
| 197 | * @param array $plugin Plugin data. |
| 198 | * @return string Strip markup, built from escaped parts. |
| 199 | */ |
| 200 | function wpcom_marketplace_card_strip( array $plugin ) { |
| 201 | // Calypso drops the price once a plugin is installed. |
| 202 | $rows = 'install' === install_plugin_install_status( $plugin )['status'] |
| 203 | ? wpcom_marketplace_price_rows( $plugin ) |
| 204 | : array( |
| 205 | 'headline' => '', |
| 206 | 'note' => '', |
| 207 | ); |
| 208 | |
| 209 | $updated = strtotime( (string) ( $plugin['last_updated'] ?? '' ) ); |
| 210 | $updated = $updated |
| 211 | ? sprintf( |
| 212 | '<strong>%s</strong> %s', |
| 213 | esc_html__( 'Last Updated:', 'jetpack-mu-wpcom' ), |
| 214 | /* translators: %s: Time since the plugin was updated, for example 2 days. */ |
| 215 | esc_html( sprintf( __( '%s ago', 'jetpack-mu-wpcom' ), human_time_diff( $updated ) ) ) |
| 216 | ) |
| 217 | : esc_html__( 'Managed by WordPress.com', 'jetpack-mu-wpcom' ); |
| 218 | |
| 219 | return sprintf( |
| 220 | '<div class="column-rating wpcom-marketplace-card__headline">%s</div><div class="column-updated">%s</div><div class="column-downloaded wpcom-marketplace-card__alternative">%s</div><div class="column-compatibility"><span class="compatibility-compatible">%s</span></div>', |
| 221 | $rows['headline'], |
| 222 | $updated, |
| 223 | $rows['note'], |
| 224 | wp_kses( __( '<strong>Compatible</strong> with your version of WordPress', 'jetpack-mu-wpcom' ), array( 'strong' => array() ) ) |
| 225 | ); |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Loads the card styles and the script that places each card's strip, on every Add Plugins tab. |
| 230 | * |
| 231 | * Search can start from any of them. |
| 232 | * |
| 233 | * @return void |
| 234 | */ |
| 235 | function wpcom_marketplace_card_assets() { |
| 236 | wp_enqueue_style( |
| 237 | 'wpcom-marketplace-tab', |
| 238 | plugins_url( 'css/marketplace-tab.css', __FILE__ ), |
| 239 | array(), |
| 240 | (string) filemtime( __DIR__ . '/css/marketplace-tab.css' ) |
| 241 | ); |
| 242 | |
| 243 | wp_enqueue_script( |
| 244 | 'wpcom-marketplace-cards', |
| 245 | plugins_url( 'js/marketplace-cards.js', __FILE__ ), |
| 246 | array(), |
| 247 | (string) filemtime( __DIR__ . '/js/marketplace-cards.js' ), |
| 248 | true |
| 249 | ); |
| 250 | } |