| @@ -88,8 +88,31 @@ | ||
| 88 | 88 | */ |
| 89 | 89 | public const THANKYOU_PROMPT_NOTICE_ID = 'srfm-thankyou-prompt'; |
| 90 | 90 | |
| 91 | 91 | /** |
| 92 | + * Where the Contact Support button writes to. | |
| 93 | + * | |
| 94 | + * An inbox rather than a form, restoring the 2.12.6 behaviour. A mailto: opens | |
| 95 | + * the composer the person already has open with the subject and the whole | |
| 96 | + * report in the body, so reporting a fault is one click and a send. The | |
| 97 | + * troubleshooting form could carry neither, which is why 2.12.7 had to gate the | |
| 98 | + * button behind copying the diagnostics by hand first. | |
| 99 | + * | |
| 100 | + * @since 2.12.8 | |
| 101 | + */ | |
| 102 | + private const SUPPORT_EMAIL = '[email protected]'; | |
| 103 | + | |
| 104 | + /** | |
| 105 | + * Longest Contact Support mailto: URL we hand to a mail client. | |
| 106 | + * | |
| 107 | + * Below the roughly 2000-character limit the strictest common clients and | |
| 108 | + * browsers apply to a link, with room to spare. | |
| 109 | + * | |
| 110 | + * @since 2.12.8 | |
| 111 | + */ | |
| 112 | + private const SUPPORT_MAILTO_MAX_LENGTH = 1800; | |
| 113 | + | |
| 114 | + /** | |
| 92 | 115 | * Dashboard widget entries data. |
| 93 | 116 | * |
| 94 | 117 | * @var array |
| 95 | 118 | * @since 1.9.1 |
| @@ -138,8 +161,25 @@ | ||
| 138 | 161 | */ |
| 139 | 162 | private static $setup_card_cache = []; |
| 140 | 163 | |
| 141 | 164 | /** |
| 165 | + * Action items for this request, or null before the first build. | |
| 166 | + * | |
| 167 | + * Built twice on every admin page without this -- once for the localisation | |
| 168 | + * payload, once in the classic renderer -- and each open failure category reads | |
| 169 | + * a log excerpt. get_action_items() also records an impression, which running | |
| 170 | + * twice counted twice. | |
| 171 | + * | |
| 172 | + * Reset with reset_action_items_cache(). Admin is a singleton, so without that | |
| 173 | + * the first build pins the answer for the whole process and any test that | |
| 174 | + * records a failure and then asks again is testing the memo. | |
| 175 | + * | |
| 176 | + * @var array<int,array<string,mixed>>|null | |
| 177 | + * @since 2.12.7 | |
| 178 | + */ | |
| 179 | + private static $action_items_cache = null; | |
| 180 | + | |
| 181 | + /** | |
| 142 | 182 | * Class constructor. |
| 143 | 183 | * |
| 144 | 184 | * @return void |
| 145 | 185 | * @since 0.0.1 |
| @@ -149,9 +189,11 @@ | ||
| 149 | 189 | add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); |
| 150 | 190 | add_action( 'admin_menu', [ $this, 'settings_page' ] ); |
| 151 | 191 | add_action( 'admin_menu', [ $this, 'add_learn_page' ] ); |
| 152 | 192 | add_action( 'admin_menu', [ $this, 'add_new_form' ] ); |
| 153 | - add_action( 'admin_menu', [ $this, 'add_suremail_page' ] ); | |
| 193 | + if ( ! Helper::hide_promotions() ) { | |
| 194 | + add_action( 'admin_menu', [ $this, 'add_suremail_page' ] ); | |
| 195 | + } | |
| 154 | 196 | if ( ! Helper::has_pro() ) { |
| 155 | 197 | add_action( 'admin_menu', [ $this, 'add_quiz_page' ] ); |
| 156 | 198 | add_action( 'admin_menu', [ $this, 'add_survey_reports_page' ] ); |
| 157 | 199 | add_action( 'admin_menu', [ $this, 'add_partial_entries_page' ] ); |
| @@ -175,8 +217,11 @@ | ||
| 175 | 217 | // cannot be saved. Registered at admin_init priority 5 so the React notice is |
| 176 | 218 | // in place before admin_enqueue_scripts localizes it. |
| 177 | 219 | add_action( 'admin_init', [ $this, 'register_database_repair_notice' ], 5 ); |
| 178 | 220 | add_action( 'admin_notices', [ $this, 'render_action_item_notices' ] ); |
| 221 | + // Late priority so the items are built after anything hooking | |
| 222 | + // srfm_action_items has had a chance to register. | |
| 223 | + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_action_item_styles' ], 20 ); | |
| 179 | 224 | add_action( 'admin_notices', [ $this, 'render_database_repair_notice' ] ); |
| 180 | 225 | add_action( 'admin_post_srfm_repair_entries_table', [ $this, 'handle_database_repair' ] ); |
| 181 | 226 | // Display notices on traditional WordPress admin pages. |
| 182 | 227 | add_action( 'admin_notices', [ $this, 'srfm_pro_version_compatibility' ] ); |
| @@ -447,8 +492,22 @@ | ||
| 447 | 492 | return self::$thankyou_prompt_cache; |
| 448 | 493 | } |
| 449 | 494 | |
| 450 | 495 | /** |
| 496 | + * Clear the request memo for the action items. | |
| 497 | + * | |
| 498 | + * Admin is a singleton, so the memo outlives a request in a test process. | |
| 499 | + * Anything that records or clears a failure inside one process has to call | |
| 500 | + * this, or it reads the answer from before the change. | |
| 501 | + * | |
| 502 | + * @since 2.12.7 | |
| 503 | + * @return void | |
| 504 | + */ | |
| 505 | + public static function reset_action_items_cache() { | |
| 506 | + self::$action_items_cache = null; | |
| 507 | + } | |
| 508 | + | |
| 509 | + /** | |
| 451 | 510 | * Clear the request memo for the Thank You prompt (#3030). |
| 452 | 511 | * |
| 453 | 512 | * Lets tests exercise the memoized public path, and is a safe hook for anything |
| 454 | 513 | * that changes which form qualifies (e.g. a form save). |
| @@ -581,9 +640,9 @@ | ||
| 581 | 640 | * @since 2.12.4 |
| 582 | 641 | * @return void |
| 583 | 642 | */ |
| 584 | 643 | public function register_form_setup_widget() { |
| 585 | - if ( ! Helper::current_user_can() ) { | |
| 644 | + if ( ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 586 | 645 | return; |
| 587 | 646 | } |
| 588 | 647 | |
| 589 | 648 | if ( null === self::get_form_setup_card() ) { |
| @@ -688,9 +747,9 @@ | ||
| 688 | 747 | * @since 2.12.4 |
| 689 | 748 | * @return void |
| 690 | 749 | */ |
| 691 | 750 | public function enqueue_form_setup_widget_assets( $hook_suffix ) { |
| 692 | - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) { | |
| 751 | + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 693 | 752 | return; |
| 694 | 753 | } |
| 695 | 754 | |
| 696 | 755 | $card = self::get_form_setup_card(); |
| @@ -1249,12 +1308,9 @@ | ||
| 1249 | 1308 | public function add_quiz_page() { |
| 1250 | 1309 | add_submenu_page( |
| 1251 | 1310 | 'sureforms_menu', |
| 1252 | 1311 | __( 'Quiz Entries', 'sureforms' ), |
| 1253 | - __( 'Quizzes', 'sureforms' ) . | |
| 1254 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1255 | - esc_html__( 'New', 'sureforms' ) . | |
| 1256 | - '</span>', | |
| 1312 | + __( 'Quizzes', 'sureforms' ), | |
| 1257 | 1313 | self::$sureforms_page_default_capability, |
| 1258 | 1314 | 'sureforms_quiz_entries', |
| 1259 | 1315 | [ $this, 'render_quiz_empty_state' ], |
| 1260 | 1316 | 5 |
| @@ -1282,12 +1338,9 @@ | ||
| 1282 | 1338 | public function add_survey_reports_page() { |
| 1283 | 1339 | add_submenu_page( |
| 1284 | 1340 | 'sureforms_menu', |
| 1285 | 1341 | __( 'Survey Reports', 'sureforms' ), |
| 1286 | - __( 'Survey Reports', 'sureforms' ) . | |
| 1287 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1288 | - esc_html__( 'New', 'sureforms' ) . | |
| 1289 | - '</span>', | |
| 1342 | + __( 'Survey Reports', 'sureforms' ), | |
| 1290 | 1343 | self::$sureforms_page_default_capability, |
| 1291 | 1344 | 'sureforms_survey_reports', |
| 1292 | 1345 | [ $this, 'render_survey_empty_state' ], |
| 1293 | 1346 | 6 |
| @@ -1315,12 +1368,9 @@ | ||
| 1315 | 1368 | public function add_partial_entries_page() { |
| 1316 | 1369 | add_submenu_page( |
| 1317 | 1370 | 'sureforms_menu', |
| 1318 | 1371 | __( 'Partial Entries', 'sureforms' ), |
| 1319 | - __( 'Partial Entries', 'sureforms' ) . | |
| 1320 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1321 | - esc_html__( 'New', 'sureforms' ) . | |
| 1322 | - '</span>', | |
| 1372 | + __( 'Partial Entries', 'sureforms' ), | |
| 1323 | 1373 | self::$sureforms_page_default_capability, |
| 1324 | 1374 | 'sureforms_partial_entries', |
| 1325 | 1375 | [ $this, 'render_partial_entries_empty_state' ], |
| 1326 | 1376 | 7 |
| @@ -1768,9 +1818,11 @@ | ||
| 1768 | 1818 | 'sureforms_pricing_page' => Helper::get_sureforms_website_url( 'pricing' ), |
| 1769 | 1819 | 'field_spacing_vars' => Helper::get_css_vars(), |
| 1770 | 1820 | 'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ), |
| 1771 | 1821 | 'integrations' => Helper::sureforms_get_integration(), |
| 1772 | - 'rotating_plugin_banner' => Helper::get_rotating_plugin_banner(), | |
| 1822 | + 'hide_promotions' => Helper::hide_promotions(), | |
| 1823 | + // Null makes the dashboard's ExtendTab render nothing. | |
| 1824 | + 'rotating_plugin_banner' => Helper::hide_promotions() ? null : Helper::get_rotating_plugin_banner(), | |
| 1773 | 1825 | 'ajax_url' => admin_url( 'admin-ajax.php' ), |
| 1774 | 1826 | 'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '', |
| 1775 | 1827 | 'action_items' => $this->get_action_items(), |
| 1776 | 1828 | 'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '', |
| @@ -1784,8 +1836,12 @@ | ||
| 1784 | 1836 | 'plugin_installed_text' => __( 'Installed', 'sureforms' ), |
| 1785 | 1837 | 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ), |
| 1786 | 1838 | 'is_rtl' => $is_rtl, |
| 1787 | 1839 | 'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false, |
| 1840 | + // Read by the onboarding cache-conflict step: the name decides whether the | |
| 1841 | + // step renders, the URL is where "View full guide" points. | |
| 1842 | + 'caching_plugin' => Helper::get_active_caching_plugin(), | |
| 1843 | + 'caching_plugin_doc_url' => Helper::get_caching_plugin_doc_url( 'onboarding' ), | |
| 1788 | 1844 | 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false, |
| 1789 | 1845 | 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ), |
| 1790 | 1846 | 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection. |
| 1791 | 1847 | 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ), |
| @@ -2619,10 +2675,10 @@ | ||
| 2619 | 2675 | if ( ! Helper::current_user_can() ) { |
| 2620 | 2676 | return; |
| 2621 | 2677 | } |
| 2622 | 2678 | |
| 2623 | - // Allow the notice to be disabled. | |
| 2624 | - if ( ! apply_filters( 'srfm_show_rating_notice', true ) ) { | |
| 2679 | + // Allow the notice to be disabled; never shown while promotions are hidden. | |
| 2680 | + if ( Helper::hide_promotions() || ! apply_filters( 'srfm_show_rating_notice', true ) ) { | |
| 2625 | 2681 | return; |
| 2626 | 2682 | } |
| 2627 | 2683 | |
| 2628 | 2684 | $notice_id = 'srfm-plugin-review-notice'; |
| @@ -2763,10 +2819,20 @@ | ||
| 2763 | 2819 | wp_localize_script( |
| 2764 | 2820 | 'srfm-notice-response', |
| 2765 | 2821 | 'srfmNoticeResponse', |
| 2766 | 2822 | [ |
| 2767 | - 'ajaxurl' => admin_url( 'admin-ajax.php' ), | |
| 2768 | - 'nonce' => wp_create_nonce( 'srfm_notice_response' ), | |
| 2823 | + 'ajaxurl' => admin_url( 'admin-ajax.php' ), | |
| 2824 | + 'nonce' => wp_create_nonce( 'srfm_notice_response' ), | |
| 2825 | + // Carousel chrome. Built in the browser rather than printed here so | |
| 2826 | + // that with JavaScript off every notice simply stays visible, which | |
| 2827 | + // is the behaviour this replaced -- controls that cannot work must | |
| 2828 | + // not be what hides a warning. | |
| 2829 | + 'carousel' => [ | |
| 2830 | + 'previous' => __( 'Previous notice', 'sureforms' ), | |
| 2831 | + 'next' => __( 'Next notice', 'sureforms' ), | |
| 2832 | + /* translators: 1: current position, 2: total notices. */ | |
| 2833 | + 'counter' => __( '%1$d of %2$d', 'sureforms' ), | |
| 2834 | + ], | |
| 2769 | 2835 | ] |
| 2770 | 2836 | ); |
| 2771 | 2837 | } |
| 2772 | 2838 | |
| @@ -2818,8 +2884,9 @@ | ||
| 2818 | 2884 | 'dismissed' => 'submission_failure_notice_dismiss', |
| 2819 | 2885 | ], |
| 2820 | 2886 | 'notification_error' => [ |
| 2821 | 2887 | 'contact_support' => 'notification_failure_notice_cta', |
| 2888 | + 'help_me_fix' => 'notification_failure_notice_guide', | |
| 2822 | 2889 | 'dismissed' => 'notification_failure_notice_dismiss', |
| 2823 | 2890 | ], |
| 2824 | 2891 | 'integration_error' => [ |
| 2825 | 2892 | 'contact_support' => 'integration_failure_notice_cta', |
| @@ -2844,14 +2911,15 @@ | ||
| 2844 | 2911 | // a side effect in a function elsewhere. |
| 2845 | 2912 | return; |
| 2846 | 2913 | } |
| 2847 | 2914 | |
| 2848 | - $event_name = $valid[ $notice_id ][ $button ]; | |
| 2849 | - Analytics::events()->track( $event_name, $button ); | |
| 2915 | + $this->track_notice_event( $valid[ $notice_id ][ $button ] ); | |
| 2850 | 2916 | |
| 2851 | 2917 | // Reporting the failures retires the notice until something new fails. |
| 2852 | - // Handled here rather than in the browser so it holds for the classic | |
| 2853 | - // wp-admin notice too, which is a plain link with no JavaScript. | |
| 2918 | + // Handled here rather than in the browser so both surfaces share it. The | |
| 2919 | + // click still reaches here only through JavaScript -- notice-response.js | |
| 2920 | + // on the classic notice, ActionItems.js on the dashboard -- so with | |
| 2921 | + // JavaScript off the link opens the email but the notice stays. | |
| 2854 | 2922 | $categories = [ |
| 2855 | 2923 | 'form_submission_error' => 'submission', |
| 2856 | 2924 | 'notification_error' => 'notification', |
| 2857 | 2925 | 'integration_error' => 'integration', |
| @@ -3008,10 +3076,11 @@ | ||
| 3008 | 3076 | * @since 1.9.1 |
| 3009 | 3077 | */ |
| 3010 | 3078 | public function maybe_register_dashboard_widget() { |
| 3011 | 3079 | |
| 3012 | - // Only for users with manage_options capability. | |
| 3013 | - if ( ! Helper::current_user_can() ) { | |
| 3080 | + // Only for users with manage_options capability, and never while | |
| 3081 | + // promotions are hidden: no SureForms widget on the WordPress dashboard. | |
| 3082 | + if ( ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 3014 | 3083 | return; |
| 3015 | 3084 | } |
| 3016 | 3085 | |
| 3017 | 3086 | // Register the AI quick draft widget for capable users (the capability gate above applies); unlike the recent-entries widget below, it is not conditional on having entries. |
| @@ -3114,9 +3183,9 @@ | ||
| 3114 | 3183 | * @since 2.12.1 |
| 3115 | 3184 | */ |
| 3116 | 3185 | public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) { |
| 3117 | 3186 | // Only on the main dashboard, and only for capable users (matches the widget gate). |
| 3118 | - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) { | |
| 3187 | + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 3119 | 3188 | return; |
| 3120 | 3189 | } |
| 3121 | 3190 | |
| 3122 | 3191 | // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching |
| @@ -3409,11 +3478,29 @@ | ||
| 3409 | 3478 | if ( Helper::validate_request_context( 'sureforms_menu', 'page' ) ) { |
| 3410 | 3479 | return; |
| 3411 | 3480 | } |
| 3412 | 3481 | |
| 3482 | + $items = $this->get_action_items(); | |
| 3483 | + | |
| 3484 | + // Only the faults reach this surface, so count those before deciding | |
| 3485 | + // whether the carousel stylesheet is worth printing. | |
| 3486 | + $rendered = 0; | |
| 3487 | + | |
| 3488 | + foreach ( $items as $item ) { | |
| 3489 | + $status = Helper::get_string_value( $item['status'] ?? '' ); | |
| 3490 | + | |
| 3491 | + if ( 'success' !== $status && '' !== $status ) { | |
| 3492 | + $rendered++; | |
| 3493 | + } | |
| 3494 | + } | |
| 3495 | + | |
| 3496 | + if ( 0 === $rendered ) { | |
| 3497 | + return; | |
| 3498 | + } | |
| 3499 | + | |
| 3413 | 3500 | $this->enqueue_notice_response_script(); |
| 3414 | 3501 | |
| 3415 | - foreach ( $this->get_action_items() as $item ) { | |
| 3502 | + foreach ( $items as $item ) { | |
| 3416 | 3503 | $status = Helper::get_string_value( $item['status'] ?? '' ); |
| 3417 | 3504 | |
| 3418 | 3505 | // Passing checks belong in the SureForms panel, not in wp-admin. A |
| 3419 | 3506 | // notice that says nothing is wrong is noise on every page load. |
| @@ -3424,21 +3511,64 @@ | ||
| 3424 | 3511 | // A fault reads as an error; advice reads as a warning. Both are shown, |
| 3425 | 3512 | // but they are not the same kind of message and should not look alike. |
| 3426 | 3513 | $class = 'error' === $status ? 'notice-error' : 'notice-warning'; |
| 3427 | 3514 | ?> |
| 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> | |
| 3515 | + <div class="notice srfm-action-item-notice <?php echo esc_attr( $class ); ?>"> | |
| 3516 | + <?php | |
| 3517 | + /* | |
| 3518 | + * Guarded like every sibling key. A filter item carrying only | |
| 3519 | + * id/status/cta_* is a shape this surface designs for, and reading | |
| 3520 | + * these unguarded is two PHP 8 undefined-key warnings plus an | |
| 3521 | + * esc_html( null ) deprecation on 8.1+. React tolerates the absence, | |
| 3522 | + * so leaving it would keep the two renderers disagreeing. | |
| 3523 | + */ | |
| 3524 | + ?> | |
| 3525 | + <p><strong><?php echo esc_html( Helper::get_string_value( $item['title'] ?? '' ) ); ?></strong></p> | |
| 3526 | + <p><?php echo esc_html( Helper::get_string_value( $item['message'] ?? '' ) ); ?></p> | |
| 3527 | + <?php | |
| 3528 | + // Self-serve first, so the emphasis follows the order rather than the | |
| 3529 | + // identity: whichever action leads is the primary button, and an item | |
| 3530 | + // with no guide still leads with Contact Support. | |
| 3531 | + $has_guide = ! empty( $item['guide_label'] ) && ! empty( $item['guide_url'] ); | |
| 3532 | + | |
| 3533 | + // Both keys, not either. An item contributed through | |
| 3534 | + // srfm_action_items may carry only guide_* keys -- reading cta_url | |
| 3535 | + // unguarded emits two PHP 8 undefined-key warnings and renders | |
| 3536 | + // href="" -- and a label without a URL renders an anchor that is not | |
| 3537 | + // keyboard focusable. React gates on the same pair. | |
| 3538 | + $has_cta = ! empty( $item['cta_label'] ) && ! empty( $item['cta_url'] ); | |
| 3539 | + ?> | |
| 3431 | 3540 | <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> | |
| 3541 | + <?php if ( $has_guide ) { ?> | |
| 3542 | + <a | |
| 3543 | + href="<?php echo esc_url( Helper::get_string_value( $item['guide_url'] ) ); ?>" | |
| 3544 | + class="button button-primary" | |
| 3545 | + data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>" | |
| 3546 | + data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['guide_action'] ?? '' ) ); ?>" | |
| 3547 | + target="_blank" | |
| 3548 | + rel="noopener noreferrer" | |
| 3549 | + > | |
| 3550 | + <?php echo esc_html( $item['guide_label'] ); ?> | |
| 3551 | + </a> | |
| 3552 | + <?php } ?> | |
| 3553 | + <?php if ( $has_cta ) { ?> | |
| 3554 | + <a | |
| 3555 | + href="<?php echo esc_url( Helper::get_string_value( $item['cta_url'] ) ); ?>" | |
| 3556 | + class="<?php echo $has_guide ? 'button' : 'button button-primary'; ?>" | |
| 3557 | + data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>" | |
| 3558 | + data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['cta_action'] ?? '' ) ); ?>" | |
| 3559 | + <?php | |
| 3560 | + // A mailto: must reach the mail client, not a new tab -- | |
| 3561 | + // there is no document to open, so _blank leaves a blank | |
| 3562 | + // one behind. | |
| 3563 | + if ( 0 !== strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ) { | |
| 3564 | + echo 'target="_blank" rel="noopener noreferrer"'; | |
| 3565 | + } | |
| 3566 | + ?> | |
| 3567 | + > | |
| 3568 | + <?php echo esc_html( $item['cta_label'] ); ?> | |
| 3569 | + </a> | |
| 3570 | + <?php } ?> | |
| 3441 | 3571 | <?php if ( ! empty( $item['dismissible'] ) ) { ?> |
| 3442 | 3572 | <a href="<?php echo esc_url( $this->get_dismiss_action_item_url( Helper::get_string_value( $item['id'] ) ) ); ?>" class="button"> |
| 3443 | 3573 | <?php esc_html_e( 'Dismiss', 'sureforms' ); ?> |
| 3444 | 3574 | </a> |
| @@ -3474,29 +3604,59 @@ | ||
| 3474 | 3604 | exit; |
| 3475 | 3605 | } |
| 3476 | 3606 | |
| 3477 | 3607 | /** |
| 3478 | - * Whether anything is currently wrong enough to warrant a notice. | |
| 3608 | + * Whether a first-party warning is currently on screen. | |
| 3479 | 3609 | * |
| 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. | |
| 3610 | + * Asked from the show_if of the rating, Getting Started and Thank You notices, | |
| 3611 | + * all of which are gated on nothing being wrong. "Wrong" has to mean the same | |
| 3612 | + * thing here as it does to the person looking at the screen. | |
| 3483 | 3613 | * |
| 3614 | + * It used to re-state the conditions instead of reading them, and the | |
| 3615 | + * restatement was narrower than the display: has_persistent_failures() reads | |
| 3616 | + * the `submission` counter alone, while the notices and the Form Checks panel | |
| 3617 | + * warn on any open failure in any of the three categories. So an open | |
| 3618 | + * notification or integration failure left this false, and the review ask | |
| 3619 | + * appeared directly beneath "We noticed a notification failure on Contact | |
| 3620 | + * Form". Submission was covered only incidentally, by FAULT_THRESHOLD being 1 -- | |
| 3621 | + * raise that and it would have joined them. | |
| 3622 | + * | |
| 3623 | + * Derived from get_first_party_action_items() now, which is the thing that | |
| 3624 | + * builds those warnings, so the gate cannot drift from the display again. | |
| 3625 | + * | |
| 3626 | + * Two constraints kept from the previous version. It must not call | |
| 3627 | + * get_action_items(): that records an impression as a side effect and must | |
| 3628 | + * never run from a show_if. And it reads the first-party set specifically, so | |
| 3629 | + * an item contributed through `srfm_action_items` cannot suppress notices that | |
| 3630 | + * have nothing to do with it. | |
| 3631 | + * | |
| 3632 | + * Returns false with logging disabled, which is what makes those notices | |
| 3633 | + * eligible again on a site that has turned this surface off. Intended: with the | |
| 3634 | + * surface off there is nothing being reported. | |
| 3635 | + * | |
| 3484 | 3636 | * @since 2.12.6 |
| 3485 | 3637 | * @return bool |
| 3486 | 3638 | */ |
| 3487 | 3639 | public function has_action_item_warnings() { |
| 3488 | - if ( Client_Logger::has_persistent_failures() ) { | |
| 3489 | - return true; | |
| 3640 | + if ( ! Client_Logger::is_enabled() ) { | |
| 3641 | + return false; | |
| 3490 | 3642 | } |
| 3491 | 3643 | |
| 3492 | - if ( '' === Helper::get_active_caching_plugin() ) { | |
| 3493 | - return false; | |
| 3644 | + foreach ( $this->get_first_party_action_items() as $item ) { | |
| 3645 | + if ( ! is_array( $item ) ) { | |
| 3646 | + continue; | |
| 3647 | + } | |
| 3648 | + | |
| 3649 | + $status = Helper::get_string_value( $item['status'] ?? '' ); | |
| 3650 | + | |
| 3651 | + // Matches the renderers: 'success' is a passing check and an empty | |
| 3652 | + // status is not a warning either, so neither suppresses anything. | |
| 3653 | + if ( 'success' !== $status && '' !== $status ) { | |
| 3654 | + return true; | |
| 3655 | + } | |
| 3494 | 3656 | } |
| 3495 | 3657 | |
| 3496 | - $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) ); | |
| 3497 | - | |
| 3498 | - return ! in_array( 'caching_plugin', $dismissed, true ); | |
| 3658 | + return false; | |
| 3499 | 3659 | } |
| 3500 | 3660 | |
| 3501 | 3661 | /** |
| 3502 | 3662 | * Things on this site that need the owner's attention, newest concern first. |
| @@ -3516,14 +3676,222 @@ | ||
| 3516 | 3676 | if ( ! Helper::current_user_can() ) { |
| 3517 | 3677 | return []; |
| 3518 | 3678 | } |
| 3519 | 3679 | |
| 3520 | - $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) ); | |
| 3521 | - $warnings = []; | |
| 3522 | - $passing = []; | |
| 3680 | + // Memoised for the request. This runs twice on every admin page -- once | |
| 3681 | + // building the localisation payload and once in the classic renderer -- and | |
| 3682 | + // each open category reads a log excerpt. It also records an impression, so | |
| 3683 | + // running twice counted twice. Matches the $thankyou_prompt_cache and | |
| 3684 | + // $setup_card_cache pattern already in this class. | |
| 3685 | + if ( null !== self::$action_items_cache ) { | |
| 3686 | + return self::$action_items_cache; | |
| 3687 | + } | |
| 3523 | 3688 | |
| 3524 | - $open = Client_Logger::get_open_failures(); | |
| 3689 | + // Logging off is the opt-out for this surface. Not because the counters go | |
| 3690 | + // stale -- Client_Logger::record_failure() has no enabled check, and the | |
| 3691 | + // notification and integration categories are written by direct calls in | |
| 3692 | + // inc/form-submit.php that keep counting accurately with logging off. It is | |
| 3693 | + // simply the switch a site owner has to turn these notices off, and it | |
| 3694 | + // covers our own items only: the filter below still runs, because a third | |
| 3695 | + // party's advisory has nothing to do with SureForms' logging toggle. | |
| 3696 | + $warnings = []; | |
| 3525 | 3697 | |
| 3698 | + if ( Client_Logger::is_enabled() ) { | |
| 3699 | + $warnings = $this->get_first_party_action_items(); | |
| 3700 | + } | |
| 3701 | + | |
| 3702 | + $this->track_action_item_impressions( $warnings ); | |
| 3703 | + | |
| 3704 | + /** | |
| 3705 | + * Filter the dashboard action items. | |
| 3706 | + * | |
| 3707 | + * Each entry needs id, status ('warning' or 'success'), title, message, | |
| 3708 | + * cta_label, cta_url and dismissible. Only ids in | |
| 3709 | + * handle_dismiss_action_item()'s allowlist can actually be dismissed, so | |
| 3710 | + * adding a dismissible item here also needs a line there. | |
| 3711 | + * | |
| 3712 | + * A third-party item's cta_url is followed as a plain link. The prefilled | |
| 3713 | + * support email is built only for SureForms' own failure items, from its own | |
| 3714 | + * client error log. | |
| 3715 | + * | |
| 3716 | + * @since 2.12.6 | |
| 3717 | + * | |
| 3718 | + * @param array<int,array<string,mixed>> $items Action items. | |
| 3719 | + */ | |
| 3720 | + $items = Helper::apply_filters_as_array( 'srfm_action_items', $warnings ); | |
| 3721 | + | |
| 3722 | + // Both URLs normalised once, here, rather than trusting each renderer to do | |
| 3723 | + // it. Two things are being fixed at once. | |
| 3724 | + // | |
| 3725 | + // The scheme: the classic notice runs esc_url() and drops anything outside | |
| 3726 | + // the allowlist, while React assigns href directly and react-dom 18 leaves | |
| 3727 | + // a javascript: URL intact -- its sanitizeURL() only warns, and the warning | |
| 3728 | + // is compiled out of the production build. esc_url_raw() with the same | |
| 3729 | + // allowlist closes both. | |
| 3730 | + // | |
| 3731 | + // The ampersands: Helper::get_sureforms_website_url() returns an esc_url()'d | |
| 3732 | + // string, so a URL with UTM parameters arrives with & in it. In an HTML | |
| 3733 | + // href the browser decodes that; React sets the property directly, so the | |
| 3734 | + // entity would be sent to the server verbatim. Decoded to one raw form here, | |
| 3735 | + // and each renderer escapes it for its own context. | |
| 3736 | + foreach ( $items as $index => $item ) { | |
| 3737 | + // A filter may hand back an object. isset() on it returns false, which | |
| 3738 | + // would slip the item past both the URL normalisation and the | |
| 3739 | + // sanitize_key() below without any sign that it had. | |
| 3740 | + if ( ! is_array( $item ) ) { | |
| 3741 | + continue; | |
| 3742 | + } | |
| 3743 | + | |
| 3744 | + foreach ( [ 'cta_url', 'guide_url' ] as $key ) { | |
| 3745 | + if ( ! isset( $item[ $key ] ) ) { | |
| 3746 | + continue; | |
| 3747 | + } | |
| 3748 | + | |
| 3749 | + $items[ $index ][ $key ] = esc_url_raw( | |
| 3750 | + wp_specialchars_decode( Helper::get_string_value( $item[ $key ] ), ENT_QUOTES ), | |
| 3751 | + [ 'http', 'https', 'mailto' ] | |
| 3752 | + ); | |
| 3753 | + } | |
| 3754 | + | |
| 3755 | + // The id ends up in the notice's data-srfm-notice-id attribute, which | |
| 3756 | + // notice-response.js matches on, and in the dismiss allowlist. | |
| 3757 | + // sanitize_key() is what both dismiss paths already apply, so applying | |
| 3758 | + // it once here means the value that renders is the value they compare | |
| 3759 | + // against -- and a filter-contributed id carrying a quote cannot break | |
| 3760 | + // the selector. | |
| 3761 | + if ( isset( $item['id'] ) ) { | |
| 3762 | + $items[ $index ]['id'] = sanitize_key( Helper::get_string_value( $item['id'] ) ); | |
| 3763 | + } | |
| 3764 | + } | |
| 3765 | + | |
| 3766 | + self::$action_items_cache = $items; | |
| 3767 | + | |
| 3768 | + return $items; | |
| 3769 | + } | |
| 3770 | + | |
| 3771 | + /** | |
| 3772 | + * Dismiss one action item. | |
| 3773 | + * | |
| 3774 | + * Hooked - wp_ajax_srfm_dismiss_action_item. | |
| 3775 | + * | |
| 3776 | + * Only items get_action_items() marks dismissible can be dismissed, so a | |
| 3777 | + * crafted request cannot silence a genuine fault. | |
| 3778 | + * | |
| 3779 | + * @since 2.12.6 | |
| 3780 | + * @return void | |
| 3781 | + */ | |
| 3782 | + public function handle_dismiss_action_item() { | |
| 3783 | + if ( ! Helper::current_user_can() ) { | |
| 3784 | + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 ); | |
| 3785 | + return; | |
| 3786 | + } | |
| 3787 | + | |
| 3788 | + if ( ! check_ajax_referer( 'srfm_dismiss_action_item', 'nonce', false ) ) { | |
| 3789 | + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 ); | |
| 3790 | + return; | |
| 3791 | + } | |
| 3792 | + | |
| 3793 | + $item_id = isset( $_POST['item_id'] ) ? sanitize_key( wp_unslash( $_POST['item_id'] ) ) : ''; | |
| 3794 | + | |
| 3795 | + if ( ! $this->dismiss_action_item( $item_id ) ) { | |
| 3796 | + wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 ); | |
| 3797 | + return; | |
| 3798 | + } | |
| 3799 | + | |
| 3800 | + wp_send_json_success(); | |
| 3801 | + } | |
| 3802 | + | |
| 3803 | + /** | |
| 3804 | + * The stylesheet for the notice carousel. | |
| 3805 | + * | |
| 3806 | + * In a stylesheet rather than inline style assignments in | |
| 3807 | + * notice-response.js, so the rules use logical properties and an RTL sheet can | |
| 3808 | + * override them. | |
| 3809 | + * | |
| 3810 | + * Only the classic wp-admin surface needs these. The SureForms dashboard is | |
| 3811 | + * styled by the Tailwind build, so nothing here reaches it. | |
| 3812 | + * | |
| 3813 | + * Attached to a registered handle with no file of its own, which is the WP way | |
| 3814 | + * to ship CSS tied to one script. | |
| 3815 | + * | |
| 3816 | + * Hooked to admin_enqueue_scripts rather than called from the renderer. | |
| 3817 | + * admin_notices fires from admin-header.php after admin_print_styles has | |
| 3818 | + * flushed the head, so enqueuing there reached the page only through core's | |
| 3819 | + * late-styles pass in the footer -- and until that parsed, every stacked notice | |
| 3820 | + * rendered expanded before collapsing to one, the carousel controls overlapped | |
| 3821 | + * the notice text, and the defensive `display: none` on the hidden payload was | |
| 3822 | + * inert, which is the exact window that rule exists for. | |
| 3823 | + * | |
| 3824 | + * @since 2.12.7 | |
| 3825 | + * @return void | |
| 3826 | + */ | |
| 3827 | + public function enqueue_action_item_styles() { | |
| 3828 | + if ( wp_style_is( 'srfm-action-items', 'enqueued' ) ) { | |
| 3829 | + return; | |
| 3830 | + } | |
| 3831 | + | |
| 3832 | + if ( ! Helper::current_user_can() ) { | |
| 3833 | + return; | |
| 3834 | + } | |
| 3835 | + | |
| 3836 | + // Nothing to style unless the carousel is actually going to build. Cheap to | |
| 3837 | + // ask: get_action_items() is memoised for the request. | |
| 3838 | + // | |
| 3839 | + // Two, not one: notice-response.js bails below two cards, so these rules | |
| 3840 | + // have no consumer on a site with a single open fault. | |
| 3841 | + $notices = 0; | |
| 3842 | + | |
| 3843 | + foreach ( $this->get_action_items() as $item ) { | |
| 3844 | + $status = Helper::get_string_value( is_array( $item ) ? $item['status'] ?? '' : '' ); | |
| 3845 | + | |
| 3846 | + if ( 'success' !== $status && '' !== $status ) { | |
| 3847 | + $notices++; | |
| 3848 | + } | |
| 3849 | + } | |
| 3850 | + | |
| 3851 | + if ( $notices < 2 ) { | |
| 3852 | + return; | |
| 3853 | + } | |
| 3854 | + | |
| 3855 | + wp_register_style( 'srfm-action-items', false, [], SRFM_VER ); | |
| 3856 | + wp_enqueue_style( 'srfm-action-items' ); | |
| 3857 | + | |
| 3858 | + $css = <<<'CSS' | |
| 3859 | +.srfm-action-item-carousel { position: relative; } | |
| 3860 | +.srfm-action-item-carousel .srfm-action-item-notice { padding-inline-end: var(--srfm-carousel-reserve, 130px); } | |
| 3861 | +/* [hidden] is only a UA rule, and WordPress sets display on .notice, so a | |
| 3862 | + third-party admin sheet can otherwise put a notice the carousel has hidden back | |
| 3863 | + on screen. */ | |
| 3864 | +.srfm-action-item-carousel .srfm-action-item-notice[hidden] { display: none; } | |
| 3865 | +.srfm-action-item-carousel-nav { | |
| 3866 | + position: absolute; | |
| 3867 | + top: 8px; | |
| 3868 | + inset-inline-end: 12px; | |
| 3869 | + margin: 0; | |
| 3870 | + display: flex; | |
| 3871 | + align-items: center; | |
| 3872 | + gap: 8px; | |
| 3873 | +} | |
| 3874 | +CSS; | |
| 3875 | + | |
| 3876 | + wp_add_inline_style( 'srfm-action-items', $css ); | |
| 3877 | + } | |
| 3878 | + | |
| 3879 | + /** | |
| 3880 | + * SureForms' own action items, before the filter. | |
| 3881 | + * | |
| 3882 | + * Split out so the Enable Logs gate in get_action_items() can sit above this | |
| 3883 | + * rather than above `srfm_action_items`. An item contributed through that | |
| 3884 | + * filter has nothing to do with SureForms' logging toggle, and was being | |
| 3885 | + * silenced by it. | |
| 3886 | + * | |
| 3887 | + * @since 2.12.7 | |
| 3888 | + * @return array<int,array<string,mixed>> | |
| 3889 | + */ | |
| 3890 | + private function get_first_party_action_items() { | |
| 3891 | + $warnings = []; | |
| 3892 | + $open = Client_Logger::get_open_failures(); | |
| 3893 | + | |
| 3526 | 3894 | // One item per category. They read differently to a site owner and must not |
| 3527 | 3895 | // be collapsed: submissions failing means visitors cannot reach you, a |
| 3528 | 3896 | // notification failing means you are not hearing about entries that did |
| 3529 | 3897 | // save, an integration failing means a third party is not receiving them. |
| @@ -3532,10 +3900,9 @@ | ||
| 3532 | 3900 | 'id' => 'form_submission_error', |
| 3533 | 3901 | /* translators: %s: form title. */ |
| 3534 | 3902 | 'title' => __( 'We noticed a form submission failure on %s.', 'sureforms' ), |
| 3535 | 3903 | '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' ), | |
| 3904 | + 'message' => __( 'Visitors may not be able to reach you, and their entries were not saved.', 'sureforms' ), | |
| 3538 | 3905 | ], |
| 3539 | 3906 | 'notification' => [ |
| 3540 | 3907 | 'id' => 'notification_error', |
| 3541 | 3908 | /* translators: %s: form title. */ |
| @@ -3540,10 +3907,19 @@ | ||
| 3540 | 3907 | 'id' => 'notification_error', |
| 3541 | 3908 | /* translators: %s: form title. */ |
| 3542 | 3909 | 'title' => __( 'We noticed a notification failure on %s.', 'sureforms' ), |
| 3543 | 3910 | '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' ), | |
| 3911 | + 'message' => __( 'The entry was saved, but we could not send the email about it. New entries may be coming in without you knowing.', 'sureforms' ), | |
| 3912 | + // Email is the one failure here a site owner can usually fix without | |
| 3913 | + // us: it is almost always SMTP not being configured. Offer the guide | |
| 3914 | + // alongside support rather than making them wait for a reply. | |
| 3915 | + 'guide' => Helper::get_sureforms_website_url( | |
| 3916 | + 'docs/troubleshooting-email-sending-in-sureforms/', | |
| 3917 | + [ | |
| 3918 | + 'utm_medium' => 'form_checks_notice', | |
| 3919 | + 'utm_content' => 'notification_error', | |
| 3920 | + ] | |
| 3921 | + ), | |
| 3546 | 3922 | ], |
| 3547 | 3923 | 'integration' => [ |
| 3548 | 3924 | 'id' => 'integration_error', |
| 3549 | 3925 | /* translators: %s: form title. */ |
| @@ -3548,24 +3924,14 @@ | ||
| 3548 | 3924 | 'id' => 'integration_error', |
| 3549 | 3925 | /* translators: %s: form title. */ |
| 3550 | 3926 | 'title' => __( 'We noticed an integration failure on %s.', 'sureforms' ), |
| 3551 | 3927 | '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' ), | |
| 3928 | + 'message' => __( 'The entry was saved, but we could not send it to a connected service.', 'sureforms' ), | |
| 3554 | 3929 | ], |
| 3555 | 3930 | ]; |
| 3556 | 3931 | |
| 3557 | 3932 | foreach ( $categories as $category => $copy ) { |
| 3558 | 3933 | 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 | 3934 | continue; |
| 3569 | 3935 | } |
| 3570 | 3936 | |
| 3571 | 3937 | // Name the form. "A form is failing" is not actionable on a site with |
| @@ -3571,9 +3937,9 @@ | ||
| 3571 | 3937 | // Name the form. "A form is failing" is not actionable on a site with |
| 3572 | 3938 | // twenty of them, and the title is the first thing anyone asks for. |
| 3573 | 3939 | $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' ); |
| 3574 | 3940 | |
| 3575 | - $warnings[] = [ | |
| 3941 | + $warning = [ | |
| 3576 | 3942 | 'id' => $copy['id'], |
| 3577 | 3943 | 'status' => 'error', |
| 3578 | 3944 | 'title' => '' !== $form_title |
| 3579 | 3945 | ? sprintf( $copy['title'], $form_title ) |
| @@ -3578,28 +3944,52 @@ | ||
| 3578 | 3944 | 'title' => '' !== $form_title |
| 3579 | 3945 | ? sprintf( $copy['title'], $form_title ) |
| 3580 | 3946 | : $copy['generic'], |
| 3581 | 3947 | 'message' => $copy['message'], |
| 3948 | + // Straight to a composed email, as 2.12.6 did. The subject, the | |
| 3949 | + // diagnostics and the log tail are already in it, so reporting a | |
| 3950 | + // fault is one click and a send. | |
| 3951 | + // | |
| 3952 | + // Built when the page renders, so the report ships in the href of the | |
| 3953 | + // classic notice on every admin screen and in srfm_admin.action_items | |
| 3954 | + // on the dashboard, both for capable users only. Its log comes from | |
| 3955 | + // the client error log, which any visitor with a form's submit token | |
| 3956 | + // can write to, so treat it as untrusted text. It is inert here: | |
| 3957 | + // http_build_query() percent-encodes all of it, so it cannot break | |
| 3958 | + // out of the attribute or add &cc= / &bcc= to the mailto:, and the | |
| 3959 | + // URL is length-capped. Building it on click instead would bring back | |
| 3960 | + // an AJAX round trip and a nonce to open an email -- the 2.12.7 | |
| 3961 | + // dialog's machinery -- for text the person reads in the composer | |
| 3962 | + // before anything is sent. | |
| 3582 | 3963 | 'cta_label' => __( 'Contact Support', 'sureforms' ), |
| 3583 | - 'cta_url' => $this->get_support_mailto_url(), | |
| 3964 | + 'cta_url' => $this->get_support_contact_url( $category, $form_title ), | |
| 3584 | 3965 | 'cta_action' => 'contact_support', |
| 3585 | 3966 | 'dismissible' => false, |
| 3586 | 3967 | ]; |
| 3968 | + | |
| 3969 | + // A second, optional action. Absent keys render nothing, so a category | |
| 3970 | + // without a guide needs no branch in either renderer, and neither does | |
| 3971 | + // an item contributed through srfm_action_items. | |
| 3972 | + if ( ! empty( $copy['guide'] ) ) { | |
| 3973 | + $warning['guide_label'] = __( 'Help Me Fix', 'sureforms' ); | |
| 3974 | + $warning['guide_url'] = $copy['guide']; | |
| 3975 | + $warning['guide_action'] = 'help_me_fix'; | |
| 3976 | + } | |
| 3977 | + | |
| 3978 | + $warnings[] = $warning; | |
| 3587 | 3979 | } |
| 3588 | 3980 | |
| 3589 | 3981 | $caching_plugin = Helper::get_active_caching_plugin(); |
| 3590 | 3982 | |
| 3591 | 3983 | 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 ) ) { | |
| 3984 | + return $warnings; | |
| 3985 | + } | |
| 3986 | + | |
| 3987 | + // Read here rather than at the top: with no caching plugin active nothing | |
| 3988 | + // consults it, and this is the only dismissible item. | |
| 3989 | + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) ); | |
| 3990 | + | |
| 3991 | + if ( ! in_array( 'caching_plugin', $dismissed, true ) ) { | |
| 3602 | 3992 | $warnings[] = [ |
| 3603 | 3993 | 'id' => 'caching_plugin', |
| 3604 | 3994 | 'status' => 'warning', |
| 3605 | 3995 | 'title' => sprintf( |
| @@ -3606,70 +3996,20 @@ | ||
| 3606 | 3996 | /* translators: %s: caching plugin name. */ |
| 3607 | 3997 | __( '%s may interfere with your forms.', 'sureforms' ), |
| 3608 | 3998 | $caching_plugin |
| 3609 | 3999 | ), |
| 3610 | - 'message' => __( 'Caching and JavaScript optimisation can serve a stale copy of your form or load its scripts out of order.', 'sureforms' ), | |
| 4000 | + 'message' => __( 'Caching can show visitors an old copy of your form, or load its scripts in the wrong order.', 'sureforms' ), | |
| 3611 | 4001 | 'cta_label' => __( 'Help Me Fix', 'sureforms' ), |
| 3612 | - 'cta_url' => 'https://sureforms.com/docs/how-to-set-up-sureforms-with-caching-plugins/', | |
| 4002 | + 'cta_url' => Helper::get_caching_plugin_doc_url(), | |
| 3613 | 4003 | 'cta_action' => 'help_me_fix', |
| 3614 | 4004 | 'dismissible' => true, |
| 3615 | 4005 | ]; |
| 3616 | 4006 | } |
| 3617 | 4007 | |
| 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 ); | |
| 4008 | + return $warnings; | |
| 3637 | 4009 | } |
| 3638 | 4010 | |
| 3639 | 4011 | /** |
| 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 | 4012 | * Nonce-protected URL that repairs the entries table. |
| 3673 | 4013 | * |
| 3674 | 4014 | * Shared by both notice surfaces so there is one repair route, one nonce and one |
| 3675 | 4015 | * place that counts the click. Private, so it stays off the public API and out of |
| @@ -4198,11 +4538,13 @@ | ||
| 4198 | 4538 | private function is_admin_pointer_visible() { |
| 4199 | 4539 | global $pagenow; |
| 4200 | 4540 | $allowed_pages = [ 'index.php', 'options-general.php' ]; |
| 4201 | 4541 | |
| 4202 | - // Do not show if pointer dismissed, accepted, or more than 1 form exists. | |
| 4542 | + // Do not show if promotions are hidden, the pointer was dismissed or | |
| 4543 | + // accepted, or more than 1 form exists. | |
| 4203 | 4544 | if ( |
| 4204 | - ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) ) | |
| 4545 | + Helper::hide_promotions() | |
| 4546 | + || ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) ) | |
| 4205 | 4547 | || ! empty( Helper::get_srfm_option( 'pointer_popup_accepted' ) ) |
| 4206 | 4548 | || (int) ( wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0 ) > 1 |
| 4207 | 4549 | ) { |
| 4208 | 4550 | return false; |
| @@ -4245,11 +4587,13 @@ | ||
| 4245 | 4587 | * each render would measure how much wp-admin someone browses, not how many |
| 4246 | 4588 | * sites are affected. A day per user answers the question that matters -- how |
| 4247 | 4589 | * many people are seeing this -- for one option write. |
| 4248 | 4590 | * |
| 4249 | - * Passing checks are not counted. "Nothing is wrong" is not an impression. | |
| 4591 | + * Counts SureForms' own items only. It runs before `srfm_action_items`, so a | |
| 4592 | + * third party's contribution is not counted here -- SureForms has no name for | |
| 4593 | + * it and no analytics key that would mean anything. | |
| 4250 | 4594 | * |
| 4251 | - * @param array<int,array<string,mixed>> $warnings Warning items only. | |
| 4595 | + * @param array<int,array<string,mixed>> $warnings SureForms' own items. | |
| 4252 | 4596 | * @since 2.12.6 |
| 4253 | 4597 | * @return void |
| 4254 | 4598 | */ |
| 4255 | 4599 | private function track_action_item_impressions( $warnings ) { |
| @@ -4299,103 +4643,387 @@ | ||
| 4299 | 4643 | } |
| 4300 | 4644 | } |
| 4301 | 4645 | |
| 4302 | 4646 | /** |
| 4303 | - * Pre-addressed support email for a run of failed submissions. | |
| 4647 | + * Record one interaction with a Form Checks notice, cumulatively. | |
| 4304 | 4648 | * |
| 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. | |
| 4649 | + * Both the value and `$force` matter. Analytics_Events::track() returns early | |
| 4650 | + * when the event name is already in `usage_events_pushed`, so a call with | |
| 4651 | + * `$force` omitted records each name at most once per site, ever -- the report | |
| 4652 | + * could then say whether a button had ever been clicked but not how often, and | |
| 4653 | + * these events exist to answer the second question. Sending a running total | |
| 4654 | + * with `$force = true` re-sends each new value while an identical repeat still | |
| 4655 | + * short-circuits inside track(). Same reasoning as | |
| 4656 | + * track_action_item_impressions(). | |
| 4308 | 4657 | * |
| 4658 | + * @param string $event_name Analytics key from the allowlist. | |
| 4659 | + * @since 2.12.7 | |
| 4660 | + * @return void | |
| 4661 | + */ | |
| 4662 | + private function track_notice_event( $event_name ) { | |
| 4663 | + $counts = Helper::get_array_value( Helper::get_srfm_option( 'action_item_events', [] ) ); | |
| 4664 | + | |
| 4665 | + $counts[ $event_name ] = Helper::get_integer_value( $counts[ $event_name ] ?? 0 ) + 1; | |
| 4666 | + | |
| 4667 | + Helper::update_srfm_option( 'action_item_events', $counts ); | |
| 4668 | + | |
| 4669 | + Analytics::events()->track( $event_name, (string) $counts[ $event_name ], [], true ); | |
| 4670 | + } | |
| 4671 | + | |
| 4672 | + /** | |
| 4673 | + * A pre-addressed support email for the failure being reported. | |
| 4674 | + * | |
| 4675 | + * Restores the 2.12.6 behaviour: the button opens the composer the person | |
| 4676 | + * already uses, with the subject and the whole report written for them. What | |
| 4677 | + * 2.12.7 replaced it with -- a web form -- could carry neither the diagnostics | |
| 4678 | + * nor the log, so the button had to be gated behind copying them by hand and | |
| 4679 | + * pasting them into a field on the far side. That is three deliberate steps to | |
| 4680 | + * report a fault the plugin had already written up. | |
| 4681 | + * | |
| 4309 | 4682 | * The log is pasted into the body rather than attached because mailto has no |
| 4310 | 4683 | * attachment parameter -- browsers drop any attempt to add one -- and it is a |
| 4311 | 4684 | * tail rather than the whole file because a megabyte of JSON would exceed the |
| 4312 | - * URL length every mail client enforces. | |
| 4685 | + * URL length every mail client enforces. The finished URL is capped at | |
| 4686 | + * SUPPORT_MAILTO_MAX_LENGTH, and the log is what gives way to meet it. | |
| 4313 | 4687 | * |
| 4314 | - * @since 2.12.6 | |
| 4688 | + * The subject and body are English on every site, deliberately untranslated: | |
| 4689 | + * they are written for SureForms support, and plain literals cannot be | |
| 4690 | + * rewritten by a locale, a translation plugin or a gettext filter. | |
| 4691 | + * | |
| 4692 | + * @param string $category One of Client_Logger::CATEGORIES, naming the failure | |
| 4693 | + * being reported. An unknown or absent one gets | |
| 4694 | + * deliberately neutral wording via get_support_copy(). | |
| 4695 | + * @param string $form_title Form the failure was recorded against, when known. | |
| 4696 | + * @since 2.12.8 | |
| 4315 | 4697 | * @return string |
| 4316 | 4698 | */ |
| 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 ) ) | |
| 4322 | - ); | |
| 4699 | + private function get_support_contact_url( $category, $form_title = '' ) { | |
| 4700 | + $copy = $this->get_support_copy( $category ); | |
| 4701 | + $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ); | |
| 4323 | 4702 | |
| 4324 | - $log = Client_Logger::get_tail(); | |
| 4325 | - $body = $this->get_support_message(); | |
| 4326 | - $body .= "\r\n\r\n" . '---' . "\r\n"; | |
| 4703 | + $subject = sprintf( $copy['subject'], $host ); | |
| 4327 | 4704 | |
| 4328 | - 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"; | |
| 4705 | + $url = $this->build_support_mailto_within_limit( $category, $form_title, $subject ); | |
| 4337 | 4706 | |
| 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" . '```'; | |
| 4707 | + // The last resort: the subject alone still names the problem and the site. | |
| 4708 | + if ( '' === $url ) { | |
| 4709 | + $url = $this->build_support_mailto( $subject ); | |
| 4710 | + } | |
| 4341 | 4711 | |
| 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' ); | |
| 4712 | + /** | |
| 4713 | + * Filter where the Contact Support action sends people. | |
| 4714 | + * | |
| 4715 | + * A white-label install wants its own inbox or its own support page, so both | |
| 4716 | + * are accepted. Returning an http(s) URL is supported but drops the body -- | |
| 4717 | + * a web form cannot carry it -- so the person arrives without the site | |
| 4718 | + * details or the debug log. They can still download the log from SureForms → | |
| 4719 | + * Settings → General, but nothing prompts them to, so a filter returning a | |
| 4720 | + * page should ask for it there. | |
| 4721 | + * | |
| 4722 | + * @since 2.12.7 | |
| 4723 | + * | |
| 4724 | + * @param string $url The pre-addressed mailto: URL. | |
| 4725 | + * @param string $category The failure being reported. | |
| 4726 | + * @param string $form_title Form the failure was recorded against, or ''. | |
| 4727 | + */ | |
| 4728 | + $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category, $form_title ) ); | |
| 4729 | + | |
| 4730 | + // Escaped after the filter, not before: the point of escaping here is that | |
| 4731 | + // neither renderer has to trust what comes back. | |
| 4732 | + $safe = esc_url_raw( $filtered, [ 'http', 'https', 'mailto' ] ); | |
| 4733 | + | |
| 4734 | + // Never empty. Contact Support is the only action that retires these | |
| 4735 | + // notices and they are dismissible => false, so returning '' for a filter | |
| 4736 | + // value that cannot survive escaping leaves an undismissable notice with | |
| 4737 | + // nothing on it that works. The unfiltered URL is built here rather than | |
| 4738 | + // supplied, so it always escapes. | |
| 4739 | + return '' !== $safe ? $safe : esc_url_raw( $url, [ 'mailto' ] ); | |
| 4740 | + } | |
| 4741 | + | |
| 4742 | + /** | |
| 4743 | + * The fullest support mailto: that fits SUPPORT_MAILTO_MAX_LENGTH. | |
| 4744 | + * | |
| 4745 | + * A mailto: is a URL and every client enforces a length limit on it. | |
| 4746 | + * Overrunning it does not truncate politely -- it drops the body, or the | |
| 4747 | + * whole link -- while the click still retires the notice. So the cap is on | |
| 4748 | + * the encoded URL, not the raw log: JSON-escaped non-ASCII text grows about | |
| 4749 | + * eight times once percent-encoded. The log gives way first, because the | |
| 4750 | + * site details are the part support cannot do without. | |
| 4751 | + * | |
| 4752 | + * @param string $category The failure being reported. | |
| 4753 | + * @param string $form_title Form the failure was recorded against, or ''. | |
| 4754 | + * @param string $subject Subject line. | |
| 4755 | + * @since 2.12.8 | |
| 4756 | + * @return string The URL, or '' when even the body without a log is too long. | |
| 4757 | + */ | |
| 4758 | + private function build_support_mailto_within_limit( $category, $form_title, $subject ) { | |
| 4759 | + // CRLF, not "\n". RFC 6068 leaves the line ending to the client and the | |
| 4760 | + // major composers normalise either, but Outlook renders a bare LF body as a | |
| 4761 | + // single run-on line -- which is exactly the report a support agent has to | |
| 4762 | + // read. | |
| 4763 | + $message = str_replace( "\n", "\r\n", $this->get_support_message( $category, $form_title ) ); | |
| 4764 | + | |
| 4765 | + foreach ( [ 1200, 800, 400, 0 ] as $budget ) { | |
| 4766 | + $log = 0 < $budget | |
| 4767 | + ? $this->get_support_log_block( $budget ) | |
| 4768 | + : '---' . "\n" . 'Debug log left out to keep this email short enough to send. The full log can be downloaded from SureForms → Settings → General.'; | |
| 4769 | + | |
| 4770 | + $url = $this->build_support_mailto( $subject, $message . "\r\n\r\n" . str_replace( "\n", "\r\n", $log ) ); | |
| 4771 | + | |
| 4772 | + if ( strlen( $url ) <= self::SUPPORT_MAILTO_MAX_LENGTH ) { | |
| 4773 | + return $url; | |
| 4344 | 4774 | } |
| 4345 | 4775 | } |
| 4346 | 4776 | |
| 4347 | - return 'mailto:[email protected]?' . http_build_query( | |
| 4348 | - [ | |
| 4349 | - 'subject' => $subject, | |
| 4350 | - 'body' => $body, | |
| 4351 | - ], | |
| 4777 | + return ''; | |
| 4778 | + } | |
| 4779 | + | |
| 4780 | + /** | |
| 4781 | + * A mailto: to the support inbox. | |
| 4782 | + * | |
| 4783 | + * @param string $subject Subject line. | |
| 4784 | + * @param string $body Body, with CRLF line endings. Omitted when empty. | |
| 4785 | + * @since 2.12.8 | |
| 4786 | + * @return string | |
| 4787 | + */ | |
| 4788 | + private function build_support_mailto( $subject, $body = '' ) { | |
| 4789 | + $query = [ 'subject' => $subject ]; | |
| 4790 | + | |
| 4791 | + if ( '' !== $body ) { | |
| 4792 | + $query['body'] = $body; | |
| 4793 | + } | |
| 4794 | + | |
| 4795 | + return 'mailto:' . self::SUPPORT_EMAIL . '?' . http_build_query( | |
| 4796 | + $query, | |
| 4352 | 4797 | '', |
| 4353 | 4798 | '&', |
| 4799 | + // RFC 3986, so a space is %20 rather than +. A mail client reading a | |
| 4800 | + // mailto: body decodes it as a URI, not as form data, so + arrives as a | |
| 4801 | + // literal plus in every word gap. | |
| 4354 | 4802 | PHP_QUERY_RFC3986 |
| 4355 | 4803 | ); |
| 4356 | 4804 | } |
| 4357 | 4805 | |
| 4358 | 4806 | /** |
| 4359 | - * Diagnostics block for the support email. | |
| 4807 | + * The log tail, formatted for pasting. | |
| 4360 | 4808 | * |
| 4809 | + * The budget is a parameter because it goes into a mailto: URL, and | |
| 4810 | + * get_support_contact_url() lowers it until the encoded URL fits. | |
| 4811 | + * | |
| 4812 | + * @param int $max_chars Characters of log to include. | |
| 4813 | + * @since 2.12.7 | |
| 4814 | + * @return string | |
| 4815 | + */ | |
| 4816 | + private function get_support_log_block( $max_chars = 1200 ) { | |
| 4817 | + $log = Client_Logger::get_tail( $max_chars ); | |
| 4818 | + $block = '---' . "\n"; | |
| 4819 | + | |
| 4820 | + if ( '' === $log['text'] ) { | |
| 4821 | + return $block . 'Debug log: no entries recorded.'; | |
| 4822 | + } | |
| 4823 | + | |
| 4824 | + $block .= sprintf( | |
| 4825 | + 'Debug log (most recent %1$d of %2$d entries)', | |
| 4826 | + $log['shown'], | |
| 4827 | + $log['total'] | |
| 4828 | + ) . "\n"; | |
| 4829 | + | |
| 4830 | + // Fenced so it survives a reply and reads as data rather than prose wherever | |
| 4831 | + // Markdown is rendered. | |
| 4832 | + $block .= '```' . "\n" . $log['text'] . "\n" . '```'; | |
| 4833 | + | |
| 4834 | + if ( $log['shown'] < $log['total'] ) { | |
| 4835 | + $block .= "\n\n" . 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.'; | |
| 4836 | + } | |
| 4837 | + | |
| 4838 | + return $block; | |
| 4839 | + } | |
| 4840 | + | |
| 4841 | + /** | |
| 4842 | + * Subject and countless opening line for one kind of failure. | |
| 4843 | + * | |
| 4844 | + * Both come from here so they cannot drift apart: a subject naming one problem | |
| 4845 | + * over a body describing another is worse than either alone. The counted form | |
| 4846 | + * of the opening line lives in get_support_count_sentence(). | |
| 4847 | + * | |
| 4848 | + * An unknown or absent category gets deliberately neutral wording. The | |
| 4849 | + * alternative -- defaulting to the submission copy -- states something specific | |
| 4850 | + * that may not be true, and an item contributed through srfm_action_items has no | |
| 4851 | + * category at all. | |
| 4852 | + * | |
| 4853 | + * @param string $category One of Client_Logger::CATEGORIES. | |
| 4854 | + * @since 2.12.7 | |
| 4855 | + * @return array{subject:string,anon:string} | |
| 4856 | + */ | |
| 4857 | + private function get_support_copy( $category ) { | |
| 4858 | + $copy = [ | |
| 4859 | + 'submission' => [ | |
| 4860 | + 'subject' => 'SureForms: form submissions are failing on %s', | |
| 4861 | + 'anon' => 'SureForms has recorded form submissions on %s that could not be completed.', | |
| 4862 | + ], | |
| 4863 | + 'notification' => [ | |
| 4864 | + 'subject' => 'SureForms: notification emails are not being sent on %s', | |
| 4865 | + 'anon' => 'SureForms saved entries on %s but could not send the notification emails for them.', | |
| 4866 | + ], | |
| 4867 | + 'integration' => [ | |
| 4868 | + 'subject' => 'SureForms: an integration is not receiving entries on %s', | |
| 4869 | + 'anon' => 'SureForms saved entries on %s but could not pass them to a connected service.', | |
| 4870 | + ], | |
| 4871 | + ]; | |
| 4872 | + | |
| 4873 | + if ( isset( $copy[ $category ] ) ) { | |
| 4874 | + return $copy[ $category ]; | |
| 4875 | + } | |
| 4876 | + | |
| 4877 | + return [ | |
| 4878 | + 'subject' => 'SureForms: a problem with the forms on %s', | |
| 4879 | + 'anon' => 'SureForms has recorded a problem with the forms on %s.', | |
| 4880 | + ]; | |
| 4881 | + } | |
| 4882 | + | |
| 4883 | + /** | |
| 4884 | + * The sentence that opens the support email, with the failure count in it. | |
| 4885 | + * | |
| 4886 | + * English only, like the rest of the support email, so `1 === $count` is the | |
| 4887 | + * whole plural rule. | |
| 4888 | + * | |
| 4889 | + * @param string $category One of Client_Logger::CATEGORIES. Unknown or absent | |
| 4890 | + * gets neutral wording rather than a specific claim. | |
| 4891 | + * @param int $count Failures recorded for that category. | |
| 4892 | + * @since 2.12.7 | |
| 4893 | + * @return string | |
| 4894 | + */ | |
| 4895 | + private function get_support_count_sentence( $category, $count ) { | |
| 4896 | + switch ( $category ) { | |
| 4897 | + case 'submission': | |
| 4898 | + return sprintf( | |
| 4899 | + ( 1 === $count | |
| 4900 | + ? 'SureForms has recorded %d form submission that could not be completed.' | |
| 4901 | + : 'SureForms has recorded %d form submissions that could not be completed.' ), | |
| 4902 | + $count | |
| 4903 | + ); | |
| 4904 | + | |
| 4905 | + case 'notification': | |
| 4906 | + return sprintf( | |
| 4907 | + ( 1 === $count | |
| 4908 | + ? 'SureForms saved %d entry but could not send the notification email for it.' | |
| 4909 | + : 'SureForms saved %d entries but could not send the notification emails for them.' ), | |
| 4910 | + $count | |
| 4911 | + ); | |
| 4912 | + | |
| 4913 | + case 'integration': | |
| 4914 | + return sprintf( | |
| 4915 | + ( 1 === $count | |
| 4916 | + ? 'SureForms saved %d entry but could not pass it to a connected service.' | |
| 4917 | + : 'SureForms saved %d entries but could not pass them to a connected service.' ), | |
| 4918 | + $count | |
| 4919 | + ); | |
| 4920 | + | |
| 4921 | + default: | |
| 4922 | + return sprintf( | |
| 4923 | + ( 1 === $count | |
| 4924 | + ? 'SureForms has recorded %d problem with the forms on this site.' | |
| 4925 | + : 'SureForms has recorded %d problems with the forms on this site.' ), | |
| 4926 | + $count | |
| 4927 | + ); | |
| 4928 | + } | |
| 4929 | + } | |
| 4930 | + | |
| 4931 | + /** | |
| 4932 | + * Diagnostics block for the support report. | |
| 4933 | + * | |
| 4361 | 4934 | * Carries what support would otherwise have to ask for, so the first reply can |
| 4362 | 4935 | * be an answer rather than a questionnaire. |
| 4363 | 4936 | * |
| 4937 | + * The count is the one for this category, not get_fault_streak(), which reports | |
| 4938 | + * submissions only -- so a notification failure used to quote a number from an | |
| 4939 | + * unrelated counter, often zero. | |
| 4940 | + * | |
| 4941 | + * @param string $category One of Client_Logger::CATEGORIES. | |
| 4942 | + * @param string $form_title Form the failure was recorded against, when known. | |
| 4364 | 4943 | * @since 2.12.6 |
| 4365 | 4944 | * @return string |
| 4366 | 4945 | */ |
| 4367 | - private function get_support_message() { | |
| 4946 | + private function get_support_message( $category = '', $form_title = '' ) { | |
| 4368 | 4947 | global $wp_version; |
| 4369 | 4948 | |
| 4370 | - $count = Client_Logger::get_fault_streak(); | |
| 4949 | + $failures = Client_Logger::get_failures(); | |
| 4950 | + $count = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 ); | |
| 4951 | + $copy = $this->get_support_copy( $category ); | |
| 4371 | 4952 | |
| 4953 | + // With nothing recorded, describe the failure without a number. The old | |
| 4954 | + // max( 1, $count ) reported "recorded 1 problem" and "Recorded failures: 1" | |
| 4955 | + // for a count nobody recorded -- a number support would then chase. | |
| 4956 | + $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ); | |
| 4957 | + | |
| 4372 | 4958 | $lines = [ |
| 4373 | - __( 'Hello SureForms support,', 'sureforms' ), | |
| 4959 | + 'Hello SureForms support,', | |
| 4374 | 4960 | '', |
| 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' | |
| 4961 | + $count > 0 | |
| 4962 | + ? $this->get_support_count_sentence( $category, $count ) | |
| 4963 | + : sprintf( $copy['anon'], $host ), | |
| 4964 | + ]; | |
| 4965 | + | |
| 4966 | + if ( '' !== $form_title ) { | |
| 4967 | + $lines[] = ''; | |
| 4968 | + $lines[] = sprintf( | |
| 4969 | + 'Form: %s', | |
| 4970 | + $form_title | |
| 4971 | + ); | |
| 4972 | + } | |
| 4973 | + | |
| 4974 | + // Once: each call reads an option and a site option. | |
| 4975 | + $caching = Helper::get_active_caching_plugin(); | |
| 4976 | + | |
| 4977 | + $lines = array_merge( | |
| 4978 | + $lines, | |
| 4979 | + [ | |
| 4980 | + '', | |
| 4981 | + '---', | |
| 4982 | + 'Site details', | |
| 4983 | + sprintf( 'Site: %s', home_url() ), | |
| 4984 | + sprintf( 'SureForms: %s', SRFM_VER ), | |
| 4985 | + sprintf( | |
| 4986 | + 'SureForms Pro: %s', | |
| 4987 | + Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : 'not active' | |
| 4382 | 4988 | ), |
| 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 | - ]; | |
| 4989 | + sprintf( 'WordPress: %s', Helper::get_string_value( $wp_version ) ), | |
| 4990 | + sprintf( 'PHP: %s', PHP_VERSION ), | |
| 4991 | + sprintf( | |
| 4992 | + 'Caching: %s', | |
| 4993 | + '' !== $caching ? $caching : 'none detected' | |
| 4994 | + ), | |
| 4995 | + sprintf( | |
| 4996 | + 'Recorded failures: %s', | |
| 4997 | + $count > 0 ? Helper::get_string_value( $count ) : 'none recorded' | |
| 4998 | + ), | |
| 4999 | + ] | |
| 5000 | + ); | |
| 4396 | 5001 | |
| 4397 | - return implode( "\r\n", $lines ); | |
| 5002 | + // Only when there is one. A repeat report is worth knowing about: the same | |
| 5003 | + // category having been reported before means the last answer did not hold, | |
| 5004 | + // which is a different conversation from a first report. Appended with the | |
| 5005 | + // rest of the site details rather than raised to the top, because it is | |
| 5006 | + // context for them rather than a headline. | |
| 5007 | + // | |
| 5008 | + // Survives only until the next success in that category, because | |
| 5009 | + // clear_category() unsets the whole record -- so in practice it is | |
| 5010 | + // reachable for 'integration', which has no success signal, and transient | |
| 5011 | + // for the other two. | |
| 5012 | + // | |
| 5013 | + // Stored as time(), a UTC epoch comparable with the sibling 'at', and | |
| 5014 | + // formatted here with wp_date() so it reads in the site's timezone rather | |
| 5015 | + // than the server's. | |
| 5016 | + $acked_at = Helper::get_integer_value( $failures[ $category ]['acked_at'] ?? 0 ); | |
| 5017 | + | |
| 5018 | + if ( $acked_at > 0 ) { | |
| 5019 | + $lines[] = sprintf( | |
| 5020 | + 'Previously reported: %s', | |
| 5021 | + Helper::get_string_value( wp_date( 'Y-m-d H:i T', $acked_at ) ) | |
| 5022 | + ); | |
| 5023 | + } | |
| 5024 | + | |
| 5025 | + return implode( "\n", $lines ); | |
| 4398 | 5026 | } |
| 4399 | 5027 | |
| 4400 | 5028 | /** |
| 4401 | 5029 | * Record one dismissal, shared by the AJAX and no-JS entry points. |
| @@ -4420,9 +5048,9 @@ | ||
| 4420 | 5048 | Helper::update_srfm_option( 'dismissed_action_items', $dismissed ); |
| 4421 | 5049 | |
| 4422 | 5050 | // Recorded here rather than at each caller: both the cross in the |
| 4423 | 5051 | // dashboard panel and the no-JS link in the classic notice land here. |
| 4424 | - Analytics::events()->track( $item_id . '_notice_dismiss', 'dismissed' ); | |
| 5052 | + $this->track_notice_event( $item_id . '_notice_dismiss' ); | |
| 4425 | 5053 | } |
| 4426 | 5054 | |
| 4427 | 5055 | return true; |
| 4428 | 5056 | } |