PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.3
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.3
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.3.3, at includes/Campaign/Templates/TemplateManager.php

2,153 lines 122.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\Campaign\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 /* Design tokens */
1727 /* --------------------------------------------------------------------- */
1728
1729 /**
1730 * Per-template design tokens.
1731 *
1732 * A template's visual identity comes from two places and no others: the
1733 * typography and colours it sets on its own elements (emitted inline with
1734 * `!important` by the renderer), and — for the `-v2` templates only — the
1735 * stylesheet block keyed on its `theme_class`. This table holds the first,
1736 * and the SCSS holds the second; `accent` is deliberately the same value in
1737 * both so a button and its theme agree.
1738 *
1739 * Every `accent` is dark enough to carry white text at 4.5:1 — it colours the
1740 * donate button and the selected amount chip, both of which the renderer sets
1741 * `#fff` on. Teal, amber and sky all failed that at their obvious mid-tone
1742 * (#0d9488 → 3.7:1, #d97706 → 3.2:1), so they are one step darker here; sky
1743 * could not be darkened without losing the dark theme's only bright colour,
1744 * and instead gets dark button text in the SCSS. Check a new accent before
1745 * adding one.
1746 *
1747 * `accent` drives the donate button and the amount chips; `ink` and `muted`
1748 * are heading and body colours; `title` / `head` / `body` are the type
1749 * scale. Sizes are px, weights are CSS weight strings, `track` is
1750 * letter-spacing in px (negative tightens large headings) and `leading` is a
1751 * unitless multiplier — the formats
1752 * `RendererService::css_font_size()` / `css_length()` / `css_line_height()`
1753 * accept. A value outside them is silently dropped rather than failing
1754 * loudly, which is why `CampaignTemplatesTest` asserts the formats.
1755 *
1756 * @since 2.3.2
1757 *
1758 * @param string $key Template key.
1759 * @return array
1760 */
1761 private static function palette( string $key ): array {
1762 $palettes = [
1763
1764 // Terracotta on warm stone. Humanitarian without being sombre.
1765 'charity-basic-v2' => [
1766 'accent' => '#c2410c',
1767 'ink' => '#1c1917',
1768 'muted' => '#57534e',
1769 'title' => [ 'size' => 46, 'weight' => '800', 'track' => -1.2, 'leading' => 1.06 ],
1770 'head' => [ 'size' => 25, 'weight' => '700', 'track' => -0.4 ],
1771 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1772 ],
1773
1774 // Clinical teal. Calm and precise — the tone a medical appeal needs.
1775 'medical-relief-v2' => [
1776 'accent' => '#0f766e',
1777 'ink' => '#0f172a',
1778 'muted' => '#475569',
1779 'title' => [ 'size' => 44, 'weight' => '800', 'track' => -1.1, 'leading' => 1.08 ],
1780 'head' => [ 'size' => 24, 'weight' => '700', 'track' => -0.35 ],
1781 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1782 ],
1783
1784 // Indigo. Optimistic and contemporary — a scholarship prospectus.
1785 'education-fund-v2' => [
1786 'accent' => '#4f46e5',
1787 'ink' => '#1e1b4b',
1788 'muted' => '#4c4a6a',
1789 'title' => [ 'size' => 50, 'weight' => '800', 'track' => -1.5, 'leading' => 1.04 ],
1790 'head' => [ 'size' => 25, 'weight' => '700', 'track' => -0.4 ],
1791 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1792 ],
1793
1794 // Fairway green on cream. Restrained, club-stationery formality.
1795 'golf-destinations-v2' => [
1796 'accent' => '#15803d',
1797 'ink' => '#14261a',
1798 'muted' => '#4b5c50',
1799 'title' => [ 'size' => 40, 'weight' => '700', 'track' => 1.2, 'leading' => 1.12, 'transform' => 'uppercase' ],
1800 'head' => [ 'size' => 22, 'weight' => '700', 'track' => 0.2 ],
1801 'body' => [ 'size' => 17, 'leading' => 1.8 ],
1802 ],
1803
1804 // Sky on midnight. The one dark template — see the SCSS note on why
1805 // the theme, not the element settings, owns the light text.
1806 'child-healthcare-v2' => [
1807 'accent' => '#38bdf8',
1808 'ink' => '#f1f5f9',
1809 'muted' => '#a8b6c8',
1810 'title' => [ 'size' => 50, 'weight' => '800', 'track' => -1.6, 'leading' => 1.04 ],
1811 'head' => [ 'size' => 25, 'weight' => '700', 'track' => -0.4 ],
1812 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1813 ],
1814
1815 // Emerald on mint. Growth, quietly.
1816 'tree-plantation-v2' => [
1817 'accent' => '#047857',
1818 'ink' => '#0f2e23',
1819 'muted' => '#47615a',
1820 'title' => [ 'size' => 48, 'weight' => '800', 'track' => -1.4, 'leading' => 1.05 ],
1821 'head' => [ 'size' => 25, 'weight' => '700', 'track' => -0.4 ],
1822 'body' => [ 'size' => 17, 'leading' => 1.75 ],
1823 ],
1824
1825 // Amber on warm white. Friendly, hand-on-the-shoulder.
1826 // Rose on blush, soft and round. Amber sat a few degrees from
1827 // charity-basic's terracotta, so the two cards read as one design in
1828 // two shades — the whole reason this template was reworked.
1829 'animal-rescue-v2' => [
1830 'accent' => '#be185d',
1831 'ink' => '#3f0c22',
1832 'muted' => '#6b4453',
1833 'title' => [ 'size' => 44, 'weight' => '800', 'track' => -1.0, 'leading' => 1.1 ],
1834 'head' => [ 'size' => 24, 'weight' => '700', 'track' => -0.2 ],
1835 'body' => [ 'size' => 17, 'leading' => 1.8 ],
1836 ],
1837
1838 // Red, uppercase, tight. Urgency before a word is read.
1839 'disaster-relief-v2' => [
1840 'accent' => '#dc2626',
1841 'ink' => '#18181b',
1842 'muted' => '#3f3f46',
1843 'title' => [ 'size' => 50, 'weight' => '800', 'track' => -1.6, 'leading' => 1.03 ],
1844 'head' => [ 'size' => 26, 'weight' => '700', 'track' => -0.5 ],
1845 'body' => [ 'size' => 18, 'leading' => 1.7 ],
1846 ],
1847 ];
1848
1849 return $palettes[ $key ];
1850 }
1851
1852 /* --------------------------------------------------------------------- */
1853 /* Helpers */
1854 /* --------------------------------------------------------------------- */
1855
1856 /**
1857 * URL for campaign artwork shipped with the plugin.
1858 *
1859 * Templates must never hotlink a third-party image host. Four of the
1860 * original prebuilt templates pull a second photo from `images.unsplash.com`
1861 * — that URL is copied verbatim into every campaign built from them, so the
1862 * page depends on a CDN nobody here controls, leaks each visitor's IP and
1863 * referrer to it, and shows a broken image the day the photo is pulled. The
1864 * `-v2` templates ship every image they use.
1865 *
1866 * @since 2.3.2
1867 *
1868 * @param string $path Path below assets/img/campaign/templates/.
1869 * @return string
1870 */
1871 private static function template_image( string $path ): string {
1872 return BETTER_PAYMENT_ASSETS . '/img/campaign/templates/' . $path;
1873 }
1874
1875 /**
1876 * Memo for {@see self::default_organizer_id()}.
1877 *
1878 * @var int|null
1879 */
1880 private static $organizer_id = null;
1881
1882 /**
1883 * The user the Organizer element points at until the campaign owner changes it.
1884 *
1885 * The first user on the site, falling back to whoever is building the
1886 * campaign. Memoized so building nine templates costs one query, not nine.
1887 *
1888 * @since 2.3.2
1889 *
1890 * @return int
1891 */
1892 private static function default_organizer_id(): int {
1893 if ( self::$organizer_id !== null ) {
1894 return self::$organizer_id;
1895 }
1896
1897 $users = get_users( [ 'fields' => [ 'ID' ], 'number' => 1 ] );
1898 self::$organizer_id = ! empty( $users ) ? (int) $users[0]->ID : get_current_user_id();
1899
1900 return self::$organizer_id;
1901 }
1902
1903 /* --------------------------------------------------------------------- */
1904 /* Element builders */
1905 /* --------------------------------------------------------------------- */
1906
1907 /**
1908 * Campaign title, styled from the palette's title scale.
1909 *
1910 * The text is left unset on purpose — `APPLY_TEMPLATE` seeds it from the
1911 * template's `default_title` (or the campaign's existing title, if the user
1912 * has already named it), so hardcoding it here would overwrite a name the
1913 * user chose.
1914 *
1915 * @param array $p Palette.
1916 * @param string $align left|center|right.
1917 * @return array
1918 */
1919 private static function title_el( array $p, string $align = 'left' ): array {
1920 $settings = [
1921 'align' => $align,
1922 'font_size' => $p['title']['size'],
1923 'font_weight' => $p['title']['weight'],
1924 'letter_spacing' => $p['title']['track'],
1925 'line_height' => $p['title']['leading'],
1926 'color' => $p['ink'],
1927 ];
1928
1929 if ( ! empty( $p['title']['transform'] ) ) {
1930 $settings['text_transform'] = $p['title']['transform'];
1931 }
1932
1933 return [ 'id' => 'el_title', 'type' => 'campaign_title', 'settings' => $settings ];
1934 }
1935
1936 /**
1937 * Description block with its headline and body styled independently.
1938 *
1939 * `title_*` keys style the headline, unprefixed keys the body — two separate
1940 * typography sets on one element, which is what lets a template carry a real
1941 * heading/body hierarchy rather than one flat block of text.
1942 *
1943 * @param string $id Element id.
1944 * @param array $p Palette.
1945 * @param string $headline Headline text ('' renders no headline).
1946 * @param string $content Body copy.
1947 * @param array $opts Optional: 'align', 'size' (body px), 'accent_headline' (bool).
1948 * @return array
1949 */
1950 private static function desc_el( string $id, array $p, string $headline, string $content, array $opts = [] ): array {
1951 $settings = [
1952 'headline' => $headline,
1953 'content' => $content,
1954 'title_font_size' => $p['head']['size'],
1955 'title_font_weight' => $p['head']['weight'],
1956 'title_letter_spacing' => $p['head']['track'],
1957 'title_color' => ! empty( $opts['accent_headline'] ) ? $p['accent'] : $p['ink'],
1958 'font_size' => $opts['size'] ?? $p['body']['size'],
1959 'line_height' => $p['body']['leading'],
1960 'color' => $p['muted'],
1961 ];
1962
1963 if ( isset( $opts['align'] ) ) {
1964 $settings['align'] = $opts['align'];
1965 }
1966
1967 return [ 'id' => $id, 'type' => 'campaign_description', 'settings' => $settings ];
1968 }
1969
1970 /**
1971 * Photo element.
1972 *
1973 * @param string $id Element id.
1974 * @param string $path Path below assets/img/campaign/templates/.
1975 * @param string $alt Alt text.
1976 * @param string $align left|center|right.
1977 * @return array
1978 */
1979 private static function photo_el( string $id, string $path, string $alt, string $align = 'center' ): array {
1980 return [
1981 'id' => $id,
1982 'type' => 'photo',
1983 'settings' => [
1984 'src' => self::template_image( $path ),
1985 'alt' => $alt,
1986 'width' => 100,
1987 'align' => $align,
1988 ],
1989 ];
1990 }
1991
1992 /**
1993 * Progress bar.
1994 *
1995 * `donate_label` / `goal_label` are prefixes the renderer prints an amount
1996 * after — never put a figure in one, or the page reads "Goal: $5,000 $5,000".
1997 *
1998 * @param string $headline Headline ('' renders no headline).
1999 * @param string $raised Label printed before the raised figure.
2000 * @param string $goal Label printed before the goal figure.
2001 * @param string $align left|center|right.
2002 * @return array
2003 */
2004 private static function progress_el( string $headline, string $raised, string $goal, string $align = 'left' ): array {
2005 return [
2006 'id' => 'el_progress',
2007 'type' => 'progress_bar',
2008 'settings' => [
2009 'headline' => $headline,
2010 'show_donated' => true,
2011 'show_goal' => true,
2012 'round_amounts' => true,
2013 'donate_label' => $raised,
2014 'goal_label' => $goal,
2015 'width' => 100,
2016 'align' => $align,
2017 ],
2018 ];
2019 }
2020
2021 /**
2022 * Campaign summary figures.
2023 *
2024 * @param array $show Which figures to show: raised, donors, percent, days.
2025 * @param string $align left|center|right.
2026 * @return array
2027 */
2028 private static function summary_el( array $show, string $align = 'left' ): array {
2029 return [
2030 'id' => 'el_summary',
2031 'type' => 'campaign_summary',
2032 'settings' => [
2033 'headline' => '',
2034 'show_raised' => in_array( 'raised', $show, true ),
2035 'show_donors' => in_array( 'donors', $show, true ),
2036 'show_percent' => in_array( 'percent', $show, true ),
2037 'show_days' => in_array( 'days', $show, true ),
2038 'width' => 100,
2039 'align' => $align,
2040 ],
2041 ];
2042 }
2043
2044 /**
2045 * Suggested donation amounts.
2046 *
2047 * Only `headline` is an element setting — the amounts themselves live in
2048 * campaign meta (`bpc_suggested_amounts`, a metaKey-bound control), which a
2049 * template cannot seed through element settings.
2050 *
2051 * @param string $headline Headline text.
2052 * @return array
2053 */
2054 private static function amounts_el( string $headline ): array {
2055 return [ 'id' => 'el_amounts', 'type' => 'donate_amount', 'settings' => [ 'headline' => $headline ] ];
2056 }
2057
2058 /**
2059 * Donate button, coloured from the palette accent.
2060 *
2061 * Always placed directly after {@see self::amounts_el()} where both appear:
2062 * choosing an amount and confirming it is one action, and a button parked
2063 * further down the page reads as an unrelated control.
2064 *
2065 * @param array $p Palette.
2066 * @param string $label Button label.
2067 * @return array
2068 */
2069 private static function donate_el( array $p, string $label ): array {
2070 return [
2071 'id' => 'el_donate',
2072 'type' => 'donation_form',
2073 'settings' => [
2074 'button_label' => $label,
2075 'button_color' => $p['accent'],
2076 'url' => '',
2077 'width' => 100,
2078 'align' => 'center',
2079 ],
2080 ];
2081 }
2082
2083 /**
2084 * Organizer block bound to the default organizer.
2085 *
2086 * @param string $role_title Role shown under the name.
2087 * @param string $description Short bio.
2088 * @return array
2089 */
2090 private static function organizer_el( string $role_title, string $description ): array {
2091 return [
2092 'id' => 'el_organizer',
2093 'type' => 'organizer',
2094 'settings' => [
2095 'creator_user_id' => self::default_organizer_id(),
2096 'role_title' => $role_title,
2097 'description' => $description,
2098 'width' => 100,
2099 'align' => 'left',
2100 ],
2101 ];
2102 }
2103
2104 /**
2105 * Social sharing buttons.
2106 *
2107 * @param string $headline Headline text.
2108 * @param string $align left|center|right.
2109 * @return array
2110 */
2111 private static function share_el( string $headline, string $align = 'left' ): array {
2112 return [
2113 'id' => 'el_share',
2114 'type' => 'social_sharing',
2115 'settings' => [
2116 'headline' => $headline,
2117 'facebook' => true,
2118 'twitter' => true,
2119 'linkedin' => true,
2120 'threads' => true,
2121 'pinterest' => false,
2122 'mastodon' => false,
2123 'bluesky' => false,
2124 'align' => $align,
2125 ],
2126 ];
2127 }
2128
2129 /**
2130 * Social profile links.
2131 *
2132 * The URLs are bare network home pages, never an invented handle: a
2133 * plausible-looking `facebook.com/<something>` points a fundraiser at an
2134 * account nobody involved controls.
2135 *
2136 * @param string $headline Headline text.
2137 * @param array $links Network => URL.
2138 * @param string $align left|center|right.
2139 * @return array
2140 */
2141 private static function links_el( string $headline, array $links, string $align = 'left' ): array {
2142 return [
2143 'id' => 'el_links',
2144 'type' => 'social_links',
2145 'settings' => array_merge(
2146 [ 'headline' => $headline, 'align' => $align ],
2147 $links
2148 ),
2149 ];
2150 }
2151
2152 }
2153