PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.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
← All changes | inc/helper.php +1121 -77 0.0.1 → 1.6.1 View file →
@@ -6,8 +6,14 @@
6 6 */
7 7
8 8 namespace SureDonation\Inc;
9 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\Field_Validation;
14 +use SureDonation\Inc\Payments\Payment_Helper;
15 +
10 16 // Exit if accessed directly.
11 17 if ( ! defined( 'ABSPATH' ) ) {
12 18 exit;
13 19 }
@@ -39,16 +45,15 @@
39 45 * @since 0.0.1
40 46 * @var array<string, mixed>
41 47 */
42 48 private static $campaign_meta_defaults = [
43 - 'goal_type' => 'raised_amount',
44 - 'goal_amount' => 0,
45 - 'campaign_status' => 'active',
46 - 'email_settings' => [],
47 - 'allow_fees_coverage' => false,
48 - 'require_terms' => false,
49 - 'terms_text' => '',
50 - 'thank_you_message' => '',
49 + 'goal_type' => 'raised_amount',
50 + 'goal_amount' => 0,
51 + 'campaign_status' => 'active',
52 + 'email_settings' => [],
53 + 'require_terms' => false,
54 + 'terms_text' => '',
55 + 'thank_you_message' => '',
51 56 ];
52 57
53 58 /**
54 59 * Get a value from the suredonation_options array.
@@ -88,8 +93,70 @@
88 93 return update_option( self::OPTION_NAME, $options );
89 94 }
90 95
91 96 /**
97 + * Whether honeypot spam protection is enabled in the global settings.
98 + *
99 + * @return bool True when the honeypot is enabled.
100 + * @since 1.1.0
101 + */
102 + public static function is_honeypot_enabled() {
103 + $spam_settings = self::get_suredonation_option( Settings_API::SPAM_OPTION_KEY, [] );
104 +
105 + return is_array( $spam_settings ) && ! empty( $spam_settings['honeypot'] );
106 + }
107 +
108 + /**
109 + * Output the hidden honeypot field when spam protection is enabled.
110 + *
111 + * Genuine visitors never see or fill this hidden field, so it is submitted
112 + * with an empty value. A filled value (a bot that auto-fills every input) or
113 + * a missing field (a bot that strips unknown inputs) is flagged as spam at
114 + * submission time.
115 + *
116 + * @return void
117 + * @see Helper::is_honeypot_spam()
118 + * @since 1.1.0
119 + */
120 + public static function render_honeypot_field() {
121 + if ( ! self::is_honeypot_enabled() ) {
122 + return;
123 + }
124 +
125 + echo '<input type="hidden" name="suredonation_honeypot" value="" />';
126 + }
127 +
128 + /**
129 + * Determine whether the current submission tripped the honeypot.
130 + *
131 + * Returns false when honeypot protection is disabled. When enabled, a real
132 + * submission always carries the hidden field with an empty value; a missing
133 + * field or any non-empty value is treated as spam.
134 + *
135 + * The honeypot field holds no sensitive data and is only inspected for
136 + * emptiness. Nonce/referer verification is performed by the calling
137 + * submission handler before this method runs.
138 + *
139 + * @return bool True when the submission should be rejected as spam.
140 + * @since 1.1.0
141 + */
142 + public static function is_honeypot_spam() {
143 + if ( ! self::is_honeypot_enabled() ) {
144 + return false;
145 + }
146 +
147 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by the calling submission handler; value only checked for emptiness.
148 + if ( ! isset( $_POST['suredonation_honeypot'] ) ) {
149 + return true;
150 + }
151 +
152 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See note above.
153 + $value = sanitize_text_field( wp_unslash( $_POST['suredonation_honeypot'] ) );
154 +
155 + return '' !== $value;
156 + }
157 +
158 + /**
92 159 * Get all campaign meta as an array.
93 160 *
94 161 * @param int $campaign_id Campaign post ID.
95 162 * @return array<string, mixed> Campaign meta values.
@@ -137,8 +204,40 @@
137 204 return update_post_meta( $campaign_id, self::SUREDONATION_CAMPAIGN_META_KEY, wp_json_encode( $meta ) );
138 205 }
139 206
140 207 /**
208 + * Whether a (possibly nested) block tree contains a block of the given name.
209 + *
210 + * Walks parse_blocks() output, descending into innerBlocks so a block nested
211 + * inside a layout wrapper (Group/Columns) is still found. Note that a block
212 + * inside a synced pattern is not reachable: those parse as `core/block` with
213 + * no innerBlocks.
214 + *
215 + * Lives here rather than on Form_Renderer or Payment_Helper — both need it,
216 + * they sit in unrelated namespaces, and this is a generic block utility with
217 + * no rendering or payment semantics.
218 + *
219 + * @param array<int|string, mixed> $blocks Parsed blocks (parse_blocks output).
220 + * @param string $target Block name to look for.
221 + * @return bool
222 + * @since 1.5.1
223 + */
224 + public static function block_tree_contains( $blocks, $target ) {
225 + foreach ( $blocks as $block ) {
226 + if ( ! is_array( $block ) ) {
227 + continue;
228 + }
229 + if ( isset( $block['blockName'] ) && $block['blockName'] === $target ) {
230 + return true;
231 + }
232 + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) && self::block_tree_contains( $block['innerBlocks'], $target ) ) {
233 + return true;
234 + }
235 + }
236 + return false;
237 + }
238 +
239 + /**
141 240 * Checks if current value is string or else returns default value
142 241 *
143 242 * @param mixed $data data which need to be checked if is string.
144 243 * @return string
@@ -269,9 +368,9 @@
269 368 if ( ! is_array( $block ) ) {
270 369 continue;
271 370 }
272 371 // Skip non-SureDonation blocks.
273 - if ( ! isset( $block['blockName'] ) || ! is_string( $block['blockName'] ) || strpos( $block['blockName'], 'sd/' ) !== 0 ) {
372 + if ( ! isset( $block['blockName'] ) || ! is_string( $block['blockName'] ) || strpos( $block['blockName'], 'suredonation/' ) !== 0 ) {
274 373 // Process inner blocks if any.
275 374 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
276 375 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $prefix );
277 376 }
@@ -329,9 +428,9 @@
329 428 * @return string The generated unique block slug.
330 429 * @since 0.0.1
331 430 */
332 431 public static function generate_unique_block_slug( $block, $slugs, $prefix = '' ) {
333 - $slug = is_string( $block['blockName'] ?? '' ) ? str_replace( 'sd/', '', $block['blockName'] ) : '';
432 + $slug = is_string( $block['blockName'] ?? '' ) ? str_replace( 'suredonation/', '', $block['blockName'] ) : '';
334 433
335 434 // Use label if available.
336 435 if ( ! empty( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
337 436 $slug = sanitize_title( $block['attrs']['label'] );
@@ -372,39 +471,100 @@
372 471 return $slug . '-' . $index;
373 472 }
374 473
375 474 /**
475 + * Generate a unique block ID for a server-created block.
476 + *
477 + * Mirrors the client-side generateBlockId() used in each block's edit.js
478 + * (a 7-character base36 string). Blocks created programmatically (e.g. the
479 + * default form auto-generated when a campaign is published) never run the
480 + * editor, so they would otherwise have no block_id. The server-side payment
481 + * validation config is keyed on block_id, so without one no config is stored
482 + * and donations fail with "Invalid form configuration." until the form is
483 + * opened and saved in the editor.
484 + *
485 + * @return string A 7-character base36 identifier.
486 + * @since 1.1.1
487 + */
488 + public static function generate_block_id() {
489 + $chars = '0123456789abcdefghijklmnopqrstuvwxyz';
490 + $block_id = '';
491 + for ( $i = 0; $i < 7; $i++ ) {
492 + $block_id .= $chars[ wp_rand( 0, 35 ) ];
493 + }
494 + return $block_id;
495 + }
496 +
497 + /**
376 498 * Get client IP address for logging purposes.
377 499 *
378 - * Checks forwarded headers first (for proxied/load-balanced environments)
379 - * then falls back to REMOTE_ADDR. This is suitable for informational
380 - * logging only — do NOT use for security-critical IP validation.
500 + * Uses REMOTE_ADDR only — forwarded headers (HTTP_X_FORWARDED_FOR,
501 + * HTTP_CLIENT_IP) are deliberately ignored because they are trivially
502 + * spoofable. Note: behind a proxy/CDN that does not restore the real client
503 + * IP, this returns the proxy's address. Suitable for informational logging
504 + * and best-effort geolocation only — do NOT use for security-critical IP
505 + * validation.
381 506 *
382 507 * @return string Client IP address.
383 508 * @since 0.0.1
384 509 */
385 510 public static function get_client_ip() {
386 - $ip_headers = [
387 - 'HTTP_CLIENT_IP',
388 - 'HTTP_X_FORWARDED_FOR',
389 - 'REMOTE_ADDR',
390 - ];
511 + // Only trust REMOTE_ADDR — proxy headers (HTTP_X_FORWARDED_FOR, HTTP_CLIENT_IP)
512 + // are trivially spoofable and should not be used for logging or security.
513 + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
391 514
392 - foreach ( $ip_headers as $header ) {
393 - if ( ! empty( $_SERVER[ $header ] ) ) {
394 - $ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
395 - $ip = trim( $ips[0] );
515 + if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
516 + return $ip;
517 + }
396 518
397 - if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
398 - return $ip;
399 - }
400 - }
519 + return '';
520 + }
521 +
522 + /**
523 + * Per-IP rate limiter for public (unauthenticated) submission endpoints.
524 + *
525 + * Uses a short-lived transient bucket keyed by action + client IP to
526 + * throttle abuse (card-testing, DB/email flooding) on nopriv AJAX handlers.
527 + * When the client IP cannot be determined the request is allowed, so
528 + * legitimate donors are never blocked by a missing IP.
529 + *
530 + * @param string $action Unique action identifier namespacing the bucket.
531 + * @param int $max Maximum attempts permitted within the window.
532 + * @param int $window Window length in seconds.
533 + * @return bool True if the request is within limits; false if the limit is exceeded.
534 + * @since 1.1.0
535 + */
536 + public static function check_rate_limit( $action, $max = 15, $window = MINUTE_IN_SECONDS ) {
537 + $ip = self::get_client_ip();
538 + if ( '' === $ip ) {
539 + return true;
401 540 }
402 541
403 - return '';
542 + $key = 'suredonation_rl_' . md5( (string) $action . '|' . $ip );
543 + $count = (int) get_transient( $key );
544 +
545 + if ( $count >= $max ) {
546 + return false;
547 + }
548 +
549 + set_transient( $key, $count + 1, $window );
550 + return true;
404 551 }
405 552
406 553 /**
554 + * Get sanitized request metadata (user agent and referer).
555 + *
556 + * @return array{user_agent: string, referer_url: string} Request metadata.
557 + * @since 1.0.0
558 + */
559 + public static function get_request_meta() {
560 + return [
561 + 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
562 + 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
563 + ];
564 + }
565 +
566 + /**
407 567 * Get allowed HTML tags for form markup.
408 568 *
409 569 * The wp_kses_post() doesn't allow form elements, so we need a custom allowed tags array.
410 570 * This is safe because the markup is generated internally by trusted code that already
@@ -415,48 +575,89 @@
415 575 */
416 576 public static function get_allowed_form_html() {
417 577 // Note: data-* wildcard doesn't work in wp_kses, so we list each data attribute explicitly.
418 578 $common_data_attrs = [
419 - 'data-block-id' => true,
420 - 'data-form-id' => true,
421 - 'data-gateway' => true,
422 - 'data-stripe-key' => true,
423 - 'data-currency' => true,
424 - 'data-payment-mode' => true,
425 - 'data-amount-type' => true,
426 - 'data-fixed-amount' => true,
427 - 'data-payment-type' => true,
428 - 'data-customer-name-field' => true,
429 - 'data-customer-email-field' => true,
430 - 'data-nonce' => true,
431 - 'data-variable-amount-field' => true,
432 - 'data-minimum-amount' => true,
433 - 'data-subscription-plan-name' => true,
434 - 'data-subscription-interval' => true,
435 - 'data-subscription-billing-cycles' => true,
436 - 'data-currency-symbol' => true,
437 - 'data-message-format' => true,
438 - 'data-slug' => true,
439 - 'data-required' => true,
440 - 'data-fee-percentage' => true,
441 - 'data-fee-fixed' => true,
442 - 'data-invalid-email-msg' => true,
579 + 'data-block-id' => true,
580 + 'data-form-id' => true,
581 + 'data-gateway' => true,
582 + 'data-stripe-key' => true,
583 + 'data-currency' => true,
584 + 'data-payment-mode' => true,
585 + 'data-amount-type' => true,
586 + 'data-fixed-amount' => true,
587 + 'data-payment-type' => true,
588 + 'data-customer-name-field' => true,
589 + 'data-customer-email-field' => true,
590 + 'data-nonce' => true,
591 + 'data-variable-amount-field' => true,
592 + 'data-minimum-amount' => true,
593 + 'data-subscription-plan-name' => true,
594 + 'data-subscription-interval' => true,
595 + 'data-subscription-billing-cycles' => true,
596 + // Dual-mode ("both") payment block: per-choice amount configuration read by
597 + // the chooser when the donor switches between one-time and recurring.
598 + 'data-original-payment-type' => true,
599 + 'data-default-payment-choice' => true,
600 + 'data-one-time-amount-type' => true,
601 + 'data-one-time-fixed-amount' => true,
602 + 'data-one-time-minimum-amount' => true,
603 + 'data-one-time-variable-amount-field' => true,
604 + 'data-subscription-amount-type' => true,
605 + 'data-subscription-fixed-amount' => true,
606 + 'data-subscription-minimum-amount' => true,
607 + 'data-subscription-variable-amount-field' => true,
608 + 'data-currency-symbol' => true,
609 + 'data-message-format' => true,
610 + 'data-payment-methods' => true,
611 + 'data-payment-available' => true,
612 + 'data-method' => true,
613 + 'data-slug' => true,
614 + 'data-required' => true,
615 + 'data-fee-percentage' => true,
616 + 'data-fee-fixed' => true,
617 + 'data-fee-mode' => true,
618 + 'data-gateway-fees' => true,
619 + 'data-invalid-email-msg' => true,
620 + 'data-invalid-url-msg' => true,
621 + 'data-sd-mask' => true,
622 + 'data-custom-sd-mask' => true,
623 + // Dropdown (tom-select) field.
624 + 'data-multiple' => true,
625 + 'data-searchable' => true,
626 + 'data-preselected' => true,
627 + 'data-min-selection' => true,
628 + 'data-max-selection' => true,
629 + 'data-placeholder' => true,
630 + // Phone (intl-tel-input) field.
631 + 'data-default-country' => true,
632 + 'data-auto-country' => true,
633 + 'data-enable-country-filter' => true,
634 + 'data-country-filter-type' => true,
635 + 'data-include-countries' => true,
636 + 'data-exclude-countries' => true,
443 637 ];
444 638
445 - return [
446 - 'div' => array_merge(
639 + $allowed = [
640 + 'div' => array_merge(
447 641 [
448 642 'id' => true,
449 643 'class' => true,
450 644 'style' => true,
451 645 'role' => true,
646 + 'tabindex' => true,
452 647 'aria-live' => true,
453 648 'aria-atomic' => true,
649 + 'aria-hidden' => true,
454 650 'aria-labelledby' => true,
651 + 'aria-label' => true,
652 + // The dual-mode payment chooser hides the inactive amount panel with
653 + // `hidden`; without it here kses strips the attribute and both panels
654 + // render at once.
655 + 'hidden' => true,
455 656 ],
456 657 $common_data_attrs
457 658 ),
458 - 'form' => array_merge(
659 + 'form' => array_merge(
459 660 [
460 661 'id' => true,
461 662 'class' => true,
462 663 'method' => true,
@@ -463,22 +664,22 @@
463 664 'action' => true,
464 665 ],
465 666 $common_data_attrs
466 667 ),
467 - 'fieldset' => [
668 + 'fieldset' => [
468 669 'id' => true,
469 670 'class' => true,
470 671 ],
471 - 'legend' => [
672 + 'legend' => [
472 673 'id' => true,
473 674 'class' => true,
474 675 ],
475 - 'label' => [
676 + 'label' => [
476 677 'id' => true,
477 678 'class' => true,
478 679 'for' => true,
479 680 ],
480 - 'input' => array_merge(
681 + 'input' => array_merge(
481 682 [
482 683 'id' => true,
483 684 'class' => true,
484 685 'type' => true,
@@ -492,15 +693,20 @@
492 693 'checked' => true,
493 694 'disabled' => true,
494 695 'readonly' => true,
495 696 'required' => true,
697 + 'tabindex' => true,
698 + 'autocomplete' => true,
699 + 'inputmode' => true,
496 700 'aria-describedby' => true,
497 701 'aria-required' => true,
498 702 'aria-hidden' => true,
703 + // Payment-type chooser radios point at the amount panel they reveal.
704 + 'aria-controls' => true,
499 705 ],
500 706 $common_data_attrs
501 707 ),
502 - 'button' => array_merge(
708 + 'button' => array_merge(
503 709 [
504 710 'id' => true,
505 711 'class' => true,
506 712 'type' => true,
@@ -507,9 +713,9 @@
507 713 'disabled' => true,
508 714 ],
509 715 $common_data_attrs
510 716 ),
511 - 'select' => array_merge(
717 + 'select' => array_merge(
512 718 [
513 719 'id' => true,
514 720 'class' => true,
515 721 'name' => true,
@@ -514,19 +720,23 @@
514 720 'class' => true,
515 721 'name' => true,
516 722 'disabled' => true,
517 723 'required' => true,
724 + 'multiple' => true,
725 + 'tabindex' => true,
726 + 'autocomplete' => true,
518 727 'aria-describedby' => true,
519 728 'aria-required' => true,
520 729 ],
521 730 $common_data_attrs
522 731 ),
523 - 'option' => [
732 + 'option' => [
524 733 'value' => true,
734 + 'class' => true,
525 735 'selected' => true,
526 736 'disabled' => true,
527 737 ],
528 - 'textarea' => array_merge(
738 + 'textarea' => array_merge(
529 739 [
530 740 'id' => true,
531 741 'class' => true,
532 742 'name' => true,
@@ -541,21 +751,48 @@
541 751 'aria-required' => true,
542 752 ],
543 753 $common_data_attrs
544 754 ),
545 - 'span' => [
546 - 'id' => true,
547 - 'class' => true,
548 - 'style' => true,
549 - 'aria-hidden' => true,
550 - ],
551 - 'p' => [
755 + 'span' => array_merge(
756 + [
757 + 'id' => true,
758 + 'class' => true,
759 + 'style' => true,
760 + 'aria-hidden' => true,
761 + ],
762 + $common_data_attrs
763 + ),
764 + 'p' => [
552 765 'id' => true,
553 766 'class' => true,
554 767 'style' => true,
555 768 'role' => true,
556 769 ],
557 - 'a' => [
770 + 'h1' => [
771 + 'id' => true,
772 + 'class' => true,
773 + ],
774 + 'h2' => [
775 + 'id' => true,
776 + 'class' => true,
777 + ],
778 + 'h3' => [
779 + 'id' => true,
780 + 'class' => true,
781 + ],
782 + 'h4' => [
783 + 'id' => true,
784 + 'class' => true,
785 + ],
786 + 'h5' => [
787 + 'id' => true,
788 + 'class' => true,
789 + ],
790 + 'h6' => [
791 + 'id' => true,
792 + 'class' => true,
793 + ],
794 + 'a' => [
558 795 'id' => true,
559 796 'class' => true,
560 797 'href' => true,
561 798 'target' => true,
@@ -561,16 +798,60 @@
561 798 'target' => true,
562 799 'rel' => true,
563 800 'style' => true,
564 801 ],
565 - 'strong' => [
802 + 'strong' => [
566 803 'class' => true,
567 804 ],
568 - 'em' => [
805 + 'em' => [
569 806 'class' => true,
570 807 ],
571 - 'br' => [],
572 - 'svg' => [
808 + 'ol' => [
809 + 'class' => true,
810 + ],
811 + 'ul' => [
812 + 'class' => true,
813 + ],
814 + 'li' => [
815 + 'class' => true,
816 + ],
817 + 'br' => [],
818 + 'hr' => [
819 + 'class' => true,
820 + ],
821 + // img/figure/figcaption back the Image block (inc/blocks/image) — the
822 + // render depends on these entries, so don't drop them in a cleanup.
823 + 'img' => [
824 + 'src' => true,
825 + 'fetchpriority' => true,
826 + 'srcset' => true,
827 + 'sizes' => true,
828 + 'alt' => true,
829 + 'class' => true,
830 + 'style' => true,
831 + 'width' => true,
832 + 'height' => true,
833 + 'loading' => true,
834 + 'decoding' => true,
835 + 'title' => true,
836 + // Lazy-load optimizers (WP Rocket, Perfmatters, Optimole, the
837 + // Bricks theme, …) rewrite wp_get_attachment_image() output into
838 + // these data-* attributes with a data: placeholder in src; allow
839 + // them so kses doesn't strip the real URLs the lazy JS swaps back.
840 + 'data-src' => true,
841 + 'data-srcset' => true,
842 + 'data-sizes' => true,
843 + 'data-lazy-src' => true,
844 + 'data-lazy-srcset' => true,
845 + 'data-lazy-sizes' => true,
846 + ],
847 + 'figure' => [
848 + 'class' => true,
849 + ],
850 + 'figcaption' => [
851 + 'class' => true,
852 + ],
853 + 'svg' => [
573 854 'class' => true,
574 855 'width' => true,
575 856 'height' => true,
576 857 'viewbox' => true,
@@ -577,9 +858,9 @@
577 858 'fill' => true,
578 859 'xmlns' => true,
579 860 'aria-hidden' => true,
580 861 ],
581 - 'circle' => [
862 + 'circle' => [
582 863 'cx' => true,
583 864 'cy' => true,
584 865 'r' => true,
585 866 'stroke' => true,
@@ -585,9 +866,9 @@
585 866 'stroke' => true,
586 867 'stroke-width' => true,
587 868 'fill' => true,
588 869 ],
589 - 'rect' => [
870 + 'rect' => [
590 871 'x' => true,
591 872 'y' => true,
592 873 'width' => true,
593 874 'height' => true,
@@ -594,9 +875,9 @@
594 875 'rx' => true,
595 876 'stroke' => true,
596 877 'stroke-width' => true,
597 878 ],
598 - 'path' => [
879 + 'path' => [
599 880 'class' => true,
600 881 'd' => true,
601 882 'stroke' => true,
602 883 'stroke-width' => true,
@@ -603,7 +884,770 @@
603 884 'stroke-linecap' => true,
604 885 'stroke-linejoin' => true,
605 886 'fill' => true,
606 887 ],
888 + ];
889 +
890 + /**
891 + * Filter the allowed HTML tags/attributes for SureDonation form markup.
892 + *
893 + * Lets extensions (e.g. the SureDonation Pro date/time pickers) permit the
894 + * extra tags or data attributes their fields render.
895 + *
896 + * @since 1.1.1
897 + * @param array<string, array<string, bool>> $allowed Allowed tags/attributes.
898 + */
899 + return apply_filters( 'suredonation_allowed_form_html', $allowed );
900 + }
901 +
902 + /**
903 + * Get the nonce action string for a donation form.
904 + *
905 + * Shared between block render, shortcode render, and donation handler
906 + * to ensure the nonce action is always consistent.
907 + *
908 + * @param int $campaign_id Campaign ID (0 for standalone forms).
909 + * @return string Nonce action string.
910 + * @since 1.0.0
911 + */
912 + public static function get_donation_nonce_action( $campaign_id ) {
913 + // Note: This nonce is used by the generic donation-handler.php (form POST flow).
914 + // Stripe and Offline AJAX handlers use a separate fixed nonce action
915 + // 'suredonation_donation_form' generated in payment-markup.php — these are
916 + // intentionally different nonce paths (form POST vs payment AJAX).
917 + return $campaign_id ? 'suredonation_donation_' . $campaign_id : 'suredonation_donation_standalone';
918 + }
919 +
920 + /**
921 + * Get form payment settings from post meta.
922 + *
923 + * Shared between the block and shortcode render paths to build
924 + * the `window.suredonationPayment` frontend configuration object.
925 + *
926 + * @param int $form_id Form post ID.
927 + * @return array<string, mixed> Payment settings array.
928 + * @since 1.0.0
929 + */
930 + public static function get_form_payment_settings( $form_id ) {
931 + $data = self::get_form_confirmation_settings( $form_id );
932 +
933 + // Map confirmation type to frontend format.
934 + $confirmation_type = 'message';
935 + $redirect_url = '';
936 + if ( 'custom url' === $data['confirmation_type'] ) {
937 + $confirmation_type = 'redirect';
938 + $redirect_url = $data['custom_url'];
939 + } elseif ( 'different page' === $data['confirmation_type'] ) {
940 + $confirmation_type = 'redirect';
941 + $redirect_url = $data['page_url'];
942 + }
943 +
944 + $success_message = ! empty( $data['message'] )
945 + ? $data['message']
946 + : esc_html__( 'Thank you for your donation!', 'suredonation' );
947 +
948 + return [
949 + 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
950 + 'confirmationType' => $confirmation_type,
951 + 'successTitle' => esc_html__( 'Thank You!', 'suredonation' ),
952 + 'successMessage' => wp_kses_post( self::get_string_value( $success_message ) ),
953 + // Shown when payment succeeded at the gateway but our server-side
954 + // finalize did not complete; the webhook will finalize it, so the
955 + // donor must not be prompted to pay again.
956 + 'processingMessage' => esc_html__( 'Payment received. We are finalizing your donation and will email you a confirmation shortly. Please do not pay again.', 'suredonation' ),
957 + // Shown when the card form itself could not be rendered — almost
958 + // always because the connected Stripe account is not allowed to
959 + // charge cards. Deliberately says nothing about the account: the
960 + // cause is the site's to fix, and the gateway's own wording would
961 + // put its account state on a public page.
962 + 'cardUnavailableMessage' => esc_html__( 'Card payments are unavailable right now. Please choose another payment method or contact the site owner.', 'suredonation' ),
963 + 'redirectUrl' => ! empty( $redirect_url ) ? esc_url( self::get_string_value( $redirect_url ) ) : '',
964 + 'submissionAction' => $data['submission_action'],
965 + // translators: %s: formatted fee amount with currency symbol.
966 + 'feeIncludesText' => __( '(includes %s processing fee)', 'suredonation' ),
967 + 'amountPlaceholder' => __( 'Complete the form to view the amount.', 'suredonation' ),
968 + // Shown when a failed recurring confirmation forces the Stripe
969 + // Payment Element to rebuild after switching to one-time — see
970 + // StripeGateway.updatePaymentType(). Assigned via textContent
971 + // (GatewayBase.showError()), which doesn't decode HTML entities,
972 + // so this must not be esc_html__() or an apostrophe in
973 + // translation would render as the literal "&#039;".
974 + 'reenterCardMessage' => __( 'Please re-enter your card details to continue.', 'suredonation' ),
975 + // Currency symbol placement for client-side amount/fee formatting.
976 + 'currencySignPosition' => Payment_Helper::get_currency_sign_position(),
977 + ];
978 + }
979 +
980 + /**
981 + * Get form confirmation settings from post meta.
982 + *
983 + * Reads from consolidated _suredonation_form_confirmation meta key.
984 + *
985 + * @param int $form_id Form post ID.
986 + * @return array<string, string> Confirmation settings with defaults applied.
987 + * @since 1.0.0
988 + */
989 + public static function get_form_confirmation_settings( $form_id ) {
990 + $defaults = [
991 + 'confirmation_type' => 'same page',
992 + 'message' => '',
993 + 'submission_action' => 'hide form',
994 + 'custom_url' => '',
995 + 'page_url' => '',
996 + ];
997 +
998 + $raw = get_post_meta( $form_id, '_suredonation_form_confirmation', true );
999 +
1000 + if ( ! empty( $raw ) && is_string( $raw ) ) {
1001 + $data = json_decode( $raw, true );
1002 + if ( is_array( $data ) ) {
1003 + return wp_parse_args( $data, $defaults );
1004 + }
1005 + }
1006 +
1007 + return $defaults;
1008 + }
1009 +
1010 + /**
1011 + * Get smart tags definitions grouped by context.
1012 + *
1013 + * Centralized source of truth for all smart tag lists used across
1014 + * admin UI, form editor, and email settings.
1015 + *
1016 + * @return array<string, array<int, array<string, mixed>>> Smart tags grouped by context.
1017 + * @since 1.0.0
1018 + */
1019 + public static function get_smart_tags() {
1020 + $confirmation_tags = [
1021 + [
1022 + 'tag' => '{donor_name}',
1023 + 'title' => __( 'Donor Name', 'suredonation' ),
1024 + ],
1025 + [
1026 + 'tag' => '{donor_email}',
1027 + 'title' => __( 'Donor Email', 'suredonation' ),
1028 + ],
1029 + [
1030 + 'tag' => '{amount}',
1031 + 'title' => __( 'Donation Amount', 'suredonation' ),
1032 + ],
1033 + [
1034 + 'tag' => '{campaign_name}',
1035 + 'title' => __( 'Campaign Name', 'suredonation' ),
1036 + ],
1037 + [
1038 + 'tag' => '{donation_date}',
1039 + 'title' => __( 'Donation Date', 'suredonation' ),
1040 + ],
1041 + [
1042 + 'tag' => '{transaction_id}',
1043 + 'title' => __( 'Transaction ID', 'suredonation' ),
1044 + ],
1045 + [
1046 + 'tag' => '{payment_method}',
1047 + 'title' => __( 'Payment Method', 'suredonation' ),
1048 + ],
1049 + [
1050 + 'tag' => '{site_title}',
1051 + 'title' => __( 'Site Title', 'suredonation' ),
1052 + ],
1053 + [
1054 + 'tag' => '{donation_total}',
1055 + 'title' => __( 'Donation Total', 'suredonation' ),
1056 + ],
1057 + [
1058 + 'tag' => '{payment_status}',
1059 + 'title' => __( 'Payment Status', 'suredonation' ),
1060 + ],
1061 + [
1062 + 'tag' => '{donation_receipt}',
1063 + 'title' => __( 'Donation Receipt', 'suredonation' ),
1064 + ],
1065 + [
1066 + 'tag' => '{success_badge}',
1067 + 'title' => __( 'Success Badge', 'suredonation' ),
1068 + ],
1069 + ];
1070 +
1071 + $smart_tags = [
1072 + 'confirmation' => $confirmation_tags,
1073 + 'email' => array_merge(
1074 + $confirmation_tags,
1075 + [
1076 + [
1077 + 'tag' => '{admin_email}',
1078 + 'title' => __( 'Admin Email', 'suredonation' ),
1079 + ],
1080 + [
1081 + 'tag' => '{site_url}',
1082 + 'title' => __( 'Site URL', 'suredonation' ),
1083 + ],
1084 + [
1085 + 'tag' => '{admin_url}',
1086 + 'title' => __( 'Admin URL', 'suredonation' ),
1087 + ],
1088 + [
1089 + 'tag' => '{offline_instructions}',
1090 + 'title' => __( 'Offline Instructions', 'suredonation' ),
1091 + ],
1092 + ]
1093 + ),
1094 + 'email_grouped' => [
1095 + [
1096 + 'label' => __( 'Donation Tags', 'suredonation' ),
1097 + 'tags' => [
1098 + [
1099 + 'tag' => '{donor_name}',
1100 + 'title' => __( 'Donor Name', 'suredonation' ),
1101 + ],
1102 + [
1103 + 'tag' => '{donor_email}',
1104 + 'title' => __( 'Donor Email', 'suredonation' ),
1105 + ],
1106 + [
1107 + 'tag' => '{amount}',
1108 + 'title' => __( 'Donation Amount', 'suredonation' ),
1109 + ],
1110 + [
1111 + 'tag' => '{campaign_name}',
1112 + 'title' => __( 'Campaign Name', 'suredonation' ),
1113 + ],
1114 + [
1115 + 'tag' => '{donation_date}',
1116 + 'title' => __( 'Donation Date', 'suredonation' ),
1117 + ],
1118 + [
1119 + 'tag' => '{transaction_id}',
1120 + 'title' => __( 'Transaction ID', 'suredonation' ),
1121 + ],
1122 + [
1123 + 'tag' => '{payment_method}',
1124 + 'title' => __( 'Payment Method', 'suredonation' ),
1125 + ],
1126 + [
1127 + 'tag' => '{refund_amount}',
1128 + 'title' => __( 'Refund Amount', 'suredonation' ),
1129 + ],
1130 + [
1131 + 'tag' => '{form_fields}',
1132 + 'title' => __( 'Form Fields', 'suredonation' ),
1133 + // Resolves to a block-level receipt card. The editor
1134 + // offers this same list for Subject, From Name and
1135 + // Reply-To, all of which are run through
1136 + // process_smart_tags() — inserting it there would put
1137 + // raw markup in a mail header. Body editor only.
1138 + 'bodyOnly' => true,
1139 + ],
1140 + ],
1141 + ],
1142 + [
1143 + 'label' => __( 'General Tags', 'suredonation' ),
1144 + 'tags' => [
1145 + [
1146 + 'tag' => '{site_title}',
1147 + 'title' => __( 'Site Title', 'suredonation' ),
1148 + ],
1149 + [
1150 + 'tag' => '{admin_email}',
1151 + 'title' => __( 'Admin Email', 'suredonation' ),
1152 + ],
1153 + [
1154 + 'tag' => '{site_url}',
1155 + 'title' => __( 'Site URL', 'suredonation' ),
1156 + ],
1157 + [
1158 + 'tag' => '{admin_url}',
1159 + 'title' => __( 'Admin URL', 'suredonation' ),
1160 + ],
1161 + [
1162 + 'tag' => '{offline_instructions}',
1163 + 'title' => __( 'Offline Instructions', 'suredonation' ),
1164 + ],
1165 + ],
1166 + ],
1167 + ],
1168 + 'offline_instructions' => [
1169 + [
1170 + 'tag' => '{campaign_name}',
1171 + 'title' => __( 'Campaign Name', 'suredonation' ),
1172 + ],
1173 + [
1174 + 'tag' => '{site_title}',
1175 + 'title' => __( 'Site Title', 'suredonation' ),
1176 + ],
1177 + [
1178 + 'tag' => '{site_url}',
1179 + 'title' => __( 'Site URL', 'suredonation' ),
1180 + ],
1181 + [
1182 + 'tag' => '{admin_email}',
1183 + 'title' => __( 'Admin Email', 'suredonation' ),
1184 + ],
1185 + ],
1186 + ];
1187 +
1188 + // Recurring tags resolve to nothing without Pro, so a free-only site was
1189 + // being offered two tags it could never use. They stay here rather than
1190 + // moving into Pro so that activating Pro does not depend on shipping a
1191 + // matching Pro release; anything Pro adds beyond these comes through the
1192 + // filter below.
1193 + if ( defined( 'SUREDONATION_PRO_VER' ) ) {
1194 + $smart_tags['email_grouped'][0]['tags'][] = [
1195 + 'tag' => '{subscription_id}',
1196 + 'title' => __( 'Recurring Donation ID', 'suredonation' ),
1197 + ];
1198 + $smart_tags['email_grouped'][0]['tags'][] = [
1199 + 'tag' => '{subscription_interval}',
1200 + 'title' => __( 'Frequency', 'suredonation' ),
1201 + ];
1202 + }
1203 +
1204 + /**
1205 + * Filter the grouped smart tags offered in the email notification editor.
1206 + *
1207 + * The list is what an admin can insert, so anything registering a tag
1208 + * resolver via `suredonation_email_smart_tags` needs to advertise it here
1209 + * too. Without this, Pro could resolve recurring tags but had no way to
1210 + * surface them, and free listed subscription tags that could never
1211 + * resolve for a free-only site.
1212 + *
1213 + * @param array<int, array<string, mixed>> $groups Grouped tag definitions.
1214 + * @since 1.5.1
1215 + */
1216 + $grouped = apply_filters( 'suredonation_email_smart_tag_groups', $smart_tags['email_grouped'] );
1217 +
1218 + // The filter feeds the editor's tag picker, which iterates groups and
1219 + // their tags. A callback returning a non-array — or groups without a
1220 + // `tags` array — would fatal there rather than in whatever added it, so
1221 + // the shape is re-checked before it is handed on.
1222 + if ( is_array( $grouped ) ) {
1223 + $smart_tags['email_grouped'] = array_values(
1224 + array_filter(
1225 + $grouped,
1226 + static function ( $group ) {
1227 + return is_array( $group ) && isset( $group['tags'] ) && is_array( $group['tags'] );
1228 + }
1229 + )
1230 + );
1231 + }
1232 +
1233 + /**
1234 + * Filter the smart-tag catalogue grouped by context.
1235 + *
1236 + * Lets extensions register additional contexts (e.g. a 'pdf' group for
1237 + * PDF receipt templates) or extend existing ones. This catalogue only
1238 + * drives tag-picker UIs; tag resolution happens in
1239 + * Email_Handler::process_smart_tags() and its
1240 + * 'suredonation_email_smart_tags' filter, so new tags must be
1241 + * registered there as well to take effect.
1242 + *
1243 + * @param array<string, array<int, array<string, mixed>>> $smart_tags Smart tags grouped by context.
1244 + * @since 1.5.0
1245 + */
1246 + return apply_filters( 'suredonation_smart_tags', $smart_tags );
1247 + }
1248 +
1249 + /**
1250 + * Map a payment gateway slug to a human-readable label.
1251 + *
1252 + * @param string $gateway Gateway slug (e.g. stripe, paypal, manual).
1253 + * @return string Display label.
1254 + * @since 1.0.0
1255 + */
1256 + public static function get_payment_method_label( $gateway ) {
1257 + switch ( $gateway ) {
1258 + case 'paypal':
1259 + return __( 'PayPal', 'suredonation' );
1260 + case 'manual':
1261 + case 'offline':
1262 + return __( 'Offline Donation', 'suredonation' );
1263 + case 'stripe':
1264 + return __( 'Stripe', 'suredonation' );
1265 + default:
1266 + return ucwords( str_replace( [ '_', '-' ], ' ', (string) $gateway ) );
1267 + }
1268 + }
1269 +
1270 + /**
1271 + * Render the static "Success" badge used by the {success_badge} smart tag.
1272 + *
1273 + * @return string Badge HTML.
1274 + * @since 1.0.0
1275 + */
1276 + public static function render_success_badge() {
1277 + return '<span class="sd-success-box__badge">' . esc_html__( 'Success', 'suredonation' ) . '</span>';
1278 + }
1279 +
1280 + /**
1281 + * Render a styled payment-status badge for the donation confirmation.
1282 + *
1283 + * @param string $status Payment status (e.g. completed, pending, failed).
1284 + * @return array Badge HTML.
1285 + * @since 1.0.0
1286 + */
1287 + public static function get_payment_status_config( $status ) {
1288 + $status = strtolower( trim( (string) $status ) );
1289 +
1290 + $map = [
1291 + 'completed' => [
1292 + 'label' => __( 'Complete', 'suredonation' ),
1293 + 'variant' => 'complete',
1294 + ],
1295 + 'complete' => [
1296 + 'label' => __( 'Complete', 'suredonation' ),
1297 + 'variant' => 'complete',
1298 + ],
1299 + 'pending' => [
1300 + 'label' => __( 'Pending', 'suredonation' ),
1301 + 'variant' => 'pending',
1302 + ],
1303 + 'processing' => [
1304 + 'label' => __( 'Processing', 'suredonation' ),
1305 + 'variant' => 'pending',
1306 + ],
1307 + 'failed' => [
1308 + 'label' => __( 'Failed', 'suredonation' ),
1309 + 'variant' => 'failed',
1310 + ],
1311 + 'refunded' => [
1312 + 'label' => __( 'Refunded', 'suredonation' ),
1313 + 'variant' => 'refunded',
1314 + ],
1315 + ];
1316 +
1317 + return $map[ $status ] ?? [
1318 + 'label' => '' !== $status ? ucfirst( $status ) : __( 'Complete', 'suredonation' ),
1319 + 'variant' => 'pending',
1320 + ];
1321 + }
1322 +
1323 + /**
1324 + * Render a styled payment-status badge for the donation receipt row.
1325 + *
1326 + * @param string $status Payment status (e.g. completed, pending, failed).
1327 + * @return string Badge HTML.
1328 + * @since 1.0.0
1329 + */
1330 + public static function render_payment_status_badge( $status ) {
1331 + $config = self::get_payment_status_config( $status );
1332 + return sprintf(
1333 + '<span class="sd-receipt-badge sd-receipt-badge--%1$s">%2$s</span>',
1334 + esc_attr( $config['variant'] ),
1335 + esc_html( $config['label'] )
1336 + );
1337 + }
1338 +
1339 + /**
1340 + * Render the donation receipt card used by the {donation_receipt} smart tag.
1341 + *
1342 + * @param array<string, mixed> $donation_data Donation data.
1343 + * @param string $campaign_name Campaign name ('' for standalone forms).
1344 + * @return string Receipt card HTML.
1345 + * @since 1.0.0
1346 + */
1347 + public static function render_donation_receipt( $donation_data, $campaign_name = '' ) {
1348 + $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD';
1349 + $base_amount = isset( $donation_data['amount'] ) && is_numeric( $donation_data['amount'] ) ? (float) $donation_data['amount'] : 0.0;
1350 + $fees_covered = isset( $donation_data['fees_covered'] ) && is_numeric( $donation_data['fees_covered'] ) ? (float) $donation_data['fees_covered'] : 0.0;
1351 + $total = $base_amount + $fees_covered;
1352 +
1353 + $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : '';
1354 + $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
1355 + $gateway = isset( $donation_data['gateway'] ) && is_string( $donation_data['gateway'] ) ? $donation_data['gateway'] : '';
1356 + $status = isset( $donation_data['payment_status'] ) && is_string( $donation_data['payment_status'] ) ? $donation_data['payment_status'] : '';
1357 +
1358 + $rows = [
1359 + [
1360 + 'label' => __( 'Donor Name', 'suredonation' ),
1361 + 'value' => esc_html( $donor_name ),
1362 + ],
1363 + [
1364 + 'label' => __( 'Donor Email', 'suredonation' ),
1365 + 'value' => esc_html( $donor_email ),
1366 + ],
1367 + ];
1368 +
1369 + if ( '' !== $campaign_name ) {
1370 + $rows[] = [
1371 + 'label' => __( 'Campaign Name', 'suredonation' ),
1372 + 'value' => esc_html( $campaign_name ),
1373 + ];
1374 + }
1375 +
1376 + $rows[] = [
1377 + 'label' => __( 'Payment Status', 'suredonation' ),
1378 + 'value' => self::render_payment_status_badge( $status ),
1379 + ];
1380 + $rows[] = [
1381 + 'label' => __( 'Payment Method', 'suredonation' ),
1382 + 'value' => esc_html( self::get_payment_method_label( $gateway ) ),
1383 + ];
1384 + $rows[] = [
1385 + 'label' => __( 'Donation Amount', 'suredonation' ),
1386 + 'value' => esc_html( Payment_Helper::format_amount( $base_amount, $currency ) ),
1387 + ];
1388 +
1389 + $rows_html = '';
1390 + foreach ( $rows as $row ) {
1391 + $rows_html .= sprintf(
1392 + '<div class="sd-receipt-row"><span class="sd-receipt-row__label">%1$s</span><span class="sd-receipt-row__value">%2$s</span></div>',
1393 + esc_html( $row['label'] ),
1394 + $row['value']
1395 + );
1396 + }
1397 +
1398 + $rows_html .= sprintf(
1399 + '<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>',
1400 + esc_html__( 'Donation Total', 'suredonation' ),
1401 + esc_html( Payment_Helper::format_amount( $total, $currency ) )
1402 + );
1403 +
1404 + return sprintf(
1405 + '<div class="sd-receipt-card"><h3 class="sd-receipt-card__title">%1$s</h3><div class="sd-receipt-rows">%2$s</div></div>',
1406 + esc_html__( 'Donation Receipt', 'suredonation' ),
1407 + $rows_html
1408 + );
1409 + }
1410 +
1411 + /**
1412 + * Translate a stored checkbox value for display.
1413 + *
1414 + * Checkbox fields persist the canonical, untranslated tokens in
1415 + * Field_Validation::CHECKBOX_VALUES so the stored column stays comparable
1416 + * across locales and survives an export/re-import. Anything shown to a human
1417 + * runs through here; the CSV export deliberately does not, so the exported
1418 + * column keeps the canonical token.
1419 + *
1420 + * Values that are not a checkbox token are returned untouched, so this is
1421 + * safe to apply to a mixed field set.
1422 + *
1423 + * @param string $value Stored field value.
1424 + * @return string Display value.
1425 + * @since 1.5.1
1426 + */
1427 + public static function format_checkbox_field_value( $value ) {
1428 + $value = self::get_string_value( $value );
1429 +
1430 + switch ( $value ) {
1431 + case Field_Validation::CHECKBOX_VALUES['yes']:
1432 + return _x( 'Yes', 'checkbox field value', 'suredonation' );
1433 + case Field_Validation::CHECKBOX_VALUES['no']:
1434 + return _x( 'No', 'checkbox field value', 'suredonation' );
1435 + default:
1436 + return $value;
1437 + }
1438 + }
1439 +
1440 + /**
1441 + * Render the donation's submitted form fields as receipt rows.
1442 + *
1443 + * The values persisted under donation_data['fields'] (see
1444 + * Donations::set_submitted_fields) already surface on the entry screen and
1445 + * in exports; this renders the same set for the email templates, behind the
1446 + * {form_fields} smart tag. Returns '' when the donation has none, so a
1447 + * template carrying the tag is unchanged for forms with no extra fields.
1448 + *
1449 + * SECURITY: the return value is substituted into email HTML by the
1450 + * {form_fields} smart tag, and that tag is exempt from the escaping pass in
1451 + * Email_Handler::process_smart_tags() because core tags are compared by value
1452 + * and left alone. The esc_html() calls below are therefore the only thing
1453 + * between donor-submitted text and an admin's mailbox — both the label and
1454 + * the value must stay escaped here. See the regression test in
1455 + * tests/unit/inc/test-helper.php.
1456 + *
1457 + * @param array<mixed> $fields Stored fields as label/value/group entries.
1458 + * @return string Rendered markup, or '' when there is nothing to show.
1459 + * @since 1.5.1
1460 + */
1461 + public static function render_submitted_fields( $fields ) {
1462 + if ( empty( $fields ) || ! is_array( $fields ) ) {
1463 + return '';
1464 + }
1465 +
1466 + $rows_html = '';
1467 + foreach ( $fields as $field ) {
1468 + if ( ! is_array( $field ) ) {
1469 + continue;
1470 + }
1471 +
1472 + $label = self::get_string_value( $field['label'] ?? '' );
1473 + $value = self::format_checkbox_field_value( $field['value'] ?? '' );
1474 + $group = self::get_string_value( $field['group'] ?? '' );
1475 +
1476 + if ( '' === $label && '' === $value ) {
1477 + continue;
1478 + }
1479 +
1480 + // Sub-fields (e.g. the Address block's parts) are stored with their
1481 + // parent block's label as the group; prefix it so "Street Address"
1482 + // reads as "Address: Street Address" rather than losing its context.
1483 + if ( '' !== $group ) {
1484 + // str_replace (not sprintf) because the format is translator
1485 + // editable and this runs inside the gateway webhook handlers — a
1486 + // stray literal % would make sprintf throw a ValueError on PHP 8,
1487 + // 500 the webhook and trigger gateway retries. Same rule as
1488 + // Field_Validation's message formatting.
1489 + $label = str_replace(
1490 + [ '%1$s', '%2$s' ],
1491 + [ $group, $label ],
1492 + /* translators: 1: parent field label, 2: sub-field label. */
1493 + _x( '%1$s: %2$s', 'parent field label: sub-field label', 'suredonation' )
1494 + );
1495 + }
1496 +
1497 + $rows_html .= sprintf(
1498 + '<div class="sd-receipt-row"><span class="sd-receipt-row__label">%1$s</span><span class="sd-receipt-row__value">%2$s</span></div>',
1499 + esc_html( $label ),
1500 + esc_html( $value )
1501 + );
1502 + }
1503 +
1504 + if ( '' === $rows_html ) {
1505 + return '';
1506 + }
1507 +
1508 + return sprintf(
1509 + '<div class="sd-receipt-card"><h3 class="sd-receipt-card__title">%1$s</h3><div class="sd-receipt-rows">%2$s</div></div>',
1510 + esc_html__( 'Form Details', 'suredonation' ),
1511 + $rows_html
1512 + );
1513 + }
1514 +
1515 + /**
1516 + * Default confirmation message template (receipt layout with smart tags).
1517 + *
1518 + * @return string Message HTML template.
1519 + * @since 1.0.0
1520 + */
1521 + public static function get_default_confirmation_message() {
1522 + return '<p style="text-align: center; margin: 0;">{success_badge}</p>'
1523 + . '<h2 class="sd-receipt-title" style="text-align: center;">'
1524 + /* translators: {donor_name} is a smart tag replaced with the donor's name. */
1525 + . esc_html__( 'Thank you {donor_name} for your Donation', 'suredonation' )
1526 + . '</h2>'
1527 + . '<p class="sd-receipt-subtitle" style="text-align: center;">'
1528 + . esc_html__( 'Your contribution means a lot. We have sent a confirmation email to your registered address with the details of your donation.', 'suredonation' )
1529 + . '</p>{donation_receipt}';
1530 + }
1531 +
1532 + /**
1533 + * Build the rendered confirmation/thank-you HTML for a donation.
1534 + *
1535 + * Resolves the form's confirmation message template against the donation's
1536 + * real data (smart tags) so the frontend can display the receipt. The
1537 + * billing interval is lifted out of the nested donation_data column, which
1538 + * is the only field of the set that is not stored as a column of its own.
1539 + *
1540 + * @param int $donation_id Donation ID.
1541 + * @param array<string, mixed>|null $donation Donation row to render from.
1542 + * Defaults to reading it. Pass one
1543 + * when the caller already holds the
1544 + * row, or when the row on disk does
1545 + * not yet reflect the state being
1546 + * reported to the donor.
1547 + * @return string Sanitized confirmation HTML, or '' on failure.
1548 + * @since 1.0.0
1549 + */
1550 + public static function render_confirmation_message( $donation_id, $donation = null ) {
1551 + if ( ! is_array( $donation ) ) {
1552 + $donation = Donations::get( $donation_id );
1553 + }
1554 +
1555 + if ( ! is_array( $donation ) ) {
1556 + return '';
1557 + }
1558 +
1559 + $form_id = isset( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
1560 + $campaign_id = isset( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
1561 +
1562 + $settings = self::get_form_confirmation_settings( $form_id );
1563 + $template = ! empty( $settings['message'] ) ? $settings['message'] : self::get_default_confirmation_message();
1564 +
1565 + // The billing interval is the one field the donation row does not carry
1566 + // as a column; it is written a level down inside donation_data, so it
1567 + // has to be lifted out before the tag map can see it.
1568 + $stored = $donation['donation_data'] ?? [];
1569 + if ( is_string( $stored ) && '' !== $stored ) {
1570 + $stored = json_decode( $stored, true );
1571 + }
1572 + $stored = is_array( $stored ) ? $stored : [];
1573 +
1574 + $donation_data = [
1575 + 'id' => $donation_id,
1576 + 'donor_name' => $donation['donor_name'] ?? '',
1577 + 'donor_email' => $donation['donor_email'] ?? '',
1578 + 'amount' => $donation['amount'] ?? 0,
1579 + 'fees_covered' => $donation['fees_covered'] ?? 0,
1580 + 'currency' => $donation['currency'] ?? Payment_Helper::get_currency(),
1581 + 'gateway' => $donation['gateway'] ?? '',
1582 + 'payment_status' => $donation['payment_status'] ?? '',
1583 + 'transaction_id' => $donation['transaction_id'] ?? '',
1584 + 'donation_type' => $donation['donation_type'] ?? 'one-time',
1585 + // Recurring donations resolve these two; a one-time donation has
1586 + // neither, and the tag map already renders a missing value as empty.
1587 + 'subscription_id' => $donation['subscription_id'] ?? '',
1588 + 'subscription_interval' => $stored['subscription_interval'] ?? '',
1589 + ];
1590 +
1591 + $campaign = $campaign_id ? get_post( $campaign_id ) : null;
1592 +
1593 + $rendered = Email_Handler::process_smart_tags( $template, $donation_data, $campaign );
1594 +
1595 + return wp_kses_post( $rendered );
1596 + }
1597 +
1598 + /**
1599 + * Check whether the OttoKit (formerly SureTriggers) plugin is active and
1600 + * authenticated with the OttoKit SaaS.
1601 + *
1602 + * @return bool True when OttoKit is installed, active and connected.
1603 + * @since 1.2.0
1604 + */
1605 + public static function is_suretriggers_ready() {
1606 + if ( ! defined( 'SURE_TRIGGERS_FILE' ) ) {
1607 + // Plugin is deactivated or not installed at all.
1608 + return false;
1609 + }
1610 +
1611 + $suretriggers_data = get_option( 'suretrigger_options', [] );
1612 + if ( ! is_array( $suretriggers_data ) || empty( $suretriggers_data['secret_key'] ) || ! is_string( $suretriggers_data['secret_key'] ) ) {
1613 + // OttoKit is not authenticated yet.
1614 + return false;
1615 + }
1616 +
1617 + return true;
1618 + }
1619 +
1620 + /**
1621 + * Get OttoKit (formerly SureTriggers) integration metadata.
1622 + *
1623 + * Shared by the admin app and the donation form editor so both surface the
1624 + * same install/activate/connect state.
1625 + *
1626 + * @return array<string,mixed> Integration metadata.
1627 + * @since 1.2.0
1628 + */
1629 + public static function get_ottokit_integration() {
1630 + $plugin_file = 'suretriggers/suretriggers.php';
1631 +
1632 + if ( ! function_exists( 'is_plugin_active' ) ) {
1633 + include_once ABSPATH . 'wp-admin/includes/plugin.php';
1634 + }
1635 +
1636 + $status = 'Install';
1637 + if ( is_plugin_active( $plugin_file ) ) {
1638 + $status = 'Activated';
1639 + } elseif ( array_key_exists( $plugin_file, get_plugins() ) ) {
1640 + $status = 'Installed';
1641 + }
1642 +
1643 + return [
1644 + 'title' => 'OttoKit',
1645 + 'slug' => 'suretriggers',
1646 + 'path' => $plugin_file,
1647 + 'status' => $status,
1648 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Filter is owned by the OttoKit plugin.
1649 + 'connected' => apply_filters( 'suretriggers_is_user_connected', '' ),
1650 + 'connection_url' => admin_url( 'admin.php?page=suretriggers' ),
607 1651 ];
608 1652 }
609 1653 }