post_type !== 'bp_campaign' ) { return ''; } $meta = MetaBox::get_all( $campaign_id ); $stats = CampaignStats::get_stats( $campaign_id ); $template_key = $meta['bpc_template_key'] ?? ''; $all_templates = TemplateManager::get_all(); $theme_class = ( $template_key && isset( $all_templates[ $template_key ]['theme_class'] ) ) ? ' ' . sanitize_html_class( $all_templates[ $template_key ]['theme_class'] ) : ''; if ( $is_preview && $preview_layout !== null ) { $layout_data = $preview_layout; } else { $raw = $meta['bpc_fields_layout'] ?? []; $layout_data = self::normalize_layout( $raw ); } $columns = $layout_data['columns'] ?? []; $layout = $layout_data['layout'] ?? '1-column'; // Only fall back to default when there are literally no columns. // Blank templates (1-col, 2-col, 3-col) have columns with empty elements arrays — // that is intentional and must render as blank to match the editor and preview. if ( empty( $columns ) ) { $layout_data = self::default_layout(); $columns = $layout_data['columns']; $layout = $layout_data['layout']; } $color_style = self::generate_color_style( $meta, $campaign_id ); ob_start(); ?>
[] ] ); ?>
block for campaign colour customisation. * * Only emits rules for colours that are explicitly set, so theme CSS remains * the default when a colour is empty. Uses !important to override any theme class. * * @param array $meta Campaign meta (bpc_color_primary, background, …). * @param int $campaign_id Scopes the selectors. 0 = isolated builder preview iframe. * @return string or ''. */ private static function generate_color_style( array $meta, int $campaign_id ): string { // Campaign background colour (set in the builder's Advanced tab). The // per-button colour is now a property of the Donate Button widget // (settings['button_color']), not a campaign-wide override. $background = ! empty( $meta['bpc_color_background'] ) ? sanitize_hex_color( $meta['bpc_color_background'] ) : ''; if ( ! $background ) { return ''; } // $campaign_id is typed int and $background is validated hex — both safe for CSS output. $scope = $campaign_id > 0 ? '.bp-campaign[data-campaign-id="' . $campaign_id . '"]' : '.bp-campaign'; $css = $scope . ' { background-color: ' . $background . ' !important; }'; return ''; } /** * Render a single element by type. * * @param array $element Element definition with type, settings. * @param int $campaign_id * @param \WP_Post $post * @param array $meta Campaign meta from MetaBox::get_all(). * @param array $stats Campaign stats from CampaignStats::get_stats(). * @param bool $is_preview True when rendering for the builder canvas or the * template picker rather than a live campaign page. * Passed through to add-on elements as `$ctx['is_preview']` * so they can stand in sample content — data-driven * elements otherwise render an empty state in the * builder, leaving their settings with no visible * effect. Never true on the frontend. * @return string HTML output. */ public static function render_element( array $element, int $campaign_id, \WP_Post $post, array $meta, array $stats, bool $is_preview = false ): string { $type = $element['type'] ?? ''; $settings = $element['settings'] ?? []; $el_id = $element['id'] ?? ''; ob_start(); switch ( $type ) { case 'campaign_title': $user_color = self::css_hex_color( $settings['color'] ?? '' ); $user_size = self::css_font_size( $settings['font_size'] ?? '' ); // isset(), not ! empty(): clearing the field is an instruction, not // an accident. Only a title that was never set at all falls back to // the campaign name — an emptied one stays empty (and the builder // shows a placeholder in its place so it can still be selected). $title_text = isset( $settings['title'] ) ? (string) $settings['title'] : $post->post_title; $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left'; // A user-set value is emitted with !important so it wins over // template rules (some templates force the title colour/size with // !important). When unset we emit a plain default the template can // still override. $title_style = '' !== $user_color ? 'color:' . $user_color . ' !important;' : 'color:#1a1a2e;'; $title_style .= '' !== $user_size ? 'font-size:' . $user_size . ' !important;' : 'font-size:32px;'; $title_style .= self::decls_to_style( self::typography_common_decls( $settings ) ); $title_style .= 'text-align:' . $align . ';'; ?>

post_content; $desc_width = self::resolve_element_width( $settings ); $desc_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left'; if ( ! $desc_headline && ! $desc_content ) break; // Wrapper carries layout only. Typography is applied directly to the // headline and content elements (with !important) so it beats template // rules that target those elements specifically. The headline (title) // and the body each have their own independent typography set. $desc_wrap = 'width:' . $desc_width . '%;'; if ( 'center' === $desc_align ) $desc_wrap .= 'margin:0 auto;'; elseif ( 'right' === $desc_align ) $desc_wrap .= 'margin-left:auto;'; // Title (headline) typography — its own set under the `title_` // prefixed keys. font-size IS applied here so the title can be // sized directly (empty leaves the template heading scale). $title_typo = self::prefixed_typography( $settings, 'title_' ); $title_color = self::css_hex_color( $title_typo['color'] ?? '' ); $title_size = self::css_font_size( $title_typo['font_size'] ?? '' ); $desc_headline_style = self::decls_to_style( self::typography_common_decls( $title_typo ) ); if ( '' !== $title_color ) $desc_headline_style .= 'color:' . $title_color . ' !important;'; if ( '' !== $title_size ) $desc_headline_style .= 'font-size:' . $title_size . ' !important;'; // Body (content) typography — the base (unprefixed) keys, so any // previously-saved description typography still applies here. $desc_color = self::css_hex_color( $settings['color'] ?? '' ); $desc_size = self::css_font_size( $settings['font_size'] ?? '' ); $desc_content_style = self::decls_to_style( self::typography_common_decls( $settings ) ); if ( '' !== $desc_color ) $desc_content_style .= 'color:' . $desc_color . ' !important;'; if ( '' !== $desc_size ) $desc_content_style .= 'font-size:' . $desc_size . ' !important;'; ?>

>

>
0 ) { $img_data = wp_get_attachment_image_src( $src_id, $size ); $src = $img_data ? $img_data[0] : ''; } if ( ! $src ) { $src = ! empty( $settings['src'] ) ? $settings['src'] : get_the_post_thumbnail_url( $campaign_id, $size ); } $alt_text = ! empty( $settings['alt'] ) ? $settings['alt'] : $post->post_title; $ph_width = self::resolve_element_width( $settings ); $ph_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'center'; if ( $src ) : $img_style = 'width:auto;max-width:' . $ph_width . '%;height:auto;display:block;border-radius:4px;'; if ( 'center' === $ph_align ) $img_style .= 'margin:0 auto;'; elseif ( 'right' === $ph_align ) $img_style .= 'margin-left:auto;'; ?>
<?php echo esc_attr( $alt_text ); ?>
0 ? min( 100, ( $raised / $goal ) * 100 ) : 0 ); $goal_fmt = number_format( (int) ceil( $goal ) ); } else { $display_progress = $progress; // 1 decimal float from CampaignStats $goal_fmt = number_format( $goal, 2 ); } ?>

0 ? min( 100, round( ( (float) $stats['total_raised'] / $goal ) * 100, 1 ) ) : 0; $sm_currency = self::global_currency(); $sm_currency_sym = self::currency_symbol( $sm_currency ); $wrap_style = 'width:' . $sm_width . '%;'; if ( 'center' === $sm_align ) $wrap_style .= 'margin:0 auto;'; elseif ( 'right' === $sm_align ) $wrap_style .= 'margin-left:auto;'; ?>

%
0 ) : ?>

aria-disabled="true" title="" >
post_author; $role_title = ! empty( $settings['role_title'] ) ? $settings['role_title'] : __( 'Organizer', 'better-payment' ); $description = ! empty( $settings['description'] ) ? $settings['description'] : ''; $width = self::resolve_element_width( $settings ); $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left'; $creator = get_user_by( 'ID', $creator_user_id ); if ( ! $creator ) break; $wrap_style = 'width:' . $width . '%;'; if ( 'center' === $align ) { $wrap_style .= 'margin:0 auto;'; } elseif ( 'right' === $align ) { $wrap_style .= 'margin-left:auto;'; } ?>
user_email, 48 ); ?>
display_name ); ?>
$a, 'is_default' => false ]; } } ?>

$item ) : $amt = floatval( $item['amount'] ?? 0 ); $uid = 'bp_camt_' . $campaign_id . '_' . $i; $is_default = ! empty( $item['is_default'] ); ?> hidden />
post_title ); $sh_target = $sh_open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : ''; $sh_justify = [ 'center' => 'center', 'right' => 'flex-end' ][ $sh_align ] ?? 'flex-start'; $share_links = [ 'twitter' => 'https://twitter.com/intent/tweet?url=' . rawurlencode( $sh_page_url ) . '&text=' . $sh_title, 'facebook' => 'https://www.facebook.com/sharer/sharer.php?u=' . rawurlencode( $sh_page_url ), 'linkedin' => 'https://www.linkedin.com/shareArticle?mini=true&url=' . rawurlencode( $sh_page_url ) . '&title=' . $sh_title, 'pinterest' => 'https://pinterest.com/pin/create/button/?url=' . rawurlencode( $sh_page_url ) . '&description=' . $sh_title, 'mastodon' => 'https://mastodonshare.com/?text=' . $sh_title . '&url=' . rawurlencode( $sh_page_url ), 'threads' => 'https://threads.net/intent/post?text=' . $sh_title . '%20' . rawurlencode( $sh_page_url ), 'bluesky' => 'https://bsky.app/intent/compose?text=' . $sh_title . '%20' . rawurlencode( $sh_page_url ), ]; $active_sharing = array_filter( $share_links, function( $url, $key ) use ( $settings ) { return ( $settings[ $key ] ?? true ) !== false; }, ARRAY_FILTER_USE_BOTH ); // Same as social_links above: with every network switched off there // is nothing to share, so a lone "Share Now" heading would sit on the // page labelling nothing. if ( $active_sharing ) : ?>

$share_url ) : ?> title="">
'center', 'right' => 'flex-end' ][ $sl_align ] ?? 'flex-start'; $all_link_keys = [ 'twitter', 'facebook', 'linkedin', 'instagram', 'tiktok', 'pinterest', 'youtube', 'threads', 'bluesky', 'mastodon' ]; $active_links = []; foreach ( $all_link_keys as $key ) { if ( ! empty( $settings[ $key ] ) ) { $active_links[ $key ] = $settings[ $key ]; } } // The links are the widget; the headline only labels them. With no // links there is nothing to label, so a lone "Follow Now" heading is // a promise the page cannot keep — render nothing instead. (Was // `$active_links || $sl_headline`, which kept the heading alive on // its own because the headline defaults to a non-empty string.) if ( $active_links ) : ?> $element, 'settings' => $settings, 'campaign_id' => $campaign_id, 'post' => $post, 'meta' => $meta, 'stats' => $stats, 'is_preview' => $is_preview, ]; echo apply_filters( "better_payment/campaign/render_element_{$type}", '', $ctx ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped break; } $html = ob_get_clean(); // An element with nothing to show needs opposite treatment in the two // places it renders, and this is the one point every element type passes // through — free, Pro, and anything added later — so neither behaviour can // be reintroduced by an individual widget. // // Builder: substitute a placeholder. Without it the element occupies no // space, so there is nothing to see, click, drag or delete — and because // the `data-bp-element-id` wrapper below is skipped for empty output, the // canvas cannot even place a hotspot over it. The author is left with an // invisible element they cannot reach. // // Frontend: render nothing at all. "Renders nothing" is not the same as // "is an empty string": an emptied Title still emits an

that occupies // a 32px line, and an emptied Donate button a coloured bar with no label. // Both are holes in a live campaign page, so they are dropped entirely // rather than shipped as blank boxes. if ( self::renders_nothing_visible( (string) $html ) ) { $html = $is_preview ? self::empty_element_placeholder( $type ) : ''; } if ( ! $html || ! $el_id ) { return $html; } return '
' . $html . '
'; } /** * Does this element's markup put nothing on the page a reader could see? * * Not the same as an empty string. An emptied Title still renders * `

`, and a Social Links block * with no URLs still renders its wrapper — both are markup, and both are * invisible. Only checking for `''` would leave those elements unreachable on * the canvas, which is the bug this whole placeholder exists to prevent. * * Errs firmly toward "not empty": wrongly blanking an element that HAS * content would hide the author's work, which is far worse than missing a * placeholder. So anything that can paint pixels without text — an image, * an embed, a form control, a chart — counts as content immediately, and only * markup with no such node AND no visible text is called empty. * * @param string $html Rendered element markup. * @return bool True when the element would show nothing. */ private static function renders_nothing_visible( string $html ): bool { if ( '' === trim( $html ) ) { return true; } // Nodes that show something without needing text content. If any is // present the element is showing the author something real. $visual = '#<(img|svg|iframe|video|audio|canvas|picture|object|embed|input|select|textarea|progress|meter|hr|table)[\s/>]#i'; if ( preg_match( $visual, $html ) ) { return false; } // Otherwise it is only worth showing if it renders visible text. // wp_strip_all_tags() also drops