PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
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 / Elements / ProElementPreview.php

ProElementPreview.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.1, at includes/Campaign/Elements/ProElementPreview.php

528 lines 25.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\Elements;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 /**
10 * Mock, editor-only previews of the three Pro elements (Donors Wall, FAQ, Video)
11 * for sites without Better Payment Pro.
12 *
13 * Replaces the old flat "PRO" banner. A banner told a free user nothing about
14 * what the element does; this paints a realistic approximation so they can see
15 * the widget and judge whether Pro is worth buying.
16 *
17 * A free install registers only a lock stub for these elements
18 * ({@see ProElementStubs}: no schema, no default settings), so a freshly dropped
19 * element reaches here with `[]` settings and every mock must look complete from
20 * built-in sample content alone. Settings that ARE present — a campaign built
21 * while Pro was active — are still honoured, so a downgraded campaign shows the
22 * author's own configuration.
23 *
24 * ── The one rule that matters ────────────────────────────────────────────────
25 * **This NEVER renders on the public frontend.** Every method here fabricates
26 * data — invented donor names, invented amounts. On a live fundraising page that
27 * is not a cosmetic bug, it is a lie about who gave money. Two independent gates
28 * enforce it:
29 *
30 * 1. Only `RendererService::build_preview_document()` calls this. The public
31 * paths (`render_campaign()`, the shortcode, the block, the CPT template)
32 * have no reference to it — there a Pro element falls through
33 * `render_element()`'s `default:` case, the render filter has no listener,
34 * returns `''`, and the empty-element handler drops it entirely.
35 * 2. {@see self::render()} re-checks `$is_preview` itself and returns `''` if
36 * it is anything but strictly true, so a future caller cannot leak it by
37 * accident.
38 *
39 * `EmptyElementPlaceholderTest` and `ProElementDowngradeTest` guard both.
40 *
41 * ── Why the markup lives here and not in Pro ─────────────────────────────────
42 * Pro stays fully self-contained: its own views in
43 * `includes/Admin/views/campaign-builder/` are untouched and never loaded by
44 * Lite. That means this markup is a deliberate, simplified re-creation rather
45 * than a shared template — it will not track Pro's styling pixel-for-pixel, and
46 * it is not supposed to. It is a demo, and the PRO ribbon says so.
47 *
48 * Everything is inline-styled on purpose, same reasoning as the empty-element
49 * placeholder: the builder's preview iframe ships no `<style>` block of its own
50 * and **loads no dashicons**, so class hooks and icon fonts are both unavailable.
51 */
52 class ProElementPreview {
53
54 /**
55 * Element types this class can mock.
56 *
57 * @return array<int, string>
58 */
59 public static function supported_types(): array {
60 return [ 'donors_wall', 'faq', 'video' ];
61 }
62
63 /**
64 * Render a mock preview for a Pro element.
65 *
66 * @param string $type Element type.
67 * @param array $settings Element settings as saved in the layout. Honoured
68 * where practical so a downgraded campaign still
69 * shows the author's real configuration.
70 * @param bool $is_preview Must be strictly true. See the class docblock.
71 * @return string HTML, or '' when not a builder preview / unsupported type.
72 */
73 public static function render( string $type, array $settings, bool $is_preview ): string {
74 if ( true !== $is_preview ) {
75 return '';
76 }
77
78 if ( ! in_array( $type, self::supported_types(), true ) ) {
79 return '';
80 }
81
82 // Whether the mock needs the "sample data" disclosure under it. Donors
83 // Wall invents donors and FAQ echoes the author's own questions back at a
84 // sample scale, so both must say so. The Video mock displays no data at
85 // all — it is a drawn poster frame with no names, figures or copy that
86 // could be mistaken for the user's — so the note has nothing to disclose
87 // and only adds a line of grey text under the artwork.
88 $show_note = true;
89
90 switch ( $type ) {
91 case 'donors_wall':
92 $body = self::donors_wall( $settings );
93 $label = __( 'Donors Wall', 'better-payment' );
94 break;
95 case 'faq':
96 $body = self::faq( $settings );
97 $label = __( 'FAQ', 'better-payment' );
98 break;
99 default:
100 $body = self::video( $settings );
101 $label = __( 'Video', 'better-payment' );
102 $show_note = false;
103 break;
104 }
105
106 return self::wrap( $body, $label, $show_note );
107 }
108
109 /**
110 * Chrome around every mock: a dashed frame plus a PRO ribbon, so a demo is
111 * never mistaken for the real thing.
112 *
113 * @param string $body Already-escaped inner HTML.
114 * @param string $label Element label.
115 * @param bool $show_note Whether to print the "sample data" disclosure.
116 * Defaults true: a mock that shows fabricated data
117 * must say so, and that is the common case. Only the
118 * Video frame opts out, because it displays no data
119 * to disclose. The PRO ribbon is NOT optional — it is
120 * what stops any mock being read as the real element.
121 * @return string
122 */
123 private static function wrap( string $body, string $label, bool $show_note = true ): string {
124 $frame = 'position:relative;border:1px dashed #c3c4c7;border-radius:8px;'
125 . 'padding:28px 16px 16px;margin:0 0 4px;background:#fff;';
126
127 // Pro-orange gradient — the exact fill of the "Get PRO to Unlock" button
128 // (`.bp-lc-hotspot__pro-lock-btn`), so the ribbon reads as the same Pro
129 // upsell affordance rather than a second, unrelated brand colour.
130 $ribbon = 'position:absolute;top:0;left:0;display:inline-flex;align-items:center;gap:6px;'
131 . 'background:linear-gradient(135deg,#f6a821,#ec6a2b);color:#fff;font-size:10px;font-weight:700;letter-spacing:.06em;'
132 . 'text-transform:uppercase;padding:3px 10px;border-radius:8px 0 8px 0;';
133
134 $note = 'margin:12px 0 0;font-size:11px;line-height:1.5;color:#787c82;text-align:center;';
135
136 ob_start();
137 ?>
138 <div style="<?php echo esc_attr( $frame ); ?>" data-bp-pro-preview="1">
139 <span style="<?php echo esc_attr( $ribbon ); ?>">
140 <?php
141 printf(
142 /* translators: %s: element name, e.g. "Donors Wall". */
143 esc_html__( 'Pro preview — %s', 'better-payment' ),
144 esc_html( $label )
145 );
146 ?>
147 </span>
148 <?php echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts below. ?>
149 <?php if ( $show_note ) : ?>
150 <p style="<?php echo esc_attr( $note ); ?>">
151 <?php esc_html_e( 'Sample data shown in the editor only. Activate Better Payment Pro to use this element on your live campaign.', 'better-payment' ); ?>
152 </p>
153 <?php endif; ?>
154 </div>
155 <?php
156 return (string) ob_get_clean();
157 }
158
159 /* --------------------------------------------------------------------- */
160 /* Per-element mocks */
161 /* --------------------------------------------------------------------- */
162
163 /**
164 * @param array $settings
165 * @return string
166 */
167 private static function donors_wall( array $settings ): string {
168 // A completely unset key takes the schema default; an explicit 0 means
169 // "show none" and is honoured — same contract as Pro's real renderer, so
170 // the preview doesn't disagree with what Pro would do.
171 $limit = isset( $settings['number_to_show'] ) ? (int) $settings['number_to_show'] : 10;
172 $limit = max( 0, min( 6, $limit ) );
173 $layout = isset( $settings['layout'] ) && in_array( $settings['layout'], [ 'list', 'grid', 'ticker' ], true )
174 ? $settings['layout']
175 : 'list';
176 $columns = isset( $settings['columns'] ) ? max( 2, min( 4, (int) $settings['columns'] ) ) : 2;
177
178 $show_summary = ! isset( $settings['show_summary'] ) || $settings['show_summary'];
179 $show_name = ! isset( $settings['show_name'] ) || $settings['show_name'];
180 $show_amount = ! isset( $settings['show_amount'] ) || $settings['show_amount'];
181 $show_avatar = ! isset( $settings['show_avatar'] ) || $settings['show_avatar'];
182 $show_date = ! isset( $settings['show_date'] ) || $settings['show_date'];
183
184 // Unset (a stub-registered element) takes the sample heading; an emptied
185 // heading stays empty.
186 $headline = isset( $settings['headline'] ) ? (string) $settings['headline'] : __( 'Recent Donors', 'better-payment' );
187 $accent = self::safe_color( isset( $settings['accent_color'] ) ? $settings['accent_color'] : '' );
188
189 $donors = array_slice( self::sample_donors(), 0, $limit );
190
191 $row_style = 'grid' === $layout
192 ? 'display:grid;grid-template-columns:repeat(' . (int) $columns . ',minmax(0,1fr));gap:10px;'
193 : 'display:flex;flex-direction:column;gap:8px;';
194
195 ob_start();
196 ?>
197 <div>
198 <?php if ( '' !== $headline ) : ?>
199 <h4 style="margin:0 0 12px;font-size:16px;font-weight:600;color:#1a1a2e;">
200 <?php echo esc_html( $headline ); ?>
201 </h4>
202 <?php endif; ?>
203
204 <?php if ( $show_summary ) : ?>
205 <div style="display:flex;gap:20px;padding:10px 12px;margin:0 0 12px;border-radius:6px;background:#f6f7f7;">
206 <span style="font-size:12px;color:#50575e;">
207 <strong style="display:block;font-size:16px;color:<?php echo esc_attr( $accent ); ?>;">$4,820</strong>
208 <?php esc_html_e( 'raised', 'better-payment' ); ?>
209 </span>
210 <span style="font-size:12px;color:#50575e;">
211 <strong style="display:block;font-size:16px;color:#1a1a2e;">37</strong>
212 <?php esc_html_e( 'donors', 'better-payment' ); ?>
213 </span>
214 </div>
215 <?php endif; ?>
216
217 <?php if ( 'ticker' === $layout && ! empty( $donors ) ) : ?>
218 <p style="margin:0 0 8px;font-size:11px;color:#787c82;font-style:italic;">
219 <?php esc_html_e( 'Ticker layout scrolls automatically on the live campaign.', 'better-payment' ); ?>
220 </p>
221 <?php endif; ?>
222
223 <?php if ( empty( $donors ) ) : ?>
224 <p style="margin:0;font-size:12px;color:#787c82;">
225 <?php esc_html_e( 'Donor list hidden — “Number of Donors To Show” is set to 0.', 'better-payment' ); ?>
226 </p>
227 <?php else : ?>
228 <div style="<?php echo esc_attr( $row_style ); ?>">
229 <?php foreach ( $donors as $donor ) : ?>
230 <div style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid #f0f0f1;border-radius:6px;">
231 <?php if ( $show_avatar ) : ?>
232 <span style="flex:0 0 auto;width:28px;height:28px;border-radius:50%;background:<?php echo esc_attr( $accent ); ?>;color:#fff;font-size:12px;font-weight:600;display:flex;align-items:center;justify-content:center;">
233 <?php echo esc_html( mb_substr( $donor['name'], 0, 1 ) ); ?>
234 </span>
235 <?php endif; ?>
236 <span style="flex:1 1 auto;min-width:0;">
237 <?php if ( $show_name ) : ?>
238 <span style="display:block;font-size:13px;font-weight:600;color:#1a1a2e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
239 <?php echo esc_html( $donor['name'] ); ?>
240 </span>
241 <?php endif; ?>
242 <?php if ( $show_date ) : ?>
243 <span style="display:block;font-size:11px;color:#787c82;">
244 <?php echo esc_html( $donor['when'] ); ?>
245 </span>
246 <?php endif; ?>
247 </span>
248 <?php if ( $show_amount ) : ?>
249 <span style="flex:0 0 auto;font-size:13px;font-weight:600;color:<?php echo esc_attr( $accent ); ?>;">
250 <?php echo esc_html( $donor['amount'] ); ?>
251 </span>
252 <?php endif; ?>
253 </div>
254 <?php endforeach; ?>
255 </div>
256 <?php endif; ?>
257 </div>
258 <?php
259 return (string) ob_get_clean();
260 }
261
262 /**
263 * The FAQ mock renders the author's OWN items when the saved settings carry
264 * any (a campaign built while Pro was active) — those questions are not Pro
265 * data. Otherwise it shows {@see self::sample_faq_items()}: placeholder copy
266 * that describes the widget and states nothing about the campaign. Only the
267 * accordion's interactivity is missing, so every item is shown open.
268 *
269 * @param array $settings
270 * @return string
271 */
272 private static function faq( array $settings ): string {
273 $heading = isset( $settings['heading'] ) ? (string) $settings['heading'] : __( 'Frequently Asked Questions', 'better-payment' );
274 $accent = self::safe_color( isset( $settings['accent_color'] ) ? $settings['accent_color'] : '' );
275
276 $items = ( isset( $settings['items'] ) && is_array( $settings['items'] ) ) ? $settings['items'] : [];
277 $items = array_slice( $items, 0, 30 );
278
279 if ( ! self::has_faq_content( $items ) ) {
280 $items = self::sample_faq_items();
281 }
282
283 ob_start();
284 ?>
285 <div>
286 <?php if ( '' !== $heading ) : ?>
287 <h4 style="margin:0 0 12px;font-size:16px;font-weight:600;color:#1a1a2e;">
288 <?php echo esc_html( $heading ); ?>
289 </h4>
290 <?php endif; ?>
291
292 <div style="display:flex;flex-direction:column;gap:8px;">
293 <?php foreach ( $items as $item ) :
294 $question = isset( $item['question'] ) ? (string) $item['question'] : '';
295 $answer = isset( $item['answer'] ) ? (string) $item['answer'] : '';
296
297 if ( '' === $question && '' === $answer ) {
298 continue;
299 }
300 ?>
301 <div style="border:1px solid #f0f0f1;border-radius:6px;padding:10px 12px;">
302 <div style="display:flex;align-items:flex-start;gap:8px;">
303 <span style="flex:0 0 auto;color:<?php echo esc_attr( $accent ); ?>;font-weight:700;line-height:1.4;">+</span>
304 <span style="flex:1 1 auto;font-size:13px;font-weight:600;color:#1a1a2e;line-height:1.4;">
305 <?php echo esc_html( $question ); ?>
306 </span>
307 </div>
308 <?php if ( '' !== $answer ) : ?>
309 <p style="margin:6px 0 0 18px;font-size:12px;line-height:1.6;color:#50575e;">
310 <?php echo esc_html( $answer ); ?>
311 </p>
312 <?php endif; ?>
313 </div>
314 <?php endforeach; ?>
315 </div>
316 </div>
317 <?php
318 return (string) ob_get_clean();
319 }
320
321 /**
322 * Whether any FAQ row carries a question or an answer.
323 *
324 * @param array $items Saved FAQ rows.
325 * @return bool
326 */
327 private static function has_faq_content( array $items ): bool {
328 foreach ( $items as $item ) {
329 if ( ! is_array( $item ) ) {
330 continue;
331 }
332 $question = isset( $item['question'] ) ? (string) $item['question'] : '';
333 $answer = isset( $item['answer'] ) ? (string) $item['answer'] : '';
334 if ( '' !== $question || '' !== $answer ) {
335 return true;
336 }
337 }
338
339 return false;
340 }
341
342 /**
343 * Sample FAQ rows for a mock with no saved questions.
344 *
345 * The questions are the generic ones any donor asks; the answers describe the
346 * widget, not the campaign, so nothing here can be read as a claim about how a
347 * real campaign spends its money.
348 *
349 * @return array<int, array<string, string>>
350 */
351 private static function sample_faq_items(): array {
352 return [
353 [
354 'question' => __( 'How will my donation be used?', 'better-payment' ),
355 'answer' => __( 'Your own answer appears here. Add the questions donors ask most, each with a short, direct answer.', 'better-payment' ),
356 ],
357 [
358 'question' => __( 'Is my payment secure?', 'better-payment' ),
359 'answer' => __( 'Sample answer. Each question opens and closes on your live campaign.', 'better-payment' ),
360 ],
361 [
362 'question' => __( 'Can I donate any amount?', 'better-payment' ),
363 'answer' => __( 'Sample answer. Add, remove and reorder questions as your campaign needs.', 'better-payment' ),
364 ],
365 ];
366 }
367
368 /**
369 * A poster frame, never a real embed.
370 *
371 * Rendering the actual `<iframe>` would mean Lite reproducing Pro's URL
372 * parsing and, for self-hosted files, its oEmbed HTTP call — exactly the
373 * business logic that must stay in Pro. It would also let a free install
374 * ship a working Video element, which is the feature being sold.
375 *
376 * The frame is **drawn**, not fetched. It deliberately does not pull the real
377 * thumbnail from `img.youtube.com`, which would have meant:
378 * - parsing a video ID out of the URL — the Pro-owned logic above;
379 * - an outbound request to Google on every builder preview render, carrying
380 * the admin's IP and referrer, which no one opted into;
381 * - a YouTube-only result, since Vimeo thumbnails need an oEmbed API call.
382 * Real artwork for one provider and a grey box for the others is a worse,
383 * less predictable preview than one honest frame for all three.
384 *
385 * What it must NOT do is invent facts. The play button, scrubber and duration
386 * pill are chrome — they say "this is a video player" and cannot be read as a
387 * claim about the user's campaign. A plausible-looking video title or a
388 * "12K views" counter would be, and is the same mistake the fictional donor
389 * names in {@see self::sample_donors()} are careful to avoid.
390 *
391 * That is also why nothing is printed *under* the frame. A channel row (avatar,
392 * title, source host) lived here briefly and was cut: it added a second block
393 * of grey text below the artwork for no information the user cannot see in the
394 * settings panel, and the title line was placeholder copy dressed as content.
395 * The poster is the whole element. Because it displays no data, `render()`
396 * also passes `$show_note = false` to {@see self::wrap()} — there is no sample
397 * data here to disclose, unlike Donors Wall and FAQ.
398 *
399 * With the source line gone, `$settings['url']` is no longer read here at all
400 * — `aspect_ratio` is the only setting that changes what is drawn. That is the
401 * strongest form of the boundary rule above: the mock cannot leak anything
402 * about the URL because it never looks at it.
403 *
404 * @param array $settings
405 * @return string
406 */
407 private static function video( array $settings ): string {
408 $ratios = [
409 '16-9' => '56.25%',
410 '4-3' => '75%',
411 '1-1' => '100%',
412 '21-9' => '42.86%',
413 ];
414 $ratio_key = isset( $settings['aspect_ratio'] ) ? (string) $settings['aspect_ratio'] : '16-9';
415 $padding = isset( $ratios[ $ratio_key ] ) ? $ratios[ $ratio_key ] : $ratios['16-9'];
416
417 // Branded artwork over a CSS gradient, in that order.
418 //
419 // The gradient is not decoration for the artwork — it is the fallback that
420 // renders if the SVG 404s (a partial deploy, a CDN rewrite, an install
421 // serving assets/ from somewhere unexpected). Declared underneath rather
422 // than instead of, so a missing file degrades to the plain dark poster
423 // this element had before instead of to a white rectangle with a red play
424 // button floating on it.
425 //
426 // The SVG carries the product's own palette — Better Payment indigo, Pro
427 // orange — plus the heart-in-hands mark shared with ai-default-hero.svg,
428 // so a placeholder video reads as the same family as AI-generated campaign
429 // artwork rather than as generic stock.
430 $poster_layers = 'linear-gradient(135deg,#252c52 0%,#151b31 55%,#0a0e18 100%)';
431
432 if ( defined( 'BETTER_PAYMENT_ASSETS' ) ) {
433 $poster_url = BETTER_PAYMENT_ASSETS . '/img/campaign/video-poster.svg';
434 $poster_layers = "url('" . esc_url( $poster_url ) . "') center/cover no-repeat," . $poster_layers;
435 }
436
437 ob_start();
438 ?>
439 <div>
440 <div style="position:relative;width:100%;padding-bottom:<?php echo esc_attr( $padding ); ?>;background:<?php echo esc_attr( $poster_layers ); ?>;border-radius:10px;overflow:hidden;">
441
442 <?php /* Play button — YouTube's rounded pill, not a circle. */ ?>
443 <span style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:68px;height:48px;border-radius:14px;background:rgba(255,0,0,.92);box-shadow:0 2px 10px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;">
444 <span style="display:block;width:0;height:0;margin-left:4px;border-style:solid;border-width:11px 0 11px 19px;border-color:transparent transparent transparent #fff;"></span>
445 </span>
446
447 <?php /* Duration pill. Chrome, not a claim — see the docblock. */ ?>
448 <span style="position:absolute;right:10px;bottom:12px;padding:2px 5px;border-radius:3px;background:rgba(0,0,0,.8);color:#fff;font-size:11px;font-weight:600;line-height:1.4;">2:14</span>
449
450 <?php
451 /*
452 * Scrubber — the red played portion only, deliberately with no
453 * track behind it. A light track (this was rgba(255,255,255,.28))
454 * paints a pale grey band across the full width of an otherwise
455 * dark poster, and at 3px tall against the rounded bottom corners
456 * it reads as a rendering seam rather than as part of the player.
457 * The red segment alone carries the same meaning.
458 */
459 ?>
460 <span style="position:absolute;left:0;bottom:0;width:38%;height:3px;background:#f00;"></span>
461 </div>
462 </div>
463 <?php
464 return (string) ob_get_clean();
465 }
466
467 /* --------------------------------------------------------------------- */
468 /* Fixtures */
469 /* --------------------------------------------------------------------- */
470
471 /**
472 * Obviously-fictional donors. Deliberately generic placeholder names — a
473 * demo that looked like real supporter data would be worse, not better.
474 *
475 * @return array<int, array<string, string>>
476 */
477 private static function sample_donors(): array {
478 return [
479 [
480 'name' => __( 'Jordan A.', 'better-payment' ),
481 'amount' => '$250',
482 'when' => __( '2 hours ago', 'better-payment' ),
483 ],
484 [
485 'name' => __( 'Priya S.', 'better-payment' ),
486 'amount' => '$100',
487 'when' => __( '5 hours ago', 'better-payment' ),
488 ],
489 [
490 'name' => __( 'Marco B.', 'better-payment' ),
491 'amount' => '$75',
492 'when' => __( 'Yesterday', 'better-payment' ),
493 ],
494 [
495 'name' => __( 'Anonymous', 'better-payment' ),
496 'amount' => '$50',
497 'when' => __( 'Yesterday', 'better-payment' ),
498 ],
499 [
500 'name' => __( 'Lena K.', 'better-payment' ),
501 'amount' => '$40',
502 'when' => __( '2 days ago', 'better-payment' ),
503 ],
504 [
505 'name' => __( 'Sam O.', 'better-payment' ),
506 'amount' => '$25',
507 'when' => __( '3 days ago', 'better-payment' ),
508 ],
509 ];
510 }
511
512 /**
513 * Accept only a literal hex colour; anything else falls back to the campaign
514 * purple. The value reaches a `style` attribute, so it must not be trusted
515 * even though it comes from an admin-authored layout.
516 *
517 * @param mixed $value
518 * @return string
519 */
520 private static function safe_color( $value ): string {
521 if ( is_string( $value ) && preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value ) ) {
522 return $value;
523 }
524
525 return '#6a4bff';
526 }
527 }
528