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;';
?>
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;';
?>
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;';
}
?>
$a, 'is_default' => false ];
}
}
?>
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 ) :
?>
'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