PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.0
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 / post-types / donation-form.php

donation-form.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/post-types/donation-form.php

963 lines 30.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Donation Form Custom Post Type.
4 *
5 * @package SureDonation
6 * @since 0.0.1
7 */
8
9 namespace SureDonation\Inc\Post_Types;
10
11 use SureDonation\Inc\Traits\Get_Instance;
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Donation Form Post Type Class.
20 *
21 * @since 0.0.1
22 */
23 class Donation_Form {
24 use Get_Instance;
25
26 /**
27 * Post type slug.
28 *
29 * @since 0.0.1
30 */
31 public const POST_TYPE = 'suredonation_form';
32
33 /**
34 * Meta key for linked campaign ID.
35 *
36 * @since 0.0.1
37 */
38 public const META_CAMPAIGN_ID = '_suredonation_campaign_id';
39
40 /**
41 * Meta key for the per-form styling settings (JSON blob).
42 *
43 * @var string
44 * @since 1.0.0
45 */
46 public const META_STYLING = '_suredonation_form_styling';
47
48 /**
49 * Meta key for the per-form Custom CSS.
50 *
51 * @var string
52 * @since 1.5.0
53 */
54 public const META_CUSTOM_CSS = '_suredonation_form_custom_css';
55
56 /**
57 * Constructor.
58 *
59 * @since 0.0.1
60 */
61 public function __construct() {
62 add_action( 'init', [ $this, 'register_post_type' ] );
63 add_action( 'init', [ $this, 'register_meta' ] );
64 add_filter( 'allowed_block_types_all', [ $this, 'restrict_blocks' ], 10, 2 );
65 add_filter( 'render_block_data', [ $this, 'alias_legacy_multi_choice_block' ] );
66 add_filter( 'surerank_excluded_post_types_from_seo_checks', [ $this, 'exclude_from_surerank_seo_checks' ] );
67 add_action( 'load-post-new.php', [ $this, 'set_campaign_on_auto_draft' ] );
68 add_action( 'save_post_' . self::POST_TYPE, [ $this, 'maybe_set_campaign_from_url' ], 10, 2 );
69 add_action( 'save_post_' . self::POST_TYPE, [ $this, 'update_field_slugs' ], 10, 2 );
70 add_action( 'save_post_' . self::POST_TYPE, [ $this, 'store_block_config' ], 10, 2 );
71 }
72
73 /**
74 * Exclude the donation form post type from SureRank's SEO checks.
75 *
76 * Stops SureRank from injecting its SEO meta box / "Optimize" button into
77 * the donation form editor (and its column on the list table), where SEO
78 * is not relevant — mirroring how SureRank excludes sureforms_form.
79 *
80 * @param array<string> $post_types Post types excluded from SEO checks.
81 * @return array<string> Filtered list of excluded post types.
82 * @since 1.0.0
83 */
84 public function exclude_from_surerank_seo_checks( $post_types ) {
85 $post_types = is_array( $post_types ) ? $post_types : [];
86 $post_types[] = self::POST_TYPE;
87
88 return $post_types;
89 }
90
91 /**
92 * Render-time alias: any saved suredonation/multi-choice block (from before the
93 * rename) renders as suredonation/donation-amount. Keeps existing forms working
94 * without a content migration.
95 *
96 * Intentionally registered globally rather than gated on the
97 * suredonation_form post type: forms are embedded on regular pages via
98 * the donation-form block / shortcode, where the queried post is the
99 * page, not the form CPT. The early string compare is cheap, and the
100 * slug is plugin-specific so it only ever matches our own blocks.
101 *
102 * @param array<string, mixed> $parsed_block Parsed block data.
103 * @return array<string, mixed>
104 * @since 1.0.0
105 */
106 public function alias_legacy_multi_choice_block( $parsed_block ) {
107 if ( isset( $parsed_block['blockName'] ) && 'suredonation/multi-choice' === $parsed_block['blockName'] ) {
108 $parsed_block['blockName'] = 'suredonation/donation-amount';
109 }
110 return $parsed_block;
111 }
112
113 /**
114 * Register the donation form post type.
115 *
116 * @return void
117 * @since 0.0.1
118 */
119 public function register_post_type() {
120 $labels = [
121 'name' => _x( 'Donation Forms', 'Post type general name', 'suredonation' ),
122 'singular_name' => _x( 'Donation Form', 'Post type singular name', 'suredonation' ),
123 'menu_name' => _x( 'Donation Forms', 'Admin Menu text', 'suredonation' ),
124 'name_admin_bar' => _x( 'Donation Form', 'Add New on Toolbar', 'suredonation' ),
125 'add_new' => __( 'Add New', 'suredonation' ),
126 'add_new_item' => __( 'Add New Form', 'suredonation' ),
127 'new_item' => __( 'New Form', 'suredonation' ),
128 'edit_item' => __( 'Edit Form', 'suredonation' ),
129 'view_item' => __( 'View Form', 'suredonation' ),
130 'all_items' => __( 'All Forms', 'suredonation' ),
131 'search_items' => __( 'Search Forms', 'suredonation' ),
132 'parent_item_colon' => __( 'Parent Forms:', 'suredonation' ),
133 'not_found' => __( 'No forms found.', 'suredonation' ),
134 'not_found_in_trash' => __( 'No forms found in Trash.', 'suredonation' ),
135 'archives' => _x( 'Form archives', 'The post type archive label used in nav menus.', 'suredonation' ),
136 'insert_into_item' => _x( 'Insert into form', 'Overrides the "Insert into post" phrase.', 'suredonation' ),
137 'uploaded_to_this_item' => _x( 'Uploaded to this form', 'Overrides the "Uploaded to this post" phrase.', 'suredonation' ),
138 'filter_items_list' => _x( 'Filter forms list', 'Screen reader text for the filter links heading.', 'suredonation' ),
139 'items_list_navigation' => _x( 'Forms list navigation', 'Screen reader text for the pagination heading.', 'suredonation' ),
140 'items_list' => _x( 'Forms list', 'Screen reader text for the items list heading.', 'suredonation' ),
141 ];
142
143 $args = [
144 'labels' => $labels,
145 'description' => __( 'Donation forms for SureDonation.', 'suredonation' ),
146 'public' => false,
147 'publicly_queryable' => false,
148 'show_ui' => true,
149 'show_in_menu' => 'suredonation',
150
151 /*
152 * Keep forms out of the admin bar's "+ New" menu. Without this, core
153 * derives the flag from show_in_menu (truthy) and offers a standalone
154 * form, but forms belong to a campaign — they are created from the
155 * campaign screen, which passes the campaign_id along.
156 */
157 'show_in_admin_bar' => false,
158 'query_var' => false,
159 'rewrite' => false,
160 'capability_type' => 'post',
161 'has_archive' => false,
162 'hierarchical' => false,
163 'supports' => [ 'title', 'editor', 'custom-fields' ],
164 'show_in_rest' => true, // Required for Gutenberg.
165 'template' => $this->get_default_template(),
166 'template_lock' => false,
167 ];
168
169 register_post_type( self::POST_TYPE, $args );
170 }
171
172 /**
173 * Register post meta for the donation form.
174 *
175 * @return void
176 * @since 0.0.1
177 */
178 public function register_meta() {
179 register_post_meta(
180 self::POST_TYPE,
181 self::META_CAMPAIGN_ID,
182 [
183 'type' => 'integer',
184 'description' => __( 'The ID of the linked campaign.', 'suredonation' ),
185 'single' => true,
186 'default' => 0,
187 'show_in_rest' => true,
188 'sanitize_callback' => 'absint',
189 'auth_callback' => static function () {
190 return current_user_can( 'manage_options' );
191 },
192 ]
193 );
194
195 register_post_meta(
196 self::POST_TYPE,
197 self::META_STYLING,
198 [
199 'type' => 'string',
200 'description' => __( 'Per-form styling settings (JSON).', 'suredonation' ),
201 'single' => true,
202 'default' => '',
203 'show_in_rest' => true,
204 'sanitize_callback' => [ \SureDonation\Inc\Fields\Form_Styling::class, 'sanitize_json' ],
205 'auth_callback' => static function () {
206 return current_user_can( 'manage_options' );
207 },
208 ]
209 );
210
211 register_post_meta(
212 self::POST_TYPE,
213 self::META_CUSTOM_CSS,
214 [
215 'type' => 'string',
216 'description' => __( 'Per-form Custom CSS.', 'suredonation' ),
217 'single' => true,
218 'default' => '',
219 // Editor-only: the form editor reads meta from the `edit`
220 // context, and per-form CSS need not be publicly readable.
221 'show_in_rest' => [
222 'schema' => [
223 'type' => 'string',
224 'context' => [ 'edit' ],
225 ],
226 ],
227 'sanitize_callback' => [ \SureDonation\Inc\Fields\Form_Custom_CSS::class, 'sanitize' ],
228 'auth_callback' => static function () {
229 return current_user_can( 'manage_options' );
230 },
231 ]
232 );
233
234 register_post_meta(
235 self::POST_TYPE,
236 \SureDonation\Inc\Payments\Stripe\Stripe_Helper::FORM_ACCOUNT_META_KEY,
237 [
238 'type' => 'string',
239 'description' => __( 'Selected Stripe account for this form (account id, or empty/"default" to use the site default).', 'suredonation' ),
240 'single' => true,
241 'default' => '',
242 'show_in_rest' => true,
243 'sanitize_callback' => 'sanitize_text_field',
244 'auth_callback' => static function () {
245 return current_user_can( 'manage_options' );
246 },
247 ]
248 );
249 }
250
251 /**
252 * Restrict allowed blocks in the donation form editor.
253 *
254 * @param bool|array<string> $allowed_block_types Array of allowed block types or true for all.
255 * @param \WP_Block_Editor_Context $context Block editor context.
256 * @return bool|array<string> Array of allowed block types.
257 * @since 0.0.1
258 */
259 public function restrict_blocks( $allowed_block_types, $context ) {
260 if ( ! isset( $context->post ) || self::POST_TYPE !== $context->post->post_type ) {
261 return $allowed_block_types;
262 }
263
264 // SureDonation form blocks.
265 $blocks = [
266 'suredonation/input',
267 'suredonation/email',
268 'suredonation/number',
269 'suredonation/checkbox',
270 'suredonation/dropdown',
271 'suredonation/address',
272 'suredonation/phone',
273 'suredonation/url',
274 'suredonation/donor-comment',
275 'suredonation/heading',
276 'suredonation/html',
277 'suredonation/image',
278 'suredonation/donation-amount',
279 'suredonation/anonymous-donation',
280 'suredonation/payment',
281 'suredonation/donate-button',
282 'suredonation/cover-fees',
283 ];
284
285 /**
286 * Filter the blocks allowed in the donation form editor.
287 *
288 * Lets extensions (e.g. SureDonation Pro) register additional field
289 * blocks — such as the date/time pickers — so they appear in the form
290 * editor's inserter.
291 *
292 * @since 1.1.1
293 * @param array<string> $blocks Allowed block names.
294 */
295 return apply_filters( 'suredonation_allowed_form_blocks', $blocks );
296 }
297
298 /**
299 * Set campaign ID on the auto-draft when creating a new form from a campaign page.
300 *
301 * Hooks into load-post-new.php so the meta is set before the block editor
302 * renders, ensuring the campaign link is stored even if the URL parameter
303 * is lost after the first save/redirect.
304 *
305 * @return void
306 * @since 1.0.0
307 */
308 public function set_campaign_on_auto_draft() {
309 // Only for our post type.
310 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check on admin page load.
311 $post_type = isset( $_GET['post_type'] ) ? sanitize_text_field( wp_unslash( $_GET['post_type'] ) ) : '';
312 if ( self::POST_TYPE !== $post_type ) {
313 return;
314 }
315
316 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only; validated below via capability check.
317 $campaign_id = isset( $_GET['campaign_id'] ) ? absint( $_GET['campaign_id'] ) : 0;
318 if ( $campaign_id <= 0 ) {
319 return;
320 }
321
322 // Validate campaign exists and user can access it.
323 $campaign = get_post( $campaign_id );
324 if ( ! $campaign instanceof \WP_Post || SUREDONATION_POST_TYPE !== $campaign->post_type || ! current_user_can( 'edit_post', $campaign_id ) ) {
325 return;
326 }
327
328 // WordPress creates the auto-draft via get_default_post_to_edit() which runs
329 // before our hook. We can get the post ID from the global $post or from the
330 // auto-draft that will be created. Use a filter on wp_insert_post_data to
331 // capture it, or simply hook into wp_insert_post to set meta right after.
332 //
333 // The closure stays attached for the rest of the request, but its condition
334 // (post_type + auto-draft) is narrow enough that subsequent wp_insert_post
335 // calls for other types are no-ops. Only one auto-draft is created per
336 // load-post-new.php request, so a one-shot removal adds complexity without benefit.
337 add_action(
338 'wp_insert_post',
339 static function ( $post_id, $post ) use ( $campaign_id ) {
340 if ( self::POST_TYPE === $post->post_type && 'auto-draft' === $post->post_status ) {
341 update_post_meta( $post_id, self::META_CAMPAIGN_ID, $campaign_id );
342 }
343 },
344 10,
345 2
346 );
347 }
348
349 /**
350 * Set campaign ID from URL parameter when creating a new form.
351 *
352 * This handles the case when a form is created via the "Add Form" button
353 * from the campaign page, which passes campaign_id as a URL parameter.
354 *
355 * SECURITY: This method implements defense-in-depth with multiple checks:
356 * 1. Nonce verification via verify_save_post_nonce() (WordPress REST nonce or classic editor nonce)
357 * 2. Capability check: current_user_can('edit_post', $post_id) for the form
358 * 3. Capability check: current_user_can('edit_post', $campaign_id) for the campaign
359 * 4. Validation: Campaign must exist and be the correct post type
360 *
361 * @param int $post_id Post ID.
362 * @param \WP_Post $post Post object.
363 * @return void
364 * @since 0.0.1
365 */
366 public function maybe_set_campaign_from_url( $post_id, $post ) {
367 unset( $post ); // Unused parameter.
368
369 // Skip autosave.
370 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
371 return;
372 }
373
374 // Skip revisions.
375 if ( wp_is_post_revision( $post_id ) ) {
376 return;
377 }
378
379 // Security check 1: Verify user has permission to edit this form.
380 if ( ! current_user_can( 'edit_post', $post_id ) ) {
381 return;
382 }
383
384 // Security check 2: Verify nonce - handles both block editor (REST API) and classic editor.
385 if ( ! self::verify_save_post_nonce( $post_id ) ) {
386 return;
387 }
388
389 // Only process if campaign_id is not already set.
390 $existing_campaign_id = self::get_form_campaign_id( $post_id );
391 if ( $existing_campaign_id > 0 ) {
392 return;
393 }
394
395 // Get campaign_id from URL parameter (from "Add Form" button on campaign pages).
396 // Security: Nonce verified above via verify_save_post_nonce(). Authorization verified
397 // via capability checks on both form (above) and campaign (below).
398 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified above via verify_save_post_nonce().
399 $campaign_id = isset( $_GET['campaign_id'] ) ? absint( $_GET['campaign_id'] ) : 0;
400
401 if ( $campaign_id > 0 ) {
402 // Security check 3: Verify the campaign exists, is the correct type, and user can edit it.
403 $campaign = get_post( $campaign_id );
404 if ( $campaign instanceof \WP_Post && SUREDONATION_POST_TYPE === $campaign->post_type && current_user_can( 'edit_post', $campaign_id ) ) {
405 self::set_form_campaign_id( $post_id, $campaign_id );
406 }
407 }
408 }
409
410 /**
411 * Store block configuration for server-side validation.
412 *
413 * This method extracts and stores payment block configuration (amount type,
414 * fixed amount, minimum amount, etc.) in post meta. This stored configuration
415 * is used during payment processing to validate that the submitted amount
416 * matches the form's configured values, preventing payment manipulation attacks.
417 *
418 * @param int $post_id Post ID.
419 * @param \WP_Post $post Post object.
420 * @return void
421 * @since 0.0.1
422 */
423 public function store_block_config( $post_id, $post ) {
424 // Skip autosave.
425 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
426 return;
427 }
428
429 // Skip revisions.
430 if ( wp_is_post_revision( $post_id ) ) {
431 return;
432 }
433
434 // Verify user has permission to edit this post.
435 if ( ! current_user_can( 'edit_post', $post_id ) ) {
436 return;
437 }
438
439 // Verify nonce - handles both block editor (REST API) and classic editor.
440 if ( ! self::verify_save_post_nonce( $post_id ) ) {
441 return;
442 }
443
444 // Re-fetch the post content fresh. update_field_slugs() runs on the same
445 // save_post hook and rewrites post_content with generated field slugs via
446 // a nested wp_update_post(); the $post handed to this callback is the
447 // pre-update copy, so reading $post->post_content directly would miss the
448 // slug for a newly added field and the config would be stored without it
449 // (skipping that field in server-side validation until the next save).
450 $fresh = get_post( $post_id );
451 $content = $fresh instanceof \WP_Post ? $fresh->post_content : $post->post_content;
452 $blocks = parse_blocks( $content );
453
454 if ( empty( $blocks ) ) {
455 return;
456 }
457
458 // Store block configuration using Field_Validation class.
459 \SureDonation\Inc\Field_Validation::add_block_config( $blocks, $post_id );
460 }
461
462 /**
463 * Generate unique slugs for SureDonation blocks on form save.
464 *
465 * Parses the form content, generates slugs for blocks that don't have one,
466 * ensures uniqueness, and updates the post content if needed.
467 *
468 * @param int $post_id Post ID.
469 * @param \WP_Post $post Post object.
470 * @return void
471 * @since 0.0.1
472 */
473 public function update_field_slugs( $post_id, $post ) {
474 // Skip autosave.
475 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
476 return;
477 }
478
479 // Skip revisions.
480 if ( wp_is_post_revision( $post_id ) ) {
481 return;
482 }
483
484 // Verify user has permission to edit this post.
485 if ( ! current_user_can( 'edit_post', $post_id ) ) {
486 return;
487 }
488
489 // Verify nonce - handles both block editor (REST API) and classic editor.
490 if ( ! self::verify_save_post_nonce( $post_id ) ) {
491 return;
492 }
493
494 $blocks = parse_blocks( $post->post_content );
495
496 if ( empty( $blocks ) ) {
497 return;
498 }
499
500 // Sanitize untrusted authors' raw HTML-block markup at save so the stored
501 // value cannot contain markup the front end would strip on render
502 // (defense-in-depth). Authors with unfiltered_html keep their raw markup,
503 // mirroring how WordPress treats post_content.
504 $html_sanitized = false;
505 if ( ! current_user_can( 'unfiltered_html' ) ) {
506 $html_sanitized = self::sanitize_html_block_content( $blocks );
507 }
508
509 // Process blocks to generate slugs.
510 [ $blocks, , $updated ] = \SureDonation\Inc\Helper::process_blocks( $blocks );
511
512 // Only update if blocks were modified (slugs generated or HTML sanitized).
513 if ( ! $updated && ! $html_sanitized ) {
514 return;
515 }
516
517 // Serialize blocks and update post.
518 $post_content = serialize_blocks( $blocks ); // @phpstan-ignore argument.type
519
520 // Remove save action to prevent infinite loop.
521 remove_action( 'save_post_' . self::POST_TYPE, [ $this, 'update_field_slugs' ], 10 );
522
523 // wp_slash() the content to preserve the JSON unicode escapes that
524 // serialize_blocks() writes into block attributes for characters such as the
525 // angle brackets in raw HTML. wp_update_post() runs wp_unslash() internally,
526 // so without re-slashing those escape sequences lose their leading backslash
527 // and the HTML block's stored markup is corrupted.
528 wp_update_post(
529 [
530 'ID' => $post_id,
531 'post_content' => wp_slash( $post_content ),
532 ]
533 );
534
535 // Re-add save action.
536 add_action( 'save_post_' . self::POST_TYPE, [ $this, 'update_field_slugs' ], 10, 2 );
537 }
538
539 /**
540 * Sanitize the raw markup stored in HTML blocks at save time.
541 *
542 * Runs wp_kses_post() over each suredonation/html block's htmlContent so the
543 * stored value cannot hold markup the front end would strip on render. Applied
544 * to authors without the unfiltered_html capability. Inner blocks (columns,
545 * groups) are walked recursively.
546 *
547 * @param array<mixed> $blocks Parsed blocks to process, by reference.
548 * @return bool True if any block's content was modified.
549 * @since 1.1.1
550 */
551 private static function sanitize_html_block_content( &$blocks ) {
552 $changed = false;
553
554 foreach ( $blocks as &$block ) {
555 if ( ! is_array( $block ) ) {
556 continue;
557 }
558
559 if (
560 isset( $block['blockName'], $block['attrs']['htmlContent'] )
561 && 'suredonation/html' === $block['blockName']
562 && is_string( $block['attrs']['htmlContent'] )
563 ) {
564 $sanitized = wp_kses_post( $block['attrs']['htmlContent'] );
565 if ( $sanitized !== $block['attrs']['htmlContent'] ) {
566 $block['attrs']['htmlContent'] = $sanitized;
567 $changed = true;
568 }
569 }
570
571 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
572 if ( self::sanitize_html_block_content( $block['innerBlocks'] ) ) {
573 $changed = true;
574 }
575 }
576 }
577 unset( $block );
578
579 return $changed;
580 }
581
582 /**
583 * Check if a post is a donation form.
584 *
585 * @param int|\WP_Post $post Post ID or post object.
586 * @return bool
587 * @since 0.0.1
588 */
589 public static function is_donation_form( $post ) {
590 $post = get_post( $post );
591
592 if ( ! $post ) {
593 return false;
594 }
595
596 return self::POST_TYPE === $post->post_type;
597 }
598
599 /**
600 * Get all donation forms.
601 *
602 * @param array<string, mixed> $args Additional WP_Query arguments.
603 * @return array<\WP_Post> Array of donation form posts.
604 * @since 0.0.1
605 */
606 public static function get_forms( $args = [] ) {
607 $defaults = [
608 'post_type' => self::POST_TYPE,
609 'posts_per_page' => -1,
610 'post_status' => 'publish',
611 'orderby' => 'title',
612 'order' => 'ASC',
613 ];
614
615 $query_args = wp_parse_args( $args, $defaults );
616
617 return get_posts( $query_args ); // @phpstan-ignore return.type
618 }
619
620 /**
621 * Count donation forms matching the given query arguments.
622 *
623 * Companion to get_forms() for callers that need a total rather than the
624 * rows — get_forms() goes through get_posts(), which sets no_found_rows, so
625 * the only way to total it was to fetch every ID and count() them.
626 *
627 * @param array<string, mixed> $args Additional WP_Query arguments.
628 * @return int Number of matching forms.
629 * @since 1.5.0
630 */
631 public static function count_forms( $args = [] ) {
632 $defaults = [
633 'post_type' => self::POST_TYPE,
634 'post_status' => 'publish',
635 ];
636
637 $query_args = wp_parse_args( $args, $defaults );
638
639 // One row is enough: the total comes from found_posts.
640 $query_args['posts_per_page'] = 1;
641 $query_args['paged'] = 1;
642 $query_args['fields'] = 'ids';
643 $query_args['no_found_rows'] = false;
644 $query_args['ignore_sticky_posts'] = true;
645 $query_args['update_post_meta_cache'] = false;
646 $query_args['update_post_term_cache'] = false;
647
648 $query = new \WP_Query( $query_args );
649
650 return (int) $query->found_posts;
651 }
652
653 /**
654 * Get forms linked to a specific campaign.
655 *
656 * @param int $campaign_id Campaign ID.
657 * @return array<\WP_Post> Array of donation form posts.
658 * @since 0.0.1
659 */
660 public static function get_forms_by_campaign( $campaign_id ) {
661 return self::get_forms(
662 [
663 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
664 [
665 'key' => self::META_CAMPAIGN_ID,
666 'value' => $campaign_id,
667 'compare' => '=',
668 'type' => 'NUMERIC',
669 ],
670 ],
671 ]
672 );
673 }
674
675 /**
676 * Get the campaign ID linked to a form.
677 *
678 * @param int $form_id Form ID.
679 * @return int Campaign ID or 0 if not linked.
680 * @since 0.0.1
681 */
682 public static function get_form_campaign_id( $form_id ) {
683 $campaign_id = get_post_meta( $form_id, self::META_CAMPAIGN_ID, true );
684 return is_numeric( $campaign_id ) ? (int) $campaign_id : 0;
685 }
686
687 /**
688 * Set the campaign ID for a form.
689 *
690 * @param int $form_id Form ID.
691 * @param int $campaign_id Campaign ID.
692 * @return bool True on success, false on failure.
693 * @since 0.0.1
694 */
695 public static function set_form_campaign_id( $form_id, $campaign_id ) {
696 return (bool) update_post_meta( $form_id, self::META_CAMPAIGN_ID, absint( $campaign_id ) );
697 }
698
699 /**
700 * Create a default donation form for a campaign.
701 *
702 * Creates a single-page form with all the essential fields matching
703 * the hardcoded template structure.
704 *
705 * @param int $campaign_id Campaign ID to link the form to.
706 * @param string $campaign_name Campaign name for the form title.
707 * @param string|null $form_blocks Optional serialized block markup for the form
708 * content (e.g. from a campaign template). When
709 * null/empty, the standard default form is used.
710 * @return int|false Form ID on success, false on failure.
711 * @since 0.0.1
712 */
713 public static function create_default_form_for_campaign( $campaign_id, $campaign_name = '', $form_blocks = null ) {
714 if ( ! $campaign_id ) {
715 return false;
716 }
717
718 // Generate form title.
719 $form_title = $campaign_name
720 ? sprintf(
721 /* translators: %s: campaign name */
722 __( '%s - Donation Form', 'suredonation' ),
723 $campaign_name
724 )
725 : __( 'Donation Form', 'suredonation' );
726
727 // Build the block content — template-provided markup when given, else the
728 // standard default form.
729 $blocks_content = ( is_string( $form_blocks ) && '' !== $form_blocks )
730 ? $form_blocks
731 : self::get_default_form_blocks_content();
732
733 // Create the form post.
734 $form_id = wp_insert_post(
735 [
736 'post_title' => $form_title,
737 'post_content' => $blocks_content,
738 'post_status' => 'publish',
739 'post_type' => self::POST_TYPE,
740 'meta_input' => [
741 self::META_CAMPAIGN_ID => $campaign_id,
742 ],
743 ],
744 true
745 );
746
747 if ( is_wp_error( $form_id ) ) {
748 return false;
749 }
750
751 return $form_id;
752 }
753
754 /**
755 * Get the default block template for new forms.
756 *
757 * @return array<int, array<int, mixed>>
758 * @since 0.0.1
759 */
760 private function get_default_template() {
761 return [
762 [
763 'suredonation/input',
764 [
765 'label' => __( 'Full Name', 'suredonation' ),
766 'required' => true,
767 'placeholder' => __( 'Enter your full name', 'suredonation' ),
768 'slug' => 'donor-name',
769 'fieldWidth' => 50,
770 ],
771 ],
772 [
773 'suredonation/email',
774 [
775 'label' => __( 'Email Address', 'suredonation' ),
776 'required' => true,
777 'placeholder' => __( 'Enter your email', 'suredonation' ),
778 'slug' => 'donor-email',
779 'fieldWidth' => 50,
780 ],
781 ],
782 [
783 'suredonation/donation-amount',
784 [
785 'label' => __( 'Select Donation Amount', 'suredonation' ),
786 'required' => true,
787 'choiceType' => 'radio',
788 'layout' => 'horizontal',
789 'slug' => 'donation-amount',
790 'options' => [
791 [
792 'label' => '25',
793 'value' => '25',
794 ],
795 [
796 'label' => '50',
797 'value' => '50',
798 ],
799 [
800 'label' => '100',
801 'value' => '100',
802 ],
803 [
804 'label' => '250',
805 'value' => '250',
806 ],
807 ],
808 ],
809 ],
810 [
811 'suredonation/payment',
812 [
813 'gateway' => 'stripe',
814 // Set explicitly so it is serialized into the form markup: the block
815 // default stays ['stripe'] so existing forms keep their saved
816 // behavior, and only newly created forms offer both gateways.
817 'paymentMethods' => [ 'stripe', 'paypal' ],
818 'paymentType' => 'one-time',
819 'amountType' => 'variable',
820 'minimumAmount' => 0,
821 'variableAmountField' => 'donation-amount',
822 'customerEmailField' => 'donor-email',
823 'customerNameField' => 'donor-name',
824 ],
825 ],
826 [
827 'suredonation/donate-button',
828 [
829 'buttonText' => __( 'Donate', 'suredonation' ),
830 'slug' => 'donate-button',
831 ],
832 ],
833 ];
834 }
835
836 /**
837 * Get the default form blocks content as serialized block markup.
838 *
839 * Creates a single-page donation form with:
840 * - Multi-choice for preset amounts (radio buttons)
841 * - Input for donor name
842 * - Email for donor email
843 * - Payment block configured for donation-amount variable amount
844 *
845 * @return string Serialized block content.
846 * @since 0.0.1
847 */
848 public static function get_default_form_blocks_content() {
849 $blocks = [];
850
851 // Donor name.
852 $blocks[] = '<!-- wp:suredonation/input ' . wp_json_encode(
853 [
854 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
855 'label' => __( 'Full Name', 'suredonation' ),
856 'required' => true,
857 'placeholder' => __( 'Enter your full name', 'suredonation' ),
858 'slug' => 'donor-name',
859 'fieldWidth' => 50,
860 ]
861 ) . ' /-->';
862
863 // Donor email.
864 $blocks[] = '<!-- wp:suredonation/email ' . wp_json_encode(
865 [
866 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
867 'label' => __( 'Email Address', 'suredonation' ),
868 'required' => true,
869 'placeholder' => __( 'Enter your email', 'suredonation' ),
870 'slug' => 'donor-email',
871 'fieldWidth' => 50,
872 ]
873 ) . ' /-->';
874
875 // Preset donation amounts using donation-amount (radio buttons).
876 $blocks[] = '<!-- wp:suredonation/donation-amount ' . wp_json_encode(
877 [
878 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
879 'label' => __( 'Select Donation Amount', 'suredonation' ),
880 'required' => true,
881 'choiceType' => 'radio',
882 'layout' => 'horizontal',
883 'slug' => 'donation-amount',
884 'options' => [
885 [
886 'label' => '25',
887 'value' => '25',
888 ],
889 [
890 'label' => '50',
891 'value' => '50',
892 ],
893 [
894 'label' => '100',
895 'value' => '100',
896 ],
897 [
898 'label' => '250',
899 'value' => '250',
900 ],
901 ],
902 ]
903 ) . ' /-->';
904
905 // Payment block configured for donation-amount variable amount.
906 $blocks[] = '<!-- wp:suredonation/payment ' . wp_json_encode(
907 [
908 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
909 'gateway' => 'stripe',
910 // Set explicitly so it is serialized into the form markup: the block
911 // default stays ['stripe'] so existing forms keep their saved
912 // behavior, and only newly created forms offer both gateways.
913 'paymentMethods' => [ 'stripe', 'paypal' ],
914 'paymentType' => 'one-time',
915 'amountType' => 'variable',
916 'minimumAmount' => 0,
917 'variableAmountField' => 'donation-amount',
918 'customerEmailField' => 'donor-email',
919 'customerNameField' => 'donor-name',
920 ]
921 ) . ' /-->';
922
923 // Donate button.
924 $blocks[] = '<!-- wp:suredonation/donate-button ' . wp_json_encode(
925 [
926 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
927 'buttonText' => __( 'Donate', 'suredonation' ),
928 'slug' => 'donate-button',
929 ]
930 ) . ' /-->';
931
932 return implode( "\n\n", $blocks );
933 }
934
935 /**
936 * Verify nonce for save_post hooks.
937 *
938 * Handles both block editor (REST API) and classic editor nonce verification.
939 * - Block Editor: Verifies the wp_rest nonce via REST_REQUEST constant
940 * - Classic Editor: Verifies _wpnonce with update-post_{$post_id} action
941 *
942 * @param int $post_id Post ID being saved.
943 * @return int|bool 1 if nonce is valid and generated between 0-12 hours (classic editor), 2 if valid and between 12-24 hours (classic editor), true for block editor, false otherwise.
944 * @since 0.0.1
945 */
946 private static function verify_save_post_nonce( $post_id ) {
947 // Block editor saves via REST API - nonce already verified by WordPress REST authentication.
948 // The REST_REQUEST constant is only defined after successful authentication.
949 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
950 return true;
951 }
952
953 // Classic editor - verify _wpnonce with update-post action.
954 $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
955
956 if ( ! is_string( $nonce ) || '' === $nonce ) {
957 return false;
958 }
959
960 return wp_verify_nonce( $nonce, 'update-post_' . $post_id );
961 }
962 }
963