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