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 +452 -115 1.1.2 → 1.6.1 View file →
@@ -9,8 +9,9 @@
9 9
10 10 use SureDonation\Inc\API\Settings_API;
11 11 use SureDonation\Inc\Database\Tables\Donations;
12 12 use SureDonation\Inc\Emails\Email_Handler;
13 +use SureDonation\Inc\Field_Validation;
13 14 use SureDonation\Inc\Payments\Payment_Helper;
14 15
15 16 // Exit if accessed directly.
16 17 if ( ! defined( 'ABSPATH' ) ) {
@@ -203,8 +204,40 @@
203 204 return update_post_meta( $campaign_id, self::SUREDONATION_CAMPAIGN_META_KEY, wp_json_encode( $meta ) );
204 205 }
205 206
206 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 + /**
207 240 * Checks if current value is string or else returns default value
208 241 *
209 242 * @param mixed $data data which need to be checked if is string.
210 243 * @return string
@@ -542,58 +575,70 @@
542 575 */
543 576 public static function get_allowed_form_html() {
544 577 // Note: data-* wildcard doesn't work in wp_kses, so we list each data attribute explicitly.
545 578 $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,
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,
578 623 // 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,
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,
585 630 // 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,
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,
592 637 ];
593 638
594 639 $allowed = [
595 - 'div' => array_merge(
640 + 'div' => array_merge(
596 641 [
597 642 'id' => true,
598 643 'class' => true,
599 644 'style' => true,
@@ -602,12 +647,17 @@
602 647 'aria-live' => true,
603 648 'aria-atomic' => true,
604 649 'aria-hidden' => true,
605 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,
606 656 ],
607 657 $common_data_attrs
608 658 ),
609 - 'form' => array_merge(
659 + 'form' => array_merge(
610 660 [
611 661 'id' => true,
612 662 'class' => true,
613 663 'method' => true,
@@ -614,22 +664,22 @@
614 664 'action' => true,
615 665 ],
616 666 $common_data_attrs
617 667 ),
618 - 'fieldset' => [
668 + 'fieldset' => [
619 669 'id' => true,
620 670 'class' => true,
621 671 ],
622 - 'legend' => [
672 + 'legend' => [
623 673 'id' => true,
624 674 'class' => true,
625 675 ],
626 - 'label' => [
676 + 'label' => [
627 677 'id' => true,
628 678 'class' => true,
629 679 'for' => true,
630 680 ],
631 - 'input' => array_merge(
681 + 'input' => array_merge(
632 682 [
633 683 'id' => true,
634 684 'class' => true,
635 685 'type' => true,
@@ -649,12 +699,14 @@
649 699 'inputmode' => true,
650 700 'aria-describedby' => true,
651 701 'aria-required' => true,
652 702 'aria-hidden' => true,
703 + // Payment-type chooser radios point at the amount panel they reveal.
704 + 'aria-controls' => true,
653 705 ],
654 706 $common_data_attrs
655 707 ),
656 - 'button' => array_merge(
708 + 'button' => array_merge(
657 709 [
658 710 'id' => true,
659 711 'class' => true,
660 712 'type' => true,
@@ -661,9 +713,9 @@
661 713 'disabled' => true,
662 714 ],
663 715 $common_data_attrs
664 716 ),
665 - 'select' => array_merge(
717 + 'select' => array_merge(
666 718 [
667 719 'id' => true,
668 720 'class' => true,
669 721 'name' => true,
@@ -676,15 +728,15 @@
676 728 'aria-required' => true,
677 729 ],
678 730 $common_data_attrs
679 731 ),
680 - 'option' => [
732 + 'option' => [
681 733 'value' => true,
682 734 'class' => true,
683 735 'selected' => true,
684 736 'disabled' => true,
685 737 ],
686 - 'textarea' => array_merge(
738 + 'textarea' => array_merge(
687 739 [
688 740 'id' => true,
689 741 'class' => true,
690 742 'name' => true,
@@ -699,9 +751,9 @@
699 751 'aria-required' => true,
700 752 ],
701 753 $common_data_attrs
702 754 ),
703 - 'span' => array_merge(
755 + 'span' => array_merge(
704 756 [
705 757 'id' => true,
706 758 'class' => true,
707 759 'style' => true,
@@ -708,39 +760,39 @@
708 760 'aria-hidden' => true,
709 761 ],
710 762 $common_data_attrs
711 763 ),
712 - 'p' => [
764 + 'p' => [
713 765 'id' => true,
714 766 'class' => true,
715 767 'style' => true,
716 768 'role' => true,
717 769 ],
718 - 'h1' => [
770 + 'h1' => [
719 771 'id' => true,
720 772 'class' => true,
721 773 ],
722 - 'h2' => [
774 + 'h2' => [
723 775 'id' => true,
724 776 'class' => true,
725 777 ],
726 - 'h3' => [
778 + 'h3' => [
727 779 'id' => true,
728 780 'class' => true,
729 781 ],
730 - 'h4' => [
782 + 'h4' => [
731 783 'id' => true,
732 784 'class' => true,
733 785 ],
734 - 'h5' => [
786 + 'h5' => [
735 787 'id' => true,
736 788 'class' => true,
737 789 ],
738 - 'h6' => [
790 + 'h6' => [
739 791 'id' => true,
740 792 'class' => true,
741 793 ],
742 - 'a' => [
794 + 'a' => [
743 795 'id' => true,
744 796 'class' => true,
745 797 'href' => true,
746 798 'target' => true,
@@ -746,28 +798,60 @@
746 798 'target' => true,
747 799 'rel' => true,
748 800 'style' => true,
749 801 ],
750 - 'strong' => [
802 + 'strong' => [
751 803 'class' => true,
752 804 ],
753 - 'em' => [
805 + 'em' => [
754 806 'class' => true,
755 807 ],
756 - 'ol' => [
808 + 'ol' => [
757 809 'class' => true,
758 810 ],
759 - 'ul' => [
811 + 'ul' => [
760 812 'class' => true,
761 813 ],
762 - 'li' => [
814 + 'li' => [
763 815 'class' => true,
764 816 ],
765 - 'br' => [],
766 - 'hr' => [
817 + 'br' => [],
818 + 'hr' => [
767 819 'class' => true,
768 820 ],
769 - 'svg' => [
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' => [
770 854 'class' => true,
771 855 'width' => true,
772 856 'height' => true,
773 857 'viewbox' => true,
@@ -774,9 +858,9 @@
774 858 'fill' => true,
775 859 'xmlns' => true,
776 860 'aria-hidden' => true,
777 861 ],
778 - 'circle' => [
862 + 'circle' => [
779 863 'cx' => true,
780 864 'cy' => true,
781 865 'r' => true,
782 866 'stroke' => true,
@@ -782,9 +866,9 @@
782 866 'stroke' => true,
783 867 'stroke-width' => true,
784 868 'fill' => true,
785 869 ],
786 - 'rect' => [
870 + 'rect' => [
787 871 'x' => true,
788 872 'y' => true,
789 873 'width' => true,
790 874 'height' => true,
@@ -791,9 +875,9 @@
791 875 'rx' => true,
792 876 'stroke' => true,
793 877 'stroke-width' => true,
794 878 ],
795 - 'path' => [
879 + 'path' => [
796 880 'class' => true,
797 881 'd' => true,
798 882 'stroke' => true,
799 883 'stroke-width' => true,
@@ -861,21 +945,36 @@
861 945 ? $data['message']
862 946 : esc_html__( 'Thank you for your donation!', 'suredonation' );
863 947
864 948 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 ) ),
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 ) ),
869 953 // Shown when payment succeeded at the gateway but our server-side
870 954 // finalize did not complete; the webhook will finalize it, so the
871 955 // 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'],
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'],
875 965 // 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' ),
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(),
878 977 ];
879 978 }
880 979
881 980 /**
@@ -968,9 +1067,9 @@
968 1067 'title' => __( 'Success Badge', 'suredonation' ),
969 1068 ],
970 1069 ];
971 1070
972 - return [
1071 + $smart_tags = [
973 1072 'confirmation' => $confirmation_tags,
974 1073 'email' => array_merge(
975 1074 $confirmation_tags,
976 1075 [
@@ -986,16 +1085,8 @@
986 1085 'tag' => '{admin_url}',
987 1086 'title' => __( 'Admin URL', 'suredonation' ),
988 1087 ],
989 1088 [
990 - 'tag' => '{subscription_id}',
991 - 'title' => __( 'Subscription ID', 'suredonation' ),
992 - ],
993 - [
994 - 'tag' => '{subscription_interval}',
995 - 'title' => __( 'Subscription Interval', 'suredonation' ),
996 - ],
997 - [
998 1089 'tag' => '{offline_instructions}',
999 1090 'title' => __( 'Offline Instructions', 'suredonation' ),
1000 1091 ],
1001 1092 ]
@@ -1032,19 +1123,21 @@
1032 1123 'tag' => '{payment_method}',
1033 1124 'title' => __( 'Payment Method', 'suredonation' ),
1034 1125 ],
1035 1126 [
1036 - 'tag' => '{subscription_id}',
1037 - 'title' => __( 'Subscription ID', 'suredonation' ),
1127 + 'tag' => '{refund_amount}',
1128 + 'title' => __( 'Refund Amount', 'suredonation' ),
1038 1129 ],
1039 1130 [
1040 - 'tag' => '{subscription_interval}',
1041 - 'title' => __( 'Subscription Interval', 'suredonation' ),
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,
1042 1139 ],
1043 - [
1044 - 'tag' => '{refund_amount}',
1045 - 'title' => __( 'Refund Amount', 'suredonation' ),
1046 - ],
1047 1140 ],
1048 1141 ],
1049 1142 [
1050 1143 'label' => __( 'General Tags', 'suredonation' ),
@@ -1090,8 +1183,68 @@
1090 1183 'title' => __( 'Admin Email', 'suredonation' ),
1091 1184 ],
1092 1185 ],
1093 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 );
1094 1247 }
1095 1248
1096 1249 /**
1097 1250 * Map a payment gateway slug to a human-readable label.
@@ -1255,8 +1408,112 @@
1255 1408 );
1256 1409 }
1257 1410
1258 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 + /**
1259 1516 * Default confirmation message template (receipt layout with smart tags).
1260 1517 *
1261 1518 * @return string Message HTML template.
1262 1519 * @since 1.0.0
@@ -1267,9 +1524,9 @@
1267 1524 /* translators: {donor_name} is a smart tag replaced with the donor's name. */
1268 1525 . esc_html__( 'Thank you {donor_name} for your Donation', 'suredonation' )
1269 1526 . '</h2>'
1270 1527 . '<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' )
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' )
1272 1529 . '</p>{donation_receipt}';
1273 1530 }
1274 1531
1275 1532 /**
@@ -1275,17 +1532,28 @@
1275 1532 /**
1276 1533 * Build the rendered confirmation/thank-you HTML for a donation.
1277 1534 *
1278 1535 * Resolves the form's confirmation message template against the donation's
1279 - * real data (smart tags) so the frontend can display the receipt.
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.
1280 1539 *
1281 - * @param int $donation_id Donation ID.
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.
1282 1547 * @return string Sanitized confirmation HTML, or '' on failure.
1283 1548 * @since 1.0.0
1284 1549 */
1285 - public static function render_confirmation_message( $donation_id ) {
1286 - $donation = Donations::get( $donation_id );
1550 + public static function render_confirmation_message( $donation_id, $donation = null ) {
1287 1551 if ( ! is_array( $donation ) ) {
1552 + $donation = Donations::get( $donation_id );
1553 + }
1554 +
1555 + if ( ! is_array( $donation ) ) {
1288 1556 return '';
1289 1557 }
1290 1558
1291 1559 $form_id = isset( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
@@ -1293,19 +1561,32 @@
1293 1561
1294 1562 $settings = self::get_form_confirmation_settings( $form_id );
1295 1563 $template = ! empty( $settings['message'] ) ? $settings['message'] : self::get_default_confirmation_message();
1296 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 +
1297 1574 $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',
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'] ?? '',
1308 1589 ];
1309 1590
1310 1591 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
1311 1592
@@ -1311,6 +1592,62 @@
1311 1592
1312 1593 $rendered = Email_Handler::process_smart_tags( $template, $donation_data, $campaign );
1313 1594
1314 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' ),
1651 + ];
1315 1652 }
1316 1653 }