Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.48% covered (warning)
61.48%
383 / 623
25.00% covered (danger)
25.00%
5 / 20
CRAP
n/a
0 / 0
Automattic\Jetpack\Extensions\Donations\register_block
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
Automattic\Jetpack\Extensions\Donations\render_block
65.66% covered (warning)
65.66%
130 / 198
0.00% covered (danger)
0.00%
0 / 1
92.45
Automattic\Jetpack\Extensions\Donations\get_donation_plan_ids
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
10
Automattic\Jetpack\Extensions\Donations\render_email
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
17
Automattic\Jetpack\Extensions\Donations\render_email_donate_button
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
7.77
Automattic\Jetpack\Extensions\Donations\get_email_target_width
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\Extensions\Donations\build_security_data_attrs
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
Automattic\Jetpack\Extensions\Donations\build_custom_styles
88.24% covered (warning)
88.24%
90 / 102
0.00% covered (danger)
0.00%
0 / 1
22.79
Automattic\Jetpack\Extensions\Donations\build_border_decls
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
132
Automattic\Jetpack\Extensions\Donations\build_radius_decls
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
Automattic\Jetpack\Extensions\Donations\sanitize_css_value
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
Automattic\Jetpack\Extensions\Donations\get_default_texts
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\Donations\get_trigger_icon_svg
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
Automattic\Jetpack\Extensions\Donations\load_editor_scripts
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
Automattic\Jetpack\Extensions\Donations\amp_skip_post
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
Automattic\Jetpack\Extensions\Donations\record_block_published_event
38.89% covered (danger)
38.89%
7 / 18
0.00% covered (danger)
0.00%
0 / 1
27.49
Automattic\Jetpack\Extensions\Donations\collect_donation_blocks
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
Automattic\Jetpack\Extensions\Donations\build_block_published_event_props
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
Automattic\Jetpack\Extensions\Donations\has_custom_styles
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
Automattic\Jetpack\Extensions\Donations\record_published_event
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
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\Host;
14use Automattic\Jetpack\Status\Request;
15use Automattic\Jetpack\Tracking;
16use Jetpack;
17use Jetpack_Gutenberg;
18use WP_Post;
19
20if ( ! defined( 'ABSPATH' ) ) {
21    exit( 0 );
22}
23
24/**
25 * Registers the block for use in Gutenberg
26 * This is done via an action so that we can disable
27 * registration if we need to.
28 */
29function register_block() {
30
31    require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php';
32    if ( \Jetpack_Memberships::should_enable_monetize_blocks_in_editor() ) {
33        Blocks::jetpack_register_block(
34            __DIR__,
35            array(
36                'render_callback'       => __NAMESPACE__ . '\render_block',
37                'render_email_callback' => __NAMESPACE__ . '\render_email',
38                'plan_check'            => true,
39            )
40        );
41    }
42}
43add_action( 'init', __NAMESPACE__ . '\register_block' );
44
45/**
46 * Donations block dynamic rendering.
47 *
48 * @param array  $attr    Array containing the Donations block attributes.
49 * @param string $content String containing the Donations block content.
50 *
51 * @return string
52 */
53function render_block( $attr, $content ) {
54    // Keep content as-is if rendered in other contexts than frontend (i.e. feed, emails, API, etc.).
55    if ( ! Request::is_frontend() ) {
56        $parsed = parse_blocks( $content );
57        if ( ! empty( $parsed[0] ) ) {
58            // Inject the link of the current post from the server side as the fallback link to make sure the donations block
59            // points to the correct post when it's inserted from the synced pattern (aka “My Pattern”).
60            $post_link                             = get_permalink();
61            $parsed[0]['attrs']['fallbackLinkUrl'] = $post_link;
62            $content                               = \render_block( $parsed[0] );
63            if ( preg_match( '/<a\s+class="jetpack-donations-fallback-link"\s+href="([^"]*)"/', $content, $matches ) ) {
64                $content = str_replace( $matches[1], $post_link, $content );
65            }
66        }
67
68        return $content;
69    }
70
71    require_once JETPACK__PLUGIN_DIR . 'modules/memberships/class-jetpack-memberships.php';
72
73    // If stripe isn't connected don't show anything to potential donors - they can't actually make a donation.
74    if ( ! \Jetpack_Memberships::has_connected_account() ) {
75        return '';
76    }
77
78    Jetpack_Gutenberg::load_assets_as_required( __DIR__ );
79
80    require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-currencies.php';
81
82    $default_texts = get_default_texts();
83
84    // `array_merge` lets the user-supplied attributes override only the keys
85    // they actually set. Undefined keys (new blocks that have never been
86    // edited) fall back to the defaults in the first array. User-cleared
87    // keys (empty strings explicitly saved) win over defaults, so
88    // "blank stays blank" and "never set" gets the default.
89    // Treat `show !== false` as on, so legacy blocks (where `show` was never
90    // set on oneTimeDonation) still render the one-time interval by default.
91    $donations = array();
92    if ( false !== ( $attr['oneTimeDonation']['show'] ?? true ) ) {
93        $donations['one-time'] = array_merge(
94            array(
95                'planId'     => null,
96                'title'      => __( 'One-Time', 'jetpack' ),
97                'class'      => 'donations__one-time-item',
98                'heading'    => $default_texts['oneTimeDonation']['heading'],
99                'buttonText' => $default_texts['oneTimeDonation']['buttonText'],
100            ),
101            $attr['oneTimeDonation']
102        );
103    }
104    if ( $attr['monthlyDonation']['show'] ) {
105        $donations['1 month'] = array_merge(
106            array(
107                'planId'     => null,
108                'title'      => __( 'Monthly', 'jetpack' ),
109                'class'      => 'donations__monthly-item',
110                'heading'    => $default_texts['monthlyDonation']['heading'],
111                'buttonText' => $default_texts['monthlyDonation']['buttonText'],
112            ),
113            $attr['monthlyDonation']
114        );
115    }
116    if ( $attr['annualDonation']['show'] ) {
117        $donations['1 year'] = array_merge(
118            array(
119                'planId'     => null,
120                'title'      => __( 'Yearly', 'jetpack' ),
121                'class'      => 'donations__annual-item',
122                'heading'    => $default_texts['annualDonation']['heading'],
123                'buttonText' => $default_texts['annualDonation']['buttonText'],
124            ),
125            $attr['annualDonation']
126        );
127    }
128
129    $choose_amount_text = $attr['chooseAmountText'] ?? $default_texts['chooseAmountText'];
130    $custom_amount_text = $attr['customAmountText'] ?? $default_texts['customAmountText'];
131    $currency           = $attr['currency'];
132
133    // Drop intervals left without a plan, so the active tab is computed against
134    // the actually-rendered set, not the configured set.
135    $valid_donations   = array();
136    $fallback_plan_ids = null;
137    foreach ( $donations as $interval => $donation ) {
138        // get_post( 0 ) falls back to the current post, so a missing plan ID must not reach it.
139        $plan_id = (int) $donation['planId'];
140        $plan    = $plan_id > 0 ? get_post( $plan_id ) : null;
141        if ( ! $plan instanceof WP_Post || \Jetpack_Memberships::$post_type_plan !== $plan->post_type ) {
142            // Blocks saved without plan IDs (older Tips blocks, blocks created outside the editor) get the plan the editor would have saved.
143            $fallback_plan_ids ??= get_donation_plan_ids( $currency );
144            if ( empty( $fallback_plan_ids[ $interval ] ) ) {
145                continue;
146            }
147            $donation['planId'] = $fallback_plan_ids[ $interval ];
148        }
149        $valid_donations[ $interval ] = $donation;
150    }
151
152    // With no valid plan, there is nothing a visitor could donate to.
153    if ( empty( $valid_donations ) ) {
154        return '';
155    }
156
157    $donations          = $valid_donations;
158    $rendered_intervals = array_keys( $donations );
159
160    // Effective default = configured defaultInterval if it survived plan validation,
161    // otherwise the first rendered interval (one-time → monthly → annual).
162    $default_interval = $attr['defaultInterval'] ?? null;
163    if ( ! in_array( $default_interval, $rendered_intervals, true ) ) {
164        $default_interval = $rendered_intervals[0] ?? null;
165    }
166    $tab_content_class_map = array(
167        'one-time' => 'is-one-time',
168        '1 month'  => 'is-monthly',
169        '1 year'   => 'is-annual',
170    );
171    $tab_content_class     = $default_interval ? $tab_content_class_map[ $default_interval ] : '';
172
173    $nav        = '';
174    $headings   = '';
175    $amounts    = '';
176    $extra_text = '';
177    $buttons    = '';
178    foreach ( $donations as $interval => $donation ) {
179        $plan_id = (int) $donation['planId'];
180
181        if ( count( $donations ) > 1 ) {
182            if ( ! $nav ) {
183                $nav .= '<div class="donations__nav">';
184            }
185            $is_active_class = $interval === $default_interval ? ' is-active' : '';
186            $nav            .= sprintf(
187                '<div role="button" tabindex="0" class="donations__nav-item%3$s" data-interval="%1$s">%2$s</div>',
188                esc_attr( $interval ),
189                esc_html( $donation['title'] ),
190                esc_attr( $is_active_class )
191            );
192        }
193        $heading_text = wp_kses_post( $donation['heading'] ?? '' );
194        if ( '' !== trim( $heading_text ) ) {
195            $headings .= sprintf(
196                '<h4 class="%1$s">%2$s</h4>',
197                esc_attr( $donation['class'] ),
198                $heading_text
199            );
200        }
201        $default_index_attr = '';
202        if ( isset( $donation['defaultAmountIndex'] ) && is_numeric( $donation['defaultAmountIndex'] ) ) {
203            $default_index_attr = sprintf( ' data-default-index="%d"', (int) $donation['defaultAmountIndex'] );
204        }
205        $amounts .= sprintf(
206            '<div class="donations__amounts %s"%s>',
207            esc_attr( $donation['class'] ),
208            $default_index_attr
209        );
210        foreach ( $donation['amounts'] as $amount ) {
211            $amounts .= sprintf(
212                '<div class="donations__amount" data-amount="%1$s">%2$s</div>',
213                esc_attr( $amount ),
214                esc_html( \Jetpack_Currencies::format_price( $amount, $currency ) )
215            );
216        }
217        $amounts        .= '</div>';
218        $extra_text_html = wp_kses_post( $donation['extraText'] ?? $default_texts['extraText'] );
219        if ( '' !== trim( $extra_text_html ) ) {
220            $extra_text .= sprintf(
221                '<p class="%1$s">%2$s</p>',
222                esc_attr( $donation['class'] ),
223                $extra_text_html
224            );
225        }
226        $buttons .= sprintf(
227            '<div class="wp-block-button donations__donate-button-wrapper %1$s"><a class="wp-block-button__link wp-element-button donations__donate-button %1$s" href="%2$s">%3$s</a></div>',
228            esc_attr( $donation['class'] ),
229            esc_url( \Jetpack_Memberships::get_instance()->get_subscription_url( $plan_id ) ),
230            wp_kses_post( $donation['buttonText'] )
231        );
232    }
233    if ( $nav ) {
234        $nav .= '</div>';
235    }
236
237    $custom_amount = '';
238    if ( $attr['showCustomAmount'] ) {
239        $custom_amount_html = wp_kses_post( $custom_amount_text );
240        if ( '' !== trim( $custom_amount_html ) ) {
241            $custom_amount .= sprintf( '<p>%s</p>', $custom_amount_html );
242        }
243        $default_custom_amount = $attr['customAmountPlaceholder']
244            ?? ( \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1 ) * 100;
245        $custom_amount        .= sprintf(
246            '<div class="donations__amount donations__custom-amount">
247                %1$s
248                <div class="donations__amount-value" data-currency="%2$s" data-empty-text="%3$s"></div>
249            </div>',
250            esc_html( \Jetpack_Currencies::CURRENCIES[ $currency ]['symbol'] ?? '¤' ),
251            esc_attr( $currency ),
252            esc_attr( \Jetpack_Currencies::format_price( $default_custom_amount, $currency, false ) )
253        );
254    }
255
256    $display_mode     = $attr['displayMode'] ?? 'inline';
257    $instance_id      = wp_unique_id( 'jp-donations-' );
258    $instance_classes = $instance_id;
259    if ( isset( $attr['tabsAppearance'] ) && 'buttons' === $attr['tabsAppearance'] ) {
260        $instance_classes .= ' is-style-buttons';
261    }
262    if ( 'modal' === $display_mode ) {
263        $instance_classes .= ' is-display-modal';
264        if ( ! empty( $attr['triggerSticky'] ) ) {
265            $instance_classes .= ' is-sticky';
266        }
267    }
268    $wrapper_attr_array = array( 'class' => $instance_classes );
269    if ( $default_interval ) {
270        $wrapper_attr_array['data-default-interval'] = $default_interval;
271    }
272    $wrapper_attr_array = array_merge( $wrapper_attr_array, build_security_data_attrs( $attr, $currency ) );
273    $wrapper_attrs      = get_block_wrapper_attributes( $wrapper_attr_array );
274    $custom_styles      = build_custom_styles( $attr, '.' . $instance_id );
275
276    $choose_amount_html  = wp_kses_post( $choose_amount_text );
277    $choose_amount_block = '' !== trim( $choose_amount_html ) ? '<p>' . $choose_amount_html . '</p>' : '';
278
279    if ( 'modal' === $display_mode ) {
280        $trigger_text     = $attr['triggerButtonText'] ?? $default_texts['triggerButtonText'];
281        $trigger_icon_key = $attr['triggerIcon'] ?? 'heart';
282        $trigger_svg      = get_trigger_icon_svg( $trigger_icon_key );
283        $modal_id         = $instance_id . '-modal';
284
285        return sprintf(
286            '
287<div %1$s>%9$s
288    <button
289        class="donations__trigger-button wp-block-button__link wp-element-button"
290        aria-haspopup="dialog"
291        aria-controls="%11$s"
292    >%12$s%10$s</button>
293    <div
294        id="%11$s"
295        class="donations__modal-overlay"
296        role="dialog"
297        aria-modal="true"
298        aria-label="%10$s"
299        hidden
300    >
301        <div class="donations__modal-dialog">
302            <button class="donations__modal-close" aria-label="%13$s">&#x2715;</button>
303            <div class="donations__modal-content">
304                <div class="donations__container">
305                    %2$s
306                    <div class="donations__content">
307                        <div class="donations__tab %14$s">
308                            %3$s
309                            %4$s
310                            %5$s
311                            %6$s
312                            <div class="donations__range-error"></div>
313                            <hr class="donations__separator">
314                            %7$s
315                            %8$s
316                        </div>
317                    </div>
318                </div>
319            </div>
320        </div>
321    </div>
322</div>
323',
324            $wrapper_attrs,
325            $nav,
326            $headings,
327            $choose_amount_block,
328            $amounts,
329            $custom_amount,
330            $extra_text,
331            $buttons,
332            $custom_styles ? '<style>' . $custom_styles . '</style>' : '',
333            esc_html( $trigger_text ),
334            esc_attr( $modal_id ),
335            $trigger_svg,
336            esc_attr__( 'Close', 'jetpack' ),
337            esc_attr( $tab_content_class )
338        );
339    }
340
341    return sprintf(
342        '
343<div %1$s>%9$s
344    <div class="donations__container">
345        %2$s
346        <div class="donations__content">
347            <div class="donations__tab %10$s">
348                %3$s
349                %4$s
350                %5$s
351                %6$s
352                <div class="donations__range-error"></div>
353                <hr class="donations__separator">
354                %7$s
355                %8$s
356            </div>
357        </div>
358    </div>
359</div>
360',
361        $wrapper_attrs,
362        $nav,
363        $headings,
364        $choose_amount_block,
365        $amounts,
366        $custom_amount,
367        $extra_text,
368        $buttons,
369        $custom_styles ? '<style>' . $custom_styles . '</style>' : '',
370        esc_attr( $tab_content_class )
371    );
372}
373
374/**
375 * Get the site's donation plan for each interval in a currency, picked like the editor picks them:
376 * the cheapest plan that is neither deleted nor editable, then the oldest.
377 *
378 * @param string $currency Currency code, e.g. USD.
379 *
380 * @return array<string,int> Plan IDs keyed by interval ('one-time', '1 month', '1 year').
381 */
382function get_donation_plan_ids( $currency ) {
383    if ( ! is_string( $currency ) || '' === $currency ) {
384        return array();
385    }
386
387    // WP_Query caches the result, and a change to any post or post meta clears that cache.
388    $plans = get_posts(
389        array(
390            'post_type'      => \Jetpack_Memberships::$post_type_plan,
391            'posts_per_page' => -1,
392            'orderby'        => array(
393                'date' => 'ASC',
394                'ID'   => 'ASC',
395            ),
396            'meta_query'     => array(
397                array(
398                    'key'   => 'jetpack_memberships_type',
399                    'value' => 'donation',
400                ),
401            ),
402        )
403    );
404
405    $plan_ids = array();
406    $prices   = array();
407    foreach ( $plans as $plan ) {
408        $interval = get_post_meta( $plan->ID, 'jetpack_memberships_interval', true );
409        $price    = (float) get_post_meta( $plan->ID, 'jetpack_memberships_price', true );
410        if (
411            ! is_string( $interval )
412            || get_post_meta( $plan->ID, 'jetpack_memberships_currency', true ) !== $currency
413            || get_post_meta( $plan->ID, 'jetpack_memberships_is_deleted', true )
414            || 1 === (int) get_post_meta( $plan->ID, 'jetpack_memberships_is_editable', true )
415            || ( isset( $prices[ $interval ] ) && $prices[ $interval ] <= $price )
416        ) {
417            continue;
418        }
419        $plan_ids[ $interval ] = $plan->ID;
420        $prices[ $interval ]   = $price;
421    }
422
423    return $plan_ids;
424}
425
426/**
427 * WooCommerce Email Editor render callback for the Donations block.
428 *
429 * Builds a dedicated email version of the block from its attributes (rather than
430 * post-processing the interactive frontend markup) so customizations — per
431 * interval heading, supporting text and button label — are preserved. Each
432 * interval is rendered as an email-safe table section with a CTA button drawn by
433 * the Button block's own email renderer.
434 *
435 * @param string $block_content     The rendered block content (unused; we rebuild from attributes).
436 * @param array  $parsed_block      The parsed block data.
437 * @param object $rendering_context The email rendering context.
438 *
439 * @return string
440 */
441function render_email( $block_content, array $parsed_block, $rendering_context ) {
442    if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] )
443        || ! function_exists( '\Automattic\Jetpack\Extensions\Button\render_email' )
444        || ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
445        return '';
446    }
447
448    $attr          = $parsed_block['attrs'];
449    $default_texts = get_default_texts();
450
451    $url = get_permalink();
452    if ( ! is_string( $url ) || '' === $url ) {
453        $url = is_string( $attr['fallbackLinkUrl'] ?? null ) && '' !== $attr['fallbackLinkUrl'] ? $attr['fallbackLinkUrl'] : '#';
454    }
455
456    $alignment   = in_array( $attr['contentAlignment'] ?? '', array( 'left', 'center', 'right' ), true ) ? $attr['contentAlignment'] : 'left';
457    $table_style = sprintf( 'width:100%%;max-width:%dpx;border-collapse:collapse;', get_email_target_width( $rendering_context ) );
458
459    $sections = '';
460    foreach ( array(
461        'oneTimeDonation' => true,
462        'monthlyDonation' => false,
463        'annualDonation'  => false,
464    ) as $key => $default_show ) {
465        $interval = is_array( $attr[ $key ] ?? null ) ? $attr[ $key ] : array();
466        if ( false === ( $interval['show'] ?? $default_show ) ) {
467            continue;
468        }
469
470        $heading = wp_kses_post( $interval['heading'] ?? $default_texts[ $key ]['heading'] );
471        $extra   = wp_kses_post( $interval['extraText'] ?? $default_texts['extraText'] );
472
473        $content = '';
474        if ( '' !== trim( wp_strip_all_tags( $heading ) ) ) {
475            $content .= sprintf(
476                '<p style="margin:0 0 4px;font-size:18px;font-weight:700;line-height:1.3;text-align:%s;">%s</p>',
477                esc_attr( $alignment ),
478                $heading
479            );
480        }
481        if ( '' !== trim( wp_strip_all_tags( $extra ) ) ) {
482            $content .= sprintf(
483                '<p style="margin:0 0 14px;font-size:15px;line-height:1.5;text-align:%s;">%s</p>',
484                esc_attr( $alignment ),
485                $extra
486            );
487        }
488        $content .= render_email_donate_button( $interval['buttonText'] ?? $default_texts[ $key ]['buttonText'], $url, $attr, $rendering_context );
489
490        $is_first   = '' === $sections;
491        $cell_style = sprintf(
492            'text-align:%1$s;padding:%2$s;%3$s',
493            esc_attr( $alignment ),
494            $is_first ? '0 0 24px' : '24px 0',
495            $is_first ? '' : 'border-top:1px solid #e0e0e0;'
496        );
497        $sections  .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
498            $content,
499            array( 'style' => $table_style ),
500            array( 'style' => $cell_style )
501        );
502    }
503
504    return $sections;
505}
506
507/**
508 * Render a single donation CTA as an email-safe button via the Button block's
509 * email renderer, carrying over the donation block's button styling.
510 *
511 * @param string $text              Button label.
512 * @param string $url               Button destination.
513 * @param array  $attr              Donations block attributes.
514 * @param object $rendering_context The email rendering context.
515 * @return string
516 */
517function render_email_donate_button( $text, $url, $attr, $rendering_context ) {
518    $text = trim( wp_strip_all_tags( (string) $text ) );
519
520    $button_attrs = array(
521        'text' => '' !== $text ? $text : __( 'Donate', 'jetpack' ),
522        'url'  => $url,
523    );
524
525    if ( ! empty( $attr['buttonFontSize'] ) && is_string( $attr['buttonFontSize'] ) ) {
526        $button_attrs['customFontSize'] = $attr['buttonFontSize'];
527    }
528    if ( isset( $attr['buttonBorderRadius'] ) && is_string( $attr['buttonBorderRadius'] ) ) {
529        $radius = (int) $attr['buttonBorderRadius'];
530        if ( $radius > 0 ) {
531            $button_attrs['borderRadius'] = $radius;
532        }
533    }
534
535    return \Automattic\Jetpack\Extensions\Button\render_email(
536        '',
537        array( 'attrs' => $button_attrs ),
538        $rendering_context
539    );
540}
541
542/**
543 * Resolve the target content width (in px) from the email rendering context,
544 * falling back to a sensible default.
545 *
546 * @param object $rendering_context The email rendering context.
547 * @return int Width in pixels.
548 */
549function get_email_target_width( $rendering_context ) {
550    $target_width = 600;
551    if ( is_object( $rendering_context ) && method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
552        $width = $rendering_context->get_layout_width_without_padding();
553        if ( is_string( $width ) && preg_match( '/(\d+)px/', $width, $matches ) ) {
554            $parsed = (int) $matches[1];
555            if ( $parsed > 0 ) {
556                $target_width = $parsed;
557            }
558        }
559    }
560    return $target_width;
561}
562
563/**
564 * Build data-attributes array for security (min/max amount) constraints.
565 *
566 * Extracted so it can be tested independently of the full render pipeline.
567 *
568 * @since 15.9
569 *
570 * @param array  $attr     Block attributes.
571 * @param string $currency Currency code (e.g. 'USD').
572 * @return array Associative array of data-attribute name => value.
573 */
574function build_security_data_attrs( $attr, $currency ) {
575    $attrs      = array();
576    $min_amount = isset( $attr['minimumAmount'] ) ? (float) $attr['minimumAmount'] : null;
577    $max_amount = isset( $attr['maximumAmount'] ) ? (float) $attr['maximumAmount'] : null;
578    if ( null !== $min_amount ) {
579        $attrs['data-min-amount'] = $min_amount;
580        $attrs['data-min-error']  = sprintf(
581            /* translators: %s: minimum donation amount formatted with currency symbol */
582            __( 'The minimum donation amount is %s.', 'jetpack' ),
583            \Jetpack_Currencies::format_price( (string) $min_amount, $currency )
584        );
585    }
586    if ( null !== $max_amount ) {
587        $attrs['data-max-amount'] = $max_amount;
588        $attrs['data-max-error']  = sprintf(
589            /* translators: %s: maximum donation amount formatted with currency symbol */
590            __( 'The maximum donation amount is %s.', 'jetpack' ),
591            \Jetpack_Currencies::format_price( (string) $max_amount, $currency )
592        );
593    }
594    $stripe_min                     = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1;
595    $attrs['data-stripe-min-error'] = sprintf(
596        /* translators: %s: payment processor minimum donation amount formatted with currency symbol */
597        _x( 'The minimum donation amount is %s.', 'payment processor minimum', 'jetpack' ),
598        \Jetpack_Currencies::format_price( (string) $stripe_min, $currency )
599    );
600    return $attrs;
601}
602
603/**
604 * Build a CSS string scoping per-state and tab-level style rules to a single
605 * block instance.
606 *
607 * @param array  $attr  Block attributes.
608 * @param string $scope CSS class selector (with leading dot) unique to this instance.
609 * @return string CSS rules joined into one string, or '' when no overrides are set.
610 */
611function build_custom_styles( $attr, $scope ) {
612    $tab_padding    = isset( $attr['tabPadding'] ) && is_array( $attr['tabPadding'] ) ? $attr['tabPadding'] : array();
613    $button_padding = isset( $attr['buttonPadding'] ) && is_array( $attr['buttonPadding'] ) ? $attr['buttonPadding'] : array();
614
615    $groups = array(
616        array(
617            'selector'   => $scope . ' .donations__nav-item',
618            'properties' => array(
619                'font-size'      => $attr['tabFontSize'] ?? '',
620                'padding-top'    => $tab_padding['top'] ?? '',
621                'padding-right'  => $tab_padding['right'] ?? '',
622                'padding-bottom' => $tab_padding['bottom'] ?? '',
623                'padding-left'   => $tab_padding['left'] ?? '',
624            ),
625        ),
626        array(
627            'selector'   => $scope . ' .donations__nav-item.is-active',
628            'properties' => array(
629                'background' => $attr['activeTabBackgroundColor'] ?? '',
630                'color'      => $attr['activeTabTextColor'] ?? '',
631            ),
632        ),
633        array(
634            'selector'   => $scope . ' .donations__nav-item:not(.is-active)',
635            'properties' => array(
636                'background' => $attr['inactiveTabBackgroundColor'] ?? '',
637                'color'      => $attr['inactiveTabTextColor'] ?? '',
638            ),
639        ),
640        array(
641            'selector'   => $scope . ' .donations__amount.is-selected',
642            'properties' => array(
643                'background-color' => $attr['selectedAmountBackgroundColor'] ?? '',
644                'color'            => $attr['selectedAmountTextColor'] ?? '',
645            ),
646        ),
647        array(
648            'selector'   => $scope . ' .donations__amount.is-selected',
649            'properties' => array(
650                // Override only the outer ring color; the inner 1px white separator stays put.
651                'box-shadow' => isset( $attr['selectedAmountOutlineColor'] ) && '' !== $attr['selectedAmountOutlineColor']
652                    ? '0 0 0 1px #fff,0 0 0 3px ' . $attr['selectedAmountOutlineColor']
653                    : '',
654            ),
655        ),
656        array(
657            'selector'   => $scope . ' .donations__donate-button',
658            'properties' => array(
659                'font-size'      => $attr['buttonFontSize'] ?? '',
660                'padding-top'    => $button_padding['top'] ?? '',
661                'padding-right'  => $button_padding['right'] ?? '',
662                'padding-bottom' => $button_padding['bottom'] ?? '',
663                'padding-left'   => $button_padding['left'] ?? '',
664            ),
665        ),
666    );
667
668    $rules = array();
669
670    $content_alignment = $attr['contentAlignment'] ?? '';
671    if ( in_array( $content_alignment, array( 'left', 'center', 'right' ), true ) ) {
672        $rules[]     = $scope . ' .donations__content{text-align:' . $content_alignment . '}';
673        $justify_map = array(
674            'left'   => 'flex-start',
675            'center' => 'center',
676            'right'  => 'flex-end',
677        );
678        $rules[]     = $scope . ' .donations__amounts{justify-content:' . $justify_map[ $content_alignment ] . '}';
679    }
680
681    foreach ( $groups as $group ) {
682        $decls = array();
683        foreach ( $group['properties'] as $property => $value ) {
684            $safe = sanitize_css_value( $value );
685            if ( '' !== $safe ) {
686                $decls[] = $property . ':' . $safe;
687            }
688        }
689        if ( $decls ) {
690            $rules[] = $group['selector'] . '{' . implode( ';', $decls ) . '}';
691        }
692    }
693
694    // User-set tab border color: applies to the default-style nav bottom
695    // divider, the per-tab dividers, and the buttons-style pill borders.
696    $tab_border_safe = sanitize_css_value( $attr['tabBorderColor'] ?? '' );
697    if ( '' !== $tab_border_safe ) {
698        $rules[] = $scope . ' .donations__nav,' . $scope . ' .donations__nav-item,' . $scope . ' .donations__nav-item.is-active{border-color:' . $tab_border_safe . '}';
699    }
700
701    $button_radius_decls = build_radius_decls( $attr['buttonBorderRadius'] ?? null );
702    if ( $button_radius_decls ) {
703        $rules[] = $scope . ' .donations__donate-button{' . implode( ';', $button_radius_decls ) . '}';
704    }
705
706    // User-set amount tile font size, border (BorderBoxControl shape) and
707    // border radius (BorderRadiusControl shape). Applies to all amount tiles
708    // (preset + custom); selected-state colors above only kick in when an
709    // amount has the is-selected class.
710    $amount_decls = array();
711    $amount_font  = sanitize_css_value( $attr['amountFontSize'] ?? '' );
712    if ( '' !== $amount_font ) {
713        $amount_decls[] = 'font-size:' . $amount_font;
714    }
715    $amount_decls = array_merge( $amount_decls, build_border_decls( $attr['amountBorder'] ?? null ) );
716    $amount_decls = array_merge( $amount_decls, build_radius_decls( $attr['amountBorderRadius'] ?? null ) );
717    if ( $amount_decls ) {
718        $rules[] = $scope . ' .donations__amount{' . implode( ';', $amount_decls ) . '}';
719    }
720
721    $button_alignment = $attr['buttonAlignment'] ?? '';
722    if ( in_array( $button_alignment, array( 'left', 'center', 'right' ), true ) ) {
723        $rules[] = $scope . ' .donations__donate-button-wrapper{text-align:' . $button_alignment . '}';
724    } elseif ( 'full' === $button_alignment ) {
725        $rules[] = $scope . ' .donations__donate-button-wrapper{display:block;width:100%}'
726            . $scope . ' .donations__donate-button{display:block;width:100%;box-sizing:border-box;text-align:center}';
727    }
728
729    // Pop-up display mode: wire up trigger-button alignment via text-align on
730    // the wrapper so the inline-flex button responds to the alignment toolbar.
731    if ( 'modal' === ( $attr['displayMode'] ?? 'inline' ) ) {
732        if ( in_array( $content_alignment, array( 'left', 'center', 'right' ), true ) ) {
733            $rules[] = $scope . '{text-align:' . $content_alignment . '}';
734        }
735    }
736
737    // Wrapper border (In-page mode only). Compound selector (.wp-block-jetpack-donations.jp-donations-N)
738    // has specificity 0,2,0 and wins over the default single-class rule (0,1,0) in common.scss.
739    if ( 'modal' !== ( $attr['displayMode'] ?? 'inline' ) ) {
740        $wrapper_border_decls = array_merge(
741            build_border_decls( $attr['blockBorder'] ?? null ),
742            build_radius_decls( $attr['blockBorderRadius'] ?? null )
743        );
744        if ( $wrapper_border_decls ) {
745            $rules[] = '.wp-block-jetpack-donations' . $scope . '{' . implode( ';', $wrapper_border_decls ) . '}';
746        }
747    }
748
749    return implode( '', $rules );
750}
751
752/**
753 * Convert a uniform-or-split BorderBoxControl value into individual CSS declarations.
754 * Uniform shape: { color, style, width }. Split shape: { top: {...}, right: ..., etc. }.
755 *
756 * @param mixed $border BorderBoxControl value (or null).
757 * @return array List of CSS declaration strings (e.g. "border-color:#abc"), already sanitized.
758 */
759function build_border_decls( $border ) {
760    if ( ! is_array( $border ) ) {
761        return array();
762    }
763    $decls    = array();
764    $sides    = array( 'top', 'right', 'bottom', 'left' );
765    $is_split = false;
766    foreach ( $sides as $side ) {
767        if ( isset( $border[ $side ] ) ) {
768            $is_split = true;
769            break;
770        }
771    }
772    if ( $is_split ) {
773        foreach ( $sides as $side ) {
774            $sb = $border[ $side ] ?? null;
775            if ( ! is_array( $sb ) ) {
776                continue;
777            }
778            foreach ( array( 'color', 'style', 'width' ) as $prop ) {
779                $safe = sanitize_css_value( $sb[ $prop ] ?? '' );
780                if ( '' !== $safe ) {
781                    $decls[] = 'border-' . $side . '-' . $prop . ':' . $safe;
782                }
783            }
784        }
785    } else {
786        foreach ( array( 'color', 'style', 'width' ) as $prop ) {
787            $safe = sanitize_css_value( $border[ $prop ] ?? '' );
788            if ( '' !== $safe ) {
789                $decls[] = 'border-' . $prop . ':' . $safe;
790            }
791        }
792    }
793    return $decls;
794}
795
796/**
797 * Convert a uniform-or-per-corner BorderRadiusControl value into CSS declarations.
798 * Uniform shape: a string like "8px". Per-corner shape:
799 * { topLeft, topRight, bottomRight, bottomLeft } each with string values.
800 *
801 * @param mixed $radius BorderRadiusControl value (or null).
802 * @return array List of CSS declaration strings.
803 */
804function build_radius_decls( $radius ) {
805    if ( is_string( $radius ) && '' !== $radius ) {
806        $safe = sanitize_css_value( $radius );
807        return '' !== $safe ? array( 'border-radius:' . $safe ) : array();
808    }
809    if ( ! is_array( $radius ) ) {
810        return array();
811    }
812    $corners = array(
813        'topLeft'     => 'border-top-left-radius',
814        'topRight'    => 'border-top-right-radius',
815        'bottomRight' => 'border-bottom-right-radius',
816        'bottomLeft'  => 'border-bottom-left-radius',
817    );
818    $decls   = array();
819    foreach ( $corners as $key => $css_prop ) {
820        $safe = sanitize_css_value( $radius[ $key ] ?? '' );
821        if ( '' !== $safe ) {
822            $decls[] = $css_prop . ':' . $safe;
823        }
824    }
825    return $decls;
826}
827
828/**
829 * Sanitize a user-supplied CSS value (color, length, etc.) for safe inclusion
830 * in a <style> element. Strips characters that could break out of the style
831 * context (<, >, {, }, ;, quotes, backslash) and caps length, while leaving
832 * valid hex / rgb() / hsl() / var() / named-color / px / rem / em values intact.
833 *
834 * @param mixed $value Raw attribute value.
835 * @return string Sanitized value, or '' if rejected.
836 */
837function sanitize_css_value( $value ) {
838    if ( ! is_string( $value ) || '' === $value ) {
839        return '';
840    }
841    $value = trim( $value );
842    if ( strlen( $value ) > 100 ) {
843        return '';
844    }
845    if ( preg_match( '/[<>{};\\\\\'"]/', $value ) ) {
846        return '';
847    }
848    return $value;
849}
850
851/**
852 * Get the default texts for the block.
853 *
854 * @return array
855 */
856function get_default_texts() {
857    return array(
858        'chooseAmountText'  => __( 'Choose an amount', 'jetpack' ),
859        'customAmountText'  => __( 'Or enter a custom amount', 'jetpack' ),
860        'extraText'         => __( 'Your contribution is appreciated.', 'jetpack' ),
861        'triggerButtonText' => __( 'Donate', 'jetpack' ),
862        'oneTimeDonation'   => array(
863            'heading'    => __( 'Make a one-time donation', 'jetpack' ),
864            'buttonText' => __( 'Donate', 'jetpack' ),
865        ),
866        'monthlyDonation'   => array(
867            'heading'    => __( 'Make a monthly donation', 'jetpack' ),
868            'buttonText' => __( 'Donate monthly', 'jetpack' ),
869        ),
870        'annualDonation'    => array(
871            'heading'    => __( 'Make a yearly donation', 'jetpack' ),
872            'buttonText' => __( 'Donate yearly', 'jetpack' ),
873        ),
874    );
875}
876
877/**
878 * Return inline SVG markup for a trigger button icon.
879 *
880 * Path data sourced from icons.js ICON_SVG_PATHS — keep in sync.
881 *
882 * @param string $icon_key Icon key (e.g. 'coffee', 'heart', 'gift').
883 * @return string SVG HTML string, or '' when key is 'none' or unknown.
884 */
885function get_trigger_icon_svg( $icon_key ) {
886    $paths = array(
887        'heart'      => array( 'd' => 'M16.5 4.5c2.206 0 4 1.794 4 4 0 4.67-5.543 8.94-8.5 11.023C9.043 17.44 3.5 13.17 3.5 8.5c0-2.206 1.794-4 4-4 1.298 0 2.522.638 3.273 1.706L12 7.953l1.227-1.746c.75-1.07 1.975-1.707 3.273-1.707m0-1.5c-1.862 0-3.505.928-4.5 2.344C11.005 3.928 9.362 3 7.5 3 4.462 3 2 5.462 2 8.5c0 5.72 6.5 10.438 10 12.85 3.5-2.412 10-7.13 10-12.85C22 5.462 19.538 3 16.5 3z' ),
888        'gift'       => array( 'd' => 'M15.333 4C16.6677 4 17.75 5.0823 17.75 6.41699V6.75C17.75 7.20058 17.6394 7.62468 17.4473 8H18.5C19.2767 8 19.9154 8.59028 19.9922 9.34668L20 9.5V18.5C20 19.3284 19.3284 20 18.5 20H5.5C4.72334 20 4.08461 19.4097 4.00781 18.6533L4 18.5V9.5L4.00781 9.34668C4.07949 8.64069 4.64069 8.07949 5.34668 8.00781L5.5 8H6.55273C6.36065 7.62468 6.25 7.20058 6.25 6.75V6.41699C6.25 5.0823 7.3323 4 8.66699 4C10.0436 4.00011 11.2604 4.68183 12 5.72559C12.7396 4.68183 13.9564 4.00011 15.333 4ZM5.5 18.5H11.25V9.5H5.5V18.5ZM12.75 18.5H18.5V9.5H12.75V18.5ZM8.66699 5.5C8.16073 5.5 7.75 5.91073 7.75 6.41699V6.75C7.75 7.44036 8.30964 8 9 8H11.2461C11.2021 6.61198 10.0657 5.50017 8.66699 5.5ZM15.333 5.5C13.9343 5.50017 12.7979 6.61198 12.7539 8H15C15.6904 8 16.25 7.44036 16.25 6.75V6.41699C16.25 5.91073 15.8393 5.5 15.333 5.5Z' ),
889        'star'       => array( 'd' => 'M11.776 4.454a.25.25 0 01.448 0l2.069 4.192a.25.25 0 00.188.137l4.626.672a.25.25 0 01.139.426l-3.348 3.263a.25.25 0 00-.072.222l.79 4.607a.25.25 0 01-.362.263l-4.138-2.175a.25.25 0 00-.232 0l-4.138 2.175a.25.25 0 01-.363-.263l.79-4.607a.25.25 0 00-.071-.222L4.754 9.881a.25.25 0 01.139-.426l4.626-.672a.25.25 0 00.188-.137l2.069-4.192z' ),
890        'thumbs-up'  => array( 'd' => 'm3 12 1 8h1.5l-1-8H3Zm15.8-2h-4.4l.8-3.6c.3-1.3-.7-2.4-1.9-2.4h-.2c-.6 0-1.2.3-1.6.8l-5 6.6c-.3.4-.4.8-.4 1.2v.2l.7 5.4v.2c.2.9 1 1.5 1.9 1.5h8.2c.9 0 1.7-.6 1.9-1.4l1.8-6c.4-1.3-.6-2.6-1.9-2.6Zm.5 2.1-1.8 6c0 .2-.3.4-.5.4H8.8c-.3 0-.5-.2-.5-.4l-.7-5.4v-.4l5-6.6c0-.1.2-.2.4-.2h.2c.3 0 .6.3.5.6l-.8 3.6c-.1.4 0 .9.3 1.3s.7.6 1.2.6h4.4c.3 0 .6.3.5.6Z' ),
891        'smiley'     => array( 'd' => 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5 14.67 11 15.5 11zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z' ),
892        'coffee'     => array(
893            'd'         => 'M6.5 5h11v3.25h1a2 2 0 010 4H17.5v1.25a3 3 0 01-3 3H9.5a3 3 0 01-3-3V5zm1.5 1.5v7.25a1.5 1.5 0 001.5 1.5h5a1.5 1.5 0 001.5-1.5V6.5h-8zm10 2.5H17.5v2h.5a1 1 0 000-2zM5 20h14V18.5H5z',
894            'fill-rule' => 'evenodd',
895        ),
896        'tip-jar'    => array( 'd' => 'M9 3h6c0-1.1-.9-2-2-2H11C9.9 1 9 1.9 9 3zm7 0H8c-.55 0-1 .45-1 1v1.5c0 .55.45 1 1 1h8c.55 0 1-.45 1-1V4c0-.55-.45-1-1-1zm-1 3.5H9V19c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2V6.5zm-3 1.5h2v1.5l-1 1.5-1-1.5V8z' ),
897        'hand-heart' => array( 'd' => 'M15.5 2.1c-1.1 0-2 .6-2.5 1.4-.5-.9-1.4-1.4-2.5-1.4C8.8 2.1 7.5 3.4 7.5 5c0 2.5 4.5 5.9 5.5 6.6 1-.7 5.5-4.1 5.5-6.6 0-1.6-1.3-2.9-3-2.9zM9 14H7l-2 7h14l-2-7h-2l-1 3H10l-1-3z' ),
898        'people'     => array(
899            'd'         => 'M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z',
900            'fill-rule' => 'evenodd',
901        ),
902    );
903
904    if ( ! isset( $paths[ $icon_key ] ) || 'none' === $icon_key ) {
905        return '';
906    }
907
908    $icon        = $paths[ $icon_key ];
909    $extra_attrs = isset( $icon['fill-rule'] ) ? ' fill-rule="' . esc_attr( $icon['fill-rule'] ) . '"' : '';
910
911    return sprintf(
912        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false" class="donations__trigger-icon"><path d="%s"%s/></svg>',
913        esc_attr( $icon['d'] ),
914        $extra_attrs
915    );
916}
917
918/**
919 * Make default texts available to the editor.
920 */
921function load_editor_scripts() {
922    // Only relevant to the editor right now.
923    if ( ! is_admin() ) {
924        return;
925    }
926
927    $data = array(
928        'defaultTexts' => get_default_texts(),
929    );
930
931    wp_add_inline_script(
932        'jetpack-blocks-editor',
933        'var Jetpack_DonationsBlock = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
934        'before'
935    );
936}
937add_action( 'enqueue_block_assets', __NAMESPACE__ . '\load_editor_scripts', 11 );
938
939/**
940 * Determine if AMP should be disabled on posts having Donations blocks.
941 *
942 * @param bool    $skip Skipped.
943 * @param int     $post_id Post ID.
944 * @param WP_Post $post Post.
945 *
946 * @return bool Whether to skip the post from AMP.
947 */
948function amp_skip_post( $skip, $post_id, $post ) {
949    // When AMP is on standard mode, there are no non-AMP posts to link to where the donation can be completed, so let's
950    // prevent the post from being available in AMP.
951    if ( function_exists( 'amp_is_canonical' ) && \amp_is_canonical() && has_block( Blocks::get_block_name( __DIR__ ), $post->post_content ) ) {
952        return true;
953    }
954    return $skip;
955}
956add_filter( 'amp_skip_post', __NAMESPACE__ . '\amp_skip_post', 10, 3 );
957
958/**
959 * Record a Tracks event when a post containing the Donations block transitions
960 * to "publish" status. Fires once per donations block in the post, with the
961 * block's configuration snapshot as event properties.
962 *
963 * Admin-driven event only (no donor data). Does not fire on regular updates of
964 * an already-published post, on autosaves, or on revisions.
965 *
966 * @since 15.9
967 *
968 * @param string  $new_status New post status.
969 * @param string  $old_status Previous post status.
970 * @param WP_Post $post       Post being transitioned.
971 * @return void
972 */
973function record_block_published_event( $new_status, $old_status, $post ) {
974    if ( 'publish' !== $new_status || 'publish' === $old_status ) {
975        return;
976    }
977    if ( ! $post instanceof WP_Post ) {
978        return;
979    }
980    if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
981        return;
982    }
983    $block_name = Blocks::get_block_name( __DIR__ );
984    if ( ! has_block( $block_name, $post ) ) {
985        return;
986    }
987
988    $donation_blocks = collect_donation_blocks( parse_blocks( $post->post_content ), $block_name );
989    if ( empty( $donation_blocks ) ) {
990        return;
991    }
992
993    require_once JETPACK__PLUGIN_DIR . 'modules/memberships/class-jetpack-memberships.php';
994    $stripe_connected = \Jetpack_Memberships::has_connected_account();
995    $block_count      = count( $donation_blocks );
996
997    foreach ( $donation_blocks as $index => $block ) {
998        $props = build_block_published_event_props( $block, $post, $index, $block_count, $stripe_connected );
999        record_published_event( 'donations_block_published', $props );
1000    }
1001}
1002add_action( 'transition_post_status', __NAMESPACE__ . '\record_block_published_event', 10, 3 );
1003
1004/**
1005 * Walk a parsed block tree and return a flat list of Donations blocks (including
1006 * nested ones inside columns / groups / patterns).
1007 *
1008 * @since 15.9
1009 *
1010 * @param array  $blocks     Parsed blocks from parse_blocks().
1011 * @param string $block_name Donations block name (e.g. 'jetpack/donations').
1012 * @return array List of parsed blocks matching $block_name.
1013 */
1014function collect_donation_blocks( $blocks, $block_name ) {
1015    $matches = array();
1016    foreach ( $blocks as $block ) {
1017        if ( isset( $block['blockName'] ) && $block_name === $block['blockName'] ) {
1018            $matches[] = $block;
1019        }
1020        if ( ! empty( $block['innerBlocks'] ) ) {
1021            $matches = array_merge( $matches, collect_donation_blocks( $block['innerBlocks'], $block_name ) );
1022        }
1023    }
1024    return $matches;
1025}
1026
1027/**
1028 * Build the property bag for a jetpack_donations_block_published Tracks event.
1029 *
1030 * Extracted so it can be tested independently of the WordPress hook plumbing.
1031 *
1032 * @since 15.9
1033 *
1034 * @param array   $block            Parsed block (with 'attrs' key).
1035 * @param WP_Post $post             Post being published.
1036 * @param int     $index            0-based position of this block within the post's donation blocks.
1037 * @param int     $block_count      Total count of donations blocks in the post.
1038 * @param bool    $stripe_connected Whether the site currently has a connected Stripe account.
1039 * @return array Tracks event properties.
1040 */
1041function build_block_published_event_props( $block, $post, $index, $block_count, $stripe_connected ) {
1042    $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
1043
1044    $one_time_show = ! isset( $attrs['oneTimeDonation']['show'] ) || false !== $attrs['oneTimeDonation']['show'];
1045    $monthly_show  = ! empty( $attrs['monthlyDonation']['show'] );
1046    $yearly_show   = ! empty( $attrs['annualDonation']['show'] );
1047
1048    $default_interval  = $attrs['defaultInterval'] ?? 'one-time';
1049    $interval_to_freq  = array(
1050        'one-time' => 'one_time',
1051        '1 month'  => 'monthly',
1052        '1 year'   => 'yearly',
1053    );
1054    $default_frequency = $interval_to_freq[ $default_interval ] ?? $default_interval;
1055    $enabled_count     = (int) $one_time_show + (int) $monthly_show + (int) $yearly_show;
1056    $tabs_appearance   = $attrs['tabsAppearance'] ?? 'tabs';
1057
1058    return array(
1059        'feature'                   => 'donations',
1060        'surface'                   => 'server',
1061        'stripe_connected'          => (bool) $stripe_connected,
1062        'post_id'                   => (int) $post->ID,
1063        'post_type'                 => (string) $post->post_type,
1064        'block_index_in_post'       => (int) $index,
1065        'block_count_in_post'       => (int) $block_count,
1066        'currency'                  => isset( $attrs['currency'] ) ? (string) $attrs['currency'] : null,
1067        'default_frequency'         => $default_frequency,
1068        'enabled_frequencies_count' => $enabled_count,
1069        'show_one_time'             => $one_time_show,
1070        'show_monthly'              => $monthly_show,
1071        'show_yearly'               => $yearly_show,
1072        'show_custom_amount'        => ! empty( $attrs['showCustomAmount'] ),
1073        'has_min_amount'            => isset( $attrs['minimumAmount'] ),
1074        'has_max_amount'            => isset( $attrs['maximumAmount'] ),
1075        'has_custom_styles'         => has_custom_styles( $attrs ),
1076        'tabs_appearance'           => (string) $tabs_appearance,
1077    );
1078}
1079
1080/**
1081 * Detect whether the block carries any of the customizable style attributes
1082 * we ship for the RSM project (so we can measure adoption of the new options).
1083 *
1084 * @since 15.9
1085 *
1086 * @param array $attrs Block attributes.
1087 * @return bool Whether any style override is set.
1088 */
1089function has_custom_styles( $attrs ) {
1090    $style_keys = array(
1091        'tabFontSize',
1092        'tabPadding',
1093        'activeTabBackgroundColor',
1094        'activeTabTextColor',
1095        'buttonPadding',
1096    );
1097    foreach ( $style_keys as $key ) {
1098        if ( isset( $attrs[ $key ] ) && '' !== $attrs[ $key ] && array() !== $attrs[ $key ] ) {
1099            return true;
1100        }
1101    }
1102    return false;
1103}
1104
1105/**
1106 * Send a Donations Tracks event using whichever pipeline is available for the
1107 * current environment (WPCOM Simple sites vs. WoA / connected Jetpack sites).
1108 *
1109 * Mirrors the dual-path pattern used by the Map block. No-op on environments
1110 * that have neither path available, so this stays safe on unconnected sites.
1111 *
1112 * @since 15.9
1113 *
1114 * @param string $event_name Event name WITHOUT the `jetpack_` prefix (the Tracking class adds it).
1115 * @param array  $props      Tracks event properties.
1116 * @return void
1117 */
1118function record_published_event( $event_name, $props ) {
1119    if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1120        require_lib( 'tracks/client' );
1121        tracks_record_event( wp_get_current_user(), 'jetpack_' . $event_name, $props );
1122        return;
1123    }
1124    if ( ( new Host() )->is_woa_site() && Jetpack::is_connection_ready() ) {
1125        $tracking = new Tracking();
1126        $tracking->record_user_event( $event_name, $props );
1127    }
1128}