__NAMESPACE__ . '\render_block', 'render_email_callback' => __NAMESPACE__ . '\render_email', 'plan_check' => true, ) ); } // Add a meta field to the user to track if the donation warning has been dismissed. \register_meta( 'user', 'jetpack_donation_warning_dismissed', array( 'type' => 'boolean', 'single' => true, 'show_in_rest' => true, 'default' => false, ) ); } add_action( 'init', __NAMESPACE__ . '\register_block' ); /** * Donations block dynamic rendering. * * @param array $attr Array containing the Donations block attributes. * @param string $content String containing the Donations block content. * * @return string */ function render_block( $attr, $content ) { // Keep content as-is if rendered in other contexts than frontend (i.e. feed, emails, API, etc.). if ( ! Request::is_frontend() ) { $parsed = parse_blocks( $content ); if ( ! empty( $parsed[0] ) ) { // Inject the link of the current post from the server side as the fallback link to make sure the donations block // points to the correct post when it's inserted from the synced pattern (aka “My Pattern”). $post_link = get_permalink(); $parsed[0]['attrs']['fallbackLinkUrl'] = $post_link; $content = \render_block( $parsed[0] ); if ( preg_match( '/ null, 'title' => __( 'One-Time', 'jetpack' ), 'class' => 'donations__one-time-item', 'heading' => $default_texts['oneTimeDonation']['heading'], 'buttonText' => $default_texts['oneTimeDonation']['buttonText'], ), $attr['oneTimeDonation'] ); } if ( $attr['monthlyDonation']['show'] ) { $donations['1 month'] = array_merge( array( 'planId' => null, 'title' => __( 'Monthly', 'jetpack' ), 'class' => 'donations__monthly-item', 'heading' => $default_texts['monthlyDonation']['heading'], 'buttonText' => $default_texts['monthlyDonation']['buttonText'], ), $attr['monthlyDonation'] ); } if ( $attr['annualDonation']['show'] ) { $donations['1 year'] = array_merge( array( 'planId' => null, 'title' => __( 'Yearly', 'jetpack' ), 'class' => 'donations__annual-item', 'heading' => $default_texts['annualDonation']['heading'], 'buttonText' => $default_texts['annualDonation']['buttonText'], ), $attr['annualDonation'] ); } $choose_amount_text = $attr['chooseAmountText'] ?? $default_texts['chooseAmountText']; $custom_amount_text = $attr['customAmountText'] ?? $default_texts['customAmountText']; $currency = $attr['currency']; // Drop intervals whose plan no longer resolves so we can compute the active tab // against the actually-rendered set, not the configured set. $valid_donations = array(); foreach ( $donations as $interval => $donation ) { $plan = get_post( (int) $donation['planId'] ); if ( $plan && ! is_wp_error( $plan ) ) { $valid_donations[ $interval ] = $donation; } } $donations = $valid_donations; $rendered_intervals = array_keys( $donations ); // Effective default = configured defaultInterval if it survived plan validation, // otherwise the first rendered interval (one-time → monthly → annual). $default_interval = $attr['defaultInterval'] ?? null; if ( ! in_array( $default_interval, $rendered_intervals, true ) ) { $default_interval = $rendered_intervals[0] ?? null; } $tab_content_class_map = array( 'one-time' => 'is-one-time', '1 month' => 'is-monthly', '1 year' => 'is-annual', ); $tab_content_class = $default_interval ? $tab_content_class_map[ $default_interval ] : ''; $nav = ''; $headings = ''; $amounts = ''; $extra_text = ''; $buttons = ''; foreach ( $donations as $interval => $donation ) { $plan_id = (int) $donation['planId']; if ( count( $donations ) > 1 ) { if ( ! $nav ) { $nav .= '
'; } $is_active_class = $interval === $default_interval ? ' is-active' : ''; $nav .= sprintf( '
%2$s
', esc_attr( $interval ), esc_html( $donation['title'] ), esc_attr( $is_active_class ) ); } $heading_text = wp_kses_post( $donation['heading'] ?? '' ); if ( '' !== trim( $heading_text ) ) { $headings .= sprintf( '

%2$s

', esc_attr( $donation['class'] ), $heading_text ); } $default_index_attr = ''; if ( isset( $donation['defaultAmountIndex'] ) && is_numeric( $donation['defaultAmountIndex'] ) ) { $default_index_attr = sprintf( ' data-default-index="%d"', (int) $donation['defaultAmountIndex'] ); } $amounts .= sprintf( '
', esc_attr( $donation['class'] ), $default_index_attr ); foreach ( $donation['amounts'] as $amount ) { $amounts .= sprintf( '
%2$s
', esc_attr( $amount ), esc_html( \Jetpack_Currencies::format_price( $amount, $currency ) ) ); } $amounts .= '
'; $extra_text_html = wp_kses_post( $donation['extraText'] ?? $default_texts['extraText'] ); if ( '' !== trim( $extra_text_html ) ) { $extra_text .= sprintf( '

%2$s

', esc_attr( $donation['class'] ), $extra_text_html ); } $buttons .= sprintf( '', esc_attr( $donation['class'] ), esc_url( \Jetpack_Memberships::get_instance()->get_subscription_url( $plan_id ) ), wp_kses_post( $donation['buttonText'] ) ); } if ( $nav ) { $nav .= '
'; } $custom_amount = ''; if ( $attr['showCustomAmount'] ) { $custom_amount_html = wp_kses_post( $custom_amount_text ); if ( '' !== trim( $custom_amount_html ) ) { $custom_amount .= sprintf( '

%s

', $custom_amount_html ); } $default_custom_amount = $attr['customAmountPlaceholder'] ?? ( \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1 ) * 100; $custom_amount .= sprintf( '
%1$s
', esc_html( \Jetpack_Currencies::CURRENCIES[ $currency ]['symbol'] ?? '¤' ), esc_attr( $currency ), esc_attr( \Jetpack_Currencies::format_price( $default_custom_amount, $currency, false ) ) ); } $display_mode = $attr['displayMode'] ?? 'inline'; $instance_id = wp_unique_id( 'jp-donations-' ); $instance_classes = $instance_id; if ( isset( $attr['tabsAppearance'] ) && 'buttons' === $attr['tabsAppearance'] ) { $instance_classes .= ' is-style-buttons'; } if ( 'modal' === $display_mode ) { $instance_classes .= ' is-display-modal'; if ( ! empty( $attr['triggerSticky'] ) ) { $instance_classes .= ' is-sticky'; } } $wrapper_attr_array = array( 'class' => $instance_classes ); if ( $default_interval ) { $wrapper_attr_array['data-default-interval'] = $default_interval; } $wrapper_attr_array = array_merge( $wrapper_attr_array, build_security_data_attrs( $attr, $currency ) ); $wrapper_attrs = get_block_wrapper_attributes( $wrapper_attr_array ); $custom_styles = build_custom_styles( $attr, '.' . $instance_id ); $choose_amount_html = wp_kses_post( $choose_amount_text ); $choose_amount_block = '' !== trim( $choose_amount_html ) ? '

' . $choose_amount_html . '

' : ''; if ( 'modal' === $display_mode ) { $trigger_text = $attr['triggerButtonText'] ?? $default_texts['triggerButtonText']; $trigger_icon_key = $attr['triggerIcon'] ?? 'heart'; $trigger_svg = get_trigger_icon_svg( $trigger_icon_key ); $modal_id = $instance_id . '-modal'; return sprintf( '
%9$s
', $wrapper_attrs, $nav, $headings, $choose_amount_block, $amounts, $custom_amount, $extra_text, $buttons, $custom_styles ? '' : '', esc_html( $trigger_text ), esc_attr( $modal_id ), $trigger_svg, esc_attr__( 'Close', 'jetpack' ), esc_attr( $tab_content_class ) ); } return sprintf( '
%9$s
%2$s
%3$s %4$s %5$s %6$s

%7$s %8$s
', $wrapper_attrs, $nav, $headings, $choose_amount_block, $amounts, $custom_amount, $extra_text, $buttons, $custom_styles ? '' : '', esc_attr( $tab_content_class ) ); } /** * WooCommerce Email Editor render callback for the Donations block. * * Builds a dedicated email version of the block from its attributes (rather than * post-processing the interactive frontend markup) so customizations — per * interval heading, supporting text and button label — are preserved. Each * interval is rendered as an email-safe table section with a CTA button drawn by * the Button block's own email renderer. * * @param string $block_content The rendered block content (unused; we rebuild from attributes). * @param array $parsed_block The parsed block data. * @param object $rendering_context The email rendering context. * * @return string */ function render_email( $block_content, array $parsed_block, $rendering_context ) { if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) || ! function_exists( '\Automattic\Jetpack\Extensions\Button\render_email' ) || ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) { return ''; } $attr = $parsed_block['attrs']; $default_texts = get_default_texts(); $url = get_permalink(); if ( ! is_string( $url ) || '' === $url ) { $url = is_string( $attr['fallbackLinkUrl'] ?? null ) && '' !== $attr['fallbackLinkUrl'] ? $attr['fallbackLinkUrl'] : '#'; } $alignment = in_array( $attr['contentAlignment'] ?? '', array( 'left', 'center', 'right' ), true ) ? $attr['contentAlignment'] : 'left'; $table_style = sprintf( 'width:100%%;max-width:%dpx;border-collapse:collapse;', get_email_target_width( $rendering_context ) ); $sections = ''; foreach ( array( 'oneTimeDonation' => true, 'monthlyDonation' => false, 'annualDonation' => false, ) as $key => $default_show ) { $interval = is_array( $attr[ $key ] ?? null ) ? $attr[ $key ] : array(); if ( false === ( $interval['show'] ?? $default_show ) ) { continue; } $heading = wp_kses_post( $interval['heading'] ?? $default_texts[ $key ]['heading'] ); $extra = wp_kses_post( $interval['extraText'] ?? $default_texts['extraText'] ); $content = ''; if ( '' !== trim( wp_strip_all_tags( $heading ) ) ) { $content .= sprintf( '

%s

', esc_attr( $alignment ), $heading ); } if ( '' !== trim( wp_strip_all_tags( $extra ) ) ) { $content .= sprintf( '

%s

', esc_attr( $alignment ), $extra ); } $content .= render_email_donate_button( $interval['buttonText'] ?? $default_texts[ $key ]['buttonText'], $url, $attr, $rendering_context ); $is_first = '' === $sections; $cell_style = sprintf( 'text-align:%1$s;padding:%2$s;%3$s', esc_attr( $alignment ), $is_first ? '0 0 24px' : '24px 0', $is_first ? '' : 'border-top:1px solid #e0e0e0;' ); $sections .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper( $content, array( 'style' => $table_style ), array( 'style' => $cell_style ) ); } return $sections; } /** * Render a single donation CTA as an email-safe button via the Button block's * email renderer, carrying over the donation block's button styling. * * @param string $text Button label. * @param string $url Button destination. * @param array $attr Donations block attributes. * @param object $rendering_context The email rendering context. * @return string */ function render_email_donate_button( $text, $url, $attr, $rendering_context ) { $text = trim( wp_strip_all_tags( (string) $text ) ); $button_attrs = array( 'text' => '' !== $text ? $text : __( 'Donate', 'jetpack' ), 'url' => $url, ); if ( ! empty( $attr['buttonFontSize'] ) && is_string( $attr['buttonFontSize'] ) ) { $button_attrs['customFontSize'] = $attr['buttonFontSize']; } if ( isset( $attr['buttonBorderRadius'] ) && is_string( $attr['buttonBorderRadius'] ) ) { $radius = (int) $attr['buttonBorderRadius']; if ( $radius > 0 ) { $button_attrs['borderRadius'] = $radius; } } return \Automattic\Jetpack\Extensions\Button\render_email( '', array( 'attrs' => $button_attrs ), $rendering_context ); } /** * Resolve the target content width (in px) from the email rendering context, * falling back to a sensible default. * * @param object $rendering_context The email rendering context. * @return int Width in pixels. */ function get_email_target_width( $rendering_context ) { $target_width = 600; if ( is_object( $rendering_context ) && method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) { $width = $rendering_context->get_layout_width_without_padding(); if ( is_string( $width ) && preg_match( '/(\d+)px/', $width, $matches ) ) { $parsed = (int) $matches[1]; if ( $parsed > 0 ) { $target_width = $parsed; } } } return $target_width; } /** * Build data-attributes array for security (min/max amount) constraints. * * Extracted so it can be tested independently of the full render pipeline. * * @since 15.9 * * @param array $attr Block attributes. * @param string $currency Currency code (e.g. 'USD'). * @return array Associative array of data-attribute name => value. */ function build_security_data_attrs( $attr, $currency ) { $attrs = array(); $min_amount = isset( $attr['minimumAmount'] ) ? (float) $attr['minimumAmount'] : null; $max_amount = isset( $attr['maximumAmount'] ) ? (float) $attr['maximumAmount'] : null; if ( null !== $min_amount ) { $attrs['data-min-amount'] = $min_amount; $attrs['data-min-error'] = sprintf( /* translators: %s: minimum donation amount formatted with currency symbol */ __( 'The minimum donation amount is %s.', 'jetpack' ), \Jetpack_Currencies::format_price( (string) $min_amount, $currency ) ); } if ( null !== $max_amount ) { $attrs['data-max-amount'] = $max_amount; $attrs['data-max-error'] = sprintf( /* translators: %s: maximum donation amount formatted with currency symbol */ __( 'The maximum donation amount is %s.', 'jetpack' ), \Jetpack_Currencies::format_price( (string) $max_amount, $currency ) ); } $stripe_min = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1; $attrs['data-stripe-min-error'] = sprintf( /* translators: %s: payment processor minimum donation amount formatted with currency symbol */ _x( 'The minimum donation amount is %s.', 'payment processor minimum', 'jetpack' ), \Jetpack_Currencies::format_price( (string) $stripe_min, $currency ) ); return $attrs; } /** * Build a CSS string scoping per-state and tab-level style rules to a single * block instance. * * @param array $attr Block attributes. * @param string $scope CSS class selector (with leading dot) unique to this instance. * @return string CSS rules joined into one string, or '' when no overrides are set. */ function build_custom_styles( $attr, $scope ) { $tab_padding = isset( $attr['tabPadding'] ) && is_array( $attr['tabPadding'] ) ? $attr['tabPadding'] : array(); $button_padding = isset( $attr['buttonPadding'] ) && is_array( $attr['buttonPadding'] ) ? $attr['buttonPadding'] : array(); $groups = array( array( 'selector' => $scope . ' .donations__nav-item', 'properties' => array( 'font-size' => $attr['tabFontSize'] ?? '', 'padding-top' => $tab_padding['top'] ?? '', 'padding-right' => $tab_padding['right'] ?? '', 'padding-bottom' => $tab_padding['bottom'] ?? '', 'padding-left' => $tab_padding['left'] ?? '', ), ), array( 'selector' => $scope . ' .donations__nav-item.is-active', 'properties' => array( 'background' => $attr['activeTabBackgroundColor'] ?? '', 'color' => $attr['activeTabTextColor'] ?? '', ), ), array( 'selector' => $scope . ' .donations__nav-item:not(.is-active)', 'properties' => array( 'background' => $attr['inactiveTabBackgroundColor'] ?? '', 'color' => $attr['inactiveTabTextColor'] ?? '', ), ), array( 'selector' => $scope . ' .donations__amount.is-selected', 'properties' => array( 'background-color' => $attr['selectedAmountBackgroundColor'] ?? '', 'color' => $attr['selectedAmountTextColor'] ?? '', ), ), array( 'selector' => $scope . ' .donations__amount.is-selected', 'properties' => array( // Override only the outer ring color; the inner 1px white separator stays put. 'box-shadow' => isset( $attr['selectedAmountOutlineColor'] ) && '' !== $attr['selectedAmountOutlineColor'] ? '0 0 0 1px #fff,0 0 0 3px ' . $attr['selectedAmountOutlineColor'] : '', ), ), array( 'selector' => $scope . ' .donations__donate-button', 'properties' => array( 'font-size' => $attr['buttonFontSize'] ?? '', 'padding-top' => $button_padding['top'] ?? '', 'padding-right' => $button_padding['right'] ?? '', 'padding-bottom' => $button_padding['bottom'] ?? '', 'padding-left' => $button_padding['left'] ?? '', ), ), ); $rules = array(); $content_alignment = $attr['contentAlignment'] ?? ''; if ( in_array( $content_alignment, array( 'left', 'center', 'right' ), true ) ) { $rules[] = $scope . ' .donations__content{text-align:' . $content_alignment . '}'; $justify_map = array( 'left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end', ); $rules[] = $scope . ' .donations__amounts{justify-content:' . $justify_map[ $content_alignment ] . '}'; } foreach ( $groups as $group ) { $decls = array(); foreach ( $group['properties'] as $property => $value ) { $safe = sanitize_css_value( $value ); if ( '' !== $safe ) { $decls[] = $property . ':' . $safe; } } if ( $decls ) { $rules[] = $group['selector'] . '{' . implode( ';', $decls ) . '}'; } } // User-set tab border color: applies to the default-style nav bottom // divider, the per-tab dividers, and the buttons-style pill borders. $tab_border_safe = sanitize_css_value( $attr['tabBorderColor'] ?? '' ); if ( '' !== $tab_border_safe ) { $rules[] = $scope . ' .donations__nav,' . $scope . ' .donations__nav-item,' . $scope . ' .donations__nav-item.is-active{border-color:' . $tab_border_safe . '}'; } $button_radius_decls = build_radius_decls( $attr['buttonBorderRadius'] ?? null ); if ( $button_radius_decls ) { $rules[] = $scope . ' .donations__donate-button{' . implode( ';', $button_radius_decls ) . '}'; } // User-set amount tile font size, border (BorderBoxControl shape) and // border radius (BorderRadiusControl shape). Applies to all amount tiles // (preset + custom); selected-state colors above only kick in when an // amount has the is-selected class. $amount_decls = array(); $amount_font = sanitize_css_value( $attr['amountFontSize'] ?? '' ); if ( '' !== $amount_font ) { $amount_decls[] = 'font-size:' . $amount_font; } $amount_decls = array_merge( $amount_decls, build_border_decls( $attr['amountBorder'] ?? null ) ); $amount_decls = array_merge( $amount_decls, build_radius_decls( $attr['amountBorderRadius'] ?? null ) ); if ( $amount_decls ) { $rules[] = $scope . ' .donations__amount{' . implode( ';', $amount_decls ) . '}'; } $button_alignment = $attr['buttonAlignment'] ?? ''; if ( in_array( $button_alignment, array( 'left', 'center', 'right' ), true ) ) { $rules[] = $scope . ' .donations__donate-button-wrapper{text-align:' . $button_alignment . '}'; } elseif ( 'full' === $button_alignment ) { $rules[] = $scope . ' .donations__donate-button-wrapper{display:block;width:100%}' . $scope . ' .donations__donate-button{display:block;width:100%;box-sizing:border-box;text-align:center}'; } // Pop-up display mode: wire up trigger-button alignment via text-align on // the wrapper so the inline-flex button responds to the alignment toolbar. if ( 'modal' === ( $attr['displayMode'] ?? 'inline' ) ) { if ( in_array( $content_alignment, array( 'left', 'center', 'right' ), true ) ) { $rules[] = $scope . '{text-align:' . $content_alignment . '}'; } } // Wrapper border (In-page mode only). Compound selector (.wp-block-jetpack-donations.jp-donations-N) // has specificity 0,2,0 and wins over the default single-class rule (0,1,0) in common.scss. if ( 'modal' !== ( $attr['displayMode'] ?? 'inline' ) ) { $wrapper_border_decls = array_merge( build_border_decls( $attr['blockBorder'] ?? null ), build_radius_decls( $attr['blockBorderRadius'] ?? null ) ); if ( $wrapper_border_decls ) { $rules[] = '.wp-block-jetpack-donations' . $scope . '{' . implode( ';', $wrapper_border_decls ) . '}'; } } return implode( '', $rules ); } /** * Convert a uniform-or-split BorderBoxControl value into individual CSS declarations. * Uniform shape: { color, style, width }. Split shape: { top: {...}, right: ..., etc. }. * * @param mixed $border BorderBoxControl value (or null). * @return array List of CSS declaration strings (e.g. "border-color:#abc"), already sanitized. */ function build_border_decls( $border ) { if ( ! is_array( $border ) ) { return array(); } $decls = array(); $sides = array( 'top', 'right', 'bottom', 'left' ); $is_split = false; foreach ( $sides as $side ) { if ( isset( $border[ $side ] ) ) { $is_split = true; break; } } if ( $is_split ) { foreach ( $sides as $side ) { $sb = $border[ $side ] ?? null; if ( ! is_array( $sb ) ) { continue; } foreach ( array( 'color', 'style', 'width' ) as $prop ) { $safe = sanitize_css_value( $sb[ $prop ] ?? '' ); if ( '' !== $safe ) { $decls[] = 'border-' . $side . '-' . $prop . ':' . $safe; } } } } else { foreach ( array( 'color', 'style', 'width' ) as $prop ) { $safe = sanitize_css_value( $border[ $prop ] ?? '' ); if ( '' !== $safe ) { $decls[] = 'border-' . $prop . ':' . $safe; } } } return $decls; } /** * Convert a uniform-or-per-corner BorderRadiusControl value into CSS declarations. * Uniform shape: a string like "8px". Per-corner shape: * { topLeft, topRight, bottomRight, bottomLeft } each with string values. * * @param mixed $radius BorderRadiusControl value (or null). * @return array List of CSS declaration strings. */ function build_radius_decls( $radius ) { if ( is_string( $radius ) && '' !== $radius ) { $safe = sanitize_css_value( $radius ); return '' !== $safe ? array( 'border-radius:' . $safe ) : array(); } if ( ! is_array( $radius ) ) { return array(); } $corners = array( 'topLeft' => 'border-top-left-radius', 'topRight' => 'border-top-right-radius', 'bottomRight' => 'border-bottom-right-radius', 'bottomLeft' => 'border-bottom-left-radius', ); $decls = array(); foreach ( $corners as $key => $css_prop ) { $safe = sanitize_css_value( $radius[ $key ] ?? '' ); if ( '' !== $safe ) { $decls[] = $css_prop . ':' . $safe; } } return $decls; } /** * Sanitize a user-supplied CSS value (color, length, etc.) for safe inclusion * in a