| 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 |
|