PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 All 504 releases
← All changes | extensions/blocks/donations/donations.php +919 -109 12.5.2 → 16.3-a.1 View file →
@@ -9,12 +9,18 @@
9 9
10 10 namespace Automattic\Jetpack\Extensions\Donations;
11 11
12 12 use Automattic\Jetpack\Blocks;
13 +use Automattic\Jetpack\Status\Host;
14 +use Automattic\Jetpack\Status\Request;
15 +use Automattic\Jetpack\Tracking;
16 +use Jetpack;
13 17 use Jetpack_Gutenberg;
18 +use WP_Post;
14 19
15 -const FEATURE_NAME = 'donations';
16 -const BLOCK_NAME = 'jetpack/' . FEATURE_NAME;
20 +if ( ! defined( 'ABSPATH' ) ) {
21 + exit( 0 );
22 +}
17 23
18 24 /**
19 25 * Registers the block for use in Gutenberg
20 26 * This is done via an action so that we can disable
@@ -20,66 +26,29 @@
20 26 * This is done via an action so that we can disable
21 27 * registration if we need to.
22 28 */
23 29 function register_block() {
24 - Blocks::jetpack_register_block(
25 - BLOCK_NAME,
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 + // Add a meta field to the user to track if the donation warning has been dismissed.
43 + \register_meta(
44 + 'user',
45 + 'jetpack_donation_warning_dismissed',
26 46 array(
27 - 'render_callback' => __NAMESPACE__ . '\render_block',
28 - 'attributes' => array(
29 - 'currency' => array(
30 - 'type' => 'string',
31 - 'default' => '',
32 - ),
33 - 'oneTimeDonation' => array(
34 - 'type' => 'object',
35 - 'default' => array(
36 - 'show' => true,
37 - 'planId' => null,
38 - 'amounts' => array( 5, 15, 100 ),
39 - 'heading' => __( 'Make a one-time donation', 'jetpack' ),
40 - 'extraText' => __( 'Your contribution is appreciated.', 'jetpack' ),
41 - 'buttonText' => __( 'Donate', 'jetpack' ),
42 - ),
43 - ),
44 - 'monthlyDonation' => array(
45 - 'type' => 'object',
46 - 'default' => array(
47 - 'show' => true,
48 - 'planId' => null,
49 - 'amounts' => array( 5, 15, 100 ),
50 - 'heading' => __( 'Make a monthly donation', 'jetpack' ),
51 - 'extraText' => __( 'Your contribution is appreciated.', 'jetpack' ),
52 - 'buttonText' => __( 'Donate monthly', 'jetpack' ),
53 - ),
54 - ),
55 - 'annualDonation' => array(
56 - 'type' => 'object',
57 - 'default' => array(
58 - 'show' => true,
59 - 'planId' => null,
60 - 'amounts' => array( 5, 15, 100 ),
61 - 'heading' => __( 'Make a yearly donation', 'jetpack' ),
62 - 'extraText' => __( 'Your contribution is appreciated.', 'jetpack' ),
63 - 'buttonText' => __( 'Donate yearly', 'jetpack' ),
64 - ),
65 - ),
66 - 'showCustomAmount' => array(
67 - 'type' => 'boolean',
68 - 'default' => true,
69 - ),
70 - 'chooseAmountText' => array(
71 - 'type' => 'string',
72 - 'default' => __( 'Choose an amount', 'jetpack' ),
73 - ),
74 - 'customAmountText' => array(
75 - 'type' => 'string',
76 - 'default' => __( 'Or enter a custom amount', 'jetpack' ),
77 - ),
78 - 'fallbackLinkUrl' => array(
79 - 'type' => 'string',
80 - ),
81 - ),
47 + 'type' => 'boolean',
48 + 'single' => true,
49 + 'show_in_rest' => true,
50 + 'default' => false,
82 51 )
83 52 );
84 53 }
85 54 add_action( 'init', __NAMESPACE__ . '\register_block' );
@@ -93,9 +62,21 @@
93 62 * @return string
94 63 */
95 64 function render_block( $attr, $content ) {
96 65 // Keep content as-is if rendered in other contexts than frontend (i.e. feed, emails, API, etc.).
97 - if ( ! jetpack_is_frontend() ) {
66 + if ( ! Request::is_frontend() ) {
67 + $parsed = parse_blocks( $content );
68 + if ( ! empty( $parsed[0] ) ) {
69 + // Inject the link of the current post from the server side as the fallback link to make sure the donations block
70 + // points to the correct post when it's inserted from the synced pattern (aka “My Pattern”).
71 + $post_link = get_permalink();
72 + $parsed[0]['attrs']['fallbackLinkUrl'] = $post_link;
73 + $content = \render_block( $parsed[0] );
74 + if ( preg_match( '/<a\s+class="jetpack-donations-fallback-link"\s+href="([^"]*)"/', $content, $matches ) ) {
75 + $content = str_replace( $matches[1], $post_link, $content );
76 + }
77 + }
78 +
98 79 return $content;
99 80 }
100 81
101 82 require_once JETPACK__PLUGIN_DIR . 'modules/memberships/class-jetpack-memberships.php';
@@ -104,27 +85,42 @@
104 85 if ( ! \Jetpack_Memberships::has_connected_account() ) {
105 86 return '';
106 87 }
107 88
108 - Jetpack_Gutenberg::load_assets_as_required( FEATURE_NAME, array( 'thickbox' ) );
109 - add_thickbox();
89 + Jetpack_Gutenberg::load_assets_as_required( __DIR__ );
110 90
111 91 require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-currencies.php';
112 92
113 - $donations = array(
114 - 'one-time' => array_merge(
93 + $default_texts = get_default_texts();
94 +
95 + // `array_merge` lets the user-supplied attributes override only the keys
96 + // they actually set. Undefined keys (new blocks that have never been
97 + // edited) fall back to the defaults in the first array. User-cleared
98 + // keys (empty strings explicitly saved) win over defaults, so
99 + // "blank stays blank" and "never set" gets the default.
100 + // Treat `show !== false` as on, so legacy blocks (where `show` was never
101 + // set on oneTimeDonation) still render the one-time interval by default.
102 + $donations = array();
103 + if ( false !== ( $attr['oneTimeDonation']['show'] ?? true ) ) {
104 + $donations['one-time'] = array_merge(
115 105 array(
116 - 'title' => __( 'One-Time', 'jetpack' ),
117 - 'class' => 'donations__one-time-item',
106 + 'planId' => null,
107 + 'title' => __( 'One-Time', 'jetpack' ),
108 + 'class' => 'donations__one-time-item',
109 + 'heading' => $default_texts['oneTimeDonation']['heading'],
110 + 'buttonText' => $default_texts['oneTimeDonation']['buttonText'],
118 111 ),
119 112 $attr['oneTimeDonation']
120 - ),
121 - );
113 + );
114 + }
122 115 if ( $attr['monthlyDonation']['show'] ) {
123 116 $donations['1 month'] = array_merge(
124 117 array(
125 - 'title' => __( 'Monthly', 'jetpack' ),
126 - 'class' => 'donations__monthly-item',
118 + 'planId' => null,
119 + 'title' => __( 'Monthly', 'jetpack' ),
120 + 'class' => 'donations__monthly-item',
121 + 'heading' => $default_texts['monthlyDonation']['heading'],
122 + 'buttonText' => $default_texts['monthlyDonation']['buttonText'],
127 123 ),
128 124 $attr['monthlyDonation']
129 125 );
130 126 }
@@ -130,16 +126,47 @@
130 126 }
131 127 if ( $attr['annualDonation']['show'] ) {
132 128 $donations['1 year'] = array_merge(
133 129 array(
134 - 'title' => __( 'Yearly', 'jetpack' ),
135 - 'class' => 'donations__annual-item',
130 + 'planId' => null,
131 + 'title' => __( 'Yearly', 'jetpack' ),
132 + 'class' => 'donations__annual-item',
133 + 'heading' => $default_texts['annualDonation']['heading'],
134 + 'buttonText' => $default_texts['annualDonation']['buttonText'],
136 135 ),
137 136 $attr['annualDonation']
138 137 );
139 138 }
140 139
141 - $currency = $attr['currency'];
140 + $choose_amount_text = $attr['chooseAmountText'] ?? $default_texts['chooseAmountText'];
141 + $custom_amount_text = $attr['customAmountText'] ?? $default_texts['customAmountText'];
142 + $currency = $attr['currency'];
143 +
144 + // Drop intervals whose plan no longer resolves so we can compute the active tab
145 + // against the actually-rendered set, not the configured set.
146 + $valid_donations = array();
147 + foreach ( $donations as $interval => $donation ) {
148 + $plan = get_post( (int) $donation['planId'] );
149 + if ( $plan && ! is_wp_error( $plan ) ) {
150 + $valid_donations[ $interval ] = $donation;
151 + }
152 + }
153 + $donations = $valid_donations;
154 + $rendered_intervals = array_keys( $donations );
155 +
156 + // Effective default = configured defaultInterval if it survived plan validation,
157 + // otherwise the first rendered interval (one-time → monthly → annual).
158 + $default_interval = $attr['defaultInterval'] ?? null;
159 + if ( ! in_array( $default_interval, $rendered_intervals, true ) ) {
160 + $default_interval = $rendered_intervals[0] ?? null;
161 + }
162 + $tab_content_class_map = array(
163 + 'one-time' => 'is-one-time',
164 + '1 month' => 'is-monthly',
165 + '1 year' => 'is-annual',
166 + );
167 + $tab_content_class = $default_interval ? $tab_content_class_map[ $default_interval ] : '';
168 +
142 169 $nav = '';
143 170 $headings = '';
144 171 $amounts = '';
145 172 $extra_text = '';
@@ -145,32 +172,38 @@
145 172 $extra_text = '';
146 173 $buttons = '';
147 174 foreach ( $donations as $interval => $donation ) {
148 175 $plan_id = (int) $donation['planId'];
149 - $plan = get_post( $plan_id );
150 - if ( ! $plan || is_wp_error( $plan ) ) {
151 - continue;
152 - }
153 176
154 177 if ( count( $donations ) > 1 ) {
155 178 if ( ! $nav ) {
156 179 $nav .= '<div class="donations__nav">';
157 180 }
158 - $nav .= sprintf(
159 - '<div role="button" tabindex="0" class="donations__nav-item" data-interval="%1$s">%2$s</div>',
181 + $is_active_class = $interval === $default_interval ? ' is-active' : '';
182 + $nav .= sprintf(
183 + '<div role="button" tabindex="0" class="donations__nav-item%3$s" data-interval="%1$s">%2$s</div>',
160 184 esc_attr( $interval ),
161 - esc_html( $donation['title'] )
185 + esc_html( $donation['title'] ),
186 + esc_attr( $is_active_class )
162 187 );
163 188 }
164 - $headings .= sprintf(
165 - '<h4 class="%1$s">%2$s</h4>',
189 + $heading_text = wp_kses_post( $donation['heading'] ?? '' );
190 + if ( '' !== trim( $heading_text ) ) {
191 + $headings .= sprintf(
192 + '<h4 class="%1$s">%2$s</h4>',
193 + esc_attr( $donation['class'] ),
194 + $heading_text
195 + );
196 + }
197 + $default_index_attr = '';
198 + if ( isset( $donation['defaultAmountIndex'] ) && is_numeric( $donation['defaultAmountIndex'] ) ) {
199 + $default_index_attr = sprintf( ' data-default-index="%d"', (int) $donation['defaultAmountIndex'] );
200 + }
201 + $amounts .= sprintf(
202 + '<div class="donations__amounts %s"%s>',
166 203 esc_attr( $donation['class'] ),
167 - wp_kses_post( $donation['heading'] )
204 + $default_index_attr
168 205 );
169 - $amounts .= sprintf(
170 - '<div class="donations__amounts %s">',
171 - esc_attr( $donation['class'] )
172 - );
173 206 foreach ( $donation['amounts'] as $amount ) {
174 207 $amounts .= sprintf(
175 208 '<div class="donations__amount" data-amount="%1$s">%2$s</div>',
176 209 esc_attr( $amount ),
@@ -176,17 +209,20 @@
176 209 esc_attr( $amount ),
177 210 esc_html( \Jetpack_Currencies::format_price( $amount, $currency ) )
178 211 );
179 212 }
180 - $amounts .= '</div>';
181 - $extra_text .= sprintf(
182 - '<p class="%1$s">%2$s</p>',
213 + $amounts .= '</div>';
214 + $extra_text_html = wp_kses_post( $donation['extraText'] ?? $default_texts['extraText'] );
215 + if ( '' !== trim( $extra_text_html ) ) {
216 + $extra_text .= sprintf(
217 + '<p class="%1$s">%2$s</p>',
218 + esc_attr( $donation['class'] ),
219 + $extra_text_html
220 + );
221 + }
222 + $buttons .= sprintf(
223 + '<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>',
183 224 esc_attr( $donation['class'] ),
184 - wp_kses_post( $donation['extraText'] )
185 - );
186 - $buttons .= sprintf(
187 - '<a class="wp-block-button__link donations__donate-button %1$s" href="%2$s">%3$s</a>',
188 - esc_attr( $donation['class'] ),
189 225 esc_url( \Jetpack_Memberships::get_instance()->get_subscription_url( $plan_id ) ),
190 226 wp_kses_post( $donation['buttonText'] )
191 227 );
192 228 }
@@ -195,35 +231,122 @@
195 231 }
196 232
197 233 $custom_amount = '';
198 234 if ( $attr['showCustomAmount'] ) {
235 + $custom_amount_html = wp_kses_post( $custom_amount_text );
236 + if ( '' !== trim( $custom_amount_html ) ) {
237 + $custom_amount .= sprintf( '<p>%s</p>', $custom_amount_html );
238 + }
239 + $default_custom_amount = $attr['customAmountPlaceholder']
240 + ?? ( \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1 ) * 100;
199 241 $custom_amount .= sprintf(
200 - '<p>%s</p>',
201 - wp_kses_post( $attr['customAmountText'] )
202 - );
203 - $default_custom_amount = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] * 100;
204 - $custom_amount .= sprintf(
205 242 '<div class="donations__amount donations__custom-amount">
206 243 %1$s
207 244 <div class="donations__amount-value" data-currency="%2$s" data-empty-text="%3$s"></div>
208 245 </div>',
209 - esc_html( \Jetpack_Currencies::CURRENCIES[ $attr['currency'] ]['symbol'] ),
210 - esc_attr( $attr['currency'] ),
246 + esc_html( \Jetpack_Currencies::CURRENCIES[ $currency ]['symbol'] ?? '¤' ),
247 + esc_attr( $currency ),
211 248 esc_attr( \Jetpack_Currencies::format_price( $default_custom_amount, $currency, false ) )
212 249 );
213 250 }
214 251
252 + $display_mode = $attr['displayMode'] ?? 'inline';
253 + $instance_id = wp_unique_id( 'jp-donations-' );
254 + $instance_classes = $instance_id;
255 + if ( isset( $attr['tabsAppearance'] ) && 'buttons' === $attr['tabsAppearance'] ) {
256 + $instance_classes .= ' is-style-buttons';
257 + }
258 + if ( 'modal' === $display_mode ) {
259 + $instance_classes .= ' is-display-modal';
260 + if ( ! empty( $attr['triggerSticky'] ) ) {
261 + $instance_classes .= ' is-sticky';
262 + }
263 + }
264 + $wrapper_attr_array = array( 'class' => $instance_classes );
265 + if ( $default_interval ) {
266 + $wrapper_attr_array['data-default-interval'] = $default_interval;
267 + }
268 + $wrapper_attr_array = array_merge( $wrapper_attr_array, build_security_data_attrs( $attr, $currency ) );
269 + $wrapper_attrs = get_block_wrapper_attributes( $wrapper_attr_array );
270 + $custom_styles = build_custom_styles( $attr, '.' . $instance_id );
271 +
272 + $choose_amount_html = wp_kses_post( $choose_amount_text );
273 + $choose_amount_block = '' !== trim( $choose_amount_html ) ? '<p>' . $choose_amount_html . '</p>' : '';
274 +
275 + if ( 'modal' === $display_mode ) {
276 + $trigger_text = $attr['triggerButtonText'] ?? $default_texts['triggerButtonText'];
277 + $trigger_icon_key = $attr['triggerIcon'] ?? 'heart';
278 + $trigger_svg = get_trigger_icon_svg( $trigger_icon_key );
279 + $modal_id = $instance_id . '-modal';
280 +
281 + return sprintf(
282 + '
283 +<div %1$s>%9$s
284 + <button
285 + class="donations__trigger-button wp-block-button__link wp-element-button"
286 + aria-haspopup="dialog"
287 + aria-controls="%11$s"
288 + >%12$s%10$s</button>
289 + <div
290 + id="%11$s"
291 + class="donations__modal-overlay"
292 + role="dialog"
293 + aria-modal="true"
294 + aria-label="%10$s"
295 + hidden
296 + >
297 + <div class="donations__modal-dialog">
298 + <button class="donations__modal-close" aria-label="%13$s">&#x2715;</button>
299 + <div class="donations__modal-content">
300 + <div class="donations__container">
301 + %2$s
302 + <div class="donations__content">
303 + <div class="donations__tab %14$s">
304 + %3$s
305 + %4$s
306 + %5$s
307 + %6$s
308 + <div class="donations__range-error"></div>
309 + <hr class="donations__separator">
310 + %7$s
311 + %8$s
312 + </div>
313 + </div>
314 + </div>
315 + </div>
316 + </div>
317 + </div>
318 +</div>
319 +',
320 + $wrapper_attrs,
321 + $nav,
322 + $headings,
323 + $choose_amount_block,
324 + $amounts,
325 + $custom_amount,
326 + $extra_text,
327 + $buttons,
328 + $custom_styles ? '<style>' . $custom_styles . '</style>' : '',
329 + esc_html( $trigger_text ),
330 + esc_attr( $modal_id ),
331 + $trigger_svg,
332 + esc_attr__( 'Close', 'jetpack' ),
333 + esc_attr( $tab_content_class )
334 + );
335 + }
336 +
215 337 return sprintf(
216 338 '
217 -<div class="%1$s">
339 +<div %1$s>%9$s
218 340 <div class="donations__container">
219 341 %2$s
220 342 <div class="donations__content">
221 - <div class="donations__tab">
343 + <div class="donations__tab %10$s">
222 344 %3$s
223 - <p>%4$s</p>
345 + %4$s
224 346 %5$s
225 347 %6$s
348 + <div class="donations__range-error"></div>
226 349 <hr class="donations__separator">
227 350 %7$s
228 351 %8$s
229 352 </div>
@@ -230,20 +353,535 @@
230 353 </div>
231 354 </div>
232 355 </div>
233 356 ',
234 - esc_attr( Blocks::classes( FEATURE_NAME, $attr ) ),
357 + $wrapper_attrs,
235 358 $nav,
236 359 $headings,
237 - $attr['chooseAmountText'],
360 + $choose_amount_block,
238 361 $amounts,
239 362 $custom_amount,
240 363 $extra_text,
241 - $buttons
364 + $buttons,
365 + $custom_styles ? '<style>' . $custom_styles . '</style>' : '',
366 + esc_attr( $tab_content_class )
242 367 );
243 368 }
244 369
245 370 /**
371 + * WooCommerce Email Editor render callback for the Donations block.
372 + *
373 + * Builds a dedicated email version of the block from its attributes (rather than
374 + * post-processing the interactive frontend markup) so customizations — per
375 + * interval heading, supporting text and button label — are preserved. Each
376 + * interval is rendered as an email-safe table section with a CTA button drawn by
377 + * the Button block's own email renderer.
378 + *
379 + * @param string $block_content The rendered block content (unused; we rebuild from attributes).
380 + * @param array $parsed_block The parsed block data.
381 + * @param object $rendering_context The email rendering context.
382 + *
383 + * @return string
384 + */
385 +function render_email( $block_content, array $parsed_block, $rendering_context ) {
386 + if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] )
387 + || ! function_exists( '\Automattic\Jetpack\Extensions\Button\render_email' )
388 + || ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
389 + return '';
390 + }
391 +
392 + $attr = $parsed_block['attrs'];
393 + $default_texts = get_default_texts();
394 +
395 + $url = get_permalink();
396 + if ( ! is_string( $url ) || '' === $url ) {
397 + $url = is_string( $attr['fallbackLinkUrl'] ?? null ) && '' !== $attr['fallbackLinkUrl'] ? $attr['fallbackLinkUrl'] : '#';
398 + }
399 +
400 + $alignment = in_array( $attr['contentAlignment'] ?? '', array( 'left', 'center', 'right' ), true ) ? $attr['contentAlignment'] : 'left';
401 + $table_style = sprintf( 'width:100%%;max-width:%dpx;border-collapse:collapse;', get_email_target_width( $rendering_context ) );
402 +
403 + $sections = '';
404 + foreach ( array(
405 + 'oneTimeDonation' => true,
406 + 'monthlyDonation' => false,
407 + 'annualDonation' => false,
408 + ) as $key => $default_show ) {
409 + $interval = is_array( $attr[ $key ] ?? null ) ? $attr[ $key ] : array();
410 + if ( false === ( $interval['show'] ?? $default_show ) ) {
411 + continue;
412 + }
413 +
414 + $heading = wp_kses_post( $interval['heading'] ?? $default_texts[ $key ]['heading'] );
415 + $extra = wp_kses_post( $interval['extraText'] ?? $default_texts['extraText'] );
416 +
417 + $content = '';
418 + if ( '' !== trim( wp_strip_all_tags( $heading ) ) ) {
419 + $content .= sprintf(
420 + '<p style="margin:0 0 4px;font-size:18px;font-weight:700;line-height:1.3;text-align:%s;">%s</p>',
421 + esc_attr( $alignment ),
422 + $heading
423 + );
424 + }
425 + if ( '' !== trim( wp_strip_all_tags( $extra ) ) ) {
426 + $content .= sprintf(
427 + '<p style="margin:0 0 14px;font-size:15px;line-height:1.5;text-align:%s;">%s</p>',
428 + esc_attr( $alignment ),
429 + $extra
430 + );
431 + }
432 + $content .= render_email_donate_button( $interval['buttonText'] ?? $default_texts[ $key ]['buttonText'], $url, $attr, $rendering_context );
433 +
434 + $is_first = '' === $sections;
435 + $cell_style = sprintf(
436 + 'text-align:%1$s;padding:%2$s;%3$s',
437 + esc_attr( $alignment ),
438 + $is_first ? '0 0 24px' : '24px 0',
439 + $is_first ? '' : 'border-top:1px solid #e0e0e0;'
440 + );
441 + $sections .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
442 + $content,
443 + array( 'style' => $table_style ),
444 + array( 'style' => $cell_style )
445 + );
446 + }
447 +
448 + return $sections;
449 +}
450 +
451 +/**
452 + * Render a single donation CTA as an email-safe button via the Button block's
453 + * email renderer, carrying over the donation block's button styling.
454 + *
455 + * @param string $text Button label.
456 + * @param string $url Button destination.
457 + * @param array $attr Donations block attributes.
458 + * @param object $rendering_context The email rendering context.
459 + * @return string
460 + */
461 +function render_email_donate_button( $text, $url, $attr, $rendering_context ) {
462 + $text = trim( wp_strip_all_tags( (string) $text ) );
463 +
464 + $button_attrs = array(
465 + 'text' => '' !== $text ? $text : __( 'Donate', 'jetpack' ),
466 + 'url' => $url,
467 + );
468 +
469 + if ( ! empty( $attr['buttonFontSize'] ) && is_string( $attr['buttonFontSize'] ) ) {
470 + $button_attrs['customFontSize'] = $attr['buttonFontSize'];
471 + }
472 + if ( isset( $attr['buttonBorderRadius'] ) && is_string( $attr['buttonBorderRadius'] ) ) {
473 + $radius = (int) $attr['buttonBorderRadius'];
474 + if ( $radius > 0 ) {
475 + $button_attrs['borderRadius'] = $radius;
476 + }
477 + }
478 +
479 + return \Automattic\Jetpack\Extensions\Button\render_email(
480 + '',
481 + array( 'attrs' => $button_attrs ),
482 + $rendering_context
483 + );
484 +}
485 +
486 +/**
487 + * Resolve the target content width (in px) from the email rendering context,
488 + * falling back to a sensible default.
489 + *
490 + * @param object $rendering_context The email rendering context.
491 + * @return int Width in pixels.
492 + */
493 +function get_email_target_width( $rendering_context ) {
494 + $target_width = 600;
495 + if ( is_object( $rendering_context ) && method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
496 + $width = $rendering_context->get_layout_width_without_padding();
497 + if ( is_string( $width ) && preg_match( '/(\d+)px/', $width, $matches ) ) {
498 + $parsed = (int) $matches[1];
499 + if ( $parsed > 0 ) {
500 + $target_width = $parsed;
501 + }
502 + }
503 + }
504 + return $target_width;
505 +}
506 +
507 +/**
508 + * Build data-attributes array for security (min/max amount) constraints.
509 + *
510 + * Extracted so it can be tested independently of the full render pipeline.
511 + *
512 + * @since 15.9
513 + *
514 + * @param array $attr Block attributes.
515 + * @param string $currency Currency code (e.g. 'USD').
516 + * @return array Associative array of data-attribute name => value.
517 + */
518 +function build_security_data_attrs( $attr, $currency ) {
519 + $attrs = array();
520 + $min_amount = isset( $attr['minimumAmount'] ) ? (float) $attr['minimumAmount'] : null;
521 + $max_amount = isset( $attr['maximumAmount'] ) ? (float) $attr['maximumAmount'] : null;
522 + if ( null !== $min_amount ) {
523 + $attrs['data-min-amount'] = $min_amount;
524 + $attrs['data-min-error'] = sprintf(
525 + /* translators: %s: minimum donation amount formatted with currency symbol */
526 + __( 'The minimum donation amount is %s.', 'jetpack' ),
527 + \Jetpack_Currencies::format_price( (string) $min_amount, $currency )
528 + );
529 + }
530 + if ( null !== $max_amount ) {
531 + $attrs['data-max-amount'] = $max_amount;
532 + $attrs['data-max-error'] = sprintf(
533 + /* translators: %s: maximum donation amount formatted with currency symbol */
534 + __( 'The maximum donation amount is %s.', 'jetpack' ),
535 + \Jetpack_Currencies::format_price( (string) $max_amount, $currency )
536 + );
537 + }
538 + $stripe_min = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1;
539 + $attrs['data-stripe-min-error'] = sprintf(
540 + /* translators: %s: payment processor minimum donation amount formatted with currency symbol */
541 + _x( 'The minimum donation amount is %s.', 'payment processor minimum', 'jetpack' ),
542 + \Jetpack_Currencies::format_price( (string) $stripe_min, $currency )
543 + );
544 + return $attrs;
545 +}
546 +
547 +/**
548 + * Build a CSS string scoping per-state and tab-level style rules to a single
549 + * block instance.
550 + *
551 + * @param array $attr Block attributes.
552 + * @param string $scope CSS class selector (with leading dot) unique to this instance.
553 + * @return string CSS rules joined into one string, or '' when no overrides are set.
554 + */
555 +function build_custom_styles( $attr, $scope ) {
556 + $tab_padding = isset( $attr['tabPadding'] ) && is_array( $attr['tabPadding'] ) ? $attr['tabPadding'] : array();
557 + $button_padding = isset( $attr['buttonPadding'] ) && is_array( $attr['buttonPadding'] ) ? $attr['buttonPadding'] : array();
558 +
559 + $groups = array(
560 + array(
561 + 'selector' => $scope . ' .donations__nav-item',
562 + 'properties' => array(
563 + 'font-size' => $attr['tabFontSize'] ?? '',
564 + 'padding-top' => $tab_padding['top'] ?? '',
565 + 'padding-right' => $tab_padding['right'] ?? '',
566 + 'padding-bottom' => $tab_padding['bottom'] ?? '',
567 + 'padding-left' => $tab_padding['left'] ?? '',
568 + ),
569 + ),
570 + array(
571 + 'selector' => $scope . ' .donations__nav-item.is-active',
572 + 'properties' => array(
573 + 'background' => $attr['activeTabBackgroundColor'] ?? '',
574 + 'color' => $attr['activeTabTextColor'] ?? '',
575 + ),
576 + ),
577 + array(
578 + 'selector' => $scope . ' .donations__nav-item:not(.is-active)',
579 + 'properties' => array(
580 + 'background' => $attr['inactiveTabBackgroundColor'] ?? '',
581 + 'color' => $attr['inactiveTabTextColor'] ?? '',
582 + ),
583 + ),
584 + array(
585 + 'selector' => $scope . ' .donations__amount.is-selected',
586 + 'properties' => array(
587 + 'background-color' => $attr['selectedAmountBackgroundColor'] ?? '',
588 + 'color' => $attr['selectedAmountTextColor'] ?? '',
589 + ),
590 + ),
591 + array(
592 + 'selector' => $scope . ' .donations__amount.is-selected',
593 + 'properties' => array(
594 + // Override only the outer ring color; the inner 1px white separator stays put.
595 + 'box-shadow' => isset( $attr['selectedAmountOutlineColor'] ) && '' !== $attr['selectedAmountOutlineColor']
596 + ? '0 0 0 1px #fff,0 0 0 3px ' . $attr['selectedAmountOutlineColor']
597 + : '',
598 + ),
599 + ),
600 + array(
601 + 'selector' => $scope . ' .donations__donate-button',
602 + 'properties' => array(
603 + 'font-size' => $attr['buttonFontSize'] ?? '',
604 + 'padding-top' => $button_padding['top'] ?? '',
605 + 'padding-right' => $button_padding['right'] ?? '',
606 + 'padding-bottom' => $button_padding['bottom'] ?? '',
607 + 'padding-left' => $button_padding['left'] ?? '',
608 + ),
609 + ),
610 + );
611 +
612 + $rules = array();
613 +
614 + $content_alignment = $attr['contentAlignment'] ?? '';
615 + if ( in_array( $content_alignment, array( 'left', 'center', 'right' ), true ) ) {
616 + $rules[] = $scope . ' .donations__content{text-align:' . $content_alignment . '}';
617 + $justify_map = array(
618 + 'left' => 'flex-start',
619 + 'center' => 'center',
620 + 'right' => 'flex-end',
621 + );
622 + $rules[] = $scope . ' .donations__amounts{justify-content:' . $justify_map[ $content_alignment ] . '}';
623 + }
624 +
625 + foreach ( $groups as $group ) {
626 + $decls = array();
627 + foreach ( $group['properties'] as $property => $value ) {
628 + $safe = sanitize_css_value( $value );
629 + if ( '' !== $safe ) {
630 + $decls[] = $property . ':' . $safe;
631 + }
632 + }
633 + if ( $decls ) {
634 + $rules[] = $group['selector'] . '{' . implode( ';', $decls ) . '}';
635 + }
636 + }
637 +
638 + // User-set tab border color: applies to the default-style nav bottom
639 + // divider, the per-tab dividers, and the buttons-style pill borders.
640 + $tab_border_safe = sanitize_css_value( $attr['tabBorderColor'] ?? '' );
641 + if ( '' !== $tab_border_safe ) {
642 + $rules[] = $scope . ' .donations__nav,' . $scope . ' .donations__nav-item,' . $scope . ' .donations__nav-item.is-active{border-color:' . $tab_border_safe . '}';
643 + }
644 +
645 + $button_radius_decls = build_radius_decls( $attr['buttonBorderRadius'] ?? null );
646 + if ( $button_radius_decls ) {
647 + $rules[] = $scope . ' .donations__donate-button{' . implode( ';', $button_radius_decls ) . '}';
648 + }
649 +
650 + // User-set amount tile font size, border (BorderBoxControl shape) and
651 + // border radius (BorderRadiusControl shape). Applies to all amount tiles
652 + // (preset + custom); selected-state colors above only kick in when an
653 + // amount has the is-selected class.
654 + $amount_decls = array();
655 + $amount_font = sanitize_css_value( $attr['amountFontSize'] ?? '' );
656 + if ( '' !== $amount_font ) {
657 + $amount_decls[] = 'font-size:' . $amount_font;
658 + }
659 + $amount_decls = array_merge( $amount_decls, build_border_decls( $attr['amountBorder'] ?? null ) );
660 + $amount_decls = array_merge( $amount_decls, build_radius_decls( $attr['amountBorderRadius'] ?? null ) );
661 + if ( $amount_decls ) {
662 + $rules[] = $scope . ' .donations__amount{' . implode( ';', $amount_decls ) . '}';
663 + }
664 +
665 + $button_alignment = $attr['buttonAlignment'] ?? '';
666 + if ( in_array( $button_alignment, array( 'left', 'center', 'right' ), true ) ) {
667 + $rules[] = $scope . ' .donations__donate-button-wrapper{text-align:' . $button_alignment . '}';
668 + } elseif ( 'full' === $button_alignment ) {
669 + $rules[] = $scope . ' .donations__donate-button-wrapper{display:block;width:100%}'
670 + . $scope . ' .donations__donate-button{display:block;width:100%;box-sizing:border-box;text-align:center}';
671 + }
672 +
673 + // Pop-up display mode: wire up trigger-button alignment via text-align on
674 + // the wrapper so the inline-flex button responds to the alignment toolbar.
675 + if ( 'modal' === ( $attr['displayMode'] ?? 'inline' ) ) {
676 + if ( in_array( $content_alignment, array( 'left', 'center', 'right' ), true ) ) {
677 + $rules[] = $scope . '{text-align:' . $content_alignment . '}';
678 + }
679 + }
680 +
681 + // Wrapper border (In-page mode only). Compound selector (.wp-block-jetpack-donations.jp-donations-N)
682 + // has specificity 0,2,0 and wins over the default single-class rule (0,1,0) in common.scss.
683 + if ( 'modal' !== ( $attr['displayMode'] ?? 'inline' ) ) {
684 + $wrapper_border_decls = array_merge(
685 + build_border_decls( $attr['blockBorder'] ?? null ),
686 + build_radius_decls( $attr['blockBorderRadius'] ?? null )
687 + );
688 + if ( $wrapper_border_decls ) {
689 + $rules[] = '.wp-block-jetpack-donations' . $scope . '{' . implode( ';', $wrapper_border_decls ) . '}';
690 + }
691 + }
692 +
693 + return implode( '', $rules );
694 +}
695 +
696 +/**
697 + * Convert a uniform-or-split BorderBoxControl value into individual CSS declarations.
698 + * Uniform shape: { color, style, width }. Split shape: { top: {...}, right: ..., etc. }.
699 + *
700 + * @param mixed $border BorderBoxControl value (or null).
701 + * @return array List of CSS declaration strings (e.g. "border-color:#abc"), already sanitized.
702 + */
703 +function build_border_decls( $border ) {
704 + if ( ! is_array( $border ) ) {
705 + return array();
706 + }
707 + $decls = array();
708 + $sides = array( 'top', 'right', 'bottom', 'left' );
709 + $is_split = false;
710 + foreach ( $sides as $side ) {
711 + if ( isset( $border[ $side ] ) ) {
712 + $is_split = true;
713 + break;
714 + }
715 + }
716 + if ( $is_split ) {
717 + foreach ( $sides as $side ) {
718 + $sb = $border[ $side ] ?? null;
719 + if ( ! is_array( $sb ) ) {
720 + continue;
721 + }
722 + foreach ( array( 'color', 'style', 'width' ) as $prop ) {
723 + $safe = sanitize_css_value( $sb[ $prop ] ?? '' );
724 + if ( '' !== $safe ) {
725 + $decls[] = 'border-' . $side . '-' . $prop . ':' . $safe;
726 + }
727 + }
728 + }
729 + } else {
730 + foreach ( array( 'color', 'style', 'width' ) as $prop ) {
731 + $safe = sanitize_css_value( $border[ $prop ] ?? '' );
732 + if ( '' !== $safe ) {
733 + $decls[] = 'border-' . $prop . ':' . $safe;
734 + }
735 + }
736 + }
737 + return $decls;
738 +}
739 +
740 +/**
741 + * Convert a uniform-or-per-corner BorderRadiusControl value into CSS declarations.
742 + * Uniform shape: a string like "8px". Per-corner shape:
743 + * { topLeft, topRight, bottomRight, bottomLeft } each with string values.
744 + *
745 + * @param mixed $radius BorderRadiusControl value (or null).
746 + * @return array List of CSS declaration strings.
747 + */
748 +function build_radius_decls( $radius ) {
749 + if ( is_string( $radius ) && '' !== $radius ) {
750 + $safe = sanitize_css_value( $radius );
751 + return '' !== $safe ? array( 'border-radius:' . $safe ) : array();
752 + }
753 + if ( ! is_array( $radius ) ) {
754 + return array();
755 + }
756 + $corners = array(
757 + 'topLeft' => 'border-top-left-radius',
758 + 'topRight' => 'border-top-right-radius',
759 + 'bottomRight' => 'border-bottom-right-radius',
760 + 'bottomLeft' => 'border-bottom-left-radius',
761 + );
762 + $decls = array();
763 + foreach ( $corners as $key => $css_prop ) {
764 + $safe = sanitize_css_value( $radius[ $key ] ?? '' );
765 + if ( '' !== $safe ) {
766 + $decls[] = $css_prop . ':' . $safe;
767 + }
768 + }
769 + return $decls;
770 +}
771 +
772 +/**
773 + * Sanitize a user-supplied CSS value (color, length, etc.) for safe inclusion
774 + * in a <style> element. Strips characters that could break out of the style
775 + * context (<, >, {, }, ;, quotes, backslash) and caps length, while leaving
776 + * valid hex / rgb() / hsl() / var() / named-color / px / rem / em values intact.
777 + *
778 + * @param mixed $value Raw attribute value.
779 + * @return string Sanitized value, or '' if rejected.
780 + */
781 +function sanitize_css_value( $value ) {
782 + if ( ! is_string( $value ) || '' === $value ) {
783 + return '';
784 + }
785 + $value = trim( $value );
786 + if ( strlen( $value ) > 100 ) {
787 + return '';
788 + }
789 + if ( preg_match( '/[<>{};\\\\\'"]/', $value ) ) {
790 + return '';
791 + }
792 + return $value;
793 +}
794 +
795 +/**
796 + * Get the default texts for the block.
797 + *
798 + * @return array
799 + */
800 +function get_default_texts() {
801 + return array(
802 + 'chooseAmountText' => __( 'Choose an amount', 'jetpack' ),
803 + 'customAmountText' => __( 'Or enter a custom amount', 'jetpack' ),
804 + 'extraText' => __( 'Your contribution is appreciated.', 'jetpack' ),
805 + 'triggerButtonText' => __( 'Donate', 'jetpack' ),
806 + 'oneTimeDonation' => array(
807 + 'heading' => __( 'Make a one-time donation', 'jetpack' ),
808 + 'buttonText' => __( 'Donate', 'jetpack' ),
809 + ),
810 + 'monthlyDonation' => array(
811 + 'heading' => __( 'Make a monthly donation', 'jetpack' ),
812 + 'buttonText' => __( 'Donate monthly', 'jetpack' ),
813 + ),
814 + 'annualDonation' => array(
815 + 'heading' => __( 'Make a yearly donation', 'jetpack' ),
816 + 'buttonText' => __( 'Donate yearly', 'jetpack' ),
817 + ),
818 + );
819 +}
820 +
821 +/**
822 + * Return inline SVG markup for a trigger button icon.
823 + *
824 + * Path data sourced from icons.js ICON_SVG_PATHS — keep in sync.
825 + *
826 + * @param string $icon_key Icon key (e.g. 'coffee', 'heart', 'gift').
827 + * @return string SVG HTML string, or '' when key is 'none' or unknown.
828 + */
829 +function get_trigger_icon_svg( $icon_key ) {
830 + $paths = array(
831 + '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' ),
832 + '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' ),
833 + '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' ),
834 + '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' ),
835 + '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' ),
836 + 'coffee' => array(
837 + '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',
838 + 'fill-rule' => 'evenodd',
839 + ),
840 + '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' ),
841 + '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' ),
842 + 'people' => array(
843 + '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',
844 + 'fill-rule' => 'evenodd',
845 + ),
846 + );
847 +
848 + if ( ! isset( $paths[ $icon_key ] ) || 'none' === $icon_key ) {
849 + return '';
850 + }
851 +
852 + $icon = $paths[ $icon_key ];
853 + $extra_attrs = isset( $icon['fill-rule'] ) ? ' fill-rule="' . esc_attr( $icon['fill-rule'] ) . '"' : '';
854 +
855 + return sprintf(
856 + '<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>',
857 + esc_attr( $icon['d'] ),
858 + $extra_attrs
859 + );
860 +}
861 +
862 +/**
863 + * Make default texts available to the editor.
864 + */
865 +function load_editor_scripts() {
866 + // Only relevant to the editor right now.
867 + if ( ! is_admin() ) {
868 + return;
869 + }
870 +
871 + $data = array(
872 + 'defaultTexts' => get_default_texts(),
873 + );
874 +
875 + wp_add_inline_script(
876 + 'jetpack-blocks-editor',
877 + 'var Jetpack_DonationsBlock = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
878 + 'before'
879 + );
880 +}
881 +add_action( 'enqueue_block_assets', __NAMESPACE__ . '\load_editor_scripts', 11 );
882 +
883 +/**
246 884 * Determine if AMP should be disabled on posts having Donations blocks.
247 885 *
248 886 * @param bool $skip Skipped.
249 887 * @param int $post_id Post ID.
@@ -253,10 +891,182 @@
253 891 */
254 892 function amp_skip_post( $skip, $post_id, $post ) {
255 893 // When AMP is on standard mode, there are no non-AMP posts to link to where the donation can be completed, so let's
256 894 // prevent the post from being available in AMP.
257 - if ( function_exists( 'amp_is_canonical' ) && \amp_is_canonical() && has_block( BLOCK_NAME, $post->post_content ) ) {
895 + if ( function_exists( 'amp_is_canonical' ) && \amp_is_canonical() && has_block( Blocks::get_block_name( __DIR__ ), $post->post_content ) ) {
258 896 return true;
259 897 }
260 898 return $skip;
261 899 }
262 900 add_filter( 'amp_skip_post', __NAMESPACE__ . '\amp_skip_post', 10, 3 );
901 +
902 +/**
903 + * Record a Tracks event when a post containing the Donations block transitions
904 + * to "publish" status. Fires once per donations block in the post, with the
905 + * block's configuration snapshot as event properties.
906 + *
907 + * Admin-driven event only (no donor data). Does not fire on regular updates of
908 + * an already-published post, on autosaves, or on revisions.
909 + *
910 + * @since 15.9
911 + *
912 + * @param string $new_status New post status.
913 + * @param string $old_status Previous post status.
914 + * @param WP_Post $post Post being transitioned.
915 + * @return void
916 + */
917 +function record_block_published_event( $new_status, $old_status, $post ) {
918 + if ( 'publish' !== $new_status || 'publish' === $old_status ) {
919 + return;
920 + }
921 + if ( ! $post instanceof WP_Post ) {
922 + return;
923 + }
924 + if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
925 + return;
926 + }
927 + $block_name = Blocks::get_block_name( __DIR__ );
928 + if ( ! has_block( $block_name, $post ) ) {
929 + return;
930 + }
931 +
932 + $donation_blocks = collect_donation_blocks( parse_blocks( $post->post_content ), $block_name );
933 + if ( empty( $donation_blocks ) ) {
934 + return;
935 + }
936 +
937 + require_once JETPACK__PLUGIN_DIR . 'modules/memberships/class-jetpack-memberships.php';
938 + $stripe_connected = \Jetpack_Memberships::has_connected_account();
939 + $block_count = count( $donation_blocks );
940 +
941 + foreach ( $donation_blocks as $index => $block ) {
942 + $props = build_block_published_event_props( $block, $post, $index, $block_count, $stripe_connected );
943 + record_published_event( 'donations_block_published', $props );
944 + }
945 +}
946 +add_action( 'transition_post_status', __NAMESPACE__ . '\record_block_published_event', 10, 3 );
947 +
948 +/**
949 + * Walk a parsed block tree and return a flat list of Donations blocks (including
950 + * nested ones inside columns / groups / patterns).
951 + *
952 + * @since 15.9
953 + *
954 + * @param array $blocks Parsed blocks from parse_blocks().
955 + * @param string $block_name Donations block name (e.g. 'jetpack/donations').
956 + * @return array List of parsed blocks matching $block_name.
957 + */
958 +function collect_donation_blocks( $blocks, $block_name ) {
959 + $matches = array();
960 + foreach ( $blocks as $block ) {
961 + if ( isset( $block['blockName'] ) && $block_name === $block['blockName'] ) {
962 + $matches[] = $block;
963 + }
964 + if ( ! empty( $block['innerBlocks'] ) ) {
965 + $matches = array_merge( $matches, collect_donation_blocks( $block['innerBlocks'], $block_name ) );
966 + }
967 + }
968 + return $matches;
969 +}
970 +
971 +/**
972 + * Build the property bag for a jetpack_donations_block_published Tracks event.
973 + *
974 + * Extracted so it can be tested independently of the WordPress hook plumbing.
975 + *
976 + * @since 15.9
977 + *
978 + * @param array $block Parsed block (with 'attrs' key).
979 + * @param WP_Post $post Post being published.
980 + * @param int $index 0-based position of this block within the post's donation blocks.
981 + * @param int $block_count Total count of donations blocks in the post.
982 + * @param bool $stripe_connected Whether the site currently has a connected Stripe account.
983 + * @return array Tracks event properties.
984 + */
985 +function build_block_published_event_props( $block, $post, $index, $block_count, $stripe_connected ) {
986 + $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
987 +
988 + $one_time_show = ! isset( $attrs['oneTimeDonation']['show'] ) || false !== $attrs['oneTimeDonation']['show'];
989 + $monthly_show = ! empty( $attrs['monthlyDonation']['show'] );
990 + $yearly_show = ! empty( $attrs['annualDonation']['show'] );
991 +
992 + $default_interval = $attrs['defaultInterval'] ?? 'one-time';
993 + $interval_to_freq = array(
994 + 'one-time' => 'one_time',
995 + '1 month' => 'monthly',
996 + '1 year' => 'yearly',
997 + );
998 + $default_frequency = $interval_to_freq[ $default_interval ] ?? $default_interval;
999 + $enabled_count = (int) $one_time_show + (int) $monthly_show + (int) $yearly_show;
1000 + $tabs_appearance = $attrs['tabsAppearance'] ?? 'tabs';
1001 +
1002 + return array(
1003 + 'feature' => 'donations',
1004 + 'surface' => 'server',
1005 + 'stripe_connected' => (bool) $stripe_connected,
1006 + 'post_id' => (int) $post->ID,
1007 + 'post_type' => (string) $post->post_type,
1008 + 'block_index_in_post' => (int) $index,
1009 + 'block_count_in_post' => (int) $block_count,
1010 + 'currency' => isset( $attrs['currency'] ) ? (string) $attrs['currency'] : null,
1011 + 'default_frequency' => $default_frequency,
1012 + 'enabled_frequencies_count' => $enabled_count,
1013 + 'show_one_time' => $one_time_show,
1014 + 'show_monthly' => $monthly_show,
1015 + 'show_yearly' => $yearly_show,
1016 + 'show_custom_amount' => ! empty( $attrs['showCustomAmount'] ),
1017 + 'has_min_amount' => isset( $attrs['minimumAmount'] ),
1018 + 'has_max_amount' => isset( $attrs['maximumAmount'] ),
1019 + 'has_custom_styles' => has_custom_styles( $attrs ),
1020 + 'tabs_appearance' => (string) $tabs_appearance,
1021 + );
1022 +}
1023 +
1024 +/**
1025 + * Detect whether the block carries any of the customizable style attributes
1026 + * we ship for the RSM project (so we can measure adoption of the new options).
1027 + *
1028 + * @since 15.9
1029 + *
1030 + * @param array $attrs Block attributes.
1031 + * @return bool Whether any style override is set.
1032 + */
1033 +function has_custom_styles( $attrs ) {
1034 + $style_keys = array(
1035 + 'tabFontSize',
1036 + 'tabPadding',
1037 + 'activeTabBackgroundColor',
1038 + 'activeTabTextColor',
1039 + 'buttonPadding',
1040 + );
1041 + foreach ( $style_keys as $key ) {
1042 + if ( isset( $attrs[ $key ] ) && '' !== $attrs[ $key ] && array() !== $attrs[ $key ] ) {
1043 + return true;
1044 + }
1045 + }
1046 + return false;
1047 +}
1048 +
1049 +/**
1050 + * Send a Donations Tracks event using whichever pipeline is available for the
1051 + * current environment (WPCOM Simple sites vs. WoA / connected Jetpack sites).
1052 + *
1053 + * Mirrors the dual-path pattern used by the Map block. No-op on environments
1054 + * that have neither path available, so this stays safe on unconnected sites.
1055 + *
1056 + * @since 15.9
1057 + *
1058 + * @param string $event_name Event name WITHOUT the `jetpack_` prefix (the Tracking class adds it).
1059 + * @param array $props Tracks event properties.
1060 + * @return void
1061 + */
1062 +function record_published_event( $event_name, $props ) {
1063 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1064 + require_lib( 'tracks/client' );
1065 + tracks_record_event( wp_get_current_user(), 'jetpack_' . $event_name, $props );
1066 + return;
1067 + }
1068 + if ( ( new Host() )->is_woa_site() && Jetpack::is_connection_ready() ) {
1069 + $tracking = new Tracking();
1070 + $tracking->record_user_event( $event_name, $props );
1071 + }
1072 +}