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

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

1,228 lines 36.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Helper Class - Utility functions for SureDonation
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc;
9
10 use SureDonation\Inc\API\Settings_API;
11 use SureDonation\Inc\Database\Tables\Donations;
12 use SureDonation\Inc\Emails\Email_Handler;
13 use SureDonation\Inc\Payments\Payment_Helper;
14
15 // Exit if accessed directly.
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 /**
21 * Helper class.
22 * Provides utility functions for the plugin.
23 *
24 * @since 0.0.1
25 */
26 class Helper {
27 /**
28 * Option name for all SureDonation settings.
29 *
30 * @since 0.0.1
31 */
32 public const OPTION_NAME = 'suredonation_options';
33
34 /**
35 * Campaign meta key name.
36 *
37 * @since 0.0.1
38 */
39 public const SUREDONATION_CAMPAIGN_META_KEY = '_suredonation_campaign_meta';
40
41 /**
42 * Default campaign meta values.
43 *
44 * @since 0.0.1
45 * @var array<string, mixed>
46 */
47 private static $campaign_meta_defaults = [
48 'goal_type' => 'raised_amount',
49 'goal_amount' => 0,
50 'campaign_status' => 'active',
51 'email_settings' => [],
52 'require_terms' => false,
53 'terms_text' => '',
54 'thank_you_message' => '',
55 ];
56
57 /**
58 * Get a value from the suredonation_options array.
59 *
60 * @param string $key The key to retrieve.
61 * @param mixed $default_value Default value if key doesn't exist.
62 * @return mixed
63 * @since 0.0.1
64 */
65 public static function get_suredonation_option( $key, $default_value = null ) {
66 $options = get_option( self::OPTION_NAME, [] );
67
68 if ( ! is_array( $options ) ) {
69 $options = [];
70 }
71
72 return array_key_exists( $key, $options ) ? $options[ $key ] : $default_value;
73 }
74
75 /**
76 * Update a value in the suredonation_options array.
77 *
78 * @param string $key The key to update.
79 * @param mixed $value The value to set.
80 * @return bool True on success, false on failure.
81 * @since 0.0.1
82 */
83 public static function update_suredonation_option( $key, $value ) {
84 $options = get_option( self::OPTION_NAME, [] );
85
86 if ( ! is_array( $options ) ) {
87 $options = [];
88 }
89
90 $options[ $key ] = $value;
91
92 return update_option( self::OPTION_NAME, $options );
93 }
94
95 /**
96 * Whether honeypot spam protection is enabled in the global settings.
97 *
98 * @return bool True when the honeypot is enabled.
99 * @since 1.1.0
100 */
101 public static function is_honeypot_enabled() {
102 $spam_settings = self::get_suredonation_option( Settings_API::SPAM_OPTION_KEY, [] );
103
104 return is_array( $spam_settings ) && ! empty( $spam_settings['honeypot'] );
105 }
106
107 /**
108 * Output the hidden honeypot field when spam protection is enabled.
109 *
110 * Genuine visitors never see or fill this hidden field, so it is submitted
111 * with an empty value. A filled value (a bot that auto-fills every input) or
112 * a missing field (a bot that strips unknown inputs) is flagged as spam at
113 * submission time.
114 *
115 * @return void
116 * @see Helper::is_honeypot_spam()
117 * @since 1.1.0
118 */
119 public static function render_honeypot_field() {
120 if ( ! self::is_honeypot_enabled() ) {
121 return;
122 }
123
124 echo '<input type="hidden" name="suredonation_honeypot" value="" />';
125 }
126
127 /**
128 * Determine whether the current submission tripped the honeypot.
129 *
130 * Returns false when honeypot protection is disabled. When enabled, a real
131 * submission always carries the hidden field with an empty value; a missing
132 * field or any non-empty value is treated as spam.
133 *
134 * The honeypot field holds no sensitive data and is only inspected for
135 * emptiness. Nonce/referer verification is performed by the calling
136 * submission handler before this method runs.
137 *
138 * @return bool True when the submission should be rejected as spam.
139 * @since 1.1.0
140 */
141 public static function is_honeypot_spam() {
142 if ( ! self::is_honeypot_enabled() ) {
143 return false;
144 }
145
146 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by the calling submission handler; value only checked for emptiness.
147 if ( ! isset( $_POST['suredonation_honeypot'] ) ) {
148 return true;
149 }
150
151 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See note above.
152 $value = sanitize_text_field( wp_unslash( $_POST['suredonation_honeypot'] ) );
153
154 return '' !== $value;
155 }
156
157 /**
158 * Get all campaign meta as an array.
159 *
160 * @param int $campaign_id Campaign post ID.
161 * @return array<string, mixed> Campaign meta values.
162 * @since 0.0.1
163 */
164 public static function get_campaign_meta( $campaign_id ) {
165 $raw = get_post_meta( $campaign_id, self::SUREDONATION_CAMPAIGN_META_KEY, true );
166
167 $meta = ! empty( $raw ) && is_string( $raw ) ? json_decode( $raw, true ) : [];
168
169 if ( ! is_array( $meta ) ) {
170 $meta = [];
171 }
172
173 return array_merge( self::$campaign_meta_defaults, $meta );
174 }
175
176 /**
177 * Get a single campaign meta value.
178 *
179 * @param int $campaign_id Campaign post ID.
180 * @param string $key Meta key within the campaign meta array.
181 * @param mixed $default_value Default value if not set.
182 * @return mixed
183 * @since 0.0.1
184 */
185 public static function get_campaign_meta_value( $campaign_id, $key, $default_value = null ) {
186 $meta = self::get_campaign_meta( $campaign_id );
187
188 return $meta[ $key ] ?? $default_value;
189 }
190
191 /**
192 * Update campaign meta. Merges provided values with existing meta.
193 *
194 * @param int $campaign_id Campaign post ID.
195 * @param array<string, mixed> $values Key-value pairs to update.
196 * @return bool|int Meta ID on success, false on failure.
197 * @since 0.0.1
198 */
199 public static function update_campaign_meta( $campaign_id, $values ) {
200 $meta = self::get_campaign_meta( $campaign_id );
201 $meta = array_merge( $meta, $values );
202
203 return update_post_meta( $campaign_id, self::SUREDONATION_CAMPAIGN_META_KEY, wp_json_encode( $meta ) );
204 }
205
206 /**
207 * Checks if current value is string or else returns default value
208 *
209 * @param mixed $data data which need to be checked if is string.
210 * @return string
211 * @since 0.0.1
212 */
213 public static function get_string_value( $data ) {
214 if ( is_scalar( $data ) ) {
215 return (string) $data;
216 }
217 if ( is_object( $data ) && method_exists( $data, '__toString' ) ) {
218 return $data->__toString();
219 }
220 if ( is_null( $data ) ) {
221 return '';
222 }
223 return '';
224 }
225
226 /**
227 * Checks if current value is number or else returns default value
228 *
229 * @param mixed $value data which need to be checked if is string.
230 * @param int $base value can be set is $data is not a string, defaults to empty string.
231 * @return int
232 * @since 0.0.1
233 */
234 public static function get_integer_value( $value, $base = 10 ) {
235 if ( is_numeric( $value ) ) {
236 return (int) $value;
237 }
238 if ( is_string( $value ) ) {
239 $trimmed_value = trim( $value );
240 return intval( $trimmed_value, $base );
241 }
242 return 0;
243 }
244
245 /**
246 * Safely converts a mixed value to float
247 *
248 * @param mixed $value The value to convert.
249 * @param float $default_value Default value if conversion fails.
250 * @return float
251 * @since 0.0.1
252 */
253 public static function get_float_value( $value, $default_value = 0.0 ) {
254 if ( is_numeric( $value ) ) {
255 return (float) $value;
256 }
257 return $default_value;
258 }
259
260 /**
261 * Safely get array value with type checking
262 *
263 * @param mixed $value The value to check.
264 * @param array<string, mixed> $default_value Default value if not an array.
265 * @return array<string, mixed>
266 * @since 0.0.1
267 */
268 public static function get_array_value( $value, $default_value = [] ) {
269 return is_array( $value ) ? $value : $default_value;
270 }
271
272 /**
273 * Check if current user has required capability.
274 *
275 * @param string $capability Capability to check (default: 'manage_options').
276 * @param array<mixed> $args Additional arguments for capability check.
277 * @return bool True if user has capability.
278 * @since 0.0.1
279 */
280 public static function current_user_can( $capability = '', $args = [] ) {
281 if ( ! function_exists( 'current_user_can' ) ) {
282 return false;
283 }
284
285 if ( ! is_string( $capability ) || empty( $capability ) ) {
286 $capability = 'manage_options';
287 }
288
289 return ! empty( $args )
290 ? current_user_can( $capability, ...$args )
291 : current_user_can( $capability );
292 }
293
294 /**
295 * Join an array of strings into a single string, filtering out empty values.
296 *
297 * @param array<string> $strings Array of strings to join.
298 * @param string $glue Separator to use (default: ' ').
299 * @return string Joined string.
300 * @since 0.0.1
301 */
302 public static function join_strings( $strings, $glue = ' ' ) {
303 if ( ! is_array( $strings ) ) {
304 return '';
305 }
306
307 $filtered = array_filter(
308 $strings,
309 static function ( $item ) {
310 return is_string( $item ) && '' !== trim( $item );
311 }
312 );
313
314 return implode( $glue, array_map( 'trim', $filtered ) );
315 }
316
317 /**
318 * Process blocks to generate unique slugs for SureDonation blocks.
319 *
320 * Recursively processes all blocks and generates slugs for those that
321 * don't have one set. Ensures all slugs are unique within the form.
322 *
323 * @param array<mixed> $blocks The blocks to process.
324 * @param array<string> $slugs Array of existing slugs (keyed by block_id).
325 * @param bool $updated Whether any blocks were updated.
326 * @param string $prefix Optional prefix for nested blocks.
327 * @return array{0: array<mixed>, 1: array<string>, 2: bool} Processed blocks, slugs, and updated flag.
328 * @since 0.0.1
329 */
330 public static function process_blocks( $blocks, $slugs = [], $updated = false, $prefix = '' ) {
331 if ( ! is_array( $blocks ) ) {
332 return [ [], $slugs, $updated ];
333 }
334 foreach ( $blocks as $index => $block ) {
335 if ( ! is_array( $block ) ) {
336 continue;
337 }
338 // Skip non-SureDonation blocks.
339 if ( ! isset( $block['blockName'] ) || ! is_string( $block['blockName'] ) || strpos( $block['blockName'], 'suredonation/' ) !== 0 ) {
340 // Process inner blocks if any.
341 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
342 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $prefix );
343 }
344 continue;
345 }
346
347 // Skip if no attrs or slug is already set and block_id is in slugs array.
348 if (
349 ! isset( $block['attrs'] ) ||
350 ! is_array( $block['attrs'] ) ||
351 (
352 ! empty( $block['attrs']['slug'] ) &&
353 isset( $block['attrs']['block_id'] ) &&
354 isset( $slugs[ $block['attrs']['block_id'] ] )
355 )
356 ) {
357 // Process inner blocks if any.
358 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
359 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $prefix );
360 }
361 continue;
362 }
363
364 // Generate slug if empty.
365 if ( empty( $block['attrs']['slug'] ) ) {
366 $blocks[ $index ]['attrs']['slug'] = self::generate_unique_block_slug( $block, $slugs, $prefix );
367 $updated = true;
368 }
369
370 // Track the slug if block_id is set.
371 if ( isset( $block['attrs']['block_id'] ) ) {
372 $slugs[ $block['attrs']['block_id'] ] = $blocks[ $index ]['attrs']['slug'];
373 }
374
375 // Process inner blocks recursively.
376 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
377 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks(
378 $block['innerBlocks'],
379 $slugs,
380 $updated,
381 $blocks[ $index ]['attrs']['slug']
382 );
383 }
384 }
385
386 return [ $blocks, $slugs, $updated ];
387 }
388
389 /**
390 * Generates a unique slug based on the provided block and existing slugs.
391 *
392 * @param array<mixed> $block The block data.
393 * @param array<string> $slugs The array of existing slugs.
394 * @param string $prefix Optional prefix for nested blocks.
395 * @return string The generated unique block slug.
396 * @since 0.0.1
397 */
398 public static function generate_unique_block_slug( $block, $slugs, $prefix = '' ) {
399 $slug = is_string( $block['blockName'] ?? '' ) ? str_replace( 'suredonation/', '', $block['blockName'] ) : '';
400
401 // Use label if available.
402 if ( ! empty( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
403 $slug = sanitize_title( $block['attrs']['label'] );
404 }
405
406 // Add prefix for nested blocks.
407 if ( ! empty( $prefix ) ) {
408 $slug = $prefix . '-' . $slug;
409 }
410
411 return self::generate_unique_slug( $slug, $slugs );
412 }
413
414 /**
415 * Ensures that the slug is unique.
416 *
417 * If the slug is already taken, it appends a number to make it unique.
418 *
419 * @param string $slug The slug to make unique.
420 * @param array<string> $slugs Array of existing slugs.
421 * @return string The unique slug.
422 * @since 0.0.1
423 */
424 public static function generate_unique_slug( $slug, $slugs ) {
425 $slug = sanitize_title( $slug );
426
427 // Check if slug exists in the array values.
428 if ( ! in_array( $slug, $slugs, true ) ) {
429 return $slug;
430 }
431
432 // Append a number to make it unique.
433 $index = 1;
434 while ( in_array( $slug . '-' . $index, $slugs, true ) ) {
435 ++$index;
436 }
437
438 return $slug . '-' . $index;
439 }
440
441 /**
442 * Get client IP address for logging purposes.
443 *
444 * Checks forwarded headers first (for proxied/load-balanced environments)
445 * then falls back to REMOTE_ADDR. This is suitable for informational
446 * logging only — do NOT use for security-critical IP validation.
447 *
448 * @return string Client IP address.
449 * @since 0.0.1
450 */
451 public static function get_client_ip() {
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'] ) ) : '';
455
456 if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
457 return $ip;
458 }
459
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;
481 }
482
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;
492 }
493
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 /**
508 * Get allowed HTML tags for form markup.
509 *
510 * The wp_kses_post() doesn't allow form elements, so we need a custom allowed tags array.
511 * This is safe because the markup is generated internally by trusted code that already
512 * escapes user input with esc_attr(), esc_html(), etc.
513 *
514 * @return array<string, array<string, bool>> Allowed HTML tags and attributes.
515 * @since 0.0.1
516 */
517 public static function get_allowed_form_html() {
518 // Note: data-* wildcard doesn't work in wp_kses, so we list each data attribute explicitly.
519 $common_data_attrs = [
520 'data-block-id' => true,
521 'data-form-id' => true,
522 'data-gateway' => true,
523 'data-stripe-key' => true,
524 'data-currency' => true,
525 'data-payment-mode' => true,
526 'data-amount-type' => true,
527 'data-fixed-amount' => true,
528 'data-payment-type' => true,
529 'data-customer-name-field' => true,
530 'data-customer-email-field' => true,
531 'data-nonce' => true,
532 'data-variable-amount-field' => true,
533 'data-minimum-amount' => true,
534 'data-subscription-plan-name' => true,
535 'data-subscription-interval' => true,
536 'data-subscription-billing-cycles' => true,
537 'data-currency-symbol' => true,
538 'data-message-format' => true,
539 'data-payment-methods' => true,
540 'data-method' => true,
541 'data-slug' => true,
542 'data-required' => true,
543 'data-fee-percentage' => true,
544 'data-fee-fixed' => true,
545 'data-fee-mode' => true,
546 'data-gateway-fees' => true,
547 'data-invalid-email-msg' => true,
548 'data-sd-mask' => true,
549 'data-custom-sd-mask' => true,
550 ];
551
552 return [
553 'div' => array_merge(
554 [
555 'id' => true,
556 'class' => true,
557 'style' => true,
558 'role' => true,
559 'aria-live' => true,
560 'aria-atomic' => true,
561 'aria-labelledby' => true,
562 ],
563 $common_data_attrs
564 ),
565 'form' => array_merge(
566 [
567 'id' => true,
568 'class' => true,
569 'method' => true,
570 'action' => true,
571 ],
572 $common_data_attrs
573 ),
574 'fieldset' => [
575 'id' => true,
576 'class' => true,
577 ],
578 'legend' => [
579 'id' => true,
580 'class' => true,
581 ],
582 'label' => [
583 'id' => true,
584 'class' => true,
585 'for' => true,
586 ],
587 'input' => array_merge(
588 [
589 'id' => true,
590 'class' => true,
591 'type' => true,
592 'name' => true,
593 'value' => true,
594 'placeholder' => true,
595 'min' => true,
596 'max' => true,
597 'step' => true,
598 'maxlength' => true,
599 'checked' => true,
600 'disabled' => true,
601 'readonly' => true,
602 'required' => true,
603 'aria-describedby' => true,
604 'aria-required' => true,
605 'aria-hidden' => true,
606 ],
607 $common_data_attrs
608 ),
609 'button' => array_merge(
610 [
611 'id' => true,
612 'class' => true,
613 'type' => true,
614 'disabled' => true,
615 ],
616 $common_data_attrs
617 ),
618 'select' => array_merge(
619 [
620 'id' => true,
621 'class' => true,
622 'name' => true,
623 'disabled' => true,
624 'required' => true,
625 'aria-describedby' => true,
626 'aria-required' => true,
627 ],
628 $common_data_attrs
629 ),
630 'option' => [
631 'value' => true,
632 'selected' => true,
633 'disabled' => true,
634 ],
635 'textarea' => array_merge(
636 [
637 'id' => true,
638 'class' => true,
639 'name' => true,
640 'rows' => true,
641 'cols' => true,
642 'placeholder' => true,
643 'maxlength' => true,
644 'disabled' => true,
645 'readonly' => true,
646 'required' => true,
647 'aria-describedby' => true,
648 'aria-required' => true,
649 ],
650 $common_data_attrs
651 ),
652 'span' => array_merge(
653 [
654 'id' => true,
655 'class' => true,
656 'style' => true,
657 'aria-hidden' => true,
658 ],
659 $common_data_attrs
660 ),
661 'p' => [
662 'id' => true,
663 'class' => true,
664 'style' => true,
665 'role' => true,
666 ],
667 'a' => [
668 'id' => true,
669 'class' => true,
670 'href' => true,
671 'target' => true,
672 'rel' => true,
673 'style' => true,
674 ],
675 'strong' => [
676 'class' => true,
677 ],
678 'em' => [
679 'class' => true,
680 ],
681 'ol' => [
682 'class' => true,
683 ],
684 'ul' => [
685 'class' => true,
686 ],
687 'li' => [
688 'class' => true,
689 ],
690 'br' => [],
691 'svg' => [
692 'class' => true,
693 'width' => true,
694 'height' => true,
695 'viewbox' => true,
696 'fill' => true,
697 'xmlns' => true,
698 'aria-hidden' => true,
699 ],
700 'circle' => [
701 'cx' => true,
702 'cy' => true,
703 'r' => true,
704 'stroke' => true,
705 'stroke-width' => true,
706 'fill' => true,
707 ],
708 'rect' => [
709 'x' => true,
710 'y' => true,
711 'width' => true,
712 'height' => true,
713 'rx' => true,
714 'stroke' => true,
715 'stroke-width' => true,
716 ],
717 'path' => [
718 'class' => true,
719 'd' => true,
720 'stroke' => true,
721 'stroke-width' => true,
722 'stroke-linecap' => true,
723 'stroke-linejoin' => true,
724 'fill' => true,
725 ],
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 );
1226 }
1227 }
1228