Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 242 |
|
0.00% |
0 / 7 |
CRAP | n/a |
0 / 0 |
|
| Automattic\Jetpack\Extensions\Donations\resolve_donation_plan | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
42 | |||
| Automattic\Jetpack\Extensions\Donations\get_donation_products | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
132 | |||
| Automattic\Jetpack\Extensions\Donations\register_block | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
6 | |||
| Automattic\Jetpack\Extensions\Donations\render_block | |
0.00% |
0 / 124 |
|
0.00% |
0 / 1 |
380 | |||
| Automattic\Jetpack\Extensions\Donations\get_default_texts | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
2 | |||
| Automattic\Jetpack\Extensions\Donations\load_editor_scripts | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
| Automattic\Jetpack\Extensions\Donations\amp_skip_post | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Donations Block. |
| 4 | * |
| 5 | * @since 8.x |
| 6 | * |
| 7 | * @package automattic/jetpack |
| 8 | */ |
| 9 | |
| 10 | namespace Automattic\Jetpack\Extensions\Donations; |
| 11 | |
| 12 | use Automattic\Jetpack\Blocks; |
| 13 | use Automattic\Jetpack\Status\Request; |
| 14 | use Jetpack_Gutenberg; |
| 15 | use WP_Post; |
| 16 | |
| 17 | if ( ! defined( 'ABSPATH' ) ) { |
| 18 | exit( 0 ); |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * Resolve a donation plan post for the given interval and currency. |
| 23 | * |
| 24 | * When duplicates exist (same interval+currency), the products cache from |
| 25 | * /memberships/status is used to pick the correct one by product_id. |
| 26 | * |
| 27 | * @param string $interval The donation interval (one-time, 1 month, 1 year). |
| 28 | * @param string $currency The currency code. |
| 29 | * @return WP_Post|null The plan post, or null if not found. |
| 30 | */ |
| 31 | function resolve_donation_plan( $interval, $currency ) { |
| 32 | $post_type = \Jetpack_Memberships::$post_type_plan; |
| 33 | |
| 34 | // Query all local plans for this interval + currency. |
| 35 | $candidates = get_posts( |
| 36 | array( |
| 37 | 'posts_per_page' => -1, |
| 38 | 'post_type' => $post_type, |
| 39 | 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| 40 | array( |
| 41 | 'key' => 'jetpack_memberships_type', |
| 42 | 'value' => 'donation', |
| 43 | ), |
| 44 | array( |
| 45 | 'key' => 'jetpack_memberships_interval', |
| 46 | 'value' => $interval, |
| 47 | ), |
| 48 | array( |
| 49 | 'key' => 'jetpack_memberships_currency', |
| 50 | 'value' => $currency, |
| 51 | ), |
| 52 | array( |
| 53 | 'key' => 'jetpack_memberships_is_deleted', |
| 54 | 'compare' => 'NOT EXISTS', |
| 55 | ), |
| 56 | ), |
| 57 | ) |
| 58 | ); |
| 59 | |
| 60 | if ( empty( $candidates ) ) { |
| 61 | return null; |
| 62 | } |
| 63 | |
| 64 | // Single candidate โ no ambiguity. |
| 65 | if ( count( $candidates ) === 1 ) { |
| 66 | return $candidates[0]; |
| 67 | } |
| 68 | |
| 69 | // Multiple candidates โ use remote products to pick the right one. |
| 70 | $products = get_donation_products( $currency ); |
| 71 | if ( ! empty( $products ) ) { |
| 72 | foreach ( $candidates as $candidate ) { |
| 73 | if ( isset( $products[ $candidate->ID ] ) ) { |
| 74 | return $candidate; |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | // No products cache match. |
| 80 | return null; |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Get donation products for a currency, keyed by product_id. |
| 85 | * |
| 86 | * Returns cached data when available. Otherwise fetches from |
| 87 | * /memberships/status (GET, read-only) and caches the result. |
| 88 | * |
| 89 | * @param string $currency The currency code. |
| 90 | * @return array Map of product_id => product data, or empty array. |
| 91 | */ |
| 92 | function get_donation_products( $currency ) { |
| 93 | $cache_key = 'jetpack_donation_products_' . strtolower( $currency ); |
| 94 | $neg_key = $cache_key . '_empty'; |
| 95 | |
| 96 | // Return cached products if available. |
| 97 | $cached = get_transient( $cache_key ); |
| 98 | if ( is_array( $cached ) ) { |
| 99 | return $cached; |
| 100 | } |
| 101 | |
| 102 | // Negative cache โ no products exist for this currency. |
| 103 | if ( false !== get_transient( $neg_key ) ) { |
| 104 | return array(); |
| 105 | } |
| 106 | |
| 107 | // Fetch via internal REST request. |
| 108 | $request = new \WP_REST_Request( 'GET', '/wpcom/v2/memberships/products' ); |
| 109 | $request->set_param( 'type', 'donation' ); |
| 110 | $request->set_param( 'is_editable', false ); |
| 111 | $response = rest_do_request( $request ); |
| 112 | $data = null; |
| 113 | |
| 114 | if ( ! $response->is_error() && 200 === $response->get_status() ) { |
| 115 | $data = $response->get_data(); |
| 116 | } |
| 117 | |
| 118 | if ( ! is_array( $data ) || empty( $data['products'] ) ) { |
| 119 | set_transient( $neg_key, 1, HOUR_IN_SECONDS ); |
| 120 | return array(); |
| 121 | } |
| 122 | |
| 123 | // Build product_id => product map for the requested currency. |
| 124 | $products_by_id = array(); |
| 125 | foreach ( $data['products'] as $product ) { |
| 126 | if ( strtoupper( $product['currency'] ) !== strtoupper( $currency ) ) { |
| 127 | continue; |
| 128 | } |
| 129 | |
| 130 | $product_id = $product['id']; |
| 131 | if ( $product_id ) { |
| 132 | $products_by_id[ $product_id ] = $product; |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | if ( empty( $products_by_id ) ) { |
| 137 | set_transient( $neg_key, 1, HOUR_IN_SECONDS ); |
| 138 | return array(); |
| 139 | } |
| 140 | |
| 141 | set_transient( $cache_key, $products_by_id, DAY_IN_SECONDS ); |
| 142 | |
| 143 | return $products_by_id; |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Registers the block for use in Gutenberg |
| 148 | * This is done via an action so that we can disable |
| 149 | * registration if we need to. |
| 150 | */ |
| 151 | function register_block() { |
| 152 | |
| 153 | require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php'; |
| 154 | if ( \Jetpack_Memberships::should_enable_monetize_blocks_in_editor() ) { |
| 155 | Blocks::jetpack_register_block( |
| 156 | __DIR__, |
| 157 | array( |
| 158 | 'render_callback' => __NAMESPACE__ . '\render_block', |
| 159 | 'plan_check' => true, |
| 160 | ) |
| 161 | ); |
| 162 | } |
| 163 | // Add a meta field to the user to track if the donation warning has been dismissed. |
| 164 | \register_meta( |
| 165 | 'user', |
| 166 | 'jetpack_donation_warning_dismissed', |
| 167 | array( |
| 168 | 'type' => 'boolean', |
| 169 | 'single' => true, |
| 170 | 'show_in_rest' => true, |
| 171 | 'default' => false, |
| 172 | ) |
| 173 | ); |
| 174 | } |
| 175 | add_action( 'init', __NAMESPACE__ . '\register_block' ); |
| 176 | |
| 177 | /** |
| 178 | * Donations block dynamic rendering. |
| 179 | * |
| 180 | * @param array $attr Array containing the Donations block attributes. |
| 181 | * @param string $content String containing the Donations block content. |
| 182 | * |
| 183 | * @return string |
| 184 | */ |
| 185 | function render_block( $attr, $content ) { |
| 186 | // Keep content as-is if rendered in other contexts than frontend (i.e. feed, emails, API, etc.). |
| 187 | if ( ! Request::is_frontend() ) { |
| 188 | $parsed = parse_blocks( $content ); |
| 189 | if ( ! empty( $parsed[0] ) ) { |
| 190 | // Inject the link of the current post from the server side as the fallback link to make sure the donations block |
| 191 | // points to the correct post when it's inserted from the synced pattern (aka โMy Patternโ). |
| 192 | $post_link = get_permalink(); |
| 193 | $parsed[0]['attrs']['fallbackLinkUrl'] = $post_link; |
| 194 | $content = \render_block( $parsed[0] ); |
| 195 | if ( preg_match( '/<a\s+class="jetpack-donations-fallback-link"\s+href="([^"]*)"/', $content, $matches ) ) { |
| 196 | $content = str_replace( $matches[1], $post_link, $content ); |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | return $content; |
| 201 | } |
| 202 | |
| 203 | require_once JETPACK__PLUGIN_DIR . 'modules/memberships/class-jetpack-memberships.php'; |
| 204 | |
| 205 | // If stripe isn't connected don't show anything to potential donors - they can't actually make a donation. |
| 206 | if ( ! \Jetpack_Memberships::has_connected_account() ) { |
| 207 | return ''; |
| 208 | } |
| 209 | |
| 210 | Jetpack_Gutenberg::load_assets_as_required( __DIR__ ); |
| 211 | |
| 212 | require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-currencies.php'; |
| 213 | |
| 214 | $default_texts = get_default_texts(); |
| 215 | |
| 216 | $donations = array( |
| 217 | 'one-time' => array_merge( |
| 218 | array( |
| 219 | 'title' => __( 'One-Time', 'jetpack' ), |
| 220 | 'class' => 'donations__one-time-item', |
| 221 | 'heading' => $default_texts['oneTimeDonation']['heading'], |
| 222 | 'buttonText' => $default_texts['oneTimeDonation']['buttonText'], |
| 223 | ), |
| 224 | $attr['oneTimeDonation'] |
| 225 | ), |
| 226 | ); |
| 227 | if ( $attr['monthlyDonation']['show'] ) { |
| 228 | $donations['1 month'] = array_merge( |
| 229 | array( |
| 230 | 'title' => __( 'Monthly', 'jetpack' ), |
| 231 | 'class' => 'donations__monthly-item', |
| 232 | 'heading' => $default_texts['monthlyDonation']['heading'], |
| 233 | 'buttonText' => $default_texts['monthlyDonation']['buttonText'], |
| 234 | ), |
| 235 | $attr['monthlyDonation'] |
| 236 | ); |
| 237 | } |
| 238 | if ( $attr['annualDonation']['show'] ) { |
| 239 | $donations['1 year'] = array_merge( |
| 240 | array( |
| 241 | 'title' => __( 'Yearly', 'jetpack' ), |
| 242 | 'class' => 'donations__annual-item', |
| 243 | 'heading' => $default_texts['annualDonation']['heading'], |
| 244 | 'buttonText' => $default_texts['annualDonation']['buttonText'], |
| 245 | ), |
| 246 | $attr['annualDonation'] |
| 247 | ); |
| 248 | } |
| 249 | |
| 250 | $choose_amount_text = isset( $attr['chooseAmountText'] ) && ! empty( $attr['chooseAmountText'] ) ? $attr['chooseAmountText'] : $default_texts['chooseAmountText']; |
| 251 | $custom_amount_text = isset( $attr['customAmountText'] ) && ! empty( $attr['customAmountText'] ) ? $attr['customAmountText'] : $default_texts['customAmountText']; |
| 252 | $currency = $attr['currency']; |
| 253 | $nav = ''; |
| 254 | $headings = ''; |
| 255 | $amounts = ''; |
| 256 | $extra_text = ''; |
| 257 | $buttons = ''; |
| 258 | |
| 259 | // Resolve donation plans. When duplicates exist, resolve_donation_plan() |
| 260 | // fetches the products cache from /memberships/status to disambiguate. |
| 261 | $resolved_plans = array(); |
| 262 | foreach ( $donations as $interval => $donation ) { |
| 263 | $resolved_plans[ $interval ] = resolve_donation_plan( $interval, $currency ); |
| 264 | } |
| 265 | |
| 266 | foreach ( $donations as $interval => $donation ) { |
| 267 | $plan = $resolved_plans[ $interval ]; |
| 268 | if ( ! $plan ) { |
| 269 | continue; |
| 270 | } |
| 271 | |
| 272 | if ( count( $donations ) > 1 ) { |
| 273 | if ( ! $nav ) { |
| 274 | $nav .= '<div class="donations__nav">'; |
| 275 | } |
| 276 | $nav .= sprintf( |
| 277 | '<div role="button" tabindex="0" class="donations__nav-item" data-interval="%1$s">%2$s</div>', |
| 278 | esc_attr( $interval ), |
| 279 | esc_html( $donation['title'] ) |
| 280 | ); |
| 281 | } |
| 282 | $headings .= sprintf( |
| 283 | '<h4 class="%1$s">%2$s</h4>', |
| 284 | esc_attr( $donation['class'] ), |
| 285 | wp_kses_post( $donation['heading'] ) |
| 286 | ); |
| 287 | $amounts .= sprintf( |
| 288 | '<div class="donations__amounts %s">', |
| 289 | esc_attr( $donation['class'] ) |
| 290 | ); |
| 291 | foreach ( $donation['amounts'] as $amount ) { |
| 292 | $amounts .= sprintf( |
| 293 | '<div class="donations__amount" data-amount="%1$s">%2$s</div>', |
| 294 | esc_attr( $amount ), |
| 295 | esc_html( \Jetpack_Currencies::format_price( $amount, $currency ) ) |
| 296 | ); |
| 297 | } |
| 298 | $amounts .= '</div>'; |
| 299 | $extra_text .= sprintf( |
| 300 | '<p class="%1$s">%2$s</p>', |
| 301 | esc_attr( $donation['class'] ), |
| 302 | wp_kses_post( $donation['extraText'] ?? $default_texts['extraText'] ) |
| 303 | ); |
| 304 | $buttons .= sprintf( |
| 305 | '<a class="wp-block-button__link donations__donate-button %1$s" href="%2$s">%3$s</a>', |
| 306 | esc_attr( $donation['class'] ), |
| 307 | esc_url( \Jetpack_Memberships::get_instance()->get_subscription_url( $plan->ID ) ), |
| 308 | wp_kses_post( $donation['buttonText'] ) |
| 309 | ); |
| 310 | } |
| 311 | if ( $nav ) { |
| 312 | $nav .= '</div>'; |
| 313 | } |
| 314 | |
| 315 | $custom_amount = ''; |
| 316 | if ( $attr['showCustomAmount'] ) { |
| 317 | $custom_amount .= sprintf( |
| 318 | '<p>%s</p>', |
| 319 | wp_kses_post( $custom_amount_text ) |
| 320 | ); |
| 321 | $default_custom_amount = ( \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1 ) * 100; |
| 322 | $custom_amount .= sprintf( |
| 323 | '<div class="donations__amount donations__custom-amount"> |
| 324 | %1$s |
| 325 | <div class="donations__amount-value" data-currency="%2$s" data-empty-text="%3$s"></div> |
| 326 | </div>', |
| 327 | esc_html( \Jetpack_Currencies::CURRENCIES[ $currency ]['symbol'] ?? 'ยค' ), |
| 328 | esc_attr( $currency ), |
| 329 | esc_attr( \Jetpack_Currencies::format_price( $default_custom_amount, $currency, false ) ) |
| 330 | ); |
| 331 | } |
| 332 | |
| 333 | return sprintf( |
| 334 | ' |
| 335 | <div class="%1$s"> |
| 336 | <div class="donations__container"> |
| 337 | %2$s |
| 338 | <div class="donations__content"> |
| 339 | <div class="donations__tab"> |
| 340 | %3$s |
| 341 | <p>%4$s</p> |
| 342 | %5$s |
| 343 | %6$s |
| 344 | <hr class="donations__separator"> |
| 345 | %7$s |
| 346 | %8$s |
| 347 | </div> |
| 348 | </div> |
| 349 | </div> |
| 350 | </div> |
| 351 | ', |
| 352 | esc_attr( Blocks::classes( Blocks::get_block_feature( __DIR__ ), $attr ) ), |
| 353 | $nav, |
| 354 | $headings, |
| 355 | $choose_amount_text, |
| 356 | $amounts, |
| 357 | $custom_amount, |
| 358 | $extra_text, |
| 359 | $buttons |
| 360 | ); |
| 361 | } |
| 362 | |
| 363 | /** |
| 364 | * Get the default texts for the block. |
| 365 | * |
| 366 | * @return array |
| 367 | */ |
| 368 | function get_default_texts() { |
| 369 | return array( |
| 370 | 'chooseAmountText' => __( 'Choose an amount', 'jetpack' ), |
| 371 | 'customAmountText' => __( 'Or enter a custom amount', 'jetpack' ), |
| 372 | 'extraText' => __( 'Your contribution is appreciated.', 'jetpack' ), |
| 373 | 'oneTimeDonation' => array( |
| 374 | 'heading' => __( 'Make a one-time donation', 'jetpack' ), |
| 375 | 'buttonText' => __( 'Donate', 'jetpack' ), |
| 376 | ), |
| 377 | 'monthlyDonation' => array( |
| 378 | 'heading' => __( 'Make a monthly donation', 'jetpack' ), |
| 379 | 'buttonText' => __( 'Donate monthly', 'jetpack' ), |
| 380 | ), |
| 381 | 'annualDonation' => array( |
| 382 | 'heading' => __( 'Make a yearly donation', 'jetpack' ), |
| 383 | 'buttonText' => __( 'Donate yearly', 'jetpack' ), |
| 384 | ), |
| 385 | ); |
| 386 | } |
| 387 | |
| 388 | /** |
| 389 | * Make default texts available to the editor. |
| 390 | */ |
| 391 | function load_editor_scripts() { |
| 392 | // Only relevant to the editor right now. |
| 393 | if ( ! is_admin() ) { |
| 394 | return; |
| 395 | } |
| 396 | |
| 397 | $data = array( |
| 398 | 'defaultTexts' => get_default_texts(), |
| 399 | ); |
| 400 | |
| 401 | wp_add_inline_script( |
| 402 | 'jetpack-blocks-editor', |
| 403 | 'var Jetpack_DonationsBlock = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', |
| 404 | 'before' |
| 405 | ); |
| 406 | } |
| 407 | add_action( 'enqueue_block_assets', __NAMESPACE__ . '\load_editor_scripts', 11 ); |
| 408 | |
| 409 | /** |
| 410 | * Determine if AMP should be disabled on posts having Donations blocks. |
| 411 | * |
| 412 | * @param bool $skip Skipped. |
| 413 | * @param int $post_id Post ID. |
| 414 | * @param WP_Post $post Post. |
| 415 | * |
| 416 | * @return bool Whether to skip the post from AMP. |
| 417 | */ |
| 418 | function amp_skip_post( $skip, $post_id, $post ) { |
| 419 | // When AMP is on standard mode, there are no non-AMP posts to link to where the donation can be completed, so let's |
| 420 | // prevent the post from being available in AMP. |
| 421 | if ( function_exists( 'amp_is_canonical' ) && \amp_is_canonical() && has_block( Blocks::get_block_name( __DIR__ ), $post->post_content ) ) { |
| 422 | return true; |
| 423 | } |
| 424 | return $skip; |
| 425 | } |
| 426 | add_filter( 'amp_skip_post', __NAMESPACE__ . '\amp_skip_post', 10, 3 ); |