PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.0
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / campaign-templates / campaign-templates.php

campaign-templates.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/campaign-templates/campaign-templates.php

382 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Campaign template registry.
4 *
5 * Discovers bundled campaign templates (each a folder under templates/ with a
6 * template.php returning metadata + block builders, plus an assets/ folder) and
7 * exposes them to the seeding hooks and the REST picker. Ships a built-in
8 * `general` template whose builders delegate to the existing default generators,
9 * so "Start from scratch" stays byte-for-byte identical to today's output.
10 *
11 * @package SureDonation
12 */
13
14 namespace SureDonation\Inc\Campaign_Templates;
15
16 use SureDonation\Inc\Campaigns\Campaign_Page;
17 use SureDonation\Inc\Helper;
18 use SureDonation\Inc\Post_Types\Donation_Form;
19 use SureDonation\Inc\Traits\Get_Instance;
20
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 /**
26 * Registry of cause-based campaign templates.
27 *
28 * @since 1.5.0
29 */
30 class Campaign_Templates {
31 use Get_Instance;
32
33 /**
34 * The built-in template id used when a campaign carries no explicit template.
35 *
36 * @since 1.5.0
37 */
38 public const GENERAL = 'general';
39
40 /**
41 * Resolved templates, keyed by id. Null until first discovery.
42 *
43 * @var array<string, array<string, mixed>>|null
44 * @since 1.5.0
45 */
46 private $templates = null;
47
48 /**
49 * Get every template's metadata for the picker (no builders or file paths),
50 * excluding the built-in `general` fallback (the UI shows that as a dedicated
51 * "Start from scratch" card, not a gallery card).
52 *
53 * @return array<int, array<string, mixed>>
54 * @since 1.5.0
55 */
56 public function get_all() {
57 $out = [];
58
59 foreach ( $this->all() as $id => $template ) {
60 if ( self::GENERAL === $id ) {
61 continue;
62 }
63
64 $out[] = [
65 'id' => $id,
66 'name' => $template['name'] ?? $id,
67 'category' => $template['category'] ?? '',
68 'description' => $template['description'] ?? '',
69 'thumbnail' => $template['thumbnail'] ?? '',
70 'goal_type' => $template['goal_type'] ?? 'raised_amount',
71 'goal_amount' => $template['goal_amount'] ?? 0,
72 'campaign_title' => $template['campaign_title'] ?? '',
73 'campaign_description' => $template['campaign_description'] ?? '',
74 ];
75 }
76
77 return $out;
78 }
79
80 /**
81 * Get a full template (metadata + builders + hero path) by id.
82 *
83 * The id is treated as untrusted: it is sanitized and resolved only against
84 * the discovered whitelist, never concatenated into a filesystem path.
85 *
86 * @param string $id Template id.
87 * @return array<string, mixed>|null The template, or null when unknown.
88 * @since 1.5.0
89 */
90 public function get( $id ) {
91 $id = sanitize_key( (string) $id );
92
93 if ( '' === $id ) {
94 return null;
95 }
96
97 $all = $this->all();
98
99 return $all[ $id ] ?? null;
100 }
101
102 /**
103 * Whether a template id resolves to a known template.
104 *
105 * @param string $id Template id.
106 * @return bool
107 * @since 1.5.0
108 */
109 public function exists( $id ) {
110 return null !== $this->get( $id );
111 }
112
113 /**
114 * Import a bundled image file into the Media Library as an attachment.
115 *
116 * Fail-soft: returns 0 on any failure so seeding never breaks campaign
117 * creation.
118 *
119 * @param string $file_path Absolute path to a bundled image file.
120 * @param int $post_id Post to attach the image to.
121 * @return int Attachment id, or 0 on failure.
122 * @since 1.5.0
123 */
124 public static function import_image( $file_path, $post_id ) {
125 if ( ! is_string( $file_path ) || ! is_readable( $file_path ) ) {
126 return 0;
127 }
128
129 // Load the admin media stack unconditionally — on a REST/front-end create
130 // request none of these are loaded, and media_handle_sideload() depends on
131 // wp_handle_sideload() (file.php) and image functions (image.php). Guarding
132 // only on media_handle_sideload() can leave those undefined and fatal. All
133 // three are require_once, so this is cheap and idempotent.
134 require_once ABSPATH . 'wp-admin/includes/file.php';
135 require_once ABSPATH . 'wp-admin/includes/media.php';
136 require_once ABSPATH . 'wp-admin/includes/image.php';
137
138 $tmp = wp_tempnam( basename( $file_path ) );
139 if ( ! $tmp ) {
140 return 0;
141 }
142
143 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy -- copying a bundled plugin asset into a temp file for sideloading.
144 if ( ! copy( $file_path, $tmp ) ) {
145 wp_delete_file( $tmp );
146 return 0;
147 }
148
149 $file_array = [
150 'name' => basename( $file_path ),
151 'tmp_name' => $tmp,
152 ];
153
154 $attachment_id = media_handle_sideload( $file_array, (int) $post_id );
155
156 if ( is_wp_error( $attachment_id ) ) {
157 wp_delete_file( $tmp );
158 return 0;
159 }
160
161 return (int) $attachment_id;
162 }
163
164 /**
165 * Build donation-form block markup for a template.
166 *
167 * Shared by the bundled templates so each only varies its amounts/labels.
168 * Produces: name, email, donation-amount (given tiers), payment, donate button.
169 *
170 * Keep the block names/attribute shapes here in sync with
171 * Donation_Form::get_default_form_blocks_content() — the two must stay
172 * equivalent so template forms behave identically to the default form.
173 *
174 * @param array<string, mixed> $args {
175 * Optional. Form configuration.
176 *
177 * @type int[] $amounts Amount tier values. Default 25/50/100/250.
178 * @type string $amount_label Label for the donation-amount field.
179 * @type string $button_text Donate button text.
180 * }
181 * @return string Serialized block markup.
182 * @since 1.5.0
183 */
184 public static function build_form_blocks( $args = [] ) {
185 $amounts = isset( $args['amounts'] ) && is_array( $args['amounts'] ) ? $args['amounts'] : [ 25, 50, 100, 250 ];
186 $amount_label = $args['amount_label'] ?? __( 'Select Donation Amount', 'suredonation' );
187 $button_text = $args['button_text'] ?? __( 'Donate', 'suredonation' );
188
189 $options = array_map(
190 static function ( $value ) {
191 $value = Helper::get_string_value( $value );
192 return [
193 'label' => $value,
194 'value' => $value,
195 ];
196 },
197 $amounts
198 );
199
200 $blocks = [
201 '<!-- wp:suredonation/input ' . wp_json_encode(
202 [
203 'block_id' => Helper::generate_block_id(),
204 'label' => __( 'Full Name', 'suredonation' ),
205 'required' => true,
206 'placeholder' => __( 'Enter your full name', 'suredonation' ),
207 'slug' => 'donor-name',
208 'fieldWidth' => 50,
209 ]
210 ) . ' /-->',
211 '<!-- wp:suredonation/email ' . wp_json_encode(
212 [
213 'block_id' => Helper::generate_block_id(),
214 'label' => __( 'Email Address', 'suredonation' ),
215 'required' => true,
216 'placeholder' => __( 'Enter your email', 'suredonation' ),
217 'slug' => 'donor-email',
218 'fieldWidth' => 50,
219 ]
220 ) . ' /-->',
221 '<!-- wp:suredonation/donation-amount ' . wp_json_encode(
222 [
223 'block_id' => Helper::generate_block_id(),
224 'label' => $amount_label,
225 'required' => true,
226 'choiceType' => 'radio',
227 'layout' => 'horizontal',
228 'slug' => 'donation-amount',
229 'options' => $options,
230 ]
231 ) . ' /-->',
232 '<!-- wp:suredonation/payment ' . wp_json_encode(
233 [
234 'block_id' => Helper::generate_block_id(),
235 'gateway' => 'stripe',
236 // Serialized explicitly for the same reason block_id is:
237 // parse_blocks() applies no block.json defaults, and that
238 // default stays ['stripe'] so saved forms keep their
239 // behavior. Without this a template form silently offers
240 // Stripe only, while the default form offers both.
241 'paymentMethods' => [ 'stripe', 'paypal' ],
242 'paymentType' => 'one-time',
243 'amountType' => 'variable',
244 'minimumAmount' => 0,
245 'variableAmountField' => 'donation-amount',
246 'customerEmailField' => 'donor-email',
247 'customerNameField' => 'donor-name',
248 ]
249 ) . ' /-->',
250 '<!-- wp:suredonation/donate-button ' . wp_json_encode(
251 [
252 'block_id' => Helper::generate_block_id(),
253 'buttonText' => $button_text,
254 'slug' => 'donate-button',
255 ]
256 ) . ' /-->',
257 ];
258
259 return implode( "\n\n", $blocks );
260 }
261
262 /**
263 * Resolve and cache all templates (built-in + discovered + filtered).
264 *
265 * @return array<string, array<string, mixed>>
266 * @since 1.5.0
267 */
268 private function all() {
269 if ( null === $this->templates ) {
270 $this->templates = $this->discover();
271 }
272
273 return $this->templates;
274 }
275
276 /**
277 * Discover bundled templates from the templates/ directory, plus the built-in
278 * `general` template.
279 *
280 * @return array<string, array<string, mixed>>
281 * @since 1.5.0
282 */
283 private function discover() {
284 $templates = [ self::GENERAL => $this->general_template() ];
285
286 $base_dir = __DIR__ . '/templates';
287
288 if ( is_dir( $base_dir ) ) {
289 $dirs = glob( $base_dir . '/*', GLOB_ONLYDIR );
290
291 foreach ( (array) $dirs as $path ) {
292 $file = $path . '/template.php';
293
294 if ( ! is_readable( $file ) ) {
295 continue;
296 }
297
298 // Safe: these are first-party template files bundled inside the
299 // plugin (same trust boundary as any plugin file). Third-party or
300 // remote templates must be injected as arrays via the
301 // `suredonation_campaign_templates` filter below — never written
302 // into this directory — so this require stays first-party only.
303 $template = require $file;
304
305 if ( ! is_array( $template ) || empty( $template['id'] ) ) {
306 continue;
307 }
308
309 $id = sanitize_key( (string) $template['id'] );
310
311 // Skip invalid ids and never let a bundled folder override `general`.
312 if ( '' === $id || self::GENERAL === $id || isset( $templates[ $id ] ) ) {
313 continue;
314 }
315
316 // Resolve assets by convention so templates need not hardcode paths.
317 // Hero: prefer WebP (smaller), fall back to JPEG.
318 $hero_file = '';
319 foreach ( [ 'hero.webp', 'hero.jpg' ] as $candidate ) {
320 if ( is_readable( $path . '/assets/' . $candidate ) ) {
321 $hero_file = $candidate;
322 break;
323 }
324 }
325 $template['hero_path'] = '' !== $hero_file ? $path . '/assets/' . $hero_file : '';
326
327 // Card thumbnail: prefer a dedicated thumbnail.jpg when provided,
328 // otherwise fall back to the hero image so a single bundled image
329 // serves both the featured image and the picker card.
330 $asset_url = SUREDONATION_URL . 'inc/campaign-templates/templates/' . $id . '/assets/';
331 if ( file_exists( $path . '/assets/thumbnail.jpg' ) ) {
332 $template['thumbnail'] = esc_url_raw( $asset_url . 'thumbnail.jpg' );
333 } elseif ( '' !== $hero_file ) {
334 $template['thumbnail'] = esc_url_raw( $asset_url . $hero_file );
335 } else {
336 $template['thumbnail'] = '';
337 }
338
339 $templates[ $id ] = $template;
340 }
341 }
342
343 /**
344 * Filter the registered campaign templates.
345 *
346 * Lets Pro / remote sources inject additional templates. v1 bundles only.
347 *
348 * @param array<string, array<string, mixed>> $templates Templates keyed by id.
349 * @since 1.5.0
350 */
351 return apply_filters( 'suredonation_campaign_templates', $templates );
352 }
353
354 /**
355 * The built-in `general` template — the scratch baseline. Its builders
356 * delegate to the existing default generators so output is unchanged.
357 *
358 * @return array<string, mixed>
359 * @since 1.5.0
360 */
361 private function general_template() {
362 return [
363 'id' => self::GENERAL,
364 'name' => __( 'Start from scratch', 'suredonation' ),
365 'category' => __( 'General', 'suredonation' ),
366 'description' => __( 'A blank donation campaign with the standard form.', 'suredonation' ),
367 'campaign_title' => '',
368 'campaign_description' => '',
369 'goal_type' => 'raised_amount',
370 'goal_amount' => 0,
371 'hero_path' => '',
372 'thumbnail' => '',
373 'get_form_blocks' => static function () {
374 return Donation_Form::get_default_form_blocks_content();
375 },
376 'get_page_blocks' => static function ( $ctx ) {
377 return Campaign_Page::get_default_layout( (int) ( $ctx['campaign_id'] ?? 0 ) );
378 },
379 ];
380 }
381 }
382