PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
← All changes | includes/Campaign/Templates/TemplateManager.php +1271 -13 2.2.2 → 2.4.1 View file →
@@ -12,26 +12,112 @@
12 12 * Templates define the initial page-level column layout that the builder starts
13 13 * with. Each template specifies a layout type (1-column, 2-column, 3-column)
14 14 * and pre-places elements into columns.
15 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 + *
16 57 * To register a custom template from another plugin:
17 58 * add_filter( 'better_payment/campaign_templates', function( $templates ) {
18 59 * $templates['my-template'] = [ ... ];
19 60 * return $templates;
20 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.
21 72 */
22 73 class TemplateManager {
23 74
24 75 /**
76 + * Per-request memo for get_all().
77 + *
78 + * @var array<string, array>|null
79 + */
80 + private static $cache = null;
81 +
82 + /**
25 83 * Get all available campaign templates.
26 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 + *
27 96 * @return array<string, array>
28 97 */
29 98 public static function get_all(): array {
99 + if ( self::$cache !== null ) {
100 + return self::$cache;
101 + }
102 +
30 103 $first_users = get_users( [ 'fields' => [ 'ID' ], 'number' => 1 ] );
31 104 $first_creator_id = ! empty( $first_users ) ? (int) $first_users[0]->ID : get_current_user_id();
32 105 $tpl_img = BETTER_PAYMENT_ASSETS . '/img/campaign/templates';
33 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.
34 120 $shared_sidebar = [
35 121 [
36 122 'id' => 'el_organizer',
37 123 'type' => 'organizer',
@@ -115,36 +201,58 @@
115 201 'blank-3col' => [
116 202 'key' => 'blank-3col',
117 203 'category' => 'blank',
118 204 'tags' => [],
119 - 'label' => __( '3 Column', 'better-payment' ),
120 - 'description' => __( 'Three equal-width columns.', 'better-payment' ),
121 - 'layout' => '3-column',
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',
122 211 'columns' => [
123 212 [
124 - 'id' => 'col1',
125 - 'label' => __( 'Column 1', 'better-payment' ),
126 - 'width' => '33.33%',
213 + 'id' => 'top',
214 + 'label' => __( 'Top (Full Width)', 'better-payment' ),
215 + 'width' => '100%',
127 216 'elements' => [],
128 217 ],
129 218 [
130 - 'id' => 'col2',
131 - 'label' => __( 'Column 2', 'better-payment' ),
132 - 'width' => '33.33%',
219 + 'id' => 'bottom-left',
220 + 'label' => __( 'Bottom Left', 'better-payment' ),
221 + 'width' => '50%',
133 222 'elements' => [],
134 223 ],
135 224 [
136 - 'id' => 'col3',
137 - 'label' => __( 'Column 3', 'better-payment' ),
138 - 'width' => '33.33%',
225 + 'id' => 'bottom-right',
226 + 'label' => __( 'Bottom Right', 'better-payment' ),
227 + 'width' => '50%',
139 228 'elements' => [],
140 229 ],
141 230 ],
142 231 ],
143 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 +
144 251 'charity-basic' => [
145 252 'key' => 'charity-basic',
146 253 'category' => 'prebuilt',
254 + 'hidden' => true,
147 255 'tags' => [ 'charity' ],
148 256 'preview_image' => 'assets/img/campaign/templates/preview/charity-basic.webp',
149 257 'label' => __( 'Refugee Relief Fund', 'better-payment' ),
150 258 'default_title' => __( 'Refugee Relief Fund', 'better-payment' ),
@@ -204,8 +312,9 @@
204 312
205 313 'medical-relief' => [
206 314 'key' => 'medical-relief',
207 315 'category' => 'prebuilt',
316 + 'hidden' => true,
208 317 'tags' => [ 'medical' ],
209 318 'preview_image' => 'assets/img/campaign/templates/preview/medical-relief.webp',
210 319 'label' => __( 'Medical Emergency Fund', 'better-payment' ),
211 320 'default_title' => __( 'Medical Emergency Fund', 'better-payment' ),
@@ -300,8 +409,9 @@
300 409
301 410 'education-fund' => [
302 411 'key' => 'education-fund',
303 412 'category' => 'prebuilt',
413 + 'hidden' => true,
304 414 'tags' => [ 'education' ],
305 415 'preview_image' => 'assets/img/campaign/templates/preview/education-fund.webp',
306 416 'label' => __( 'Student Success Fund', 'better-payment' ),
307 417 'default_title' => __( 'Student Success Fund', 'better-payment' ),
@@ -410,8 +520,9 @@
410 520
411 521 'golf-destinations' => [
412 522 'key' => 'golf-destinations',
413 523 'category' => 'prebuilt',
524 + 'hidden' => true,
414 525 'tags' => [ 'club-organizations' ],
415 526 'preview_image' => 'assets/img/campaign/templates/preview/golf-destinations.webp',
416 527 'label' => __( 'Elite Golf Club Membership', 'better-payment' ),
417 528 'default_title' => __( 'Elite Golf Club Membership', 'better-payment' ),
@@ -482,8 +593,9 @@
482 593
483 594 'child-healthcare' => [
484 595 'key' => 'child-healthcare',
485 596 'category' => 'prebuilt',
597 + 'hidden' => true,
486 598 'tags' => [ 'medical' ],
487 599 'preview_image' => 'assets/img/campaign/templates/preview/child-healthcare.webp',
488 600 'label' => __( 'Child Healthcare Fund', 'better-payment' ),
489 601 'default_title' => __( 'Child Healthcare Fund', 'better-payment' ),
@@ -592,8 +704,9 @@
592 704
593 705 'tree-plantation' => [
594 706 'key' => 'tree-plantation',
595 707 'category' => 'prebuilt',
708 + 'hidden' => true,
596 709 'tags' => [ 'environmental' ],
597 710 'preview_image' => 'assets/img/campaign/templates/preview/tree-plantation.webp',
598 711 'label' => __( 'Tree Plantation Campaign', 'better-payment' ),
599 712 'default_title' => __( 'Tree Plantation Campaign', 'better-payment' ),
@@ -711,8 +824,9 @@
711 824
712 825 'animal-rescue' => [
713 826 'key' => 'animal-rescue',
714 827 'category' => 'prebuilt',
828 + 'hidden' => true,
715 829 'tags' => [ 'charity' ],
716 830 'preview_image' => 'assets/img/campaign/templates/preview/animal-rescue.webp',
717 831 'label' => __( 'Animal Rescue Fund', 'better-payment' ),
718 832 'default_title' => __( 'Animal Rescue Fund', 'better-payment' ),
@@ -811,8 +925,9 @@
811 925
812 926 'disaster-relief' => [
813 927 'key' => 'disaster-relief',
814 928 'category' => 'prebuilt',
929 + 'hidden' => true,
815 930 'tags' => [ 'environmental' ],
816 931 'preview_image' => 'assets/img/campaign/templates/preview/disaster-relief.webp',
817 932 'label' => __( 'Emergency Disaster Relief', 'better-payment' ),
818 933 'default_title' => __( 'Emergency Disaster Relief', 'better-payment' ),
@@ -924,9 +1039,1152 @@
924 1039 ],
925 1040 ],
926 1041 ],
927 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 +
928 1658 ];
929 1659
930 - return apply_filters( 'better_payment/campaign_templates', $templates );
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;
931 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 +
932 2190 }