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

1,518 lines 80.5 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 use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
10 use Better_Payment\Lite\Campaign\Elements\ProElementPreview;
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Renders campaign HTML from the column-based layout schema.
18 *
19 * Supports both the current column schema and the legacy flat-array format
20 * (auto-migrated to a single 1-column layout on render).
21 *
22 * Used by:
23 * - Shortcode::render_campaign()
24 * - CampaignAPI preview endpoint
25 * - CampaignBlock server-side render
26 */
27 class RendererService {
28
29 /**
30 * Render a full campaign.
31 *
32 * @param int $campaign_id The campaign post ID.
33 * @param bool $is_preview True when rendering a preview (skips publish check).
34 * @param array|null $preview_layout Override layout JSON (used by preview endpoint).
35 * @return string HTML output.
36 */
37 public static function render_campaign(
38 int $campaign_id,
39 bool $is_preview = false,
40 ?array $preview_layout = null
41 ): string {
42 $post = get_post( $campaign_id );
43 if ( ! $post || $post->post_type !== 'bp_campaign' ) {
44 return '';
45 }
46
47 $meta = MetaBox::get_all( $campaign_id );
48 $stats = CampaignStats::get_stats( $campaign_id );
49
50 $template_key = $meta['bpc_template_key'] ?? '';
51 $all_templates = TemplateManager::get_all();
52 $theme_class = ( $template_key && isset( $all_templates[ $template_key ]['theme_class'] ) )
53 ? ' ' . sanitize_html_class( $all_templates[ $template_key ]['theme_class'] )
54 : '';
55
56 if ( $is_preview && $preview_layout !== null ) {
57 $layout_data = $preview_layout;
58 } else {
59 $raw = $meta['bpc_fields_layout'] ?? [];
60 $layout_data = self::normalize_layout( $raw );
61 }
62
63 $columns = $layout_data['columns'] ?? [];
64 $layout = $layout_data['layout'] ?? '1-column';
65
66 // Only fall back to default when there are literally no columns.
67 // Blank templates (1-col, 2-col, 3-col) have columns with empty elements arrays —
68 // that is intentional and must render as blank to match the editor and preview.
69 if ( empty( $columns ) ) {
70 $layout_data = self::default_layout();
71 $columns = $layout_data['columns'];
72 $layout = $layout_data['layout'];
73 }
74
75 $color_style = self::generate_color_style( $meta, $campaign_id );
76
77 ob_start();
78 ?>
79 <div class="bp-campaign bp-campaign--<?php echo esc_attr( $layout ); ?> better-payment<?php echo esc_attr( $theme_class ); ?>"
80 data-campaign-id="<?php echo esc_attr( $campaign_id ); ?>">
81 <?php echo wp_kses( $color_style, [ 'style' => [] ] ); ?>
82 <input type="hidden" class="better_payment_campaign_id"
83 value="<?php echo esc_attr( $campaign_id ); ?>">
84 <input type="hidden" class="better_payment_campaign_currency"
85 value="<?php echo esc_attr( self::global_currency() ); ?>">
86 <div class="bp-campaign-columns">
87 <?php foreach ( $columns as $column ) :
88 $raw_width = $column['width'] ?? '100%';
89 $col_width = preg_match( '/^\d{1,3}(\.\d+)?%$/', $raw_width ) ? $raw_width : '100%';
90 $col_style = 'width: ' . $col_width . ';';
91 ?>
92 <div class="bp-campaign-column"
93 style="<?php echo esc_attr( $col_style ); ?>">
94 <?php
95 foreach ( $column['elements'] as $element ) {
96 echo self::render_element( $element, $campaign_id, $post, $meta, $stats );
97 }
98 ?>
99 </div>
100 <?php endforeach; ?>
101 </div>
102 </div>
103 <?php
104 return ob_get_clean();
105 }
106
107 /**
108 * Generate a scoped <style> block for campaign colour customisation.
109 *
110 * Only emits rules for colours that are explicitly set, so theme CSS remains
111 * the default when a colour is empty. Uses !important to override any theme class.
112 *
113 * @param array $meta Campaign meta (bpc_color_primary, background, …).
114 * @param int $campaign_id Scopes the selectors. 0 = isolated builder preview iframe.
115 * @return string <style>…</style> or ''.
116 */
117 private static function generate_color_style( array $meta, int $campaign_id ): string {
118 // Campaign background colour (set in the builder's Advanced tab). The
119 // per-button colour is now a property of the Donate Button widget
120 // (settings['button_color']), not a campaign-wide override.
121 $background = ! empty( $meta['bpc_color_background'] ) ? sanitize_hex_color( $meta['bpc_color_background'] ) : '';
122
123 if ( ! $background ) {
124 return '';
125 }
126
127 // $campaign_id is typed int and $background is validated hex — both safe for CSS output.
128 $scope = $campaign_id > 0
129 ? '.bp-campaign[data-campaign-id="' . $campaign_id . '"]'
130 : '.bp-campaign';
131
132 $css = $scope . ' { background-color: ' . $background . ' !important; }';
133
134 return '<style>' . wp_strip_all_tags( $css ) . '</style>';
135 }
136
137 /**
138 * Render a single element by type.
139 *
140 * @param array $element Element definition with type, settings.
141 * @param int $campaign_id
142 * @param \WP_Post $post
143 * @param array $meta Campaign meta from MetaBox::get_all().
144 * @param array $stats Campaign stats from CampaignStats::get_stats().
145 * @param bool $is_preview True when rendering for the builder canvas or the
146 * template picker rather than a live campaign page.
147 * Passed through to add-on elements as `$ctx['is_preview']`
148 * so they can stand in sample content — data-driven
149 * elements otherwise render an empty state in the
150 * builder, leaving their settings with no visible
151 * effect. Never true on the frontend.
152 * @return string HTML output.
153 */
154 public static function render_element(
155 array $element,
156 int $campaign_id,
157 \WP_Post $post,
158 array $meta,
159 array $stats,
160 bool $is_preview = false
161 ): string {
162 $type = $element['type'] ?? '';
163 $settings = $element['settings'] ?? [];
164 $el_id = $element['id'] ?? '';
165
166 ob_start();
167
168 switch ( $type ) {
169
170 case 'campaign_title':
171 $user_color = self::css_hex_color( $settings['color'] ?? '' );
172 $user_size = self::css_font_size( $settings['font_size'] ?? '' );
173 // isset(), not ! empty(): clearing the field is an instruction, not
174 // an accident. Only a title that was never set at all falls back to
175 // the campaign name — an emptied one stays empty (and the builder
176 // shows a placeholder in its place so it can still be selected).
177 $title_text = isset( $settings['title'] ) ? (string) $settings['title'] : $post->post_title;
178 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
179
180 // A user-set value is emitted with !important so it wins over
181 // template rules (some templates force the title colour/size with
182 // !important). When unset we emit a plain default the template can
183 // still override.
184 $title_style = '' !== $user_color ? 'color:' . $user_color . ' !important;' : 'color:#1a1a2e;';
185 $title_style .= '' !== $user_size ? 'font-size:' . $user_size . ' !important;' : 'font-size:32px;';
186 $title_style .= self::decls_to_style( self::typography_common_decls( $settings ) );
187 $title_style .= 'text-align:' . $align . ';';
188 ?>
189 <h2 class="bp-campaign-title" style="<?php echo esc_attr( $title_style ); ?>">
190 <?php echo esc_html( $title_text ); ?>
191 </h2>
192 <?php
193 break;
194
195 case 'campaign_description':
196 // isset(), not ! empty(), for the same reason as the title above:
197 // clearing the body means "no body", not "show the post content".
198 $desc_headline = isset( $settings['headline'] ) ? (string) $settings['headline'] : '';
199 $desc_content = isset( $settings['content'] ) ? (string) $settings['content'] : $post->post_content;
200 $desc_width = self::resolve_element_width( $settings );
201 $desc_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
202
203 if ( ! $desc_headline && ! $desc_content ) break;
204
205 // Wrapper carries layout only. Typography is applied directly to the
206 // headline and content elements (with !important) so it beats template
207 // rules that target those elements specifically. The headline (title)
208 // and the body each have their own independent typography set.
209 $desc_wrap = 'width:' . $desc_width . '%;';
210 if ( 'center' === $desc_align ) $desc_wrap .= 'margin:0 auto;';
211 elseif ( 'right' === $desc_align ) $desc_wrap .= 'margin-left:auto;';
212
213 // Title (headline) typography — its own set under the `title_`
214 // prefixed keys. font-size IS applied here so the title can be
215 // sized directly (empty leaves the template heading scale).
216 $title_typo = self::prefixed_typography( $settings, 'title_' );
217 $title_color = self::css_hex_color( $title_typo['color'] ?? '' );
218 $title_size = self::css_font_size( $title_typo['font_size'] ?? '' );
219 $desc_headline_style = self::decls_to_style( self::typography_common_decls( $title_typo ) );
220 if ( '' !== $title_color ) $desc_headline_style .= 'color:' . $title_color . ' !important;';
221 if ( '' !== $title_size ) $desc_headline_style .= 'font-size:' . $title_size . ' !important;';
222
223 // Body (content) typography — the base (unprefixed) keys, so any
224 // previously-saved description typography still applies here.
225 $desc_color = self::css_hex_color( $settings['color'] ?? '' );
226 $desc_size = self::css_font_size( $settings['font_size'] ?? '' );
227 $desc_content_style = self::decls_to_style( self::typography_common_decls( $settings ) );
228 if ( '' !== $desc_color ) $desc_content_style .= 'color:' . $desc_color . ' !important;';
229 if ( '' !== $desc_size ) $desc_content_style .= 'font-size:' . $desc_size . ' !important;';
230 ?>
231 <div class="bp-campaign-description"
232 style="text-align:<?php echo esc_attr( $desc_align ); ?>; <?php echo esc_attr( $desc_wrap ); ?>">
233 <?php if ( $desc_headline ) : ?>
234 <h3 class="bp-campaign-description-headline"<?php echo '' !== $desc_headline_style ? ' style="' . esc_attr( $desc_headline_style ) . '"' : ''; ?>><?php echo esc_html( $desc_headline ); ?></h3>
235 <?php endif; ?>
236 <?php if ( $desc_content ) : ?>
237 <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>
238 <?php endif; ?>
239 </div>
240 <?php
241 break;
242
243 case 'photo':
244 $src_id = isset( $settings['src_id'] ) ? (int) $settings['src_id'] : 0;
245 $size = ! empty( $settings['size'] ) ? $settings['size'] : 'full';
246 $allowed_sizes = [ 'thumbnail', 'medium', 'medium_large', 'large', 'full' ];
247 if ( ! in_array( $size, $allowed_sizes, true ) ) {
248 $size = 'full';
249 }
250
251 // Fallback: resolve attachment ID from URL for elements without a stored src_id.
252 if ( $src_id === 0 && ! empty( $settings['src'] ) ) {
253 $src_id = (int) attachment_url_to_postid( $settings['src'] );
254 }
255
256 $src = '';
257 if ( $src_id > 0 ) {
258 $img_data = wp_get_attachment_image_src( $src_id, $size );
259 $src = $img_data ? $img_data[0] : '';
260 }
261 if ( ! $src ) {
262 $src = ! empty( $settings['src'] ) ? $settings['src'] : get_the_post_thumbnail_url( $campaign_id, $size );
263 }
264 $alt_text = ! empty( $settings['alt'] ) ? $settings['alt'] : $post->post_title;
265 $ph_width = self::resolve_element_width( $settings );
266 $ph_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'center';
267
268 if ( $src ) :
269 $img_style = 'width:auto;max-width:' . $ph_width . '%;height:auto;display:block;border-radius:4px;';
270 if ( 'center' === $ph_align ) $img_style .= 'margin:0 auto;';
271 elseif ( 'right' === $ph_align ) $img_style .= 'margin-left:auto;';
272 ?>
273 <div class="bp-campaign-photo">
274 <img src="<?php echo esc_url( $src ); ?>"
275 alt="<?php echo esc_attr( $alt_text ); ?>"
276 class="bp-campaign-image"
277 style="<?php echo esc_attr( $img_style ); ?>" />
278 </div>
279 <?php
280 else : ?>
281 <div class="bp-campaign-photo-placeholder">
282 <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="#bbb">
283 <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"/>
284 </svg>
285 </div>
286 <?php endif;
287 break;
288
289 case 'progress_bar':
290 $raised = (float) $stats['total_raised'];
291 $goal = (float) ( $meta['bpc_goal_amount'] ?? 0 );
292 $currency = self::global_currency();
293 $progress = $stats['progress'];
294 $primary = $meta['bpc_color_primary'] ?: '#6b63f6';
295 $headline = ! empty( $settings['headline'] ) ? $settings['headline'] : '';
296 $show_donated = isset( $settings['show_donated'] ) ? (bool) $settings['show_donated'] : true;
297 $show_goal = isset( $settings['show_goal'] ) ? (bool) $settings['show_goal'] : true;
298 $round_amounts = (bool) ( $settings['round_amounts'] ?? false );
299 $donate_label = ! empty( $settings['donate_label'] ) ? $settings['donate_label'] : __( 'Donated:', 'better-payment' );
300 $goal_label = ! empty( $settings['goal_label'] ) ? $settings['goal_label'] : __( 'Goal:', 'better-payment' );
301 $width = self::resolve_element_width( $settings );
302 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
303 $currency_sym = self::currency_symbol( $currency );
304
305 // Align the block itself within its column via auto margins — the
306 // same width/margin pattern every other element uses. (A bare
307 // `justify-content` did nothing here: `.bp-campaign-progress` is
308 // not a flex container, so center/right never took effect.)
309 $wrap_style = 'width:' . $width . '%;';
310 if ( 'center' === $align ) {
311 $wrap_style .= 'margin:0 auto;';
312 } elseif ( 'right' === $align ) {
313 $wrap_style .= 'margin-left:auto;';
314 }
315
316 // Progress label: ceiling (rounded up integer) when round_amounts, else 1 decimal place.
317 if ( $round_amounts ) {
318 $display_progress = (int) ceil( $goal > 0 ? min( 100, ( $raised / $goal ) * 100 ) : 0 );
319 $goal_fmt = number_format( (int) ceil( $goal ) );
320 } else {
321 $display_progress = $progress; // 1 decimal float from CampaignStats
322 $goal_fmt = number_format( $goal, 2 );
323 }
324
325 ?>
326 <div class="bp-campaign-progress"
327 style="<?php echo esc_attr( $wrap_style ); ?>">
328 <?php if ( $headline ) : ?>
329 <h3 class="bp-progress-headline"><?php echo esc_html( $headline ); ?></h3>
330 <?php endif; ?>
331 <div class="bp-progress-bar-wrap">
332 <div class="bp-progress-bar"
333 style="width:<?php echo esc_attr( $progress ); ?>%;
334 background-color:<?php echo esc_attr( $primary ); ?>;"></div>
335 </div>
336 <?php if ( $show_donated || $show_goal ) : ?>
337 <div class="bp-progress-labels">
338 <?php if ( $show_donated ) : ?>
339 <span class="bp-progress-donated">
340 <?php echo esc_html( $donate_label . ' ' . $display_progress . '%' ); ?>
341 </span>
342 <?php endif; ?>
343 <?php if ( $show_goal ) : ?>
344 <span class="bp-progress-goal">
345 <?php echo esc_html( $goal_label . ' ' . $currency_sym . $goal_fmt ); ?>
346 </span>
347 <?php endif; ?>
348 </div>
349 <?php endif; ?>
350 </div>
351 <?php
352 break;
353
354 case 'campaign_summary':
355 $headline = ! empty( $settings['headline'] ) ? $settings['headline'] : '';
356 $show_raised = (bool) ( $settings['show_raised'] ?? true );
357 $show_donors = (bool) ( $settings['show_donors'] ?? true );
358 $show_percent = (bool) ( $settings['show_percent'] ?? true );
359 $show_days = (bool) ( $settings['show_days'] ?? true );
360 $sm_width = self::resolve_element_width( $settings );
361 $sm_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
362 $goal = (float) ( $meta['bpc_goal_amount'] ?? 0 );
363 $percent = $goal > 0 ? min( 100, round( ( (float) $stats['total_raised'] / $goal ) * 100, 1 ) ) : 0;
364 $sm_currency = self::global_currency();
365 $sm_currency_sym = self::currency_symbol( $sm_currency );
366
367 $wrap_style = 'width:' . $sm_width . '%;';
368 if ( 'center' === $sm_align ) $wrap_style .= 'margin:0 auto;';
369 elseif ( 'right' === $sm_align ) $wrap_style .= 'margin-left:auto;';
370 ?>
371 <div class="bp-campaign-summary-wrap" style="<?php echo esc_attr( $wrap_style ); ?>">
372 <?php if ( $headline ) : ?>
373 <h3 class="bp-summary-headline"><?php echo esc_html( $headline ); ?></h3>
374 <?php endif; ?>
375 <div class="bp-campaign-summary">
376 <?php if ( $show_raised ) : ?>
377 <div class="bp-summary-item">
378 <strong><?php echo esc_html( $sm_currency_sym . number_format( (float) $stats['total_raised'], 2 ) ); ?></strong>
379 <span><?php esc_html_e( 'Raised', 'better-payment' ); ?></span>
380 </div>
381 <?php endif; ?>
382 <?php if ( $show_donors ) : ?>
383 <div class="bp-summary-item">
384 <strong><?php echo esc_html( $stats['donor_count'] ); ?></strong>
385 <span><?php esc_html_e( 'Donors', 'better-payment' ); ?></span>
386 </div>
387 <?php endif; ?>
388 <?php if ( $show_percent ) : ?>
389 <div class="bp-summary-item">
390 <strong><?php echo esc_html( $percent ); ?>%</strong>
391 <span><?php esc_html_e( 'Raised', 'better-payment' ); ?></span>
392 </div>
393 <?php endif; ?>
394 <?php if ( $show_days ) : ?>
395 <div class="bp-summary-item">
396 <strong><?php echo esc_html( is_null( $stats['days_remaining'] ) ? 0 : $stats['days_remaining'] ); ?></strong>
397 <span><?php esc_html_e( 'Days Left', 'better-payment' ); ?></span>
398 </div>
399 <?php endif; ?>
400 </div>
401 </div>
402 <?php
403 break;
404
405 case 'donation_form':
406 // isset(), not ! empty(): an emptied label means "no label", and
407 // must not silently come back as "Donate Now". Only a label that was
408 // never set falls back — first to the legacy `button_text` key, then
409 // to the default. (`button_text` is the pre-rename key; keep reading
410 // it so campaigns built before the rename still show their label.)
411 if ( isset( $settings['button_label'] ) ) {
412 $button_label = (string) $settings['button_label'];
413 } elseif ( isset( $settings['button_text'] ) ) {
414 $button_label = (string) $settings['button_text'];
415 } else {
416 $button_label = __( 'Donate Now', 'better-payment' );
417 }
418 $primary = $meta['bpc_color_primary'] ?: '#6b63f6';
419 $button_color = sanitize_hex_color( $settings['button_color'] ?? '' ) ?: sanitize_hex_color( $primary ) ?: '#6b63f6';
420 $open_new_tab = ! empty( $settings['open_new_tab'] );
421 $width = self::resolve_element_width( $settings );
422 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true )
423 ? $settings['align'] : 'center';
424
425 // URL: element setting takes priority; fall back to campaign meta page ID, then '#' default.
426 $donate_url = '';
427 if ( ! empty( $settings['url'] ) ) {
428 $donate_url = esc_url( $settings['url'] );
429 } else {
430 $page_id = absint( $meta['bpc_form_page_id'] ?? 0 );
431 if ( $page_id ) {
432 $donate_url = esc_url( add_query_arg( 'campaign_id', $campaign_id, get_permalink( $page_id ) ) );
433 }
434 }
435 // Default the Payment Form Page URL to '#' so the button renders as a normal link.
436 $url_missing = false;
437 if ( ! $donate_url ) {
438 $donate_url = '#';
439 }
440
441 if ( $donate_url ) :
442 $min_amount = isset( $meta['bpc_minimum_amount'] ) && $meta['bpc_minimum_amount'] !== ''
443 ? (float) $meta['bpc_minimum_amount']
444 : 0;
445 $currency = self::global_currency();
446 $currency_symbol = self::currency_symbol( $currency );
447
448 $wrap_style = 'width:' . $width . '%;';
449 if ( 'center' === $align ) {
450 $wrap_style .= 'margin:0 auto;';
451 } elseif ( 'right' === $align ) {
452 $wrap_style .= 'margin-left:auto;';
453 }
454
455 $btn_class = 'bp-donate_btn' . ( $url_missing ? ' bp-donate_btn--no-url' : '' );
456 ?>
457 <div class="bp-campaign-donate-wrap">
458 <?php if ( $min_amount > 0 ) : ?>
459 <p class="bp-min-donation-notice" data-min="<?php echo esc_attr( $min_amount ); ?>">
460 <?php
461 printf(
462 /* translators: %s: formatted minimum amount with currency symbol */
463 esc_html__( 'The minimum donation for this campaign is %s.', 'better-payment' ),
464 esc_html( $currency_symbol . number_format( $min_amount, 2 ) )
465 );
466 ?>
467 </p>
468 <?php endif; ?>
469 <div class="bp-campaign-donate-btn" style="<?php echo esc_attr( $wrap_style ); ?>">
470 <a href="<?php echo $url_missing ? '#' : esc_url( $donate_url ); ?>"
471 class="<?php echo esc_attr( $btn_class ); ?>"
472 style="background-color:<?php echo esc_attr( $button_color ); ?> !important;"
473 <?php if ( $url_missing ) : ?>
474 aria-disabled="true"
475 title="<?php esc_attr_e( 'Payment page not configured', 'better-payment' ); ?>"
476 <?php else : ?>
477 <?php echo $open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : ''; ?>
478 <?php endif; ?>
479 >
480 <?php echo esc_html( $button_label ); ?>
481 </a>
482 </div>
483 </div>
484 <?php
485 endif;
486 break;
487
488 case 'organizer':
489 $creator_user_id = ! empty( $settings['creator_user_id'] )
490 ? (int) $settings['creator_user_id']
491 : (int) $post->post_author;
492 $role_title = ! empty( $settings['role_title'] ) ? $settings['role_title'] : __( 'Organizer', 'better-payment' );
493 $description = ! empty( $settings['description'] ) ? $settings['description'] : '';
494 $width = self::resolve_element_width( $settings );
495 $align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true )
496 ? $settings['align'] : 'left';
497
498 $creator = get_user_by( 'ID', $creator_user_id );
499 if ( ! $creator ) break;
500
501 $wrap_style = 'width:' . $width . '%;';
502 if ( 'center' === $align ) {
503 $wrap_style .= 'margin:0 auto;';
504 } elseif ( 'right' === $align ) {
505 $wrap_style .= 'margin-left:auto;';
506 }
507 ?>
508 <div class="bp-campaign-organizer" style="<?php echo esc_attr( $wrap_style ); ?>">
509 <div class="bp-organizer-avatar">
510 <?php echo get_avatar( $creator->user_email, 48 ); ?>
511 </div>
512 <div class="bp-organizer-info">
513 <span class="bp-organizer-name"><?php echo esc_html( $creator->display_name ); ?></span>
514 <span class="bp-organizer-role"><?php echo esc_html( $role_title ); ?></span>
515 <?php if ( $description ) : ?>
516 <div class="bp-organizer-description"><?php echo wp_kses_post( self::scale_inline_font_sizes( $description ) ); ?></div>
517 <?php endif; ?>
518 </div>
519 </div>
520 <?php
521 break;
522
523 case 'donate_amount':
524 $amounts_meta = $meta['bpc_suggested_amounts'] ?? [];
525 $allow_custom = (bool) ( $meta['bpc_allow_custom_amount'] ?? 1 );
526 $currency = self::global_currency();
527 $currency_symbol = self::currency_symbol( $currency );
528 $da_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Donate Amount', 'better-payment' );
529 $da_width = self::resolve_element_width( $settings );
530 $da_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
531
532 // Align the block within its column via auto margins — same
533 // width/margin pattern as every other element.
534 $da_wrap_style = 'width:' . $da_width . '%;';
535 if ( 'center' === $da_align ) {
536 $da_wrap_style .= 'margin:0 auto;';
537 } elseif ( 'right' === $da_align ) {
538 $da_wrap_style .= 'margin-left:auto;';
539 }
540
541 // Fall back to legacy comma-string or defaults when meta is empty.
542 if ( empty( $amounts_meta ) ) {
543 $fallback = ! empty( $settings['preset_amounts'] ) ? $settings['preset_amounts'] : '10,25,50,100';
544 foreach ( array_filter( array_map( 'trim', explode( ',', $fallback ) ) ) as $a ) {
545 $amounts_meta[] = [ 'amount' => $a, 'is_default' => false ];
546 }
547 }
548 ?>
549 <div class="bp-campaign-donate" style="<?php echo esc_attr( $da_wrap_style ); ?>">
550 <?php if ( $da_headline ) : ?>
551 <h3 class="bp-donate-headline"><?php echo esc_html( $da_headline ); ?></h3>
552 <?php endif; ?>
553 <div class="bp-donate_amounts">
554 <?php foreach ( $amounts_meta as $i => $item ) :
555 $amt = floatval( $item['amount'] ?? 0 );
556 $uid = 'bp_camt_' . $campaign_id . '_' . $i;
557 $is_default = ! empty( $item['is_default'] );
558 ?>
559 <input
560 type="radio"
561 class="bp-option-amount"
562 id="<?php echo esc_attr( $uid ); ?>"
563 name="option_amount_<?php echo esc_attr( $campaign_id ); ?>"
564 value="<?php echo esc_attr( $amt ); ?>"
565 <?php checked( $is_default ); ?>
566 hidden
567 />
568 <label for="<?php echo esc_attr( $uid ); ?>" class="bp-amount-label">
569 <?php echo esc_html( $currency_symbol . $amt ); ?>
570 </label>
571 <?php endforeach; ?>
572 </div>
573 <?php if ( $allow_custom ) : ?>
574 <div class="other_amount_section">
575 <span class="bp-amount-currency"><?php echo esc_html( $currency_symbol ); ?></span>
576 <input
577 type="number"
578 class="campaign-custom-amount"
579 min="0"
580 step="0.01"
581 placeholder="<?php esc_attr_e( 'Enter custom amount', 'better-payment' ); ?>"
582 />
583 </div>
584 <?php endif; ?>
585 </div>
586 <?php
587 break;
588
589 case 'social_sharing':
590 $sh_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Share Now', 'better-payment' );
591 $sh_open_new_tab = ! empty( $settings['open_new_tab'] );
592 $sh_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
593 $sh_page_url = get_permalink( $post );
594 $sh_title = rawurlencode( $post->post_title );
595 $sh_target = $sh_open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : '';
596 $sh_justify = [ 'center' => 'center', 'right' => 'flex-end' ][ $sh_align ] ?? 'flex-start';
597
598 $share_links = [
599 'twitter' => 'https://twitter.com/intent/tweet?url=' . rawurlencode( $sh_page_url ) . '&text=' . $sh_title,
600 'facebook' => 'https://www.facebook.com/sharer/sharer.php?u=' . rawurlencode( $sh_page_url ),
601 'linkedin' => 'https://www.linkedin.com/shareArticle?mini=true&url=' . rawurlencode( $sh_page_url ) . '&title=' . $sh_title,
602 'pinterest' => 'https://pinterest.com/pin/create/button/?url=' . rawurlencode( $sh_page_url ) . '&description=' . $sh_title,
603 'mastodon' => 'https://mastodonshare.com/?text=' . $sh_title . '&url=' . rawurlencode( $sh_page_url ),
604 'threads' => 'https://threads.net/intent/post?text=' . $sh_title . '%20' . rawurlencode( $sh_page_url ),
605 'bluesky' => 'https://bsky.app/intent/compose?text=' . $sh_title . '%20' . rawurlencode( $sh_page_url ),
606 ];
607
608 $active_sharing = array_filter( $share_links, function( $url, $key ) use ( $settings ) {
609 return ( $settings[ $key ] ?? true ) !== false;
610 }, ARRAY_FILTER_USE_BOTH );
611
612 // Same as social_links above: with every network switched off there
613 // is nothing to share, so a lone "Share Now" heading would sit on the
614 // page labelling nothing.
615 if ( $active_sharing ) :
616 ?>
617 <div class="bp-social-sharing" style="text-align:<?php echo esc_attr( $sh_align ); ?>;">
618 <?php if ( $sh_headline ) : ?>
619 <p class="bp-social-headline"><?php echo esc_html( $sh_headline ); ?></p>
620 <?php endif; ?>
621 <div class="bp-social-icons" style="justify-content:<?php echo esc_attr( $sh_justify ); ?>;">
622 <?php foreach ( $active_sharing as $key => $share_url ) : ?>
623 <a href="<?php echo esc_url( $share_url ); ?>"
624 class="bp-social-icon"
625 <?php echo $sh_target; ?>
626 title="<?php echo esc_attr( ucfirst( $key ) ); ?>">
627 <?php echo self::social_icon_svg( $key ); ?>
628 </a>
629 <?php endforeach; ?>
630 </div>
631 </div>
632 <?php
633 endif;
634 break;
635
636 case 'social_links':
637 $sl_headline = isset( $settings['headline'] ) ? $settings['headline'] : __( 'Follow Now', 'better-payment' );
638 $sl_open_new_tab = ! empty( $settings['open_new_tab'] );
639 $sl_align = in_array( $settings['align'] ?? '', [ 'left', 'center', 'right' ], true ) ? $settings['align'] : 'left';
640 $sl_target = $sl_open_new_tab ? 'target="_blank" rel="noopener noreferrer"' : '';
641 $sl_justify = [ 'center' => 'center', 'right' => 'flex-end' ][ $sl_align ] ?? 'flex-start';
642
643 $all_link_keys = [ 'twitter', 'facebook', 'linkedin', 'instagram', 'tiktok', 'pinterest', 'youtube', 'threads', 'bluesky', 'mastodon' ];
644 $active_links = [];
645 foreach ( $all_link_keys as $key ) {
646 if ( ! empty( $settings[ $key ] ) ) {
647 $active_links[ $key ] = $settings[ $key ];
648 }
649 }
650
651 // The links are the widget; the headline only labels them. With no
652 // links there is nothing to label, so a lone "Follow Now" heading is
653 // a promise the page cannot keep — render nothing instead. (Was
654 // `$active_links || $sl_headline`, which kept the heading alive on
655 // its own because the headline defaults to a non-empty string.)
656 if ( $active_links ) :
657 ?>
658 <div class="bp-social-links" style="text-align:<?php echo esc_attr( $sl_align ); ?>;">
659 <?php if ( $sl_headline ) : ?>
660 <p class="bp-social-headline"><?php echo esc_html( $sl_headline ); ?></p>
661 <?php endif; ?>
662 <div class="bp-social-icons" style="justify-content:<?php echo esc_attr( $sl_justify ); ?>;">
663 <?php foreach ( $active_links as $key => $url ) : ?>
664 <a href="<?php echo esc_url( $url ); ?>"
665 class="bp-social-icon"
666 <?php echo $sl_target; ?>
667 title="<?php echo esc_attr( ucfirst( $key ) ); ?>">
668 <?php echo self::social_icon_svg( $key ); ?>
669 </a>
670 <?php endforeach; ?>
671 </div>
672 </div>
673 <?php
674 endif;
675 break;
676
677 default:
678 // Extensibility seam: element types registered by add-ons (e.g. the
679 // Pro plugin, via the `better_payment/campaign_elements` filter) are
680 // not handled by any case above. Dispatch a type-specific filter so
681 // the add-on can render its own markup. The listener receives the
682 // full render context and MUST return already-escaped HTML (same
683 // contract as the built-in cases, which echo markup directly).
684 //
685 // With no listener (e.g. Pro deactivated) the filter returns '',
686 // so a saved layout containing an unknown type degrades to empty
687 // output instead of fataling.
688 $ctx = [
689 'element' => $element,
690 'settings' => $settings,
691 'campaign_id' => $campaign_id,
692 'post' => $post,
693 'meta' => $meta,
694 'stats' => $stats,
695 'is_preview' => $is_preview,
696 ];
697 echo apply_filters( "better_payment/campaign/render_element_{$type}", '', $ctx ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
698 break;
699 }
700
701 $html = ob_get_clean();
702
703 // An element with nothing to show needs opposite treatment in the two
704 // places it renders, and this is the one point every element type passes
705 // through — free, Pro, and anything added later — so neither behaviour can
706 // be reintroduced by an individual widget.
707 //
708 // Builder: substitute a placeholder. Without it the element occupies no
709 // space, so there is nothing to see, click, drag or delete — and because
710 // the `data-bp-element-id` wrapper below is skipped for empty output, the
711 // canvas cannot even place a hotspot over it. The author is left with an
712 // invisible element they cannot reach.
713 //
714 // Frontend: render nothing at all. "Renders nothing" is not the same as
715 // "is an empty string": an emptied Title still emits an <h2> that occupies
716 // a 32px line, and an emptied Donate button a coloured bar with no label.
717 // Both are holes in a live campaign page, so they are dropped entirely
718 // rather than shipped as blank boxes.
719 if ( self::renders_nothing_visible( (string) $html ) ) {
720 $html = $is_preview ? self::empty_element_placeholder( $type ) : '';
721 }
722
723 if ( ! $html || ! $el_id ) {
724 return $html;
725 }
726
727 return '<div class="bp-element-wrap" data-bp-element-id="' . esc_attr( $el_id ) . '">' . $html . '</div>';
728 }
729
730 /**
731 * Does this element's markup put nothing on the page a reader could see?
732 *
733 * Not the same as an empty string. An emptied Title still renders
734 * `<h1 class="bp-campaign-title" style="…"></h1>`, and a Social Links block
735 * with no URLs still renders its wrapper — both are markup, and both are
736 * invisible. Only checking for `''` would leave those elements unreachable on
737 * the canvas, which is the bug this whole placeholder exists to prevent.
738 *
739 * Errs firmly toward "not empty": wrongly blanking an element that HAS
740 * content would hide the author's work, which is far worse than missing a
741 * placeholder. So anything that can paint pixels without text — an image,
742 * an embed, a form control, a chart — counts as content immediately, and only
743 * markup with no such node AND no visible text is called empty.
744 *
745 * @param string $html Rendered element markup.
746 * @return bool True when the element would show nothing.
747 */
748 private static function renders_nothing_visible( string $html ): bool {
749 if ( '' === trim( $html ) ) {
750 return true;
751 }
752
753 // Nodes that show something without needing text content. If any is
754 // present the element is showing the author something real.
755 $visual = '#<(img|svg|iframe|video|audio|canvas|picture|object|embed|input|select|textarea|progress|meter|hr|table)[\s/>]#i';
756 if ( preg_match( $visual, $html ) ) {
757 return false;
758 }
759
760 // Otherwise it is only worth showing if it renders visible text.
761 // wp_strip_all_tags() also drops <script>/<style> bodies, so an element
762 // whose only output is a style block reads as empty — which it is.
763 return '' === trim( wp_strip_all_tags( $html ) );
764 }
765
766 /**
767 * Builder-only stand-in for an element that currently renders nothing.
768 *
769 * Deliberately empty: an outline and a minimum height, no icon, label or
770 * copy. The element's identity and its settings already live in the panel on
771 * the right the moment it is selected, so text in the canvas would repeat
772 * what the UI says anyway — and a widget's job is to show the author's
773 * content, not ours. All this needs to do is give the element enough of a
774 * body to be seen, clicked, dragged and deleted.
775 *
776 * Styles are inline rather than classed: this renders in two different
777 * preview documents (the builder canvas and the template picker) and only one
778 * of them ships a <style> block, so a self-contained placeholder cannot be
779 * broken by rendering in the wrong one.
780 *
781 * @param string $type Element type slug, exposed as a data attribute for
782 * debugging and E2E selectors — never shown to the user.
783 * @return string Placeholder HTML.
784 */
785 private static function empty_element_placeholder( string $type ): string {
786 $box = 'min-height:64px;border:1px dashed #c7cede;border-radius:8px;background:#fbfcfe;';
787
788 return '<div class="bp-element-empty" style="' . esc_attr( $box ) . '"'
789 . ' data-bp-empty-type="' . esc_attr( $type ) . '"></div>';
790 }
791
792 /**
793 * Normalize raw stored layout to the column schema.
794 * Handles both old flat-array format and new column format.
795 *
796 * @param mixed $raw Value from MetaBox::get_all()['bpc_fields_layout'].
797 * @return array Normalized layout with 'layout' and 'columns' keys.
798 */
799 public static function normalize_layout( $raw ): array {
800 if ( is_array( $raw ) && isset( $raw['columns'] ) ) {
801 return $raw;
802 }
803
804 // Legacy flat format: wrap all elements into a single column.
805 $elements = is_array( $raw ) ? $raw : [];
806 return [
807 'layout' => '1-column',
808 'columns' => [
809 [
810 'id' => 'main',
811 'label' => 'Main Content',
812 'width' => '100%',
813 'elements' => $elements,
814 ],
815 ],
816 ];
817 }
818
819 /**
820 * Default layout used when no layout is saved (campaign has no fields yet).
821 *
822 * @return array
823 */
824 private static function social_icon_svg( string $network ): string {
825 $paths = [
826 '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',
827 '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',
828 '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',
829 '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',
830 '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',
831 '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',
832 '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',
833 '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',
834 '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',
835 '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',
836 ];
837 if ( ! isset( $paths[ $network ] ) ) {
838 return '';
839 }
840 return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="' . esc_attr( $paths[ $network ] ) . '"/></svg>';
841 }
842
843 /**
844 * Scale up inline px font-sizes in HTML content saved by the rich text editor.
845 * Adds 2px to every explicit pixel font-size so the text matches the campaign's
846 * base 16px scale (editor default is 14px).
847 */
848 private static function scale_inline_font_sizes( string $html ): string {
849 return preg_replace_callback(
850 '/\bfont-size\s*:\s*(\d+(?:\.\d+)?)px/i',
851 function ( $m ) {
852 return 'font-size: ' . ( (float) $m[1] + 2 ) . 'px';
853 },
854 $html
855 );
856 }
857
858 /**
859 * Normalize a stored font-size setting into a safe CSS length.
860 * Numeric values are treated as pixels. Returns '' when unset/invalid.
861 *
862 * @param mixed $val
863 */
864 private static function css_font_size( $val ): string {
865 if ( is_string( $val ) ) {
866 $val = trim( $val );
867 }
868 if ( '' === $val || null === $val ) {
869 return '';
870 }
871 if ( is_numeric( $val ) ) {
872 return ( (float) $val ) . 'px';
873 }
874 if ( is_string( $val ) && preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $val ) ) {
875 return $val;
876 }
877 return '';
878 }
879
880 /**
881 * Validate a stored font-family stack. Allows letters, numbers, spaces,
882 * commas, quotes and hyphens only — blocks CSS breakout. Returns '' when
883 * unset/invalid.
884 *
885 * @param mixed $val
886 */
887 private static function css_font_family( $val ): string {
888 $val = is_string( $val ) ? trim( $val ) : '';
889 if ( '' === $val ) {
890 return '';
891 }
892 return preg_match( "/^[A-Za-z0-9 ,'\"_-]+$/", $val ) ? $val : '';
893 }
894
895 /**
896 * Validate a stored font-style value. Returns '' when unset/invalid.
897 *
898 * @param mixed $val
899 */
900 private static function css_font_style( $val ): string {
901 $val = is_string( $val ) ? strtolower( trim( $val ) ) : '';
902 return in_array( $val, [ 'normal', 'italic', 'oblique' ], true ) ? $val : '';
903 }
904
905 /**
906 * Validate a stored hex color. Returns '' when unset/invalid.
907 *
908 * @param mixed $val
909 */
910 private static function css_hex_color( $val ): string {
911 $val = is_string( $val ) ? trim( $val ) : '';
912 return preg_match( '/^#[0-9a-fA-F]{3,8}$/', $val ) ? $val : '';
913 }
914
915 /**
916 * Normalize a stored CSS length (letter/word spacing, line-height). Numeric
917 * values are treated as pixels; a leading minus is allowed. Returns '' when
918 * unset/invalid.
919 *
920 * @param mixed $val
921 */
922 /**
923 * Resolve an element's percentage width setting.
924 *
925 * A blank or non-positive width means "use the default" — never 0. Without
926 * this guard an empty-string width (e.g. produced by the AI layer) becomes
927 * `(int) '' = 0`, collapsing the element to a sliver. Valid values are
928 * clamped to 10–100%.
929 *
930 * @param array $settings
931 * @param int $default
932 */
933 private static function resolve_element_width( array $settings, int $default = 100 ): int {
934 if ( ! isset( $settings['width'] ) || '' === $settings['width'] ) {
935 return $default;
936 }
937 $width = (int) $settings['width'];
938 if ( $width <= 0 ) {
939 return $default;
940 }
941 return max( 10, min( 100, $width ) );
942 }
943
944 /**
945 * Render a line-height value. Unlike other lengths, CSS `line-height` is
946 * unitless-capable: a small value (< 4) is a multiplier (e.g. 1.5) and must
947 * NOT be emitted as pixels — `line-height: 1.5px` collapses every line on top
948 * of the next. Larger values are treated as pixels (the builder's control is
949 * labelled "px").
950 *
951 * @param mixed $val
952 */
953 private static function css_line_height( $val ): string {
954 if ( is_string( $val ) ) {
955 $val = trim( $val );
956 }
957 if ( '' === $val || null === $val ) {
958 return '';
959 }
960 if ( is_numeric( $val ) ) {
961 $num = (float) $val;
962 if ( $num > 0 && $num < 4 ) {
963 return (string) $num; // unitless multiplier
964 }
965 return $num . 'px';
966 }
967 if ( is_string( $val ) && preg_match( '/^-?\d+(\.\d+)?(px|em|rem|%)$/', $val ) ) {
968 return $val;
969 }
970 return '';
971 }
972
973 private static function css_length( $val ): string {
974 if ( is_string( $val ) ) {
975 $val = trim( $val );
976 }
977 if ( '' === $val || null === $val ) {
978 return '';
979 }
980 if ( is_numeric( $val ) ) {
981 return ( (float) $val ) . 'px';
982 }
983 if ( is_string( $val ) && preg_match( '/^-?\d+(\.\d+)?(px|em|rem|%)$/', $val ) ) {
984 return $val;
985 }
986 return '';
987 }
988
989 /**
990 * Validate a stored font-weight value. Returns '' when unset/invalid.
991 *
992 * @param mixed $val
993 */
994 private static function css_font_weight( $val ): string {
995 if ( is_int( $val ) ) {
996 $val = (string) $val;
997 }
998 $val = is_string( $val ) ? strtolower( trim( $val ) ) : '';
999 $allowed = [ '100', '200', '300', '400', '500', '600', '700', '800', '900', 'normal', 'bold', 'bolder', 'lighter' ];
1000 return in_array( $val, $allowed, true ) ? $val : '';
1001 }
1002
1003 /**
1004 * Validate a stored value against an allowlist of CSS keywords.
1005 *
1006 * @param mixed $val
1007 * @param array<string> $allowed
1008 */
1009 private static function css_keyword( $val, array $allowed ): string {
1010 $val = is_string( $val ) ? strtolower( trim( $val ) ) : '';
1011 return in_array( $val, $allowed, true ) ? $val : '';
1012 }
1013
1014 /**
1015 * Extract a prefixed typography set (e.g. 'title_font_family') back into the
1016 * base keys the typography helpers expect ('font_family'). Lets one element
1017 * carry more than one independent typography set (see the Description widget,
1018 * which styles its title and body separately) while reusing the same helpers.
1019 *
1020 * @param array $settings Element settings.
1021 * @param string $prefix Key prefix to strip (e.g. 'title_').
1022 * @return array<string, mixed> Base-keyed typography settings.
1023 */
1024 private static function prefixed_typography( array $settings, string $prefix ): array {
1025 $keys = [
1026 'font_family', 'font_size', 'font_weight', 'text_transform', 'font_style',
1027 'text_decoration', 'line_height', 'letter_spacing', 'word_spacing', 'color',
1028 ];
1029 $out = [];
1030 foreach ( $keys as $key ) {
1031 if ( isset( $settings[ $prefix . $key ] ) ) {
1032 $out[ $key ] = $settings[ $prefix . $key ];
1033 }
1034 }
1035 return $out;
1036 }
1037
1038 /**
1039 * Build the "common" typography CSS declarations (everything except color and
1040 * font-size, which callers handle per-element) from element settings.
1041 * Only user-set, valid values are included.
1042 *
1043 * @param array $settings
1044 * @return array<string, string> Map of css-property => value.
1045 */
1046 private static function typography_common_decls( array $settings ): array {
1047 $decls = [];
1048
1049 $family = self::css_font_family( $settings['font_family'] ?? '' );
1050 if ( '' !== $family ) {
1051 $decls['font-family'] = $family;
1052 }
1053 $weight = self::css_font_weight( $settings['font_weight'] ?? '' );
1054 if ( '' !== $weight ) {
1055 $decls['font-weight'] = $weight;
1056 }
1057 $style = self::css_font_style( $settings['font_style'] ?? '' );
1058 if ( '' !== $style ) {
1059 $decls['font-style'] = $style;
1060 }
1061 $transform = self::css_keyword( $settings['text_transform'] ?? '', [ 'uppercase', 'lowercase', 'capitalize', 'none' ] );
1062 if ( '' !== $transform ) {
1063 $decls['text-transform'] = $transform;
1064 }
1065 $decoration = self::css_keyword( $settings['text_decoration'] ?? '', [ 'underline', 'overline', 'line-through', 'none' ] );
1066 if ( '' !== $decoration ) {
1067 $decls['text-decoration'] = $decoration;
1068 }
1069 $line_height = self::css_line_height( $settings['line_height'] ?? '' );
1070 if ( '' !== $line_height ) {
1071 $decls['line-height'] = $line_height;
1072 }
1073 $letter = self::css_length( $settings['letter_spacing'] ?? '' );
1074 if ( '' !== $letter ) {
1075 $decls['letter-spacing'] = $letter;
1076 }
1077 $word = self::css_length( $settings['word_spacing'] ?? '' );
1078 if ( '' !== $word ) {
1079 $decls['word-spacing'] = $word;
1080 }
1081
1082 return $decls;
1083 }
1084
1085 /**
1086 * Flatten a declaration map into an inline-style string. Every declaration is
1087 * emitted with !important so user overrides win over template stylesheet rules.
1088 *
1089 * @param array<string, string> $decls
1090 */
1091 private static function decls_to_style( array $decls ): string {
1092 $out = '';
1093 foreach ( $decls as $prop => $value ) {
1094 $out .= $prop . ':' . $value . ' !important;';
1095 }
1096 return $out;
1097 }
1098
1099 /**
1100 * Returns the global Better Payment currency code from plugin settings.
1101 */
1102 private static function global_currency(): string {
1103 $code = DB::get_settings( 'better_payment_settings_general_general_currency' );
1104 return ( is_string( $code ) && $code !== '' ) ? $code : 'USD';
1105 }
1106
1107 private static function currency_symbol( string $code ): string {
1108 $map = [
1109 'USD' => '$', 'EUR' => '', 'GBP' => '£', 'JPY' => '¥',
1110 'CAD' => 'CA$', 'AUD' => 'A$', 'INR' => '', 'BRL' => 'R$',
1111 'MXN' => 'MX$', 'SGD' => 'S$', 'CHF' => 'CHF', 'SEK' => 'kr',
1112 'NOK' => 'kr', 'DKK' => 'kr', 'NZD' => 'NZ$', 'ZAR' => 'R',
1113 'BDT' => '', 'PKR' => '', 'NGN' => '', 'KES' => 'KSh',
1114 ];
1115 return $map[ strtoupper( $code ) ] ?? $code;
1116 }
1117
1118 /**
1119 * Render a template definition as HTML for the picker iframe preview.
1120 *
1121 * @param string $key Template key from TemplateManager.
1122 * @return string HTML fragment, or empty string if key not found.
1123 */
1124 public static function render_template_preview( string $key ): string {
1125 $templates = TemplateManager::get_all();
1126 if ( ! isset( $templates[ $key ] ) ) {
1127 return '';
1128 }
1129 $template = $templates[ $key ];
1130 $columns = $template['columns'] ?? [];
1131 $layout = $template['layout'] ?? '1-column';
1132
1133 $first_users = get_users( [ 'fields' => [ 'ID' ], 'number' => 1 ] );
1134 $first_creator_id = ! empty( $first_users ) ? (int) $first_users[0]->ID : get_current_user_id();
1135
1136 $fake_post = new \WP_Post( (object) [
1137 'ID' => 0,
1138 'post_title' => $template['default_title'] ?? $template['label'] ?? 'Campaign Preview',
1139 'post_content' => '',
1140 'post_author' => $first_creator_id,
1141 'post_type' => 'bp_campaign',
1142 'post_status' => 'publish',
1143 'post_name' => $key,
1144 ] );
1145
1146 $meta = [
1147 'bpc_goal_amount' => 10000,
1148 'bpc_color_primary' => $template['preview_color'] ?? '#6b63f6',
1149 'bpc_color_background' => '',
1150 'bpc_suggested_amounts' => [],
1151 'bpc_allow_custom_amount' => true,
1152 'bpc_minimum_amount' => '',
1153 'bpc_form_page_id' => 0,
1154 'bpc_status' => 'active',
1155 'bpc_template_key' => $key,
1156 'bpc_css_class' => '',
1157 ];
1158
1159 $stats = [
1160 'total_raised' => 3750,
1161 'progress' => 37.5,
1162 'donor_count' => 42,
1163 'days_remaining' => 18,
1164 ];
1165
1166 $theme_class = isset( $template['theme_class'] ) ? ' ' . sanitize_html_class( $template['theme_class'] ) : '';
1167
1168 ob_start();
1169 ?>
1170 <div class="bp-campaign bp-campaign--<?php echo esc_attr( $layout ); ?> better-payment<?php echo esc_attr( $theme_class ); ?>">
1171 <div class="bp-campaign-columns">
1172 <?php foreach ( $columns as $column ) :
1173 $col_style = 'width: ' . esc_attr( $column['width'] ?? '100%' ) . ';';
1174 if ( ! empty( $column['style'] ) ) {
1175 $col_style .= ' ' . esc_attr( $column['style'] );
1176 }
1177 ?>
1178 <div class="bp-campaign-column"
1179 style="<?php echo $col_style; ?>">
1180 <?php
1181 foreach ( $column['elements'] as $element ) {
1182 echo self::render_element( $element, 0, $fake_post, $meta, $stats, true );
1183 }
1184 ?>
1185 </div>
1186 <?php endforeach; ?>
1187 </div>
1188 </div>
1189 <?php
1190 return ob_get_clean();
1191 }
1192
1193 /**
1194 * Build a full HTML preview document for the builder's live-preview iframe.
1195 *
1196 * Each element is wrapped in a <div data-bp-element-id> so the JS overlay
1197 * can measure positions and wire up hover/drag interactions. Works for both
1198 * new unsaved campaigns (campaign_id = 0) and existing ones.
1199 *
1200 * @param array $layout_data Builder layout: { layout, columns }.
1201 * @param array $meta_input Campaign meta from the builder store (includes 'title').
1202 * @param int $campaign_id 0 for new campaigns; real ID to pull live stats.
1203 * @return string Full HTML document string.
1204 */
1205 public static function build_preview_document(
1206 array $layout_data,
1207 array $meta_input,
1208 int $campaign_id = 0
1209 ): string {
1210 // ── Post + stats ──────────────────────────────────────────────────────
1211 $post = null;
1212 $stats = null;
1213
1214 if ( $campaign_id > 0 ) {
1215 $real = get_post( $campaign_id );
1216 if ( $real && $real->post_type === 'bp_campaign' ) {
1217 $post = $real;
1218 $stats = CampaignStats::get_stats( $campaign_id );
1219 }
1220 }
1221
1222 if ( ! $post ) {
1223 $post = new \WP_Post( (object) [
1224 'ID' => 0,
1225 'post_title' => sanitize_text_field( $meta_input['title'] ?? 'Campaign Preview' ),
1226 'post_content' => '',
1227 'post_author' => get_current_user_id(),
1228 'post_type' => 'bp_campaign',
1229 'post_status' => 'publish',
1230 'post_name' => 'preview',
1231 ] );
1232 $stats = [ 'total_raised' => 0, 'progress' => 0, 'donor_count' => 0, 'days_remaining' => null ];
1233 } else {
1234 // Always reflect the current editor title, even for saved campaigns.
1235 $override = sanitize_text_field( $meta_input['title'] ?? '' );
1236 if ( $override !== '' ) {
1237 $post->post_title = $override;
1238 }
1239 }
1240
1241 // Override stats fields that depend on unsaved builder meta so the preview
1242 // reflects the current editor values without requiring a save first.
1243 $preview_end_date = $meta_input['bpc_end_date'] ?? '';
1244 if ( $preview_end_date !== '' ) {
1245 $diff = strtotime( $preview_end_date ) - current_time( 'timestamp' );
1246 $stats['days_remaining'] = max( 0, (int) ceil( $diff / DAY_IN_SECONDS ) );
1247 } else {
1248 $stats['days_remaining'] = null;
1249 }
1250
1251 $preview_goal = (float) ( $meta_input['bpc_goal_amount'] ?? 0 );
1252 if ( $preview_goal > 0 ) {
1253 $stats['progress'] = min( 100.0, round( ( $stats['total_raised'] / $preview_goal ) * 100, 1 ) );
1254 }
1255
1256 // ── Meta ─────────────────────────────────────────────────────────────
1257 $meta_defaults = [
1258 'bpc_goal_amount' => 0,
1259 'bpc_color_primary' => '#6b63f6',
1260 'bpc_color_background' => '',
1261 'bpc_suggested_amounts' => [],
1262 'bpc_allow_custom_amount' => true,
1263 'bpc_minimum_amount' => '',
1264 'bpc_form_page_id' => 0,
1265 'bpc_status' => 'active',
1266 'bpc_template_key' => '',
1267 'bpc_css_class' => '',
1268 ];
1269
1270 if ( $campaign_id > 0 ) {
1271 $meta = array_merge( MetaBox::get_all( $campaign_id ), $meta_defaults, $meta_input );
1272 } else {
1273 $meta = array_merge( $meta_defaults, $meta_input );
1274 }
1275
1276 // ── Layout ───────────────────────────────────────────────────────────
1277 $columns = $layout_data['columns'] ?? [];
1278 $layout = $layout_data['layout'] ?? '1-column';
1279
1280 if ( empty( $columns ) ) {
1281 $default = self::default_layout();
1282 $columns = $default['columns'];
1283 $layout = $default['layout'];
1284 }
1285
1286 // ── Template theme class ──────────────────────────────────────────────
1287 $template_key = $meta['bpc_template_key'] ?? '';
1288 $all_templates = TemplateManager::get_all();
1289 $theme_class = ( $template_key && isset( $all_templates[ $template_key ]['theme_class'] ) )
1290 ? ' ' . sanitize_html_class( $all_templates[ $template_key ]['theme_class'] )
1291 : '';
1292
1293 // ── Campaign HTML with element-id wrappers ────────────────────────────
1294 $color_style = self::generate_color_style( $meta, $campaign_id );
1295
1296 ob_start();
1297 ?>
1298 <div class="bp-campaign bp-campaign--<?php echo esc_attr( $layout ); ?> better-payment<?php echo esc_attr( $theme_class ); ?>"
1299 data-campaign-id="<?php echo esc_attr( $campaign_id ); ?>">
1300 <?php echo wp_kses( $color_style, [ 'style' => [] ] ); ?>
1301 <input type="hidden" class="better_payment_campaign_id"
1302 value="<?php echo esc_attr( $campaign_id ); ?>">
1303 <input type="hidden" class="better_payment_campaign_currency"
1304 value="<?php echo esc_attr( self::global_currency() ); ?>">
1305 <div class="bp-campaign-columns">
1306 <?php foreach ( $columns as $column ) :
1307 $is_empty = empty( $column['elements'] );
1308 $col_class = 'bp-campaign-column' . ( $is_empty ? ' bp-col-empty' : '' );
1309 $raw_width = $column['width'] ?? '100%';
1310 $col_width = preg_match( '/^\d{1,3}(\.\d+)?%$/', $raw_width ) ? $raw_width : '100%';
1311 $col_style = 'width: ' . $col_width . ';';
1312 ?>
1313 <div class="<?php echo esc_attr( $col_class ); ?>"
1314 data-bp-column-id="<?php echo esc_attr( $column['id'] ); ?>"
1315 style="<?php echo esc_attr( $col_style ); ?>">
1316 <?php
1317 $locked_types = self::pro_locked_types();
1318 foreach ( $column['elements'] as $element ) :
1319 $el_type = $element['type'] ?? '';
1320 ?>
1321 <div data-bp-element-id="<?php echo esc_attr( $element['id'] ?? '' ); ?>"
1322 data-bp-column-id="<?php echo esc_attr( $column['id'] ); ?>"
1323 class="bp-builder-el-wrap">
1324 <?php
1325 // Pro elements — either dropped from the palette by a free
1326 // user, or left behind by a campaign built while Pro was
1327 // active. Either way Pro's renderer is not listening, so
1328 // rendering them normally yields nothing: no markup, no
1329 // hotspot, an element that cannot be selected or removed.
1330 //
1331 // Show a mock preview instead so the element is visible,
1332 // selectable, and demonstrates what Pro would do with the
1333 // settings shown (disabled) in the sidebar.
1334 //
1335 // This is the ONLY call site — the public render path has no
1336 // reference to ProElementPreview, and ProElementPreview::render()
1337 // re-checks the preview flag itself. Its data is fabricated;
1338 // on a live campaign page it would be a lie about who donated.
1339 if ( isset( $locked_types[ $el_type ] ) ) {
1340 // Only our own three types have a mock. Any other
1341 // `pro`-flagged element (a third party's, via the
1342 // campaign_elements filter) still needs *something*
1343 // clickable, so it falls back to the generic banner.
1344 $mock = ProElementPreview::render(
1345 $el_type,
1346 isset( $element['settings'] ) && is_array( $element['settings'] ) ? $element['settings'] : [],
1347 true
1348 );
1349
1350 if ( '' === $mock ) {
1351 $mock = self::pro_locked_placeholder( $locked_types[ $el_type ] );
1352 }
1353
1354 echo $mock; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1355 } else {
1356 echo self::render_element( $element, $campaign_id, $post, $meta, $stats, true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1357 }
1358 ?>
1359 </div>
1360 <?php endforeach; ?>
1361 </div>
1362 <?php endforeach; ?>
1363 </div>
1364 </div>
1365 <?php
1366 $campaign_html = ob_get_clean();
1367
1368 // ── Assemble full HTML document ───────────────────────────────────────
1369 // Mirror exactly what the frontend enqueues (single-bp_campaign.php +
1370 // Shortcode::enqueue_frontend_styles): both campaign-display CSS and
1371 // fundraising-campaign CSS are required for pixel-perfect parity.
1372 $v = BETTER_PAYMENT_VERSION;
1373 $display_css = BETTER_PAYMENT_ASSETS . '/blocks/campaign-display/style.min.css';
1374 $fundraising_css = BETTER_PAYMENT_ASSETS . '/css/fundraising-campaign.min.css';
1375
1376 $extra_link = file_exists( BETTER_PAYMENT_ASSETS_PATH . '/css/fundraising-campaign.min.css' )
1377 ? '<link rel="stylesheet" href="' . esc_url( $fundraising_css ) . '?v=' . $v . '">' . "\n"
1378 : '';
1379
1380 return '<!DOCTYPE html>' . "\n"
1381 . '<html>' . "\n"
1382 . '<head>' . "\n"
1383 . '<meta charset="utf-8">' . "\n"
1384 . '<meta name="viewport" content="width=device-width, initial-scale=1">' . "\n"
1385 . '<base href="' . esc_url( home_url( '/' ) ) . '">' . "\n"
1386 . '<link rel="stylesheet" href="' . esc_url( $display_css ) . '?v=' . $v . '">' . "\n"
1387 . $extra_link
1388 . '<style>' . "\n"
1389 . '*, *::before, *::after { box-sizing: border-box; }' . "\n"
1390 . 'html, body { margin: 0; padding: 0; background: #fff; }' . "\n"
1391 . ( $theme_class ? 'body { padding: 0 24px 16px; }' . "\n" : '' )
1392 . '.bp-builder-el-wrap { position: relative; }' . "\n"
1393 // Empty columns need a minimum height so the overlay ColumnDropZone can
1394 // measure them and render the dashed drop-zone border at the correct size.
1395 . '.bp-campaign-column.bp-col-empty { min-height: 160px; }' . "\n"
1396 . '.bp-campaign-columns[style*="stretch"] .bp-builder-el-wrap {' . "\n"
1397 . ' height: 100%;' . "\n"
1398 . ' display: flex;' . "\n"
1399 . ' flex-direction: column;' . "\n"
1400 . '}' . "\n"
1401 // The preview is read-only: no link/button/form control should be
1402 // clickable (e.g. the Donate Now button must not navigate away).
1403 // pointer-events:none only blocks pointer interaction — page scrolling
1404 // still works. This document is preview-only (the live frontend uses
1405 // render_campaign() directly), so real pages are unaffected.
1406 . 'a, button, input, select, textarea, [role="button"] {' . "\n"
1407 . ' pointer-events: none !important;' . "\n"
1408 . ' cursor: default !important;' . "\n"
1409 . '}' . "\n"
1410 // Pro-locked element placeholder (editor preview only).
1411 . '.bp-pro-locked { position: relative; border: 1px dashed #f0a020; background: #fff8ee; border-radius: 8px; padding: 22px 18px; text-align: center; }' . "\n"
1412 . '.bp-pro-locked__badge { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: 0.5px; color: #fff; background: linear-gradient(135deg, #f6a821, #ec6a2b); padding: 3px 8px; border-radius: 20px; margin-bottom: 8px; }' . "\n"
1413 . '.bp-pro-locked__icon { color: #d9821a; }' . "\n"
1414 . '.bp-pro-locked__icon .dashicons { width: 26px; height: 26px; font-size: 26px; }' . "\n"
1415 . '.bp-pro-locked__title { font-size: 15px; font-weight: 700; color: #1a1a2e; margin: 6px 0 4px; }' . "\n"
1416 . '.bp-pro-locked__text { font-size: 12px; color: #7a6a52; margin: 0; line-height: 1.5; }' . "\n"
1417 . '</style>' . "\n"
1418 . '</head>' . "\n"
1419 . '<body>' . "\n"
1420 . preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $campaign_html ) . "\n"
1421 . '</body>' . "\n"
1422 . '</html>';
1423 }
1424
1425 /**
1426 * Element types that are Pro-only AND currently locked (Pro inactive).
1427 *
1428 * Returns a map of type => schema (label/icon) for elements that carry the
1429 * `pro` flag in the filtered registry while `better_payment/pro_enabled` is
1430 * false. When Pro is active these are overridden by the full schema (no `pro`
1431 * flag) so the map is empty and nothing is locked. Result is cached per
1432 * request.
1433 *
1434 * @return array<string, array>
1435 */
1436 private static function pro_locked_types(): array {
1437 static $cache = null;
1438
1439 if ( null !== $cache ) {
1440 return $cache;
1441 }
1442
1443 $cache = [];
1444
1445 if ( apply_filters( 'better_payment/pro_enabled', false ) ) {
1446 return $cache;
1447 }
1448
1449 foreach ( ElementRegistry::get_all() as $type => $schema ) {
1450 if ( ! empty( $schema['pro'] ) ) {
1451 $cache[ $type ] = $schema;
1452 }
1453 }
1454
1455 return $cache;
1456 }
1457
1458 /**
1459 * Editor-only placeholder banner shown in the builder preview for a
1460 * Pro-locked element (a Pro element left over from when Pro was active).
1461 * Never emitted on the public frontend.
1462 *
1463 * @param array $schema Element schema (label, icon).
1464 * @return string
1465 */
1466 private static function pro_locked_placeholder( array $schema ): string {
1467 $label = ! empty( $schema['label'] ) ? $schema['label'] : __( 'Pro Element', 'better-payment' );
1468 $icon = ! empty( $schema['icon'] ) ? $schema['icon'] : 'lock';
1469
1470 ob_start();
1471 ?>
1472 <div class="bp-pro-locked">
1473 <span class="bp-pro-locked__badge"><?php esc_html_e( 'PRO', 'better-payment' ); ?></span>
1474 <div class="bp-pro-locked__icon"><span class="dashicons dashicons-<?php echo esc_attr( $icon ); ?>"></span></div>
1475 <div class="bp-pro-locked__title"><?php echo esc_html( $label ); ?></div>
1476 <p class="bp-pro-locked__text">
1477 <?php
1478 printf(
1479 /* translators: %s: element name, e.g. "Donors Wall". */
1480 esc_html__( '%s is a Better Payment Pro element. Activate Pro to display it on your campaign.', 'better-payment' ),
1481 esc_html( $label )
1482 );
1483 ?>
1484 </p>
1485 </div>
1486 <?php
1487 return (string) ob_get_clean();
1488 }
1489
1490 private static function default_layout(): array {
1491 return [
1492 'layout' => '2-column',
1493 'columns' => [
1494 [
1495 'id' => 'main',
1496 'label' => 'Main Content',
1497 'width' => '65%',
1498 'elements' => [
1499 [ 'id' => 'def_photo', 'type' => 'photo', 'settings' => [] ],
1500 [ 'id' => 'def_title', 'type' => 'campaign_title', 'settings' => [] ],
1501 [ 'id' => 'def_desc', 'type' => 'campaign_description', 'settings' => [] ],
1502 ],
1503 ],
1504 [
1505 'id' => 'sidebar',
1506 'label' => 'Sidebar',
1507 'width' => '35%',
1508 'elements' => [
1509 [ 'id' => 'def_progress', 'type' => 'progress_bar', 'settings' => [] ],
1510 [ 'id' => 'def_summary', 'type' => 'campaign_summary', 'settings' => [] ],
1511 [ 'id' => 'def_donate', 'type' => 'donation_form', 'settings' => [] ],
1512 ],
1513 ],
1514 ],
1515 ];
1516 }
1517 }
1518