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 / Templates / TemplateManager.php

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

2,191 lines 124.0 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\Templates;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 /**
10 * Manages campaign layout templates shown in the template selection modal.
11 *
12 * Templates define the initial page-level column layout that the builder starts
13 * with. Each template specifies a layout type (1-column, 2-column, 3-column)
14 * and pre-places elements into columns.
15 *
16 * Template shape:
17 * 'key' => string Unique key; must equal the array key.
18 * 'category' => string 'blank' (wireframe starters) | 'prebuilt' (designed).
19 * 'tags' => array Category filters in the builder sidebar. Only tags
20 * known to the UI are filterable — reuse an existing
21 * one ('charity', 'medical', 'education',
22 * 'environmental', 'club-organizations') unless you
23 * also extend the sidebar's CATEGORIES list.
24 * 'label' => string Card title.
25 * 'description' => string Card subtitle; also matched by the search box.
26 * 'layout' => string '1-column' | '2-column' | '3-column'.
27 * 'columns' => array [ [ 'id', 'label', 'width' => '65%', 'elements' => [
28 * [ 'id', 'type', 'settings' => [] ], ... ] ], ... ]
29 * 'default_title' => string Optional. Seeds the campaign title when applied.
30 * 'preview_image' => string Optional. Card thumbnail.
31 * 'preview_color' => string Optional. Solid-colour fallback when no image loads.
32 * 'theme_class' => string Optional. Extra class on the campaign wrapper for
33 * CSS to target. Ships no styles of its own.
34 * 'pro' => bool Optional. Marks the card as a Pro template. Set
35 * only by {@see ProTemplateCatalog} on a free
36 * install, where such a card is shown locked with an
37 * upgrade CTA and carries no `columns`. Pro's own
38 * registration never sets it — with Pro active a
39 * template must be indistinguishable from a free one.
40 *
41 * An element `settings` key only does something where RendererService reads it.
42 * The schema (`defaultSettings` / `settingsSchema`, see CampaignElements) is what
43 * the builder UI exposes, not the full set of live keys: an unrecognised key is
44 * stored and silently ignored, while a key absent from the schema may still be
45 * honoured (`donate_amount` reads `preset_amounts` as a legacy fallback). Prefer
46 * schema-declared keys — undeclared ones can't be edited in the builder.
47 *
48 * Typography (`font_size`, `font_weight`, `letter_spacing`, `line_height`,
49 * `color`, ... on `campaign_title` / `campaign_description`) is schema-only and
50 * is where a template's visual identity comes from. Absence is meaningful: unset
51 * renders a plain default that theme CSS can beat, set is emitted inline with
52 * `!important`.
53 *
54 * Element ids are regenerated on apply, so they need only be unique within the
55 * template.
56 *
57 * To register a custom template from another plugin:
58 * add_filter( 'better_payment/campaign_templates', function( $templates ) {
59 * $templates['my-template'] = [ ... ];
60 * return $templates;
61 * } );
62 *
63 * Templates registered from another plugin must supply `preview_image` as an
64 * absolute URL — a relative path is resolved against Better Payment's own
65 * directory. Registering a template that uses Pro-only element types is what
66 * makes it Pro-only: the filter simply isn't added when the add-on is inactive.
67 *
68 * That absence used to be the whole story for Better Payment Pro's four
69 * templates — invisible on a free install, so nobody could want them. They are
70 * now advertised as locked cards by {@see ProTemplateCatalog}, merged in just
71 * before the filter and only while Pro is inactive.
72 */
73 class TemplateManager {
74
75 /**
76 * Per-request memo for get_all().
77 *
78 * @var array<string, array>|null
79 */
80 private static $cache = null;
81
82 /**
83 * Get all available campaign templates.
84 *
85 * Memoized per request. get_all() is called on every public campaign page
86 * view (RendererService looks up `theme_class`), on the builder and campaign
87 * list screens, and from the REST route — rebuilding this array and running
88 * a get_users() query each time is pure waste, and the set cannot change
89 * within a request. Registrations via `better_payment/campaign_templates`
90 * land long before the first call (plugins register on load / plugins_loaded,
91 * every caller runs on admin_enqueue_scripts, rest_api_init or render).
92 *
93 * Tests that add or remove the filter mid-request must reset the memo first:
94 * `InvokesPrivate::set_static_property( TemplateManager::class, 'cache', null )`.
95 *
96 * @return array<string, array>
97 */
98 public static function get_all(): array {
99 if ( self::$cache !== null ) {
100 return self::$cache;
101 }
102
103 $first_users = get_users( [ 'fields' => [ 'ID' ], 'number' => 1 ] );
104 $first_creator_id = ! empty( $first_users ) ? (int) $first_users[0]->ID : get_current_user_id();
105 $tpl_img = BETTER_PAYMENT_ASSETS . '/img/campaign/templates';
106
107 // Design tokens for the templates below, resolved once so a template
108 // definition stays readable as data. See self::palette().
109 $charity = self::palette( 'charity-basic-v2' );
110 $medical = self::palette( 'medical-relief-v2' );
111 $education = self::palette( 'education-fund-v2' );
112 $golf = self::palette( 'golf-destinations-v2' );
113 $childcare = self::palette( 'child-healthcare-v2' );
114 $forest = self::palette( 'tree-plantation-v2' );
115 $animal = self::palette( 'animal-rescue-v2' );
116 $relief = self::palette( 'disaster-relief-v2' );
117
118 // Sidebar shared by the frozen `charity-basic` template. Retained because
119 // that template is frozen verbatim, not because anything new uses it.
120 $shared_sidebar = [
121 [
122 'id' => 'el_organizer',
123 'type' => 'organizer',
124 'settings' => [ 'creator_user_id' => $first_creator_id ],
125 ],
126 [
127 'id' => 'el_social_sharing',
128 'type' => 'social_sharing',
129 'settings' => [
130 'headline' => 'Share Now',
131 'twitter' => true,
132 'facebook' => true,
133 'linkedin' => true,
134 'pinterest' => true,
135 'mastodon' => true,
136 'threads' => false,
137 'bluesky' => false,
138 ],
139 ],
140 [
141 'id' => 'el_social_links',
142 'type' => 'social_links',
143 'settings' => [
144 'headline' => 'Follow Now',
145 'twitter' => 'https://twitter.com/',
146 'facebook' => 'https://facebook.com/',
147 'linkedin' => 'https://linkedin.com/in/',
148 'instagram' => '',
149 'tiktok' => '',
150 'pinterest' => '',
151 'youtube' => '',
152 'threads' => '',
153 'bluesky' => '',
154 'mastodon' => '',
155 ],
156 ],
157 ];
158
159 $templates = [
160
161 'blank-1col' => [
162 'key' => 'blank-1col',
163 'category' => 'blank',
164 'tags' => [],
165 'label' => __( '1 Column', 'better-payment' ),
166 'description' => __( 'Single column, vertical stack.', 'better-payment' ),
167 'layout' => '1-column',
168 'columns' => [
169 [
170 'id' => 'main',
171 'label' => __( 'Main Content', 'better-payment' ),
172 'width' => '100%',
173 'elements' => [],
174 ],
175 ],
176 ],
177
178 'blank-2col' => [
179 'key' => 'blank-2col',
180 'category' => 'blank',
181 'tags' => [],
182 'label' => __( '2 Column', 'better-payment' ),
183 'description' => __( 'Main content area with sidebar.', 'better-payment' ),
184 'layout' => '2-column',
185 'columns' => [
186 [
187 'id' => 'main',
188 'label' => __( 'Main Content', 'better-payment' ),
189 'width' => '65%',
190 'elements' => [],
191 ],
192 [
193 'id' => 'sidebar',
194 'label' => __( 'Sidebar', 'better-payment' ),
195 'width' => '35%',
196 'elements' => [],
197 ],
198 ],
199 ],
200
201 'blank-3col' => [
202 'key' => 'blank-3col',
203 'category' => 'blank',
204 'tags' => [],
205 'label' => __( 'Custom', 'better-payment' ),
206 'description' => __( 'A full-width row, then two equal columns.', 'better-payment' ),
207 // The 'split' preset renders as two rows: a full-width column on top,
208 // then two 50/50 columns below (scoped flex-wrap — see
209 // src/blocks/campaign-display/style.scss `.bp-campaign--split`).
210 'layout' => 'split',
211 'columns' => [
212 [
213 'id' => 'top',
214 'label' => __( 'Top (Full Width)', 'better-payment' ),
215 'width' => '100%',
216 'elements' => [],
217 ],
218 [
219 'id' => 'bottom-left',
220 'label' => __( 'Bottom Left', 'better-payment' ),
221 'width' => '50%',
222 'elements' => [],
223 ],
224 [
225 'id' => 'bottom-right',
226 'label' => __( 'Bottom Right', 'better-payment' ),
227 'width' => '50%',
228 'elements' => [],
229 ],
230 ],
231 ],
232
233 /*
234 * ── Frozen originals ──────────────────────────────────────────────
235 *
236 * The eight templates below are the pre-2.3.2 designs. They are kept
237 * verbatim and marked `hidden`: the picker no longer offers them
238 * (self::get_for_picker()), but they must stay in the registry
239 * because RendererService resolves `theme_class` from here on every
240 * page view of every campaign built from one. Deleting an entry would
241 * strip the styling off live pages; editing one would restyle them.
242 *
243 * The two without a `theme_class` (`charity-basic`, `medical-relief`)
244 * are frozen for consistency rather than necessity — nothing reads
245 * them back once they are hidden — so that "pre-2.3.2 templates are
246 * frozen" has no exceptions to remember.
247 *
248 * Do not touch these. New work goes into the `-v2` designs below.
249 */
250
251 'charity-basic' => [
252 'key' => 'charity-basic',
253 'category' => 'prebuilt',
254 'hidden' => true,
255 'tags' => [ 'charity' ],
256 'preview_image' => 'assets/img/campaign/templates/preview/charity-basic.webp',
257 'label' => __( 'Refugee Relief Fund', 'better-payment' ),
258 'default_title' => __( 'Refugee Relief Fund', 'better-payment' ),
259 'description' => __( 'Support refugee communities with immediate aid and long-term recovery.', 'better-payment' ),
260 'layout' => '2-column',
261 'columns' => [
262 [
263 'id' => 'main',
264 'label' => __( 'Main Content', 'better-payment' ),
265 'width' => '65%',
266 'elements' => [
267 [
268 'id' => 'el_title',
269 'type' => 'campaign_title',
270 'settings' => [],
271 ],
272 [
273 'id' => 'el_photo',
274 'type' => 'photo',
275 'settings' => [
276 'src' => $tpl_img . '/charity/Refugee-Relief-Fund.webp',
277 'alt' => 'Humanitarian aid workers helping refugee families',
278 ],
279 ],
280 [
281 'id' => 'el_desc',
282 'type' => 'campaign_description',
283 'settings' => [
284 'headline' => 'Help Refugees Find Safety, Shelter, and Hope',
285 'content' => 'Millions of people are forced to flee their homes due to conflict, persecution, and disaster. Your donation provides immediate relief — emergency shelter, food, clean water, and medical care — to refugee families who have lost everything. Every contribution, no matter the size, helps rebuild lives and restore dignity.',
286 ],
287 ],
288 [
289 'id' => 'el_summary',
290 'type' => 'campaign_summary',
291 'settings' => [ 'show_raised' => true, 'show_donors' => true, 'show_percent' => true, 'show_days' => true ],
292 ],
293 [
294 'id' => 'el_donate',
295 'type' => 'donation_form',
296 'settings' => [
297 'button_label' => 'Donate Now',
298 'button_color' => '#B49A5F',
299 'url' => '',
300 ],
301 ],
302 ],
303 ],
304 [
305 'id' => 'sidebar',
306 'label' => __( 'Sidebar', 'better-payment' ),
307 'width' => '35%',
308 'elements' => $shared_sidebar,
309 ],
310 ],
311 ],
312
313 'medical-relief' => [
314 'key' => 'medical-relief',
315 'category' => 'prebuilt',
316 'hidden' => true,
317 'tags' => [ 'medical' ],
318 'preview_image' => 'assets/img/campaign/templates/preview/medical-relief.webp',
319 'label' => __( 'Medical Emergency Fund', 'better-payment' ),
320 'default_title' => __( 'Medical Emergency Fund', 'better-payment' ),
321 'description' => __( 'Bring hope during medical emergencies through compassionate giving.', 'better-payment' ),
322 'layout' => '2-column',
323 'columns' => [
324 [
325 'id' => 'main',
326 'label' => __( 'Main Content', 'better-payment' ),
327 'width' => '50%',
328 'elements' => [
329 [
330 'id' => 'el_title',
331 'type' => 'campaign_title',
332 'settings' => [],
333 ],
334 [
335 'id' => 'el_desc1',
336 'type' => 'campaign_description',
337 'settings' => [
338 'headline' => '',
339 'content' => 'On June 12, 2023 Brook\'s wife Maleena was admitted to hospital while 20 weeks pregnant, where she was diagnosed with Stage 4 B-cell Lymphoma. At just 22 years old, she has been confronted with a life-altering diagnosis and is now facing a grueling fight. After experiencing inflammation and pain in her legs in March, Brooke spoke with her GP and sought treatment from chiropractors and physios.',
340 ],
341 ],
342 [
343 'id' => 'el_photo1',
344 'type' => 'photo',
345 'settings' => [
346 'src' => $tpl_img . '/medical/Medical-Emergency-Fund.webp',
347 'alt' => 'Patient receiving hospital care',
348 ],
349 ],
350 [
351 'id' => 'el_desc2',
352 'type' => 'campaign_description',
353 'settings' => [
354 'headline' => '',
355 'content' => 'Devastated by Maleena\'s diagnosis, Brooke found himself navigating a whirlwind of emotions and responsibilities. He became Maleena\'s unwavering pillar of support, attending every doctor\'s appointment and chemotherapy session with her, his heart aching at the sight of his young wife enduring such pain. The couple, once planning nursery decorations and baby names, now found themselves discussing treatment options and potential outcomes. Despite the overwhelming fear and uncertainty, they clung to each other, drawing strength from their love and the life growing inside Maleena. Friends and family rallied around them, offering words of encouragement and practical help, reminding them that they were not alone in this battle. In the face of adversity, Brooke and Maleena vowed to fight together, holding onto hope and cherishing every moment they had together, knowing that their love would be the guiding light in the darkest of times.',
356 ],
357 ],
358 [
359 'id' => 'el_photo2',
360 'type' => 'photo',
361 'settings' => [
362 'src' => 'https://images.unsplash.com/photo-1631217868264-e5b90bb7e133?auto=format&w=740&q=80&fit=crop',
363 'alt' => 'Doctor providing compassionate care',
364 ],
365 ],
366 ],
367 ],
368 [
369 'id' => 'sidebar',
370 'label' => __( 'Sidebar', 'better-payment' ),
371 'width' => '50%',
372 'elements' => [
373 [
374 'id' => 'el_summary',
375 'type' => 'campaign_summary',
376 'settings' => [ 'show_raised' => true, 'show_donors' => true, 'show_percent' => true, 'show_days' => true ],
377 ],
378 [
379 'id' => 'el_progress',
380 'type' => 'progress_bar',
381 'settings' => [ 'show_donated' => true, 'show_goal' => true ],
382 ],
383 [
384 'id' => 'el_funding_note',
385 'type' => 'campaign_description',
386 'settings' => [
387 'headline' => '',
388 'content' => 'This project will only be funded if at least $270,000 is raised by December 22, 2025.',
389 ],
390 ],
391 [
392 'id' => 'el_donate',
393 'type' => 'donation_form',
394 'settings' => [
395 'button_label' => 'Donate Now',
396 'button_color' => '#7A8347',
397 'url' => '',
398 ],
399 ],
400 [
401 'id' => 'el_organizer',
402 'type' => 'organizer',
403 'settings' => [ 'creator_user_id' => $first_creator_id ],
404 ],
405 ],
406 ],
407 ],
408 ],
409
410 'education-fund' => [
411 'key' => 'education-fund',
412 'category' => 'prebuilt',
413 'hidden' => true,
414 'tags' => [ 'education' ],
415 'preview_image' => 'assets/img/campaign/templates/preview/education-fund.webp',
416 'label' => __( 'Student Success Fund', 'better-payment' ),
417 'default_title' => __( 'Student Success Fund', 'better-payment' ),
418 'description' => __( 'Empower the next generation through education.', 'better-payment' ),
419 'theme_class' => 'school-trip',
420 'preview_color' => '#5c6b38',
421 'layout' => '2-column',
422 'columns' => [
423 [
424 'id' => 'main',
425 'label' => __( 'Main Content', 'better-payment' ),
426 'width' => '50%',
427 'elements' => [
428 [
429 'id' => 'el_title',
430 'type' => 'campaign_title',
431 'settings' => [],
432 ],
433 [
434 'id' => 'el_desc',
435 'type' => 'campaign_description',
436 'settings' => [
437 'headline' => 'Raise fund for our school trip!',
438 'content' => 'We are announcing a campaign to make the dream of a once-in-a-lifetime field trip come true for our graduating 5th graders: an unforgettable adventure in California! Your support will enable us to purchase necessary supplies, ensure safe travel, and provide accommodations for both parents and teachers accompanying the students. With your contribution, we can create lasting memories and valuable learning experiences, fostering a sense of camaraderie and curiosity that will stay with these young minds for a lifetime.',
439 ],
440 ],
441 [
442 'id' => 'el_photo',
443 'type' => 'photo',
444 'settings' => [
445 'src' => $tpl_img . '/education/Student-Success-Fund.webp',
446 'alt' => 'Student on a field trip outdoors',
447 ],
448 ],
449 ],
450 ],
451 [
452 'id' => 'sidebar',
453 'label' => __( 'Sidebar', 'better-payment' ),
454 'width' => '50%',
455 'elements' => [
456 [
457 'id' => 'el_photo_hero',
458 'type' => 'photo',
459 'settings' => [
460 'src' => 'https://images.unsplash.com/photo-1587560699334-cc4ff634909a?q=80&w=1740&auto=format&fit=crop',
461 'alt' => 'Students on a school camping trip',
462 ],
463 ],
464 [
465 'id' => 'el_summary',
466 'type' => 'campaign_summary',
467 'settings' => [ 'show_raised' => true, 'show_donors' => true, 'show_percent' => false, 'show_days' => false ],
468 ],
469 [
470 'id' => 'el_quote',
471 'type' => 'campaign_description',
472 'settings' => [
473 'headline' => 'Together, we can inspire these young minds and equip them with the tools they need to make a positive impact on the world.',
474 'content' => '',
475 ],
476 ],
477 [
478 'id' => 'el_body',
479 'type' => 'campaign_description',
480 'settings' => [
481 'headline' => '',
482 'content' => 'Your generosity will not only provide the means for this adventure but will also contribute to the overall educational enrichment of our students. This trip isn\'t just a mere excursion; it\'s a chance for our 5th graders to expand their horizons, learn about different cultures, and engage with historical and natural wonders that will enhance their academic understanding.',
483 ],
484 ],
485 [
486 'id' => 'el_progress',
487 'type' => 'progress_bar',
488 'settings' => [ 'show_donated' => true, 'show_goal' => true ],
489 ],
490 [
491 'id' => 'el_donate',
492 'type' => 'donation_form',
493 'settings' => [
494 'button_label' => 'Donate Now',
495 'button_color' => '#8fa040',
496 'url' => '',
497 ],
498 ],
499 [
500 'id' => 'el_social_links',
501 'type' => 'social_links',
502 'settings' => [
503 'headline' => 'Follow on',
504 'twitter' => 'https://twitter.com/',
505 'facebook' => 'https://facebook.com/',
506 'linkedin' => 'https://linkedin.com/in/',
507 'instagram' => '',
508 'tiktok' => '',
509 'pinterest' => '',
510 'youtube' => '',
511 'threads' => '',
512 'bluesky' => '',
513 'mastodon' => '',
514 ],
515 ],
516 ],
517 ],
518 ],
519 ],
520
521 'golf-destinations' => [
522 'key' => 'golf-destinations',
523 'category' => 'prebuilt',
524 'hidden' => true,
525 'tags' => [ 'club-organizations' ],
526 'preview_image' => 'assets/img/campaign/templates/preview/golf-destinations.webp',
527 'label' => __( 'Elite Golf Club Membership', 'better-payment' ),
528 'default_title' => __( 'Elite Golf Club Membership', 'better-payment' ),
529 'description' => __( 'Join an exclusive golfing experience built for champions.', 'better-payment' ),
530 'theme_class' => 'golf-destinations',
531 'preview_color' => '#b8a46a',
532 'layout' => '2-column',
533 'columns' => [
534 [
535 'id' => 'main',
536 'label' => __( 'Main Content', 'better-payment' ),
537 'width' => '50%',
538 'elements' => [
539 [
540 'id' => 'el_photo',
541 'type' => 'photo',
542 'settings' => [
543 'src' => $tpl_img . '/club-organizations/Elite-Golf-Club-Membership.webp',
544 'alt' => 'Golfer taking a swing on the course',
545 ],
546 ],
547 [
548 'id' => 'el_desc_main',
549 'type' => 'campaign_description',
550 'settings' => [
551 'headline' => 'With 100% of donations going towards grants and programs',
552 'content' => 'Every dollar contributed goes directly to maintaining our beloved club facilities, funding community programs, and supporting the next generation of golfers. Your generosity helps preserve a cherished institution that brings people together — from weekend players to seasoned champions. Join us in securing the future of this extraordinary place.',
553 ],
554 ]
555 ],
556 ],
557 [
558 'id' => 'content',
559 'label' => __( 'Donate', 'better-payment' ),
560 'width' => '50%',
561 'elements' => [
562 [
563 'id' => 'el_desc',
564 'type' => 'campaign_description',
565 'settings' => [
566 'headline' => 'About This Club',
567 'content' => 'We are announcing a campaign to support our cherished local country club, a haven for our community, especially our elderly residents. Our non-profit is passionately rallying for funds to ensure the maintenance of this vital space and to sustain the heartwarming annual events that bring together generations. By contributing, you\'re preserving a beloved institution that unites the young and the old, fostering a sense of belonging and community spirit. Join us in safeguarding this haven for all ages!',
568 ],
569 ],
570 [
571 'id' => 'el_progress',
572 'type' => 'progress_bar',
573 'settings' => [ 'show_donated' => true, 'show_goal' => true ],
574 ],
575 [
576 'id' => 'el_summary',
577 'type' => 'campaign_summary',
578 'settings' => [ 'show_raised' => true, 'show_donors' => true, 'show_percent' => false, 'show_days' => false ],
579 ],
580 [
581 'id' => 'el_donate',
582 'type' => 'donation_form',
583 'settings' => [
584 'button_label' => 'Donate Now',
585 'button_color' => '#b8a46a',
586 'url' => '',
587 ],
588 ],
589 ],
590 ],
591 ],
592 ],
593
594 'child-healthcare' => [
595 'key' => 'child-healthcare',
596 'category' => 'prebuilt',
597 'hidden' => true,
598 'tags' => [ 'medical' ],
599 'preview_image' => 'assets/img/campaign/templates/preview/child-healthcare.webp',
600 'label' => __( 'Child Healthcare Fund', 'better-payment' ),
601 'default_title' => __( 'Child Healthcare Fund', 'better-payment' ),
602 'description' => __( 'Help children access life-saving treatments, vaccinations, surgeries, and ongoing medical care.', 'better-payment' ),
603 'theme_class' => 'child-healthcare',
604 'preview_color' => '#ecc30b',
605 'layout' => '2-column',
606 'columns' => [
607 [
608 'id' => 'main',
609 'label' => __( 'Main Content', 'better-payment' ),
610 'width' => '50%',
611 'elements' => [
612 [
613 'id' => 'el_photo',
614 'type' => 'photo',
615 'settings' => [
616 'src' => $tpl_img . '/medical/Child-Healthcare-Fund.webp',
617 'alt' => 'Doctor giving compassionate care to a young child patient',
618 ],
619 ],
620 [
621 'id' => 'el_tagline',
622 'type' => 'campaign_description',
623 'settings' => [
624 'headline' => 'Every child deserves a healthy future',
625 'content' => 'Your generosity funds life-saving treatments, vaccinations, surgeries, and ongoing care for children who need it most. Together we can ensure no child suffers without access to proper medical support.',
626 ],
627 ],
628 [
629 'id' => 'el_summary_left',
630 'type' => 'campaign_summary',
631 'settings' => [ 'show_raised' => true, 'show_donors' => true, 'show_percent' => false, 'show_days' => false ],
632 ],
633 ],
634 ],
635 [
636 'id' => 'donate',
637 'label' => __( 'Donation Panel', 'better-payment' ),
638 'width' => '50%',
639 'elements' => [
640 [
641 'id' => 'el_label',
642 'type' => 'campaign_description',
643 'settings' => [
644 'headline' => 'Let\'s Help The Children',
645 'content' => '',
646 ],
647 ],
648 [
649 'id' => 'el_title',
650 'type' => 'campaign_title',
651 'settings' => [],
652 ],
653 [
654 'id' => 'el_amounts',
655 'type' => 'donate_amount',
656 'settings' => [
657 'preset_amounts' => '5,10,15,20',
658 ],
659 ],
660 [
661 'id' => 'el_donate',
662 'type' => 'donation_form',
663 'settings' => [
664 'button_label' => 'Donate Now',
665 'button_color' => '#ecc30b',
666 'url' => '',
667 ],
668 ],
669 [
670 'id' => 'el_sharing',
671 'type' => 'social_sharing',
672 'settings' => [
673 'headline' => 'Share Now',
674 'twitter' => true,
675 'facebook' => true,
676 'linkedin' => true,
677 'pinterest' => true,
678 'mastodon' => true,
679 'threads' => false,
680 'bluesky' => false,
681 ],
682 ],
683 [
684 'id' => 'el_links',
685 'type' => 'social_links',
686 'settings' => [
687 'headline' => 'Follow Now',
688 'twitter' => 'https://twitter.com/',
689 'facebook' => 'https://facebook.com/',
690 'linkedin' => '',
691 'instagram' => 'https://instagram.com/',
692 'tiktok' => '',
693 'pinterest' => '',
694 'youtube' => '',
695 'threads' => '',
696 'bluesky' => '',
697 'mastodon' => '',
698 ],
699 ],
700 ],
701 ],
702 ],
703 ],
704
705 'tree-plantation' => [
706 'key' => 'tree-plantation',
707 'category' => 'prebuilt',
708 'hidden' => true,
709 'tags' => [ 'environmental' ],
710 'preview_image' => 'assets/img/campaign/templates/preview/tree-plantation.webp',
711 'label' => __( 'Tree Plantation Campaign', 'better-payment' ),
712 'default_title' => __( 'Tree Plantation Campaign', 'better-payment' ),
713 'description' => __( 'Support environmental restoration through tree planting, conservation, and climate action initiatives.', 'better-payment' ),
714 'theme_class' => 'tree-plantation',
715 'preview_color' => '#61aa4f',
716 'layout' => '2-column',
717 'columns' => [
718 [
719 'id' => 'main',
720 'label' => __( 'Main Content', 'better-payment' ),
721 'width' => '50%',
722 'elements' => [
723 [
724 'id' => 'el_photo1',
725 'type' => 'photo',
726 'settings' => [
727 'src' => $tpl_img . '/environmental/Tree-Plantation-Campaign.webp',
728 'alt' => 'Hands holding a young plant growing from rich soil',
729 ],
730 ],
731 [
732 'id' => 'el_desc_body',
733 'type' => 'campaign_description',
734 'settings' => [
735 'headline' => 'Keep the scene green by taking the lead',
736 'content' => 'Your support will make a significant impact, enabling us to organize community clean-up events where volunteers, equipped with the necessary resources, can work together to remove garbage and restore the beach to its natural state. Additionally, your contributions will empower us to implement educational programs aimed at raising awareness about the importance of environmental conservation. We believe that through collective action, we can not only clean up our beloved beach but also inspire a lasting change in our community\'s attitudes towards environmental responsibility. Every donation, no matter how big or small, brings us one step closer to a cleaner, healthier environment for everyone. Join us in this vital endeavor, and let\'s create a positive ripple effect that benefits both our local ecosystem and the people who call this community home.',
737 ],
738 ],
739 [
740 'id' => 'el_photo2',
741 'type' => 'photo',
742 'settings' => [
743 'src' => 'https://images.unsplash.com/photo-1520962880247-cfaf541c8724?auto=format&w=1200&q=80&fit=crop',
744 'alt' => 'Environmental activists marching for climate action',
745 ],
746 ],
747 ],
748 ],
749 [
750 'id' => 'content',
751 'label' => __( 'Donation Content', 'better-payment' ),
752 'width' => '50%',
753 'elements' => [
754 [
755 'id' => 'el_label',
756 'type' => 'campaign_description',
757 'settings' => [
758 'headline' => 'Save Earth',
759 'content' => 'Together, let\'s reclaim the serenity of our shorelines and ensure a cleaner, greener future for generations to come.',
760 ],
761 ],
762 [
763 'id' => 'el_title',
764 'type' => 'campaign_title',
765 'settings' => [],
766 ],
767 [
768 'id' => 'el_desc',
769 'type' => 'campaign_description',
770 'settings' => [
771 'headline' => '',
772 'content' => 'We are announcing a campaign dedicated to restoring the natural beauty of our local beach, which has sadly fallen victim to years of neglect and pollution. Our non-profit is on a mission to raise funds for cleaning supplies, vehicles, and essential costs to orchestrate a massive cleanup effort. By contributing, you\'re not just supporting a cleaner beach but also promoting environmental health and community pride.',
773 ],
774 ],
775 [
776 'id' => 'el_summary',
777 'type' => 'campaign_summary',
778 'settings' => [ 'show_raised' => true, 'show_donors' => true, 'show_percent' => false, 'show_days' => false ],
779 ],
780 [
781 'id' => 'el_donate',
782 'type' => 'donation_form',
783 'settings' => [
784 'button_label' => 'Donate Now',
785 'button_color' => '#61aa4f',
786 'url' => '',
787 ],
788 ],
789 [
790 'id' => 'el_social_sharing',
791 'type' => 'social_sharing',
792 'settings' => [
793 'headline' => 'Share Now',
794 'twitter' => true,
795 'facebook' => true,
796 'linkedin' => true,
797 'pinterest' => true,
798 'mastodon' => true,
799 'threads' => false,
800 'bluesky' => false,
801 ],
802 ],
803 [
804 'id' => 'el_social_links',
805 'type' => 'social_links',
806 'settings' => [
807 'headline' => 'Follow Now',
808 'twitter' => 'https://twitter.com/',
809 'facebook' => 'https://facebook.com/',
810 'linkedin' => '',
811 'instagram' => 'https://instagram.com/',
812 'tiktok' => '',
813 'pinterest' => '',
814 'youtube' => '',
815 'threads' => '',
816 'bluesky' => '',
817 'mastodon' => '',
818 ],
819 ],
820 ],
821 ],
822 ],
823 ],
824
825 'animal-rescue' => [
826 'key' => 'animal-rescue',
827 'category' => 'prebuilt',
828 'hidden' => true,
829 'tags' => [ 'charity' ],
830 'preview_image' => 'assets/img/campaign/templates/preview/animal-rescue.webp',
831 'label' => __( 'Animal Rescue Fund', 'better-payment' ),
832 'default_title' => __( 'Animal Rescue Fund', 'better-payment' ),
833 'description' => __( 'Help rescue abandoned, injured, and homeless animals through shelter, food, medical care, and adoption support.', 'better-payment' ),
834 'theme_class' => 'animal-rescue',
835 'preview_color' => '#69B0B8',
836 'layout' => '2-column',
837 'columns' => [
838 [
839 'id' => 'main',
840 'label' => __( 'Main Content', 'better-payment' ),
841 'width' => '65%',
842 'elements' => [
843 [
844 'id' => 'el_photo',
845 'type' => 'photo',
846 'settings' => [
847 'src' => $tpl_img . '/charity/Animal-Rescue-Fund.webp',
848 'alt' => 'Adorable puppies waiting for a loving home',
849 ],
850 ],
851 [
852 'id' => 'el_title',
853 'type' => 'campaign_title',
854 'settings' => [],
855 ],
856 [
857 'id' => 'el_desc',
858 'type' => 'campaign_description',
859 'settings' => [
860 'headline' => 'Donate today to support our mission to rescue, rehabilitate, and rehome',
861 'content' => 'I\'m thrilled to launch our new campaign aimed at supporting an incredible cause: an animal sanctuary dedicated to rescuing abandoned and lost animals and finding them loving homes. With your help, we aim to raise funds to maintain this sanctuary, providing a safe haven for these adorable pets and ensuring they receive the care they deserve. Your contributions will not only help us sustain the facility but also enable us to actively seek new, caring families for these animals, giving them a chance at a brighter and happier future. Together, we can make a real difference in the lives of these innocent creatures.',
862 ],
863 ],
864 [
865 'id' => 'el_summary',
866 'type' => 'campaign_summary',
867 'settings' => [ 'show_raised' => true, 'show_donors' => true, 'show_percent' => false, 'show_days' => false ],
868 ],
869 [
870 'id' => 'el_donate',
871 'type' => 'donation_form',
872 'settings' => [
873 'button_label' => 'Donate Now',
874 'button_color' => '#69B0B8',
875 'url' => '',
876 ],
877 ],
878 ],
879 ],
880 [
881 'id' => 'sidebar',
882 'label' => __( 'Sidebar', 'better-payment' ),
883 'width' => '35%',
884 'elements' => [
885 [
886 'id' => 'el_organizer',
887 'type' => 'organizer',
888 'settings' => [ 'creator_user_id' => $first_creator_id ],
889 ],
890 [
891 'id' => 'el_social_sharing',
892 'type' => 'social_sharing',
893 'settings' => [
894 'headline' => 'Share Now',
895 'twitter' => true,
896 'facebook' => true,
897 'linkedin' => true,
898 'pinterest' => true,
899 'mastodon' => true,
900 'threads' => false,
901 'bluesky' => false,
902 ],
903 ],
904 [
905 'id' => 'el_social_links',
906 'type' => 'social_links',
907 'settings' => [
908 'headline' => 'Follow Now',
909 'twitter' => 'https://twitter.com/',
910 'facebook' => 'https://facebook.com/',
911 'linkedin' => '',
912 'instagram' => 'https://instagram.com/',
913 'tiktok' => '',
914 'pinterest' => '',
915 'youtube' => '',
916 'threads' => '',
917 'bluesky' => '',
918 'mastodon' => '',
919 ],
920 ],
921 ],
922 ],
923 ],
924 ],
925
926 'disaster-relief' => [
927 'key' => 'disaster-relief',
928 'category' => 'prebuilt',
929 'hidden' => true,
930 'tags' => [ 'environmental' ],
931 'preview_image' => 'assets/img/campaign/templates/preview/disaster-relief.webp',
932 'label' => __( 'Emergency Disaster Relief', 'better-payment' ),
933 'default_title' => __( 'Emergency Disaster Relief', 'better-payment' ),
934 'description' => __( 'Help communities recover when they need it most.', 'better-payment' ),
935 'theme_class' => 'disaster-relief',
936 'preview_color' => '#c0392b',
937 'layout' => '2-column',
938 'columns' => [
939 [
940 'id' => 'main',
941 'label' => __( 'Main Content', 'better-payment' ),
942 'width' => '65%',
943 'elements' => [
944 [
945 'id' => 'el_title',
946 'type' => 'campaign_title',
947 'settings' => [],
948 ],
949 [
950 'id' => 'el_photo',
951 'type' => 'photo',
952 'settings' => [
953 'src' => $tpl_img . '/environmental/Emergency-Disaster-Relief.webp',
954 'alt' => 'Disaster aftermath showing damaged buildings',
955 ],
956 ],
957 [
958 'id' => 'el_progress',
959 'type' => 'progress_bar',
960 'settings' => [ 'show_donated' => true, 'show_goal' => true ],
961 ],
962 [
963 'id' => 'el_desc',
964 'type' => 'campaign_description',
965 'settings' => [
966 'headline' => 'About This Campaign',
967 'content' => 'We are announcing a new campaign aimed at providing crucial support to the victims of a recent devastating natural disaster. Together, we are rallying behind our fellow community members, raising funds through this non-profit initiative to help them rebuild homes, restore essential services, and reclaim their lives. Join us in making a difference, as every contribution brings us one step closer to bringing hope and stability back to those in need.',
968 ],
969 ],
970 [
971 'id' => 'el_photo2',
972 'type' => 'photo',
973 'settings' => [
974 'src' => 'https://images.unsplash.com/photo-1547032175-7fc8c7bd15b3?q=80&w=2340&auto=format&fit=crop',
975 'alt' => 'Rescue and recovery efforts after disaster',
976 ],
977 ],
978 ],
979 ],
980 [
981 'id' => 'sidebar',
982 'label' => __( 'Sidebar', 'better-payment' ),
983 'width' => '35%',
984 'elements' => [
985 [
986 'id' => 'el_donate_intro',
987 'type' => 'campaign_description',
988 'settings' => [
989 'headline' => 'Donate Today',
990 'content' => 'This project will only be funded if at least $270,000 is raised by December 22, 2025.',
991 ],
992 ],
993 [
994 'id' => 'el_summary',
995 'type' => 'campaign_summary',
996 'settings' => [ 'show_raised' => true, 'show_donors' => true, 'show_percent' => false, 'show_days' => false ],
997 ],
998 [
999 'id' => 'el_donate',
1000 'type' => 'donation_form',
1001 'settings' => [
1002 'button_label' => 'Donate Now',
1003 'button_color' => '#c0392b',
1004 'url' => '',
1005 ],
1006 ],
1007 [
1008 'id' => 'el_social_sharing',
1009 'type' => 'social_sharing',
1010 'settings' => [
1011 'headline' => 'Share',
1012 'twitter' => true,
1013 'facebook' => true,
1014 'linkedin' => true,
1015 'pinterest' => true,
1016 'mastodon' => true,
1017 'threads' => false,
1018 'bluesky' => false,
1019 ],
1020 ],
1021 [
1022 'id' => 'el_social_links',
1023 'type' => 'social_links',
1024 'settings' => [
1025 'headline' => 'Follow on',
1026 'twitter' => 'https://twitter.com/',
1027 'facebook' => 'https://facebook.com/',
1028 'linkedin' => '',
1029 'instagram' => '',
1030 'tiktok' => '',
1031 'pinterest' => '',
1032 'youtube' => '',
1033 'threads' => '',
1034 'bluesky' => '',
1035 'mastodon' => '',
1036 ],
1037 ],
1038 ],
1039 ],
1040 ],
1041 ],
1042
1043 /*
1044 * ── The -v2 templates ─────────────────────────────────────────────
1045 *
1046 * Reissues of the eight templates above.
1047 *
1048 * The six of those that carry a `theme_class` could not be redesigned
1049 * in place. Unlike `columns` — a
1050 * snapshot copied into the campaign at apply time — `theme_class` is
1051 * resolved out of this registry on every page view, by the template
1052 * key stored in the campaign's `_bpc_template_key`, and the class it
1053 * names has real CSS behind it. Restyling that class would have
1054 * silently restyled every campaign ever built from the template, and
1055 * because the original blocks lean on `:nth-child()` — they assume a
1056 * photo is the first element in the column, a description the second
1057 * — reordering the template's elements would have pointed those rules
1058 * at whatever now sits in those positions. On live fundraising pages.
1059 *
1060 * (`charity-basic` and `medical-relief` set no `theme_class` and so
1061 * were never at risk; they are reissued alongside the rest purely so
1062 * the picker offers one coherent set rather than a mix of two visual
1063 * languages.)
1064 *
1065 * So the originals are frozen exactly as they shipped and marked
1066 * `hidden`, which keeps them resolvable by the renderer while
1067 * retiring them from the picker (see self::get_for_picker()), and the
1068 * new designs arrive under new keys with new theme classes. Existing
1069 * campaigns are byte-for-byte unaffected; only new ones get these.
1070 *
1071 * The new theme CSS (src/blocks/campaign-display/style.scss →
1072 * "v2 template themes") deliberately styles element classes only —
1073 * `.bp-campaign-summary`, `.bp-progress-bar`, `.bp-amount-label` —
1074 * and never element position, so a user adding, deleting or
1075 * reordering elements can no longer knock the design out of
1076 * alignment. That is the bug the original six shipped with.
1077 */
1078
1079 /*
1080 * Layout mix is deliberate. Lite ships three starter presets and the
1081 * picker should demonstrate all three, so the eight designs are split
1082 * 3 / 3 / 2 across `2-column`, `split` and `1-column`. Six of them
1083 * were 2-column at first, which made the prebuilt row look like one
1084 * layout in eight colourways and left `split` — the most interesting
1085 * preset Lite has — represented by a single card.
1086 *
1087 * Which template gets which is not arbitrary either: `split` suits a
1088 * page with a full-width hero and then a story/ask fork; `1-column`
1089 * suits an appeal or a prospectus read top to bottom, where a sidebar
1090 * would only compete; `2-column` is the classic story-plus-sticky-ask.
1091 *
1092 * ── The order below IS the order of the cards ─────────────────────
1093 *
1094 * The picker renders these in registry order, so this array literal
1095 * is a layout decision, not just a list. They are round-robined
1096 * 2-column → split → 1-column rather than grouped by preset: grouped,
1097 * the grid reads as three blocks of near-identical thumbnails and a
1098 * user scanning the first row sees one shape. Interleaved, every row
1099 * of a three-up grid shows all three.
1100 *
1101 * Categories and accent colours are spread along the same sequence —
1102 * no two neighbours share a tag, and no two similar hues sit side by
1103 * side (which is why the two greens, Golf and Tree Plantation, are
1104 * three cards apart). Only the layout rotation is pinned by a test;
1105 * the rest is judgement, so re-check it by eye when adding a ninth.
1106 */
1107
1108 'charity-basic-v2' => [
1109 'key' => 'charity-basic-v2',
1110 'category' => 'prebuilt',
1111 'tags' => [ 'charity' ],
1112 'preview_image' => 'assets/img/campaign/templates/preview/v2/charity-basic-v2.webp',
1113 'label' => __( 'Refugee Relief Fund', 'better-payment' ),
1114 'default_title' => __( 'Refugee Relief Fund', 'better-payment' ),
1115 'description' => __( 'Story-led appeal with a sticky donation panel — for humanitarian and emergency relief funds.', 'better-payment' ),
1116 'theme_class' => 'bp-v2-charity',
1117 'preview_color' => '#c2410c',
1118 'layout' => '2-column',
1119 'columns' => [
1120 [
1121 'id' => 'main',
1122 'label' => __( 'Main Content', 'better-payment' ),
1123 'width' => '62%',
1124 'elements' => [
1125 self::title_el( $charity, 'left' ),
1126 self::desc_el(
1127 'el_lede',
1128 $charity,
1129 '',
1130 'Families arrive carrying what they could hold in their arms. The first weeks decide the next year — whether there is a roof, whether the children go back to school, whether anyone is well enough to work. That window is what this fund exists to cover.',
1131 [ 'size' => 20 ]
1132 ),
1133 self::photo_el( 'el_hero', 'charity/Refugee-Relief-Fund.webp', 'Aid workers unloading supplies for refugee families' ),
1134 self::desc_el(
1135 'el_story',
1136 $charity,
1137 'Where your donation goes',
1138 'Shelter, bedding, food, clean water, medicine and school kits — bought as close to the people receiving them as possible, so the money stays in the communities hosting new arrivals. The rest keeps the work moving: transport, storage, and the caseworkers who make sure help reaches the families who need it most rather than the ones who are easiest to reach.'
1139 ),
1140 self::desc_el(
1141 'el_why_now',
1142 $charity,
1143 'Why it matters today',
1144 'Help is cheapest and furthest-reaching at the beginning. A family supported in their first month rarely needs emergency help again; a family reached much later often needs it for years. Giving now is not the same as giving later.',
1145 [ 'accent_headline' => true ]
1146 ),
1147 self::organizer_el(
1148 'Campaign Organizer',
1149 'I run this appeal and answer every message personally. If you would like to talk before giving, please do get in touch.'
1150 ),
1151 ],
1152 ],
1153 [
1154 'id' => 'sidebar',
1155 'label' => __( 'Sidebar', 'better-payment' ),
1156 'width' => '38%',
1157 'elements' => [
1158 self::progress_el( 'Appeal progress', 'Raised so far:', 'Appeal target:' ),
1159 self::summary_el( [ 'raised', 'donors', 'percent', 'days' ] ),
1160 self::amounts_el( 'Give what you can' ),
1161 self::donate_el( $charity, 'Give to this appeal' ),
1162 self::share_el( 'Share this appeal' ),
1163 self::links_el(
1164 'Follow our work',
1165 [
1166 'facebook' => 'https://facebook.com/',
1167 'twitter' => 'https://twitter.com/',
1168 'instagram' => 'https://instagram.com/',
1169 ]
1170 ),
1171 ],
1172 ],
1173 ],
1174 ],
1175
1176 'child-healthcare-v2' => [
1177 'key' => 'child-healthcare-v2',
1178 'category' => 'prebuilt',
1179 'tags' => [ 'medical' ],
1180 'preview_image' => 'assets/img/campaign/templates/preview/v2/child-healthcare-v2.webp',
1181 'label' => __( 'Child Healthcare Fund', 'better-payment' ),
1182 'default_title' => __( 'Child Healthcare Fund', 'better-payment' ),
1183 'description' => __( 'A dark, high-contrast appeal for paediatric care — headline first, then story and ask.', 'better-payment' ),
1184 'theme_class' => 'bp-v2-childcare',
1185 'preview_color' => '#38bdf8',
1186 'layout' => 'split',
1187 'columns' => [
1188 [
1189 'id' => 'top',
1190 'label' => __( 'Hero (Full Width)', 'better-payment' ),
1191 'width' => '100%',
1192 'elements' => [
1193 self::title_el( $childcare, 'left' ),
1194 self::desc_el(
1195 'el_lede',
1196 $childcare,
1197 '',
1198 'Children do not get to wait for a funding round to close. Every week a treatment is delayed is a week of growing up spent on a ward instead of in a classroom — and the treatments that work best are almost always the ones that start soonest.',
1199 [ 'size' => 21 ]
1200 ),
1201 ],
1202 ],
1203 [
1204 'id' => 'bottom-left',
1205 'label' => __( 'Story', 'better-payment' ),
1206 'width' => '50%',
1207 'elements' => [
1208 self::photo_el( 'el_hero', 'medical/Child-Healthcare-Fund.webp', 'A child being cared for on a hospital ward' ),
1209 self::desc_el(
1210 'el_story',
1211 $childcare,
1212 'What this fund pays for',
1213 'Theatre time, and the specialists who use it. Equipment a paediatric ward cannot borrow from anywhere else. And the travel, meals and beds that decide whether a parent can be in the room at all — the part of a child\'s treatment no clinical budget covers and every family notices.'
1214 ),
1215 self::desc_el(
1216 'el_after',
1217 $childcare,
1218 'After the goal is met',
1219 'Nothing here stops when the total is reached. Anything raised beyond it goes to the next child on the list, and we say publicly where it went. You should know that before you give, not afterwards.',
1220 [ 'accent_headline' => true ]
1221 ),
1222 self::organizer_el(
1223 'Appeal Lead',
1224 'I run this appeal and post an update whenever there is real news to report — not before.'
1225 ),
1226 ],
1227 ],
1228 [
1229 'id' => 'bottom-right',
1230 'label' => __( 'Donate', 'better-payment' ),
1231 'width' => '50%',
1232 'elements' => [
1233 self::progress_el( 'Progress so far', 'Raised:', 'Goal:' ),
1234 self::summary_el( [ 'raised', 'donors', 'percent', 'days' ] ),
1235 self::amounts_el( 'Choose an amount' ),
1236 self::donate_el( $childcare, 'Give Now' ),
1237 self::share_el( 'Share this appeal' ),
1238 self::links_el(
1239 'Follow the appeal',
1240 [
1241 'facebook' => 'https://facebook.com/',
1242 'twitter' => 'https://twitter.com/',
1243 'instagram' => 'https://instagram.com/',
1244 ]
1245 ),
1246 ],
1247 ],
1248 ],
1249 ],
1250
1251 'disaster-relief-v2' => [
1252 'key' => 'disaster-relief-v2',
1253 'category' => 'prebuilt',
1254 'tags' => [ 'environmental' ],
1255 'preview_image' => 'assets/img/campaign/templates/preview/v2/disaster-relief-v2.webp',
1256 'label' => __( 'Emergency Disaster Relief', 'better-payment' ),
1257 'default_title' => __( 'Emergency Disaster Relief', 'better-payment' ),
1258 'description' => __( 'Single-column emergency appeal that puts the figures and the ask above the fold.', 'better-payment' ),
1259 'theme_class' => 'bp-v2-relief',
1260 'preview_color' => '#dc2626',
1261 'layout' => '1-column',
1262 'columns' => [
1263 [
1264 'id' => 'main',
1265 'label' => __( 'Appeal', 'better-payment' ),
1266 'width' => '100%',
1267 'elements' => [
1268 self::title_el( $relief, 'left' ),
1269 self::desc_el(
1270 'el_lede',
1271 $relief,
1272 'This is happening now',
1273 'Teams are on the ground and supplies are staged. What moves this week depends on what is raised in the next few days, not next month. If you are going to give, giving now is worth more than giving later.',
1274 [ 'size' => 20, 'accent_headline' => true ]
1275 ),
1276 // Figures before the photo, on purpose. This is a
1277 // single-column page, so everything competes for the same
1278 // vertical space — and a full-width hero pushes the ask a
1279 // screen and a half down, which is the opposite of what an
1280 // emergency appeal is for.
1281 self::progress_el( '', 'Raised:', 'Goal:' ),
1282 self::summary_el( [ 'raised', 'donors', 'percent', 'days' ] ),
1283 self::photo_el( 'el_hero', 'environmental/Emergency-Disaster-Relief.webp', 'Relief teams distributing emergency supplies' ),
1284 self::amounts_el( 'Give what you can' ),
1285 self::donate_el( $relief, 'Donate Now' ),
1286 self::desc_el(
1287 'el_buys',
1288 $relief,
1289 'What your donation buys',
1290 'A family food parcel. A shelter kit for a household with no roof left. A water filter, and the fuel that gets it to a village the road no longer reaches. These are the units a response is actually measured in — not appeals, and not totals.'
1291 ),
1292 self::desc_el(
1293 'el_situation',
1294 $relief,
1295 'How the response is running',
1296 'Distribution is handled by people who were here before this happened and will still be here afterwards. That is the difference between aid that arrives and aid that is announced.',
1297 [ 'accent_headline' => true ]
1298 ),
1299 self::share_el( 'Share this appeal', 'left' ),
1300 self::links_el(
1301 'Follow the response',
1302 [
1303 'facebook' => 'https://facebook.com/',
1304 'twitter' => 'https://twitter.com/',
1305 ]
1306 ),
1307 ],
1308 ],
1309 ],
1310 ],
1311
1312 'medical-relief-v2' => [
1313 'key' => 'medical-relief-v2',
1314 'category' => 'prebuilt',
1315 'tags' => [ 'medical' ],
1316 'preview_image' => 'assets/img/campaign/templates/preview/v2/medical-relief-v2.webp',
1317 'label' => __( 'Medical Emergency Fund', 'better-payment' ),
1318 'default_title' => __( 'Medical Emergency Fund', 'better-payment' ),
1319 'description' => __( 'Calm, clinical two-column layout for treatment appeals and family fundraisers.', 'better-payment' ),
1320 'theme_class' => 'bp-v2-medical',
1321 'preview_color' => '#0f766e',
1322 'layout' => '2-column',
1323 'columns' => [
1324 [
1325 'id' => 'main',
1326 'label' => __( 'Main Content', 'better-payment' ),
1327 'width' => '55%',
1328 'elements' => [
1329 self::title_el( $medical, 'left' ),
1330 self::desc_el(
1331 'el_lede',
1332 $medical,
1333 '',
1334 'A diagnosis arrives and everything else stops. Treatment starts immediately, the bills start with it, and the person who would normally be earning is the one in the ward.',
1335 [ 'size' => 20 ]
1336 ),
1337 self::desc_el(
1338 'el_story',
1339 $medical,
1340 'The treatment ahead',
1341 'Care like this is measured in months rather than appointments: scans, cycles of treatment, the slow recovery between them, and the journeys to reach a hospital equipped to give it. None of it is quick, and none of it waits for a fundraising page to catch up.'
1342 ),
1343 self::photo_el( 'el_hero', 'medical/Medical-Emergency-Fund.webp', 'A patient receiving care in hospital' ),
1344 self::desc_el(
1345 'el_costs',
1346 $medical,
1347 'What your donation pays for',
1348 'Hospital fees and medication. Scans and the follow-up appointments after them. Travel to treatment, and somewhere to stay when it runs late. And the income a family loses while one of them is at a bedside instead of at work — the cost nobody budgets for and every family feels.',
1349 [ 'accent_headline' => true ]
1350 ),
1351 self::share_el( 'Share this fundraiser' ),
1352 ],
1353 ],
1354 [
1355 'id' => 'sidebar',
1356 'label' => __( 'Sidebar', 'better-payment' ),
1357 'width' => '45%',
1358 'elements' => [
1359 self::summary_el( [ 'raised', 'donors', 'days' ] ),
1360 self::progress_el( 'Treatment funded', 'Funded:', 'Treatment target:' ),
1361 self::amounts_el( 'Cover a cost' ),
1362 self::donate_el( $medical, 'Fund treatment' ),
1363 self::organizer_el(
1364 'Family Organizer',
1365 'I post an update here as treatment progresses. Message me if you have questions before giving.'
1366 ),
1367 self::links_el(
1368 'Follow for updates',
1369 [
1370 'facebook' => 'https://facebook.com/',
1371 'twitter' => 'https://twitter.com/',
1372 ]
1373 ),
1374 ],
1375 ],
1376 ],
1377 ],
1378
1379 'education-fund-v2' => [
1380 'key' => 'education-fund-v2',
1381 'category' => 'prebuilt',
1382 'tags' => [ 'education' ],
1383 'preview_image' => 'assets/img/campaign/templates/preview/v2/education-fund-v2.webp',
1384 'label' => __( 'Student Success Fund', 'better-payment' ),
1385 'default_title' => __( 'Student Success Fund', 'better-payment' ),
1386 'description' => __( 'Full-width hero, then story and donation side by side — for scholarship and hardship funds.', 'better-payment' ),
1387 'theme_class' => 'bp-v2-education',
1388 'preview_color' => '#4f46e5',
1389 'layout' => 'split',
1390 'columns' => [
1391 [
1392 'id' => 'top',
1393 'label' => __( 'Hero (Full Width)', 'better-payment' ),
1394 'width' => '100%',
1395 'elements' => [
1396 self::title_el( $education, 'left' ),
1397 self::desc_el(
1398 'el_lede',
1399 $education,
1400 '',
1401 'Almost nobody leaves education because they stopped caring. They leave because a bus fare ran out, a laptop broke, or a term bill landed in a month the family could not absorb.',
1402 [ 'size' => 21 ]
1403 ),
1404 self::progress_el( 'Progress so far', 'Raised:', 'Goal:' ),
1405 ],
1406 ],
1407 [
1408 'id' => 'bottom-left',
1409 'label' => __( 'Story', 'better-payment' ),
1410 'width' => '50%',
1411 'elements' => [
1412 // The artwork carries the campaign name in the image
1413 // itself, so at full hero width it restated the H1 in a
1414 // second typeface and a second palette. At half width it
1415 // reads as an illustration beside the story, which is
1416 // what it is.
1417 self::photo_el( 'el_hero', 'education/Student-Success-Fund.webp', 'Students working together in a classroom' ),
1418 self::desc_el(
1419 'el_covers',
1420 $education,
1421 'What the fund covers',
1422 'Tuition and exam fees. Textbooks, lab materials, and a laptop that actually works. Travel to and from campus. And a small hardship grant for the months when none of the above is the real problem. Wherever it can be, the money is paid straight to the institution, so it lands where it was given for.'
1423 ),
1424 self::desc_el(
1425 'el_reaches',
1426 $education,
1427 'Who it reaches',
1428 'Students who have already done the hard part — earned the place, kept the grades, and then hit a wall that has nothing to do with ability. A modest amount at the right moment is often the whole difference between a degree finished and a year abandoned.',
1429 [ 'accent_headline' => true ]
1430 ),
1431 ],
1432 ],
1433 [
1434 'id' => 'bottom-right',
1435 'label' => __( 'Donate', 'better-payment' ),
1436 'width' => '50%',
1437 'elements' => [
1438 self::summary_el( [ 'raised', 'donors', 'percent', 'days' ] ),
1439 self::amounts_el( 'Choose an amount' ),
1440 self::donate_el( $education, 'Fund a Student' ),
1441 self::organizer_el(
1442 'Fund Coordinator',
1443 'I manage this fund and publish an update at the end of every term. Questions before you give are always welcome.'
1444 ),
1445 self::share_el( 'Share this fund' ),
1446 self::links_el(
1447 'Follow the fund',
1448 [
1449 'facebook' => 'https://facebook.com/',
1450 'twitter' => 'https://twitter.com/',
1451 'linkedin' => 'https://linkedin.com/',
1452 ]
1453 ),
1454 ],
1455 ],
1456 ],
1457 ],
1458
1459 'golf-destinations-v2' => [
1460 'key' => 'golf-destinations-v2',
1461 'category' => 'prebuilt',
1462 'tags' => [ 'club-organizations' ],
1463 'preview_image' => 'assets/img/campaign/templates/preview/v2/golf-destinations-v2.webp',
1464 'label' => __( 'Elite Golf Club Membership', 'better-payment' ),
1465 'default_title' => __( 'Elite Golf Club Membership', 'better-payment' ),
1466 'description' => __( 'A single-column prospectus for clubs and societies collecting subscriptions.', 'better-payment' ),
1467 'theme_class' => 'bp-v2-golf',
1468 'preview_color' => '#15803d',
1469 'layout' => '1-column',
1470 'columns' => [
1471 [
1472 'id' => 'main',
1473 'label' => __( 'Membership', 'better-payment' ),
1474 'width' => '100%',
1475 'elements' => [
1476 self::title_el( $golf, 'left' ),
1477 self::desc_el(
1478 'el_lede',
1479 $golf,
1480 '',
1481 'A course that rewards a good round and forgives an honest one, a clubhouse worth staying in afterwards, and a membership small enough that people know your name.',
1482 [ 'size' => 20 ]
1483 ),
1484 self::photo_el( 'el_hero', 'club-organizations/Elite-Golf-Club-Membership.webp', 'The clubhouse and first fairway at dawn' ),
1485 self::progress_el( 'Subscriptions received', 'Received:', 'Target:' ),
1486 self::summary_el( [ 'raised', 'donors', 'days' ] ),
1487 self::amounts_el( 'Membership category' ),
1488 self::donate_el( $golf, 'Join the Club' ),
1489 self::desc_el(
1490 'el_about',
1491 $golf,
1492 'About the club',
1493 'The club has kept the same character for as long as anyone can remember: unhurried, welcoming, and serious about the golf without being solemn about it. Members play year-round, guests are genuinely welcome, and the calendar is full enough to matter without ever feeling like an obligation.'
1494 ),
1495 self::desc_el(
1496 'el_includes',
1497 $golf,
1498 'What membership includes',
1499 'Full course access and priority booking, with guest rates for the people you bring. A locker and winter storage. Coaching, the competition calendar and the off-season programme. The clubhouse and its terrace — and reciprocal arrangements with clubs beyond this one.',
1500 [ 'accent_headline' => true ]
1501 ),
1502 self::share_el( 'Share with a member', 'left' ),
1503 self::links_el(
1504 'Club channels',
1505 [
1506 'facebook' => 'https://facebook.com/',
1507 'instagram' => 'https://instagram.com/',
1508 ]
1509 ),
1510 ],
1511 ],
1512 ],
1513 ],
1514
1515 'animal-rescue-v2' => [
1516 'key' => 'animal-rescue-v2',
1517 'category' => 'prebuilt',
1518 'tags' => [ 'charity' ],
1519 'preview_image' => 'assets/img/campaign/templates/preview/v2/animal-rescue-v2.webp',
1520 'label' => __( 'Animal Rescue Fund', 'better-payment' ),
1521 'default_title' => __( 'Animal Rescue Fund', 'better-payment' ),
1522 'description' => __( 'Warm, story-led page for shelters and rescue groups taking ongoing donations.', 'better-payment' ),
1523 'theme_class' => 'bp-v2-animal',
1524 'preview_color' => '#be185d',
1525 'layout' => '2-column',
1526 'columns' => [
1527 [
1528 'id' => 'main',
1529 'label' => __( 'Main Content', 'better-payment' ),
1530 'width' => '68%',
1531 'elements' => [
1532 self::photo_el( 'el_hero', 'charity/Animal-Rescue-Fund.webp', 'A rescued dog being cared for at the shelter' ),
1533 self::title_el( $animal, 'left' ),
1534 self::desc_el(
1535 'el_lede',
1536 $animal,
1537 '',
1538 'Every animal that comes through the door arrives mid-story. Some need a fortnight and a warm room. Some need surgery, months of rehabilitation and a foster carer with more patience than most people have. This fund is what stops the difference coming down to what happens to be in the account that week.',
1539 [ 'size' => 20 ]
1540 ),
1541 self::desc_el(
1542 'el_what',
1543 $animal,
1544 'What your donation covers',
1545 'Veterinary treatment and medication. Vaccination, neutering and microchipping. Food, bedding, and the heating bill for a building that cannot be allowed to get cold. Fuel for the emergency call-outs that come in at hours nobody chose.'
1546 ),
1547 self::desc_el(
1548 'el_hardest',
1549 $animal,
1550 'The ones who take longest',
1551 'The straightforward cases rehome themselves. The animals who need us are the old, the frightened and the badly hurt — the ones a shelter running on empty has to turn away. Every donation here is really a decision about how often we get to say yes.',
1552 [ 'accent_headline' => true ]
1553 ),
1554 self::links_el(
1555 'Follow the shelter',
1556 [
1557 'facebook' => 'https://facebook.com/',
1558 'instagram' => 'https://instagram.com/',
1559 'tiktok' => 'https://tiktok.com/',
1560 ]
1561 ),
1562 ],
1563 ],
1564 [
1565 'id' => 'sidebar',
1566 'label' => __( 'Sidebar', 'better-payment' ),
1567 'width' => '32%',
1568 'elements' => [
1569 self::progress_el( 'Shelter fund', 'Raised:', 'Target:' ),
1570 self::summary_el( [ 'raised', 'donors', 'days' ] ),
1571 self::amounts_el( 'Choose your gift' ),
1572 self::donate_el( $animal, 'Sponsor an animal' ),
1573 self::organizer_el(
1574 'Shelter Manager',
1575 'I look after the animals here and write the updates. If you would like to visit before donating, just ask.'
1576 ),
1577 self::share_el( 'Share the shelter' ),
1578 ],
1579 ],
1580 ],
1581 ],
1582
1583 'tree-plantation-v2' => [
1584 'key' => 'tree-plantation-v2',
1585 'category' => 'prebuilt',
1586 'tags' => [ 'environmental' ],
1587 'preview_image' => 'assets/img/campaign/templates/preview/v2/tree-plantation-v2.webp',
1588 'label' => __( 'Tree Plantation Campaign', 'better-payment' ),
1589 'default_title' => __( 'Tree Plantation Campaign', 'better-payment' ),
1590 'description' => __( 'Calm, green reforestation page built around cost-per-tree giving.', 'better-payment' ),
1591 'theme_class' => 'bp-v2-forest',
1592 'preview_color' => '#047857',
1593 'layout' => 'split',
1594 'columns' => [
1595 [
1596 'id' => 'top',
1597 'label' => __( 'Hero (Full Width)', 'better-payment' ),
1598 'width' => '100%',
1599 'elements' => [
1600 self::photo_el( 'el_hero', 'environmental/Tree-Plantation-Campaign.webp', 'Newly planted saplings on restored land' ),
1601 self::title_el( $forest, 'left' ),
1602 self::desc_el(
1603 'el_lede',
1604 $forest,
1605 '',
1606 'Planting a tree is the cheap part. Choosing the right species, getting it into the ground in the right season, and coming back for years to make sure it survived — that is the part that needs funding, and the part that decides whether any of it mattered.',
1607 [ 'size' => 21 ]
1608 ),
1609 ],
1610 ],
1611 [
1612 'id' => 'bottom-left',
1613 'label' => __( 'Story', 'better-payment' ),
1614 'width' => '50%',
1615 'elements' => [
1616 self::desc_el(
1617 'el_how',
1618 $forest,
1619 'How the planting works',
1620 'Seedlings are raised locally and planted by the people who live on the land being restored. Each site is tended long after the photographs are taken: watered through the first dry seasons, thinned, replanted where it failed, and left alone only once it can look after itself.'
1621 ),
1622 self::desc_el(
1623 'el_where',
1624 $forest,
1625 'Where we plant',
1626 'On ground that has been cleared, burned or farmed past exhaustion — where a forest will not come back on its own. Species are chosen for the place rather than for the pace, so what grows here belongs here.',
1627 [ 'accent_headline' => true ]
1628 ),
1629 self::links_el(
1630 'Follow the planting',
1631 [
1632 'facebook' => 'https://facebook.com/',
1633 'instagram' => 'https://instagram.com/',
1634 'youtube' => 'https://youtube.com/',
1635 ]
1636 ),
1637 ],
1638 ],
1639 [
1640 'id' => 'bottom-right',
1641 'label' => __( 'Donate', 'better-payment' ),
1642 'width' => '50%',
1643 'elements' => [
1644 self::progress_el( 'Progress so far', 'Raised:', 'Goal:' ),
1645 self::summary_el( [ 'raised', 'donors', 'percent', 'days' ] ),
1646 self::amounts_el( 'Plant a tree' ),
1647 self::donate_el( $forest, 'Plant Trees' ),
1648 self::organizer_el(
1649 'Planting Coordinator',
1650 'I look after the sites and report back at the end of each planting season, survival rates included.'
1651 ),
1652 self::share_el( 'Share this campaign' ),
1653 ],
1654 ],
1655 ],
1656 ],
1657
1658 ];
1659
1660 // Locked cards for the four Pro templates — added only when Pro is
1661 // inactive, so a free user can see what Pro's designs look like instead
1662 // of never learning they exist. They carry no `columns`, so there is
1663 // nothing to apply; see ProTemplateCatalog. Merged BEFORE the filter on
1664 // purpose: Pro registers the real templates under these same keys, so
1665 // even if the entitlement read were somehow wrong, the genuine template
1666 // still wins over its own placeholder.
1667 $templates = array_merge( $templates, ProTemplateCatalog::get_locked() );
1668
1669 /**
1670 * Filters the campaign templates offered in the builder.
1671 *
1672 * Fires once per request (the result is memoized). Add-ons key their own
1673 * templates in — see the class docblock for the expected shape. Templates
1674 * registered from outside this plugin must give `preview_image` as an
1675 * absolute URL; plugin-relative paths resolve against Better Payment's
1676 * own directory.
1677 *
1678 * @param array<string, array> $templates Templates keyed by template key.
1679 */
1680 // @var is deliberate: the @param above describes what we pass in, but a
1681 // third-party listener can return anything at all.
1682 /** @var mixed $filtered */
1683 $filtered = apply_filters( 'better_payment/campaign_templates', $templates );
1684
1685 // An add-on returning a non-array must not take the built-in templates down
1686 // with it, nor fatal the `: array` return — keep the unfiltered set instead.
1687 self::$cache = is_array( $filtered ) ? $filtered : $templates;
1688
1689 return self::$cache;
1690 }
1691
1692 /**
1693 * Templates the builder's picker is allowed to offer.
1694 *
1695 * {@see self::get_all()} is the registry — it must keep serving every
1696 * template ever shipped, because {@see \Better_Payment\Lite\Campaign\Services\RendererService}
1697 * resolves a live campaign's `theme_class` out of it on every single page
1698 * view, keyed by the `_bpc_template_key` stored when the campaign was built.
1699 * Drop a retired template from there and every campaign built from it loses
1700 * its styling on the next page load.
1701 *
1702 * The picker has the opposite need: a retired design must stop being offered
1703 * to new campaigns. `hidden` is what separates the two, and this is the only
1704 * place it is honoured — the four UI surfaces (the builder, the campaign
1705 * list, the React admin and the REST route) call this; the three renderer
1706 * call sites deliberately do not.
1707 *
1708 * Keys are preserved (the callers `array_values()` themselves), and a
1709 * template with no `hidden` key behaves exactly as before, so add-ons
1710 * registering through `better_payment/campaign_templates` need no changes.
1711 *
1712 * @since 2.3.2
1713 *
1714 * @return array<string, array>
1715 */
1716 public static function get_for_picker(): array {
1717 return array_filter(
1718 self::get_all(),
1719 static function ( $template ) {
1720 return empty( $template['hidden'] );
1721 }
1722 );
1723 }
1724
1725 /**
1726 * Every template key that carries a `theme_class`, as `key => class`.
1727 *
1728 * Built from {@see self::get_all()}, not {@see self::get_for_picker()}: a
1729 * campaign built from a retired design still renders under its theme class,
1730 * and the builder has to describe that campaign's theme as accurately as the
1731 * frontend renders it. Pro's templates arrive through
1732 * `better_payment/campaign_templates` like any other, so they are included
1733 * whenever Pro is active and absent when it is not — the same live
1734 * entitlement every other campaign surface reads.
1735 *
1736 * Localized to the builder as `betterPaymentCampaignData.templateThemes`,
1737 * where `utils/themeBackground.js` resolves the theme's own page colour out
1738 * of the loaded campaign CSS. The Advanced tab's Background swatch used to
1739 * claim `#ffffff` for every campaign, whatever the theme actually paints, and
1740 * its Reset button then wrote that white in as a real override.
1741 *
1742 * @since 2.4.0
1743 *
1744 * @return array<string, string>
1745 */
1746 public static function theme_classes(): array {
1747 $classes = array();
1748
1749 foreach ( self::get_all() as $key => $template ) {
1750 if ( empty( $template['theme_class'] ) || ! is_string( $template['theme_class'] ) ) {
1751 continue;
1752 }
1753
1754 // Sanitized exactly as the renderer sanitizes it before printing it
1755 // on the wrapper, so the class the builder probes is the class the
1756 // campaign gets.
1757 $classes[ (string) $key ] = sanitize_html_class( $template['theme_class'] );
1758 }
1759
1760 return $classes;
1761 }
1762
1763 /* --------------------------------------------------------------------- */
1764 /* Design tokens */
1765 /* --------------------------------------------------------------------- */
1766
1767 /**
1768 * Per-template design tokens.
1769 *
1770 * A template's visual identity comes from two places and no others: the
1771 * typography and colours it sets on its own elements (emitted inline with
1772 * `!important` by the renderer), and — for the `-v2` templates only — the
1773 * stylesheet block keyed on its `theme_class`. This table holds the first,
1774 * and the SCSS holds the second; `accent` is deliberately the same value in
1775 * both so a button and its theme agree.
1776 *
1777 * Every `accent` is dark enough to carry white text at 4.5:1 — it colours the
1778 * donate button and the selected amount chip, both of which the renderer sets
1779 * `#fff` on. Teal, amber and sky all failed that at their obvious mid-tone
1780 * (#0d9488 → 3.7:1, #d97706 → 3.2:1), so they are one step darker here; sky
1781 * could not be darkened without losing the dark theme's only bright colour,
1782 * and instead gets dark button text in the SCSS. Check a new accent before
1783 * adding one.
1784 *
1785 * `accent` drives the donate button and the amount chips; `ink` and `muted`
1786 * are heading and body colours; `title` / `head` / `body` are the type
1787 * scale. Sizes are px, weights are CSS weight strings, `track` is
1788 * letter-spacing in px (negative tightens large headings) and `leading` is a
1789 * unitless multiplier — the formats
1790 * `RendererService::css_font_size()` / `css_length()` / `css_line_height()`
1791 * accept. A value outside them is silently dropped rather than failing
1792 * loudly, which is why `CampaignTemplatesTest` asserts the formats.
1793 *
1794 * @since 2.3.2
1795 *
1796 * @param string $key Template key.
1797 * @return array
1798 */
1799 private static function palette( string $key ): array {
1800 $palettes = [
1801
1802 // Terracotta on warm stone. Humanitarian without being sombre.
1803 'charity-basic-v2' => [
1804 'accent' => '#c2410c',
1805 'ink' => '#1c1917',
1806 'muted' => '#57534e',
1807 'title' => [ 'size' => 46, 'weight' => '800', 'track' => -1.2, 'leading' => 1.06 ],
1808 'head' => [ 'size' => 25, 'weight' => '700', 'track' => -0.4 ],
1809 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1810 ],
1811
1812 // Clinical teal. Calm and precise — the tone a medical appeal needs.
1813 'medical-relief-v2' => [
1814 'accent' => '#0f766e',
1815 'ink' => '#0f172a',
1816 'muted' => '#475569',
1817 'title' => [ 'size' => 44, 'weight' => '800', 'track' => -1.1, 'leading' => 1.08 ],
1818 'head' => [ 'size' => 24, 'weight' => '700', 'track' => -0.35 ],
1819 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1820 ],
1821
1822 // Indigo. Optimistic and contemporary — a scholarship prospectus.
1823 'education-fund-v2' => [
1824 'accent' => '#4f46e5',
1825 'ink' => '#1e1b4b',
1826 'muted' => '#4c4a6a',
1827 'title' => [ 'size' => 50, 'weight' => '800', 'track' => -1.5, 'leading' => 1.04 ],
1828 'head' => [ 'size' => 25, 'weight' => '700', 'track' => -0.4 ],
1829 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1830 ],
1831
1832 // Fairway green on cream. Restrained, club-stationery formality.
1833 'golf-destinations-v2' => [
1834 'accent' => '#15803d',
1835 'ink' => '#14261a',
1836 'muted' => '#4b5c50',
1837 'title' => [ 'size' => 40, 'weight' => '700', 'track' => 1.2, 'leading' => 1.12, 'transform' => 'uppercase' ],
1838 'head' => [ 'size' => 22, 'weight' => '700', 'track' => 0.2 ],
1839 'body' => [ 'size' => 17, 'leading' => 1.8 ],
1840 ],
1841
1842 // Sky on midnight. The one dark template — see the SCSS note on why
1843 // the theme, not the element settings, owns the light text.
1844 'child-healthcare-v2' => [
1845 'accent' => '#38bdf8',
1846 'ink' => '#f1f5f9',
1847 'muted' => '#a8b6c8',
1848 'title' => [ 'size' => 50, 'weight' => '800', 'track' => -1.6, 'leading' => 1.04 ],
1849 'head' => [ 'size' => 25, 'weight' => '700', 'track' => -0.4 ],
1850 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1851 ],
1852
1853 // Emerald on mint. Growth, quietly.
1854 'tree-plantation-v2' => [
1855 'accent' => '#047857',
1856 'ink' => '#0f2e23',
1857 'muted' => '#47615a',
1858 'title' => [ 'size' => 48, 'weight' => '800', 'track' => -1.4, 'leading' => 1.05 ],
1859 'head' => [ 'size' => 25, 'weight' => '700', 'track' => -0.4 ],
1860 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1861 ],
1862
1863 // Amber on warm white. Friendly, hand-on-the-shoulder.
1864 // Rose on blush, soft and round. Amber sat a few degrees from
1865 // charity-basic's terracotta, so the two cards read as one design in
1866 // two shades — the whole reason this template was reworked.
1867 'animal-rescue-v2' => [
1868 'accent' => '#be185d',
1869 'ink' => '#3f0c22',
1870 'muted' => '#6b4453',
1871 'title' => [ 'size' => 44, 'weight' => '800', 'track' => -1.0, 'leading' => 1.1 ],
1872 'head' => [ 'size' => 24, 'weight' => '700', 'track' => -0.2 ],
1873 'body' => [ 'size' => 17, 'leading' => 1.8 ],
1874 ],
1875
1876 // Red, uppercase, tight. Urgency before a word is read.
1877 'disaster-relief-v2' => [
1878 'accent' => '#dc2626',
1879 'ink' => '#18181b',
1880 'muted' => '#3f3f46',
1881 'title' => [ 'size' => 50, 'weight' => '800', 'track' => -1.6, 'leading' => 1.03 ],
1882 'head' => [ 'size' => 26, 'weight' => '700', 'track' => -0.5 ],
1883 'body' => [ 'size' => 18, 'leading' => 1.7 ],
1884 ],
1885 ];
1886
1887 return $palettes[ $key ];
1888 }
1889
1890 /* --------------------------------------------------------------------- */
1891 /* Helpers */
1892 /* --------------------------------------------------------------------- */
1893
1894 /**
1895 * URL for campaign artwork shipped with the plugin.
1896 *
1897 * Templates must never hotlink a third-party image host. Four of the
1898 * original prebuilt templates pull a second photo from `images.unsplash.com`
1899 * — that URL is copied verbatim into every campaign built from them, so the
1900 * page depends on a CDN nobody here controls, leaks each visitor's IP and
1901 * referrer to it, and shows a broken image the day the photo is pulled. The
1902 * `-v2` templates ship every image they use.
1903 *
1904 * @since 2.3.2
1905 *
1906 * @param string $path Path below assets/img/campaign/templates/.
1907 * @return string
1908 */
1909 private static function template_image( string $path ): string {
1910 return BETTER_PAYMENT_ASSETS . '/img/campaign/templates/' . $path;
1911 }
1912
1913 /**
1914 * Memo for {@see self::default_organizer_id()}.
1915 *
1916 * @var int|null
1917 */
1918 private static $organizer_id = null;
1919
1920 /**
1921 * The user the Organizer element points at until the campaign owner changes it.
1922 *
1923 * The first user on the site, falling back to whoever is building the
1924 * campaign. Memoized so building nine templates costs one query, not nine.
1925 *
1926 * @since 2.3.2
1927 *
1928 * @return int
1929 */
1930 private static function default_organizer_id(): int {
1931 if ( self::$organizer_id !== null ) {
1932 return self::$organizer_id;
1933 }
1934
1935 $users = get_users( [ 'fields' => [ 'ID' ], 'number' => 1 ] );
1936 self::$organizer_id = ! empty( $users ) ? (int) $users[0]->ID : get_current_user_id();
1937
1938 return self::$organizer_id;
1939 }
1940
1941 /* --------------------------------------------------------------------- */
1942 /* Element builders */
1943 /* --------------------------------------------------------------------- */
1944
1945 /**
1946 * Campaign title, styled from the palette's title scale.
1947 *
1948 * The text is left unset on purpose — `APPLY_TEMPLATE` seeds it from the
1949 * template's `default_title` (or the campaign's existing title, if the user
1950 * has already named it), so hardcoding it here would overwrite a name the
1951 * user chose.
1952 *
1953 * @param array $p Palette.
1954 * @param string $align left|center|right.
1955 * @return array
1956 */
1957 private static function title_el( array $p, string $align = 'left' ): array {
1958 $settings = [
1959 'align' => $align,
1960 'font_size' => $p['title']['size'],
1961 'font_weight' => $p['title']['weight'],
1962 'letter_spacing' => $p['title']['track'],
1963 'line_height' => $p['title']['leading'],
1964 'color' => $p['ink'],
1965 ];
1966
1967 if ( ! empty( $p['title']['transform'] ) ) {
1968 $settings['text_transform'] = $p['title']['transform'];
1969 }
1970
1971 return [ 'id' => 'el_title', 'type' => 'campaign_title', 'settings' => $settings ];
1972 }
1973
1974 /**
1975 * Description block with its headline and body styled independently.
1976 *
1977 * `title_*` keys style the headline, unprefixed keys the body — two separate
1978 * typography sets on one element, which is what lets a template carry a real
1979 * heading/body hierarchy rather than one flat block of text.
1980 *
1981 * @param string $id Element id.
1982 * @param array $p Palette.
1983 * @param string $headline Headline text ('' renders no headline).
1984 * @param string $content Body copy.
1985 * @param array $opts Optional: 'align', 'size' (body px), 'accent_headline' (bool).
1986 * @return array
1987 */
1988 private static function desc_el( string $id, array $p, string $headline, string $content, array $opts = [] ): array {
1989 $settings = [
1990 'headline' => $headline,
1991 'content' => $content,
1992 'title_font_size' => $p['head']['size'],
1993 'title_font_weight' => $p['head']['weight'],
1994 'title_letter_spacing' => $p['head']['track'],
1995 'title_color' => ! empty( $opts['accent_headline'] ) ? $p['accent'] : $p['ink'],
1996 'font_size' => $opts['size'] ?? $p['body']['size'],
1997 'line_height' => $p['body']['leading'],
1998 'color' => $p['muted'],
1999 ];
2000
2001 if ( isset( $opts['align'] ) ) {
2002 $settings['align'] = $opts['align'];
2003 }
2004
2005 return [ 'id' => $id, 'type' => 'campaign_description', 'settings' => $settings ];
2006 }
2007
2008 /**
2009 * Photo element.
2010 *
2011 * @param string $id Element id.
2012 * @param string $path Path below assets/img/campaign/templates/.
2013 * @param string $alt Alt text.
2014 * @param string $align left|center|right.
2015 * @return array
2016 */
2017 private static function photo_el( string $id, string $path, string $alt, string $align = 'center' ): array {
2018 return [
2019 'id' => $id,
2020 'type' => 'photo',
2021 'settings' => [
2022 'src' => self::template_image( $path ),
2023 'alt' => $alt,
2024 'width' => 100,
2025 'align' => $align,
2026 ],
2027 ];
2028 }
2029
2030 /**
2031 * Progress bar.
2032 *
2033 * `donate_label` / `goal_label` are prefixes the renderer prints an amount
2034 * after — never put a figure in one, or the page reads "Goal: $5,000 $5,000".
2035 *
2036 * @param string $headline Headline ('' renders no headline).
2037 * @param string $raised Label printed before the raised figure.
2038 * @param string $goal Label printed before the goal figure.
2039 * @param string $align left|center|right.
2040 * @return array
2041 */
2042 private static function progress_el( string $headline, string $raised, string $goal, string $align = 'left' ): array {
2043 return [
2044 'id' => 'el_progress',
2045 'type' => 'progress_bar',
2046 'settings' => [
2047 'headline' => $headline,
2048 'show_donated' => true,
2049 'show_goal' => true,
2050 'round_amounts' => true,
2051 'donate_label' => $raised,
2052 'goal_label' => $goal,
2053 'width' => 100,
2054 'align' => $align,
2055 ],
2056 ];
2057 }
2058
2059 /**
2060 * Campaign summary figures.
2061 *
2062 * @param array $show Which figures to show: raised, donors, percent, days.
2063 * @param string $align left|center|right.
2064 * @return array
2065 */
2066 private static function summary_el( array $show, string $align = 'left' ): array {
2067 return [
2068 'id' => 'el_summary',
2069 'type' => 'campaign_summary',
2070 'settings' => [
2071 'headline' => '',
2072 'show_raised' => in_array( 'raised', $show, true ),
2073 'show_donors' => in_array( 'donors', $show, true ),
2074 'show_percent' => in_array( 'percent', $show, true ),
2075 'show_days' => in_array( 'days', $show, true ),
2076 'width' => 100,
2077 'align' => $align,
2078 ],
2079 ];
2080 }
2081
2082 /**
2083 * Suggested donation amounts.
2084 *
2085 * Only `headline` is an element setting — the amounts themselves live in
2086 * campaign meta (`bpc_suggested_amounts`, a metaKey-bound control), which a
2087 * template cannot seed through element settings.
2088 *
2089 * @param string $headline Headline text.
2090 * @return array
2091 */
2092 private static function amounts_el( string $headline ): array {
2093 return [ 'id' => 'el_amounts', 'type' => 'donate_amount', 'settings' => [ 'headline' => $headline ] ];
2094 }
2095
2096 /**
2097 * Donate button, coloured from the palette accent.
2098 *
2099 * Always placed directly after {@see self::amounts_el()} where both appear:
2100 * choosing an amount and confirming it is one action, and a button parked
2101 * further down the page reads as an unrelated control.
2102 *
2103 * @param array $p Palette.
2104 * @param string $label Button label.
2105 * @return array
2106 */
2107 private static function donate_el( array $p, string $label ): array {
2108 return [
2109 'id' => 'el_donate',
2110 'type' => 'donation_form',
2111 'settings' => [
2112 'button_label' => $label,
2113 'button_color' => $p['accent'],
2114 'url' => '',
2115 'width' => 100,
2116 'align' => 'center',
2117 ],
2118 ];
2119 }
2120
2121 /**
2122 * Organizer block bound to the default organizer.
2123 *
2124 * @param string $role_title Role shown under the name.
2125 * @param string $description Short bio.
2126 * @return array
2127 */
2128 private static function organizer_el( string $role_title, string $description ): array {
2129 return [
2130 'id' => 'el_organizer',
2131 'type' => 'organizer',
2132 'settings' => [
2133 'creator_user_id' => self::default_organizer_id(),
2134 'role_title' => $role_title,
2135 'description' => $description,
2136 'width' => 100,
2137 'align' => 'left',
2138 ],
2139 ];
2140 }
2141
2142 /**
2143 * Social sharing buttons.
2144 *
2145 * @param string $headline Headline text.
2146 * @param string $align left|center|right.
2147 * @return array
2148 */
2149 private static function share_el( string $headline, string $align = 'left' ): array {
2150 return [
2151 'id' => 'el_share',
2152 'type' => 'social_sharing',
2153 'settings' => [
2154 'headline' => $headline,
2155 'facebook' => true,
2156 'twitter' => true,
2157 'linkedin' => true,
2158 'threads' => true,
2159 'pinterest' => false,
2160 'mastodon' => false,
2161 'bluesky' => false,
2162 'align' => $align,
2163 ],
2164 ];
2165 }
2166
2167 /**
2168 * Social profile links.
2169 *
2170 * The URLs are bare network home pages, never an invented handle: a
2171 * plausible-looking `facebook.com/<something>` points a fundraiser at an
2172 * account nobody involved controls.
2173 *
2174 * @param string $headline Headline text.
2175 * @param array $links Network => URL.
2176 * @param string $align left|center|right.
2177 * @return array
2178 */
2179 private static function links_el( string $headline, array $links, string $align = 'left' ): array {
2180 return [
2181 'id' => 'el_links',
2182 'type' => 'social_links',
2183 'settings' => array_merge(
2184 [ 'headline' => $headline, 'align' => $align ],
2185 $links
2186 ),
2187 ];
2188 }
2189
2190 }
2191