PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.2.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.2.1
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.1, at includes/Campaign/Services/RendererService.php

971 lines 54.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, secondary, tertiary, button).
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 $button = ! empty( $meta['bpc_color_button'] ) ? sanitize_hex_color( $meta['bpc_color_button'] ) : '';
117
118 if ( ! $button ) {
119 return '';
120 }
121
122 // $campaign_id is typed int and $button is validated hex — both safe for CSS output.
123 $scope = $campaign_id > 0
124 ? '.bp-campaign[data-campaign-id="' . $campaign_id . '"]'
125 : '.bp-campaign';
126
127 $css = $scope . ' .bp-donate_btn { background-color: ' . $button . ' !important; }';
128
129 return '<style>' . wp_strip_all_tags( $css ) . '</style>';
130 }
131
132 /**
133 * Render a single element by type.
134 *
135 * @param array $element Element definition with type, settings.
136 * @param int $campaign_id
137 * @param \WP_Post $post
138 * @param array $meta Campaign meta from MetaBox::get_all().
139 * @param array $stats Campaign stats from CampaignStats::get_stats().
140 * @return string HTML output.
141 */
142 public static function render_element(
143 array $element,
144 int $campaign_id,
145 \WP_Post $post,
146 array $meta,
147 array $stats
148 ): string {
149 $type = $element['type'] ?? '';
150 $settings = $element['settings'] ?? [];
151 $el_id = $element['id'] ?? '';
152
153 ob_start();
154
155 switch ( $type ) {
156
157 case 'campaign_title':
158 $color = ! empty( $settings['color'] ) ? $settings['color'] : '#1a1a2e';
159 $font_size = ! empty( $settings['font_size'] ) ? $settings['font_size'] : '32px';
160 $title_text = ! empty( $settings['title'] ) ? $settings['title'] : $post->post_title;
161 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
162 ?>
163 <h2 class="bp-campaign-title"
164 style="color: <?php echo esc_attr( $color ); ?>; font-size: <?php echo esc_attr( $font_size ); ?>; text-align: <?php echo esc_attr( $align ); ?>;">
165 <?php echo esc_html( $title_text ); ?>
166 </h2>
167 <?php
168 break;
169
170 case 'campaign_description':
171 $desc_headline = ! empty( $settings['headline'] ) ? $settings['headline'] : '';
172 $desc_content = ! empty( $settings['content'] ) ? $settings['content'] : $post->post_content;
173 $desc_width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
174 $desc_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
175
176 if ( ! $desc_headline && ! $desc_content ) break;
177
178 $desc_wrap = 'width:' . $desc_width . '%;';
179 if ( 'center' === $desc_align ) $desc_wrap .= 'margin:0 auto;';
180 elseif ( 'right' === $desc_align ) $desc_wrap .= 'margin-left:auto;';
181 ?>
182 <div class="bp-campaign-description"
183 style="text-align:<?php echo esc_attr( $desc_align ); ?>; <?php echo esc_attr( $desc_wrap ); ?>">
184 <?php if ( $desc_headline ) : ?>
185 <h3 class="bp-campaign-description-headline"><?php echo esc_html( $desc_headline ); ?></h3>
186 <?php endif; ?>
187 <?php if ( $desc_content ) : ?>
188 <div class="bp-campaign-description-content"><?php echo wp_kses_post( self::scale_inline_font_sizes( $desc_content ) ); ?></div>
189 <?php endif; ?>
190 </div>
191 <?php
192 break;
193
194 case 'photo':
195 $src_id = isset( $settings['src_id'] ) ? (int) $settings['src_id'] : 0;
196 $size = ! empty( $settings['size'] ) ? $settings['size'] : 'full';
197 $allowed_sizes = [ 'thumbnail', 'medium', 'medium_large', 'large', 'full' ];
198 if ( ! in_array( $size, $allowed_sizes, true ) ) {
199 $size = 'full';
200 }
201
202 // Fallback: resolve attachment ID from URL for elements without a stored src_id.
203 if ( $src_id === 0 && ! empty( $settings['src'] ) ) {
204 $src_id = (int) attachment_url_to_postid( $settings['src'] );
205 }
206
207 $src = '';
208 if ( $src_id > 0 ) {
209 $img_data = wp_get_attachment_image_src( $src_id, $size );
210 $src = $img_data ? $img_data[0] : '';
211 }
212 if ( ! $src ) {
213 $src = ! empty( $settings['src'] ) ? $settings['src'] : get_the_post_thumbnail_url( $campaign_id, $size );
214 }
215 $alt_text = ! empty( $settings['alt'] ) ? $settings['alt'] : $post->post_title;
216 $ph_width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
217 $ph_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'center';
218
219 if ( $src ) :
220 $img_style = 'width:auto;max-width:' . $ph_width . '%;height:auto;display:block;border-radius:4px;';
221 if ( 'center' === $ph_align ) $img_style .= 'margin:0 auto;';
222 elseif ( 'right' === $ph_align ) $img_style .= 'margin-left:auto;';
223 ?>
224 <div class="bp-campaign-photo">
225 <img src="<?php echo esc_url( $src ); ?>"
226 alt="<?php echo esc_attr( $alt_text ); ?>"
227 class="bp-campaign-image"
228 style="<?php echo esc_attr( $img_style ); ?>" />
229 </div>
230 <?php
231 else : ?>
232 <div class="bp-campaign-photo-placeholder">
233 <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="#bbb">
234 <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"/>
235 </svg>
236 </div>
237 <?php endif;
238 break;
239
240 case 'progress_bar':
241 $raised = (float) $stats['total_raised'];
242 $goal = (float) ( $meta['bpc_goal_amount'] ?? 0 );
243 $currency = self::global_currency();
244 $progress = $stats['progress'];
245 $primary = $meta['bpc_color_primary'] ?: '#6b63f6';
246 $headline = ! empty( $settings['headline'] ) ? $settings['headline'] : '';
247 $show_donated = isset( $settings['show_donated'] ) ? (bool) $settings['show_donated'] : true;
248 $show_goal = isset( $settings['show_goal'] ) ? (bool) $settings['show_goal'] : true;
249 $round_amounts = (bool) ( $settings['round_amounts'] ?? false );
250 $donate_label = ! empty( $settings['donate_label'] ) ? $settings['donate_label'] : __( 'Donated:', 'better-payment' );
251 $goal_label = ! empty( $settings['goal_label'] ) ? $settings['goal_label'] : __( 'Goal:', 'better-payment' );
252 $width = isset( $settings['width'] ) ? absint( $settings['width'] ) : 100;
253 $align = ! empty( $settings['align'] ) ? $settings['align'] : 'left';
254 $align_map = [ 'left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end' ];
255 $justify = $align_map[ $align ] ?? 'flex-start';
256 $currency_sym = self::currency_symbol( $currency );
257
258 // Progress label: ceiling (rounded up integer) when round_amounts, else 1 decimal place.
259 if ( $round_amounts ) {
260 $display_progress = (int) ceil( $goal > 0 ? min( 100, ( $raised / $goal ) * 100 ) : 0 );
261 $goal_fmt = number_format( (int) ceil( $goal ) );
262 } else {
263 $display_progress = $progress; // 1 decimal float from CampaignStats
264 $goal_fmt = number_format( $goal, 2 );
265 }
266
267 ?>
268 <div class="bp-campaign-progress"
269 style="width:<?php echo esc_attr( $width ); ?>%; justify-content:<?php echo esc_attr( $justify ); ?>">
270 <?php if ( $headline ) : ?>
271 <h3 class="bp-progress-headline"><?php echo esc_html( $headline ); ?></h3>
272 <?php endif; ?>
273 <div class="bp-progress-bar-wrap">
274 <div class="bp-progress-bar"
275 style="width:<?php echo esc_attr( $progress ); ?>%;
276 background-color:<?php echo esc_attr( $primary ); ?>;"></div>
277 </div>
278 <?php if ( $show_donated || $show_goal ) : ?>
279 <div class="bp-progress-labels">
280 <?php if ( $show_donated ) : ?>
281 <span class="bp-progress-donated">
282 <?php echo esc_html( $donate_label . ' ' . $display_progress . '%' ); ?>
283 </span>
284 <?php endif; ?>
285 <?php if ( $show_goal ) : ?>
286 <span class="bp-progress-goal">
287 <?php echo esc_html( $goal_label . ' ' . $currency_sym . $goal_fmt ); ?>
288 </span>
289 <?php endif; ?>
290 </div>
291 <?php endif; ?>
292 </div>
293 <?php
294 break;
295
296 case 'campaign_summary':
297 $headline = ! empty( $settings['headline'] ) ? $settings['headline'] : '';
298 $show_raised = (bool) ( $settings['show_raised'] ?? true );
299 $show_donors = (bool) ( $settings['show_donors'] ?? true );
300 $show_percent = (bool) ( $settings['show_percent'] ?? true );
301 $show_days = (bool) ( $settings['show_days'] ?? true );
302 $sm_width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
303 $sm_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
304 $goal = (float) ( $meta['bpc_goal_amount'] ?? 0 );
305 $percent = $goal > 0 ? min( 100, round( ( (float) $stats['total_raised'] / $goal ) * 100, 1 ) ) : 0;
306 $sm_currency = self::global_currency();
307 $sm_currency_sym = self::currency_symbol( $sm_currency );
308
309 $wrap_style = 'width:' . $sm_width . '%;';
310 if ( 'center' === $sm_align ) $wrap_style .= 'margin:0 auto;';
311 elseif ( 'right' === $sm_align ) $wrap_style .= 'margin-left:auto;';
312 ?>
313 <div class="bp-campaign-summary-wrap" style="<?php echo esc_attr( $wrap_style ); ?>">
314 <?php if ( $headline ) : ?>
315 <h3 class="bp-summary-headline"><?php echo esc_html( $headline ); ?></h3>
316 <?php endif; ?>
317 <div class="bp-campaign-summary">
318 <?php if ( $show_raised ) : ?>
319 <div class="bp-summary-item">
320 <strong><?php echo esc_html( $sm_currency_sym . number_format( (float) $stats['total_raised'], 2 ) ); ?></strong>
321 <span><?php esc_html_e( 'Raised', 'better-payment' ); ?></span>
322 </div>
323 <?php endif; ?>
324 <?php if ( $show_donors ) : ?>
325 <div class="bp-summary-item">
326 <strong><?php echo esc_html( $stats['donor_count'] ); ?></strong>
327 <span><?php esc_html_e( 'Donors', 'better-payment' ); ?></span>
328 </div>
329 <?php endif; ?>
330 <?php if ( $show_percent ) : ?>
331 <div class="bp-summary-item">
332 <strong><?php echo esc_html( $percent ); ?>%</strong>
333 <span><?php esc_html_e( 'Raised', 'better-payment' ); ?></span>
334 </div>
335 <?php endif; ?>
336 <?php if ( $show_days ) : ?>
337 <div class="bp-summary-item">
338 <strong><?php echo esc_html( is_null( $stats['days_remaining'] ) ? 0 : $stats['days_remaining'] ); ?></strong>
339 <span><?php esc_html_e( 'Days Left', 'better-payment' ); ?></span>
340 </div>
341 <?php endif; ?>
342 </div>
343 </div>
344 <?php
345 break;
346
347 case 'donation_form':
348 $button_label = ! empty( $settings['button_label'] )
349 ? $settings['button_label']
350 : ( ! empty( $settings['button_text'] ) ? $settings['button_text'] : __( 'Donate Now', 'better-payment' ) );
351 $primary = $meta['bpc_color_primary'] ?: '#6b63f6';
352 $button_color = sanitize_hex_color( $settings['button_color'] ?? '' ) ?: sanitize_hex_color( $primary ) ?: '#6b63f6';
353 $open_new_tab = ! empty( $settings['open_new_tab'] );
354 $width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
355 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true )
356 ? $settings['align'] : 'center';
357
358 // URL: element setting takes priority; fall back to campaign meta page ID, then '#' default.
359 $donate_url = '';
360 if ( ! empty( $settings['url'] ) ) {
361 $donate_url = esc_url( $settings['url'] );
362 } else {
363 $page_id = absint( $meta['bpc_form_page_id'] ?? 0 );
364 if ( $page_id ) {
365 $donate_url = esc_url( add_query_arg( 'campaign_id', $campaign_id, get_permalink( $page_id ) ) );
366 }
367 }
368 // Default the Payment Form Page URL to '#' so the button renders as a normal link.
369 $url_missing = false;
370 if ( ! $donate_url ) {
371 $donate_url = '#';
372 }
373
374 if ( $donate_url ) :
375 $min_amount = isset( $meta['bpc_minimum_amount'] ) && $meta['bpc_minimum_amount'] !== ''
376 ? (float) $meta['bpc_minimum_amount']
377 : 0;
378 $currency = self::global_currency();
379 $currency_symbol = self::currency_symbol( $currency );
380
381 $wrap_style = 'width:' . $width . '%;';
382 if ( 'center' === $align ) {
383 $wrap_style .= 'margin:0 auto;';
384 } elseif ( 'right' === $align ) {
385 $wrap_style .= 'margin-left:auto;';
386 }
387
388 $btn_class = 'bp-donate_btn' . ( $url_missing ? ' bp-donate_btn--no-url' : '' );
389 ?>
390 <div class="bp-campaign-donate-wrap">
391 <?php if ( $min_amount > 0 ) : ?>
392 <p class="bp-min-donation-notice" data-min="<?php echo esc_attr( $min_amount ); ?>">
393 <?php
394 printf(
395 /* translators: %s: formatted minimum amount with currency symbol */
396 esc_html__( 'The minimum donation for this campaign is %s.', 'better-payment' ),
397 esc_html( $currency_symbol . number_format( $min_amount, 2 ) )
398 );
399 ?>
400 </p>
401 <?php endif; ?>
402 <div class="bp-campaign-donate-btn" style="<?php echo esc_attr( $wrap_style ); ?>">
403 <a href="<?php echo $url_missing ? '#' : esc_url( $donate_url ); ?>"
404 class="<?php echo esc_attr( $btn_class ); ?>"
405 style="background-color:<?php echo esc_attr( $button_color ); ?>;"
406 <?php if ( $url_missing ) : ?>
407 aria-disabled="true"
408 title="<?php esc_attr_e( 'Payment page not configured', 'better-payment' ); ?>"
409 <?php else : ?>
410 <?php echo $open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : ''; ?>
411 <?php endif; ?>
412 >
413 <?php echo esc_html( $button_label ); ?>
414 </a>
415 </div>
416 </div>
417 <?php
418 endif;
419 break;
420
421 case 'organizer':
422 $creator_user_id = ! empty( $settings['creator_user_id'] )
423 ? (int) $settings['creator_user_id']
424 : (int) $post->post_author;
425 $role_title = ! empty( $settings['role_title'] ) ? $settings['role_title'] : __( 'Organizer', 'better-payment' );
426 $description = ! empty( $settings['description'] ) ? $settings['description'] : '';
427 $width = isset( $settings['width'] ) ? max( 10, min( 100, (int) $settings['width'] ) ) : 100;
428 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true )
429 ? $settings['align'] : 'left';
430
431 $creator = get_user_by( 'ID', $creator_user_id );
432 if ( ! $creator ) break;
433
434 $wrap_style = 'width:' . $width . '%;';
435 if ( 'center' === $align ) {
436 $wrap_style .= 'margin:0 auto;';
437 } elseif ( 'right' === $align ) {
438 $wrap_style .= 'margin-left:auto;';
439 }
440 ?>
441 <div class="bp-campaign-organizer" style="<?php echo esc_attr( $wrap_style ); ?>">
442 <div class="bp-organizer-avatar">
443 <?php echo get_avatar( $creator->user_email, 48 ); ?>
444 </div>
445 <div class="bp-organizer-info">
446 <span class="bp-organizer-name"><?php echo esc_html( $creator->display_name ); ?></span>
447 <span class="bp-organizer-role"><?php echo esc_html( $role_title ); ?></span>
448 <?php if ( $description ) : ?>
449 <div class="bp-organizer-description"><?php echo wp_kses_post( self::scale_inline_font_sizes( $description ) ); ?></div>
450 <?php endif; ?>
451 </div>
452 </div>
453 <?php
454 break;
455
456 case 'donate_amount':
457 $amounts_meta = $meta['bpc_suggested_amounts'] ?? [];
458 $allow_custom = (bool) ( $meta['bpc_allow_custom_amount'] ?? 1 );
459 $currency = self::global_currency();
460 $currency_symbol = self::currency_symbol( $currency );
461 $da_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Donate Amount', 'better-payment' );
462
463 // Fall back to legacy comma-string or defaults when meta is empty.
464 if ( empty( $amounts_meta ) ) {
465 $fallback = ! empty( $settings['preset_amounts'] ) ? $settings['preset_amounts'] : '10,25,50,100';
466 foreach ( array_filter( array_map( 'trim', explode( ',', $fallback ) ) ) as $a ) {
467 $amounts_meta[] = [ 'amount' => $a, 'is_default' => false ];
468 }
469 }
470 ?>
471 <div class="bp-campaign-donate">
472 <?php if ( $da_headline ) : ?>
473 <h3 class="bp-donate-headline"><?php echo esc_html( $da_headline ); ?></h3>
474 <?php endif; ?>
475 <div class="bp-donate_amounts">
476 <?php foreach ( $amounts_meta as $i => $item ) :
477 $amt = floatval( $item['amount'] ?? 0 );
478 $uid = 'bp_camt_' . $campaign_id . '_' . $i;
479 $is_default = ! empty( $item['is_default'] );
480 ?>
481 <input
482 type="radio"
483 class="bp-option-amount"
484 id="<?php echo esc_attr( $uid ); ?>"
485 name="option_amount_<?php echo esc_attr( $campaign_id ); ?>"
486 value="<?php echo esc_attr( $amt ); ?>"
487 <?php checked( $is_default ); ?>
488 hidden
489 />
490 <label for="<?php echo esc_attr( $uid ); ?>" class="bp-amount-label">
491 <?php echo esc_html( $currency_symbol . $amt ); ?>
492 </label>
493 <?php endforeach; ?>
494 </div>
495 <?php if ( $allow_custom ) : ?>
496 <div class="other_amount_section">
497 <span class="bp-amount-currency"><?php echo esc_html( $currency_symbol ); ?></span>
498 <input
499 type="number"
500 class="campaign-custom-amount"
501 min="0"
502 step="0.01"
503 placeholder="<?php esc_attr_e( 'Enter custom amount', 'better-payment' ); ?>"
504 />
505 </div>
506 <?php endif; ?>
507 </div>
508 <?php
509 break;
510
511 case 'social_sharing':
512 $sh_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Share Now', 'better-payment' );
513 $sh_open_new_tab = ! empty( $settings['open_new_tab'] );
514 $sh_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
515 $sh_page_url = get_permalink( $post );
516 $sh_title = rawurlencode( $post->post_title );
517 $sh_target = $sh_open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : '';
518 $sh_justify = [ 'center' => 'center', 'right' => 'flex-end' ][ $sh_align ] ?? 'flex-start';
519
520 $share_links = [
521 'twitter' => 'https://twitter.com/intent/tweet?url=' . rawurlencode( $sh_page_url ) . '&text=' . $sh_title,
522 'facebook' => 'https://www.facebook.com/sharer/sharer.php?u=' . rawurlencode( $sh_page_url ),
523 'linkedin' => 'https://www.linkedin.com/shareArticle?mini=true&url=' . rawurlencode( $sh_page_url ) . '&title=' . $sh_title,
524 'pinterest' => 'https://pinterest.com/pin/create/button/?url=' . rawurlencode( $sh_page_url ) . '&description=' . $sh_title,
525 'mastodon' => 'https://mastodonshare.com/?text=' . $sh_title . '&url=' . rawurlencode( $sh_page_url ),
526 'threads' => 'https://threads.net/intent/post?text=' . $sh_title . '%20' . rawurlencode( $sh_page_url ),
527 'bluesky' => 'https://bsky.app/intent/compose?text=' . $sh_title . '%20' . rawurlencode( $sh_page_url ),
528 ];
529
530 $active_sharing = array_filter( $share_links, function( $url, $key ) use ( $settings ) {
531 return ( $settings[ $key ] ?? true ) !== false;
532 }, ARRAY_FILTER_USE_BOTH );
533
534 if ( $active_sharing || $sh_headline ) :
535 ?>
536 <div class="bp-social-sharing" style="text-align:<?php echo esc_attr( $sh_align ); ?>;">
537 <?php if ( $sh_headline ) : ?>
538 <p class="bp-social-headline"><?php echo esc_html( $sh_headline ); ?></p>
539 <?php endif; ?>
540 <?php if ( $active_sharing ) : ?>
541 <div class="bp-social-icons" style="justify-content:<?php echo esc_attr( $sh_justify ); ?>;">
542 <?php foreach ( $active_sharing as $key => $share_url ) : ?>
543 <a href="<?php echo esc_url( $share_url ); ?>"
544 class="bp-social-icon"
545 <?php echo $sh_target; ?>
546 title="<?php echo esc_attr( ucfirst( $key ) ); ?>">
547 <?php echo self::social_icon_svg( $key ); ?>
548 </a>
549 <?php endforeach; ?>
550 </div>
551 <?php endif; ?>
552 </div>
553 <?php
554 endif;
555 break;
556
557 case 'social_links':
558 $sl_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Follow Now', 'better-payment' );
559 $sl_open_new_tab = ! empty( $settings['open_new_tab'] );
560 $sl_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
561 $sl_target = $sl_open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : '';
562 $sl_justify = [ 'center' => 'center', 'right' => 'flex-end' ][ $sl_align ] ?? 'flex-start';
563
564 $all_link_keys = [ 'twitter', 'facebook', 'linkedin', 'instagram', 'tiktok', 'pinterest', 'youtube', 'threads', 'bluesky', 'mastodon' ];
565 $active_links = [];
566 foreach ( $all_link_keys as $key ) {
567 if ( ! empty( $settings[ $key ] ) ) {
568 $active_links[ $key ] = $settings[ $key ];
569 }
570 }
571
572 if ( $active_links || $sl_headline ) :
573 ?>
574 <div class="bp-social-links" style="text-align:<?php echo esc_attr( $sl_align ); ?>;">
575 <?php if ( $sl_headline ) : ?>
576 <p class="bp-social-headline"><?php echo esc_html( $sl_headline ); ?></p>
577 <?php endif; ?>
578 <?php if ( $active_links ) : ?>
579 <div class="bp-social-icons" style="justify-content:<?php echo esc_attr( $sl_justify ); ?>;">
580 <?php foreach ( $active_links as $key => $url ) : ?>
581 <a href="<?php echo esc_url( $url ); ?>"
582 class="bp-social-icon"
583 <?php echo $sl_target; ?>
584 title="<?php echo esc_attr( ucfirst( $key ) ); ?>">
585 <?php echo self::social_icon_svg( $key ); ?>
586 </a>
587 <?php endforeach; ?>
588 </div>
589 <?php endif; ?>
590 </div>
591 <?php
592 endif;
593 break;
594 }
595
596 $html = ob_get_clean();
597
598 if ( ! $html || ! $el_id ) {
599 return $html;
600 }
601
602 return '<div class="bp-element-wrap" data-bp-element-id="' . esc_attr( $el_id ) . '">' . $html . '</div>';
603 }
604
605 /**
606 * Normalize raw stored layout to the column schema.
607 * Handles both old flat-array format and new column format.
608 *
609 * @param mixed $raw Value from MetaBox::get_all()['bpc_fields_layout'].
610 * @return array Normalized layout with 'layout' and 'columns' keys.
611 */
612 public static function normalize_layout( $raw ): array {
613 if ( is_array( $raw ) && isset( $raw['columns'] ) ) {
614 return $raw;
615 }
616
617 // Legacy flat format: wrap all elements into a single column.
618 $elements = is_array( $raw ) ? $raw : [];
619 return [
620 'layout' => '1-column',
621 'columns' => [
622 [
623 'id' => 'main',
624 'label' => 'Main Content',
625 'width' => '100%',
626 'elements' => $elements,
627 ],
628 ],
629 ];
630 }
631
632 /**
633 * Default layout used when no layout is saved (campaign has no fields yet).
634 *
635 * @return array
636 */
637 private static function social_icon_svg( string $network ): string {
638 $paths = [
639 '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',
640 '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',
641 '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',
642 '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',
643 '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',
644 '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',
645 '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',
646 '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',
647 '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',
648 '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',
649 ];
650 if ( ! isset( $paths[ $network ] ) ) {
651 return '';
652 }
653 return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="' . esc_attr( $paths[ $network ] ) . '"/></svg>';
654 }
655
656 /**
657 * Scale up inline px font-sizes in HTML content saved by the rich text editor.
658 * Adds 2px to every explicit pixel font-size so the text matches the campaign's
659 * base 16px scale (editor default is 14px).
660 */
661 private static function scale_inline_font_sizes( string $html ): string {
662 return preg_replace_callback(
663 '/\bfont-size\s*:\s*(\d+(?:\.\d+)?)px/i',
664 function ( $m ) {
665 return 'font-size: ' . ( (float) $m[1] + 2 ) . 'px';
666 },
667 $html
668 );
669 }
670
671 /**
672 * Returns the global Better Payment currency code from plugin settings.
673 */
674 private static function global_currency(): string {
675 $code = DB::get_settings( 'better_payment_settings_general_general_currency' );
676 return ( is_string( $code ) && $code !== '' ) ? $code : 'USD';
677 }
678
679 private static function currency_symbol( string $code ): string {
680 $map = [
681 'USD' => '$', 'EUR' => '', 'GBP' => '£', 'JPY' => '¥',
682 'CAD' => 'CA$', 'AUD' => 'A$', 'INR' => '', 'BRL' => 'R$',
683 'MXN' => 'MX$', 'SGD' => 'S$', 'CHF' => 'CHF', 'SEK' => 'kr',
684 'NOK' => 'kr', 'DKK' => 'kr', 'NZD' => 'NZ$', 'ZAR' => 'R',
685 'BDT' => '', 'PKR' => '', 'NGN' => '', 'KES' => 'KSh',
686 ];
687 return $map[ strtoupper( $code ) ] ?? $code;
688 }
689
690 /**
691 * Render a template definition as HTML for the picker iframe preview.
692 *
693 * @param string $key Template key from TemplateManager.
694 * @return string HTML fragment, or empty string if key not found.
695 */
696 public static function render_template_preview( string $key ): string {
697 $templates = TemplateManager::get_all();
698 if ( ! isset( $templates[ $key ] ) ) {
699 return '';
700 }
701 $template = $templates[ $key ];
702 $columns = $template['columns'] ?? [];
703 $layout = $template['layout'] ?? '1-column';
704
705 $first_users = get_users( [ 'fields' => [ 'ID' ], 'number' => 1 ] );
706 $first_creator_id = ! empty( $first_users ) ? (int) $first_users[0]->ID : get_current_user_id();
707
708 $fake_post = new \WP_Post( (object) [
709 'ID' => 0,
710 'post_title' => $template['default_title'] ?? $template['label'] ?? 'Campaign Preview',
711 'post_content' => '',
712 'post_author' => $first_creator_id,
713 'post_type' => 'bp_campaign',
714 'post_status' => 'publish',
715 'post_name' => $key,
716 ] );
717
718 $meta = [
719 'bpc_goal_amount' => 10000,
720 'bpc_color_primary' => $template['preview_color'] ?? '#6b63f6',
721 'bpc_color_button' => '',
722 'bpc_suggested_amounts' => [],
723 'bpc_allow_custom_amount' => true,
724 'bpc_minimum_amount' => '',
725 'bpc_form_page_id' => 0,
726 'bpc_status' => 'active',
727 'bpc_template_key' => $key,
728 'bpc_css_class' => '',
729 ];
730
731 $stats = [
732 'total_raised' => 3750,
733 'progress' => 37.5,
734 'donor_count' => 42,
735 'days_remaining' => 18,
736 ];
737
738 $theme_class = isset( $template['theme_class'] ) ? ' ' . sanitize_html_class( $template['theme_class'] ) : '';
739
740 ob_start();
741 ?>
742 <div class="bp-campaign bp-campaign--<?php echo esc_attr( $layout ); ?> better-payment<?php echo esc_attr( $theme_class ); ?>">
743 <div class="bp-campaign-columns">
744 <?php foreach ( $columns as $column ) :
745 $col_style = 'width: ' . esc_attr( $column['width'] ?? '100%' ) . ';';
746 if ( ! empty( $column['style'] ) ) {
747 $col_style .= ' ' . esc_attr( $column['style'] );
748 }
749 ?>
750 <div class="bp-campaign-column"
751 style="<?php echo $col_style; ?>">
752 <?php
753 foreach ( $column['elements'] as $element ) {
754 echo self::render_element( $element, 0, $fake_post, $meta, $stats );
755 }
756 ?>
757 </div>
758 <?php endforeach; ?>
759 </div>
760 </div>
761 <?php
762 return ob_get_clean();
763 }
764
765 /**
766 * Build a full HTML preview document for the builder's live-preview iframe.
767 *
768 * Each element is wrapped in a <div data-bp-element-id> so the JS overlay
769 * can measure positions and wire up hover/drag interactions. Works for both
770 * new unsaved campaigns (campaign_id = 0) and existing ones.
771 *
772 * @param array $layout_data Builder layout: { layout, columns }.
773 * @param array $meta_input Campaign meta from the builder store (includes 'title').
774 * @param int $campaign_id 0 for new campaigns; real ID to pull live stats.
775 * @return string Full HTML document string.
776 */
777 public static function build_preview_document(
778 array $layout_data,
779 array $meta_input,
780 int $campaign_id = 0
781 ): string {
782 // ── Post + stats ──────────────────────────────────────────────────────
783 $post = null;
784 $stats = null;
785
786 if ( $campaign_id > 0 ) {
787 $real = get_post( $campaign_id );
788 if ( $real && $real->post_type === 'bp_campaign' ) {
789 $post = $real;
790 $stats = CampaignStats::get_stats( $campaign_id );
791 }
792 }
793
794 if ( ! $post ) {
795 $post = new \WP_Post( (object) [
796 'ID' => 0,
797 'post_title' => sanitize_text_field( $meta_input['title'] ?? 'Campaign Preview' ),
798 'post_content' => '',
799 'post_author' => get_current_user_id(),
800 'post_type' => 'bp_campaign',
801 'post_status' => 'publish',
802 'post_name' => 'preview',
803 ] );
804 $stats = [ 'total_raised' => 0, 'progress' => 0, 'donor_count' => 0, 'days_remaining' => null ];
805 } else {
806 // Always reflect the current editor title, even for saved campaigns.
807 $override = sanitize_text_field( $meta_input['title'] ?? '' );
808 if ( $override !== '' ) {
809 $post->post_title = $override;
810 }
811 }
812
813 // Override stats fields that depend on unsaved builder meta so the preview
814 // reflects the current editor values without requiring a save first.
815 $preview_end_date = $meta_input['bpc_end_date'] ?? '';
816 if ( $preview_end_date !== '' ) {
817 $diff = strtotime( $preview_end_date ) - current_time( 'timestamp' );
818 $stats['days_remaining'] = max( 0, (int) ceil( $diff / DAY_IN_SECONDS ) );
819 } else {
820 $stats['days_remaining'] = null;
821 }
822
823 $preview_goal = (float) ( $meta_input['bpc_goal_amount'] ?? 0 );
824 if ( $preview_goal > 0 ) {
825 $stats['progress'] = min( 100.0, round( ( $stats['total_raised'] / $preview_goal ) * 100, 1 ) );
826 }
827
828 // ── Meta ─────────────────────────────────────────────────────────────
829 $meta_defaults = [
830 'bpc_goal_amount' => 0,
831 'bpc_color_primary' => '#6b63f6',
832 'bpc_color_button' => '',
833 'bpc_suggested_amounts' => [],
834 'bpc_allow_custom_amount' => true,
835 'bpc_minimum_amount' => '',
836 'bpc_form_page_id' => 0,
837 'bpc_status' => 'active',
838 'bpc_template_key' => '',
839 'bpc_css_class' => '',
840 ];
841
842 if ( $campaign_id > 0 ) {
843 $meta = array_merge( MetaBox::get_all( $campaign_id ), $meta_defaults, $meta_input );
844 } else {
845 $meta = array_merge( $meta_defaults, $meta_input );
846 }
847
848 // ── Layout ───────────────────────────────────────────────────────────
849 $columns = $layout_data['columns'] ?? [];
850 $layout = $layout_data['layout'] ?? '1-column';
851
852 if ( empty( $columns ) ) {
853 $default = self::default_layout();
854 $columns = $default['columns'];
855 $layout = $default['layout'];
856 }
857
858 // ── Template theme class ──────────────────────────────────────────────
859 $template_key = $meta['bpc_template_key'] ?? '';
860 $all_templates = TemplateManager::get_all();
861 $theme_class = ( $template_key && isset( $all_templates[ $template_key ]['theme_class'] ) )
862 ? ' ' . sanitize_html_class( $all_templates[ $template_key ]['theme_class'] )
863 : '';
864
865 // ── Campaign HTML with element-id wrappers ────────────────────────────
866 $color_style = self::generate_color_style( $meta, $campaign_id );
867
868 ob_start();
869 ?>
870 <div class="bp-campaign bp-campaign--<?php echo esc_attr( $layout ); ?> better-payment<?php echo esc_attr( $theme_class ); ?>"
871 data-campaign-id="<?php echo esc_attr( $campaign_id ); ?>">
872 <?php echo wp_kses( $color_style, [ 'style' => [] ] ); ?>
873 <input type="hidden" class="better_payment_campaign_id"
874 value="<?php echo esc_attr( $campaign_id ); ?>">
875 <input type="hidden" class="better_payment_campaign_currency"
876 value="<?php echo esc_attr( self::global_currency() ); ?>">
877 <div class="bp-campaign-columns">
878 <?php foreach ( $columns as $column ) :
879 $is_empty = empty( $column['elements'] );
880 $col_class = 'bp-campaign-column' . ( $is_empty ? ' bp-col-empty' : '' );
881 $raw_width = $column['width'] ?? '100%';
882 $col_width = preg_match( '/^\d{1,3}(\.\d+)?%$/', $raw_width ) ? $raw_width : '100%';
883 $col_style = 'width: ' . $col_width . ';';
884 ?>
885 <div class="<?php echo esc_attr( $col_class ); ?>"
886 data-bp-column-id="<?php echo esc_attr( $column['id'] ); ?>"
887 style="<?php echo esc_attr( $col_style ); ?>">
888 <?php foreach ( $column['elements'] as $element ) : ?>
889 <div data-bp-element-id="<?php echo esc_attr( $element['id'] ?? '' ); ?>"
890 data-bp-column-id="<?php echo esc_attr( $column['id'] ); ?>"
891 class="bp-builder-el-wrap">
892 <?php echo self::render_element( $element, $campaign_id, $post, $meta, $stats ); ?>
893 </div>
894 <?php endforeach; ?>
895 </div>
896 <?php endforeach; ?>
897 </div>
898 </div>
899 <?php
900 $campaign_html = ob_get_clean();
901
902 // ── Assemble full HTML document ───────────────────────────────────────
903 // Mirror exactly what the frontend enqueues (single-bp_campaign.php +
904 // Shortcode::enqueue_frontend_styles): both campaign-display CSS and
905 // fundraising-campaign CSS are required for pixel-perfect parity.
906 $v = BETTER_PAYMENT_VERSION;
907 $display_css = BETTER_PAYMENT_ASSETS . '/blocks/campaign-display/style.min.css';
908 $fundraising_css = BETTER_PAYMENT_ASSETS . '/css/fundraising-campaign.min.css';
909
910 $extra_link = file_exists( BETTER_PAYMENT_ASSETS_PATH . '/css/fundraising-campaign.min.css' )
911 ? '<link rel="stylesheet" href="' . esc_url( $fundraising_css ) . '?v=' . $v . '">' . "\n"
912 : '';
913
914 return '<!DOCTYPE html>' . "\n"
915 . '<html>' . "\n"
916 . '<head>' . "\n"
917 . '<meta charset="utf-8">' . "\n"
918 . '<meta name="viewport" content="width=device-width, initial-scale=1">' . "\n"
919 . '<base href="' . esc_url( home_url( '/' ) ) . '">' . "\n"
920 . '<link rel="stylesheet" href="' . esc_url( $display_css ) . '?v=' . $v . '">' . "\n"
921 . $extra_link
922 . '<style>' . "\n"
923 . '*, *::before, *::after { box-sizing: border-box; }' . "\n"
924 . 'html, body { margin: 0; padding: 0; background: #fff; }' . "\n"
925 . ( $theme_class ? 'body { padding: 0 24px 16px; }' . "\n" : '' )
926 . '.bp-builder-el-wrap { position: relative; }' . "\n"
927 // Empty columns need a minimum height so the overlay ColumnDropZone can
928 // measure them and render the dashed drop-zone border at the correct size.
929 . '.bp-campaign-column.bp-col-empty { min-height: 160px; }' . "\n"
930 . '.bp-campaign-columns[style*="stretch"] .bp-builder-el-wrap {' . "\n"
931 . ' height: 100%;' . "\n"
932 . ' display: flex;' . "\n"
933 . ' flex-direction: column;' . "\n"
934 . '}' . "\n"
935 . '</style>' . "\n"
936 . '</head>' . "\n"
937 . '<body>' . "\n"
938 . preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $campaign_html ) . "\n"
939 . '</body>' . "\n"
940 . '</html>';
941 }
942
943 private static function default_layout(): array {
944 return [
945 'layout' => '2-column',
946 'columns' => [
947 [
948 'id' => 'main',
949 'label' => 'Main Content',
950 'width' => '65%',
951 'elements' => [
952 [ 'id' => 'def_photo', 'type' => 'photo', 'settings' => [] ],
953 [ 'id' => 'def_title', 'type' => 'campaign_title', 'settings' => [] ],
954 [ 'id' => 'def_desc', 'type' => 'campaign_description', 'settings' => [] ],
955 ],
956 ],
957 [
958 'id' => 'sidebar',
959 'label' => 'Sidebar',
960 'width' => '35%',
961 'elements' => [
962 [ 'id' => 'def_progress', 'type' => 'progress_bar', 'settings' => [] ],
963 [ 'id' => 'def_summary', 'type' => 'campaign_summary', 'settings' => [] ],
964 [ 'id' => 'def_donate', 'type' => 'donation_form', 'settings' => [] ],
965 ],
966 ],
967 ],
968 ];
969 }
970 }
971