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