PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.0-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.0-beta
16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / extensions / blocks / donations / donations.php
jetpack / extensions / blocks / donations Last commit date
donations.php 1 week ago
donations.php
1070 lines
1 <?php
2 /**
3 * Donations Block.
4 *
5 * @since 8.x
6 *
7 * @package automattic/jetpack
8 */
9
10 namespace Automattic\Jetpack\Extensions\Donations;
11
12 use Automattic\Jetpack\Blocks;
13 use Automattic\Jetpack\Status\Host;
14 use Automattic\Jetpack\Status\Request;
15 use Automattic\Jetpack\Tracking;
16 use Jetpack;
17 use Jetpack_Gutenberg;
18 use WP_Post;
19
20 if ( ! 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 */
29 function 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 // 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',
46 array(
47 'type' => 'boolean',
48 'single' => true,
49 'show_in_rest' => true,
50 'default' => false,
51 )
52 );
53 }
54 add_action( 'init', __NAMESPACE__ . '\register_block' );
55
56 /**
57 * Donations block dynamic rendering.
58 *
59 * @param array $attr Array containing the Donations block attributes.
60 * @param string $content String containing the Donations block content.
61 *
62 * @return string
63 */
64 function render_block( $attr, $content ) {
65 // Keep content as-is if rendered in other contexts than frontend (i.e. feed, emails, API, etc.).
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
79 return $content;
80 }
81
82 require_once JETPACK__PLUGIN_DIR . 'modules/memberships/class-jetpack-memberships.php';
83
84 // If stripe isn't connected don't show anything to potential donors - they can't actually make a donation.
85 if ( ! \Jetpack_Memberships::has_connected_account() ) {
86 return '';
87 }
88
89 Jetpack_Gutenberg::load_assets_as_required( __DIR__ );
90
91 require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-currencies.php';
92
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(
105 array(
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'],
111 ),
112 $attr['oneTimeDonation']
113 );
114 }
115 if ( $attr['monthlyDonation']['show'] ) {
116 $donations['1 month'] = array_merge(
117 array(
118 'planId' => null,
119 'title' => __( 'Monthly', 'jetpack' ),
120 'class' => 'donations__monthly-item',
121 'heading' => $default_texts['monthlyDonation']['heading'],
122 'buttonText' => $default_texts['monthlyDonation']['buttonText'],
123 ),
124 $attr['monthlyDonation']
125 );
126 }
127 if ( $attr['annualDonation']['show'] ) {
128 $donations['1 year'] = array_merge(
129 array(
130 'planId' => null,
131 'title' => __( 'Yearly', 'jetpack' ),
132 'class' => 'donations__annual-item',
133 'heading' => $default_texts['annualDonation']['heading'],
134 'buttonText' => $default_texts['annualDonation']['buttonText'],
135 ),
136 $attr['annualDonation']
137 );
138 }
139
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
169 $nav = '';
170 $headings = '';
171 $amounts = '';
172 $extra_text = '';
173 $buttons = '';
174 foreach ( $donations as $interval => $donation ) {
175 $plan_id = (int) $donation['planId'];
176
177 if ( count( $donations ) > 1 ) {
178 if ( ! $nav ) {
179 $nav .= '<div class="donations__nav">';
180 }
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>',
184 esc_attr( $interval ),
185 esc_html( $donation['title'] ),
186 esc_attr( $is_active_class )
187 );
188 }
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>',
203 esc_attr( $donation['class'] ),
204 $default_index_attr
205 );
206 foreach ( $donation['amounts'] as $amount ) {
207 $amounts .= sprintf(
208 '<div class="donations__amount" data-amount="%1$s">%2$s</div>',
209 esc_attr( $amount ),
210 esc_html( \Jetpack_Currencies::format_price( $amount, $currency ) )
211 );
212 }
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>',
224 esc_attr( $donation['class'] ),
225 esc_url( \Jetpack_Memberships::get_instance()->get_subscription_url( $plan_id ) ),
226 wp_kses_post( $donation['buttonText'] )
227 );
228 }
229 if ( $nav ) {
230 $nav .= '</div>';
231 }
232
233 $custom_amount = '';
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;
241 $custom_amount .= sprintf(
242 '<div class="donations__amount donations__custom-amount">
243 %1$s
244 <div class="donations__amount-value" data-currency="%2$s" data-empty-text="%3$s"></div>
245 </div>',
246 esc_html( \Jetpack_Currencies::CURRENCIES[ $currency ]['symbol'] ?? '¤' ),
247 esc_attr( $currency ),
248 esc_attr( \Jetpack_Currencies::format_price( $default_custom_amount, $currency, false ) )
249 );
250 }
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
337 return sprintf(
338 '
339 <div %1$s>%9$s
340 <div class="donations__container">
341 %2$s
342 <div class="donations__content">
343 <div class="donations__tab %10$s">
344 %3$s
345 %4$s
346 %5$s
347 %6$s
348 <div class="donations__range-error"></div>
349 <hr class="donations__separator">
350 %7$s
351 %8$s
352 </div>
353 </div>
354 </div>
355 </div>
356 ',
357 $wrapper_attrs,
358 $nav,
359 $headings,
360 $choose_amount_block,
361 $amounts,
362 $custom_amount,
363 $extra_text,
364 $buttons,
365 $custom_styles ? '<style>' . $custom_styles . '</style>' : '',
366 esc_attr( $tab_content_class )
367 );
368 }
369
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( 'd' => 'M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM4 19h16v2H4zM9 1v2M12 1v2M15 1v2' ),
837 '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' ),
838 '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' ),
839 'people' => array(
840 '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',
841 'fill-rule' => 'evenodd',
842 ),
843 );
844
845 if ( ! isset( $paths[ $icon_key ] ) || 'none' === $icon_key ) {
846 return '';
847 }
848
849 $icon = $paths[ $icon_key ];
850 $extra_attrs = isset( $icon['fill-rule'] ) ? ' fill-rule="' . esc_attr( $icon['fill-rule'] ) . '"' : '';
851
852 return sprintf(
853 '<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>',
854 esc_attr( $icon['d'] ),
855 $extra_attrs
856 );
857 }
858
859 /**
860 * Make default texts available to the editor.
861 */
862 function load_editor_scripts() {
863 // Only relevant to the editor right now.
864 if ( ! is_admin() ) {
865 return;
866 }
867
868 $data = array(
869 'defaultTexts' => get_default_texts(),
870 );
871
872 wp_add_inline_script(
873 'jetpack-blocks-editor',
874 'var Jetpack_DonationsBlock = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
875 'before'
876 );
877 }
878 add_action( 'enqueue_block_assets', __NAMESPACE__ . '\load_editor_scripts', 11 );
879
880 /**
881 * Determine if AMP should be disabled on posts having Donations blocks.
882 *
883 * @param bool $skip Skipped.
884 * @param int $post_id Post ID.
885 * @param WP_Post $post Post.
886 *
887 * @return bool Whether to skip the post from AMP.
888 */
889 function amp_skip_post( $skip, $post_id, $post ) {
890 // When AMP is on standard mode, there are no non-AMP posts to link to where the donation can be completed, so let's
891 // prevent the post from being available in AMP.
892 if ( function_exists( 'amp_is_canonical' ) && \amp_is_canonical() && has_block( Blocks::get_block_name( __DIR__ ), $post->post_content ) ) {
893 return true;
894 }
895 return $skip;
896 }
897 add_filter( 'amp_skip_post', __NAMESPACE__ . '\amp_skip_post', 10, 3 );
898
899 /**
900 * Record a Tracks event when a post containing the Donations block transitions
901 * to "publish" status. Fires once per donations block in the post, with the
902 * block's configuration snapshot as event properties.
903 *
904 * Admin-driven event only (no donor data). Does not fire on regular updates of
905 * an already-published post, on autosaves, or on revisions.
906 *
907 * @since 15.9
908 *
909 * @param string $new_status New post status.
910 * @param string $old_status Previous post status.
911 * @param WP_Post $post Post being transitioned.
912 * @return void
913 */
914 function record_block_published_event( $new_status, $old_status, $post ) {
915 if ( 'publish' !== $new_status || 'publish' === $old_status ) {
916 return;
917 }
918 if ( ! $post instanceof WP_Post ) {
919 return;
920 }
921 if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
922 return;
923 }
924 $block_name = Blocks::get_block_name( __DIR__ );
925 if ( ! has_block( $block_name, $post ) ) {
926 return;
927 }
928
929 $donation_blocks = collect_donation_blocks( parse_blocks( $post->post_content ), $block_name );
930 if ( empty( $donation_blocks ) ) {
931 return;
932 }
933
934 require_once JETPACK__PLUGIN_DIR . 'modules/memberships/class-jetpack-memberships.php';
935 $stripe_connected = \Jetpack_Memberships::has_connected_account();
936 $block_count = count( $donation_blocks );
937
938 foreach ( $donation_blocks as $index => $block ) {
939 $props = build_block_published_event_props( $block, $post, $index, $block_count, $stripe_connected );
940 record_published_event( 'donations_block_published', $props );
941 }
942 }
943 add_action( 'transition_post_status', __NAMESPACE__ . '\record_block_published_event', 10, 3 );
944
945 /**
946 * Walk a parsed block tree and return a flat list of Donations blocks (including
947 * nested ones inside columns / groups / patterns).
948 *
949 * @since 15.9
950 *
951 * @param array $blocks Parsed blocks from parse_blocks().
952 * @param string $block_name Donations block name (e.g. 'jetpack/donations').
953 * @return array List of parsed blocks matching $block_name.
954 */
955 function collect_donation_blocks( $blocks, $block_name ) {
956 $matches = array();
957 foreach ( $blocks as $block ) {
958 if ( isset( $block['blockName'] ) && $block_name === $block['blockName'] ) {
959 $matches[] = $block;
960 }
961 if ( ! empty( $block['innerBlocks'] ) ) {
962 $matches = array_merge( $matches, collect_donation_blocks( $block['innerBlocks'], $block_name ) );
963 }
964 }
965 return $matches;
966 }
967
968 /**
969 * Build the property bag for a jetpack_donations_block_published Tracks event.
970 *
971 * Extracted so it can be tested independently of the WordPress hook plumbing.
972 *
973 * @since 15.9
974 *
975 * @param array $block Parsed block (with 'attrs' key).
976 * @param WP_Post $post Post being published.
977 * @param int $index 0-based position of this block within the post's donation blocks.
978 * @param int $block_count Total count of donations blocks in the post.
979 * @param bool $stripe_connected Whether the site currently has a connected Stripe account.
980 * @return array Tracks event properties.
981 */
982 function build_block_published_event_props( $block, $post, $index, $block_count, $stripe_connected ) {
983 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
984
985 $one_time_show = ! isset( $attrs['oneTimeDonation']['show'] ) || false !== $attrs['oneTimeDonation']['show'];
986 $monthly_show = ! empty( $attrs['monthlyDonation']['show'] );
987 $yearly_show = ! empty( $attrs['annualDonation']['show'] );
988
989 $default_interval = $attrs['defaultInterval'] ?? 'one-time';
990 $interval_to_freq = array(
991 'one-time' => 'one_time',
992 '1 month' => 'monthly',
993 '1 year' => 'yearly',
994 );
995 $default_frequency = $interval_to_freq[ $default_interval ] ?? $default_interval;
996 $enabled_count = (int) $one_time_show + (int) $monthly_show + (int) $yearly_show;
997 $tabs_appearance = $attrs['tabsAppearance'] ?? 'tabs';
998
999 return array(
1000 'feature' => 'donations',
1001 'surface' => 'server',
1002 'stripe_connected' => (bool) $stripe_connected,
1003 'post_id' => (int) $post->ID,
1004 'post_type' => (string) $post->post_type,
1005 'block_index_in_post' => (int) $index,
1006 'block_count_in_post' => (int) $block_count,
1007 'currency' => isset( $attrs['currency'] ) ? (string) $attrs['currency'] : null,
1008 'default_frequency' => $default_frequency,
1009 'enabled_frequencies_count' => $enabled_count,
1010 'show_one_time' => $one_time_show,
1011 'show_monthly' => $monthly_show,
1012 'show_yearly' => $yearly_show,
1013 'show_custom_amount' => ! empty( $attrs['showCustomAmount'] ),
1014 'has_min_amount' => isset( $attrs['minimumAmount'] ),
1015 'has_max_amount' => isset( $attrs['maximumAmount'] ),
1016 'has_custom_styles' => has_custom_styles( $attrs ),
1017 'tabs_appearance' => (string) $tabs_appearance,
1018 );
1019 }
1020
1021 /**
1022 * Detect whether the block carries any of the customizable style attributes
1023 * we ship for the RSM project (so we can measure adoption of the new options).
1024 *
1025 * @since 15.9
1026 *
1027 * @param array $attrs Block attributes.
1028 * @return bool Whether any style override is set.
1029 */
1030 function has_custom_styles( $attrs ) {
1031 $style_keys = array(
1032 'tabFontSize',
1033 'tabPadding',
1034 'activeTabBackgroundColor',
1035 'activeTabTextColor',
1036 'buttonPadding',
1037 );
1038 foreach ( $style_keys as $key ) {
1039 if ( isset( $attrs[ $key ] ) && '' !== $attrs[ $key ] && array() !== $attrs[ $key ] ) {
1040 return true;
1041 }
1042 }
1043 return false;
1044 }
1045
1046 /**
1047 * Send a Donations Tracks event using whichever pipeline is available for the
1048 * current environment (WPCOM Simple sites vs. WoA / connected Jetpack sites).
1049 *
1050 * Mirrors the dual-path pattern used by the Map block. No-op on environments
1051 * that have neither path available, so this stays safe on unconnected sites.
1052 *
1053 * @since 15.9
1054 *
1055 * @param string $event_name Event name WITHOUT the `jetpack_` prefix (the Tracking class adds it).
1056 * @param array $props Tracks event properties.
1057 * @return void
1058 */
1059 function record_published_event( $event_name, $props ) {
1060 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1061 require_lib( 'tracks/client' );
1062 tracks_record_event( wp_get_current_user(), 'jetpack_' . $event_name, $props );
1063 return;
1064 }
1065 if ( ( new Host() )->is_woa_site() && Jetpack::is_connection_ready() ) {
1066 $tracking = new Tracking();
1067 $tracking->record_user_event( $event_name, $props );
1068 }
1069 }
1070