PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.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.1.1, at inc/post-types/donation-form.php

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