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">✕</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 |