PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.5.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.5.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.5.0, at inc/campaign-templates/campaign-templates.php

371 lines 11.0 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 'label' => __( 'Full Name', 'suredonation' ),
204 'required' => true,
205 'placeholder' => __( 'Enter your full name', 'suredonation' ),
206 'slug' => 'donor-name',
207 'fieldWidth' => 50,
208 ]
209 ) . ' /-->',
210 '<!-- wp:suredonation/email ' . wp_json_encode(
211 [
212 'label' => __( 'Email Address', 'suredonation' ),
213 'required' => true,
214 'placeholder' => __( 'Enter your email', 'suredonation' ),
215 'slug' => 'donor-email',
216 'fieldWidth' => 50,
217 ]
218 ) . ' /-->',
219 '<!-- wp:suredonation/donation-amount ' . wp_json_encode(
220 [
221 'label' => $amount_label,
222 'required' => true,
223 'choiceType' => 'radio',
224 'layout' => 'horizontal',
225 'slug' => 'donation-amount',
226 'options' => $options,
227 ]
228 ) . ' /-->',
229 '<!-- wp:suredonation/payment ' . wp_json_encode(
230 [
231 'gateway' => 'stripe',
232 'paymentType' => 'one-time',
233 'amountType' => 'variable',
234 'minimumAmount' => 0,
235 'variableAmountField' => 'donation-amount',
236 'customerEmailField' => 'donor-email',
237 'customerNameField' => 'donor-name',
238 ]
239 ) . ' /-->',
240 '<!-- wp:suredonation/donate-button ' . wp_json_encode(
241 [
242 'buttonText' => $button_text,
243 'slug' => 'donate-button',
244 ]
245 ) . ' /-->',
246 ];
247
248 return implode( "\n\n", $blocks );
249 }
250
251 /**
252 * Resolve and cache all templates (built-in + discovered + filtered).
253 *
254 * @return array<string, array<string, mixed>>
255 * @since 1.5.0
256 */
257 private function all() {
258 if ( null === $this->templates ) {
259 $this->templates = $this->discover();
260 }
261
262 return $this->templates;
263 }
264
265 /**
266 * Discover bundled templates from the templates/ directory, plus the built-in
267 * `general` template.
268 *
269 * @return array<string, array<string, mixed>>
270 * @since 1.5.0
271 */
272 private function discover() {
273 $templates = [ self::GENERAL => $this->general_template() ];
274
275 $base_dir = __DIR__ . '/templates';
276
277 if ( is_dir( $base_dir ) ) {
278 $dirs = glob( $base_dir . '/*', GLOB_ONLYDIR );
279
280 foreach ( (array) $dirs as $path ) {
281 $file = $path . '/template.php';
282
283 if ( ! is_readable( $file ) ) {
284 continue;
285 }
286
287 // Safe: these are first-party template files bundled inside the
288 // plugin (same trust boundary as any plugin file). Third-party or
289 // remote templates must be injected as arrays via the
290 // `suredonation_campaign_templates` filter below — never written
291 // into this directory — so this require stays first-party only.
292 $template = require $file;
293
294 if ( ! is_array( $template ) || empty( $template['id'] ) ) {
295 continue;
296 }
297
298 $id = sanitize_key( (string) $template['id'] );
299
300 // Skip invalid ids and never let a bundled folder override `general`.
301 if ( '' === $id || self::GENERAL === $id || isset( $templates[ $id ] ) ) {
302 continue;
303 }
304
305 // Resolve assets by convention so templates need not hardcode paths.
306 // Hero: prefer WebP (smaller), fall back to JPEG.
307 $hero_file = '';
308 foreach ( [ 'hero.webp', 'hero.jpg' ] as $candidate ) {
309 if ( is_readable( $path . '/assets/' . $candidate ) ) {
310 $hero_file = $candidate;
311 break;
312 }
313 }
314 $template['hero_path'] = '' !== $hero_file ? $path . '/assets/' . $hero_file : '';
315
316 // Card thumbnail: prefer a dedicated thumbnail.jpg when provided,
317 // otherwise fall back to the hero image so a single bundled image
318 // serves both the featured image and the picker card.
319 $asset_url = SUREDONATION_URL . 'inc/campaign-templates/templates/' . $id . '/assets/';
320 if ( file_exists( $path . '/assets/thumbnail.jpg' ) ) {
321 $template['thumbnail'] = esc_url_raw( $asset_url . 'thumbnail.jpg' );
322 } elseif ( '' !== $hero_file ) {
323 $template['thumbnail'] = esc_url_raw( $asset_url . $hero_file );
324 } else {
325 $template['thumbnail'] = '';
326 }
327
328 $templates[ $id ] = $template;
329 }
330 }
331
332 /**
333 * Filter the registered campaign templates.
334 *
335 * Lets Pro / remote sources inject additional templates. v1 bundles only.
336 *
337 * @param array<string, array<string, mixed>> $templates Templates keyed by id.
338 * @since 1.5.0
339 */
340 return apply_filters( 'suredonation_campaign_templates', $templates );
341 }
342
343 /**
344 * The built-in `general` template — the scratch baseline. Its builders
345 * delegate to the existing default generators so output is unchanged.
346 *
347 * @return array<string, mixed>
348 * @since 1.5.0
349 */
350 private function general_template() {
351 return [
352 'id' => self::GENERAL,
353 'name' => __( 'Start from scratch', 'suredonation' ),
354 'category' => __( 'General', 'suredonation' ),
355 'description' => __( 'A blank donation campaign with the standard form.', 'suredonation' ),
356 'campaign_title' => '',
357 'campaign_description' => '',
358 'goal_type' => 'raised_amount',
359 'goal_amount' => 0,
360 'hero_path' => '',
361 'thumbnail' => '',
362 'get_form_blocks' => static function () {
363 return Donation_Form::get_default_form_blocks_content();
364 },
365 'get_page_blocks' => static function ( $ctx ) {
366 return Campaign_Page::get_default_layout( (int) ( $ctx['campaign_id'] ?? 0 ) );
367 },
368 ];
369 }
370 }
371