PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / trunk
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 All 66 releases
better-payment / includes / Campaign / Elements / ProElementPreview.php

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

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