PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / field-validation.php

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

761 lines 26.9 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 * Field blocks whose values participate in field-level validation.
40 *
41 * @since 1.1.0
42 */
43 public const VALIDATABLE_BLOCKS = [
44 'suredonation/input',
45 'suredonation/email',
46 'suredonation/number',
47 ];
48
49 /**
50 * Add block configuration for form fields.
51 *
52 * This function processes blocks in a form and stores their configuration as post meta.
53 * It extracts payment block settings (amount type, fixed amount, minimum amount, etc.)
54 * which are used for server-side validation to prevent payment manipulation.
55 *
56 * @param array<mixed> $blocks Array of blocks to process.
57 * @param int $form_id Form post ID.
58 * @return void
59 * @since 0.0.1
60 */
61 public static function add_block_config( $blocks, $form_id ) {
62 // Initialize array to store processed block configurations.
63 $block_config = [];
64
65 // Process blocks recursively.
66 self::process_blocks_recursive( $blocks, $block_config );
67
68 // Only update meta if we have processed configurations.
69 if ( ! empty( $block_config ) ) {
70 update_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, $block_config );
71 }
72 }
73
74 /**
75 * Retrieve or migrate the block configuration for legacy forms.
76 *
77 * This function checks if the _suredonation_block_config post meta exists for the given form ID.
78 * If not found, it attempts to parse the form's post content and generate the block config.
79 *
80 * @param int $form_id The ID of the form post.
81 * @since 0.0.1
82 * @return array<string, array<string, mixed>>|null The block configuration array, or null if not found or invalid.
83 */
84 public static function get_or_migrate_block_config_for_legacy_form( $form_id ) {
85 // Validate that $form_id is a positive integer.
86 if ( ! is_int( $form_id ) || $form_id <= 0 ) {
87 return null;
88 }
89
90 // Retrieve the block config from post meta.
91 $block_config = get_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, true );
92 if ( ! empty( $block_config ) && is_array( $block_config ) ) {
93 // If it exists and is an array, return it directly (no migration needed).
94 return $block_config;
95 }
96
97 // Get the post by ID and validate.
98 $post = get_post( $form_id );
99 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
100 return null;
101 }
102
103 // Parse the blocks from the post content and attempt migration.
104 if ( function_exists( 'parse_blocks' ) ) {
105 $blocks = parse_blocks( $post->post_content );
106 if ( is_array( $blocks ) && ! empty( $blocks ) ) {
107 self::add_block_config( $blocks, $form_id );
108 }
109 }
110
111 // Retrieve the block config again after migration attempt.
112 $block_config = get_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, true );
113
114 return ! empty( $block_config ) && is_array( $block_config ) ? $block_config : null;
115 }
116
117 /**
118 * Process blocks recursively to extract configuration.
119 *
120 * @param array<mixed> $blocks Array of blocks to process.
121 * @param array<mixed> $block_config Reference to block config array.
122 * @return void
123 * @since 0.0.1
124 */
125 private static function process_blocks_recursive( $blocks, &$block_config ) {
126 foreach ( $blocks as $block ) {
127 // Ensure $block is an array and has the required structure.
128 if ( ! is_array( $block ) ) {
129 continue;
130 }
131
132 // Process inner blocks recursively (for columns, groups, etc.).
133 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
134 self::process_blocks_recursive( $block['innerBlocks'], $block_config );
135 }
136
137 if ( ! isset( $block['blockName'] ) || ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
138 continue;
139 }
140
141 // Validate block_id exists.
142 if ( ! array_key_exists( 'block_id', $block['attrs'] ) || empty( $block['attrs']['block_id'] ) || ! is_string( $block['attrs']['block_id'] ) ) {
143 continue;
144 }
145
146 $block_id = sanitize_text_field( $block['attrs']['block_id'] );
147 $block_name = $block['blockName'];
148
149 // Process specific block types.
150 $processed_config = null;
151
152 switch ( $block_name ) {
153 case 'suredonation/payment':
154 $processed_config = self::process_payment_block( $block['attrs'], $blocks );
155 break;
156 case 'suredonation/donation-amount':
157 $processed_config = self::process_donation_amount_block( $block['attrs'] );
158 break;
159 case 'suredonation/number':
160 $processed_config = self::process_number_block( $block['attrs'] );
161 break;
162 case 'suredonation/cover-fees':
163 $processed_config = self::process_cover_fees_block( $block['attrs'] );
164 break;
165 case 'suredonation/input':
166 $processed_config = self::process_input_block( $block['attrs'] );
167 break;
168 case 'suredonation/email':
169 $processed_config = self::process_email_block( $block['attrs'] );
170 break;
171 }
172
173 /**
174 * Filter the stored validation config for a field block.
175 *
176 * Lets extensions contribute configuration for field blocks the
177 * core does not handle (e.g. phone, address, url) so their rules are
178 * persisted on save and picked up by validate_form_data(). Return a
179 * non-empty array (including at least a 'required' flag plus any rule
180 * values the validator needs) to store it under the block id.
181 *
182 * @since 1.1.0
183 * @param array<string, mixed>|null $processed_config Config from core (null when unhandled).
184 * @param string $block_name Block name.
185 * @param array<string, mixed> $attrs Block attributes.
186 * @param array<mixed> $blocks All blocks in the form.
187 */
188 $processed_config = apply_filters( 'suredonation_field_block_config', $processed_config, $block_name, $block['attrs'], $blocks );
189
190 // If block was processed, store its configuration.
191 if ( null !== $processed_config && ! empty( $processed_config ) ) {
192 $processed_config['block_name'] = $block_name;
193
194 // Add the slug to the configuration.
195 if ( isset( $block['attrs']['slug'] ) && ! empty( $block['attrs']['slug'] ) ) {
196 $processed_config['slug'] = sanitize_text_field( $block['attrs']['slug'] );
197 }
198
199 $block_config[ $block_id ] = $processed_config;
200 }
201 }
202 }
203
204 /**
205 * Process payment block configuration.
206 *
207 * Extracts payment-related settings that are needed for server-side validation:
208 * - amount_type: 'fixed' or 'variable'
209 * - fixed_amount: The configured fixed amount
210 * - minimum_amount: The minimum allowed amount for variable amounts
211 * - variable_amount_field: The slug of the field providing the variable amount
212 *
213 * @param array<mixed> $attrs Block attributes.
214 * @param array<mixed> $blocks All blocks in the form.
215 * @return array<string, mixed> Processed payment configuration.
216 * @since 0.0.1
217 */
218 private static function process_payment_block( $attrs, $blocks ) {
219 $payment_config = [];
220
221 // Extract payment type (one-time or subscription).
222 // Default to 'one-time' if not set (Gutenberg may not save default values).
223 $payment_config['payment_type'] = isset( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] )
224 ? sanitize_text_field( $attrs['paymentType'] )
225 : 'one-time';
226
227 // Extract amount type (fixed or variable).
228 // IMPORTANT: Always store this - Gutenberg may not save attributes that match defaults.
229 // Default to 'fixed' which is the block.json default.
230 $payment_config['amount_type'] = isset( $attrs['amountType'] ) && is_string( $attrs['amountType'] )
231 ? sanitize_text_field( $attrs['amountType'] )
232 : 'fixed';
233
234 // Extract configured fixed amount.
235 // Default to 10.00 to match block.json default.
236 $payment_config['fixed_amount'] = isset( $attrs['fixedAmount'] )
237 ? floatval( $attrs['fixedAmount'] )
238 : 10.00;
239
240 // Extract minimum amount for variable amounts.
241 // Defaults to 0 (no minimum) — only enforced if the block setting specifies one.
242 $payment_config['minimum_amount'] = isset( $attrs['minimumAmount'] )
243 ? floatval( $attrs['minimumAmount'] )
244 : 0.0;
245
246 // Extract variable amount field reference.
247 if ( isset( $attrs['variableAmountField'] ) ) {
248 $variable_amount_slug = sanitize_text_field( $attrs['variableAmountField'] );
249 $payment_config['variable_amount_field'] = $variable_amount_slug;
250
251 // Find and add the block name from which the variable amount field comes from.
252 if ( ! empty( $variable_amount_slug ) && is_array( $blocks ) ) {
253 $block_name = self::find_block_name_by_slug( $blocks, $variable_amount_slug );
254 if ( $block_name ) {
255 $payment_config['variable_amount_field_block_name'] = $block_name;
256 }
257 }
258 }
259
260 return $payment_config;
261 }
262
263 /**
264 * Find block name by slug recursively.
265 *
266 * @param array<mixed> $blocks Array of blocks.
267 * @param string $slug Slug to find.
268 * @return string|null Block name if found, null otherwise.
269 * @since 0.0.1
270 */
271 private static function find_block_name_by_slug( $blocks, $slug ) {
272 foreach ( $blocks as $block ) {
273 if ( ! is_array( $block ) ) {
274 continue;
275 }
276
277 // Check inner blocks first.
278 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
279 $found = self::find_block_name_by_slug( $block['innerBlocks'], $slug );
280 if ( $found ) {
281 return $found;
282 }
283 }
284
285 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $slug ) {
286 return $block['blockName'];
287 }
288 }
289 return null;
290 }
291
292 /**
293 * Process donation-amount block configuration.
294 *
295 * @param array<mixed> $attrs Block attributes.
296 * @return array<string, mixed> Processed donation-amount configuration.
297 * @since 0.0.1
298 */
299 private static function process_donation_amount_block( $attrs ) {
300 $donation_amount_config = [];
301
302 // Extract required field.
303 if ( isset( $attrs['required'] ) ) {
304 $donation_amount_config['required'] = ! empty( $attrs['required'] );
305 }
306
307 // Extract choice type (radio or checkbox).
308 if ( isset( $attrs['choiceType'] ) ) {
309 $donation_amount_config['choice_type'] = sanitize_text_field( $attrs['choiceType'] );
310 }
311
312 // Extract options with their full structure (label, value).
313 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
314 $sanitized_options = [];
315 foreach ( $attrs['options'] as $option ) {
316 if ( is_array( $option ) ) {
317 $sanitized_options[] = [
318 'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '',
319 'value' => isset( $option['value'] ) ? sanitize_text_field( $option['value'] ) : '',
320 ];
321 }
322 }
323 $donation_amount_config['options'] = $sanitized_options;
324 }
325
326 // Custom amount settings (radio-mode only).
327 $donation_amount_config['allow_custom_amount'] = ! isset( $attrs['allowCustomAmount'] ) || ! empty( $attrs['allowCustomAmount'] );
328 $donation_amount_config['custom_amount_min'] = isset( $attrs['customAmountMin'] ) ? (float) $attrs['customAmountMin'] : 0.0;
329 $donation_amount_config['custom_amount_max'] = isset( $attrs['customAmountMax'] ) ? (float) $attrs['customAmountMax'] : 0.0;
330
331 return $donation_amount_config;
332 }
333
334 /**
335 * Process cover-fees block configuration.
336 *
337 * Resolves global vs block-level fee rates and stores them for server-side validation.
338 *
339 * @param array<mixed> $attrs Block attributes.
340 * @return array<string, mixed> Processed cover fees configuration.
341 * @since 1.0.0
342 */
343 private static function process_cover_fees_block( $attrs ) {
344 $use_global = $attrs['useGlobalDefaults'] ?? true;
345
346 if ( $use_global ) {
347 $fee_config = \SureDonation\Inc\Payments\Payment_Helper::get_fee_recovery_settings();
348 } else {
349 $fee_config = [
350 'fee_percentage' => isset( $attrs['feePercentage'] ) ? floatval( $attrs['feePercentage'] ) : 2.9,
351 'fee_fixed' => isset( $attrs['feeFixed'] ) ? floatval( $attrs['feeFixed'] ) : 0.30,
352 'fee_mode' => $attrs['feeMode'] ?? 'all_gateways',
353 'gateways' => $attrs['gatewayFees'] ?? [],
354 ];
355 }
356
357 return [
358 'use_global_defaults' => $use_global,
359 'fee_percentage' => (float) ( $fee_config['fee_percentage'] ?? 2.9 ),
360 'fee_fixed' => (float) ( $fee_config['fee_fixed'] ?? 0.30 ),
361 'fee_mode' => $fee_config['fee_mode'] ?? 'all_gateways',
362 'gateway_fees' => $fee_config['gateways'] ?? [],
363 ];
364 }
365
366 /**
367 * Process number block configuration.
368 *
369 * @param array<mixed> $attrs Block attributes.
370 * @return array<string, mixed> Processed number block configuration.
371 * @since 0.0.1
372 */
373 private static function process_number_block( $attrs ) {
374 $number_config = [];
375
376 // Extract required field.
377 if ( isset( $attrs['required'] ) ) {
378 $number_config['required'] = ! empty( $attrs['required'] );
379 }
380
381 // Extract min value.
382 if ( isset( $attrs['min'] ) ) {
383 $number_config['min'] = floatval( $attrs['min'] );
384 }
385
386 // Extract max value.
387 if ( isset( $attrs['max'] ) ) {
388 $number_config['max'] = floatval( $attrs['max'] );
389 }
390
391 // Field-level min/max value rules for client + server validation.
392 //
393 // These are stored under dedicated keys (read from the block's real
394 // `minValue`/`maxValue` attributes) and are deliberately kept separate
395 // from the amount-path `min`/`max` keys above, which are consumed by
396 // Payment_Helper::validate_number_field_amount(). Coerced with absint to
397 // match Number_Markup, which renders integer min/max — keeping the
398 // rendered HTML constraints and server validation in sync. The markup
399 // mirrors these exact defaults: min is always present (default 1) and
400 // max only applies when greater than zero.
401 $number_config['validation_min'] = isset( $attrs['minValue'] ) ? absint( Helper::get_string_value( $attrs['minValue'] ) ) : 1;
402 $number_config['validation_max'] = isset( $attrs['maxValue'] ) ? absint( Helper::get_string_value( $attrs['maxValue'] ) ) : 0;
403
404 // Per-field custom required message.
405 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
406 if ( '' !== $error_msg ) {
407 $number_config['error_msg'] = $error_msg;
408 }
409
410 return $number_config;
411 }
412
413 /**
414 * Process input (text) block configuration.
415 *
416 * Extracts the field-level validation rules — required, max length and the
417 * optional per-field custom required message — for server-side enforcement.
418 *
419 * @param array<mixed> $attrs Block attributes.
420 * @return array<string, mixed> Processed input block configuration.
421 * @since 1.1.0
422 */
423 private static function process_input_block( $attrs ) {
424 $input_config = [
425 'required' => ! empty( $attrs['required'] ),
426 'max_length' => isset( $attrs['maxLength'] ) ? absint( Helper::get_string_value( $attrs['maxLength'] ) ) : 100,
427 ];
428
429 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
430 if ( '' !== $error_msg ) {
431 $input_config['error_msg'] = $error_msg;
432 }
433
434 return $input_config;
435 }
436
437 /**
438 * Process email block configuration.
439 *
440 * Extracts required state, the optional per-field custom required message
441 * and the per-field invalid-email message for server-side enforcement.
442 *
443 * @param array<mixed> $attrs Block attributes.
444 * @return array<string, mixed> Processed email block configuration.
445 * @since 1.1.0
446 */
447 private static function process_email_block( $attrs ) {
448 $email_config = [
449 'required' => ! empty( $attrs['required'] ),
450 ];
451
452 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
453 if ( '' !== $error_msg ) {
454 $email_config['error_msg'] = $error_msg;
455 }
456
457 $invalid_email_msg = isset( $attrs['invalidEmailMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['invalidEmailMsg'] ) ) : '';
458 if ( '' !== $invalid_email_msg ) {
459 $email_config['invalid_email_msg'] = $invalid_email_msg;
460 }
461
462 return $email_config;
463 }
464
465 /**
466 * Get the block types that participate in field validation.
467 *
468 * Extensions register new validatable field blocks (e.g. phone, address,
469 * url) via the filter so their values run through validate_form_data().
470 * Pair this with the suredonation_field_block_config filter (to store the
471 * block's rules on save) and suredonation_validate_field (to apply them).
472 *
473 * @return array<int, string>
474 * @since 1.1.0
475 */
476 public static function get_validatable_blocks() {
477 /**
478 * Filter the block types that participate in field validation.
479 *
480 * @since 1.1.0
481 * @param array<int, string> $blocks Validatable block names.
482 */
483 $blocks = apply_filters( 'suredonation_validatable_blocks', self::VALIDATABLE_BLOCKS );
484
485 return is_array( $blocks ) ? $blocks : self::VALIDATABLE_BLOCKS;
486 }
487
488 /**
489 * Validate submitted donation form field values server-side.
490 *
491 * This is the authoritative validation pass: it reads the immutable block
492 * configuration stored on form save and enforces each field's rules
493 * (required, max length, email format, number range). Per-field custom
494 * messages take precedence over the global defaults configured under
495 * Global Settings → Form Validation.
496 *
497 * @param array<string, mixed> $fields Submitted field values keyed by field slug.
498 * @param int $form_id Donation form post ID.
499 * @return array<string, string> Map of field slug => error message. Empty when valid.
500 * @since 1.1.0
501 */
502 public static function validate_form_data( $fields, $form_id ) {
503 $errors = [];
504
505 if ( ! is_array( $fields ) ) {
506 $fields = [];
507 }
508
509 $form_id = absint( $form_id );
510 if ( $form_id <= 0 ) {
511 return $errors;
512 }
513
514 $block_config = self::get_or_migrate_block_config_for_legacy_form( $form_id );
515 if ( empty( $block_config ) || ! is_array( $block_config ) ) {
516 return $errors;
517 }
518
519 $validatable = self::get_validatable_blocks();
520
521 foreach ( $block_config as $config ) {
522 if ( ! is_array( $config ) ) {
523 continue;
524 }
525
526 $block_name = isset( $config['block_name'] ) && is_string( $config['block_name'] ) ? $config['block_name'] : '';
527 $slug = isset( $config['slug'] ) && is_string( $config['slug'] ) ? $config['slug'] : '';
528
529 if ( '' === $slug || ! in_array( $block_name, $validatable, true ) ) {
530 continue;
531 }
532
533 $raw_value = array_key_exists( $slug, $fields ) ? $fields[ $slug ] : '';
534 $value = is_scalar( $raw_value ) ? trim( (string) $raw_value ) : '';
535
536 $error = self::validate_field_value( $block_name, $config, $value );
537
538 /**
539 * Filter the validation error for a single donation form field.
540 *
541 * Lets extensions (e.g. SureDonation Pro) add custom validators for
542 * their own field types or rules. Return a non-empty string to flag
543 * the field as invalid; return an empty string to pass.
544 *
545 * @since 1.1.0
546 * @param string $error Current error message ('' when valid).
547 * @param string $value Submitted, trimmed field value.
548 * @param array<string, mixed> $config Stored block configuration for the field.
549 * @param int $form_id Donation form ID.
550 * @param string $block_name Block name (e.g. 'suredonation/input').
551 */
552 $error = apply_filters( 'suredonation_validate_field', $error, $value, $config, $form_id, $block_name );
553
554 if ( is_string( $error ) && '' !== $error ) {
555 $errors[ $slug ] = $error;
556 }
557 }
558
559 return $errors;
560 }
561
562 /**
563 * Get a validation message by key, preferring the admin override.
564 *
565 * @param string $key Message key.
566 * @return string
567 * @since 1.1.0
568 */
569 public static function get_validation_message( $key ) {
570 $defaults = self::default_validation_messages();
571 $stored = Helper::get_suredonation_option( self::VALIDATION_MESSAGES_OPTION_KEY, [] );
572
573 if ( is_array( $stored ) && ! empty( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
574 return $stored[ $key ];
575 }
576
577 return isset( $defaults[ $key ] ) ? $defaults[ $key ] : '';
578 }
579
580 /**
581 * Default (fallback) validation messages, keyed by message key.
582 *
583 * Messages containing %s use sprintf substitution for the configured bound.
584 *
585 * @return array<string, string>
586 * @since 1.1.0
587 */
588 public static function default_validation_messages() {
589 $messages = [
590 'suredonation_input_block_required_text' => __( 'This field is required.', 'suredonation' ),
591 'suredonation_email_block_required_text' => __( 'This field is required.', 'suredonation' ),
592 'suredonation_number_block_required_text' => __( 'This field is required.', 'suredonation' ),
593 'suredonation_valid_email' => __( 'Please enter a valid email address.', 'suredonation' ),
594 'suredonation_valid_number' => __( 'Please enter a valid number.', 'suredonation' ),
595 /* translators: %s: maximum number of characters allowed. */
596 'suredonation_input_max_length' => __( 'Maximum length is %s characters.', 'suredonation' ),
597 /* translators: %s: minimum allowed value. */
598 'suredonation_input_min_value' => __( 'Minimum value is %s.', 'suredonation' ),
599 /* translators: %s: maximum allowed value. */
600 'suredonation_input_max_value' => __( 'Maximum value is %s.', 'suredonation' ),
601 ];
602
603 /**
604 * Filter the default validation messages.
605 *
606 * Extensions add message keys for their own field types here so the
607 * messages resolve, localize and surface in the Form Validation tab
608 * alongside the core ones. Keys containing %s use sprintf substitution.
609 *
610 * @since 1.1.0
611 * @param array<string, string> $messages Default messages keyed by message key.
612 */
613 return apply_filters( 'suredonation_default_validation_messages', $messages );
614 }
615
616 /**
617 * Get the fully resolved validation messages (admin overrides over defaults).
618 *
619 * Used to localize the messages to the frontend so client-side validation
620 * mirrors exactly what the server enforces.
621 *
622 * @return array<string, string>
623 * @since 1.1.0
624 */
625 public static function get_resolved_validation_messages() {
626 $defaults = self::default_validation_messages();
627 $stored = Helper::get_suredonation_option( self::VALIDATION_MESSAGES_OPTION_KEY, [] );
628
629 if ( ! is_array( $stored ) ) {
630 return $defaults;
631 }
632
633 $resolved = $defaults;
634 foreach ( $defaults as $key => $default ) {
635 if ( ! empty( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
636 $resolved[ $key ] = $stored[ $key ];
637 }
638 }
639
640 return $resolved;
641 }
642
643 /**
644 * Apply the core validation rules for a single field value.
645 *
646 * @param string $block_name Block name.
647 * @param array<string, mixed> $config Stored block configuration for the field.
648 * @param string $value Submitted, trimmed field value.
649 * @return string Error message, or '' when the value passes.
650 * @since 1.1.0
651 */
652 private static function validate_field_value( $block_name, $config, $value ) {
653 // Required check applies to every field type.
654 if ( ! empty( $config['required'] ) && '' === $value ) {
655 return self::resolve_required_message( $block_name, $config );
656 }
657
658 // Format/range checks are skipped for empty optional values.
659 if ( '' === $value ) {
660 return '';
661 }
662
663 switch ( $block_name ) {
664 case 'suredonation/input':
665 $max_length = isset( $config['max_length'] ) && is_numeric( $config['max_length'] ) ? (int) $config['max_length'] : 0;
666 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value );
667 if ( $max_length > 0 && $length > $max_length ) {
668 // str_replace (not sprintf) because the message is admin/translator
669 // editable; a stray literal % would make sprintf throw on PHP 8.
670 return str_replace( '%s', number_format_i18n( $max_length ), self::get_validation_message( 'suredonation_input_max_length' ) );
671 }
672 break;
673
674 case 'suredonation/email':
675 if ( ! is_email( $value ) ) {
676 if ( ! empty( $config['invalid_email_msg'] ) && is_string( $config['invalid_email_msg'] ) ) {
677 return $config['invalid_email_msg'];
678 }
679 return self::get_validation_message( 'suredonation_valid_email' );
680 }
681 break;
682
683 case 'suredonation/number':
684 if ( ! is_numeric( $value ) ) {
685 return self::get_validation_message( 'suredonation_valid_number' );
686 }
687
688 $number = (float) $value;
689
690 if ( isset( $config['validation_min'] ) && is_numeric( $config['validation_min'] ) && $number < (float) $config['validation_min'] ) {
691 return str_replace( '%s', self::format_number( (float) $config['validation_min'] ), self::get_validation_message( 'suredonation_input_min_value' ) );
692 }
693
694 $validation_max = isset( $config['validation_max'] ) && is_numeric( $config['validation_max'] ) ? (float) $config['validation_max'] : 0.0;
695 if ( $validation_max > 0 && $number > $validation_max ) {
696 return str_replace( '%s', self::format_number( $validation_max ), self::get_validation_message( 'suredonation_input_max_value' ) );
697 }
698 break;
699 }
700
701 return '';
702 }
703
704 /**
705 * Resolve the required-error message for a field.
706 *
707 * Resolution order: per-field custom message → global default for the field
708 * type (Global Settings → Form Validation) → generic fallback. The message
709 * key is derived from the block name by convention, so new field blocks need
710 * no code change here — they only register their default message and tab
711 * field (e.g. 'suredonation/phone' → 'suredonation_phone_block_required_text').
712 *
713 * @param string $block_name Block name.
714 * @param array<string, mixed> $config Stored block configuration for the field.
715 * @return string
716 * @since 1.1.0
717 */
718 private static function resolve_required_message( $block_name, $config ) {
719 if ( ! empty( $config['error_msg'] ) && is_string( $config['error_msg'] ) ) {
720 return $config['error_msg'];
721 }
722
723 $message = self::get_validation_message( self::required_message_key( $block_name ) );
724
725 return '' !== $message ? $message : __( 'This field is required.', 'suredonation' );
726 }
727
728 /**
729 * Derive the required-message key for a block name.
730 *
731 * 'suredonation/input' => 'suredonation_input_block_required_text'.
732 *
733 * @param string $block_name Block name.
734 * @return string
735 * @since 1.1.0
736 */
737 public static function required_message_key( $block_name ) {
738 $short = str_replace( 'suredonation/', '', (string) $block_name );
739 $short = (string) preg_replace( '/[^a-z0-9_]+/', '_', strtolower( $short ) );
740
741 return 'suredonation_' . $short . '_block_required_text';
742 }
743
744 /**
745 * Format a numeric bound for display in a validation message.
746 *
747 * Drops the decimal portion for whole numbers (e.g. 10.0 → "10").
748 *
749 * @param float $number Number to format.
750 * @return string
751 * @since 1.1.0
752 */
753 private static function format_number( $number ) {
754 if ( floor( $number ) === $number ) {
755 return number_format_i18n( $number );
756 }
757
758 return number_format_i18n( $number, 2 );
759 }
760 }
761