PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | admin/admin.php +827 -199 2.12.6 → 2.12.8 View file →
@@ -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 &#038; 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 }