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

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

962 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/heading',
275 'suredonation/html',
276 'suredonation/image',
277 'suredonation/donation-amount',
278 'suredonation/anonymous-donation',
279 'suredonation/payment',
280 'suredonation/donate-button',
281 'suredonation/cover-fees',
282 ];
283
284 /**
285 * Filter the blocks allowed in the donation form editor.
286 *
287 * Lets extensions (e.g. SureDonation Pro) register additional field
288 * blocks — such as the date/time pickers — so they appear in the form
289 * editor's inserter.
290 *
291 * @since 1.1.1
292 * @param array<string> $blocks Allowed block names.
293 */
294 return apply_filters( 'suredonation_allowed_form_blocks', $blocks );
295 }
296
297 /**
298 * Set campaign ID on the auto-draft when creating a new form from a campaign page.
299 *
300 * Hooks into load-post-new.php so the meta is set before the block editor
301 * renders, ensuring the campaign link is stored even if the URL parameter
302 * is lost after the first save/redirect.
303 *
304 * @return void
305 * @since 1.0.0
306 */
307 public function set_campaign_on_auto_draft() {
308 // Only for our post type.
309 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check on admin page load.
310 $post_type = isset( $_GET['post_type'] ) ? sanitize_text_field( wp_unslash( $_GET['post_type'] ) ) : '';
311 if ( self::POST_TYPE !== $post_type ) {
312 return;
313 }
314
315 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only; validated below via capability check.
316 $campaign_id = isset( $_GET['campaign_id'] ) ? absint( $_GET['campaign_id'] ) : 0;
317 if ( $campaign_id <= 0 ) {
318 return;
319 }
320
321 // Validate campaign exists and user can access it.
322 $campaign = get_post( $campaign_id );
323 if ( ! $campaign instanceof \WP_Post || SUREDONATION_POST_TYPE !== $campaign->post_type || ! current_user_can( 'edit_post', $campaign_id ) ) {
324 return;
325 }
326
327 // WordPress creates the auto-draft via get_default_post_to_edit() which runs
328 // before our hook. We can get the post ID from the global $post or from the
329 // auto-draft that will be created. Use a filter on wp_insert_post_data to
330 // capture it, or simply hook into wp_insert_post to set meta right after.
331 //
332 // The closure stays attached for the rest of the request, but its condition
333 // (post_type + auto-draft) is narrow enough that subsequent wp_insert_post
334 // calls for other types are no-ops. Only one auto-draft is created per
335 // load-post-new.php request, so a one-shot removal adds complexity without benefit.
336 add_action(
337 'wp_insert_post',
338 static function ( $post_id, $post ) use ( $campaign_id ) {
339 if ( self::POST_TYPE === $post->post_type && 'auto-draft' === $post->post_status ) {
340 update_post_meta( $post_id, self::META_CAMPAIGN_ID, $campaign_id );
341 }
342 },
343 10,
344 2
345 );
346 }
347
348 /**
349 * Set campaign ID from URL parameter when creating a new form.
350 *
351 * This handles the case when a form is created via the "Add Form" button
352 * from the campaign page, which passes campaign_id as a URL parameter.
353 *
354 * SECURITY: This method implements defense-in-depth with multiple checks:
355 * 1. Nonce verification via verify_save_post_nonce() (WordPress REST nonce or classic editor nonce)
356 * 2. Capability check: current_user_can('edit_post', $post_id) for the form
357 * 3. Capability check: current_user_can('edit_post', $campaign_id) for the campaign
358 * 4. Validation: Campaign must exist and be the correct post type
359 *
360 * @param int $post_id Post ID.
361 * @param \WP_Post $post Post object.
362 * @return void
363 * @since 0.0.1
364 */
365 public function maybe_set_campaign_from_url( $post_id, $post ) {
366 unset( $post ); // Unused parameter.
367
368 // Skip autosave.
369 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
370 return;
371 }
372
373 // Skip revisions.
374 if ( wp_is_post_revision( $post_id ) ) {
375 return;
376 }
377
378 // Security check 1: Verify user has permission to edit this form.
379 if ( ! current_user_can( 'edit_post', $post_id ) ) {
380 return;
381 }
382
383 // Security check 2: Verify nonce - handles both block editor (REST API) and classic editor.
384 if ( ! self::verify_save_post_nonce( $post_id ) ) {
385 return;
386 }
387
388 // Only process if campaign_id is not already set.
389 $existing_campaign_id = self::get_form_campaign_id( $post_id );
390 if ( $existing_campaign_id > 0 ) {
391 return;
392 }
393
394 // Get campaign_id from URL parameter (from "Add Form" button on campaign pages).
395 // Security: Nonce verified above via verify_save_post_nonce(). Authorization verified
396 // via capability checks on both form (above) and campaign (below).
397 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified above via verify_save_post_nonce().
398 $campaign_id = isset( $_GET['campaign_id'] ) ? absint( $_GET['campaign_id'] ) : 0;
399
400 if ( $campaign_id > 0 ) {
401 // Security check 3: Verify the campaign exists, is the correct type, and user can edit it.
402 $campaign = get_post( $campaign_id );
403 if ( $campaign instanceof \WP_Post && SUREDONATION_POST_TYPE === $campaign->post_type && current_user_can( 'edit_post', $campaign_id ) ) {
404 self::set_form_campaign_id( $post_id, $campaign_id );
405 }
406 }
407 }
408
409 /**
410 * Store block configuration for server-side validation.
411 *
412 * This method extracts and stores payment block configuration (amount type,
413 * fixed amount, minimum amount, etc.) in post meta. This stored configuration
414 * is used during payment processing to validate that the submitted amount
415 * matches the form's configured values, preventing payment manipulation attacks.
416 *
417 * @param int $post_id Post ID.
418 * @param \WP_Post $post Post object.
419 * @return void
420 * @since 0.0.1
421 */
422 public function store_block_config( $post_id, $post ) {
423 // Skip autosave.
424 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
425 return;
426 }
427
428 // Skip revisions.
429 if ( wp_is_post_revision( $post_id ) ) {
430 return;
431 }
432
433 // Verify user has permission to edit this post.
434 if ( ! current_user_can( 'edit_post', $post_id ) ) {
435 return;
436 }
437
438 // Verify nonce - handles both block editor (REST API) and classic editor.
439 if ( ! self::verify_save_post_nonce( $post_id ) ) {
440 return;
441 }
442
443 // Re-fetch the post content fresh. update_field_slugs() runs on the same
444 // save_post hook and rewrites post_content with generated field slugs via
445 // a nested wp_update_post(); the $post handed to this callback is the
446 // pre-update copy, so reading $post->post_content directly would miss the
447 // slug for a newly added field and the config would be stored without it
448 // (skipping that field in server-side validation until the next save).
449 $fresh = get_post( $post_id );
450 $content = $fresh instanceof \WP_Post ? $fresh->post_content : $post->post_content;
451 $blocks = parse_blocks( $content );
452
453 if ( empty( $blocks ) ) {
454 return;
455 }
456
457 // Store block configuration using Field_Validation class.
458 \SureDonation\Inc\Field_Validation::add_block_config( $blocks, $post_id );
459 }
460
461 /**
462 * Generate unique slugs for SureDonation blocks on form save.
463 *
464 * Parses the form content, generates slugs for blocks that don't have one,
465 * ensures uniqueness, and updates the post content if needed.
466 *
467 * @param int $post_id Post ID.
468 * @param \WP_Post $post Post object.
469 * @return void
470 * @since 0.0.1
471 */
472 public function update_field_slugs( $post_id, $post ) {
473 // Skip autosave.
474 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
475 return;
476 }
477
478 // Skip revisions.
479 if ( wp_is_post_revision( $post_id ) ) {
480 return;
481 }
482
483 // Verify user has permission to edit this post.
484 if ( ! current_user_can( 'edit_post', $post_id ) ) {
485 return;
486 }
487
488 // Verify nonce - handles both block editor (REST API) and classic editor.
489 if ( ! self::verify_save_post_nonce( $post_id ) ) {
490 return;
491 }
492
493 $blocks = parse_blocks( $post->post_content );
494
495 if ( empty( $blocks ) ) {
496 return;
497 }
498
499 // Sanitize untrusted authors' raw HTML-block markup at save so the stored
500 // value cannot contain markup the front end would strip on render
501 // (defense-in-depth). Authors with unfiltered_html keep their raw markup,
502 // mirroring how WordPress treats post_content.
503 $html_sanitized = false;
504 if ( ! current_user_can( 'unfiltered_html' ) ) {
505 $html_sanitized = self::sanitize_html_block_content( $blocks );
506 }
507
508 // Process blocks to generate slugs.
509 [ $blocks, , $updated ] = \SureDonation\Inc\Helper::process_blocks( $blocks );
510
511 // Only update if blocks were modified (slugs generated or HTML sanitized).
512 if ( ! $updated && ! $html_sanitized ) {
513 return;
514 }
515
516 // Serialize blocks and update post.
517 $post_content = serialize_blocks( $blocks ); // @phpstan-ignore argument.type
518
519 // Remove save action to prevent infinite loop.
520 remove_action( 'save_post_' . self::POST_TYPE, [ $this, 'update_field_slugs' ], 10 );
521
522 // wp_slash() the content to preserve the JSON unicode escapes that
523 // serialize_blocks() writes into block attributes for characters such as the
524 // angle brackets in raw HTML. wp_update_post() runs wp_unslash() internally,
525 // so without re-slashing those escape sequences lose their leading backslash
526 // and the HTML block's stored markup is corrupted.
527 wp_update_post(
528 [
529 'ID' => $post_id,
530 'post_content' => wp_slash( $post_content ),
531 ]
532 );
533
534 // Re-add save action.
535 add_action( 'save_post_' . self::POST_TYPE, [ $this, 'update_field_slugs' ], 10, 2 );
536 }
537
538 /**
539 * Sanitize the raw markup stored in HTML blocks at save time.
540 *
541 * Runs wp_kses_post() over each suredonation/html block's htmlContent so the
542 * stored value cannot hold markup the front end would strip on render. Applied
543 * to authors without the unfiltered_html capability. Inner blocks (columns,
544 * groups) are walked recursively.
545 *
546 * @param array<mixed> $blocks Parsed blocks to process, by reference.
547 * @return bool True if any block's content was modified.
548 * @since 1.1.1
549 */
550 private static function sanitize_html_block_content( &$blocks ) {
551 $changed = false;
552
553 foreach ( $blocks as &$block ) {
554 if ( ! is_array( $block ) ) {
555 continue;
556 }
557
558 if (
559 isset( $block['blockName'], $block['attrs']['htmlContent'] )
560 && 'suredonation/html' === $block['blockName']
561 && is_string( $block['attrs']['htmlContent'] )
562 ) {
563 $sanitized = wp_kses_post( $block['attrs']['htmlContent'] );
564 if ( $sanitized !== $block['attrs']['htmlContent'] ) {
565 $block['attrs']['htmlContent'] = $sanitized;
566 $changed = true;
567 }
568 }
569
570 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
571 if ( self::sanitize_html_block_content( $block['innerBlocks'] ) ) {
572 $changed = true;
573 }
574 }
575 }
576 unset( $block );
577
578 return $changed;
579 }
580
581 /**
582 * Check if a post is a donation form.
583 *
584 * @param int|\WP_Post $post Post ID or post object.
585 * @return bool
586 * @since 0.0.1
587 */
588 public static function is_donation_form( $post ) {
589 $post = get_post( $post );
590
591 if ( ! $post ) {
592 return false;
593 }
594
595 return self::POST_TYPE === $post->post_type;
596 }
597
598 /**
599 * Get all donation forms.
600 *
601 * @param array<string, mixed> $args Additional WP_Query arguments.
602 * @return array<\WP_Post> Array of donation form posts.
603 * @since 0.0.1
604 */
605 public static function get_forms( $args = [] ) {
606 $defaults = [
607 'post_type' => self::POST_TYPE,
608 'posts_per_page' => -1,
609 'post_status' => 'publish',
610 'orderby' => 'title',
611 'order' => 'ASC',
612 ];
613
614 $query_args = wp_parse_args( $args, $defaults );
615
616 return get_posts( $query_args ); // @phpstan-ignore return.type
617 }
618
619 /**
620 * Count donation forms matching the given query arguments.
621 *
622 * Companion to get_forms() for callers that need a total rather than the
623 * rows — get_forms() goes through get_posts(), which sets no_found_rows, so
624 * the only way to total it was to fetch every ID and count() them.
625 *
626 * @param array<string, mixed> $args Additional WP_Query arguments.
627 * @return int Number of matching forms.
628 * @since 1.5.0
629 */
630 public static function count_forms( $args = [] ) {
631 $defaults = [
632 'post_type' => self::POST_TYPE,
633 'post_status' => 'publish',
634 ];
635
636 $query_args = wp_parse_args( $args, $defaults );
637
638 // One row is enough: the total comes from found_posts.
639 $query_args['posts_per_page'] = 1;
640 $query_args['paged'] = 1;
641 $query_args['fields'] = 'ids';
642 $query_args['no_found_rows'] = false;
643 $query_args['ignore_sticky_posts'] = true;
644 $query_args['update_post_meta_cache'] = false;
645 $query_args['update_post_term_cache'] = false;
646
647 $query = new \WP_Query( $query_args );
648
649 return (int) $query->found_posts;
650 }
651
652 /**
653 * Get forms linked to a specific campaign.
654 *
655 * @param int $campaign_id Campaign ID.
656 * @return array<\WP_Post> Array of donation form posts.
657 * @since 0.0.1
658 */
659 public static function get_forms_by_campaign( $campaign_id ) {
660 return self::get_forms(
661 [
662 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
663 [
664 'key' => self::META_CAMPAIGN_ID,
665 'value' => $campaign_id,
666 'compare' => '=',
667 'type' => 'NUMERIC',
668 ],
669 ],
670 ]
671 );
672 }
673
674 /**
675 * Get the campaign ID linked to a form.
676 *
677 * @param int $form_id Form ID.
678 * @return int Campaign ID or 0 if not linked.
679 * @since 0.0.1
680 */
681 public static function get_form_campaign_id( $form_id ) {
682 $campaign_id = get_post_meta( $form_id, self::META_CAMPAIGN_ID, true );
683 return is_numeric( $campaign_id ) ? (int) $campaign_id : 0;
684 }
685
686 /**
687 * Set the campaign ID for a form.
688 *
689 * @param int $form_id Form ID.
690 * @param int $campaign_id Campaign ID.
691 * @return bool True on success, false on failure.
692 * @since 0.0.1
693 */
694 public static function set_form_campaign_id( $form_id, $campaign_id ) {
695 return (bool) update_post_meta( $form_id, self::META_CAMPAIGN_ID, absint( $campaign_id ) );
696 }
697
698 /**
699 * Create a default donation form for a campaign.
700 *
701 * Creates a single-page form with all the essential fields matching
702 * the hardcoded template structure.
703 *
704 * @param int $campaign_id Campaign ID to link the form to.
705 * @param string $campaign_name Campaign name for the form title.
706 * @param string|null $form_blocks Optional serialized block markup for the form
707 * content (e.g. from a campaign template). When
708 * null/empty, the standard default form is used.
709 * @return int|false Form ID on success, false on failure.
710 * @since 0.0.1
711 */
712 public static function create_default_form_for_campaign( $campaign_id, $campaign_name = '', $form_blocks = null ) {
713 if ( ! $campaign_id ) {
714 return false;
715 }
716
717 // Generate form title.
718 $form_title = $campaign_name
719 ? sprintf(
720 /* translators: %s: campaign name */
721 __( '%s - Donation Form', 'suredonation' ),
722 $campaign_name
723 )
724 : __( 'Donation Form', 'suredonation' );
725
726 // Build the block content — template-provided markup when given, else the
727 // standard default form.
728 $blocks_content = ( is_string( $form_blocks ) && '' !== $form_blocks )
729 ? $form_blocks
730 : self::get_default_form_blocks_content();
731
732 // Create the form post.
733 $form_id = wp_insert_post(
734 [
735 'post_title' => $form_title,
736 'post_content' => $blocks_content,
737 'post_status' => 'publish',
738 'post_type' => self::POST_TYPE,
739 'meta_input' => [
740 self::META_CAMPAIGN_ID => $campaign_id,
741 ],
742 ],
743 true
744 );
745
746 if ( is_wp_error( $form_id ) ) {
747 return false;
748 }
749
750 return $form_id;
751 }
752
753 /**
754 * Get the default block template for new forms.
755 *
756 * @return array<int, array<int, mixed>>
757 * @since 0.0.1
758 */
759 private function get_default_template() {
760 return [
761 [
762 'suredonation/input',
763 [
764 'label' => __( 'Full Name', 'suredonation' ),
765 'required' => true,
766 'placeholder' => __( 'Enter your full name', 'suredonation' ),
767 'slug' => 'donor-name',
768 'fieldWidth' => 50,
769 ],
770 ],
771 [
772 'suredonation/email',
773 [
774 'label' => __( 'Email Address', 'suredonation' ),
775 'required' => true,
776 'placeholder' => __( 'Enter your email', 'suredonation' ),
777 'slug' => 'donor-email',
778 'fieldWidth' => 50,
779 ],
780 ],
781 [
782 'suredonation/donation-amount',
783 [
784 'label' => __( 'Select Donation Amount', 'suredonation' ),
785 'required' => true,
786 'choiceType' => 'radio',
787 'layout' => 'horizontal',
788 'slug' => 'donation-amount',
789 'options' => [
790 [
791 'label' => '25',
792 'value' => '25',
793 ],
794 [
795 'label' => '50',
796 'value' => '50',
797 ],
798 [
799 'label' => '100',
800 'value' => '100',
801 ],
802 [
803 'label' => '250',
804 'value' => '250',
805 ],
806 ],
807 ],
808 ],
809 [
810 'suredonation/payment',
811 [
812 'gateway' => 'stripe',
813 // Set explicitly so it is serialized into the form markup: the block
814 // default stays ['stripe'] so existing forms keep their saved
815 // behavior, and only newly created forms offer both gateways.
816 'paymentMethods' => [ 'stripe', 'paypal' ],
817 'paymentType' => 'one-time',
818 'amountType' => 'variable',
819 'minimumAmount' => 0,
820 'variableAmountField' => 'donation-amount',
821 'customerEmailField' => 'donor-email',
822 'customerNameField' => 'donor-name',
823 ],
824 ],
825 [
826 'suredonation/donate-button',
827 [
828 'buttonText' => __( 'Donate', 'suredonation' ),
829 'slug' => 'donate-button',
830 ],
831 ],
832 ];
833 }
834
835 /**
836 * Get the default form blocks content as serialized block markup.
837 *
838 * Creates a single-page donation form with:
839 * - Multi-choice for preset amounts (radio buttons)
840 * - Input for donor name
841 * - Email for donor email
842 * - Payment block configured for donation-amount variable amount
843 *
844 * @return string Serialized block content.
845 * @since 0.0.1
846 */
847 public static function get_default_form_blocks_content() {
848 $blocks = [];
849
850 // Donor name.
851 $blocks[] = '<!-- wp:suredonation/input ' . wp_json_encode(
852 [
853 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
854 'label' => __( 'Full Name', 'suredonation' ),
855 'required' => true,
856 'placeholder' => __( 'Enter your full name', 'suredonation' ),
857 'slug' => 'donor-name',
858 'fieldWidth' => 50,
859 ]
860 ) . ' /-->';
861
862 // Donor email.
863 $blocks[] = '<!-- wp:suredonation/email ' . wp_json_encode(
864 [
865 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
866 'label' => __( 'Email Address', 'suredonation' ),
867 'required' => true,
868 'placeholder' => __( 'Enter your email', 'suredonation' ),
869 'slug' => 'donor-email',
870 'fieldWidth' => 50,
871 ]
872 ) . ' /-->';
873
874 // Preset donation amounts using donation-amount (radio buttons).
875 $blocks[] = '<!-- wp:suredonation/donation-amount ' . wp_json_encode(
876 [
877 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
878 'label' => __( 'Select Donation Amount', 'suredonation' ),
879 'required' => true,
880 'choiceType' => 'radio',
881 'layout' => 'horizontal',
882 'slug' => 'donation-amount',
883 'options' => [
884 [
885 'label' => '25',
886 'value' => '25',
887 ],
888 [
889 'label' => '50',
890 'value' => '50',
891 ],
892 [
893 'label' => '100',
894 'value' => '100',
895 ],
896 [
897 'label' => '250',
898 'value' => '250',
899 ],
900 ],
901 ]
902 ) . ' /-->';
903
904 // Payment block configured for donation-amount variable amount.
905 $blocks[] = '<!-- wp:suredonation/payment ' . wp_json_encode(
906 [
907 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
908 'gateway' => 'stripe',
909 // Set explicitly so it is serialized into the form markup: the block
910 // default stays ['stripe'] so existing forms keep their saved
911 // behavior, and only newly created forms offer both gateways.
912 'paymentMethods' => [ 'stripe', 'paypal' ],
913 'paymentType' => 'one-time',
914 'amountType' => 'variable',
915 'minimumAmount' => 0,
916 'variableAmountField' => 'donation-amount',
917 'customerEmailField' => 'donor-email',
918 'customerNameField' => 'donor-name',
919 ]
920 ) . ' /-->';
921
922 // Donate button.
923 $blocks[] = '<!-- wp:suredonation/donate-button ' . wp_json_encode(
924 [
925 'block_id' => \SureDonation\Inc\Helper::generate_block_id(),
926 'buttonText' => __( 'Donate', 'suredonation' ),
927 'slug' => 'donate-button',
928 ]
929 ) . ' /-->';
930
931 return implode( "\n\n", $blocks );
932 }
933
934 /**
935 * Verify nonce for save_post hooks.
936 *
937 * Handles both block editor (REST API) and classic editor nonce verification.
938 * - Block Editor: Verifies the wp_rest nonce via REST_REQUEST constant
939 * - Classic Editor: Verifies _wpnonce with update-post_{$post_id} action
940 *
941 * @param int $post_id Post ID being saved.
942 * @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.
943 * @since 0.0.1
944 */
945 private static function verify_save_post_nonce( $post_id ) {
946 // Block editor saves via REST API - nonce already verified by WordPress REST authentication.
947 // The REST_REQUEST constant is only defined after successful authentication.
948 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
949 return true;
950 }
951
952 // Classic editor - verify _wpnonce with update-post action.
953 $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
954
955 if ( ! is_string( $nonce ) || '' === $nonce ) {
956 return false;
957 }
958
959 return wp_verify_nonce( $nonce, 'update-post_' . $post_id );
960 }
961 }
962