PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 All 97 releases
← All changes | admin/admin.php +1039 -180 2.12.62.12.7 View file →
@@ -88,8 +88,19 @@
88 88 */
89 89 public const THANKYOU_PROMPT_NOTICE_ID = 'srfm-thankyou-prompt';
90 90
91 91 /**
92 + * Where the dialog's Contact Support button goes.
93 + *
94 + * A form rather than an inbox: it collects the licence and site details support
95 + * would otherwise have to ask for, and the diagnostics are already on the
96 + * clipboard by the time someone gets here.
97 + *
98 + * @since 2.12.7
99 + */
100 + private const SUPPORT_CONTACT_URL = 'https://sureforms.com/form/troubleshooting-form/';
101 +
102 + /**
92 103 * Dashboard widget entries data.
93 104 *
94 105 * @var array
95 106 * @since 1.9.1
@@ -138,8 +149,25 @@
138 149 */
139 150 private static $setup_card_cache = [];
140 151
141 152 /**
153 + * Action items for this request, or null before the first build.
154 + *
155 + * Built twice on every admin page without this -- once for the localisation
156 + * payload, once in the classic renderer -- and each open failure category reads
157 + * a log excerpt. get_action_items() also records an impression, which running
158 + * twice counted twice.
159 + *
160 + * Reset with reset_action_items_cache(). Admin is a singleton, so without that
161 + * the first build pins the answer for the whole process and any test that
162 + * records a failure and then asks again is testing the memo.
163 + *
164 + * @var array<int,array<string,mixed>>|null
165 + * @since 2.12.7
166 + */
167 + private static $action_items_cache = null;
168 +
169 + /**
142 170 * Class constructor.
143 171 *
144 172 * @return void
145 173 * @since 0.0.1
@@ -175,8 +203,11 @@
175 203 // cannot be saved. Registered at admin_init priority 5 so the React notice is
176 204 // in place before admin_enqueue_scripts localizes it.
177 205 add_action( 'admin_init', [ $this, 'register_database_repair_notice' ], 5 );
178 206 add_action( 'admin_notices', [ $this, 'render_action_item_notices' ] );
207 + // Late priority so the items are built after anything hooking
208 + // srfm_action_items has had a chance to register.
209 + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_action_item_styles' ], 20 );
179 210 add_action( 'admin_notices', [ $this, 'render_database_repair_notice' ] );
180 211 add_action( 'admin_post_srfm_repair_entries_table', [ $this, 'handle_database_repair' ] );
181 212 // Display notices on traditional WordPress admin pages.
182 213 add_action( 'admin_notices', [ $this, 'srfm_pro_version_compatibility' ] );
@@ -201,8 +232,9 @@
201 232 add_action( 'wp_ajax_should_show_pointer', [ $this, 'pointer_should_show' ] );
202 233 add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] );
203 234 add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] );
204 235 add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] );
236 + add_action( 'wp_ajax_srfm_action_item_details', [ $this, 'handle_action_item_details' ] );
205 237 add_action( 'wp_ajax_srfm_dismiss_action_item', [ $this, 'handle_dismiss_action_item' ] );
206 238 add_action( 'admin_post_srfm_dismiss_action_item_link', [ $this, 'handle_dismiss_action_item_link' ] );
207 239 add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] );
208 240 add_action( 'load-post.php', [ $this, 'maybe_track_edit_form_button_click' ] );
@@ -447,8 +479,22 @@
447 479 return self::$thankyou_prompt_cache;
448 480 }
449 481
450 482 /**
483 + * Clear the request memo for the action items.
484 + *
485 + * Admin is a singleton, so the memo outlives a request in a test process.
486 + * Anything that records or clears a failure inside one process has to call
487 + * this, or it reads the answer from before the change.
488 + *
489 + * @since 2.12.7
490 + * @return void
491 + */
492 + public static function reset_action_items_cache() {
493 + self::$action_items_cache = null;
494 + }
495 +
496 + /**
451 497 * Clear the request memo for the Thank You prompt (#3030).
452 498 *
453 499 * Lets tests exercise the memoized public path, and is a safe hook for anything
454 500 * that changes which form qualifies (e.g. a form save).
@@ -1772,8 +1818,16 @@
1772 1818 'rotating_plugin_banner' => Helper::get_rotating_plugin_banner(),
1773 1819 'ajax_url' => admin_url( 'admin-ajax.php' ),
1774 1820 'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '',
1775 1821 'action_items' => $this->get_action_items(),
1822 + 'details_dialog' => $this->get_details_dialog_labels(),
1823 + // Where Contact Support goes when the details fetch fails and there is
1824 + // no category-tagged URL to use. Untagged, because at that point we do
1825 + // not know which check sent them -- but still a way out: these notices
1826 + // are not dismissible and Contact Support is the only action that
1827 + // retires them.
1828 + 'support_url' => $this->get_support_contact_url( '' ),
1829 + 'action_item_details_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_action_item_details' ) : '',
1776 1830 'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '',
1777 1831 'dismiss_action_item_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_dismiss_action_item' ) : '',
1778 1832 'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ),
1779 1833 'plugin_installer_nonce' => wp_create_nonce( 'updates' ),
@@ -2763,15 +2817,94 @@
2763 2817 wp_localize_script(
2764 2818 'srfm-notice-response',
2765 2819 'srfmNoticeResponse',
2766 2820 [
2767 - 'ajaxurl' => admin_url( 'admin-ajax.php' ),
2768 - 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
2821 + 'ajaxurl' => admin_url( 'admin-ajax.php' ),
2822 + 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
2823 + // The diagnostics are fetched when the dialog opens rather than
2824 + // shipped with every page, so the dialog needs its own nonce.
2825 + 'detailsNonce' => wp_create_nonce( 'srfm_action_item_details' ),
2826 + // Carousel chrome. Built in the browser rather than printed here so
2827 + // that with JavaScript off every notice simply stays visible, which
2828 + // is the behaviour this replaced -- controls that cannot work must
2829 + // not be what hides a warning.
2830 + 'carousel' => [
2831 + 'previous' => __( 'Previous notice', 'sureforms' ),
2832 + 'next' => __( 'Next notice', 'sureforms' ),
2833 + /* translators: 1: current position, 2: total notices. */
2834 + 'counter' => __( '%1$d of %2$d', 'sureforms' ),
2835 + ],
2836 + // Details modal chrome, translated here so the script carries no
2837 + // user-facing English of its own.
2838 + 'details' => $this->get_details_dialog_labels(),
2839 + // Where Contact Support goes when the fetch fails and there is no
2840 + // category-tagged URL to use. Untagged, because at that point we do
2841 + // not know which check sent them -- but still a way out: these
2842 + // notices are not dismissible and Contact Support is the only action
2843 + // that retires them.
2844 + 'supportUrl' => $this->get_support_contact_url( '' ),
2769 2845 ]
2770 2846 );
2771 2847 }
2772 2848
2773 2849 /**
2850 + * Serve one failure category's diagnostics, on demand.
2851 + *
2852 + * Hooked - wp_ajax_srfm_action_item_details.
2853 + *
2854 + * The report is built here rather than shipped with the page. Its content
2855 + * comes from the client error log, and that log is filled through a public
2856 + * REST route gated on a submit token any visitor can obtain from a form page
2857 + * rather than on a capability -- so the text is attacker-authored, and putting
2858 + * it in the localisation JSON and a hidden div on every admin screen exposed
2859 + * it far beyond the one admin who opens the dialog.
2860 + *
2861 + * Capability first, then nonce, then the category, matching the ordering of
2862 + * the sibling handlers in this class.
2863 + *
2864 + * @since 2.12.7
2865 + * @return void
2866 + */
2867 + public function handle_action_item_details() {
2868 + if ( ! Helper::current_user_can() ) {
2869 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
2870 + return;
2871 + }
2872 +
2873 + if ( ! check_ajax_referer( 'srfm_action_item_details', 'nonce', false ) ) {
2874 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
2875 + return;
2876 + }
2877 +
2878 + // sanitize_key() returns '' for anything non-scalar (formatting.php:2194), so
2879 + // a category[]= in the body arrives here as the empty string and falls into
2880 + // the refusal below rather than needing a type branch of its own.
2881 + $category = isset( $_POST['category'] ) ? sanitize_key( wp_unslash( $_POST['category'] ) ) : '';
2882 +
2883 + // The only check the category needs, and the reason there is no separate
2884 + // allowlist above it: get_open_failures() returns nothing but keys in
2885 + // Client_Logger::CATEGORIES, so an absent category, an unrecognised one and
2886 + // a recognised one with nothing wrong all land here. Asking for a category
2887 + // with no fault must not mint a report describing one.
2888 + $open = Client_Logger::get_open_failures();
2889 +
2890 + if ( ! isset( $open[ $category ] ) ) {
2891 + wp_send_json_error( [ 'message' => __( 'Nothing to report.', 'sureforms' ) ], 404 );
2892 + return;
2893 + }
2894 +
2895 + $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' );
2896 +
2897 + wp_send_json_success(
2898 + [
2899 + 'details' => $this->get_support_message( $category, $form_title )
2900 + . "\n\n" . $this->get_support_log_block( 8000 ),
2901 + 'support_url' => $this->get_support_contact_url( $category ),
2902 + ]
2903 + );
2904 + }
2905 +
2906 + /**
2774 2907 * Handle the notice response AJAX request.
2775 2908 *
2776 2909 * Validates the request and records the analytics event
2777 2910 * for the notice button that was clicked.
@@ -2813,16 +2946,23 @@
2813 2946 'dismissed' => 'database_error_notice_dismiss',
2814 2947 ],
2815 2948 // The "Finish setting up" prompt (#3030): three CTAs, plus the ✕.
2816 2949 'form_submission_error' => [
2950 + 'view_details' => 'submission_failure_notice_view',
2951 + 'copy_details' => 'submission_failure_notice_copy',
2817 2952 'contact_support' => 'submission_failure_notice_cta',
2818 2953 'dismissed' => 'submission_failure_notice_dismiss',
2819 2954 ],
2820 2955 'notification_error' => [
2956 + 'view_details' => 'notification_failure_notice_view',
2957 + 'copy_details' => 'notification_failure_notice_copy',
2821 2958 'contact_support' => 'notification_failure_notice_cta',
2959 + 'help_me_fix' => 'notification_failure_notice_guide',
2822 2960 'dismissed' => 'notification_failure_notice_dismiss',
2823 2961 ],
2824 2962 'integration_error' => [
2963 + 'view_details' => 'integration_failure_notice_view',
2964 + 'copy_details' => 'integration_failure_notice_copy',
2825 2965 'contact_support' => 'integration_failure_notice_cta',
2826 2966 'dismissed' => 'integration_failure_notice_dismiss',
2827 2967 ],
2828 2968 'caching_plugin' => [
@@ -2844,10 +2984,9 @@
2844 2984 // a side effect in a function elsewhere.
2845 2985 return;
2846 2986 }
2847 2987
2848 - $event_name = $valid[ $notice_id ][ $button ];
2849 - Analytics::events()->track( $event_name, $button );
2988 + $this->track_notice_event( $valid[ $notice_id ][ $button ] );
2850 2989
2851 2990 // Reporting the failures retires the notice until something new fails.
2852 2991 // Handled here rather than in the browser so it holds for the classic
2853 2992 // wp-admin notice too, which is a plain link with no JavaScript.
@@ -3409,11 +3548,29 @@
3409 3548 if ( Helper::validate_request_context( 'sureforms_menu', 'page' ) ) {
3410 3549 return;
3411 3550 }
3412 3551
3552 + $items = $this->get_action_items();
3553 +
3554 + // Only the faults reach this surface, so count those before deciding
3555 + // whether the carousel stylesheet is worth printing.
3556 + $rendered = 0;
3557 +
3558 + foreach ( $items as $item ) {
3559 + $status = Helper::get_string_value( $item['status'] ?? '' );
3560 +
3561 + if ( 'success' !== $status && '' !== $status ) {
3562 + $rendered++;
3563 + }
3564 + }
3565 +
3566 + if ( 0 === $rendered ) {
3567 + return;
3568 + }
3569 +
3413 3570 $this->enqueue_notice_response_script();
3414 3571
3415 - foreach ( $this->get_action_items() as $item ) {
3572 + foreach ( $items as $item ) {
3416 3573 $status = Helper::get_string_value( $item['status'] ?? '' );
3417 3574
3418 3575 // Passing checks belong in the SureForms panel, not in wp-admin. A
3419 3576 // notice that says nothing is wrong is noise on every page load.
@@ -3424,21 +3581,71 @@
3424 3581 // A fault reads as an error; advice reads as a warning. Both are shown,
3425 3582 // but they are not the same kind of message and should not look alike.
3426 3583 $class = 'error' === $status ? 'notice-error' : 'notice-warning';
3427 3584 ?>
3428 - <div class="notice <?php echo esc_attr( $class ); ?>">
3429 - <p><strong><?php echo esc_html( $item['title'] ); ?></strong></p>
3430 - <p><?php echo esc_html( $item['message'] ); ?></p>
3585 + <div class="notice srfm-action-item-notice <?php echo esc_attr( $class ); ?>">
3586 + <?php
3587 + /*
3588 + * Guarded like every sibling key. A filter item carrying only
3589 + * id/status/cta_* is a shape this surface designs for, and reading
3590 + * these unguarded is two PHP 8 undefined-key warnings plus an
3591 + * esc_html( null ) deprecation on 8.1+. React tolerates the absence,
3592 + * so leaving it would keep the two renderers disagreeing.
3593 + */
3594 + ?>
3595 + <p><strong><?php echo esc_html( Helper::get_string_value( $item['title'] ?? '' ) ); ?></strong></p>
3596 + <p><?php echo esc_html( Helper::get_string_value( $item['message'] ?? '' ) ); ?></p>
3597 + <?php
3598 + // Self-serve first, so the emphasis follows the order rather than the
3599 + // identity: whichever action leads is the primary button, and an item
3600 + // with no guide still leads with Contact Support.
3601 + $has_guide = ! empty( $item['guide_label'] ) && ! empty( $item['guide_url'] );
3602 +
3603 + // Both keys, not either. An item contributed through
3604 + // srfm_action_items may carry only guide_* keys -- reading cta_url
3605 + // unguarded emits two PHP 8 undefined-key warnings and renders
3606 + // href="" -- and a label without a URL renders an anchor that is not
3607 + // keyboard focusable. React gates on the same pair.
3608 + $has_cta = ! empty( $item['cta_label'] ) && ! empty( $item['cta_url'] );
3609 + ?>
3431 3610 <p>
3432 - <a
3433 - href="<?php echo esc_url( Helper::get_string_value( $item['cta_url'] ) ); ?>"
3434 - class="button button-primary"
3435 - data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>"
3436 - data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['cta_action'] ?? '' ) ); ?>"
3437 - <?php echo 0 === strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ? '' : 'target="_blank" rel="noopener noreferrer"'; ?>
3438 - >
3439 - <?php echo esc_html( $item['cta_label'] ); ?>
3440 - </a>
3611 + <?php if ( $has_guide ) { ?>
3612 + <a
3613 + href="<?php echo esc_url( Helper::get_string_value( $item['guide_url'] ) ); ?>"
3614 + class="button button-primary"
3615 + data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>"
3616 + data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['guide_action'] ?? '' ) ); ?>"
3617 + target="_blank"
3618 + rel="noopener noreferrer"
3619 + >
3620 + <?php echo esc_html( $item['guide_label'] ); ?>
3621 + </a>
3622 + <?php } ?>
3623 + <?php if ( $has_cta ) { ?>
3624 + <a
3625 + href="<?php echo esc_url( Helper::get_string_value( $item['cta_url'] ) ); ?>"
3626 + class="<?php echo $has_guide ? 'button' : 'button button-primary'; ?>"
3627 + data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>"
3628 + data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['cta_action'] ?? '' ) ); ?>"
3629 + <?php
3630 + // With details to fetch, the click opens them here instead
3631 + // of following the href. The href stays as the no-JS
3632 + // path: it goes to the dashboard, where the same details
3633 + // are readable.
3634 + if ( ! empty( $item['has_details'] ) ) {
3635 + printf(
3636 + 'data-srfm-details-for="%1$s" data-srfm-category="%2$s"',
3637 + esc_attr( Helper::get_string_value( $item['id'] ) ),
3638 + esc_attr( Helper::get_string_value( $item['category'] ?? '' ) )
3639 + );
3640 + } elseif ( 0 !== strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ) {
3641 + echo 'target="_blank" rel="noopener noreferrer"';
3642 + }
3643 + ?>
3644 + >
3645 + <?php echo esc_html( $item['cta_label'] ); ?>
3646 + </a>
3647 + <?php } ?>
3441 3648 <?php if ( ! empty( $item['dismissible'] ) ) { ?>
3442 3649 <a href="<?php echo esc_url( $this->get_dismiss_action_item_url( Helper::get_string_value( $item['id'] ) ) ); ?>" class="button">
3443 3650 <?php esc_html_e( 'Dismiss', 'sureforms' ); ?>
3444 3651 </a>
@@ -3474,29 +3681,59 @@
3474 3681 exit;
3475 3682 }
3476 3683
3477 3684 /**
3478 - * Whether anything is currently wrong enough to warrant a notice.
3685 + * Whether a first-party warning is currently on screen.
3479 3686 *
3480 - * Deliberately re-derives the two conditions rather than calling
3481 - * get_action_items(), which records an impression as a side effect and must not
3482 - * run from a show_if callback.
3687 + * Asked from the show_if of the rating, Getting Started and Thank You notices,
3688 + * all of which are gated on nothing being wrong. "Wrong" has to mean the same
3689 + * thing here as it does to the person looking at the screen.
3483 3690 *
3691 + * It used to re-state the conditions instead of reading them, and the
3692 + * restatement was narrower than the display: has_persistent_failures() reads
3693 + * the `submission` counter alone, while the notices and the Form Checks panel
3694 + * warn on any open failure in any of the three categories. So an open
3695 + * notification or integration failure left this false, and the review ask
3696 + * appeared directly beneath "We noticed a notification failure on Contact
3697 + * Form". Submission was covered only incidentally, by FAULT_THRESHOLD being 1 --
3698 + * raise that and it would have joined them.
3699 + *
3700 + * Derived from get_first_party_action_items() now, which is the thing that
3701 + * builds those warnings, so the gate cannot drift from the display again.
3702 + *
3703 + * Two constraints kept from the previous version. It must not call
3704 + * get_action_items(): that records an impression as a side effect and must
3705 + * never run from a show_if. And it reads the first-party set specifically, so
3706 + * an item contributed through `srfm_action_items` cannot suppress notices that
3707 + * have nothing to do with it.
3708 + *
3709 + * Returns false with logging disabled, which is what makes those notices
3710 + * eligible again on a site that has turned this surface off. Intended: with the
3711 + * surface off there is nothing being reported.
3712 + *
3484 3713 * @since 2.12.6
3485 3714 * @return bool
3486 3715 */
3487 3716 public function has_action_item_warnings() {
3488 - if ( Client_Logger::has_persistent_failures() ) {
3489 - return true;
3717 + if ( ! Client_Logger::is_enabled() ) {
3718 + return false;
3490 3719 }
3491 3720
3492 - if ( '' === Helper::get_active_caching_plugin() ) {
3493 - return false;
3721 + foreach ( $this->get_first_party_action_items() as $item ) {
3722 + if ( ! is_array( $item ) ) {
3723 + continue;
3724 + }
3725 +
3726 + $status = Helper::get_string_value( $item['status'] ?? '' );
3727 +
3728 + // Matches the renderers: 'success' is a passing check and an empty
3729 + // status is not a warning either, so neither suppresses anything.
3730 + if ( 'success' !== $status && '' !== $status ) {
3731 + return true;
3732 + }
3494 3733 }
3495 3734
3496 - $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
3497 -
3498 - return ! in_array( 'caching_plugin', $dismissed, true );
3735 + return false;
3499 3736 }
3500 3737
3501 3738 /**
3502 3739 * Things on this site that need the owner's attention, newest concern first.
@@ -3516,14 +3753,370 @@
3516 3753 if ( ! Helper::current_user_can() ) {
3517 3754 return [];
3518 3755 }
3519 3756
3520 - $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
3521 - $warnings = [];
3522 - $passing = [];
3757 + // Memoised for the request. This runs twice on every admin page -- once
3758 + // building the localisation payload and once in the classic renderer -- and
3759 + // each open category reads a log excerpt. It also records an impression, so
3760 + // running twice counted twice. Matches the $thankyou_prompt_cache and
3761 + // $setup_card_cache pattern already in this class.
3762 + if ( null !== self::$action_items_cache ) {
3763 + return self::$action_items_cache;
3764 + }
3523 3765
3524 - $open = Client_Logger::get_open_failures();
3766 + // Logging off is the opt-out for this surface. Not because the counters go
3767 + // stale -- Client_Logger::record_failure() has no enabled check, and the
3768 + // notification and integration categories are written by direct calls in
3769 + // inc/form-submit.php that keep counting accurately with logging off. It is
3770 + // simply the switch a site owner has to turn these notices off, and it
3771 + // covers our own items only: the filter below still runs, because a third
3772 + // party's advisory has nothing to do with SureForms' logging toggle.
3773 + $warnings = [];
3525 3774
3775 + if ( Client_Logger::is_enabled() ) {
3776 + $warnings = $this->get_first_party_action_items();
3777 + }
3778 +
3779 + $this->track_action_item_impressions( $warnings );
3780 +
3781 + /**
3782 + * Filter the dashboard action items.
3783 + *
3784 + * Each entry needs id, status ('warning' or 'success'), title, message,
3785 + * cta_label, cta_url and dismissible. Only ids in
3786 + * handle_dismiss_action_item()'s allowlist can actually be dismissed, so
3787 + * adding a dismissible item here also needs a line there.
3788 + *
3789 + * The details dialog is not available here: it is served by
3790 + * handle_action_item_details(), which reads SureForms' own client error log
3791 + * and knows nothing about a third-party item. Such an item's cta_url is
3792 + * followed as a link, which is what it does with JavaScript off anyway.
3793 + *
3794 + * @since 2.12.6
3795 + *
3796 + * @param array<int,array<string,mixed>> $items Action items.
3797 + */
3798 + $items = Helper::apply_filters_as_array( 'srfm_action_items', $warnings );
3799 +
3800 + // Both URLs normalised once, here, rather than trusting each renderer to do
3801 + // it. Two things are being fixed at once.
3802 + //
3803 + // The scheme: the classic notice runs esc_url() and drops anything outside
3804 + // the allowlist, while React assigns href directly and react-dom 18 leaves
3805 + // a javascript: URL intact -- its sanitizeURL() only warns, and the warning
3806 + // is compiled out of the production build. esc_url_raw() with the same
3807 + // allowlist closes both.
3808 + //
3809 + // The ampersands: Helper::get_sureforms_website_url() returns an esc_url()'d
3810 + // string, so a URL with UTM parameters arrives with &#038; in it. In an HTML
3811 + // href the browser decodes that; React sets the property directly, so the
3812 + // entity would be sent to the server verbatim. Decoded to one raw form here,
3813 + // and each renderer escapes it for its own context.
3814 + foreach ( $items as $index => $item ) {
3815 + // A filter may hand back an object. isset() on it returns false, which
3816 + // would slip the item past both the URL normalisation and the
3817 + // sanitize_key() below without any sign that it had.
3818 + if ( ! is_array( $item ) ) {
3819 + continue;
3820 + }
3821 +
3822 + foreach ( [ 'cta_url', 'guide_url' ] as $key ) {
3823 + if ( ! isset( $item[ $key ] ) ) {
3824 + continue;
3825 + }
3826 +
3827 + $items[ $index ][ $key ] = esc_url_raw(
3828 + wp_specialchars_decode( Helper::get_string_value( $item[ $key ] ), ENT_QUOTES ),
3829 + [ 'http', 'https', 'mailto' ]
3830 + );
3831 + }
3832 +
3833 + // The id ends up in a data attribute the dialog matches on with an
3834 + // attribute selector, and in the dismiss allowlist. sanitize_key() is
3835 + // what both dismiss paths already apply, so applying it once here means
3836 + // the value that renders is the value they compare against -- and a
3837 + // filter-contributed id carrying a quote cannot break the selector.
3838 + if ( isset( $item['id'] ) ) {
3839 + $items[ $index ]['id'] = sanitize_key( Helper::get_string_value( $item['id'] ) );
3840 + }
3841 + }
3842 +
3843 + self::$action_items_cache = $items;
3844 +
3845 + return $items;
3846 + }
3847 +
3848 + /**
3849 + * Dismiss one action item.
3850 + *
3851 + * Hooked - wp_ajax_srfm_dismiss_action_item.
3852 + *
3853 + * Only items get_action_items() marks dismissible can be dismissed, so a
3854 + * crafted request cannot silence a genuine fault.
3855 + *
3856 + * @since 2.12.6
3857 + * @return void
3858 + */
3859 + public function handle_dismiss_action_item() {
3860 + if ( ! Helper::current_user_can() ) {
3861 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3862 + return;
3863 + }
3864 +
3865 + if ( ! check_ajax_referer( 'srfm_dismiss_action_item', 'nonce', false ) ) {
3866 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3867 + return;
3868 + }
3869 +
3870 + $item_id = isset( $_POST['item_id'] ) ? sanitize_key( wp_unslash( $_POST['item_id'] ) ) : '';
3871 +
3872 + if ( ! $this->dismiss_action_item( $item_id ) ) {
3873 + wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 );
3874 + return;
3875 + }
3876 +
3877 + wp_send_json_success();
3878 + }
3879 +
3880 + /**
3881 + * The stylesheet for the notice carousel and the details dialog.
3882 + *
3883 + * In a stylesheet rather than inline style assignments in
3884 + * notice-response.js, so the rules use logical properties, an RTL sheet can
3885 + * override them, and a site can restyle the dialog without patching a script.
3886 + *
3887 + * Only the classic wp-admin surface needs these. The SureForms dashboard's
3888 + * dialog is force-ui's, styled by the Tailwind build, so nothing here reaches
3889 + * it -- the two surfaces share their strings, not their markup.
3890 + *
3891 + * Attached to a registered handle with no file of its own, which is the WP way
3892 + * to ship CSS tied to one script.
3893 + *
3894 + * Hooked to admin_enqueue_scripts rather than called from the renderer.
3895 + * admin_notices fires from admin-header.php after admin_print_styles has
3896 + * flushed the head, so enqueuing there reached the page only through core's
3897 + * late-styles pass in the footer -- and until that parsed, every stacked notice
3898 + * rendered expanded before collapsing to one, the carousel controls overlapped
3899 + * the notice text, and the defensive `display: none` on the hidden payload was
3900 + * inert, which is the exact window that rule exists for.
3901 + *
3902 + * The buttons are painted explicitly. They carry core's `button` classes for
3903 + * their shape and focus behaviour, and core paints those with
3904 + * `var(--wp-admin-theme-color)` -- so without this the dialog renders in
3905 + * whichever admin colour scheme the user picked, which on a default install is
3906 + * blue, on a SureForms panel that is otherwise entirely brand orange. Same
3907 + * approach and same values as print_srfm_notice_styles().
3908 + *
3909 + * @since 2.12.7
3910 + * @return void
3911 + */
3912 + public function enqueue_action_item_styles() {
3913 + if ( wp_style_is( 'srfm-action-items', 'enqueued' ) ) {
3914 + return;
3915 + }
3916 +
3917 + if ( ! Helper::current_user_can() ) {
3918 + return;
3919 + }
3920 +
3921 + // Nothing to style unless the carousel is actually going to build. Cheap to
3922 + // ask: get_action_items() is memoised for the request.
3923 + //
3924 + // Two, not one: notice-response.js bails below two cards, so these rules
3925 + // have no consumer on a site with a single open fault.
3926 + $notices = 0;
3927 +
3928 + foreach ( $this->get_action_items() as $item ) {
3929 + $status = Helper::get_string_value( is_array( $item ) ? $item['status'] ?? '' : '' );
3930 +
3931 + if ( 'success' !== $status && '' !== $status ) {
3932 + $notices++;
3933 + }
3934 + }
3935 +
3936 + if ( $notices < 2 ) {
3937 + return;
3938 + }
3939 +
3940 + wp_register_style( 'srfm-action-items', false, [], SRFM_VER );
3941 + wp_enqueue_style( 'srfm-action-items' );
3942 +
3943 + $css = <<<'CSS'
3944 +.srfm-action-item-carousel { position: relative; }
3945 +.srfm-action-item-carousel .srfm-action-item-notice { padding-inline-end: var(--srfm-carousel-reserve, 130px); }
3946 +/* [hidden] is only a UA rule, and WordPress sets display on .notice, so a
3947 + third-party admin sheet can otherwise put a notice the carousel has hidden back
3948 + on screen. */
3949 +.srfm-action-item-carousel .srfm-action-item-notice[hidden] { display: none; }
3950 +.srfm-action-item-carousel-nav {
3951 + position: absolute;
3952 + top: 8px;
3953 + inset-inline-end: 12px;
3954 + margin: 0;
3955 + display: flex;
3956 + align-items: center;
3957 + gap: 8px;
3958 +}
3959 +.srfm-details-overlay {
3960 + position: fixed;
3961 + inset: 0;
3962 + z-index: 999999;
3963 + display: flex;
3964 + align-items: center;
3965 + justify-content: center;
3966 + background: rgba(0, 0, 0, .5);
3967 + padding: 16px;
3968 +}
3969 +.srfm-details-panel {
3970 + background: #fff;
3971 + border-radius: 8px;
3972 + padding: 16px;
3973 + width: 100%;
3974 + max-width: 800px;
3975 + box-shadow: 0 10px 30px rgba(0, 0, 0, .2);
3976 +}
3977 +.srfm-details-panel h2 { margin: 0 0 4px; font-size: 14px; }
3978 +.srfm-details-panel .srfm-details-description { margin: 0 0 12px; color: #50575e; }
3979 +.srfm-details-panel pre {
3980 + margin: 0;
3981 + max-height: 320px;
3982 + overflow: auto;
3983 + white-space: pre-wrap;
3984 + word-break: break-word;
3985 + background: #f6f7f7;
3986 + padding: 12px;
3987 + border-radius: 6px;
3988 + font-size: 12px;
3989 +}
3990 +.srfm-details-actions {
3991 + display: flex;
3992 + gap: 8px;
3993 + align-items: center;
3994 + flex-wrap: wrap;
3995 + justify-content: flex-end;
3996 + margin: 12px 0 0;
3997 +}
3998 +.srfm-details-hint {
3999 + margin-inline-end: auto;
4000 + font-size: 12px;
4001 + color: #4b5563;
4002 +}
4003 +/* Core paints .button with the admin colour scheme, so these say what they are
4004 + rather than inheriting whichever scheme the user picked. */
4005 +.srfm-details-panel .srfm-details-close.button-link {
4006 + color: #50575e;
4007 + text-decoration: none;
4008 +}
4009 +.srfm-details-panel .srfm-details-close.button-link:hover,
4010 +.srfm-details-panel .srfm-details-close.button-link:focus {
4011 + color: #1e1e1e;
4012 +}
4013 +.srfm-details-panel .srfm-details-copy.button {
4014 + background: #fff;
4015 + border-color: #c3c4c7;
4016 + color: #1e1e1e;
4017 +}
4018 +.srfm-details-panel .srfm-details-copy.button:hover,
4019 +.srfm-details-panel .srfm-details-copy.button:focus {
4020 + background: #f6f7f7;
4021 + border-color: #8c8f94;
4022 + color: #1e1e1e;
4023 +}
4024 +.srfm-details-panel .srfm-details-contact.button-primary,
4025 +.srfm-details-panel .srfm-details-contact.button-primary:hover,
4026 +.srfm-details-panel .srfm-details-contact.button-primary:focus {
4027 + background: #D54407;
4028 + border-color: #D54407;
4029 + color: #fff;
4030 + box-shadow: none;
4031 + text-shadow: none;
4032 + text-decoration: none;
4033 +}
4034 +.srfm-details-panel .srfm-details-contact.button-primary:hover,
4035 +.srfm-details-panel .srfm-details-contact.button-primary:focus {
4036 + background: #C83B00;
4037 + border-color: #C83B00;
4038 +}
4039 +/* Grey rather than a dimmed orange fill. Core sets the disabled text colour with
4040 + !important, so an orange background here leaves grey on orange at 1.31:1 --
4041 + and a control that cannot be used should not wear the primary colour anyway.
4042 + This is what core gives every other disabled button, and what force-ui renders
4043 + for the same state on the dashboard, so the two surfaces agree. */
4044 +.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"],
4045 +.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:hover,
4046 +.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:focus {
4047 + background: #f6f7f7;
4048 + border-color: #dcdcde;
4049 + pointer-events: none;
4050 + box-shadow: none;
4051 +}
4052 +.srfm-details-panel .button:focus {
4053 + outline: 2px solid #D54407;
4054 + outline-offset: 1px;
4055 + box-shadow: none;
4056 +}
4057 +CSS;
4058 +
4059 + wp_add_inline_style( 'srfm-action-items', $css );
4060 + }
4061 +
4062 + /**
4063 + * The details dialog's strings.
4064 + *
4065 + * One array, two consumers: the classic wp-admin dialog in
4066 + * notice-response.js, and the dashboard's force-ui one. Declared here rather
4067 + * than inline in each, because the same sentence written as `__()` in PHP and
4068 + * again in JSX looks identical to translators until the first edit to either,
4069 + * after which one surface silently reverts to English.
4070 + *
4071 + * @since 2.12.7
4072 + * @return array<string,string>
4073 + */
4074 + private function get_details_dialog_labels() {
4075 + return [
4076 + 'title' => __( 'Details', 'sureforms' ),
4077 + 'description' => __( 'What we recorded about this problem. Copy it into your support request so we can start from the cause rather than a description of it.', 'sureforms' ),
4078 + 'copy' => __( 'Copy details', 'sureforms' ),
4079 + 'copied' => __( 'Copied', 'sureforms' ),
4080 + 'contact' => __( 'Contact Support', 'sureforms' ),
4081 + 'close' => __( 'Close', 'sureforms' ),
4082 + // Shown beside the buttons rather than as a title attribute:
4083 + // pointer-events:none suppresses the native tooltip, a title
4084 + // never fires on keyboard focus, and screen readers commonly
4085 + // drop it on an unavailable control -- so the sentence saying
4086 + // why the button is inert could not be read by anyone.
4087 + 'copyFirst' => __( 'Copy the details first, so you have them to paste.', 'sureforms' ),
4088 + // The unlock changes the label, the icon and whether Contact
4089 + // Support works, none of which was announced. This goes in a
4090 + // role="status" node so it is.
4091 + 'unlocked' => __( 'Copied. Contact Support is now available.', 'sureforms' ),
4092 + 'copyFailed' => __( 'Your browser would not let us copy. Select the text above and copy it by hand.', 'sureforms' ),
4093 + // The scrollable diagnostics block is focusable, so it needs a name of
4094 + // its own.
4095 + 'logRegion' => __( 'Recorded diagnostics', 'sureforms' ),
4096 + // The dialog opens before its payload arrives -- see
4097 + // handle_action_item_details() for why the report is not shipped with
4098 + // the page.
4099 + 'loading' => __( 'Collecting the details…', 'sureforms' ),
4100 + 'unavailable' => __( 'We could not collect the details. Contact Support and describe what happened, and we will take it from there.', 'sureforms' ),
4101 + ];
4102 + }
4103 +
4104 + /**
4105 + * SureForms' own action items, before the filter.
4106 + *
4107 + * Split out so the Enable Logs gate in get_action_items() can sit above this
4108 + * rather than above `srfm_action_items`. An item contributed through that
4109 + * filter has nothing to do with SureForms' logging toggle, and was being
4110 + * silenced by it.
4111 + *
4112 + * @since 2.12.7
4113 + * @return array<int,array<string,mixed>>
4114 + */
4115 + private function get_first_party_action_items() {
4116 + $warnings = [];
4117 + $open = Client_Logger::get_open_failures();
4118 +
3526 4119 // One item per category. They read differently to a site owner and must not
3527 4120 // be collapsed: submissions failing means visitors cannot reach you, a
3528 4121 // notification failing means you are not hearing about entries that did
3529 4122 // save, an integration failing means a third party is not receiving them.
@@ -3532,10 +4125,9 @@
3532 4125 'id' => 'form_submission_error',
3533 4126 /* translators: %s: form title. */
3534 4127 'title' => __( 'We noticed a form submission failure on %s.', 'sureforms' ),
3535 4128 'generic' => __( 'We noticed a form submission failure.', 'sureforms' ),
3536 - 'message' => __( 'Visitors may be unable to reach you, and those entries were not saved.', 'sureforms' ),
3537 - 'passing' => __( 'Form submissions are completing normally.', 'sureforms' ),
4129 + 'message' => __( 'Visitors may not be able to reach you, and their entries were not saved.', 'sureforms' ),
3538 4130 ],
3539 4131 'notification' => [
3540 4132 'id' => 'notification_error',
3541 4133 /* translators: %s: form title. */
@@ -3540,10 +4132,19 @@
3540 4132 'id' => 'notification_error',
3541 4133 /* translators: %s: form title. */
3542 4134 'title' => __( 'We noticed a notification failure on %s.', 'sureforms' ),
3543 4135 'generic' => __( 'We noticed a notification failure.', 'sureforms' ),
3544 - 'message' => __( 'The entry was saved, but the email telling you about it could not be sent — so new entries may be arriving without you hearing about them.', 'sureforms' ),
3545 - 'passing' => __( 'Notification emails are sending normally.', 'sureforms' ),
4136 + 'message' => __( 'The entry was saved, but we could not send the email about it. New entries may be coming in without you knowing.', 'sureforms' ),
4137 + // Email is the one failure here a site owner can usually fix without
4138 + // us: it is almost always SMTP not being configured. Offer the guide
4139 + // alongside support rather than making them wait for a reply.
4140 + 'guide' => Helper::get_sureforms_website_url(
4141 + 'docs/troubleshooting-email-sending-in-sureforms/',
4142 + [
4143 + 'utm_medium' => 'form_checks_notice',
4144 + 'utm_content' => 'notification_error',
4145 + ]
4146 + ),
3546 4147 ],
3547 4148 'integration' => [
3548 4149 'id' => 'integration_error',
3549 4150 /* translators: %s: form title. */
@@ -3548,24 +4149,14 @@
3548 4149 'id' => 'integration_error',
3549 4150 /* translators: %s: form title. */
3550 4151 'title' => __( 'We noticed an integration failure on %s.', 'sureforms' ),
3551 4152 'generic' => __( 'We noticed an integration failure.', 'sureforms' ),
3552 - 'message' => __( 'The entry was saved, but it could not be passed on to a connected service.', 'sureforms' ),
3553 - 'passing' => __( 'Integrations are running normally.', 'sureforms' ),
4153 + 'message' => __( 'The entry was saved, but we could not send it to a connected service.', 'sureforms' ),
3554 4154 ],
3555 4155 ];
3556 4156
3557 4157 foreach ( $categories as $category => $copy ) {
3558 4158 if ( ! isset( $open[ $category ] ) ) {
3559 - $passing[] = [
3560 - 'id' => $copy['id'],
3561 - 'status' => 'success',
3562 - 'title' => $copy['passing'],
3563 - 'message' => '',
3564 - 'cta_label' => '',
3565 - 'cta_url' => '',
3566 - 'dismissible' => false,
3567 - ];
3568 4159 continue;
3569 4160 }
3570 4161
3571 4162 // Name the form. "A form is failing" is not actionable on a site with
@@ -3571,9 +4162,9 @@
3571 4162 // Name the form. "A form is failing" is not actionable on a site with
3572 4163 // twenty of them, and the title is the first thing anyone asks for.
3573 4164 $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' );
3574 4165
3575 - $warnings[] = [
4166 + $warning = [
3576 4167 'id' => $copy['id'],
3577 4168 'status' => 'error',
3578 4169 'title' => '' !== $form_title
3579 4170 ? sprintf( $copy['title'], $form_title )
@@ -3578,28 +4169,58 @@
3578 4169 'title' => '' !== $form_title
3579 4170 ? sprintf( $copy['title'], $form_title )
3580 4171 : $copy['generic'],
3581 4172 'message' => $copy['message'],
3582 - 'cta_label' => __( 'Contact Support', 'sureforms' ),
3583 - 'cta_url' => $this->get_support_mailto_url(),
3584 - 'cta_action' => 'contact_support',
4173 + // Shows what would be sent before anything is sent. Someone reporting
4174 + // a fault on their own site is entitled to read the diagnostics and
4175 + // the log first, and a support agent gets a cleaner paste than a
4176 + // screenshot of a notice.
4177 + 'cta_label' => __( 'View details', 'sureforms' ),
4178 + // Where the classic wp-admin notice sends people, since it cannot open
4179 + // the panel's dialog. The dashboard is where the details are readable.
4180 + 'cta_url' => admin_url( 'admin.php?page=sureforms_menu' ),
4181 + 'cta_action' => 'view_details',
4182 + // Not the payload itself, only that one exists. The diagnostics are
4183 + // fetched when the dialog opens -- see handle_action_item_details().
4184 + //
4185 + // They used to ride along in the localisation JSON and in a hidden
4186 + // div on every admin page. The content is authored by whoever
4187 + // triggered the failure, and the client-error-log route is a public
4188 + // endpoint gated on a submit token rather than a capability, so an
4189 + // anonymous visitor can fill that excerpt. Broadcasting it to every
4190 + // admin screen -- read or not -- put attacker-authored text in page
4191 + // source site-wide and made any future escaping slip a
4192 + // manage_options-context problem. On demand, it reaches only the
4193 + // admin who asked for it.
4194 + 'has_details' => true,
4195 + // Which record to fetch. Not the payload, just the key.
4196 + 'category' => $category,
3585 4197 'dismissible' => false,
3586 4198 ];
4199 +
4200 + // A second, optional action. Absent keys render nothing, so a category
4201 + // without a guide needs no branch in either renderer, and neither does
4202 + // an item contributed through srfm_action_items.
4203 + if ( ! empty( $copy['guide'] ) ) {
4204 + $warning['guide_label'] = __( 'Help Me Fix', 'sureforms' );
4205 + $warning['guide_url'] = $copy['guide'];
4206 + $warning['guide_action'] = 'help_me_fix';
4207 + }
4208 +
4209 + $warnings[] = $warning;
3587 4210 }
3588 4211
3589 4212 $caching_plugin = Helper::get_active_caching_plugin();
3590 4213
3591 4214 if ( '' === $caching_plugin ) {
3592 - $passing[] = [
3593 - 'id' => 'caching_plugin',
3594 - 'status' => 'success',
3595 - 'title' => __( 'No caching plugin that needs configuring was found.', 'sureforms' ),
3596 - 'message' => '',
3597 - 'cta_label' => '',
3598 - 'cta_url' => '',
3599 - 'dismissible' => false,
3600 - ];
3601 - } elseif ( ! in_array( 'caching_plugin', $dismissed, true ) ) {
4215 + return $warnings;
4216 + }
4217 +
4218 + // Read here rather than at the top: with no caching plugin active nothing
4219 + // consults it, and this is the only dismissible item.
4220 + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
4221 +
4222 + if ( ! in_array( 'caching_plugin', $dismissed, true ) ) {
3602 4223 $warnings[] = [
3603 4224 'id' => 'caching_plugin',
3604 4225 'status' => 'warning',
3605 4226 'title' => sprintf(
@@ -3606,70 +4227,20 @@
3606 4227 /* translators: %s: caching plugin name. */
3607 4228 __( '%s may interfere with your forms.', 'sureforms' ),
3608 4229 $caching_plugin
3609 4230 ),
3610 - 'message' => __( 'Caching and JavaScript optimisation can serve a stale copy of your form or load its scripts out of order.', 'sureforms' ),
4231 + 'message' => __( 'Caching can show visitors an old copy of your form, or load its scripts in the wrong order.', 'sureforms' ),
3611 4232 'cta_label' => __( 'Help Me Fix', 'sureforms' ),
3612 - 'cta_url' => 'https://sureforms.com/docs/how-to-set-up-sureforms-with-caching-plugins/',
4233 + 'cta_url' => Helper::get_caching_plugin_doc_url(),
3613 4234 'cta_action' => 'help_me_fix',
3614 4235 'dismissible' => true,
3615 4236 ];
3616 4237 }
3617 4238
3618 - // Warnings first: the point of the panel is what needs attention, with the
3619 - // passing checks below as reassurance rather than as the headline.
3620 - $items = array_merge( $warnings, $passing );
3621 -
3622 - $this->track_action_item_impressions( $warnings );
3623 -
3624 - /**
3625 - * Filter the dashboard action items.
3626 - *
3627 - * Each entry needs id, status ('warning' or 'success'), title, message,
3628 - * cta_label, cta_url and dismissible. Only ids in
3629 - * handle_dismiss_action_item()'s allowlist can actually be dismissed, so
3630 - * adding a dismissible item here also needs a line there.
3631 - *
3632 - * @since 2.12.6
3633 - *
3634 - * @param array<int,array<string,mixed>> $items Action items.
3635 - */
3636 - return Helper::apply_filters_as_array( 'srfm_action_items', $items );
4239 + return $warnings;
3637 4240 }
3638 4241
3639 4242 /**
3640 - * Dismiss one action item.
3641 - *
3642 - * Hooked - wp_ajax_srfm_dismiss_action_item.
3643 - *
3644 - * Only items get_action_items() marks dismissible can be dismissed, so a
3645 - * crafted request cannot silence a genuine fault.
3646 - *
3647 - * @since 2.12.6
3648 - * @return void
3649 - */
3650 - public function handle_dismiss_action_item() {
3651 - if ( ! Helper::current_user_can() ) {
3652 - wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3653 - return;
3654 - }
3655 -
3656 - if ( ! check_ajax_referer( 'srfm_dismiss_action_item', 'nonce', false ) ) {
3657 - wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3658 - return;
3659 - }
3660 -
3661 - $item_id = isset( $_POST['item_id'] ) ? sanitize_key( wp_unslash( $_POST['item_id'] ) ) : '';
3662 -
3663 - if ( ! $this->dismiss_action_item( $item_id ) ) {
3664 - wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 );
3665 - return;
3666 - }
3667 -
3668 - wp_send_json_success();
3669 - }
3670 -
3671 - /**
3672 4243 * Nonce-protected URL that repairs the entries table.
3673 4244 *
3674 4245 * Shared by both notice surfaces so there is one repair route, one nonce and one
3675 4246 * place that counts the click. Private, so it stays off the public API and out of
@@ -4245,11 +4816,13 @@
4245 4816 * each render would measure how much wp-admin someone browses, not how many
4246 4817 * sites are affected. A day per user answers the question that matters -- how
4247 4818 * many people are seeing this -- for one option write.
4248 4819 *
4249 - * Passing checks are not counted. "Nothing is wrong" is not an impression.
4820 + * Counts SureForms' own items only. It runs before `srfm_action_items`, so a
4821 + * third party's contribution is not counted here -- SureForms has no name for
4822 + * it and no analytics key that would mean anything.
4250 4823 *
4251 - * @param array<int,array<string,mixed>> $warnings Warning items only.
4824 + * @param array<int,array<string,mixed>> $warnings SureForms' own items.
4252 4825 * @since 2.12.6
4253 4826 * @return void
4254 4827 */
4255 4828 private function track_action_item_impressions( $warnings ) {
@@ -4299,103 +4872,389 @@
4299 4872 }
4300 4873 }
4301 4874
4302 4875 /**
4303 - * Pre-addressed support email for a run of failed submissions.
4876 + * Record one interaction with a Form Checks notice, cumulatively.
4304 4877 *
4305 - * Carries the details support would otherwise have to ask for, so the first
4306 - * reply can be an answer rather than a questionnaire, along with the recent log
4307 - * entries inline.
4878 + * Both the value and `$force` matter. Analytics_Events::track() returns early
4879 + * when the event name is already in `usage_events_pushed`, so a call with
4880 + * `$force` omitted records each name at most once per site, ever -- the report
4881 + * could then say whether a button had ever been clicked but not how often, and
4882 + * these events exist to answer the second question. Sending a running total
4883 + * with `$force = true` re-sends each new value while an identical repeat still
4884 + * short-circuits inside track(). Same reasoning as
4885 + * track_action_item_impressions().
4308 4886 *
4309 - * The log is pasted into the body rather than attached because mailto has no
4310 - * attachment parameter -- browsers drop any attempt to add one -- and it is a
4311 - * tail rather than the whole file because a megabyte of JSON would exceed the
4312 - * URL length every mail client enforces.
4887 + * @param string $event_name Analytics key from the allowlist.
4888 + * @since 2.12.7
4889 + * @return void
4890 + */
4891 + private function track_notice_event( $event_name ) {
4892 + $counts = Helper::get_array_value( Helper::get_srfm_option( 'action_item_events', [] ) );
4893 +
4894 + $counts[ $event_name ] = Helper::get_integer_value( $counts[ $event_name ] ?? 0 ) + 1;
4895 +
4896 + Helper::update_srfm_option( 'action_item_events', $counts );
4897 +
4898 + Analytics::events()->track( $event_name, (string) $counts[ $event_name ], [], true );
4899 + }
4900 +
4901 + /**
4902 + * The contact form's address, tagged with where the click came from.
4313 4903 *
4314 - * @since 2.12.6
4904 + * One campaign, tagged per failure, so the report answers which check actually
4905 + * sends people to support rather than only how many arrive. A submission
4906 + * failure and a caching advisory are different problems and it is worth knowing
4907 + * which one drives the tickets.
4908 + *
4909 + * Prefilled with what SureForms already knows -- the admin's address, which
4910 + * failure it is, and the site host -- so the person reporting a fault does not
4911 + * retype it. Worth knowing that the address travels in the query string, so it
4912 + * reaches browser history and any referrer along the way; it is the site
4913 + * owner's own address going to SureForms' own form, which is the flow this
4914 + * button exists for.
4915 + *
4916 + * Built with add_query_arg rather than string concatenation, so it stays
4917 + * correct if the constant ever gains a query string of its own.
4918 + *
4919 + * @param string $category One of Client_Logger::CATEGORIES, naming the failure
4920 + * the visitor is reporting.
4921 + * @since 2.12.7
4315 4922 * @return string
4316 4923 */
4317 - private function get_support_mailto_url() {
4318 - $subject = sprintf(
4319 - /* translators: %s: site host. */
4320 - __( 'SureForms: form submissions are failing on %s', 'sureforms' ),
4321 - Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) )
4924 + private function get_support_contact_url( $category ) {
4925 + // Deliberately not translated. These are matched against the options on the
4926 + // troubleshooting form, so they are machine values, not copy -- a German
4927 + // site sending "E-Mail-Benachrichtigungsfehler" would arrive as an
4928 + // unrecognised subject and land in the wrong queue.
4929 + $subjects = [
4930 + 'submission' => 'Form submission failure',
4931 + 'notification' => 'Email notification failure',
4932 + 'integration' => 'Integration failure',
4933 + ];
4934 +
4935 + $user = wp_get_current_user();
4936 +
4937 + $url = add_query_arg(
4938 + [
4939 + // Prefills the form, so the person reporting a fault does not retype
4940 + // what SureForms already knows. Empty rather than absent when the
4941 + // address is unusable, so the form still opens.
4942 + 'mail' => is_email( $user->user_email ) ? $user->user_email : '',
4943 + // Falls back to "Other" for a category SureForms does not define --
4944 + // srfm_action_items is public, so an item can carry any category or
4945 + // none.
4946 + 'subject' => $subjects[ $category ] ?? 'Other',
4947 + 'site_url' => Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ),
4948 + 'utm_source' => 'sureforms',
4949 + 'utm_medium' => 'form_checks',
4950 + 'utm_campaign' => 'contact_support',
4951 + // Which check sent them. The one part that differs per button, and
4952 + // the reason for tagging at all.
4953 + 'utm_content' => $category,
4954 + ],
4955 + self::SUPPORT_CONTACT_URL
4322 4956 );
4323 4957
4324 - $log = Client_Logger::get_tail();
4325 - $body = $this->get_support_message();
4326 - $body .= "\r\n\r\n" . '---' . "\r\n";
4958 + /**
4959 + * Filter where the Contact Support action sends people.
4960 + *
4961 + * Replaces the `srfm_support_email_address` filter, which pointed at an
4962 + * inbox and has no destination left to change now that the action opens a
4963 + * form. A white-label install wants to point this at its own support page.
4964 + *
4965 + * @since 2.12.7
4966 + *
4967 + * @param string $url Contact form URL, already UTM-tagged.
4968 + * @param string $category The failure being reported.
4969 + */
4970 + $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category ) );
4327 4971
4972 + // Escaped after the filter, not before: the point of escaping here is that
4973 + // neither renderer has to trust what comes back. mailto: is allowed because
4974 + // an inbox is a legitimate destination for a white-label support contact,
4975 + // and get_action_items() already allows it on the sibling item URLs.
4976 + $safe = esc_url_raw( $filtered, [ 'http', 'https', 'mailto' ] );
4977 +
4978 + // Never empty. Contact Support is the only action that retires these
4979 + // notices and they are dismissible => false, so returning '' for a filter
4980 + // value that cannot survive escaping leaves an undismissable notice with
4981 + // nothing on it that works. Falling back to SureForms' own form is worse
4982 + // for a white-label than their own URL and better than a dead end, and the
4983 + // unfiltered URL is built here rather than supplied, so it always escapes.
4984 + return '' !== $safe ? $safe : esc_url_raw( $url, [ 'http', 'https' ] );
4985 + }
4986 +
4987 + /**
4988 + * The log tail, formatted for pasting.
4989 + *
4990 + * One builder, so the text someone reads before sending is the text that gets
4991 + * sent. They used to be built separately, which is how a "details" view drifts
4992 + * from what it claims to show.
4993 + *
4994 + * The budget is a parameter because nothing here is going into a URL any more.
4995 + * Client_Logger::get_tail()'s 1200-character default existed to fit a compose
4996 + * URL; a clipboard and a <pre> have no such limit, so the dialog asks for more
4997 + * and the note below describes the real constraint rather than a mail client
4998 + * that is not in this flow.
4999 + *
5000 + * @param int $max_chars Characters of log to include.
5001 + * @since 2.12.7
5002 + * @return string
5003 + */
5004 + private function get_support_log_block( $max_chars = 1200 ) {
5005 + $log = Client_Logger::get_tail( $max_chars );
5006 + $block = '---' . "\n";
5007 +
4328 5008 if ( '' === $log['text'] ) {
4329 - $body .= __( 'Debug log: no entries recorded.', 'sureforms' );
4330 - } else {
4331 - $body .= sprintf(
4332 - /* translators: 1: entries shown, 2: entries recorded. */
4333 - __( 'Debug log (most recent %1$d of %2$d entries)', 'sureforms' ),
4334 - $log['shown'],
4335 - $log['total']
4336 - ) . "\r\n";
5009 + return $block . __( 'Debug log: no entries recorded.', 'sureforms' );
5010 + }
4337 5011
4338 - // Fenced so it survives a reply and reads as data rather than prose in
4339 - // clients that render Markdown.
4340 - $body .= '```' . "\r\n" . str_replace( "\n", "\r\n", $log['text'] ) . "\r\n" . '```';
5012 + $block .= sprintf(
5013 + /* translators: 1: entries shown, 2: entries recorded. */
5014 + __( 'Debug log (most recent %1$d of %2$d entries)', 'sureforms' ),
5015 + $log['shown'],
5016 + $log['total']
5017 + ) . "\n";
4341 5018
4342 - if ( $log['shown'] < $log['total'] ) {
4343 - $body .= "\r\n\r\n" . __( 'Older entries were left out to keep this email within the length a mail client accepts. The full log can be downloaded from SureForms → Settings → General.', 'sureforms' );
4344 - }
5019 + // Fenced so it survives a reply and reads as data rather than prose wherever
5020 + // Markdown is rendered.
5021 + $block .= '```' . "\n" . $log['text'] . "\n" . '```';
5022 +
5023 + if ( $log['shown'] < $log['total'] ) {
5024 + $block .= "\n\n" . __( 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.', 'sureforms' );
4345 5025 }
4346 5026
4347 - return 'mailto:support@sureforms.com?' . http_build_query(
4348 - [
4349 - 'subject' => $subject,
4350 - 'body' => $body,
5027 + return $block;
5028 + }
5029 +
5030 + /**
5031 + * Subject and countless opening line for one kind of failure.
5032 + *
5033 + * Both come from here so they cannot drift apart: a subject naming one problem
5034 + * over a body describing another is worse than either alone. The counted form
5035 + * of the opening line lives in get_support_count_sentence(), which needs
5036 + * `_n()`'s literals and so cannot be an array lookup.
5037 + *
5038 + * An unknown or absent category gets deliberately neutral wording. The
5039 + * alternative -- defaulting to the submission copy -- states something specific
5040 + * that may not be true, and an item contributed through srfm_action_items has no
5041 + * category at all.
5042 + *
5043 + * @param string $category One of Client_Logger::CATEGORIES.
5044 + * @since 2.12.7
5045 + * @return array{subject:string,anon:string}
5046 + */
5047 + private function get_support_copy( $category ) {
5048 + $copy = [
5049 + 'submission' => [
5050 + /* translators: %s: site host. */
5051 + 'subject' => __( 'SureForms: form submissions are failing on %s', 'sureforms' ),
5052 + /* translators: %s: site host. */
5053 + 'anon' => __( 'SureForms has recorded form submissions on %s that could not be completed.', 'sureforms' ),
4351 5054 ],
4352 - '',
4353 - '&',
4354 - PHP_QUERY_RFC3986
4355 - );
5055 + 'notification' => [
5056 + /* translators: %s: site host. */
5057 + 'subject' => __( 'SureForms: notification emails are not being sent on %s', 'sureforms' ),
5058 + /* translators: %s: site host. */
5059 + 'anon' => __( 'SureForms saved entries on %s but could not send the notification emails for them.', 'sureforms' ),
5060 + ],
5061 + 'integration' => [
5062 + /* translators: %s: site host. */
5063 + 'subject' => __( 'SureForms: an integration is not receiving entries on %s', 'sureforms' ),
5064 + /* translators: %s: site host. */
5065 + 'anon' => __( 'SureForms saved entries on %s but could not pass them to a connected service.', 'sureforms' ),
5066 + ],
5067 + ];
5068 +
5069 + if ( isset( $copy[ $category ] ) ) {
5070 + return $copy[ $category ];
5071 + }
5072 +
5073 + return [
5074 + /* translators: %s: site host. */
5075 + 'subject' => __( 'SureForms: a problem with the forms on %s', 'sureforms' ),
5076 + /* translators: %s: site host. */
5077 + 'anon' => __( 'SureForms has recorded a problem with the forms on %s.', 'sureforms' ),
5078 + ];
4356 5079 }
4357 5080
4358 5081 /**
4359 - * Diagnostics block for the support email.
5082 + * The sentence that opens the support email, with the failure count in it.
4360 5083 *
5084 + * A switch with literal `_n()` calls rather than a singular/plural pair looked
5085 + * up from an array. `_n()` has to see its two literals at extraction time to
5086 + * emit an `msgid_plural`, and only that lets a locale supply the number of
5087 + * forms it actually uses -- Polish and Russian need three, Arabic six,
5088 + * Japanese one. Choosing on `1 === $count` in PHP is correct for English and
5089 + * wrong everywhere with a different plural rule.
5090 + *
5091 + * @param string $category One of Client_Logger::CATEGORIES. Unknown or absent
5092 + * gets neutral wording rather than a specific claim.
5093 + * @param int $count Failures recorded for that category.
5094 + * @since 2.12.7
5095 + * @return string
5096 + */
5097 + private function get_support_count_sentence( $category, $count ) {
5098 + switch ( $category ) {
5099 + case 'submission':
5100 + return sprintf(
5101 + /* translators: %d: number of failed submissions. */
5102 + _n(
5103 + 'SureForms has recorded %d form submission that could not be completed.',
5104 + 'SureForms has recorded %d form submissions that could not be completed.',
5105 + $count,
5106 + 'sureforms'
5107 + ),
5108 + $count
5109 + );
5110 +
5111 + case 'notification':
5112 + return sprintf(
5113 + /* translators: %d: number of failed notifications. */
5114 + _n(
5115 + 'SureForms saved %d entry but could not send the notification email for it.',
5116 + 'SureForms saved %d entries but could not send the notification emails for them.',
5117 + $count,
5118 + 'sureforms'
5119 + ),
5120 + $count
5121 + );
5122 +
5123 + case 'integration':
5124 + return sprintf(
5125 + /* translators: %d: number of failed integration hand-offs. */
5126 + _n(
5127 + 'SureForms saved %d entry but could not pass it to a connected service.',
5128 + 'SureForms saved %d entries but could not pass them to a connected service.',
5129 + $count,
5130 + 'sureforms'
5131 + ),
5132 + $count
5133 + );
5134 +
5135 + default:
5136 + return sprintf(
5137 + /* translators: %d: number of recorded problems. */
5138 + _n(
5139 + 'SureForms has recorded %d problem with the forms on this site.',
5140 + 'SureForms has recorded %d problems with the forms on this site.',
5141 + $count,
5142 + 'sureforms'
5143 + ),
5144 + $count
5145 + );
5146 + }
5147 + }
5148 +
5149 + /**
5150 + * Diagnostics block for the support report.
5151 + *
4361 5152 * Carries what support would otherwise have to ask for, so the first reply can
4362 5153 * be an answer rather than a questionnaire.
4363 5154 *
5155 + * The count is the one for this category, not get_fault_streak(), which reports
5156 + * submissions only -- so a notification failure used to quote a number from an
5157 + * unrelated counter, often zero.
5158 + *
5159 + * @param string $category One of Client_Logger::CATEGORIES.
5160 + * @param string $form_title Form the failure was recorded against, when known.
4364 5161 * @since 2.12.6
4365 5162 * @return string
4366 5163 */
4367 - private function get_support_message() {
5164 + private function get_support_message( $category = '', $form_title = '' ) {
4368 5165 global $wp_version;
4369 5166
4370 - $count = Client_Logger::get_fault_streak();
5167 + $failures = Client_Logger::get_failures();
5168 + $count = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 );
5169 + $copy = $this->get_support_copy( $category );
4371 5170
5171 + // With nothing recorded, describe the failure without a number. The old
5172 + // max( 1, $count ) reported "recorded 1 problem" and "Recorded failures: 1"
5173 + // for a count nobody recorded -- a number support would then chase.
5174 + $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) );
5175 +
4372 5176 $lines = [
4373 5177 __( 'Hello SureForms support,', 'sureforms' ),
4374 5178 '',
4375 - sprintf(
4376 - /* translators: %d: number of consecutive failed submissions. */
4377 - _n(
4378 - 'SureForms has recorded %d form submission in a row that could not be completed.',
4379 - 'SureForms has recorded %d form submissions in a row that could not be completed.',
4380 - $count,
4381 - 'sureforms'
5179 + $count > 0
5180 + ? $this->get_support_count_sentence( $category, $count )
5181 + : sprintf( $copy['anon'], $host ),
5182 + ];
5183 +
5184 + if ( '' !== $form_title ) {
5185 + $lines[] = '';
5186 + $lines[] = sprintf(
5187 + /* translators: %s: form title. */
5188 + __( 'Form: %s', 'sureforms' ),
5189 + $form_title
5190 + );
5191 + }
5192 +
5193 + // Once: each call reads an option and a site option.
5194 + $caching = Helper::get_active_caching_plugin();
5195 +
5196 + $lines = array_merge(
5197 + $lines,
5198 + [
5199 + '',
5200 + '---',
5201 + __( 'Site details', 'sureforms' ),
5202 + // Labels translated, values not. The site owner reads this on screen
5203 + // before sending it, so the labels are copy; the values are machine
5204 + // data -- a version, a URL, a plugin name -- and stay verbatim. The
5205 + // debug log below is left alone entirely for the same reason.
5206 + /* translators: %s: site address. */
5207 + sprintf( __( 'Site: %s', 'sureforms' ), home_url() ),
5208 + /* translators: %s: SureForms version. */
5209 + sprintf( __( 'SureForms: %s', 'sureforms' ), SRFM_VER ),
5210 + sprintf(
5211 + /* translators: %s: SureForms Pro version, or a note that it is not active. */
5212 + __( 'SureForms Pro: %s', 'sureforms' ),
5213 + Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : __( 'not active', 'sureforms' )
4382 5214 ),
4383 - $count
4384 - ),
4385 - '',
4386 - '---',
4387 - __( 'Site details', 'sureforms' ),
4388 - 'Site: ' . home_url(),
4389 - 'SureForms: ' . SRFM_VER,
4390 - 'SureForms Pro: ' . ( Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : __( 'not active', 'sureforms' ) ),
4391 - 'WordPress: ' . Helper::get_string_value( $wp_version ),
4392 - 'PHP: ' . PHP_VERSION,
4393 - 'Caching: ' . ( '' !== Helper::get_active_caching_plugin() ? Helper::get_active_caching_plugin() : __( 'none detected', 'sureforms' ) ),
4394 - 'Consecutive failures: ' . $count,
4395 - ];
5215 + /* translators: %s: WordPress version. */
5216 + sprintf( __( 'WordPress: %s', 'sureforms' ), Helper::get_string_value( $wp_version ) ),
5217 + /* translators: %s: PHP version. */
5218 + sprintf( __( 'PHP: %s', 'sureforms' ), PHP_VERSION ),
5219 + sprintf(
5220 + /* translators: %s: caching plugin name, or a note that none was detected. */
5221 + __( 'Caching: %s', 'sureforms' ),
5222 + '' !== $caching ? $caching : __( 'none detected', 'sureforms' )
5223 + ),
5224 + sprintf(
5225 + /* translators: %s: number of recorded failures, or a note that none were. */
5226 + __( 'Recorded failures: %s', 'sureforms' ),
5227 + $count > 0 ? Helper::get_string_value( $count ) : __( 'none recorded', 'sureforms' )
5228 + ),
5229 + ]
5230 + );
4396 5231
4397 - return implode( "\r\n", $lines );
5232 + // Only when there is one. A repeat report is worth knowing about: the same
5233 + // category having been reported before means the last answer did not hold,
5234 + // which is a different conversation from a first report. Appended with the
5235 + // rest of the site details rather than raised to the top, because it is
5236 + // context for them rather than a headline.
5237 + //
5238 + // Survives only until the next success in that category, because
5239 + // clear_category() unsets the whole record -- so in practice it is
5240 + // reachable for 'integration', which has no success signal, and transient
5241 + // for the other two.
5242 + //
5243 + // Stored as time(), a UTC epoch comparable with the sibling 'at', and
5244 + // formatted here with wp_date() so it reads in the site's timezone rather
5245 + // than the server's.
5246 + $acked_at = Helper::get_integer_value( $failures[ $category ]['acked_at'] ?? 0 );
5247 +
5248 + if ( $acked_at > 0 ) {
5249 + $lines[] = sprintf(
5250 + /* translators: %s: date and time of the previous report, in the site's timezone. */
5251 + __( 'Previously reported: %s', 'sureforms' ),
5252 + Helper::get_string_value( wp_date( 'Y-m-d H:i T', $acked_at ) )
5253 + );
5254 + }
5255 +
5256 + return implode( "\n", $lines );
4398 5257 }
4399 5258
4400 5259 /**
4401 5260 * Record one dismissal, shared by the AJAX and no-JS entry points.
@@ -4420,9 +5279,9 @@
4420 5279 Helper::update_srfm_option( 'dismissed_action_items', $dismissed );
4421 5280
4422 5281 // Recorded here rather than at each caller: both the cross in the
4423 5282 // dashboard panel and the no-JS link in the classic notice land here.
4424 - Analytics::events()->track( $item_id . '_notice_dismiss', 'dismissed' );
5283 + $this->track_notice_event( $item_id . '_notice_dismiss' );
4425 5284 }
4426 5285
4427 5286 return true;
4428 5287 }