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 / field-validation.php

field-validation.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.5.1, at inc/field-validation.php

1,427 lines 53.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Field Validation Class
4 *
5 * Handles field validation for SureDonation forms.
6 * Stores block configuration on form save and retrieves it for validation.
7 *
8 * @package SureDonation
9 * @since 0.0.1
10 */
11
12 namespace SureDonation\Inc;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit; // Exit if accessed directly.
16 }
17
18 /**
19 * Field Validation Class
20 */
21 class Field_Validation {
22 /**
23 * Meta key for storing block configuration.
24 *
25 * @since 0.0.1
26 */
27 public const BLOCK_CONFIG_META_KEY = '_suredonation_block_config';
28
29 /**
30 * Key within the consolidated suredonation_options array that stores the
31 * admin-overridden default validation messages (Global Settings → Form
32 * Validation). Per-field messages always take precedence over these.
33 *
34 * @since 1.1.0
35 */
36 public const VALIDATION_MESSAGES_OPTION_KEY = 'validation_messages';
37
38 /**
39 * Canonical stored values for a checkbox field.
40 *
41 * Deliberately untranslated: the value is persisted to donation_data, read
42 * back by the entry screen, the abilities runtime and the CSV export, and can
43 * be re-imported on another site. Display layers translate it on read via
44 * Helper::format_checkbox_field_value(); the export keeps the canonical token
45 * so the column stays comparable across locales.
46 *
47 * @since 1.5.1
48 */
49 public const CHECKBOX_VALUES = [
50 'yes' => 'Yes',
51 'no' => 'No',
52 ];
53
54 /**
55 * Field blocks whose values participate in field-level validation.
56 *
57 * @since 1.1.0
58 */
59 public const VALIDATABLE_BLOCKS = [
60 'suredonation/input',
61 'suredonation/email',
62 'suredonation/number',
63 'suredonation/checkbox',
64 'suredonation/dropdown',
65 'suredonation/phone',
66 'suredonation/url',
67 ];
68
69 /**
70 * Add block configuration for form fields.
71 *
72 * This function processes blocks in a form and stores their configuration as post meta.
73 * It extracts payment block settings (amount type, fixed amount, minimum amount, etc.)
74 * which are used for server-side validation to prevent payment manipulation.
75 *
76 * @param array<mixed> $blocks Array of blocks to process.
77 * @param int $form_id Form post ID.
78 * @return void
79 * @since 0.0.1
80 */
81 public static function add_block_config( $blocks, $form_id ) {
82 // Initialize array to store processed block configurations.
83 $block_config = [];
84
85 // Process blocks recursively.
86 self::process_blocks_recursive( $blocks, $block_config );
87
88 // Only update meta if we have processed configurations.
89 if ( ! empty( $block_config ) ) {
90 update_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, $block_config );
91 }
92 }
93
94 /**
95 * Retrieve or migrate the block configuration for legacy forms.
96 *
97 * This function checks if the _suredonation_block_config post meta exists for the given form ID.
98 * If not found, it attempts to parse the form's post content and generate the block config.
99 *
100 * @param int $form_id The ID of the form post.
101 * @since 0.0.1
102 * @return array<string, array<string, mixed>>|null The block configuration array, or null if not found or invalid.
103 */
104 public static function get_or_migrate_block_config_for_legacy_form( $form_id ) {
105 // Validate that $form_id is a positive integer.
106 if ( ! is_int( $form_id ) || $form_id <= 0 ) {
107 return null;
108 }
109
110 // Retrieve the block config from post meta.
111 $block_config = get_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, true );
112 if ( ! empty( $block_config ) && is_array( $block_config ) ) {
113 // If it exists and is an array, return it directly (no migration needed).
114 return $block_config;
115 }
116
117 // Get the post by ID and validate.
118 $post = get_post( $form_id );
119 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
120 return null;
121 }
122
123 // Parse the blocks from the post content and attempt migration.
124 if ( function_exists( 'parse_blocks' ) ) {
125 $blocks = parse_blocks( $post->post_content );
126 if ( is_array( $blocks ) && ! empty( $blocks ) ) {
127 self::add_block_config( $blocks, $form_id );
128 }
129 }
130
131 // Retrieve the block config again after migration attempt.
132 $block_config = get_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, true );
133
134 return ! empty( $block_config ) && is_array( $block_config ) ? $block_config : null;
135 }
136
137 /**
138 * Process blocks recursively to extract configuration.
139 *
140 * @param array<mixed> $blocks Array of blocks to process.
141 * @param array<mixed> $block_config Reference to block config array.
142 * @return void
143 * @since 0.0.1
144 */
145 private static function process_blocks_recursive( $blocks, &$block_config ) {
146 foreach ( $blocks as $block ) {
147 // Ensure $block is an array and has the required structure.
148 if ( ! is_array( $block ) ) {
149 continue;
150 }
151
152 // Process inner blocks recursively (for columns, groups, etc.).
153 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
154 self::process_blocks_recursive( $block['innerBlocks'], $block_config );
155 }
156
157 if ( ! isset( $block['blockName'] ) || ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
158 continue;
159 }
160
161 // Validate block_id exists.
162 if ( ! array_key_exists( 'block_id', $block['attrs'] ) || empty( $block['attrs']['block_id'] ) || ! is_string( $block['attrs']['block_id'] ) ) {
163 continue;
164 }
165
166 $block_id = sanitize_text_field( $block['attrs']['block_id'] );
167 $block_name = $block['blockName'];
168
169 // Process specific block types.
170 $processed_config = null;
171
172 switch ( $block_name ) {
173 case 'suredonation/payment':
174 $processed_config = self::process_payment_block( $block['attrs'], $blocks );
175 break;
176 case 'suredonation/donation-amount':
177 $processed_config = self::process_donation_amount_block( $block['attrs'] );
178 break;
179 case 'suredonation/number':
180 $processed_config = self::process_number_block( $block['attrs'] );
181 break;
182 case 'suredonation/cover-fees':
183 $processed_config = self::process_cover_fees_block( $block['attrs'] );
184 break;
185 case 'suredonation/input':
186 $processed_config = self::process_input_block( $block['attrs'] );
187 break;
188 case 'suredonation/email':
189 $processed_config = self::process_email_block( $block['attrs'] );
190 break;
191 case 'suredonation/checkbox':
192 $processed_config = self::process_checkbox_block( $block['attrs'] );
193 break;
194 case 'suredonation/dropdown':
195 $processed_config = self::process_dropdown_block( $block['attrs'] );
196 break;
197 case 'suredonation/phone':
198 $processed_config = self::process_phone_block( $block['attrs'] );
199 break;
200 case 'suredonation/url':
201 $processed_config = self::process_url_block( $block['attrs'] );
202 break;
203 }
204
205 /**
206 * Filter the stored validation config for a field block.
207 *
208 * Lets extensions contribute configuration for field blocks the
209 * core does not handle (e.g. phone, address, url) so their rules are
210 * persisted on save and picked up by validate_form_data(). Return a
211 * non-empty array (including at least a 'required' flag plus any rule
212 * values the validator needs) to store it under the block id.
213 *
214 * Set 'is_checkbox' => true for a consent-style boolean field so the
215 * submission handler stores its value as the canonical Yes/No token
216 * (see CHECKBOX_VALUES) and keeps the unticked state on the record
217 * instead of dropping it as an empty value.
218 *
219 * @since 1.1.0
220 * @param array<string, mixed>|null $processed_config Config from core (null when unhandled).
221 * @param string $block_name Block name.
222 * @param array<string, mixed> $attrs Block attributes.
223 * @param array<mixed> $blocks All blocks in the form.
224 */
225 $processed_config = apply_filters( 'suredonation_field_block_config', $processed_config, $block_name, $block['attrs'], $blocks );
226
227 // If block was processed, store its configuration.
228 if ( null !== $processed_config && ! empty( $processed_config ) ) {
229 $processed_config['block_name'] = $block_name;
230
231 // Add the slug to the configuration.
232 if ( isset( $block['attrs']['slug'] ) && ! empty( $block['attrs']['slug'] ) ) {
233 $processed_config['slug'] = sanitize_text_field( $block['attrs']['slug'] );
234 }
235
236 $block_config[ $block_id ] = $processed_config;
237 }
238 }
239 }
240
241 /**
242 * Process payment block configuration.
243 *
244 * Extracts payment-related settings that are needed for server-side validation:
245 * - payment_type: 'one-time', 'subscription' or 'both'
246 * - amount_type: 'fixed' or 'variable'
247 * - fixed_amount: The configured fixed amount
248 * - minimum_amount: The minimum allowed amount for variable amounts
249 * - variable_amount_field: The slug of the field providing the variable amount
250 * - one_time / subscription: per-choice amount configs, 'both' mode only
251 * - subscription_interval / subscription_billing_cycles: billing cadence, when a
252 * subscription path exists
253 *
254 * @param array<mixed> $attrs Block attributes.
255 * @param array<mixed> $blocks All blocks in the form.
256 * @return array<string, mixed> Processed payment configuration.
257 * @since 0.0.1
258 */
259 private static function process_payment_block( $attrs, $blocks ) {
260 $payment_config = [];
261
262 // Extract payment type (one-time, subscription, or both).
263 // Default to 'one-time' if not set (Gutenberg may not save default values).
264 $payment_config['payment_type'] = isset( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] )
265 ? sanitize_text_field( $attrs['paymentType'] )
266 : 'one-time';
267
268 // Shared amount configuration. Kept at the top level for every payment type,
269 // including 'both', so blocks saved before dual-mode support — and any code
270 // still reading the flat keys — behave exactly as before.
271 $payment_config = array_merge( $payment_config, self::build_amount_config( $attrs, $blocks ) );
272
273 // In 'both' mode each choice carries its own amount configuration. Store them
274 // as separate sub-configs so validation can check the submitted amount against
275 // the mode the donor actually selected rather than a single shared amount.
276 if ( 'both' === $payment_config['payment_type'] ) {
277 $payment_config['one_time'] = self::build_amount_config( $attrs, $blocks, 'oneTime' );
278 $payment_config['subscription'] = self::build_amount_config( $attrs, $blocks, 'subscription' );
279 }
280
281 // Persist the billing cadence for any form with a subscription path. The admin
282 // picks these in the editor, so the stored values are the source of truth on
283 // submit — a tampered interval/cycles in the request cannot redirect the
284 // gateway to a different cadence.
285 //
286 // Stored UNCONDITIONALLY with PHP-side defaults (not gated on
287 // isset( subscriptionPlan )), exactly like build_amount_config() below:
288 // block.json's subscriptionPlan default is a fully-populated object, so
289 // Gutenberg omits the attribute whenever the admin accepts the defaults
290 // (Monthly / Ongoing / default name). Gating on it would leave the cadence
291 // unstored for that common case, get_subscription_cadence() would return
292 // empty, and the submit path would fall back to the request-supplied cadence.
293 if ( in_array( $payment_config['payment_type'], [ 'subscription', 'both' ], true ) ) {
294 $cadence = self::derive_subscription_cadence_from_attrs( $attrs );
295 $payment_config['subscription_interval'] = $cadence['subscription_interval'];
296 $payment_config['subscription_billing_cycles'] = $cadence['subscription_billing_cycles'];
297 }
298
299 return $payment_config;
300 }
301
302 /**
303 * Build one amount configuration (type, fixed, minimum, variable field) from a
304 * set of block attributes.
305 *
306 * Single-mode blocks use the unprefixed attributes (`amountType`, `fixedAmount`,
307 * …); 'both' mode stores an independent configuration per choice under the
308 * `oneTime`/`subscription` attribute prefixes. Defaults match block.json, because
309 * Gutenberg omits attributes whose value equals the default.
310 *
311 * @param array<mixed> $attrs Block attributes.
312 * @param array<mixed> $blocks All blocks in the form.
313 * @param string $prefix Attribute prefix ('' for the shared config, 'oneTime' or 'subscription').
314 * @return array<string, mixed> Amount configuration.
315 * @since 1.5.1
316 */
317 private static function build_amount_config( $attrs, $blocks, $prefix = '' ) {
318 // Maps a suffix onto the prefixed attribute name: an empty prefix gives
319 // "amountType", the oneTime prefix gives "oneTimeAmountType".
320 $attr_key = static function ( $name ) use ( $prefix ) {
321 return '' === $prefix ? lcfirst( $name ) : $prefix . $name;
322 };
323
324 $amount_type_key = $attr_key( 'AmountType' );
325 $fixed_key = $attr_key( 'FixedAmount' );
326 $minimum_key = $attr_key( 'MinimumAmount' );
327 $variable_key = $attr_key( 'VariableAmountField' );
328
329 $config = [
330 'amount_type' => isset( $attrs[ $amount_type_key ] ) && is_string( $attrs[ $amount_type_key ] )
331 ? sanitize_text_field( $attrs[ $amount_type_key ] )
332 : 'fixed',
333 'fixed_amount' => isset( $attrs[ $fixed_key ] ) ? floatval( Helper::get_string_value( $attrs[ $fixed_key ] ) ) : 10.00,
334 // Defaults to 0 (no minimum) — only enforced if the block setting specifies one.
335 'minimum_amount' => isset( $attrs[ $minimum_key ] ) ? floatval( Helper::get_string_value( $attrs[ $minimum_key ] ) ) : 0.0,
336 ];
337
338 // Stored unconditionally (empty string when unset) so absence is
339 // unambiguous: a 'variable' choice whose field was never picked reads as
340 // '' here, which validate_dynamic_amount_field() must reject rather than
341 // wave through. Gutenberg omits the attribute while it equals its ''
342 // default, so keying on isset() alone would hide that misconfiguration.
343 $variable_amount_slug = isset( $attrs[ $variable_key ] )
344 ? sanitize_text_field( Helper::get_string_value( $attrs[ $variable_key ] ) )
345 : '';
346 $config['variable_amount_field'] = $variable_amount_slug;
347
348 // Find and add the block name from which the variable amount field comes from.
349 if ( '' !== $variable_amount_slug && is_array( $blocks ) ) {
350 $block_name = self::find_block_name_by_slug( $blocks, $variable_amount_slug );
351 if ( $block_name ) {
352 $config['variable_amount_field_block_name'] = $block_name;
353 }
354 }
355
356 return $config;
357 }
358
359 /**
360 * Find block name by slug recursively.
361 *
362 * @param array<mixed> $blocks Array of blocks.
363 * @param string $slug Slug to find.
364 * @return string|null Block name if found, null otherwise.
365 * @since 0.0.1
366 */
367 private static function find_block_name_by_slug( $blocks, $slug ) {
368 foreach ( $blocks as $block ) {
369 if ( ! is_array( $block ) ) {
370 continue;
371 }
372
373 // Check inner blocks first.
374 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
375 $found = self::find_block_name_by_slug( $block['innerBlocks'], $slug );
376 if ( $found ) {
377 return $found;
378 }
379 }
380
381 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $slug ) {
382 return $block['blockName'];
383 }
384 }
385 return null;
386 }
387
388 /**
389 * Resolve the slugs of the core donor fields (name, email, variable amount
390 * and the optional mapped phone) from a form's saved payment block.
391 *
392 * These fields are surfaced as first-class donation data and persisted in
393 * their own columns, so the stored "additional" field set omits them.
394 * Deriving the slugs from the saved form here (instead of trusting a
395 * client-supplied list) keeps the exclusion authoritative — a tampered
396 * submission cannot smuggle a core field into the additional set.
397 *
398 * @since 1.1.1
399 * @param int $form_id The donation form post ID.
400 * @return array<int, string> List of core field slugs (empty when none/invalid).
401 */
402 public static function get_core_field_slugs( $form_id ) {
403 if ( ! is_int( $form_id ) || $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) {
404 return [];
405 }
406
407 $post = get_post( $form_id );
408 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
409 return [];
410 }
411
412 $payment_attrs = self::find_payment_block_attrs( parse_blocks( $post->post_content ) );
413 if ( empty( $payment_attrs ) ) {
414 return [];
415 }
416
417 $slugs = [];
418 foreach ( [ 'customerNameField', 'customerEmailField', 'customerPhoneField', 'variableAmountField' ] as $attr ) {
419 if ( isset( $payment_attrs[ $attr ] ) && is_string( $payment_attrs[ $attr ] ) ) {
420 $slug = sanitize_text_field( $payment_attrs[ $attr ] );
421 if ( '' !== $slug ) {
422 $slugs[] = $slug;
423 }
424 }
425 }
426
427 return array_values( array_unique( $slugs ) );
428 }
429
430 /**
431 * Find the suredonation/payment block's attributes recursively.
432 *
433 * @since 1.1.1
434 * @param array<mixed> $blocks Array of parsed blocks.
435 * @return array<string, mixed>|null The payment block attributes, or null when absent.
436 */
437 private static function find_payment_block_attrs( $blocks ) {
438 if ( ! is_array( $blocks ) ) {
439 return null;
440 }
441
442 foreach ( $blocks as $block ) {
443 if ( ! is_array( $block ) ) {
444 continue;
445 }
446
447 if ( isset( $block['blockName'] ) && 'suredonation/payment' === $block['blockName'] && isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
448 return $block['attrs'];
449 }
450
451 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
452 $found = self::find_payment_block_attrs( $block['innerBlocks'] );
453 if ( null !== $found ) {
454 return $found;
455 }
456 }
457 }
458
459 return null;
460 }
461
462 /**
463 * Derive the billing cadence from a payment block's attributes.
464 *
465 * Single source of truth shared by build_amount_config() (store time) and
466 * resolve_subscription_cadence_from_content() (legacy re-resolve). block.json's
467 * subscriptionPlan default is a fully-populated object, so Gutenberg omits the
468 * attribute whenever the admin accepts the defaults — hence the PHP-side
469 * defaults of month / ongoing here.
470 *
471 * @since 1.5.1
472 * @param array<string, mixed> $attrs Parsed payment-block attributes.
473 * @return array{subscription_interval: string, subscription_billing_cycles: int|string}
474 */
475 private static function derive_subscription_cadence_from_attrs( $attrs ) {
476 $plan = isset( $attrs['subscriptionPlan'] ) && is_array( $attrs['subscriptionPlan'] )
477 ? $attrs['subscriptionPlan']
478 : [];
479
480 $interval = isset( $plan['interval'] ) && is_string( $plan['interval'] )
481 ? sanitize_text_field( $plan['interval'] )
482 : 'month';
483
484 if ( isset( $plan['billingCycles'] ) ) {
485 // billingCycles is either an integer count or the string 'ongoing'.
486 $cycles = $plan['billingCycles'];
487 $billing_cycles = is_numeric( $cycles )
488 ? (int) $cycles
489 : sanitize_text_field( Helper::get_string_value( $cycles ) );
490 } else {
491 $billing_cycles = 'ongoing';
492 }
493
494 return [
495 'subscription_interval' => $interval,
496 'subscription_billing_cycles' => $billing_cycles,
497 ];
498 }
499
500 /**
501 * Re-resolve a form's billing cadence from its stored post content.
502 *
503 * Forms saved before the cadence was persisted into the block config carry no
504 * subscription_interval/billing_cycles keys in stored meta, and the config is
505 * only rebuilt on save_post — so those keys never appear until the admin
506 * happens to re-save. Rather than let the submit path assume month / ongoing
507 * (which silently rewrites the admin's real plan — e.g. a 5-cycle yearly plan
508 * becomes ongoing monthly), re-derive from the parsed payment block. This is
509 * still server-side and untamperable: it reads post_content, never the request.
510 *
511 * @since 1.5.1
512 * @param int $form_id Donation form post ID.
513 * @return array{subscription_interval: string, subscription_billing_cycles: int|string}|null
514 * Cadence, or null when the form has no readable payment block.
515 */
516 public static function resolve_subscription_cadence_from_content( $form_id ) {
517 if ( ! is_int( $form_id ) || $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) {
518 return null;
519 }
520
521 $post = get_post( $form_id );
522 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
523 return null;
524 }
525
526 $attrs = self::find_payment_block_attrs( parse_blocks( $post->post_content ) );
527 if ( ! is_array( $attrs ) ) {
528 return null;
529 }
530
531 return self::derive_subscription_cadence_from_attrs( $attrs );
532 }
533
534 /**
535 * Resolve the slug of the field mapped to the donor phone on the payment block.
536 *
537 * The mapping is optional: when an author maps a Phone field via the payment
538 * block's "Customer Phone Field" picker, its value is stored in the dedicated
539 * donor_phone column. Returning the slug lets the submission handlers read the
540 * already-validated value from the submitted fields rather than trusting a
541 * separate client-supplied donor_phone field.
542 *
543 * @since 1.1.1
544 * @param int $form_id The donation form post ID.
545 * @return string The mapped phone field slug, or '' when unset/invalid.
546 */
547 public static function get_mapped_phone_slug( $form_id ) {
548 if ( ! is_int( $form_id ) || $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) {
549 return '';
550 }
551
552 $post = get_post( $form_id );
553 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
554 return '';
555 }
556
557 $payment_attrs = self::find_payment_block_attrs( parse_blocks( $post->post_content ) );
558 if ( empty( $payment_attrs ) || ! isset( $payment_attrs['customerPhoneField'] ) || ! is_string( $payment_attrs['customerPhoneField'] ) ) {
559 return '';
560 }
561
562 return sanitize_text_field( $payment_attrs['customerPhoneField'] );
563 }
564
565 /**
566 * Build a map of field slug => label from a form's saved blocks.
567 *
568 * The label persisted with each submitted field is resolved from the saved
569 * form (the authoritative source) rather than scraped from the rendered
570 * page and trusted from the request — mirroring how SureForms recovers a
571 * field's label server-side instead of from client-supplied text. Gutenberg
572 * omits attributes left at their default, so a slug missing from this map
573 * simply has no customized label and the caller falls back to the label
574 * sent with the submission.
575 *
576 * @since 1.1.1
577 * @param int $form_id The donation form post ID.
578 * @return array<string, string> Map of field slug => label (empty when none/invalid).
579 */
580 public static function get_field_labels_map( $form_id ) {
581 if ( ! is_int( $form_id ) || $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) {
582 return [];
583 }
584
585 $post = get_post( $form_id );
586 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
587 return [];
588 }
589
590 $labels = [];
591 self::collect_field_labels( parse_blocks( $post->post_content ), $labels );
592
593 return $labels;
594 }
595
596 /**
597 * Recursively collect slug => label pairs from parsed blocks.
598 *
599 * Inner blocks are walked first so nested sub-fields are captured before
600 * their container; the first label seen for a slug wins.
601 *
602 * @since 1.1.1
603 * @param array<mixed> $blocks Parsed blocks.
604 * @param array<string, string> $labels Accumulator passed by reference.
605 * @return void
606 */
607 private static function collect_field_labels( $blocks, &$labels ) {
608 if ( ! is_array( $blocks ) ) {
609 return;
610 }
611
612 foreach ( $blocks as $block ) {
613 if ( ! is_array( $block ) ) {
614 continue;
615 }
616
617 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
618 self::collect_field_labels( $block['innerBlocks'], $labels );
619 }
620
621 if ( ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
622 continue;
623 }
624
625 $slug = isset( $block['attrs']['slug'] ) && is_string( $block['attrs']['slug'] )
626 ? sanitize_text_field( $block['attrs']['slug'] )
627 : '';
628 if ( '' === $slug || isset( $labels[ $slug ] ) ) {
629 continue;
630 }
631
632 if ( isset( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
633 $labels[ $slug ] = sanitize_text_field( $block['attrs']['label'] );
634 }
635 }
636 }
637
638 /**
639 * Process donation-amount block configuration.
640 *
641 * @param array<mixed> $attrs Block attributes.
642 * @return array<string, mixed> Processed donation-amount configuration.
643 * @since 0.0.1
644 */
645 private static function process_donation_amount_block( $attrs ) {
646 $donation_amount_config = [];
647
648 // Extract required field.
649 if ( isset( $attrs['required'] ) ) {
650 $donation_amount_config['required'] = ! empty( $attrs['required'] );
651 }
652
653 // Extract choice type (radio or checkbox).
654 if ( isset( $attrs['choiceType'] ) ) {
655 $donation_amount_config['choice_type'] = sanitize_text_field( $attrs['choiceType'] );
656 }
657
658 // Extract options with their full structure (label, value).
659 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
660 $sanitized_options = [];
661 foreach ( $attrs['options'] as $option ) {
662 if ( is_array( $option ) ) {
663 $sanitized_options[] = [
664 'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '',
665 'value' => isset( $option['value'] ) ? sanitize_text_field( $option['value'] ) : '',
666 ];
667 }
668 }
669 $donation_amount_config['options'] = $sanitized_options;
670 }
671
672 // Custom amount settings (radio-mode only).
673 $donation_amount_config['allow_custom_amount'] = ! isset( $attrs['allowCustomAmount'] ) || ! empty( $attrs['allowCustomAmount'] );
674 $donation_amount_config['custom_amount_min'] = isset( $attrs['customAmountMin'] ) ? (float) $attrs['customAmountMin'] : 0.0;
675 $donation_amount_config['custom_amount_max'] = isset( $attrs['customAmountMax'] ) ? (float) $attrs['customAmountMax'] : 0.0;
676
677 return $donation_amount_config;
678 }
679
680 /**
681 * Process cover-fees block configuration.
682 *
683 * Resolves global vs block-level fee rates and stores them for server-side validation.
684 *
685 * @param array<mixed> $attrs Block attributes.
686 * @return array<string, mixed> Processed cover fees configuration.
687 * @since 1.0.0
688 */
689 private static function process_cover_fees_block( $attrs ) {
690 $use_global = $attrs['useGlobalDefaults'] ?? true;
691
692 if ( $use_global ) {
693 $fee_config = \SureDonation\Inc\Payments\Payment_Helper::get_fee_recovery_settings();
694 } else {
695 $fee_config = [
696 'fee_percentage' => isset( $attrs['feePercentage'] ) ? floatval( $attrs['feePercentage'] ) : 2.9,
697 'fee_fixed' => isset( $attrs['feeFixed'] ) ? floatval( $attrs['feeFixed'] ) : 0.30,
698 'fee_mode' => $attrs['feeMode'] ?? 'all_gateways',
699 'gateways' => $attrs['gatewayFees'] ?? [],
700 ];
701 }
702
703 return [
704 'use_global_defaults' => $use_global,
705 'fee_percentage' => (float) ( $fee_config['fee_percentage'] ?? 2.9 ),
706 'fee_fixed' => (float) ( $fee_config['fee_fixed'] ?? 0.30 ),
707 'fee_mode' => $fee_config['fee_mode'] ?? 'all_gateways',
708 'gateway_fees' => $fee_config['gateways'] ?? [],
709 ];
710 }
711
712 /**
713 * Process number block configuration.
714 *
715 * @param array<mixed> $attrs Block attributes.
716 * @return array<string, mixed> Processed number block configuration.
717 * @since 0.0.1
718 */
719 private static function process_number_block( $attrs ) {
720 $number_config = [];
721
722 // Extract required field.
723 if ( isset( $attrs['required'] ) ) {
724 $number_config['required'] = ! empty( $attrs['required'] );
725 }
726
727 // Extract min value.
728 if ( isset( $attrs['min'] ) ) {
729 $number_config['min'] = floatval( $attrs['min'] );
730 }
731
732 // Extract max value.
733 if ( isset( $attrs['max'] ) ) {
734 $number_config['max'] = floatval( $attrs['max'] );
735 }
736
737 // Field-level min/max value rules for client + server validation.
738 //
739 // These are stored under dedicated keys (read from the block's real
740 // `minValue`/`maxValue` attributes) and are deliberately kept separate
741 // from the amount-path `min`/`max` keys above, which are consumed by
742 // Payment_Helper::validate_number_field_amount(). Coerced with absint to
743 // match Number_Markup, which renders integer min/max — keeping the
744 // rendered HTML constraints and server validation in sync. The markup
745 // mirrors these exact defaults: min is always present (default 1) and
746 // max only applies when greater than zero.
747 $number_config['validation_min'] = isset( $attrs['minValue'] ) ? absint( Helper::get_string_value( $attrs['minValue'] ) ) : 1;
748 $number_config['validation_max'] = isset( $attrs['maxValue'] ) ? absint( Helper::get_string_value( $attrs['maxValue'] ) ) : 0;
749
750 // Per-field custom required message.
751 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
752 if ( '' !== $error_msg ) {
753 $number_config['error_msg'] = $error_msg;
754 }
755
756 return $number_config;
757 }
758
759 /**
760 * Process input (text) block configuration.
761 *
762 * Extracts the field-level validation rules — required, max length and the
763 * optional per-field custom required message — for server-side enforcement.
764 *
765 * @param array<mixed> $attrs Block attributes.
766 * @return array<string, mixed> Processed input block configuration.
767 * @since 1.1.0
768 */
769 private static function process_input_block( $attrs ) {
770 $input_config = [
771 'required' => ! empty( $attrs['required'] ),
772 'max_length' => isset( $attrs['maxLength'] ) ? absint( Helper::get_string_value( $attrs['maxLength'] ) ) : 100,
773 ];
774
775 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
776 if ( '' !== $error_msg ) {
777 $input_config['error_msg'] = $error_msg;
778 }
779
780 return $input_config;
781 }
782
783 /**
784 * Process email block configuration.
785 *
786 * Extracts required state, the optional per-field custom required message
787 * and the per-field invalid-email message for server-side enforcement.
788 *
789 * @param array<mixed> $attrs Block attributes.
790 * @return array<string, mixed> Processed email block configuration.
791 * @since 1.1.0
792 */
793 private static function process_email_block( $attrs ) {
794 $email_config = [
795 'required' => ! empty( $attrs['required'] ),
796 ];
797
798 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
799 if ( '' !== $error_msg ) {
800 $email_config['error_msg'] = $error_msg;
801 }
802
803 $invalid_email_msg = isset( $attrs['invalidEmailMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['invalidEmailMsg'] ) ) : '';
804 if ( '' !== $invalid_email_msg ) {
805 $email_config['invalid_email_msg'] = $invalid_email_msg;
806 }
807
808 return $email_config;
809 }
810
811 /**
812 * Process dropdown block configuration.
813 *
814 * Stores required state, multi-select bounds and the allowed option labels so
815 * the server can enforce required/min/max selections and reject tampered values.
816 *
817 * @param array<mixed> $attrs Block attributes.
818 * @return array<string, mixed> Processed dropdown block configuration.
819 * @since 1.1.1
820 */
821 private static function process_dropdown_block( $attrs ) {
822 $dropdown_config = [
823 'required' => ! empty( $attrs['required'] ),
824 'multi_select' => ! empty( $attrs['multiSelect'] ),
825 'min_selection' => isset( $attrs['minSelection'] ) ? absint( Helper::get_string_value( $attrs['minSelection'] ) ) : 0,
826 'max_selection' => isset( $attrs['maxSelection'] ) ? absint( Helper::get_string_value( $attrs['maxSelection'] ) ) : 0,
827 ];
828
829 // Allowed option labels (the submitted value(s) must match one of these).
830 $options = [];
831 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
832 foreach ( $attrs['options'] as $option ) {
833 if ( is_array( $option ) && isset( $option['label'] ) && '' !== $option['label'] ) {
834 $options[] = sanitize_text_field( Helper::get_string_value( $option['label'] ) );
835 }
836 }
837 }
838 $dropdown_config['options'] = $options;
839
840 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
841 if ( '' !== $error_msg ) {
842 $dropdown_config['error_msg'] = $error_msg;
843 }
844
845 return $dropdown_config;
846 }
847
848 /**
849 * Process phone block configuration.
850 *
851 * Stores required state and the optional per-field custom required message for
852 * server-side enforcement. Phone-number format is validated loosely (see
853 * validate_field_value) because the submitted value is the E.164-style number
854 * produced by intl-tel-input.
855 *
856 * @param array<mixed> $attrs Block attributes.
857 * @return array<string, mixed> Processed phone block configuration.
858 * @since 1.1.1
859 */
860 private static function process_phone_block( $attrs ) {
861 $phone_config = [
862 'required' => ! empty( $attrs['required'] ),
863 ];
864
865 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
866 if ( '' !== $error_msg ) {
867 $phone_config['error_msg'] = $error_msg;
868 }
869
870 return $phone_config;
871 }
872
873 /**
874 * Process url block configuration.
875 *
876 * Stores required state, the optional per-field custom required message and
877 * the per-field invalid-URL message for server-side enforcement.
878 *
879 * @param array<mixed> $attrs Block attributes.
880 * @return array<string, mixed> Processed url block configuration.
881 * @since 1.1.1
882 */
883 private static function process_url_block( $attrs ) {
884 $url_config = [
885 'required' => ! empty( $attrs['required'] ),
886 ];
887
888 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
889 if ( '' !== $error_msg ) {
890 $url_config['error_msg'] = $error_msg;
891 }
892
893 $invalid_url_msg = isset( $attrs['invalidUrlMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['invalidUrlMsg'] ) ) : '';
894 if ( '' !== $invalid_url_msg ) {
895 $url_config['invalid_url_msg'] = $invalid_url_msg;
896 }
897
898 return $url_config;
899 }
900
901 /**
902 * Process checkbox block configuration.
903 *
904 * A checkbox carries no format or range rules — only the required flag and
905 * the optional per-field error message. `is_checkbox` is stored so the
906 * submission handler can recognise the field by its saved configuration
907 * (rather than trusting the request) when it renders the value as Yes/No.
908 *
909 * @param array<mixed> $attrs Block attributes.
910 * @return array<string, mixed> Processed checkbox configuration.
911 * @since 1.5.1
912 */
913 private static function process_checkbox_block( $attrs ) {
914 $checkbox_config = [
915 'required' => ! empty( $attrs['required'] ),
916 'is_checkbox' => true,
917 ];
918
919 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
920 if ( '' !== $error_msg ) {
921 $checkbox_config['error_msg'] = $error_msg;
922 }
923
924 return $checkbox_config;
925 }
926
927 /**
928 * Resolve the slugs of the form's checkbox fields.
929 *
930 * Read from the saved form's stored block configuration, so a submission
931 * cannot claim a field is (or is not) a checkbox. Used to render the
932 * submitted value as a readable Yes/No rather than a bare "1"/empty, and to
933 * keep an unchecked box in the stored record instead of dropping it as an
934 * empty value.
935 *
936 * Only `suredonation/checkbox` fields are returned. The fixed-purpose
937 * consent checkboxes (anonymous donation, cover fees, privacy consent) are
938 * not form-editor field blocks and have no entry in the block config, so
939 * their storage is unaffected.
940 *
941 * @param int $form_id Donation form post ID.
942 * @return array<int, string> Checkbox field slugs.
943 * @since 1.5.1
944 */
945 public static function get_checkbox_field_slugs( $form_id ) {
946 $form_id = absint( $form_id );
947 if ( $form_id <= 0 ) {
948 return [];
949 }
950
951 $block_config = self::get_or_migrate_block_config_for_legacy_form( $form_id );
952 if ( empty( $block_config ) || ! is_array( $block_config ) ) {
953 return [];
954 }
955
956 $slugs = [];
957 foreach ( $block_config as $config ) {
958 if ( ! is_array( $config ) || empty( $config['is_checkbox'] ) ) {
959 continue;
960 }
961
962 $slug = isset( $config['slug'] ) && is_string( $config['slug'] ) ? $config['slug'] : '';
963 if ( '' !== $slug ) {
964 $slugs[] = $slug;
965 }
966 }
967
968 return $slugs;
969 }
970
971 /**
972 * Get the block types that participate in field validation.
973 *
974 * Extensions register new validatable field blocks (e.g. phone, address,
975 * url) via the filter so their values run through validate_form_data().
976 * Pair this with the suredonation_field_block_config filter (to store the
977 * block's rules on save) and suredonation_validate_field (to apply them).
978 *
979 * @return array<int, string>
980 * @since 1.1.0
981 */
982 public static function get_validatable_blocks() {
983 /**
984 * Filter the block types that participate in field validation.
985 *
986 * @since 1.1.0
987 * @param array<int, string> $blocks Validatable block names.
988 */
989 $blocks = apply_filters( 'suredonation_validatable_blocks', self::VALIDATABLE_BLOCKS );
990
991 return is_array( $blocks ) ? $blocks : self::VALIDATABLE_BLOCKS;
992 }
993
994 /**
995 * Validate submitted donation form field values server-side.
996 *
997 * This is the authoritative validation pass: it reads the immutable block
998 * configuration stored on form save and enforces each field's rules
999 * (required, max length, email format, number range). Per-field custom
1000 * messages take precedence over the global defaults configured under
1001 * Global Settings → Form Validation.
1002 *
1003 * @param array<string, mixed> $fields Submitted field values keyed by field slug.
1004 * @param int $form_id Donation form post ID.
1005 * @return array<string, string> Map of field slug => error message. Empty when valid.
1006 * @since 1.1.0
1007 */
1008 public static function validate_form_data( $fields, $form_id ) {
1009 $errors = [];
1010
1011 if ( ! is_array( $fields ) ) {
1012 $fields = [];
1013 }
1014
1015 $form_id = absint( $form_id );
1016 if ( $form_id <= 0 ) {
1017 return $errors;
1018 }
1019
1020 $block_config = self::get_or_migrate_block_config_for_legacy_form( $form_id );
1021 if ( empty( $block_config ) || ! is_array( $block_config ) ) {
1022 return $errors;
1023 }
1024
1025 $validatable = self::get_validatable_blocks();
1026
1027 foreach ( $block_config as $config ) {
1028 if ( ! is_array( $config ) ) {
1029 continue;
1030 }
1031
1032 $block_name = isset( $config['block_name'] ) && is_string( $config['block_name'] ) ? $config['block_name'] : '';
1033 $slug = isset( $config['slug'] ) && is_string( $config['slug'] ) ? $config['slug'] : '';
1034
1035 if ( '' === $slug || ! in_array( $block_name, $validatable, true ) ) {
1036 continue;
1037 }
1038
1039 $raw_value = array_key_exists( $slug, $fields ) ? $fields[ $slug ] : '';
1040 $value = is_scalar( $raw_value ) ? trim( (string) $raw_value ) : '';
1041
1042 $error = self::validate_field_value( $block_name, $config, $value );
1043
1044 /**
1045 * Filter the validation error for a single donation form field.
1046 *
1047 * Lets extensions (e.g. SureDonation Pro) add custom validators for
1048 * their own field types or rules. Return a non-empty string to flag
1049 * the field as invalid; return an empty string to pass.
1050 *
1051 * @since 1.1.0
1052 * @param string $error Current error message ('' when valid).
1053 * @param string $value Submitted, trimmed field value.
1054 * @param array<string, mixed> $config Stored block configuration for the field.
1055 * @param int $form_id Donation form ID.
1056 * @param string $block_name Block name (e.g. 'suredonation/input').
1057 */
1058 $error = apply_filters( 'suredonation_validate_field', $error, $value, $config, $form_id, $block_name );
1059
1060 if ( is_string( $error ) && '' !== $error ) {
1061 $errors[ $slug ] = $error;
1062 }
1063 }
1064
1065 return $errors;
1066 }
1067
1068 /**
1069 * Get a validation message by key, preferring the admin override.
1070 *
1071 * @param string $key Message key.
1072 * @return string
1073 * @since 1.1.0
1074 */
1075 public static function get_validation_message( $key ) {
1076 $defaults = self::default_validation_messages();
1077 $stored = Helper::get_suredonation_option( self::VALIDATION_MESSAGES_OPTION_KEY, [] );
1078
1079 if ( is_array( $stored ) && ! empty( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
1080 return $stored[ $key ];
1081 }
1082
1083 return isset( $defaults[ $key ] ) ? $defaults[ $key ] : '';
1084 }
1085
1086 /**
1087 * Default (fallback) validation messages, keyed by message key.
1088 *
1089 * Messages containing %s use sprintf substitution for the configured bound.
1090 *
1091 * @return array<string, string>
1092 * @since 1.1.0
1093 */
1094 public static function default_validation_messages() {
1095 $messages = [
1096 'suredonation_input_block_required_text' => __( 'This field is required.', 'suredonation' ),
1097 'suredonation_email_block_required_text' => __( 'This field is required.', 'suredonation' ),
1098 'suredonation_number_block_required_text' => __( 'This field is required.', 'suredonation' ),
1099 'suredonation_checkbox_block_required_text' => __( 'This field is required.', 'suredonation' ),
1100 'suredonation_dropdown_block_required_text' => __( 'This field is required.', 'suredonation' ),
1101 'suredonation_phone_block_required_text' => __( 'This field is required.', 'suredonation' ),
1102 'suredonation_url_block_required_text' => __( 'This field is required.', 'suredonation' ),
1103 'suredonation_valid_email' => __( 'Please enter a valid email address.', 'suredonation' ),
1104 'suredonation_valid_number' => __( 'Please enter a valid number.', 'suredonation' ),
1105 'suredonation_valid_phone' => __( 'Please enter a valid phone number.', 'suredonation' ),
1106 'suredonation_valid_url' => __( 'Please enter a valid URL.', 'suredonation' ),
1107 'suredonation_dropdown_invalid_option' => __( 'Please select a valid option.', 'suredonation' ),
1108 /* translators: %s: minimum number of selections required. */
1109 'suredonation_dropdown_min_selection' => __( 'Please select at least %s option(s).', 'suredonation' ),
1110 /* translators: %s: maximum number of selections allowed. */
1111 'suredonation_dropdown_max_selection' => __( 'Please select no more than %s option(s).', 'suredonation' ),
1112 /* translators: %s: maximum number of characters allowed. */
1113 'suredonation_input_max_length' => __( 'Maximum length is %s characters.', 'suredonation' ),
1114 /* translators: %s: maximum characters allowed before the @ symbol. */
1115 'suredonation_email_local_max_length' => __( 'The part before @ may not exceed %s characters.', 'suredonation' ),
1116 /* translators: %s: maximum characters allowed after the @ symbol. */
1117 'suredonation_email_domain_max_length' => __( 'The part after @ may not exceed %s characters.', 'suredonation' ),
1118 /* translators: %s: maximum total characters allowed in an email address. */
1119 'suredonation_email_max_length' => __( 'The email address may not exceed %s characters.', 'suredonation' ),
1120 /* translators: %s: minimum allowed value. */
1121 'suredonation_input_min_value' => __( 'Minimum value is %s.', 'suredonation' ),
1122 /* translators: %s: maximum allowed value. */
1123 'suredonation_input_max_value' => __( 'Maximum value is %s.', 'suredonation' ),
1124 ];
1125
1126 /**
1127 * Filter the default validation messages.
1128 *
1129 * Extensions add message keys for their own field types here so the
1130 * messages resolve, localize and surface in the Form Validation tab
1131 * alongside the core ones. Keys containing %s use sprintf substitution.
1132 *
1133 * @since 1.1.0
1134 * @param array<string, string> $messages Default messages keyed by message key.
1135 */
1136 return apply_filters( 'suredonation_default_validation_messages', $messages );
1137 }
1138
1139 /**
1140 * Get the fully resolved validation messages (admin overrides over defaults).
1141 *
1142 * Used to localize the messages to the frontend so client-side validation
1143 * mirrors exactly what the server enforces.
1144 *
1145 * @return array<string, string>
1146 * @since 1.1.0
1147 */
1148 public static function get_resolved_validation_messages() {
1149 $defaults = self::default_validation_messages();
1150 $stored = Helper::get_suredonation_option( self::VALIDATION_MESSAGES_OPTION_KEY, [] );
1151
1152 if ( ! is_array( $stored ) ) {
1153 return $defaults;
1154 }
1155
1156 $resolved = $defaults;
1157 foreach ( $defaults as $key => $default ) {
1158 if ( ! empty( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
1159 $resolved[ $key ] = $stored[ $key ];
1160 }
1161 }
1162
1163 return $resolved;
1164 }
1165
1166 /**
1167 * Apply the core validation rules for a single field value.
1168 *
1169 * @param string $block_name Block name.
1170 * @param array<string, mixed> $config Stored block configuration for the field.
1171 * @param string $value Submitted, trimmed field value.
1172 * @return string Error message, or '' when the value passes.
1173 * @since 1.1.0
1174 */
1175 private static function validate_field_value( $block_name, $config, $value ) {
1176 // Required check applies to every field type.
1177 if ( ! empty( $config['required'] ) && '' === $value ) {
1178 return self::resolve_required_message( $block_name, $config );
1179 }
1180
1181 // Format/range checks are skipped for empty optional values.
1182 if ( '' === $value ) {
1183 return '';
1184 }
1185
1186 switch ( $block_name ) {
1187 case 'suredonation/input':
1188 $max_length = isset( $config['max_length'] ) && is_numeric( $config['max_length'] ) ? (int) $config['max_length'] : 0;
1189 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value );
1190 if ( $max_length > 0 && $length > $max_length ) {
1191 // str_replace (not sprintf) because the message is admin/translator
1192 // editable; a stray literal % would make sprintf throw on PHP 8.
1193 return str_replace( '%s', number_format_i18n( $max_length ), self::get_validation_message( 'suredonation_input_max_length' ) );
1194 }
1195 break;
1196
1197 case 'suredonation/email':
1198 if ( ! is_email( $value ) ) {
1199 if ( ! empty( $config['invalid_email_msg'] ) && is_string( $config['invalid_email_msg'] ) ) {
1200 return $config['invalid_email_msg'];
1201 }
1202 return self::get_validation_message( 'suredonation_valid_email' );
1203 }
1204
1205 $email_length_error = self::validate_email_length( $value );
1206 if ( '' !== $email_length_error ) {
1207 return $email_length_error;
1208 }
1209 break;
1210
1211 case 'suredonation/url':
1212 // Intentional dotted-host-only restriction (same as SureForms): the
1213 // value must be a domain with a TLD or an IPv4 host, with an optional
1214 // scheme, port, path, query and fragment. Bare single-label hosts
1215 // (localhost, intranet names, typos like "abcdef") are deliberately
1216 // rejected for a public "website" field. The 2048-byte cap
1217 // short-circuits before the regex on overlong, public, unauthenticated
1218 // input so its host-label sub-pattern cannot backtrack (ReDoS guard).
1219 // Kept in sync with the client check in src/form-frontend/validation.js.
1220 if ( strlen( $value ) > 2048 || ! preg_match( '#^(https?://)?((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(:\d+)?(/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?(\#[-a-z\d_]*)?$#i', $value ) ) {
1221 if ( ! empty( $config['invalid_url_msg'] ) && is_string( $config['invalid_url_msg'] ) ) {
1222 return $config['invalid_url_msg'];
1223 }
1224 return self::get_validation_message( 'suredonation_valid_url' );
1225 }
1226 break;
1227
1228 case 'suredonation/phone':
1229 // Loose format check: digits plus the common phone punctuation,
1230 // 6–20 characters. The strict country-aware check happens client-side
1231 // via intl-tel-input; this guards against obviously bad submissions.
1232 if ( ! preg_match( '/^[\d\s()+.\-]{6,20}$/', $value ) ) {
1233 return self::get_validation_message( 'suredonation_valid_phone' );
1234 }
1235 break;
1236
1237 case 'suredonation/number':
1238 if ( ! is_numeric( $value ) ) {
1239 return self::get_validation_message( 'suredonation_valid_number' );
1240 }
1241
1242 $number = (float) $value;
1243
1244 if ( isset( $config['validation_min'] ) && is_numeric( $config['validation_min'] ) && $number < (float) $config['validation_min'] ) {
1245 return str_replace( '%s', self::format_number( (float) $config['validation_min'] ), self::get_validation_message( 'suredonation_input_min_value' ) );
1246 }
1247
1248 $validation_max = isset( $config['validation_max'] ) && is_numeric( $config['validation_max'] ) ? (float) $config['validation_max'] : 0.0;
1249 if ( $validation_max > 0 && $number > $validation_max ) {
1250 return str_replace( '%s', self::format_number( $validation_max ), self::get_validation_message( 'suredonation_input_max_value' ) );
1251 }
1252 break;
1253
1254 case 'suredonation/dropdown':
1255 $multi_select = ! empty( $config['multi_select'] );
1256 // Deduplicate before the min/max count check — the server is the
1257 // trust boundary, and a crafted "A|A|A" must not pass max_selection
1258 // (or min_selection) with a single distinct value.
1259 $selections = $multi_select
1260 ? array_values( array_unique( array_filter( array_map( 'trim', explode( '|', $value ) ), 'strlen' ) ) )
1261 : [ $value ];
1262
1263 // Reject values that are not among the configured options.
1264 $allowed = isset( $config['options'] ) && is_array( $config['options'] ) ? $config['options'] : [];
1265 if ( ! empty( $allowed ) ) {
1266 foreach ( $selections as $selection ) {
1267 if ( ! in_array( $selection, $allowed, true ) ) {
1268 return self::get_validation_message( 'suredonation_dropdown_invalid_option' );
1269 }
1270 }
1271 }
1272
1273 // Min/max apply to multi-select only.
1274 if ( $multi_select ) {
1275 $count = count( $selections );
1276 $min = isset( $config['min_selection'] ) ? (int) $config['min_selection'] : 0;
1277 $max = isset( $config['max_selection'] ) ? (int) $config['max_selection'] : 0;
1278
1279 if ( $min > 0 && $count < $min ) {
1280 return str_replace( '%s', number_format_i18n( $min ), self::get_validation_message( 'suredonation_dropdown_min_selection' ) );
1281 }
1282 if ( $max > 0 && $count > $max ) {
1283 return str_replace( '%s', number_format_i18n( $max ), self::get_validation_message( 'suredonation_dropdown_max_selection' ) );
1284 }
1285 }
1286 break;
1287 }
1288
1289 return '';
1290 }
1291
1292 /**
1293 * Enforce RFC 5321 length limits on an email value.
1294 *
1295 * The value is split on the last @ so the local part (before @, max 64) and
1296 * domain part (after @, max 255) are bounded separately. Limits are
1297 * overridable via the suredonation_email_field_char_limits filter.
1298 *
1299 * Public so the payment layer can length-cap the persisted donor_email
1300 * (which is separate from the validation-only fields[] copy this class
1301 * normally inspects). A value with no @ — possible when the caller has not
1302 * already run is_email() — is bounded by the local-part limit so oversized
1303 * junk still cannot be stored.
1304 *
1305 * @param string $value Submitted, trimmed email value.
1306 * @return string Error message, or '' when the value passes.
1307 * @since 1.1.1
1308 */
1309 public static function validate_email_length( $value ) {
1310 $defaults = [
1311 'local' => 64,
1312 'domain' => 255,
1313 ];
1314
1315 /**
1316 * Filter the RFC 5321 character limits enforced on the Email field.
1317 *
1318 * @since 1.1.1
1319 * @param array{local:int,domain:int} $limits Max characters for the local and domain parts.
1320 */
1321 $limits = apply_filters( 'suredonation_email_field_char_limits', $defaults );
1322
1323 // Fall back to defaults if the filter returns junk or non-positive values.
1324 $local_limit = is_array( $limits ) && isset( $limits['local'] ) && (int) $limits['local'] > 0 ? (int) $limits['local'] : $defaults['local'];
1325 $domain_limit = is_array( $limits ) && isset( $limits['domain'] ) && (int) $limits['domain'] > 0 ? (int) $limits['domain'] : $defaults['domain'];
1326
1327 $at = strrpos( $value, '@' );
1328 if ( false === $at ) {
1329 // No @ (caller did not run is_email first): bound the whole value by
1330 // the local-part limit so oversized junk cannot be persisted.
1331 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value );
1332 if ( $length > $local_limit ) {
1333 return str_replace( '%s', number_format_i18n( $local_limit ), self::get_validation_message( 'suredonation_email_local_max_length' ) );
1334 }
1335 // Defensive total cap (see below): only reachable if a filter raised the
1336 // local limit past 254; keeps a no-@ value within the VARCHAR(255) column.
1337 if ( $length > 254 ) {
1338 return str_replace( '%s', number_format_i18n( 254 ), self::get_validation_message( 'suredonation_email_max_length' ) );
1339 }
1340 return '';
1341 }
1342
1343 $local_part = substr( $value, 0, $at );
1344 $domain_part = substr( $value, $at + 1 );
1345
1346 $local_length = function_exists( 'mb_strlen' ) ? mb_strlen( $local_part ) : strlen( $local_part );
1347 $domain_length = function_exists( 'mb_strlen' ) ? mb_strlen( $domain_part ) : strlen( $domain_part );
1348
1349 // str_replace (not sprintf) because the message is admin/translator
1350 // editable; a stray literal % would make sprintf throw on PHP 8.
1351 if ( $local_length > $local_limit ) {
1352 return str_replace( '%s', number_format_i18n( $local_limit ), self::get_validation_message( 'suredonation_email_local_max_length' ) );
1353 }
1354
1355 if ( $domain_length > $domain_limit ) {
1356 return str_replace( '%s', number_format_i18n( $domain_limit ), self::get_validation_message( 'suredonation_email_domain_max_length' ) );
1357 }
1358
1359 // RFC 5321 §4.5.3.1.3: the whole address may not exceed 254 chars. This is a
1360 // fixed cap (independent of the per-part filter) because it also guarantees
1361 // the value fits the VARCHAR(255) donor_email/email columns, which the
1362 // per-part caps alone do not — they sum to 320.
1363 if ( ( $local_length + 1 + $domain_length ) > 254 ) {
1364 return str_replace( '%s', number_format_i18n( 254 ), self::get_validation_message( 'suredonation_email_max_length' ) );
1365 }
1366
1367 return '';
1368 }
1369
1370 /**
1371 * Resolve the required-error message for a field.
1372 *
1373 * Resolution order: per-field custom message → global default for the field
1374 * type (Global Settings → Form Validation) → generic fallback. The message
1375 * key is derived from the block name by convention, so new field blocks need
1376 * no code change here — they only register their default message and tab
1377 * field (e.g. 'suredonation/phone' → 'suredonation_phone_block_required_text').
1378 *
1379 * @param string $block_name Block name.
1380 * @param array<string, mixed> $config Stored block configuration for the field.
1381 * @return string
1382 * @since 1.1.0
1383 */
1384 private static function resolve_required_message( $block_name, $config ) {
1385 if ( ! empty( $config['error_msg'] ) && is_string( $config['error_msg'] ) ) {
1386 return $config['error_msg'];
1387 }
1388
1389 $message = self::get_validation_message( self::required_message_key( $block_name ) );
1390
1391 return '' !== $message ? $message : __( 'This field is required.', 'suredonation' );
1392 }
1393
1394 /**
1395 * Derive the required-message key for a block name.
1396 *
1397 * 'suredonation/input' => 'suredonation_input_block_required_text'.
1398 *
1399 * @param string $block_name Block name.
1400 * @return string
1401 * @since 1.1.0
1402 */
1403 public static function required_message_key( $block_name ) {
1404 $short = str_replace( 'suredonation/', '', (string) $block_name );
1405 $short = (string) preg_replace( '/[^a-z0-9_]+/', '_', strtolower( $short ) );
1406
1407 return 'suredonation_' . $short . '_block_required_text';
1408 }
1409
1410 /**
1411 * Format a numeric bound for display in a validation message.
1412 *
1413 * Drops the decimal portion for whole numbers (e.g. 10.0 → "10").
1414 *
1415 * @param float $number Number to format.
1416 * @return string
1417 * @since 1.1.0
1418 */
1419 private static function format_number( $number ) {
1420 if ( floor( $number ) === $number ) {
1421 return number_format_i18n( $number );
1422 }
1423
1424 return number_format_i18n( $number, 2 );
1425 }
1426 }
1427