| @@ -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 & 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 | } |