PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.2.2
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.2.2
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 All 66 releases
better-payment / includes / Campaign / Services / RendererService.php

RendererService.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.2.2, at includes/Campaign/Services/RendererService.php

1,203 lines 64.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\Campaign\Services;
4
5 use Better_Payment\Lite\Admin\DB;
6 use Better_Payment\Lite\Campaign\CampaignStats;
7 use Better_Payment\Lite\Campaign\MetaBox;
8 use Better_Payment\Lite\Campaign\Templates\TemplateManager;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 /**
15 * Renders campaign HTML from the column-based layout schema.
16 *
17 * Supports both the current column schema and the legacy flat-array format
18 * (auto-migrated to a single 1-column layout on render).
19 *
20 * Used by:
21 * - Shortcode::render_campaign()
22 * - CampaignAPI preview endpoint
23 * - CampaignBlock server-side render
24 */
25 class RendererService {
26
27 /**
28 * Render a full campaign.
29 *
30 * @param int $campaign_id The campaign post ID.
31 * @param bool $is_preview True when rendering a preview (skips publish check).
32 * @param array|null $preview_layout Override layout JSON (used by preview endpoint).
33 * @return string HTML output.
34 */
35 public static function render_campaign(
36 int $campaign_id,
37 bool $is_preview = false,
38 ?array $preview_layout = null
39 ): string {
40 $post = get_post( $campaign_id );
41 if ( ! $post || $post->post_type !== 'bp_campaign' ) {
42 return '';
43 }
44
45 $meta = MetaBox::get_all( $campaign_id );
46 $stats = CampaignStats::get_stats( $campaign_id );
47
48 $template_key = $meta['bpc_template_key'] ?? '';
49 $all_templates = TemplateManager::get_all();
50 $theme_class = ( $template_key && isset( $all_templates[ $template_key ]['theme_class'] ) )
51 ? ' ' . sanitize_html_class( $all_templates[ $template_key ]['theme_class'] )
52 : '';
53
54 if ( $is_preview && $preview_layout !== null ) {
55 $layout_data = $preview_layout;
56 } else {
57 $raw = $meta['bpc_fields_layout'] ?? [];
58 $layout_data = self::normalize_layout( $raw );
59 }
60
61 $columns = $layout_data['columns'] ?? [];
62 $layout = $layout_data['layout'] ?? '1-column';
63
64 // Only fall back to default when there are literally no columns.
65 // Blank templates (1-col, 2-col, 3-col) have columns with empty elements arrays —
66 // that is intentional and must render as blank to match the editor and preview.
67 if ( empty( $columns ) ) {
68 $layout_data = self::default_layout();
69 $columns = $layout_data['columns'];
70 $layout = $layout_data['layout'];
71 }
72
73 $color_style = self::generate_color_style( $meta, $campaign_id );
74
75 ob_start();
76 ?>
77 <div class="bp-campaign bp-campaign--<?php echo esc_attr( $layout ); ?> better-payment<?php echo esc_attr( $theme_class ); ?>"
78 data-campaign-id="<?php echo esc_attr( $campaign_id ); ?>">
79 <?php echo wp_kses( $color_style, [ 'style' => [] ] ); ?>
80 <input type="hidden" class="better_payment_campaign_id"
81 value="<?php echo esc_attr( $campaign_id ); ?>">
82 <input type="hidden" class="better_payment_campaign_currency"
83 value="<?php echo esc_attr( self::global_currency() ); ?>">
84 <div class="bp-campaign-columns">
85 <?php foreach ( $columns as $column ) :
86 $raw_width = $column['width'] ?? '100%';
87 $col_width = preg_match( '/^\d{1,3}(\.\d+)?%$/', $raw_width ) ? $raw_width : '100%';
88 $col_style = 'width: ' . $col_width . ';';
89 ?>
90 <div class="bp-campaign-column"
91 style="<?php echo esc_attr( $col_style ); ?>">
92 <?php
93 foreach ( $column['elements'] as $element ) {
94 echo self::render_element( $element, $campaign_id, $post, $meta, $stats );
95 }
96 ?>
97 </div>
98 <?php endforeach; ?>
99 </div>
100 </div>
101 <?php
102 return ob_get_clean();
103 }
104
105 /**
106 * Generate a scoped <style> block for campaign colour customisation.
107 *
108 * Only emits rules for colours that are explicitly set, so theme CSS remains
109 * the default when a colour is empty. Uses !important to override any theme class.
110 *
111 * @param array $meta Campaign meta (bpc_color_primary, background, …).
112 * @param int $campaign_id Scopes the selectors. 0 = isolated builder preview iframe.
113 * @return string <style>…</style> or ''.
114 */
115 private static function generate_color_style( array $meta, int $campaign_id ): string {
116 // Campaign background colour (set in the builder's Advanced tab). The
117 // per-button colour is now a property of the Donate Button widget
118 // (settings['button_color']), not a campaign-wide override.
119 $background = ! empty( $meta['bpc_color_background'] ) ? sanitize_hex_color( $meta['bpc_color_background'] ) : '';
120
121 if ( ! $background ) {
122 return '';
123 }
124
125 // $campaign_id is typed int and $background is validated hex — both safe for CSS output.
126 $scope = $campaign_id > 0
127 ? '.bp-campaign[data-campaign-id="' . $campaign_id . '"]'
128 : '.bp-campaign';
129
130 $css = $scope . ' { background-color: ' . $background . ' !important; }';
131
132 return '<style>' . wp_strip_all_tags( $css ) . '</style>';
133 }
134
135 /**
136 * Render a single element by type.
137 *
138 * @param array $element Element definition with type, settings.
139 * @param int $campaign_id
140 * @param \WP_Post $post
141 * @param array $meta Campaign meta from MetaBox::get_all().
142 * @param array $stats Campaign stats from CampaignStats::get_stats().
143 * @return string HTML output.
144 */
145 public static function render_element(
146 array $element,
147 int $campaign_id,
148 \WP_Post $post,
149 array $meta,
150 array $stats
151 ): string {
152 $type = $element['type'] ?? '';
153 $settings = $element['settings'] ?? [];
154 $el_id = $element['id'] ?? '';
155
156 ob_start();
157
158 switch ( $type ) {
159
160 case 'campaign_title':
161 $user_color = self::css_hex_color( $settings['color'] ?? '' );
162 $user_size = self::css_font_size( $settings['font_size'] ?? '' );
163 $title_text = ! empty( $settings['title'] ) ? $settings['title'] : $post->post_title;
164 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
165
166 // A user-set value is emitted with !important so it wins over
167 // template rules (some templates force the title colour/size with
168 // !important). When unset we emit a plain default the template can
169 // still override.
170 $title_style = '' !== $user_color ? 'color:' . $user_color . ' !important;' : 'color:#1a1a2e;';
171 $title_style .= '' !== $user_size ? 'font-size:' . $user_size . ' !important;' : 'font-size:32px;';
172 $title_style .= self::decls_to_style( self::typography_common_decls( $settings ) );
173 $title_style .= 'text-align:' . $align . ';';
174 ?>
175 <h2 class="bp-campaign-title" style="<?php echo esc_attr( $title_style ); ?>">
176 <?php echo esc_html( $title_text ); ?>
177 </h2>
178 <?php
179 break;
180
181 case 'campaign_description':
182 $desc_headline = ! empty( $settings['headline'] ) ? $settings['headline'] : '';
183 $desc_content = ! empty( $settings['content'] ) ? $settings['content'] : $post->post_content;
184 $desc_width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
185 $desc_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
186
187 if ( ! $desc_headline && ! $desc_content ) break;
188
189 // Wrapper carries layout only. Typography is applied directly to the
190 // headline and content elements (with !important) so it beats template
191 // rules that target those elements specifically. The headline (title)
192 // and the body each have their own independent typography set.
193 $desc_wrap = 'width:' . $desc_width . '%;';
194 if ( 'center' === $desc_align ) $desc_wrap .= 'margin:0 auto;';
195 elseif ( 'right' === $desc_align ) $desc_wrap .= 'margin-left:auto;';
196
197 // Title (headline) typography — its own set under the `title_`
198 // prefixed keys. font-size IS applied here so the title can be
199 // sized directly (empty leaves the template heading scale).
200 $title_typo = self::prefixed_typography( $settings, 'title_' );
201 $title_color = self::css_hex_color( $title_typo['color'] ?? '' );
202 $title_size = self::css_font_size( $title_typo['font_size'] ?? '' );
203 $desc_headline_style = self::decls_to_style( self::typography_common_decls( $title_typo ) );
204 if ( '' !== $title_color ) $desc_headline_style .= 'color:' . $title_color . ' !important;';
205 if ( '' !== $title_size ) $desc_headline_style .= 'font-size:' . $title_size . ' !important;';
206
207 // Body (content) typography — the base (unprefixed) keys, so any
208 // previously-saved description typography still applies here.
209 $desc_color = self::css_hex_color( $settings['color'] ?? '' );
210 $desc_size = self::css_font_size( $settings['font_size'] ?? '' );
211 $desc_content_style = self::decls_to_style( self::typography_common_decls( $settings ) );
212 if ( '' !== $desc_color ) $desc_content_style .= 'color:' . $desc_color . ' !important;';
213 if ( '' !== $desc_size ) $desc_content_style .= 'font-size:' . $desc_size . ' !important;';
214 ?>
215 <div class="bp-campaign-description"
216 style="text-align:<?php echo esc_attr( $desc_align ); ?>; <?php echo esc_attr( $desc_wrap ); ?>">
217 <?php if ( $desc_headline ) : ?>
218 <h3 class="bp-campaign-description-headline"<?php echo '' !== $desc_headline_style ? ' style="' . esc_attr( $desc_headline_style ) . '"' : ''; ?>><?php echo esc_html( $desc_headline ); ?></h3>
219 <?php endif; ?>
220 <?php if ( $desc_content ) : ?>
221 <div class="bp-campaign-description-content"<?php echo '' !== $desc_content_style ? ' style="' . esc_attr( $desc_content_style ) . '"' : ''; ?>><?php echo wp_kses_post( self::scale_inline_font_sizes( $desc_content ) ); ?></div>
222 <?php endif; ?>
223 </div>
224 <?php
225 break;
226
227 case 'photo':
228 $src_id = isset( $settings['src_id'] ) ? (int) $settings['src_id'] : 0;
229 $size = ! empty( $settings['size'] ) ? $settings['size'] : 'full';
230 $allowed_sizes = [ 'thumbnail', 'medium', 'medium_large', 'large', 'full' ];
231 if ( ! in_array( $size, $allowed_sizes, true ) ) {
232 $size = 'full';
233 }
234
235 // Fallback: resolve attachment ID from URL for elements without a stored src_id.
236 if ( $src_id === 0 && ! empty( $settings['src'] ) ) {
237 $src_id = (int) attachment_url_to_postid( $settings['src'] );
238 }
239
240 $src = '';
241 if ( $src_id > 0 ) {
242 $img_data = wp_get_attachment_image_src( $src_id, $size );
243 $src = $img_data ? $img_data[0] : '';
244 }
245 if ( ! $src ) {
246 $src = ! empty( $settings['src'] ) ? $settings['src'] : get_the_post_thumbnail_url( $campaign_id, $size );
247 }
248 $alt_text = ! empty( $settings['alt'] ) ? $settings['alt'] : $post->post_title;
249 $ph_width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
250 $ph_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'center';
251
252 if ( $src ) :
253 $img_style = 'width:auto;max-width:' . $ph_width . '%;height:auto;display:block;border-radius:4px;';
254 if ( 'center' === $ph_align ) $img_style .= 'margin:0 auto;';
255 elseif ( 'right' === $ph_align ) $img_style .= 'margin-left:auto;';
256 ?>
257 <div class="bp-campaign-photo">
258 <img src="<?php echo esc_url( $src ); ?>"
259 alt="<?php echo esc_attr( $alt_text ); ?>"
260 class="bp-campaign-image"
261 style="<?php echo esc_attr( $img_style ); ?>" />
262 </div>
263 <?php
264 else : ?>
265 <div class="bp-campaign-photo-placeholder">
266 <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="#bbb">
267 <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
268 </svg>
269 </div>
270 <?php endif;
271 break;
272
273 case 'progress_bar':
274 $raised = (float) $stats['total_raised'];
275 $goal = (float) ( $meta['bpc_goal_amount'] ?? 0 );
276 $currency = self::global_currency();
277 $progress = $stats['progress'];
278 $primary = $meta['bpc_color_primary'] ?: '#6b63f6';
279 $headline = ! empty( $settings['headline'] ) ? $settings['headline'] : '';
280 $show_donated = isset( $settings['show_donated'] ) ? (bool) $settings['show_donated'] : true;
281 $show_goal = isset( $settings['show_goal'] ) ? (bool) $settings['show_goal'] : true;
282 $round_amounts = (bool) ( $settings['round_amounts'] ?? false );
283 $donate_label = ! empty( $settings['donate_label'] ) ? $settings['donate_label'] : __( 'Donated:', 'better-payment' );
284 $goal_label = ! empty( $settings['goal_label'] ) ? $settings['goal_label'] : __( 'Goal:', 'better-payment' );
285 $width = isset( $settings['width'] ) ? absint( $settings['width'] ) : 100;
286 $align = ! empty( $settings['align'] ) ? $settings['align'] : 'left';
287 $align_map = [ 'left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end' ];
288 $justify = $align_map[ $align ] ?? 'flex-start';
289 $currency_sym = self::currency_symbol( $currency );
290
291 // Progress label: ceiling (rounded up integer) when round_amounts, else 1 decimal place.
292 if ( $round_amounts ) {
293 $display_progress = (int) ceil( $goal > 0 ? min( 100, ( $raised / $goal ) * 100 ) : 0 );
294 $goal_fmt = number_format( (int) ceil( $goal ) );
295 } else {
296 $display_progress = $progress; // 1 decimal float from CampaignStats
297 $goal_fmt = number_format( $goal, 2 );
298 }
299
300 ?>
301 <div class="bp-campaign-progress"
302 style="width:<?php echo esc_attr( $width ); ?>%; justify-content:<?php echo esc_attr( $justify ); ?>">
303 <?php if ( $headline ) : ?>
304 <h3 class="bp-progress-headline"><?php echo esc_html( $headline ); ?></h3>
305 <?php endif; ?>
306 <div class="bp-progress-bar-wrap">
307 <div class="bp-progress-bar"
308 style="width:<?php echo esc_attr( $progress ); ?>%;
309 background-color:<?php echo esc_attr( $primary ); ?>;"></div>
310 </div>
311 <?php if ( $show_donated || $show_goal ) : ?>
312 <div class="bp-progress-labels">
313 <?php if ( $show_donated ) : ?>
314 <span class="bp-progress-donated">
315 <?php echo esc_html( $donate_label . ' ' . $display_progress . '%' ); ?>
316 </span>
317 <?php endif; ?>
318 <?php if ( $show_goal ) : ?>
319 <span class="bp-progress-goal">
320 <?php echo esc_html( $goal_label . ' ' . $currency_sym . $goal_fmt ); ?>
321 </span>
322 <?php endif; ?>
323 </div>
324 <?php endif; ?>
325 </div>
326 <?php
327 break;
328
329 case 'campaign_summary':
330 $headline = ! empty( $settings['headline'] ) ? $settings['headline'] : '';
331 $show_raised = (bool) ( $settings['show_raised'] ?? true );
332 $show_donors = (bool) ( $settings['show_donors'] ?? true );
333 $show_percent = (bool) ( $settings['show_percent'] ?? true );
334 $show_days = (bool) ( $settings['show_days'] ?? true );
335 $sm_width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
336 $sm_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
337 $goal = (float) ( $meta['bpc_goal_amount'] ?? 0 );
338 $percent = $goal > 0 ? min( 100, round( ( (float) $stats['total_raised'] / $goal ) * 100, 1 ) ) : 0;
339 $sm_currency = self::global_currency();
340 $sm_currency_sym = self::currency_symbol( $sm_currency );
341
342 $wrap_style = 'width:' . $sm_width . '%;';
343 if ( 'center' === $sm_align ) $wrap_style .= 'margin:0 auto;';
344 elseif ( 'right' === $sm_align ) $wrap_style .= 'margin-left:auto;';
345 ?>
346 <div class="bp-campaign-summary-wrap" style="<?php echo esc_attr( $wrap_style ); ?>">
347 <?php if ( $headline ) : ?>
348 <h3 class="bp-summary-headline"><?php echo esc_html( $headline ); ?></h3>
349 <?php endif; ?>
350 <div class="bp-campaign-summary">
351 <?php if ( $show_raised ) : ?>
352 <div class="bp-summary-item">
353 <strong><?php echo esc_html( $sm_currency_sym . number_format( (float) $stats['total_raised'], 2 ) ); ?></strong>
354 <span><?php esc_html_e( 'Raised', 'better-payment' ); ?></span>
355 </div>
356 <?php endif; ?>
357 <?php if ( $show_donors ) : ?>
358 <div class="bp-summary-item">
359 <strong><?php echo esc_html( $stats['donor_count'] ); ?></strong>
360 <span><?php esc_html_e( 'Donors', 'better-payment' ); ?></span>
361 </div>
362 <?php endif; ?>
363 <?php if ( $show_percent ) : ?>
364 <div class="bp-summary-item">
365 <strong><?php echo esc_html( $percent ); ?>%</strong>
366 <span><?php esc_html_e( 'Raised', 'better-payment' ); ?></span>
367 </div>
368 <?php endif; ?>
369 <?php if ( $show_days ) : ?>
370 <div class="bp-summary-item">
371 <strong><?php echo esc_html( is_null( $stats['days_remaining'] ) ? 0 : $stats['days_remaining'] ); ?></strong>
372 <span><?php esc_html_e( 'Days Left', 'better-payment' ); ?></span>
373 </div>
374 <?php endif; ?>
375 </div>
376 </div>
377 <?php
378 break;
379
380 case 'donation_form':
381 $button_label = ! empty( $settings['button_label'] )
382 ? $settings['button_label']
383 : ( ! empty( $settings['button_text'] ) ? $settings['button_text'] : __( 'Donate Now', 'better-payment' ) );
384 $primary = $meta['bpc_color_primary'] ?: '#6b63f6';
385 $button_color = sanitize_hex_color( $settings['button_color'] ?? '' ) ?: sanitize_hex_color( $primary ) ?: '#6b63f6';
386 $open_new_tab = ! empty( $settings['open_new_tab'] );
387 $width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
388 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true )
389 ? $settings['align'] : 'center';
390
391 // URL: element setting takes priority; fall back to campaign meta page ID, then '#' default.
392 $donate_url = '';
393 if ( ! empty( $settings['url'] ) ) {
394 $donate_url = esc_url( $settings['url'] );
395 } else {
396 $page_id = absint( $meta['bpc_form_page_id'] ?? 0 );
397 if ( $page_id ) {
398 $donate_url = esc_url( add_query_arg( 'campaign_id', $campaign_id, get_permalink( $page_id ) ) );
399 }
400 }
401 // Default the Payment Form Page URL to '#' so the button renders as a normal link.
402 $url_missing = false;
403 if ( ! $donate_url ) {
404 $donate_url = '#';
405 }
406
407 if ( $donate_url ) :
408 $min_amount = isset( $meta['bpc_minimum_amount'] ) && $meta['bpc_minimum_amount'] !== ''
409 ? (float) $meta['bpc_minimum_amount']
410 : 0;
411 $currency = self::global_currency();
412 $currency_symbol = self::currency_symbol( $currency );
413
414 $wrap_style = 'width:' . $width . '%;';
415 if ( 'center' === $align ) {
416 $wrap_style .= 'margin:0 auto;';
417 } elseif ( 'right' === $align ) {
418 $wrap_style .= 'margin-left:auto;';
419 }
420
421 $btn_class = 'bp-donate_btn' . ( $url_missing ? ' bp-donate_btn--no-url' : '' );
422 ?>
423 <div class="bp-campaign-donate-wrap">
424 <?php if ( $min_amount > 0 ) : ?>
425 <p class="bp-min-donation-notice" data-min="<?php echo esc_attr( $min_amount ); ?>">
426 <?php
427 printf(
428 /* translators: %s: formatted minimum amount with currency symbol */
429 esc_html__( 'The minimum donation for this campaign is %s.', 'better-payment' ),
430 esc_html( $currency_symbol . number_format( $min_amount, 2 ) )
431 );
432 ?>
433 </p>
434 <?php endif; ?>
435 <div class="bp-campaign-donate-btn" style="<?php echo esc_attr( $wrap_style ); ?>">
436 <a href="<?php echo $url_missing ? '#' : esc_url( $donate_url ); ?>"
437 class="<?php echo esc_attr( $btn_class ); ?>"
438 style="background-color:<?php echo esc_attr( $button_color ); ?> !important;"
439 <?php if ( $url_missing ) : ?>
440 aria-disabled="true"
441 title="<?php esc_attr_e( 'Payment page not configured', 'better-payment' ); ?>"
442 <?php else : ?>
443 <?php echo $open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : ''; ?>
444 <?php endif; ?>
445 >
446 <?php echo esc_html( $button_label ); ?>
447 </a>
448 </div>
449 </div>
450 <?php
451 endif;
452 break;
453
454 case 'organizer':
455 $creator_user_id = ! empty( $settings['creator_user_id'] )
456 ? (int) $settings['creator_user_id']
457 : (int) $post->post_author;
458 $role_title = ! empty( $settings['role_title'] ) ? $settings['role_title'] : __( 'Organizer', 'better-payment' );
459 $description = ! empty( $settings['description'] ) ? $settings['description'] : '';
460 $width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
461 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true )
462 ? $settings['align'] : 'left';
463
464 $creator = get_user_by( 'ID', $creator_user_id );
465 if ( ! $creator ) break;
466
467 $wrap_style = 'width:' . $width . '%;';
468 if ( 'center' === $align ) {
469 $wrap_style .= 'margin:0 auto;';
470 } elseif ( 'right' === $align ) {
471 $wrap_style .= 'margin-left:auto;';
472 }
473 ?>
474 <div class="bp-campaign-organizer" style="<?php echo esc_attr( $wrap_style ); ?>">
475 <div class="bp-organizer-avatar">
476 <?php echo get_avatar( $creator->user_email, 48 ); ?>
477 </div>
478 <div class="bp-organizer-info">
479 <span class="bp-organizer-name"><?php echo esc_html( $creator->display_name ); ?></span>
480 <span class="bp-organizer-role"><?php echo esc_html( $role_title ); ?></span>
481 <?php if ( $description ) : ?>
482 <div class="bp-organizer-description"><?php echo wp_kses_post( self::scale_inline_font_sizes( $description ) ); ?></div>
483 <?php endif; ?>
484 </div>
485 </div>
486 <?php
487 break;
488
489 case 'donate_amount':
490 $amounts_meta = $meta['bpc_suggested_amounts'] ?? [];
491 $allow_custom = (bool) ( $meta['bpc_allow_custom_amount'] ?? 1 );
492 $currency = self::global_currency();
493 $currency_symbol = self::currency_symbol( $currency );
494 $da_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Donate Amount', 'better-payment' );
495
496 // Fall back to legacy comma-string or defaults when meta is empty.
497 if ( empty( $amounts_meta ) ) {
498 $fallback = ! empty( $settings['preset_amounts'] ) ? $settings['preset_amounts'] : '10,25,50,100';
499 foreach ( array_filter( array_map( 'trim', explode( ',', $fallback ) ) ) as $a ) {
500 $amounts_meta[] = [ 'amount' => $a, 'is_default' => false ];
501 }
502 }
503 ?>
504 <div class="bp-campaign-donate">
505 <?php if ( $da_headline ) : ?>
506 <h3 class="bp-donate-headline"><?php echo esc_html( $da_headline ); ?></h3>
507 <?php endif; ?>
508 <div class="bp-donate_amounts">
509 <?php foreach ( $amounts_meta as $i => $item ) :
510 $amt = floatval( $item['amount'] ?? 0 );
511 $uid = 'bp_camt_' . $campaign_id . '_' . $i;
512 $is_default = ! empty( $item['is_default'] );
513 ?>
514 <input
515 type="radio"
516 class="bp-option-amount"
517 id="<?php echo esc_attr( $uid ); ?>"
518 name="option_amount_<?php echo esc_attr( $campaign_id ); ?>"
519 value="<?php echo esc_attr( $amt ); ?>"
520 <?php checked( $is_default ); ?>
521 hidden
522 />
523 <label for="<?php echo esc_attr( $uid ); ?>" class="bp-amount-label">
524 <?php echo esc_html( $currency_symbol . $amt ); ?>
525 </label>
526 <?php endforeach; ?>
527 </div>
528 <?php if ( $allow_custom ) : ?>
529 <div class="other_amount_section">
530 <span class="bp-amount-currency"><?php echo esc_html( $currency_symbol ); ?></span>
531 <input
532 type="number"
533 class="campaign-custom-amount"
534 min="0"
535 step="0.01"
536 placeholder="<?php esc_attr_e( 'Enter custom amount', 'better-payment' ); ?>"
537 />
538 </div>
539 <?php endif; ?>
540 </div>
541 <?php
542 break;
543
544 case 'social_sharing':
545 $sh_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Share Now', 'better-payment' );
546 $sh_open_new_tab = ! empty( $settings['open_new_tab'] );
547 $sh_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
548 $sh_page_url = get_permalink( $post );
549 $sh_title = rawurlencode( $post->post_title );
550 $sh_target = $sh_open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : '';
551 $sh_justify = [ 'center' => 'center', 'right' => 'flex-end' ][ $sh_align ] ?? 'flex-start';
552
553 $share_links = [
554 'twitter' => 'https://twitter.com/intent/tweet?url=' . rawurlencode( $sh_page_url ) . '&text=' . $sh_title,
555 'facebook' => 'https://www.facebook.com/sharer/sharer.php?u=' . rawurlencode( $sh_page_url ),
556 'linkedin' => 'https://www.linkedin.com/shareArticle?mini=true&url=' . rawurlencode( $sh_page_url ) . '&title=' . $sh_title,
557 'pinterest' => 'https://pinterest.com/pin/create/button/?url=' . rawurlencode( $sh_page_url ) . '&description=' . $sh_title,
558 'mastodon' => 'https://mastodonshare.com/?text=' . $sh_title . '&url=' . rawurlencode( $sh_page_url ),
559 'threads' => 'https://threads.net/intent/post?text=' . $sh_title . '%20' . rawurlencode( $sh_page_url ),
560 'bluesky' => 'https://bsky.app/intent/compose?text=' . $sh_title . '%20' . rawurlencode( $sh_page_url ),
561 ];
562
563 $active_sharing = array_filter( $share_links, function( $url, $key ) use ( $settings ) {
564 return ( $settings[ $key ] ?? true ) !== false;
565 }, ARRAY_FILTER_USE_BOTH );
566
567 if ( $active_sharing || $sh_headline ) :
568 ?>
569 <div class="bp-social-sharing" style="text-align:<?php echo esc_attr( $sh_align ); ?>;">
570 <?php if ( $sh_headline ) : ?>
571 <p class="bp-social-headline"><?php echo esc_html( $sh_headline ); ?></p>
572 <?php endif; ?>
573 <?php if ( $active_sharing ) : ?>
574 <div class="bp-social-icons" style="justify-content:<?php echo esc_attr( $sh_justify ); ?>;">
575 <?php foreach ( $active_sharing as $key => $share_url ) : ?>
576 <a href="<?php echo esc_url( $share_url ); ?>"
577 class="bp-social-icon"
578 <?php echo $sh_target; ?>
579 title="<?php echo esc_attr( ucfirst( $key ) ); ?>">
580 <?php echo self::social_icon_svg( $key ); ?>
581 </a>
582 <?php endforeach; ?>
583 </div>
584 <?php endif; ?>
585 </div>
586 <?php
587 endif;
588 break;
589
590 case 'social_links':
591 $sl_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Follow Now', 'better-payment' );
592 $sl_open_new_tab = ! empty( $settings['open_new_tab'] );
593 $sl_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
594 $sl_target = $sl_open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : '';
595 $sl_justify = [ 'center' => 'center', 'right' => 'flex-end' ][ $sl_align ] ?? 'flex-start';
596
597 $all_link_keys = [ 'twitter', 'facebook', 'linkedin', 'instagram', 'tiktok', 'pinterest', 'youtube', 'threads', 'bluesky', 'mastodon' ];
598 $active_links = [];
599 foreach ( $all_link_keys as $key ) {
600 if ( ! empty( $settings[ $key ] ) ) {
601 $active_links[ $key ] = $settings[ $key ];
602 }
603 }
604
605 if ( $active_links || $sl_headline ) :
606 ?>
607 <div class="bp-social-links" style="text-align:<?php echo esc_attr( $sl_align ); ?>;">
608 <?php if ( $sl_headline ) : ?>
609 <p class="bp-social-headline"><?php echo esc_html( $sl_headline ); ?></p>
610 <?php endif; ?>
611 <?php if ( $active_links ) : ?>
612 <div class="bp-social-icons" style="justify-content:<?php echo esc_attr( $sl_justify ); ?>;">
613 <?php foreach ( $active_links as $key => $url ) : ?>
614 <a href="<?php echo esc_url( $url ); ?>"
615 class="bp-social-icon"
616 <?php echo $sl_target; ?>
617 title="<?php echo esc_attr( ucfirst( $key ) ); ?>">
618 <?php echo self::social_icon_svg( $key ); ?>
619 </a>
620 <?php endforeach; ?>
621 </div>
622 <?php endif; ?>
623 </div>
624 <?php
625 endif;
626 break;
627 }
628
629 $html = ob_get_clean();
630
631 if ( ! $html || ! $el_id ) {
632 return $html;
633 }
634
635 return '<div class="bp-element-wrap" data-bp-element-id="' . esc_attr( $el_id ) . '">' . $html . '</div>';
636 }
637
638 /**
639 * Normalize raw stored layout to the column schema.
640 * Handles both old flat-array format and new column format.
641 *
642 * @param mixed $raw Value from MetaBox::get_all()['bpc_fields_layout'].
643 * @return array Normalized layout with 'layout' and 'columns' keys.
644 */
645 public static function normalize_layout( $raw ): array {
646 if ( is_array( $raw ) && isset( $raw['columns'] ) ) {
647 return $raw;
648 }
649
650 // Legacy flat format: wrap all elements into a single column.
651 $elements = is_array( $raw ) ? $raw : [];
652 return [
653 'layout' => '1-column',
654 'columns' => [
655 [
656 'id' => 'main',
657 'label' => 'Main Content',
658 'width' => '100%',
659 'elements' => $elements,
660 ],
661 ],
662 ];
663 }
664
665 /**
666 * Default layout used when no layout is saved (campaign has no fields yet).
667 *
668 * @return array
669 */
670 private static function social_icon_svg( string $network ): string {
671 $paths = [
672 'twitter' => 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.253 5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z',
673 'facebook' => 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z',
674 'linkedin' => 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z',
675 'instagram' => 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162S8.597 18.163 12 18.163s6.162-2.759 6.162-6.162S15.403 5.838 12 5.838zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z',
676 'tiktok' => 'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
677 'pinterest' => 'M12 0C5.373 0 0 5.372 0 12c0 5.084 3.163 9.426 7.627 11.174-.105-.949-.2-2.405.042-3.441.218-.937 1.407-5.965 1.407-5.965s-.359-.719-.359-1.782c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738a.36.36 0 01.083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.632-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0z',
678 'youtube' => 'M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z',
679 'threads' => 'M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.028-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 013.271.165c-.07-.75-.273-1.318-.614-1.716-.434-.507-1.119-.768-2.036-.777h-.045c-.67 0-1.938.181-2.669 1.4l-1.812-.755c.97-1.868 2.836-2.677 4.484-2.677h.06c3.233.03 5.164 2.01 5.34 5.49.208 3.394-1.112 5.49-3.317 6.548-.384.186-.785.34-1.197.461C16.418 23.61 14.397 24 12.186 24z',
680 'bluesky' => 'M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 01-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.204-.659-.299-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8z',
681 'mastodon' => 'M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 00.023-.043v-1.809a.052.052 0 00-.066-.051c-1.517.363-3.072.546-4.632.546-2.685 0-3.463-1.284-3.674-1.818a5.593 5.593 0 01-.319-1.433.053.053 0 01.066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.124-.01 1.554-.043 3.19-.167 4.72-.498.038-.009.075-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.005-.109.033-1.25.033-1.36 0-.43.138-3.032-.019-4.643zm-3.22 9.214h-2.058V9.47c0-1.063-.447-1.601-1.35-1.601-1 0-1.5.647-1.5 1.923v2.786h-2.048V9.792c0-1.276-.5-1.923-1.5-1.923-.903 0-1.35.538-1.35 1.601v5.04H7.884V9.32c0-1.062.27-1.907.81-2.534.558-.627 1.287-.948 2.192-.948 1.047 0 1.84.402 2.363 1.206l.509.855.51-.855c.523-.804 1.316-1.206 2.363-1.206.904 0 1.633.32 2.192.948.54.627.925 1.472.925 2.534v5.19z',
682 ];
683 if ( ! isset( $paths[ $network ] ) ) {
684 return '';
685 }
686 return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="' . esc_attr( $paths[ $network ] ) . '"/></svg>';
687 }
688
689 /**
690 * Scale up inline px font-sizes in HTML content saved by the rich text editor.
691 * Adds 2px to every explicit pixel font-size so the text matches the campaign's
692 * base 16px scale (editor default is 14px).
693 */
694 private static function scale_inline_font_sizes( string $html ): string {
695 return preg_replace_callback(
696 '/\bfont-size\s*:\s*(\d+(?:\.\d+)?)px/i',
697 function ( $m ) {
698 return 'font-size: ' . ( (float) $m[1] + 2 ) . 'px';
699 },
700 $html
701 );
702 }
703
704 /**
705 * Normalize a stored font-size setting into a safe CSS length.
706 * Numeric values are treated as pixels. Returns '' when unset/invalid.
707 *
708 * @param mixed $val
709 */
710 private static function css_font_size( $val ): string {
711 if ( is_string( $val ) ) {
712 $val = trim( $val );
713 }
714 if ( '' === $val || null === $val ) {
715 return '';
716 }
717 if ( is_numeric( $val ) ) {
718 return ( (float) $val ) . 'px';
719 }
720 if ( is_string( $val ) && preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $val ) ) {
721 return $val;
722 }
723 return '';
724 }
725
726 /**
727 * Validate a stored font-family stack. Allows letters, numbers, spaces,
728 * commas, quotes and hyphens only — blocks CSS breakout. Returns '' when
729 * unset/invalid.
730 *
731 * @param mixed $val
732 */
733 private static function css_font_family( $val ): string {
734 $val = is_string( $val ) ? trim( $val ) : '';
735 if ( '' === $val ) {
736 return '';
737 }
738 return preg_match( "/^[A-Za-z0-9 ,'\"_-]+$/", $val ) ? $val : '';
739 }
740
741 /**
742 * Validate a stored font-style value. Returns '' when unset/invalid.
743 *
744 * @param mixed $val
745 */
746 private static function css_font_style( $val ): string {
747 $val = is_string( $val ) ? strtolower( trim( $val ) ) : '';
748 return in_array( $val, [ 'normal', 'italic', 'oblique' ], true ) ? $val : '';
749 }
750
751 /**
752 * Validate a stored hex color. Returns '' when unset/invalid.
753 *
754 * @param mixed $val
755 */
756 private static function css_hex_color( $val ): string {
757 $val = is_string( $val ) ? trim( $val ) : '';
758 return preg_match( '/^#[0-9a-fA-F]{3,8}$/', $val ) ? $val : '';
759 }
760
761 /**
762 * Normalize a stored CSS length (letter/word spacing, line-height). Numeric
763 * values are treated as pixels; a leading minus is allowed. Returns '' when
764 * unset/invalid.
765 *
766 * @param mixed $val
767 */
768 private static function css_length( $val ): string {
769 if ( is_string( $val ) ) {
770 $val = trim( $val );
771 }
772 if ( '' === $val || null === $val ) {
773 return '';
774 }
775 if ( is_numeric( $val ) ) {
776 return ( (float) $val ) . 'px';
777 }
778 if ( is_string( $val ) && preg_match( '/^-?\d+(\.\d+)?(px|em|rem|%)$/', $val ) ) {
779 return $val;
780 }
781 return '';
782 }
783
784 /**
785 * Validate a stored font-weight value. Returns '' when unset/invalid.
786 *
787 * @param mixed $val
788 */
789 private static function css_font_weight( $val ): string {
790 if ( is_int( $val ) ) {
791 $val = (string) $val;
792 }
793 $val = is_string( $val ) ? strtolower( trim( $val ) ) : '';
794 $allowed = [ '100', '200', '300', '400', '500', '600', '700', '800', '900', 'normal', 'bold', 'bolder', 'lighter' ];
795 return in_array( $val, $allowed, true ) ? $val : '';
796 }
797
798 /**
799 * Validate a stored value against an allowlist of CSS keywords.
800 *
801 * @param mixed $val
802 * @param array<string> $allowed
803 */
804 private static function css_keyword( $val, array $allowed ): string {
805 $val = is_string( $val ) ? strtolower( trim( $val ) ) : '';
806 return in_array( $val, $allowed, true ) ? $val : '';
807 }
808
809 /**
810 * Extract a prefixed typography set (e.g. 'title_font_family') back into the
811 * base keys the typography helpers expect ('font_family'). Lets one element
812 * carry more than one independent typography set (see the Description widget,
813 * which styles its title and body separately) while reusing the same helpers.
814 *
815 * @param array $settings Element settings.
816 * @param string $prefix Key prefix to strip (e.g. 'title_').
817 * @return array<string, mixed> Base-keyed typography settings.
818 */
819 private static function prefixed_typography( array $settings, string $prefix ): array {
820 $keys = [
821 'font_family', 'font_size', 'font_weight', 'text_transform', 'font_style',
822 'text_decoration', 'line_height', 'letter_spacing', 'word_spacing', 'color',
823 ];
824 $out = [];
825 foreach ( $keys as $key ) {
826 if ( isset( $settings[ $prefix . $key ] ) ) {
827 $out[ $key ] = $settings[ $prefix . $key ];
828 }
829 }
830 return $out;
831 }
832
833 /**
834 * Build the "common" typography CSS declarations (everything except color and
835 * font-size, which callers handle per-element) from element settings.
836 * Only user-set, valid values are included.
837 *
838 * @param array $settings
839 * @return array<string, string> Map of css-property => value.
840 */
841 private static function typography_common_decls( array $settings ): array {
842 $decls = [];
843
844 $family = self::css_font_family( $settings['font_family'] ?? '' );
845 if ( '' !== $family ) {
846 $decls['font-family'] = $family;
847 }
848 $weight = self::css_font_weight( $settings['font_weight'] ?? '' );
849 if ( '' !== $weight ) {
850 $decls['font-weight'] = $weight;
851 }
852 $style = self::css_font_style( $settings['font_style'] ?? '' );
853 if ( '' !== $style ) {
854 $decls['font-style'] = $style;
855 }
856 $transform = self::css_keyword( $settings['text_transform'] ?? '', [ 'uppercase', 'lowercase', 'capitalize', 'none' ] );
857 if ( '' !== $transform ) {
858 $decls['text-transform'] = $transform;
859 }
860 $decoration = self::css_keyword( $settings['text_decoration'] ?? '', [ 'underline', 'overline', 'line-through', 'none' ] );
861 if ( '' !== $decoration ) {
862 $decls['text-decoration'] = $decoration;
863 }
864 $line_height = self::css_length( $settings['line_height'] ?? '' );
865 if ( '' !== $line_height ) {
866 $decls['line-height'] = $line_height;
867 }
868 $letter = self::css_length( $settings['letter_spacing'] ?? '' );
869 if ( '' !== $letter ) {
870 $decls['letter-spacing'] = $letter;
871 }
872 $word = self::css_length( $settings['word_spacing'] ?? '' );
873 if ( '' !== $word ) {
874 $decls['word-spacing'] = $word;
875 }
876
877 return $decls;
878 }
879
880 /**
881 * Flatten a declaration map into an inline-style string. Every declaration is
882 * emitted with !important so user overrides win over template stylesheet rules.
883 *
884 * @param array<string, string> $decls
885 */
886 private static function decls_to_style( array $decls ): string {
887 $out = '';
888 foreach ( $decls as $prop => $value ) {
889 $out .= $prop . ':' . $value . ' !important;';
890 }
891 return $out;
892 }
893
894 /**
895 * Returns the global Better Payment currency code from plugin settings.
896 */
897 private static function global_currency(): string {
898 $code = DB::get_settings( 'better_payment_settings_general_general_currency' );
899 return ( is_string( $code ) && $code !== '' ) ? $code : 'USD';
900 }
901
902 private static function currency_symbol( string $code ): string {
903 $map = [
904 'USD' => '$', 'EUR' => '', 'GBP' => '£', 'JPY' => '¥',
905 'CAD' => 'CA$', 'AUD' => 'A$', 'INR' => '', 'BRL' => 'R$',
906 'MXN' => 'MX$', 'SGD' => 'S$', 'CHF' => 'CHF', 'SEK' => 'kr',
907 'NOK' => 'kr', 'DKK' => 'kr', 'NZD' => 'NZ$', 'ZAR' => 'R',
908 'BDT' => '', 'PKR' => '', 'NGN' => '', 'KES' => 'KSh',
909 ];
910 return $map[ strtoupper( $code ) ] ?? $code;
911 }
912
913 /**
914 * Render a template definition as HTML for the picker iframe preview.
915 *
916 * @param string $key Template key from TemplateManager.
917 * @return string HTML fragment, or empty string if key not found.
918 */
919 public static function render_template_preview( string $key ): string {
920 $templates = TemplateManager::get_all();
921 if ( ! isset( $templates[ $key ] ) ) {
922 return '';
923 }
924 $template = $templates[ $key ];
925 $columns = $template['columns'] ?? [];
926 $layout = $template['layout'] ?? '1-column';
927
928 $first_users = get_users( [ 'fields' => [ 'ID' ], 'number' => 1 ] );
929 $first_creator_id = ! empty( $first_users ) ? (int) $first_users[0]->ID : get_current_user_id();
930
931 $fake_post = new \WP_Post( (object) [
932 'ID' => 0,
933 'post_title' => $template['default_title'] ?? $template['label'] ?? 'Campaign Preview',
934 'post_content' => '',
935 'post_author' => $first_creator_id,
936 'post_type' => 'bp_campaign',
937 'post_status' => 'publish',
938 'post_name' => $key,
939 ] );
940
941 $meta = [
942 'bpc_goal_amount' => 10000,
943 'bpc_color_primary' => $template['preview_color'] ?? '#6b63f6',
944 'bpc_color_background' => '',
945 'bpc_suggested_amounts' => [],
946 'bpc_allow_custom_amount' => true,
947 'bpc_minimum_amount' => '',
948 'bpc_form_page_id' => 0,
949 'bpc_status' => 'active',
950 'bpc_template_key' => $key,
951 'bpc_css_class' => '',
952 ];
953
954 $stats = [
955 'total_raised' => 3750,
956 'progress' => 37.5,
957 'donor_count' => 42,
958 'days_remaining' => 18,
959 ];
960
961 $theme_class = isset( $template['theme_class'] ) ? ' ' . sanitize_html_class( $template['theme_class'] ) : '';
962
963 ob_start();
964 ?>
965 <div class="bp-campaign bp-campaign--<?php echo esc_attr( $layout ); ?> better-payment<?php echo esc_attr( $theme_class ); ?>">
966 <div class="bp-campaign-columns">
967 <?php foreach ( $columns as $column ) :
968 $col_style = 'width: ' . esc_attr( $column['width'] ?? '100%' ) . ';';
969 if ( ! empty( $column['style'] ) ) {
970 $col_style .= ' ' . esc_attr( $column['style'] );
971 }
972 ?>
973 <div class="bp-campaign-column"
974 style="<?php echo $col_style; ?>">
975 <?php
976 foreach ( $column['elements'] as $element ) {
977 echo self::render_element( $element, 0, $fake_post, $meta, $stats );
978 }
979 ?>
980 </div>
981 <?php endforeach; ?>
982 </div>
983 </div>
984 <?php
985 return ob_get_clean();
986 }
987
988 /**
989 * Build a full HTML preview document for the builder's live-preview iframe.
990 *
991 * Each element is wrapped in a <div data-bp-element-id> so the JS overlay
992 * can measure positions and wire up hover/drag interactions. Works for both
993 * new unsaved campaigns (campaign_id = 0) and existing ones.
994 *
995 * @param array $layout_data Builder layout: { layout, columns }.
996 * @param array $meta_input Campaign meta from the builder store (includes 'title').
997 * @param int $campaign_id 0 for new campaigns; real ID to pull live stats.
998 * @return string Full HTML document string.
999 */
1000 public static function build_preview_document(
1001 array $layout_data,
1002 array $meta_input,
1003 int $campaign_id = 0
1004 ): string {
1005 // ── Post + stats ──────────────────────────────────────────────────────
1006 $post = null;
1007 $stats = null;
1008
1009 if ( $campaign_id > 0 ) {
1010 $real = get_post( $campaign_id );
1011 if ( $real && $real->post_type === 'bp_campaign' ) {
1012 $post = $real;
1013 $stats = CampaignStats::get_stats( $campaign_id );
1014 }
1015 }
1016
1017 if ( ! $post ) {
1018 $post = new \WP_Post( (object) [
1019 'ID' => 0,
1020 'post_title' => sanitize_text_field( $meta_input['title'] ?? 'Campaign Preview' ),
1021 'post_content' => '',
1022 'post_author' => get_current_user_id(),
1023 'post_type' => 'bp_campaign',
1024 'post_status' => 'publish',
1025 'post_name' => 'preview',
1026 ] );
1027 $stats = [ 'total_raised' => 0, 'progress' => 0, 'donor_count' => 0, 'days_remaining' => null ];
1028 } else {
1029 // Always reflect the current editor title, even for saved campaigns.
1030 $override = sanitize_text_field( $meta_input['title'] ?? '' );
1031 if ( $override !== '' ) {
1032 $post->post_title = $override;
1033 }
1034 }
1035
1036 // Override stats fields that depend on unsaved builder meta so the preview
1037 // reflects the current editor values without requiring a save first.
1038 $preview_end_date = $meta_input['bpc_end_date'] ?? '';
1039 if ( $preview_end_date !== '' ) {
1040 $diff = strtotime( $preview_end_date ) - current_time( 'timestamp' );
1041 $stats['days_remaining'] = max( 0, (int) ceil( $diff / DAY_IN_SECONDS ) );
1042 } else {
1043 $stats['days_remaining'] = null;
1044 }
1045
1046 $preview_goal = (float) ( $meta_input['bpc_goal_amount'] ?? 0 );
1047 if ( $preview_goal > 0 ) {
1048 $stats['progress'] = min( 100.0, round( ( $stats['total_raised'] / $preview_goal ) * 100, 1 ) );
1049 }
1050
1051 // ── Meta ─────────────────────────────────────────────────────────────
1052 $meta_defaults = [
1053 'bpc_goal_amount' => 0,
1054 'bpc_color_primary' => '#6b63f6',
1055 'bpc_color_background' => '',
1056 'bpc_suggested_amounts' => [],
1057 'bpc_allow_custom_amount' => true,
1058 'bpc_minimum_amount' => '',
1059 'bpc_form_page_id' => 0,
1060 'bpc_status' => 'active',
1061 'bpc_template_key' => '',
1062 'bpc_css_class' => '',
1063 ];
1064
1065 if ( $campaign_id > 0 ) {
1066 $meta = array_merge( MetaBox::get_all( $campaign_id ), $meta_defaults, $meta_input );
1067 } else {
1068 $meta = array_merge( $meta_defaults, $meta_input );
1069 }
1070
1071 // ── Layout ───────────────────────────────────────────────────────────
1072 $columns = $layout_data['columns'] ?? [];
1073 $layout = $layout_data['layout'] ?? '1-column';
1074
1075 if ( empty( $columns ) ) {
1076 $default = self::default_layout();
1077 $columns = $default['columns'];
1078 $layout = $default['layout'];
1079 }
1080
1081 // ── Template theme class ──────────────────────────────────────────────
1082 $template_key = $meta['bpc_template_key'] ?? '';
1083 $all_templates = TemplateManager::get_all();
1084 $theme_class = ( $template_key && isset( $all_templates[ $template_key ]['theme_class'] ) )
1085 ? ' ' . sanitize_html_class( $all_templates[ $template_key ]['theme_class'] )
1086 : '';
1087
1088 // ── Campaign HTML with element-id wrappers ────────────────────────────
1089 $color_style = self::generate_color_style( $meta, $campaign_id );
1090
1091 ob_start();
1092 ?>
1093 <div class="bp-campaign bp-campaign--<?php echo esc_attr( $layout ); ?> better-payment<?php echo esc_attr( $theme_class ); ?>"
1094 data-campaign-id="<?php echo esc_attr( $campaign_id ); ?>">
1095 <?php echo wp_kses( $color_style, [ 'style' => [] ] ); ?>
1096 <input type="hidden" class="better_payment_campaign_id"
1097 value="<?php echo esc_attr( $campaign_id ); ?>">
1098 <input type="hidden" class="better_payment_campaign_currency"
1099 value="<?php echo esc_attr( self::global_currency() ); ?>">
1100 <div class="bp-campaign-columns">
1101 <?php foreach ( $columns as $column ) :
1102 $is_empty = empty( $column['elements'] );
1103 $col_class = 'bp-campaign-column' . ( $is_empty ? ' bp-col-empty' : '' );
1104 $raw_width = $column['width'] ?? '100%';
1105 $col_width = preg_match( '/^\d{1,3}(\.\d+)?%$/', $raw_width ) ? $raw_width : '100%';
1106 $col_style = 'width: ' . $col_width . ';';
1107 ?>
1108 <div class="<?php echo esc_attr( $col_class ); ?>"
1109 data-bp-column-id="<?php echo esc_attr( $column['id'] ); ?>"
1110 style="<?php echo esc_attr( $col_style ); ?>">
1111 <?php foreach ( $column['elements'] as $element ) : ?>
1112 <div data-bp-element-id="<?php echo esc_attr( $element['id'] ?? '' ); ?>"
1113 data-bp-column-id="<?php echo esc_attr( $column['id'] ); ?>"
1114 class="bp-builder-el-wrap">
1115 <?php echo self::render_element( $element, $campaign_id, $post, $meta, $stats ); ?>
1116 </div>
1117 <?php endforeach; ?>
1118 </div>
1119 <?php endforeach; ?>
1120 </div>
1121 </div>
1122 <?php
1123 $campaign_html = ob_get_clean();
1124
1125 // ── Assemble full HTML document ───────────────────────────────────────
1126 // Mirror exactly what the frontend enqueues (single-bp_campaign.php +
1127 // Shortcode::enqueue_frontend_styles): both campaign-display CSS and
1128 // fundraising-campaign CSS are required for pixel-perfect parity.
1129 $v = BETTER_PAYMENT_VERSION;
1130 $display_css = BETTER_PAYMENT_ASSETS . '/blocks/campaign-display/style.min.css';
1131 $fundraising_css = BETTER_PAYMENT_ASSETS . '/css/fundraising-campaign.min.css';
1132
1133 $extra_link = file_exists( BETTER_PAYMENT_ASSETS_PATH . '/css/fundraising-campaign.min.css' )
1134 ? '<link rel="stylesheet" href="' . esc_url( $fundraising_css ) . '?v=' . $v . '">' . "\n"
1135 : '';
1136
1137 return '<!DOCTYPE html>' . "\n"
1138 . '<html>' . "\n"
1139 . '<head>' . "\n"
1140 . '<meta charset="utf-8">' . "\n"
1141 . '<meta name="viewport" content="width=device-width, initial-scale=1">' . "\n"
1142 . '<base href="' . esc_url( home_url( '/' ) ) . '">' . "\n"
1143 . '<link rel="stylesheet" href="' . esc_url( $display_css ) . '?v=' . $v . '">' . "\n"
1144 . $extra_link
1145 . '<style>' . "\n"
1146 . '*, *::before, *::after { box-sizing: border-box; }' . "\n"
1147 . 'html, body { margin: 0; padding: 0; background: #fff; }' . "\n"
1148 . ( $theme_class ? 'body { padding: 0 24px 16px; }' . "\n" : '' )
1149 . '.bp-builder-el-wrap { position: relative; }' . "\n"
1150 // Empty columns need a minimum height so the overlay ColumnDropZone can
1151 // measure them and render the dashed drop-zone border at the correct size.
1152 . '.bp-campaign-column.bp-col-empty { min-height: 160px; }' . "\n"
1153 . '.bp-campaign-columns[style*="stretch"] .bp-builder-el-wrap {' . "\n"
1154 . ' height: 100%;' . "\n"
1155 . ' display: flex;' . "\n"
1156 . ' flex-direction: column;' . "\n"
1157 . '}' . "\n"
1158 // The preview is read-only: no link/button/form control should be
1159 // clickable (e.g. the Donate Now button must not navigate away).
1160 // pointer-events:none only blocks pointer interaction — page scrolling
1161 // still works. This document is preview-only (the live frontend uses
1162 // render_campaign() directly), so real pages are unaffected.
1163 . 'a, button, input, select, textarea, [role="button"] {' . "\n"
1164 . ' pointer-events: none !important;' . "\n"
1165 . ' cursor: default !important;' . "\n"
1166 . '}' . "\n"
1167 . '</style>' . "\n"
1168 . '</head>' . "\n"
1169 . '<body>' . "\n"
1170 . preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $campaign_html ) . "\n"
1171 . '</body>' . "\n"
1172 . '</html>';
1173 }
1174
1175 private static function default_layout(): array {
1176 return [
1177 'layout' => '2-column',
1178 'columns' => [
1179 [
1180 'id' => 'main',
1181 'label' => 'Main Content',
1182 'width' => '65%',
1183 'elements' => [
1184 [ 'id' => 'def_photo', 'type' => 'photo', 'settings' => [] ],
1185 [ 'id' => 'def_title', 'type' => 'campaign_title', 'settings' => [] ],
1186 [ 'id' => 'def_desc', 'type' => 'campaign_description', 'settings' => [] ],
1187 ],
1188 ],
1189 [
1190 'id' => 'sidebar',
1191 'label' => 'Sidebar',
1192 'width' => '35%',
1193 'elements' => [
1194 [ 'id' => 'def_progress', 'type' => 'progress_bar', 'settings' => [] ],
1195 [ 'id' => 'def_summary', 'type' => 'campaign_summary', 'settings' => [] ],
1196 [ 'id' => 'def_donate', 'type' => 'donation_form', 'settings' => [] ],
1197 ],
1198 ],
1199 ],
1200 ];
1201 }
1202 }
1203