PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.0
2.4.1 2.4.0 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 All 68 releases
better-payment / includes / Campaign / Services / RendererService.php

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

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