Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 242
0.00% covered (danger)
0.00%
0 / 7
CRAP
n/a
0 / 0
Automattic\Jetpack\Extensions\Donations\resolve_donation_plan
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
42
Automattic\Jetpack\Extensions\Donations\get_donation_products
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
132
Automattic\Jetpack\Extensions\Donations\register_block
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
Automattic\Jetpack\Extensions\Donations\render_block
0.00% covered (danger)
0.00%
0 / 124
0.00% covered (danger)
0.00%
0 / 1
380
Automattic\Jetpack\Extensions\Donations\get_default_texts
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
Automattic\Jetpack\Extensions\Donations\load_editor_scripts
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
Automattic\Jetpack\Extensions\Donations\amp_skip_post
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Donations Block.
4 *
5 * @since 8.x
6 *
7 * @package automattic/jetpack
8 */
9
10namespace Automattic\Jetpack\Extensions\Donations;
11
12use Automattic\Jetpack\Blocks;
13use Automattic\Jetpack\Status\Request;
14use Jetpack_Gutenberg;
15use WP_Post;
16
17if ( ! 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 */
31function 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 */
92function 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 */
151function 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}
175add_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 */
185function 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 */
368function 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 */
391function 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}
407add_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 */
418function 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}
426add_filter( 'amp_skip_post', __NAMESPACE__ . '\amp_skip_post', 10, 3 );