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

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