PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
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 trunk, at inc/helper.php

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