| @@ -9,8 +9,9 @@ | ||
| 9 | 9 | |
| 10 | 10 | use SureDonation\Inc\API\Settings_API; |
| 11 | 11 | use SureDonation\Inc\Database\Tables\Donations; |
| 12 | 12 | use SureDonation\Inc\Emails\Email_Handler; |
| 13 | +use SureDonation\Inc\Field_Validation; | |
| 13 | 14 | use SureDonation\Inc\Payments\Payment_Helper; |
| 14 | 15 | |
| 15 | 16 | // Exit if accessed directly. |
| 16 | 17 | if ( ! defined( 'ABSPATH' ) ) { |
| @@ -217,9 +218,9 @@ | ||
| 217 | 218 | * |
| 218 | 219 | * @param array<int|string, mixed> $blocks Parsed blocks (parse_blocks output). |
| 219 | 220 | * @param string $target Block name to look for. |
| 220 | 221 | * @return bool |
| 221 | - * @since 1.4.0 | |
| 222 | + * @since 1.5.1 | |
| 222 | 223 | */ |
| 223 | 224 | public static function block_tree_contains( $blocks, $target ) { |
| 224 | 225 | foreach ( $blocks as $block ) { |
| 225 | 226 | if ( ! is_array( $block ) ) { |
| @@ -574,54 +575,66 @@ | ||
| 574 | 575 | */ |
| 575 | 576 | public static function get_allowed_form_html() { |
| 576 | 577 | // Note: data-* wildcard doesn't work in wp_kses, so we list each data attribute explicitly. |
| 577 | 578 | $common_data_attrs = [ |
| 578 | - 'data-block-id' => true, | |
| 579 | - 'data-form-id' => true, | |
| 580 | - 'data-gateway' => true, | |
| 581 | - 'data-stripe-key' => true, | |
| 582 | - 'data-currency' => true, | |
| 583 | - 'data-payment-mode' => true, | |
| 584 | - 'data-amount-type' => true, | |
| 585 | - 'data-fixed-amount' => true, | |
| 586 | - 'data-payment-type' => true, | |
| 587 | - 'data-customer-name-field' => true, | |
| 588 | - 'data-customer-email-field' => true, | |
| 589 | - 'data-nonce' => true, | |
| 590 | - 'data-variable-amount-field' => true, | |
| 591 | - 'data-minimum-amount' => true, | |
| 592 | - 'data-subscription-plan-name' => true, | |
| 593 | - 'data-subscription-interval' => true, | |
| 594 | - 'data-subscription-billing-cycles' => true, | |
| 595 | - 'data-currency-symbol' => true, | |
| 596 | - 'data-message-format' => true, | |
| 597 | - 'data-payment-methods' => true, | |
| 598 | - 'data-payment-available' => true, | |
| 599 | - 'data-method' => true, | |
| 600 | - 'data-slug' => true, | |
| 601 | - 'data-required' => true, | |
| 602 | - 'data-fee-percentage' => true, | |
| 603 | - 'data-fee-fixed' => true, | |
| 604 | - 'data-fee-mode' => true, | |
| 605 | - 'data-gateway-fees' => true, | |
| 606 | - 'data-invalid-email-msg' => true, | |
| 607 | - 'data-invalid-url-msg' => true, | |
| 608 | - 'data-sd-mask' => true, | |
| 609 | - 'data-custom-sd-mask' => true, | |
| 579 | + 'data-block-id' => true, | |
| 580 | + 'data-form-id' => true, | |
| 581 | + 'data-gateway' => true, | |
| 582 | + 'data-stripe-key' => true, | |
| 583 | + 'data-currency' => true, | |
| 584 | + 'data-payment-mode' => true, | |
| 585 | + 'data-amount-type' => true, | |
| 586 | + 'data-fixed-amount' => true, | |
| 587 | + 'data-payment-type' => true, | |
| 588 | + 'data-customer-name-field' => true, | |
| 589 | + 'data-customer-email-field' => true, | |
| 590 | + 'data-nonce' => true, | |
| 591 | + 'data-variable-amount-field' => true, | |
| 592 | + 'data-minimum-amount' => true, | |
| 593 | + 'data-subscription-plan-name' => true, | |
| 594 | + 'data-subscription-interval' => true, | |
| 595 | + 'data-subscription-billing-cycles' => true, | |
| 596 | + // Dual-mode ("both") payment block: per-choice amount configuration read by | |
| 597 | + // the chooser when the donor switches between one-time and recurring. | |
| 598 | + 'data-original-payment-type' => true, | |
| 599 | + 'data-default-payment-choice' => true, | |
| 600 | + 'data-one-time-amount-type' => true, | |
| 601 | + 'data-one-time-fixed-amount' => true, | |
| 602 | + 'data-one-time-minimum-amount' => true, | |
| 603 | + 'data-one-time-variable-amount-field' => true, | |
| 604 | + 'data-subscription-amount-type' => true, | |
| 605 | + 'data-subscription-fixed-amount' => true, | |
| 606 | + 'data-subscription-minimum-amount' => true, | |
| 607 | + 'data-subscription-variable-amount-field' => true, | |
| 608 | + 'data-currency-symbol' => true, | |
| 609 | + 'data-message-format' => true, | |
| 610 | + 'data-payment-methods' => true, | |
| 611 | + 'data-payment-available' => true, | |
| 612 | + 'data-method' => true, | |
| 613 | + 'data-slug' => true, | |
| 614 | + 'data-required' => true, | |
| 615 | + 'data-fee-percentage' => true, | |
| 616 | + 'data-fee-fixed' => true, | |
| 617 | + 'data-fee-mode' => true, | |
| 618 | + 'data-gateway-fees' => true, | |
| 619 | + 'data-invalid-email-msg' => true, | |
| 620 | + 'data-invalid-url-msg' => true, | |
| 621 | + 'data-sd-mask' => true, | |
| 622 | + 'data-custom-sd-mask' => true, | |
| 610 | 623 | // Dropdown (tom-select) field. |
| 611 | - 'data-multiple' => true, | |
| 612 | - 'data-searchable' => true, | |
| 613 | - 'data-preselected' => true, | |
| 614 | - 'data-min-selection' => true, | |
| 615 | - 'data-max-selection' => true, | |
| 616 | - 'data-placeholder' => true, | |
| 624 | + 'data-multiple' => true, | |
| 625 | + 'data-searchable' => true, | |
| 626 | + 'data-preselected' => true, | |
| 627 | + 'data-min-selection' => true, | |
| 628 | + 'data-max-selection' => true, | |
| 629 | + 'data-placeholder' => true, | |
| 617 | 630 | // Phone (intl-tel-input) field. |
| 618 | - 'data-default-country' => true, | |
| 619 | - 'data-auto-country' => true, | |
| 620 | - 'data-enable-country-filter' => true, | |
| 621 | - 'data-country-filter-type' => true, | |
| 622 | - 'data-include-countries' => true, | |
| 623 | - 'data-exclude-countries' => true, | |
| 631 | + 'data-default-country' => true, | |
| 632 | + 'data-auto-country' => true, | |
| 633 | + 'data-enable-country-filter' => true, | |
| 634 | + 'data-country-filter-type' => true, | |
| 635 | + 'data-include-countries' => true, | |
| 636 | + 'data-exclude-countries' => true, | |
| 624 | 637 | ]; |
| 625 | 638 | |
| 626 | 639 | $allowed = [ |
| 627 | 640 | 'div' => array_merge( |
| @@ -634,8 +647,13 @@ | ||
| 634 | 647 | 'aria-live' => true, |
| 635 | 648 | 'aria-atomic' => true, |
| 636 | 649 | 'aria-hidden' => true, |
| 637 | 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, | |
| 638 | 656 | ], |
| 639 | 657 | $common_data_attrs |
| 640 | 658 | ), |
| 641 | 659 | 'form' => array_merge( |
| @@ -681,8 +699,10 @@ | ||
| 681 | 699 | 'inputmode' => true, |
| 682 | 700 | 'aria-describedby' => true, |
| 683 | 701 | 'aria-required' => true, |
| 684 | 702 | 'aria-hidden' => true, |
| 703 | + // Payment-type chooser radios point at the amount panel they reveal. | |
| 704 | + 'aria-controls' => true, | |
| 685 | 705 | ], |
| 686 | 706 | $common_data_attrs |
| 687 | 707 | ), |
| 688 | 708 | 'button' => array_merge( |
| @@ -925,23 +945,36 @@ | ||
| 925 | 945 | ? $data['message'] |
| 926 | 946 | : esc_html__( 'Thank you for your donation!', 'suredonation' ); |
| 927 | 947 | |
| 928 | 948 | return [ |
| 929 | - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), | |
| 930 | - 'confirmationType' => $confirmation_type, | |
| 931 | - 'successTitle' => esc_html__( 'Thank You!', 'suredonation' ), | |
| 932 | - 'successMessage' => wp_kses_post( self::get_string_value( $success_message ) ), | |
| 949 | + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), | |
| 950 | + 'confirmationType' => $confirmation_type, | |
| 951 | + 'successTitle' => esc_html__( 'Thank You!', 'suredonation' ), | |
| 952 | + 'successMessage' => wp_kses_post( self::get_string_value( $success_message ) ), | |
| 933 | 953 | // Shown when payment succeeded at the gateway but our server-side |
| 934 | 954 | // finalize did not complete; the webhook will finalize it, so the |
| 935 | 955 | // donor must not be prompted to pay again. |
| 936 | - 'processingMessage' => esc_html__( 'Payment received. We are finalizing your donation and will email you a confirmation shortly. Please do not pay again.', 'suredonation' ), | |
| 937 | - 'redirectUrl' => ! empty( $redirect_url ) ? esc_url( self::get_string_value( $redirect_url ) ) : '', | |
| 938 | - 'submissionAction' => $data['submission_action'], | |
| 956 | + 'processingMessage' => esc_html__( 'Payment received. We are finalizing your donation and will email you a confirmation shortly. Please do not pay again.', 'suredonation' ), | |
| 957 | + // Shown when the card form itself could not be rendered — almost | |
| 958 | + // always because the connected Stripe account is not allowed to | |
| 959 | + // charge cards. Deliberately says nothing about the account: the | |
| 960 | + // cause is the site's to fix, and the gateway's own wording would | |
| 961 | + // put its account state on a public page. | |
| 962 | + 'cardUnavailableMessage' => esc_html__( 'Card payments are unavailable right now. Please choose another payment method or contact the site owner.', 'suredonation' ), | |
| 963 | + 'redirectUrl' => ! empty( $redirect_url ) ? esc_url( self::get_string_value( $redirect_url ) ) : '', | |
| 964 | + 'submissionAction' => $data['submission_action'], | |
| 939 | 965 | // translators: %s: formatted fee amount with currency symbol. |
| 940 | - 'feeIncludesText' => __( '(includes %s processing fee)', 'suredonation' ), | |
| 941 | - 'amountPlaceholder' => __( 'Complete the form to view the amount.', 'suredonation' ), | |
| 966 | + 'feeIncludesText' => __( '(includes %s processing fee)', 'suredonation' ), | |
| 967 | + 'amountPlaceholder' => __( 'Complete the form to view the amount.', 'suredonation' ), | |
| 968 | + // Shown when a failed recurring confirmation forces the Stripe | |
| 969 | + // Payment Element to rebuild after switching to one-time — see | |
| 970 | + // StripeGateway.updatePaymentType(). Assigned via textContent | |
| 971 | + // (GatewayBase.showError()), which doesn't decode HTML entities, | |
| 972 | + // so this must not be esc_html__() or an apostrophe in | |
| 973 | + // translation would render as the literal "'". | |
| 974 | + 'reenterCardMessage' => __( 'Please re-enter your card details to continue.', 'suredonation' ), | |
| 942 | 975 | // Currency symbol placement for client-side amount/fee formatting. |
| 943 | - 'currencySignPosition' => Payment_Helper::get_currency_sign_position(), | |
| 976 | + 'currencySignPosition' => Payment_Helper::get_currency_sign_position(), | |
| 944 | 977 | ]; |
| 945 | 978 | } |
| 946 | 979 | |
| 947 | 980 | /** |
| @@ -1093,8 +1126,18 @@ | ||
| 1093 | 1126 | [ |
| 1094 | 1127 | 'tag' => '{refund_amount}', |
| 1095 | 1128 | 'title' => __( 'Refund Amount', 'suredonation' ), |
| 1096 | 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 | + ], | |
| 1097 | 1140 | ], |
| 1098 | 1141 | ], |
| 1099 | 1142 | [ |
| 1100 | 1143 | 'label' => __( 'General Tags', 'suredonation' ), |
| @@ -1167,9 +1210,9 @@ | ||
| 1167 | 1210 | * surface them, and free listed subscription tags that could never |
| 1168 | 1211 | * resolve for a free-only site. |
| 1169 | 1212 | * |
| 1170 | 1213 | * @param array<int, array<string, mixed>> $groups Grouped tag definitions. |
| 1171 | - * @since 1.4.0 | |
| 1214 | + * @since 1.5.1 | |
| 1172 | 1215 | */ |
| 1173 | 1216 | $grouped = apply_filters( 'suredonation_email_smart_tag_groups', $smart_tags['email_grouped'] ); |
| 1174 | 1217 | |
| 1175 | 1218 | // The filter feeds the editor's tag picker, which iterates groups and |
| @@ -1360,8 +1403,112 @@ | ||
| 1360 | 1403 | |
| 1361 | 1404 | return sprintf( |
| 1362 | 1405 | '<div class="sd-receipt-card"><h3 class="sd-receipt-card__title">%1$s</h3><div class="sd-receipt-rows">%2$s</div></div>', |
| 1363 | 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' ), | |
| 1364 | 1511 | $rows_html |
| 1365 | 1512 | ); |
| 1366 | 1513 | } |
| 1367 | 1514 | |