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 / helper.php

helper.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.1.1, at inc/helper.php

1,317 lines 39.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Helper Class - Utility functions for SureDonation
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc;
9
10 use SureDonation\Inc\API\Settings_API;
11 use SureDonation\Inc\Database\Tables\Donations;
12 use SureDonation\Inc\Emails\Email_Handler;
13 use SureDonation\Inc\Payments\Payment_Helper;
14
15 // Exit if accessed directly.
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 /**
21 * Helper class.
22 * Provides utility functions for the plugin.
23 *
24 * @since 0.0.1
25 */
26 class Helper {
27 /**
28 * Option name for all SureDonation settings.
29 *
30 * @since 0.0.1
31 */
32 public const OPTION_NAME = 'suredonation_options';
33
34 /**
35 * Campaign meta key name.
36 *
37 * @since 0.0.1
38 */
39 public const SUREDONATION_CAMPAIGN_META_KEY = '_suredonation_campaign_meta';
40
41 /**
42 * Default campaign meta values.
43 *
44 * @since 0.0.1
45 * @var array<string, mixed>
46 */
47 private static $campaign_meta_defaults = [
48 'goal_type' => 'raised_amount',
49 'goal_amount' => 0,
50 'campaign_status' => 'active',
51 'email_settings' => [],
52 'require_terms' => false,
53 'terms_text' => '',
54 'thank_you_message' => '',
55 ];
56
57 /**
58 * Get a value from the suredonation_options array.
59 *
60 * @param string $key The key to retrieve.
61 * @param mixed $default_value Default value if key doesn't exist.
62 * @return mixed
63 * @since 0.0.1
64 */
65 public static function get_suredonation_option( $key, $default_value = null ) {
66 $options = get_option( self::OPTION_NAME, [] );
67
68 if ( ! is_array( $options ) ) {
69 $options = [];
70 }
71
72 return array_key_exists( $key, $options ) ? $options[ $key ] : $default_value;
73 }
74
75 /**
76 * Update a value in the suredonation_options array.
77 *
78 * @param string $key The key to update.
79 * @param mixed $value The value to set.
80 * @return bool True on success, false on failure.
81 * @since 0.0.1
82 */
83 public static function update_suredonation_option( $key, $value ) {
84 $options = get_option( self::OPTION_NAME, [] );
85
86 if ( ! is_array( $options ) ) {
87 $options = [];
88 }
89
90 $options[ $key ] = $value;
91
92 return update_option( self::OPTION_NAME, $options );
93 }
94
95 /**
96 * Whether honeypot spam protection is enabled in the global settings.
97 *
98 * @return bool True when the honeypot is enabled.
99 * @since 1.1.0
100 */
101 public static function is_honeypot_enabled() {
102 $spam_settings = self::get_suredonation_option( Settings_API::SPAM_OPTION_KEY, [] );
103
104 return is_array( $spam_settings ) && ! empty( $spam_settings['honeypot'] );
105 }
106
107 /**
108 * Output the hidden honeypot field when spam protection is enabled.
109 *
110 * Genuine visitors never see or fill this hidden field, so it is submitted
111 * with an empty value. A filled value (a bot that auto-fills every input) or
112 * a missing field (a bot that strips unknown inputs) is flagged as spam at
113 * submission time.
114 *
115 * @return void
116 * @see Helper::is_honeypot_spam()
117 * @since 1.1.0
118 */
119 public static function render_honeypot_field() {
120 if ( ! self::is_honeypot_enabled() ) {
121 return;
122 }
123
124 echo '<input type="hidden" name="suredonation_honeypot" value="" />';
125 }
126
127 /**
128 * Determine whether the current submission tripped the honeypot.
129 *
130 * Returns false when honeypot protection is disabled. When enabled, a real
131 * submission always carries the hidden field with an empty value; a missing
132 * field or any non-empty value is treated as spam.
133 *
134 * The honeypot field holds no sensitive data and is only inspected for
135 * emptiness. Nonce/referer verification is performed by the calling
136 * submission handler before this method runs.
137 *
138 * @return bool True when the submission should be rejected as spam.
139 * @since 1.1.0
140 */
141 public static function is_honeypot_spam() {
142 if ( ! self::is_honeypot_enabled() ) {
143 return false;
144 }
145
146 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by the calling submission handler; value only checked for emptiness.
147 if ( ! isset( $_POST['suredonation_honeypot'] ) ) {
148 return true;
149 }
150
151 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See note above.
152 $value = sanitize_text_field( wp_unslash( $_POST['suredonation_honeypot'] ) );
153
154 return '' !== $value;
155 }
156
157 /**
158 * Get all campaign meta as an array.
159 *
160 * @param int $campaign_id Campaign post ID.
161 * @return array<string, mixed> Campaign meta values.
162 * @since 0.0.1
163 */
164 public static function get_campaign_meta( $campaign_id ) {
165 $raw = get_post_meta( $campaign_id, self::SUREDONATION_CAMPAIGN_META_KEY, true );
166
167 $meta = ! empty( $raw ) && is_string( $raw ) ? json_decode( $raw, true ) : [];
168
169 if ( ! is_array( $meta ) ) {
170 $meta = [];
171 }
172
173 return array_merge( self::$campaign_meta_defaults, $meta );
174 }
175
176 /**
177 * Get a single campaign meta value.
178 *
179 * @param int $campaign_id Campaign post ID.
180 * @param string $key Meta key within the campaign meta array.
181 * @param mixed $default_value Default value if not set.
182 * @return mixed
183 * @since 0.0.1
184 */
185 public static function get_campaign_meta_value( $campaign_id, $key, $default_value = null ) {
186 $meta = self::get_campaign_meta( $campaign_id );
187
188 return $meta[ $key ] ?? $default_value;
189 }
190
191 /**
192 * Update campaign meta. Merges provided values with existing meta.
193 *
194 * @param int $campaign_id Campaign post ID.
195 * @param array<string, mixed> $values Key-value pairs to update.
196 * @return bool|int Meta ID on success, false on failure.
197 * @since 0.0.1
198 */
199 public static function update_campaign_meta( $campaign_id, $values ) {
200 $meta = self::get_campaign_meta( $campaign_id );
201 $meta = array_merge( $meta, $values );
202
203 return update_post_meta( $campaign_id, self::SUREDONATION_CAMPAIGN_META_KEY, wp_json_encode( $meta ) );
204 }
205
206 /**
207 * Checks if current value is string or else returns default value
208 *
209 * @param mixed $data data which need to be checked if is string.
210 * @return string
211 * @since 0.0.1
212 */
213 public static function get_string_value( $data ) {
214 if ( is_scalar( $data ) ) {
215 return (string) $data;
216 }
217 if ( is_object( $data ) && method_exists( $data, '__toString' ) ) {
218 return $data->__toString();
219 }
220 if ( is_null( $data ) ) {
221 return '';
222 }
223 return '';
224 }
225
226 /**
227 * Checks if current value is number or else returns default value
228 *
229 * @param mixed $value data which need to be checked if is string.
230 * @param int $base value can be set is $data is not a string, defaults to empty string.
231 * @return int
232 * @since 0.0.1
233 */
234 public static function get_integer_value( $value, $base = 10 ) {
235 if ( is_numeric( $value ) ) {
236 return (int) $value;
237 }
238 if ( is_string( $value ) ) {
239 $trimmed_value = trim( $value );
240 return intval( $trimmed_value, $base );
241 }
242 return 0;
243 }
244
245 /**
246 * Safely converts a mixed value to float
247 *
248 * @param mixed $value The value to convert.
249 * @param float $default_value Default value if conversion fails.
250 * @return float
251 * @since 0.0.1
252 */
253 public static function get_float_value( $value, $default_value = 0.0 ) {
254 if ( is_numeric( $value ) ) {
255 return (float) $value;
256 }
257 return $default_value;
258 }
259
260 /**
261 * Safely get array value with type checking
262 *
263 * @param mixed $value The value to check.
264 * @param array<string, mixed> $default_value Default value if not an array.
265 * @return array<string, mixed>
266 * @since 0.0.1
267 */
268 public static function get_array_value( $value, $default_value = [] ) {
269 return is_array( $value ) ? $value : $default_value;
270 }
271
272 /**
273 * Check if current user has required capability.
274 *
275 * @param string $capability Capability to check (default: 'manage_options').
276 * @param array<mixed> $args Additional arguments for capability check.
277 * @return bool True if user has capability.
278 * @since 0.0.1
279 */
280 public static function current_user_can( $capability = '', $args = [] ) {
281 if ( ! function_exists( 'current_user_can' ) ) {
282 return false;
283 }
284
285 if ( ! is_string( $capability ) || empty( $capability ) ) {
286 $capability = 'manage_options';
287 }
288
289 return ! empty( $args )
290 ? current_user_can( $capability, ...$args )
291 : current_user_can( $capability );
292 }
293
294 /**
295 * Join an array of strings into a single string, filtering out empty values.
296 *
297 * @param array<string> $strings Array of strings to join.
298 * @param string $glue Separator to use (default: ' ').
299 * @return string Joined string.
300 * @since 0.0.1
301 */
302 public static function join_strings( $strings, $glue = ' ' ) {
303 if ( ! is_array( $strings ) ) {
304 return '';
305 }
306
307 $filtered = array_filter(
308 $strings,
309 static function ( $item ) {
310 return is_string( $item ) && '' !== trim( $item );
311 }
312 );
313
314 return implode( $glue, array_map( 'trim', $filtered ) );
315 }
316
317 /**
318 * Process blocks to generate unique slugs for SureDonation blocks.
319 *
320 * Recursively processes all blocks and generates slugs for those that
321 * don't have one set. Ensures all slugs are unique within the form.
322 *
323 * @param array<mixed> $blocks The blocks to process.
324 * @param array<string> $slugs Array of existing slugs (keyed by block_id).
325 * @param bool $updated Whether any blocks were updated.
326 * @param string $prefix Optional prefix for nested blocks.
327 * @return array{0: array<mixed>, 1: array<string>, 2: bool} Processed blocks, slugs, and updated flag.
328 * @since 0.0.1
329 */
330 public static function process_blocks( $blocks, $slugs = [], $updated = false, $prefix = '' ) {
331 if ( ! is_array( $blocks ) ) {
332 return [ [], $slugs, $updated ];
333 }
334 foreach ( $blocks as $index => $block ) {
335 if ( ! is_array( $block ) ) {
336 continue;
337 }
338 // Skip non-SureDonation blocks.
339 if ( ! isset( $block['blockName'] ) || ! is_string( $block['blockName'] ) || strpos( $block['blockName'], 'suredonation/' ) !== 0 ) {
340 // Process inner blocks if any.
341 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
342 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $prefix );
343 }
344 continue;
345 }
346
347 // Skip if no attrs or slug is already set and block_id is in slugs array.
348 if (
349 ! isset( $block['attrs'] ) ||
350 ! is_array( $block['attrs'] ) ||
351 (
352 ! empty( $block['attrs']['slug'] ) &&
353 isset( $block['attrs']['block_id'] ) &&
354 isset( $slugs[ $block['attrs']['block_id'] ] )
355 )
356 ) {
357 // Process inner blocks if any.
358 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
359 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $prefix );
360 }
361 continue;
362 }
363
364 // Generate slug if empty.
365 if ( empty( $block['attrs']['slug'] ) ) {
366 $blocks[ $index ]['attrs']['slug'] = self::generate_unique_block_slug( $block, $slugs, $prefix );
367 $updated = true;
368 }
369
370 // Track the slug if block_id is set.
371 if ( isset( $block['attrs']['block_id'] ) ) {
372 $slugs[ $block['attrs']['block_id'] ] = $blocks[ $index ]['attrs']['slug'];
373 }
374
375 // Process inner blocks recursively.
376 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
377 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks(
378 $block['innerBlocks'],
379 $slugs,
380 $updated,
381 $blocks[ $index ]['attrs']['slug']
382 );
383 }
384 }
385
386 return [ $blocks, $slugs, $updated ];
387 }
388
389 /**
390 * Generates a unique slug based on the provided block and existing slugs.
391 *
392 * @param array<mixed> $block The block data.
393 * @param array<string> $slugs The array of existing slugs.
394 * @param string $prefix Optional prefix for nested blocks.
395 * @return string The generated unique block slug.
396 * @since 0.0.1
397 */
398 public static function generate_unique_block_slug( $block, $slugs, $prefix = '' ) {
399 $slug = is_string( $block['blockName'] ?? '' ) ? str_replace( 'suredonation/', '', $block['blockName'] ) : '';
400
401 // Use label if available.
402 if ( ! empty( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
403 $slug = sanitize_title( $block['attrs']['label'] );
404 }
405
406 // Add prefix for nested blocks.
407 if ( ! empty( $prefix ) ) {
408 $slug = $prefix . '-' . $slug;
409 }
410
411 return self::generate_unique_slug( $slug, $slugs );
412 }
413
414 /**
415 * Ensures that the slug is unique.
416 *
417 * If the slug is already taken, it appends a number to make it unique.
418 *
419 * @param string $slug The slug to make unique.
420 * @param array<string> $slugs Array of existing slugs.
421 * @return string The unique slug.
422 * @since 0.0.1
423 */
424 public static function generate_unique_slug( $slug, $slugs ) {
425 $slug = sanitize_title( $slug );
426
427 // Check if slug exists in the array values.
428 if ( ! in_array( $slug, $slugs, true ) ) {
429 return $slug;
430 }
431
432 // Append a number to make it unique.
433 $index = 1;
434 while ( in_array( $slug . '-' . $index, $slugs, true ) ) {
435 ++$index;
436 }
437
438 return $slug . '-' . $index;
439 }
440
441 /**
442 * Generate a unique block ID for a server-created block.
443 *
444 * Mirrors the client-side generateBlockId() used in each block's edit.js
445 * (a 7-character base36 string). Blocks created programmatically (e.g. the
446 * default form auto-generated when a campaign is published) never run the
447 * editor, so they would otherwise have no block_id. The server-side payment
448 * validation config is keyed on block_id, so without one no config is stored
449 * and donations fail with "Invalid form configuration." until the form is
450 * opened and saved in the editor.
451 *
452 * @return string A 7-character base36 identifier.
453 * @since 1.1.1
454 */
455 public static function generate_block_id() {
456 $chars = '0123456789abcdefghijklmnopqrstuvwxyz';
457 $block_id = '';
458 for ( $i = 0; $i < 7; $i++ ) {
459 $block_id .= $chars[ wp_rand( 0, 35 ) ];
460 }
461 return $block_id;
462 }
463
464 /**
465 * Get client IP address for logging purposes.
466 *
467 * Uses REMOTE_ADDR only — forwarded headers (HTTP_X_FORWARDED_FOR,
468 * HTTP_CLIENT_IP) are deliberately ignored because they are trivially
469 * spoofable. Note: behind a proxy/CDN that does not restore the real client
470 * IP, this returns the proxy's address. Suitable for informational logging
471 * and best-effort geolocation only — do NOT use for security-critical IP
472 * validation.
473 *
474 * @return string Client IP address.
475 * @since 0.0.1
476 */
477 public static function get_client_ip() {
478 // Only trust REMOTE_ADDR — proxy headers (HTTP_X_FORWARDED_FOR, HTTP_CLIENT_IP)
479 // are trivially spoofable and should not be used for logging or security.
480 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
481
482 if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
483 return $ip;
484 }
485
486 return '';
487 }
488
489 /**
490 * Per-IP rate limiter for public (unauthenticated) submission endpoints.
491 *
492 * Uses a short-lived transient bucket keyed by action + client IP to
493 * throttle abuse (card-testing, DB/email flooding) on nopriv AJAX handlers.
494 * When the client IP cannot be determined the request is allowed, so
495 * legitimate donors are never blocked by a missing IP.
496 *
497 * @param string $action Unique action identifier namespacing the bucket.
498 * @param int $max Maximum attempts permitted within the window.
499 * @param int $window Window length in seconds.
500 * @return bool True if the request is within limits; false if the limit is exceeded.
501 * @since 1.1.0
502 */
503 public static function check_rate_limit( $action, $max = 15, $window = MINUTE_IN_SECONDS ) {
504 $ip = self::get_client_ip();
505 if ( '' === $ip ) {
506 return true;
507 }
508
509 $key = 'suredonation_rl_' . md5( (string) $action . '|' . $ip );
510 $count = (int) get_transient( $key );
511
512 if ( $count >= $max ) {
513 return false;
514 }
515
516 set_transient( $key, $count + 1, $window );
517 return true;
518 }
519
520 /**
521 * Get sanitized request metadata (user agent and referer).
522 *
523 * @return array{user_agent: string, referer_url: string} Request metadata.
524 * @since 1.0.0
525 */
526 public static function get_request_meta() {
527 return [
528 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
529 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
530 ];
531 }
532
533 /**
534 * Get allowed HTML tags for form markup.
535 *
536 * The wp_kses_post() doesn't allow form elements, so we need a custom allowed tags array.
537 * This is safe because the markup is generated internally by trusted code that already
538 * escapes user input with esc_attr(), esc_html(), etc.
539 *
540 * @return array<string, array<string, bool>> Allowed HTML tags and attributes.
541 * @since 0.0.1
542 */
543 public static function get_allowed_form_html() {
544 // Note: data-* wildcard doesn't work in wp_kses, so we list each data attribute explicitly.
545 $common_data_attrs = [
546 'data-block-id' => true,
547 'data-form-id' => true,
548 'data-gateway' => true,
549 'data-stripe-key' => true,
550 'data-currency' => true,
551 'data-payment-mode' => true,
552 'data-amount-type' => true,
553 'data-fixed-amount' => true,
554 'data-payment-type' => true,
555 'data-customer-name-field' => true,
556 'data-customer-email-field' => true,
557 'data-nonce' => true,
558 'data-variable-amount-field' => true,
559 'data-minimum-amount' => true,
560 'data-subscription-plan-name' => true,
561 'data-subscription-interval' => true,
562 'data-subscription-billing-cycles' => true,
563 'data-currency-symbol' => true,
564 'data-message-format' => true,
565 'data-payment-methods' => true,
566 'data-payment-available' => true,
567 'data-method' => true,
568 'data-slug' => true,
569 'data-required' => true,
570 'data-fee-percentage' => true,
571 'data-fee-fixed' => true,
572 'data-fee-mode' => true,
573 'data-gateway-fees' => true,
574 'data-invalid-email-msg' => true,
575 'data-invalid-url-msg' => true,
576 'data-sd-mask' => true,
577 'data-custom-sd-mask' => true,
578 // Dropdown (tom-select) field.
579 'data-multiple' => true,
580 'data-searchable' => true,
581 'data-preselected' => true,
582 'data-min-selection' => true,
583 'data-max-selection' => true,
584 'data-placeholder' => true,
585 // Phone (intl-tel-input) field.
586 'data-default-country' => true,
587 'data-auto-country' => true,
588 'data-enable-country-filter' => true,
589 'data-country-filter-type' => true,
590 'data-include-countries' => true,
591 'data-exclude-countries' => true,
592 ];
593
594 $allowed = [
595 'div' => array_merge(
596 [
597 'id' => true,
598 'class' => true,
599 'style' => true,
600 'role' => true,
601 'tabindex' => true,
602 'aria-live' => true,
603 'aria-atomic' => true,
604 'aria-hidden' => true,
605 'aria-labelledby' => true,
606 ],
607 $common_data_attrs
608 ),
609 'form' => array_merge(
610 [
611 'id' => true,
612 'class' => true,
613 'method' => true,
614 'action' => true,
615 ],
616 $common_data_attrs
617 ),
618 'fieldset' => [
619 'id' => true,
620 'class' => true,
621 ],
622 'legend' => [
623 'id' => true,
624 'class' => true,
625 ],
626 'label' => [
627 'id' => true,
628 'class' => true,
629 'for' => true,
630 ],
631 'input' => array_merge(
632 [
633 'id' => true,
634 'class' => true,
635 'type' => true,
636 'name' => true,
637 'value' => true,
638 'placeholder' => true,
639 'min' => true,
640 'max' => true,
641 'step' => true,
642 'maxlength' => true,
643 'checked' => true,
644 'disabled' => true,
645 'readonly' => true,
646 'required' => true,
647 'tabindex' => true,
648 'autocomplete' => true,
649 'inputmode' => true,
650 'aria-describedby' => true,
651 'aria-required' => true,
652 'aria-hidden' => true,
653 ],
654 $common_data_attrs
655 ),
656 'button' => array_merge(
657 [
658 'id' => true,
659 'class' => true,
660 'type' => true,
661 'disabled' => true,
662 ],
663 $common_data_attrs
664 ),
665 'select' => array_merge(
666 [
667 'id' => true,
668 'class' => true,
669 'name' => true,
670 'disabled' => true,
671 'required' => true,
672 'multiple' => true,
673 'tabindex' => true,
674 'autocomplete' => true,
675 'aria-describedby' => true,
676 'aria-required' => true,
677 ],
678 $common_data_attrs
679 ),
680 'option' => [
681 'value' => true,
682 'class' => true,
683 'selected' => true,
684 'disabled' => true,
685 ],
686 'textarea' => array_merge(
687 [
688 'id' => true,
689 'class' => true,
690 'name' => true,
691 'rows' => true,
692 'cols' => true,
693 'placeholder' => true,
694 'maxlength' => true,
695 'disabled' => true,
696 'readonly' => true,
697 'required' => true,
698 'aria-describedby' => true,
699 'aria-required' => true,
700 ],
701 $common_data_attrs
702 ),
703 'span' => array_merge(
704 [
705 'id' => true,
706 'class' => true,
707 'style' => true,
708 'aria-hidden' => true,
709 ],
710 $common_data_attrs
711 ),
712 'p' => [
713 'id' => true,
714 'class' => true,
715 'style' => true,
716 'role' => true,
717 ],
718 'h1' => [
719 'id' => true,
720 'class' => true,
721 ],
722 'h2' => [
723 'id' => true,
724 'class' => true,
725 ],
726 'h3' => [
727 'id' => true,
728 'class' => true,
729 ],
730 'h4' => [
731 'id' => true,
732 'class' => true,
733 ],
734 'h5' => [
735 'id' => true,
736 'class' => true,
737 ],
738 'h6' => [
739 'id' => true,
740 'class' => true,
741 ],
742 'a' => [
743 'id' => true,
744 'class' => true,
745 'href' => true,
746 'target' => true,
747 'rel' => true,
748 'style' => true,
749 ],
750 'strong' => [
751 'class' => true,
752 ],
753 'em' => [
754 'class' => true,
755 ],
756 'ol' => [
757 'class' => true,
758 ],
759 'ul' => [
760 'class' => true,
761 ],
762 'li' => [
763 'class' => true,
764 ],
765 'br' => [],
766 'hr' => [
767 'class' => true,
768 ],
769 'svg' => [
770 'class' => true,
771 'width' => true,
772 'height' => true,
773 'viewbox' => true,
774 'fill' => true,
775 'xmlns' => true,
776 'aria-hidden' => true,
777 ],
778 'circle' => [
779 'cx' => true,
780 'cy' => true,
781 'r' => true,
782 'stroke' => true,
783 'stroke-width' => true,
784 'fill' => true,
785 ],
786 'rect' => [
787 'x' => true,
788 'y' => true,
789 'width' => true,
790 'height' => true,
791 'rx' => true,
792 'stroke' => true,
793 'stroke-width' => true,
794 ],
795 'path' => [
796 'class' => true,
797 'd' => true,
798 'stroke' => true,
799 'stroke-width' => true,
800 'stroke-linecap' => true,
801 'stroke-linejoin' => true,
802 'fill' => true,
803 ],
804 ];
805
806 /**
807 * Filter the allowed HTML tags/attributes for SureDonation form markup.
808 *
809 * Lets extensions (e.g. the SureDonation Pro date/time pickers) permit the
810 * extra tags or data attributes their fields render.
811 *
812 * @since 1.1.1
813 * @param array<string, array<string, bool>> $allowed Allowed tags/attributes.
814 */
815 return apply_filters( 'suredonation_allowed_form_html', $allowed );
816 }
817
818 /**
819 * Get the nonce action string for a donation form.
820 *
821 * Shared between block render, shortcode render, and donation handler
822 * to ensure the nonce action is always consistent.
823 *
824 * @param int $campaign_id Campaign ID (0 for standalone forms).
825 * @return string Nonce action string.
826 * @since 1.0.0
827 */
828 public static function get_donation_nonce_action( $campaign_id ) {
829 // Note: This nonce is used by the generic donation-handler.php (form POST flow).
830 // Stripe and Offline AJAX handlers use a separate fixed nonce action
831 // 'suredonation_donation_form' generated in payment-markup.php — these are
832 // intentionally different nonce paths (form POST vs payment AJAX).
833 return $campaign_id ? 'suredonation_donation_' . $campaign_id : 'suredonation_donation_standalone';
834 }
835
836 /**
837 * Get form payment settings from post meta.
838 *
839 * Shared between the block and shortcode render paths to build
840 * the `window.suredonationPayment` frontend configuration object.
841 *
842 * @param int $form_id Form post ID.
843 * @return array<string, mixed> Payment settings array.
844 * @since 1.0.0
845 */
846 public static function get_form_payment_settings( $form_id ) {
847 $data = self::get_form_confirmation_settings( $form_id );
848
849 // Map confirmation type to frontend format.
850 $confirmation_type = 'message';
851 $redirect_url = '';
852 if ( 'custom url' === $data['confirmation_type'] ) {
853 $confirmation_type = 'redirect';
854 $redirect_url = $data['custom_url'];
855 } elseif ( 'different page' === $data['confirmation_type'] ) {
856 $confirmation_type = 'redirect';
857 $redirect_url = $data['page_url'];
858 }
859
860 $success_message = ! empty( $data['message'] )
861 ? $data['message']
862 : esc_html__( 'Thank you for your donation!', 'suredonation' );
863
864 return [
865 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
866 'confirmationType' => $confirmation_type,
867 'successTitle' => esc_html__( 'Thank You!', 'suredonation' ),
868 'successMessage' => wp_kses_post( self::get_string_value( $success_message ) ),
869 // Shown when payment succeeded at the gateway but our server-side
870 // finalize did not complete; the webhook will finalize it, so the
871 // donor must not be prompted to pay again.
872 'processingMessage' => esc_html__( 'Payment received. We are finalizing your donation and will email you a confirmation shortly. Please do not pay again.', 'suredonation' ),
873 'redirectUrl' => ! empty( $redirect_url ) ? esc_url( self::get_string_value( $redirect_url ) ) : '',
874 'submissionAction' => $data['submission_action'],
875 // translators: %s: formatted fee amount with currency symbol.
876 'feeIncludesText' => __( '(includes %s processing fee)', 'suredonation' ),
877 'amountPlaceholder' => __( 'Complete the form to view the amount.', 'suredonation' ),
878 ];
879 }
880
881 /**
882 * Get form confirmation settings from post meta.
883 *
884 * Reads from consolidated _suredonation_form_confirmation meta key.
885 *
886 * @param int $form_id Form post ID.
887 * @return array<string, string> Confirmation settings with defaults applied.
888 * @since 1.0.0
889 */
890 public static function get_form_confirmation_settings( $form_id ) {
891 $defaults = [
892 'confirmation_type' => 'same page',
893 'message' => '',
894 'submission_action' => 'hide form',
895 'custom_url' => '',
896 'page_url' => '',
897 ];
898
899 $raw = get_post_meta( $form_id, '_suredonation_form_confirmation', true );
900
901 if ( ! empty( $raw ) && is_string( $raw ) ) {
902 $data = json_decode( $raw, true );
903 if ( is_array( $data ) ) {
904 return wp_parse_args( $data, $defaults );
905 }
906 }
907
908 return $defaults;
909 }
910
911 /**
912 * Get smart tags definitions grouped by context.
913 *
914 * Centralized source of truth for all smart tag lists used across
915 * admin UI, form editor, and email settings.
916 *
917 * @return array<string, array<int, array<string, mixed>>> Smart tags grouped by context.
918 * @since 1.0.0
919 */
920 public static function get_smart_tags() {
921 $confirmation_tags = [
922 [
923 'tag' => '{donor_name}',
924 'title' => __( 'Donor Name', 'suredonation' ),
925 ],
926 [
927 'tag' => '{donor_email}',
928 'title' => __( 'Donor Email', 'suredonation' ),
929 ],
930 [
931 'tag' => '{amount}',
932 'title' => __( 'Donation Amount', 'suredonation' ),
933 ],
934 [
935 'tag' => '{campaign_name}',
936 'title' => __( 'Campaign Name', 'suredonation' ),
937 ],
938 [
939 'tag' => '{donation_date}',
940 'title' => __( 'Donation Date', 'suredonation' ),
941 ],
942 [
943 'tag' => '{transaction_id}',
944 'title' => __( 'Transaction ID', 'suredonation' ),
945 ],
946 [
947 'tag' => '{payment_method}',
948 'title' => __( 'Payment Method', 'suredonation' ),
949 ],
950 [
951 'tag' => '{site_title}',
952 'title' => __( 'Site Title', 'suredonation' ),
953 ],
954 [
955 'tag' => '{donation_total}',
956 'title' => __( 'Donation Total', 'suredonation' ),
957 ],
958 [
959 'tag' => '{payment_status}',
960 'title' => __( 'Payment Status', 'suredonation' ),
961 ],
962 [
963 'tag' => '{donation_receipt}',
964 'title' => __( 'Donation Receipt', 'suredonation' ),
965 ],
966 [
967 'tag' => '{success_badge}',
968 'title' => __( 'Success Badge', 'suredonation' ),
969 ],
970 ];
971
972 return [
973 'confirmation' => $confirmation_tags,
974 'email' => array_merge(
975 $confirmation_tags,
976 [
977 [
978 'tag' => '{admin_email}',
979 'title' => __( 'Admin Email', 'suredonation' ),
980 ],
981 [
982 'tag' => '{site_url}',
983 'title' => __( 'Site URL', 'suredonation' ),
984 ],
985 [
986 'tag' => '{admin_url}',
987 'title' => __( 'Admin URL', 'suredonation' ),
988 ],
989 [
990 'tag' => '{subscription_id}',
991 'title' => __( 'Subscription ID', 'suredonation' ),
992 ],
993 [
994 'tag' => '{subscription_interval}',
995 'title' => __( 'Subscription Interval', 'suredonation' ),
996 ],
997 [
998 'tag' => '{offline_instructions}',
999 'title' => __( 'Offline Instructions', 'suredonation' ),
1000 ],
1001 ]
1002 ),
1003 'email_grouped' => [
1004 [
1005 'label' => __( 'Donation Tags', 'suredonation' ),
1006 'tags' => [
1007 [
1008 'tag' => '{donor_name}',
1009 'title' => __( 'Donor Name', 'suredonation' ),
1010 ],
1011 [
1012 'tag' => '{donor_email}',
1013 'title' => __( 'Donor Email', 'suredonation' ),
1014 ],
1015 [
1016 'tag' => '{amount}',
1017 'title' => __( 'Donation Amount', 'suredonation' ),
1018 ],
1019 [
1020 'tag' => '{campaign_name}',
1021 'title' => __( 'Campaign Name', 'suredonation' ),
1022 ],
1023 [
1024 'tag' => '{donation_date}',
1025 'title' => __( 'Donation Date', 'suredonation' ),
1026 ],
1027 [
1028 'tag' => '{transaction_id}',
1029 'title' => __( 'Transaction ID', 'suredonation' ),
1030 ],
1031 [
1032 'tag' => '{payment_method}',
1033 'title' => __( 'Payment Method', 'suredonation' ),
1034 ],
1035 [
1036 'tag' => '{subscription_id}',
1037 'title' => __( 'Subscription ID', 'suredonation' ),
1038 ],
1039 [
1040 'tag' => '{subscription_interval}',
1041 'title' => __( 'Subscription Interval', 'suredonation' ),
1042 ],
1043 [
1044 'tag' => '{refund_amount}',
1045 'title' => __( 'Refund Amount', 'suredonation' ),
1046 ],
1047 ],
1048 ],
1049 [
1050 'label' => __( 'General Tags', 'suredonation' ),
1051 'tags' => [
1052 [
1053 'tag' => '{site_title}',
1054 'title' => __( 'Site Title', 'suredonation' ),
1055 ],
1056 [
1057 'tag' => '{admin_email}',
1058 'title' => __( 'Admin Email', 'suredonation' ),
1059 ],
1060 [
1061 'tag' => '{site_url}',
1062 'title' => __( 'Site URL', 'suredonation' ),
1063 ],
1064 [
1065 'tag' => '{admin_url}',
1066 'title' => __( 'Admin URL', 'suredonation' ),
1067 ],
1068 [
1069 'tag' => '{offline_instructions}',
1070 'title' => __( 'Offline Instructions', 'suredonation' ),
1071 ],
1072 ],
1073 ],
1074 ],
1075 'offline_instructions' => [
1076 [
1077 'tag' => '{campaign_name}',
1078 'title' => __( 'Campaign Name', 'suredonation' ),
1079 ],
1080 [
1081 'tag' => '{site_title}',
1082 'title' => __( 'Site Title', 'suredonation' ),
1083 ],
1084 [
1085 'tag' => '{site_url}',
1086 'title' => __( 'Site URL', 'suredonation' ),
1087 ],
1088 [
1089 'tag' => '{admin_email}',
1090 'title' => __( 'Admin Email', 'suredonation' ),
1091 ],
1092 ],
1093 ];
1094 }
1095
1096 /**
1097 * Map a payment gateway slug to a human-readable label.
1098 *
1099 * @param string $gateway Gateway slug (e.g. stripe, paypal, manual).
1100 * @return string Display label.
1101 * @since 1.0.0
1102 */
1103 public static function get_payment_method_label( $gateway ) {
1104 switch ( $gateway ) {
1105 case 'paypal':
1106 return __( 'PayPal', 'suredonation' );
1107 case 'manual':
1108 case 'offline':
1109 return __( 'Offline Donation', 'suredonation' );
1110 case 'stripe':
1111 return __( 'Stripe', 'suredonation' );
1112 default:
1113 return ucwords( str_replace( [ '_', '-' ], ' ', (string) $gateway ) );
1114 }
1115 }
1116
1117 /**
1118 * Render the static "Success" badge used by the {success_badge} smart tag.
1119 *
1120 * @return string Badge HTML.
1121 * @since 1.0.0
1122 */
1123 public static function render_success_badge() {
1124 return '<span class="sd-success-box__badge">' . esc_html__( 'Success', 'suredonation' ) . '</span>';
1125 }
1126
1127 /**
1128 * Render a styled payment-status badge for the donation confirmation.
1129 *
1130 * @param string $status Payment status (e.g. completed, pending, failed).
1131 * @return array Badge HTML.
1132 * @since 1.0.0
1133 */
1134 public static function get_payment_status_config( $status ) {
1135 $status = strtolower( trim( (string) $status ) );
1136
1137 $map = [
1138 'completed' => [
1139 'label' => __( 'Complete', 'suredonation' ),
1140 'variant' => 'complete',
1141 ],
1142 'complete' => [
1143 'label' => __( 'Complete', 'suredonation' ),
1144 'variant' => 'complete',
1145 ],
1146 'pending' => [
1147 'label' => __( 'Pending', 'suredonation' ),
1148 'variant' => 'pending',
1149 ],
1150 'processing' => [
1151 'label' => __( 'Processing', 'suredonation' ),
1152 'variant' => 'pending',
1153 ],
1154 'failed' => [
1155 'label' => __( 'Failed', 'suredonation' ),
1156 'variant' => 'failed',
1157 ],
1158 'refunded' => [
1159 'label' => __( 'Refunded', 'suredonation' ),
1160 'variant' => 'refunded',
1161 ],
1162 ];
1163
1164 return $map[ $status ] ?? [
1165 'label' => '' !== $status ? ucfirst( $status ) : __( 'Complete', 'suredonation' ),
1166 'variant' => 'pending',
1167 ];
1168 }
1169
1170 /**
1171 * Render a styled payment-status badge for the donation receipt row.
1172 *
1173 * @param string $status Payment status (e.g. completed, pending, failed).
1174 * @return string Badge HTML.
1175 * @since 1.0.0
1176 */
1177 public static function render_payment_status_badge( $status ) {
1178 $config = self::get_payment_status_config( $status );
1179 return sprintf(
1180 '<span class="sd-receipt-badge sd-receipt-badge--%1$s">%2$s</span>',
1181 esc_attr( $config['variant'] ),
1182 esc_html( $config['label'] )
1183 );
1184 }
1185
1186 /**
1187 * Render the donation receipt card used by the {donation_receipt} smart tag.
1188 *
1189 * @param array<string, mixed> $donation_data Donation data.
1190 * @param string $campaign_name Campaign name ('' for standalone forms).
1191 * @return string Receipt card HTML.
1192 * @since 1.0.0
1193 */
1194 public static function render_donation_receipt( $donation_data, $campaign_name = '' ) {
1195 $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD';
1196 $base_amount = isset( $donation_data['amount'] ) && is_numeric( $donation_data['amount'] ) ? (float) $donation_data['amount'] : 0.0;
1197 $fees_covered = isset( $donation_data['fees_covered'] ) && is_numeric( $donation_data['fees_covered'] ) ? (float) $donation_data['fees_covered'] : 0.0;
1198 $total = $base_amount + $fees_covered;
1199
1200 $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : '';
1201 $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
1202 $gateway = isset( $donation_data['gateway'] ) && is_string( $donation_data['gateway'] ) ? $donation_data['gateway'] : '';
1203 $status = isset( $donation_data['payment_status'] ) && is_string( $donation_data['payment_status'] ) ? $donation_data['payment_status'] : '';
1204
1205 $rows = [
1206 [
1207 'label' => __( 'Donor Name', 'suredonation' ),
1208 'value' => esc_html( $donor_name ),
1209 ],
1210 [
1211 'label' => __( 'Donor Email', 'suredonation' ),
1212 'value' => esc_html( $donor_email ),
1213 ],
1214 ];
1215
1216 if ( '' !== $campaign_name ) {
1217 $rows[] = [
1218 'label' => __( 'Campaign Name', 'suredonation' ),
1219 'value' => esc_html( $campaign_name ),
1220 ];
1221 }
1222
1223 $rows[] = [
1224 'label' => __( 'Payment Status', 'suredonation' ),
1225 'value' => self::render_payment_status_badge( $status ),
1226 ];
1227 $rows[] = [
1228 'label' => __( 'Payment Method', 'suredonation' ),
1229 'value' => esc_html( self::get_payment_method_label( $gateway ) ),
1230 ];
1231 $rows[] = [
1232 'label' => __( 'Donation Amount', 'suredonation' ),
1233 'value' => esc_html( Payment_Helper::format_amount( $base_amount, $currency ) ),
1234 ];
1235
1236 $rows_html = '';
1237 foreach ( $rows as $row ) {
1238 $rows_html .= sprintf(
1239 '<div class="sd-receipt-row"><span class="sd-receipt-row__label">%1$s</span><span class="sd-receipt-row__value">%2$s</span></div>',
1240 esc_html( $row['label'] ),
1241 $row['value']
1242 );
1243 }
1244
1245 $rows_html .= sprintf(
1246 '<div class="sd-receipt-row sd-receipt-row--total"><span class="sd-receipt-row__label">%1$s</span><span class="sd-receipt-row__value">%2$s</span></div>',
1247 esc_html__( 'Donation Total', 'suredonation' ),
1248 esc_html( Payment_Helper::format_amount( $total, $currency ) )
1249 );
1250
1251 return sprintf(
1252 '<div class="sd-receipt-card"><h3 class="sd-receipt-card__title">%1$s</h3><div class="sd-receipt-rows">%2$s</div></div>',
1253 esc_html__( 'Donation Receipt', 'suredonation' ),
1254 $rows_html
1255 );
1256 }
1257
1258 /**
1259 * Default confirmation message template (receipt layout with smart tags).
1260 *
1261 * @return string Message HTML template.
1262 * @since 1.0.0
1263 */
1264 public static function get_default_confirmation_message() {
1265 return '<p style="text-align: center; margin: 0;">{success_badge}</p>'
1266 . '<h2 class="sd-receipt-title" style="text-align: center;">'
1267 /* translators: {donor_name} is a smart tag replaced with the donor's name. */
1268 . esc_html__( 'Thank you {donor_name} for your Donation', 'suredonation' )
1269 . '</h2>'
1270 . '<p class="sd-receipt-subtitle" style="text-align: center;">'
1271 . esc_html__( 'Your contribution means a lot. We have sent an email to your registered account along with a receipt for your donation.', 'suredonation' )
1272 . '</p>{donation_receipt}';
1273 }
1274
1275 /**
1276 * Build the rendered confirmation/thank-you HTML for a donation.
1277 *
1278 * Resolves the form's confirmation message template against the donation's
1279 * real data (smart tags) so the frontend can display the receipt.
1280 *
1281 * @param int $donation_id Donation ID.
1282 * @return string Sanitized confirmation HTML, or '' on failure.
1283 * @since 1.0.0
1284 */
1285 public static function render_confirmation_message( $donation_id ) {
1286 $donation = Donations::get( $donation_id );
1287 if ( ! is_array( $donation ) ) {
1288 return '';
1289 }
1290
1291 $form_id = isset( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
1292 $campaign_id = isset( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
1293
1294 $settings = self::get_form_confirmation_settings( $form_id );
1295 $template = ! empty( $settings['message'] ) ? $settings['message'] : self::get_default_confirmation_message();
1296
1297 $donation_data = [
1298 'id' => $donation_id,
1299 'donor_name' => $donation['donor_name'] ?? '',
1300 'donor_email' => $donation['donor_email'] ?? '',
1301 'amount' => $donation['amount'] ?? 0,
1302 'fees_covered' => $donation['fees_covered'] ?? 0,
1303 'currency' => $donation['currency'] ?? Payment_Helper::get_currency(),
1304 'gateway' => $donation['gateway'] ?? '',
1305 'payment_status' => $donation['payment_status'] ?? '',
1306 'transaction_id' => $donation['transaction_id'] ?? '',
1307 'donation_type' => $donation['donation_type'] ?? 'one-time',
1308 ];
1309
1310 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
1311
1312 $rendered = Email_Handler::process_smart_tags( $template, $donation_data, $campaign );
1313
1314 return wp_kses_post( $rendered );
1315 }
1316 }
1317