PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
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 +648 -30 0.0.1 → 1.1.0 View file →
@@ -6,8 +6,13 @@
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\Payments\Payment_Helper;
14 +
10 15 // Exit if accessed directly.
11 16 if ( ! defined( 'ABSPATH' ) ) {
12 17 exit;
13 18 }
@@ -39,16 +44,15 @@
39 44 * @since 0.0.1
40 45 * @var array<string, mixed>
41 46 */
42 47 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' => '',
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' => '',
51 55 ];
52 56
53 57 /**
54 58 * Get a value from the suredonation_options array.
@@ -88,8 +92,70 @@
88 92 return update_option( self::OPTION_NAME, $options );
89 93 }
90 94
91 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 + /**
92 158 * Get all campaign meta as an array.
93 159 *
94 160 * @param int $campaign_id Campaign post ID.
95 161 * @return array<string, mixed> Campaign meta values.
@@ -269,9 +335,9 @@
269 335 if ( ! is_array( $block ) ) {
270 336 continue;
271 337 }
272 338 // Skip non-SureDonation blocks.
273 - if ( ! isset( $block['blockName'] ) || ! is_string( $block['blockName'] ) || strpos( $block['blockName'], 'sd/' ) !== 0 ) {
339 + if ( ! isset( $block['blockName'] ) || ! is_string( $block['blockName'] ) || strpos( $block['blockName'], 'suredonation/' ) !== 0 ) {
274 340 // Process inner blocks if any.
275 341 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
276 342 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $prefix );
277 343 }
@@ -329,9 +395,9 @@
329 395 * @return string The generated unique block slug.
330 396 * @since 0.0.1
331 397 */
332 398 public static function generate_unique_block_slug( $block, $slugs, $prefix = '' ) {
333 - $slug = is_string( $block['blockName'] ?? '' ) ? str_replace( 'sd/', '', $block['blockName'] ) : '';
399 + $slug = is_string( $block['blockName'] ?? '' ) ? str_replace( 'suredonation/', '', $block['blockName'] ) : '';
334 400
335 401 // Use label if available.
336 402 if ( ! empty( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
337 403 $slug = sanitize_title( $block['attrs']['label'] );
@@ -382,29 +448,64 @@
382 448 * @return string Client IP address.
383 449 * @since 0.0.1
384 450 */
385 451 public static function get_client_ip() {
386 - $ip_headers = [
387 - 'HTTP_CLIENT_IP',
388 - 'HTTP_X_FORWARDED_FOR',
389 - 'REMOTE_ADDR',
390 - ];
452 + // Only trust REMOTE_ADDR — proxy headers (HTTP_X_FORWARDED_FOR, HTTP_CLIENT_IP)
453 + // are trivially spoofable and should not be used for logging or security.
454 + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
391 455
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] );
456 + if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
457 + return $ip;
458 + }
396 459
397 - if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
398 - return $ip;
399 - }
400 - }
460 + return '';
461 + }
462 +
463 + /**
464 + * Per-IP rate limiter for public (unauthenticated) submission endpoints.
465 + *
466 + * Uses a short-lived transient bucket keyed by action + client IP to
467 + * throttle abuse (card-testing, DB/email flooding) on nopriv AJAX handlers.
468 + * When the client IP cannot be determined the request is allowed, so
469 + * legitimate donors are never blocked by a missing IP.
470 + *
471 + * @param string $action Unique action identifier namespacing the bucket.
472 + * @param int $max Maximum attempts permitted within the window.
473 + * @param int $window Window length in seconds.
474 + * @return bool True if the request is within limits; false if the limit is exceeded.
475 + * @since 1.1.0
476 + */
477 + public static function check_rate_limit( $action, $max = 15, $window = MINUTE_IN_SECONDS ) {
478 + $ip = self::get_client_ip();
479 + if ( '' === $ip ) {
480 + return true;
401 481 }
402 482
403 - return '';
483 + $key = 'suredonation_rl_' . md5( (string) $action . '|' . $ip );
484 + $count = (int) get_transient( $key );
485 +
486 + if ( $count >= $max ) {
487 + return false;
488 + }
489 +
490 + set_transient( $key, $count + 1, $window );
491 + return true;
404 492 }
405 493
406 494 /**
495 + * Get sanitized request metadata (user agent and referer).
496 + *
497 + * @return array{user_agent: string, referer_url: string} Request metadata.
498 + * @since 1.0.0
499 + */
500 + public static function get_request_meta() {
501 + return [
502 + 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
503 + 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
504 + ];
505 + }
506 +
507 + /**
407 508 * Get allowed HTML tags for form markup.
408 509 *
409 510 * The wp_kses_post() doesn't allow form elements, so we need a custom allowed tags array.
410 511 * This is safe because the markup is generated internally by trusted code that already
@@ -434,13 +535,19 @@
434 535 'data-subscription-interval' => true,
435 536 'data-subscription-billing-cycles' => true,
436 537 'data-currency-symbol' => true,
437 538 'data-message-format' => true,
539 + 'data-payment-methods' => true,
540 + 'data-method' => true,
438 541 'data-slug' => true,
439 542 'data-required' => true,
440 543 'data-fee-percentage' => true,
441 544 'data-fee-fixed' => true,
545 + 'data-fee-mode' => true,
546 + 'data-gateway-fees' => true,
442 547 'data-invalid-email-msg' => true,
548 + 'data-sd-mask' => true,
549 + 'data-custom-sd-mask' => true,
443 550 ];
444 551
445 552 return [
446 553 'div' => array_merge(
@@ -541,14 +648,17 @@
541 648 'aria-required' => true,
542 649 ],
543 650 $common_data_attrs
544 651 ),
545 - 'span' => [
546 - 'id' => true,
547 - 'class' => true,
548 - 'style' => true,
549 - 'aria-hidden' => true,
550 - ],
652 + 'span' => array_merge(
653 + [
654 + 'id' => true,
655 + 'class' => true,
656 + 'style' => true,
657 + 'aria-hidden' => true,
658 + ],
659 + $common_data_attrs
660 + ),
551 661 'p' => [
552 662 'id' => true,
553 663 'class' => true,
554 664 'style' => true,
@@ -567,8 +677,17 @@
567 677 ],
568 678 'em' => [
569 679 'class' => true,
570 680 ],
681 + 'ol' => [
682 + 'class' => true,
683 + ],
684 + 'ul' => [
685 + 'class' => true,
686 + ],
687 + 'li' => [
688 + 'class' => true,
689 + ],
571 690 'br' => [],
572 691 'svg' => [
573 692 'class' => true,
574 693 'width' => true,
@@ -604,6 +723,505 @@
604 723 'stroke-linejoin' => true,
605 724 'fill' => true,
606 725 ],
607 726 ];
727 + }
728 +
729 + /**
730 + * Get the nonce action string for a donation form.
731 + *
732 + * Shared between block render, shortcode render, and donation handler
733 + * to ensure the nonce action is always consistent.
734 + *
735 + * @param int $campaign_id Campaign ID (0 for standalone forms).
736 + * @return string Nonce action string.
737 + * @since 1.0.0
738 + */
739 + public static function get_donation_nonce_action( $campaign_id ) {
740 + // Note: This nonce is used by the generic donation-handler.php (form POST flow).
741 + // Stripe and Offline AJAX handlers use a separate fixed nonce action
742 + // 'suredonation_donation_form' generated in payment-markup.php — these are
743 + // intentionally different nonce paths (form POST vs payment AJAX).
744 + return $campaign_id ? 'suredonation_donation_' . $campaign_id : 'suredonation_donation_standalone';
745 + }
746 +
747 + /**
748 + * Get form payment settings from post meta.
749 + *
750 + * Shared between the block and shortcode render paths to build
751 + * the `window.suredonationPayment` frontend configuration object.
752 + *
753 + * @param int $form_id Form post ID.
754 + * @return array<string, mixed> Payment settings array.
755 + * @since 1.0.0
756 + */
757 + public static function get_form_payment_settings( $form_id ) {
758 + $data = self::get_form_confirmation_settings( $form_id );
759 +
760 + // Map confirmation type to frontend format.
761 + $confirmation_type = 'message';
762 + $redirect_url = '';
763 + if ( 'custom url' === $data['confirmation_type'] ) {
764 + $confirmation_type = 'redirect';
765 + $redirect_url = $data['custom_url'];
766 + } elseif ( 'different page' === $data['confirmation_type'] ) {
767 + $confirmation_type = 'redirect';
768 + $redirect_url = $data['page_url'];
769 + }
770 +
771 + $success_message = ! empty( $data['message'] )
772 + ? $data['message']
773 + : esc_html__( 'Thank you for your donation!', 'suredonation' );
774 +
775 + return [
776 + 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
777 + 'confirmationType' => $confirmation_type,
778 + 'successTitle' => esc_html__( 'Thank You!', 'suredonation' ),
779 + 'successMessage' => wp_kses_post( self::get_string_value( $success_message ) ),
780 + // Shown when payment succeeded at the gateway but our server-side
781 + // finalize did not complete; the webhook will finalize it, so the
782 + // donor must not be prompted to pay again.
783 + 'processingMessage' => esc_html__( 'Payment received. We are finalizing your donation and will email you a confirmation shortly. Please do not pay again.', 'suredonation' ),
784 + 'redirectUrl' => ! empty( $redirect_url ) ? esc_url( self::get_string_value( $redirect_url ) ) : '',
785 + 'submissionAction' => $data['submission_action'],
786 + // translators: %s: formatted fee amount with currency symbol.
787 + 'feeIncludesText' => __( '(includes %s processing fee)', 'suredonation' ),
788 + 'amountPlaceholder' => __( 'Complete the form to view the amount.', 'suredonation' ),
789 + ];
790 + }
791 +
792 + /**
793 + * Get form confirmation settings from post meta.
794 + *
795 + * Reads from consolidated _suredonation_form_confirmation meta key.
796 + *
797 + * @param int $form_id Form post ID.
798 + * @return array<string, string> Confirmation settings with defaults applied.
799 + * @since 1.0.0
800 + */
801 + public static function get_form_confirmation_settings( $form_id ) {
802 + $defaults = [
803 + 'confirmation_type' => 'same page',
804 + 'message' => '',
805 + 'submission_action' => 'hide form',
806 + 'custom_url' => '',
807 + 'page_url' => '',
808 + ];
809 +
810 + $raw = get_post_meta( $form_id, '_suredonation_form_confirmation', true );
811 +
812 + if ( ! empty( $raw ) && is_string( $raw ) ) {
813 + $data = json_decode( $raw, true );
814 + if ( is_array( $data ) ) {
815 + return wp_parse_args( $data, $defaults );
816 + }
817 + }
818 +
819 + return $defaults;
820 + }
821 +
822 + /**
823 + * Get smart tags definitions grouped by context.
824 + *
825 + * Centralized source of truth for all smart tag lists used across
826 + * admin UI, form editor, and email settings.
827 + *
828 + * @return array<string, array<int, array<string, mixed>>> Smart tags grouped by context.
829 + * @since 1.0.0
830 + */
831 + public static function get_smart_tags() {
832 + $confirmation_tags = [
833 + [
834 + 'tag' => '{donor_name}',
835 + 'title' => __( 'Donor Name', 'suredonation' ),
836 + ],
837 + [
838 + 'tag' => '{donor_email}',
839 + 'title' => __( 'Donor Email', 'suredonation' ),
840 + ],
841 + [
842 + 'tag' => '{amount}',
843 + 'title' => __( 'Donation Amount', 'suredonation' ),
844 + ],
845 + [
846 + 'tag' => '{campaign_name}',
847 + 'title' => __( 'Campaign Name', 'suredonation' ),
848 + ],
849 + [
850 + 'tag' => '{donation_date}',
851 + 'title' => __( 'Donation Date', 'suredonation' ),
852 + ],
853 + [
854 + 'tag' => '{transaction_id}',
855 + 'title' => __( 'Transaction ID', 'suredonation' ),
856 + ],
857 + [
858 + 'tag' => '{payment_method}',
859 + 'title' => __( 'Payment Method', 'suredonation' ),
860 + ],
861 + [
862 + 'tag' => '{site_title}',
863 + 'title' => __( 'Site Title', 'suredonation' ),
864 + ],
865 + [
866 + 'tag' => '{donation_total}',
867 + 'title' => __( 'Donation Total', 'suredonation' ),
868 + ],
869 + [
870 + 'tag' => '{payment_status}',
871 + 'title' => __( 'Payment Status', 'suredonation' ),
872 + ],
873 + [
874 + 'tag' => '{donation_receipt}',
875 + 'title' => __( 'Donation Receipt', 'suredonation' ),
876 + ],
877 + [
878 + 'tag' => '{success_badge}',
879 + 'title' => __( 'Success Badge', 'suredonation' ),
880 + ],
881 + ];
882 +
883 + return [
884 + 'confirmation' => $confirmation_tags,
885 + 'email' => array_merge(
886 + $confirmation_tags,
887 + [
888 + [
889 + 'tag' => '{admin_email}',
890 + 'title' => __( 'Admin Email', 'suredonation' ),
891 + ],
892 + [
893 + 'tag' => '{site_url}',
894 + 'title' => __( 'Site URL', 'suredonation' ),
895 + ],
896 + [
897 + 'tag' => '{admin_url}',
898 + 'title' => __( 'Admin URL', 'suredonation' ),
899 + ],
900 + [
901 + 'tag' => '{subscription_id}',
902 + 'title' => __( 'Subscription ID', 'suredonation' ),
903 + ],
904 + [
905 + 'tag' => '{subscription_interval}',
906 + 'title' => __( 'Subscription Interval', 'suredonation' ),
907 + ],
908 + [
909 + 'tag' => '{offline_instructions}',
910 + 'title' => __( 'Offline Instructions', 'suredonation' ),
911 + ],
912 + ]
913 + ),
914 + 'email_grouped' => [
915 + [
916 + 'label' => __( 'Donation Tags', 'suredonation' ),
917 + 'tags' => [
918 + [
919 + 'tag' => '{donor_name}',
920 + 'title' => __( 'Donor Name', 'suredonation' ),
921 + ],
922 + [
923 + 'tag' => '{donor_email}',
924 + 'title' => __( 'Donor Email', 'suredonation' ),
925 + ],
926 + [
927 + 'tag' => '{amount}',
928 + 'title' => __( 'Donation Amount', 'suredonation' ),
929 + ],
930 + [
931 + 'tag' => '{campaign_name}',
932 + 'title' => __( 'Campaign Name', 'suredonation' ),
933 + ],
934 + [
935 + 'tag' => '{donation_date}',
936 + 'title' => __( 'Donation Date', 'suredonation' ),
937 + ],
938 + [
939 + 'tag' => '{transaction_id}',
940 + 'title' => __( 'Transaction ID', 'suredonation' ),
941 + ],
942 + [
943 + 'tag' => '{payment_method}',
944 + 'title' => __( 'Payment Method', 'suredonation' ),
945 + ],
946 + [
947 + 'tag' => '{subscription_id}',
948 + 'title' => __( 'Subscription ID', 'suredonation' ),
949 + ],
950 + [
951 + 'tag' => '{subscription_interval}',
952 + 'title' => __( 'Subscription Interval', 'suredonation' ),
953 + ],
954 + [
955 + 'tag' => '{refund_amount}',
956 + 'title' => __( 'Refund Amount', 'suredonation' ),
957 + ],
958 + ],
959 + ],
960 + [
961 + 'label' => __( 'General Tags', 'suredonation' ),
962 + 'tags' => [
963 + [
964 + 'tag' => '{site_title}',
965 + 'title' => __( 'Site Title', 'suredonation' ),
966 + ],
967 + [
968 + 'tag' => '{admin_email}',
969 + 'title' => __( 'Admin Email', 'suredonation' ),
970 + ],
971 + [
972 + 'tag' => '{site_url}',
973 + 'title' => __( 'Site URL', 'suredonation' ),
974 + ],
975 + [
976 + 'tag' => '{admin_url}',
977 + 'title' => __( 'Admin URL', 'suredonation' ),
978 + ],
979 + [
980 + 'tag' => '{offline_instructions}',
981 + 'title' => __( 'Offline Instructions', 'suredonation' ),
982 + ],
983 + ],
984 + ],
985 + ],
986 + 'offline_instructions' => [
987 + [
988 + 'tag' => '{campaign_name}',
989 + 'title' => __( 'Campaign Name', 'suredonation' ),
990 + ],
991 + [
992 + 'tag' => '{site_title}',
993 + 'title' => __( 'Site Title', 'suredonation' ),
994 + ],
995 + [
996 + 'tag' => '{site_url}',
997 + 'title' => __( 'Site URL', 'suredonation' ),
998 + ],
999 + [
1000 + 'tag' => '{admin_email}',
1001 + 'title' => __( 'Admin Email', 'suredonation' ),
1002 + ],
1003 + ],
1004 + ];
1005 + }
1006 +
1007 + /**
1008 + * Map a payment gateway slug to a human-readable label.
1009 + *
1010 + * @param string $gateway Gateway slug (e.g. stripe, paypal, manual).
1011 + * @return string Display label.
1012 + * @since 1.0.0
1013 + */
1014 + public static function get_payment_method_label( $gateway ) {
1015 + switch ( $gateway ) {
1016 + case 'paypal':
1017 + return __( 'PayPal', 'suredonation' );
1018 + case 'manual':
1019 + case 'offline':
1020 + return __( 'Offline Donation', 'suredonation' );
1021 + case 'stripe':
1022 + return __( 'Stripe', 'suredonation' );
1023 + default:
1024 + return ucwords( str_replace( [ '_', '-' ], ' ', (string) $gateway ) );
1025 + }
1026 + }
1027 +
1028 + /**
1029 + * Render the static "Success" badge used by the {success_badge} smart tag.
1030 + *
1031 + * @return string Badge HTML.
1032 + * @since 1.0.0
1033 + */
1034 + public static function render_success_badge() {
1035 + return '<span class="sd-success-box__badge">' . esc_html__( 'Success', 'suredonation' ) . '</span>';
1036 + }
1037 +
1038 + /**
1039 + * Render a styled payment-status badge for the donation confirmation.
1040 + *
1041 + * @param string $status Payment status (e.g. completed, pending, failed).
1042 + * @return string Badge HTML.
1043 + * @since 1.0.0
1044 + */
1045 + public static function get_payment_status_config( $status ) {
1046 + $status = strtolower( trim( (string) $status ) );
1047 +
1048 + $map = [
1049 + 'completed' => [
1050 + 'label' => __( 'Complete', 'suredonation' ),
1051 + 'variant' => 'complete',
1052 + ],
1053 + 'complete' => [
1054 + 'label' => __( 'Complete', 'suredonation' ),
1055 + 'variant' => 'complete',
1056 + ],
1057 + 'pending' => [
1058 + 'label' => __( 'Pending', 'suredonation' ),
1059 + 'variant' => 'pending',
1060 + ],
1061 + 'processing' => [
1062 + 'label' => __( 'Processing', 'suredonation' ),
1063 + 'variant' => 'pending',
1064 + ],
1065 + 'failed' => [
1066 + 'label' => __( 'Failed', 'suredonation' ),
1067 + 'variant' => 'failed',
1068 + ],
1069 + 'refunded' => [
1070 + 'label' => __( 'Refunded', 'suredonation' ),
1071 + 'variant' => 'refunded',
1072 + ],
1073 + ];
1074 +
1075 + return $map[ $status ] ?? [
1076 + 'label' => '' !== $status ? ucfirst( $status ) : __( 'Complete', 'suredonation' ),
1077 + 'variant' => 'pending',
1078 + ];
1079 + }
1080 +
1081 + /**
1082 + * Render a styled payment-status badge for the donation receipt row.
1083 + *
1084 + * @param string $status Payment status (e.g. completed, pending, failed).
1085 + * @return string Badge HTML.
1086 + * @since 1.0.0
1087 + */
1088 + public static function render_payment_status_badge( $status ) {
1089 + $config = self::get_payment_status_config( $status );
1090 + return sprintf(
1091 + '<span class="sd-receipt-badge sd-receipt-badge--%1$s">%2$s</span>',
1092 + esc_attr( $config['variant'] ),
1093 + esc_html( $config['label'] )
1094 + );
1095 + }
1096 +
1097 + /**
1098 + * Render the donation receipt card used by the {donation_receipt} smart tag.
1099 + *
1100 + * @param array<string, mixed> $donation_data Donation data.
1101 + * @param string $campaign_name Campaign name ('' for standalone forms).
1102 + * @return string Receipt card HTML.
1103 + * @since 1.0.0
1104 + */
1105 + public static function render_donation_receipt( $donation_data, $campaign_name = '' ) {
1106 + $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD';
1107 + $base_amount = isset( $donation_data['amount'] ) && is_numeric( $donation_data['amount'] ) ? (float) $donation_data['amount'] : 0.0;
1108 + $fees_covered = isset( $donation_data['fees_covered'] ) && is_numeric( $donation_data['fees_covered'] ) ? (float) $donation_data['fees_covered'] : 0.0;
1109 + $total = $base_amount + $fees_covered;
1110 +
1111 + $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : '';
1112 + $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
1113 + $gateway = isset( $donation_data['gateway'] ) && is_string( $donation_data['gateway'] ) ? $donation_data['gateway'] : '';
1114 + $status = isset( $donation_data['payment_status'] ) && is_string( $donation_data['payment_status'] ) ? $donation_data['payment_status'] : '';
1115 +
1116 + $rows = [
1117 + [
1118 + 'label' => __( 'Donor Name', 'suredonation' ),
1119 + 'value' => esc_html( $donor_name ),
1120 + ],
1121 + [
1122 + 'label' => __( 'Donor Email', 'suredonation' ),
1123 + 'value' => esc_html( $donor_email ),
1124 + ],
1125 + ];
1126 +
1127 + if ( '' !== $campaign_name ) {
1128 + $rows[] = [
1129 + 'label' => __( 'Campaign Name', 'suredonation' ),
1130 + 'value' => esc_html( $campaign_name ),
1131 + ];
1132 + }
1133 +
1134 + $rows[] = [
1135 + 'label' => __( 'Payment Status', 'suredonation' ),
1136 + 'value' => self::render_payment_status_badge( $status ),
1137 + ];
1138 + $rows[] = [
1139 + 'label' => __( 'Payment Method', 'suredonation' ),
1140 + 'value' => esc_html( self::get_payment_method_label( $gateway ) ),
1141 + ];
1142 + $rows[] = [
1143 + 'label' => __( 'Donation Amount', 'suredonation' ),
1144 + 'value' => esc_html( Payment_Helper::format_amount( $base_amount, $currency ) ),
1145 + ];
1146 +
1147 + $rows_html = '';
1148 + foreach ( $rows as $row ) {
1149 + $rows_html .= sprintf(
1150 + '<div class="sd-receipt-row"><span class="sd-receipt-row__label">%1$s</span><span class="sd-receipt-row__value">%2$s</span></div>',
1151 + esc_html( $row['label'] ),
1152 + $row['value']
1153 + );
1154 + }
1155 +
1156 + $rows_html .= sprintf(
1157 + '<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>',
1158 + esc_html__( 'Donation Total', 'suredonation' ),
1159 + esc_html( Payment_Helper::format_amount( $total, $currency ) )
1160 + );
1161 +
1162 + return sprintf(
1163 + '<div class="sd-receipt-card"><h3 class="sd-receipt-card__title">%1$s</h3><div class="sd-receipt-rows">%2$s</div></div>',
1164 + esc_html__( 'Donation Receipt', 'suredonation' ),
1165 + $rows_html
1166 + );
1167 + }
1168 +
1169 + /**
1170 + * Default confirmation message template (receipt layout with smart tags).
1171 + *
1172 + * @return string Message HTML template.
1173 + * @since 1.0.0
1174 + */
1175 + public static function get_default_confirmation_message() {
1176 + return '<p style="text-align: center; margin: 0;">{success_badge}</p>'
1177 + . '<h2 class="sd-receipt-title" style="text-align: center;">'
1178 + /* translators: {donor_name} is a smart tag replaced with the donor's name. */
1179 + . esc_html__( 'Thank you {donor_name} for your Donation', 'suredonation' )
1180 + . '</h2>'
1181 + . '<p class="sd-receipt-subtitle" style="text-align: center;">'
1182 + . esc_html__( 'Your contribution means a lot. We have sent an email to your registered account along with a receipt for your donation.', 'suredonation' )
1183 + . '</p>{donation_receipt}';
1184 + }
1185 +
1186 + /**
1187 + * Build the rendered confirmation/thank-you HTML for a donation.
1188 + *
1189 + * Resolves the form's confirmation message template against the donation's
1190 + * real data (smart tags) so the frontend can display the receipt.
1191 + *
1192 + * @param int $donation_id Donation ID.
1193 + * @return string Sanitized confirmation HTML, or '' on failure.
1194 + * @since 1.0.0
1195 + */
1196 + public static function render_confirmation_message( $donation_id ) {
1197 + $donation = Donations::get( $donation_id );
1198 + if ( ! is_array( $donation ) ) {
1199 + return '';
1200 + }
1201 +
1202 + $form_id = isset( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
1203 + $campaign_id = isset( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
1204 +
1205 + $settings = self::get_form_confirmation_settings( $form_id );
1206 + $template = ! empty( $settings['message'] ) ? $settings['message'] : self::get_default_confirmation_message();
1207 +
1208 + $donation_data = [
1209 + 'id' => $donation_id,
1210 + 'donor_name' => $donation['donor_name'] ?? '',
1211 + 'donor_email' => $donation['donor_email'] ?? '',
1212 + 'amount' => $donation['amount'] ?? 0,
1213 + 'fees_covered' => $donation['fees_covered'] ?? 0,
1214 + 'currency' => $donation['currency'] ?? Payment_Helper::get_currency(),
1215 + 'gateway' => $donation['gateway'] ?? '',
1216 + 'payment_status' => $donation['payment_status'] ?? '',
1217 + 'transaction_id' => $donation['transaction_id'] ?? '',
1218 + 'donation_type' => $donation['donation_type'] ?? 'one-time',
1219 + ];
1220 +
1221 + $campaign = $campaign_id ? get_post( $campaign_id ) : null;
1222 +
1223 + $rendered = Email_Handler::process_smart_tags( $template, $donation_data, $campaign );
1224 +
1225 + return wp_kses_post( $rendered );
608 1226 }
609 1227 }