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 +235 -466 2.12.7 → 2.12.8 View file →
@@ -88,19 +88,31 @@
88 88 */
89 89 public const THANKYOU_PROMPT_NOTICE_ID = 'srfm-thankyou-prompt';
90 90
91 91 /**
92 - * Where the dialog's Contact Support button goes.
92 + * Where the Contact Support button writes to.
93 93 *
94 - * A form rather than an inbox: it collects the licence and site details support
95 - * would otherwise have to ask for, and the diagnostics are already on the
96 - * clipboard by the time someone gets here.
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.
97 99 *
98 - * @since 2.12.7
100 + * @since 2.12.8
99 101 */
100 - private const SUPPORT_CONTACT_URL = 'https://sureforms.com/form/troubleshooting-form/';
102 + private const SUPPORT_EMAIL = '[email protected]';
101 103
102 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 + /**
103 115 * Dashboard widget entries data.
104 116 *
105 117 * @var array
106 118 * @since 1.9.1
@@ -177,9 +189,11 @@
177 189 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
178 190 add_action( 'admin_menu', [ $this, 'settings_page' ] );
179 191 add_action( 'admin_menu', [ $this, 'add_learn_page' ] );
180 192 add_action( 'admin_menu', [ $this, 'add_new_form' ] );
181 - add_action( 'admin_menu', [ $this, 'add_suremail_page' ] );
193 + if ( ! Helper::hide_promotions() ) {
194 + add_action( 'admin_menu', [ $this, 'add_suremail_page' ] );
195 + }
182 196 if ( ! Helper::has_pro() ) {
183 197 add_action( 'admin_menu', [ $this, 'add_quiz_page' ] );
184 198 add_action( 'admin_menu', [ $this, 'add_survey_reports_page' ] );
185 199 add_action( 'admin_menu', [ $this, 'add_partial_entries_page' ] );
@@ -232,9 +246,8 @@
232 246 add_action( 'wp_ajax_should_show_pointer', [ $this, 'pointer_should_show' ] );
233 247 add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] );
234 248 add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] );
235 249 add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] );
236 - add_action( 'wp_ajax_srfm_action_item_details', [ $this, 'handle_action_item_details' ] );
237 250 add_action( 'wp_ajax_srfm_dismiss_action_item', [ $this, 'handle_dismiss_action_item' ] );
238 251 add_action( 'admin_post_srfm_dismiss_action_item_link', [ $this, 'handle_dismiss_action_item_link' ] );
239 252 add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] );
240 253 add_action( 'load-post.php', [ $this, 'maybe_track_edit_form_button_click' ] );
@@ -627,9 +640,9 @@
627 640 * @since 2.12.4
628 641 * @return void
629 642 */
630 643 public function register_form_setup_widget() {
631 - if ( ! Helper::current_user_can() ) {
644 + if ( ! Helper::current_user_can() || Helper::hide_promotions() ) {
632 645 return;
633 646 }
634 647
635 648 if ( null === self::get_form_setup_card() ) {
@@ -734,9 +747,9 @@
734 747 * @since 2.12.4
735 748 * @return void
736 749 */
737 750 public function enqueue_form_setup_widget_assets( $hook_suffix ) {
738 - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
751 + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) {
739 752 return;
740 753 }
741 754
742 755 $card = self::get_form_setup_card();
@@ -1295,12 +1308,9 @@
1295 1308 public function add_quiz_page() {
1296 1309 add_submenu_page(
1297 1310 'sureforms_menu',
1298 1311 __( 'Quiz Entries', 'sureforms' ),
1299 - __( 'Quizzes', 'sureforms' ) .
1300 - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1301 - esc_html__( 'New', 'sureforms' ) .
1302 - '</span>',
1312 + __( 'Quizzes', 'sureforms' ),
1303 1313 self::$sureforms_page_default_capability,
1304 1314 'sureforms_quiz_entries',
1305 1315 [ $this, 'render_quiz_empty_state' ],
1306 1316 5
@@ -1328,12 +1338,9 @@
1328 1338 public function add_survey_reports_page() {
1329 1339 add_submenu_page(
1330 1340 'sureforms_menu',
1331 1341 __( 'Survey Reports', 'sureforms' ),
1332 - __( 'Survey Reports', 'sureforms' ) .
1333 - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1334 - esc_html__( 'New', 'sureforms' ) .
1335 - '</span>',
1342 + __( 'Survey Reports', 'sureforms' ),
1336 1343 self::$sureforms_page_default_capability,
1337 1344 'sureforms_survey_reports',
1338 1345 [ $this, 'render_survey_empty_state' ],
1339 1346 6
@@ -1361,12 +1368,9 @@
1361 1368 public function add_partial_entries_page() {
1362 1369 add_submenu_page(
1363 1370 'sureforms_menu',
1364 1371 __( 'Partial Entries', 'sureforms' ),
1365 - __( 'Partial Entries', 'sureforms' ) .
1366 - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1367 - esc_html__( 'New', 'sureforms' ) .
1368 - '</span>',
1372 + __( 'Partial Entries', 'sureforms' ),
1369 1373 self::$sureforms_page_default_capability,
1370 1374 'sureforms_partial_entries',
1371 1375 [ $this, 'render_partial_entries_empty_state' ],
1372 1376 7
@@ -1814,20 +1818,14 @@
1814 1818 'sureforms_pricing_page' => Helper::get_sureforms_website_url( 'pricing' ),
1815 1819 'field_spacing_vars' => Helper::get_css_vars(),
1816 1820 'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ),
1817 1821 'integrations' => Helper::sureforms_get_integration(),
1818 - '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(),
1819 1825 'ajax_url' => admin_url( 'admin-ajax.php' ),
1820 1826 'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '',
1821 1827 'action_items' => $this->get_action_items(),
1822 - 'details_dialog' => $this->get_details_dialog_labels(),
1823 - // Where Contact Support goes when the details fetch fails and there is
1824 - // no category-tagged URL to use. Untagged, because at that point we do
1825 - // not know which check sent them -- but still a way out: these notices
1826 - // are not dismissible and Contact Support is the only action that
1827 - // retires them.
1828 - 'support_url' => $this->get_support_contact_url( '' ),
1829 - 'action_item_details_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_action_item_details' ) : '',
1830 1828 'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '',
1831 1829 'dismiss_action_item_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_dismiss_action_item' ) : '',
1832 1830 'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ),
1833 1831 'plugin_installer_nonce' => wp_create_nonce( 'updates' ),
@@ -1838,8 +1836,12 @@
1838 1836 'plugin_installed_text' => __( 'Installed', 'sureforms' ),
1839 1837 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ),
1840 1838 'is_rtl' => $is_rtl,
1841 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' ),
1842 1844 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false,
1843 1845 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ),
1844 1846 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection.
1845 1847 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ),
@@ -2673,10 +2675,10 @@
2673 2675 if ( ! Helper::current_user_can() ) {
2674 2676 return;
2675 2677 }
2676 2678
2677 - // Allow the notice to be disabled.
2678 - 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 ) ) {
2679 2681 return;
2680 2682 }
2681 2683
2682 2684 $notice_id = 'srfm-plugin-review-notice';
@@ -2817,94 +2819,25 @@
2817 2819 wp_localize_script(
2818 2820 'srfm-notice-response',
2819 2821 'srfmNoticeResponse',
2820 2822 [
2821 - 'ajaxurl' => admin_url( 'admin-ajax.php' ),
2822 - 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
2823 - // The diagnostics are fetched when the dialog opens rather than
2824 - // shipped with every page, so the dialog needs its own nonce.
2825 - 'detailsNonce' => wp_create_nonce( 'srfm_action_item_details' ),
2823 + 'ajaxurl' => admin_url( 'admin-ajax.php' ),
2824 + 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
2826 2825 // Carousel chrome. Built in the browser rather than printed here so
2827 2826 // that with JavaScript off every notice simply stays visible, which
2828 2827 // is the behaviour this replaced -- controls that cannot work must
2829 2828 // not be what hides a warning.
2830 - 'carousel' => [
2829 + 'carousel' => [
2831 2830 'previous' => __( 'Previous notice', 'sureforms' ),
2832 2831 'next' => __( 'Next notice', 'sureforms' ),
2833 2832 /* translators: 1: current position, 2: total notices. */
2834 2833 'counter' => __( '%1$d of %2$d', 'sureforms' ),
2835 2834 ],
2836 - // Details modal chrome, translated here so the script carries no
2837 - // user-facing English of its own.
2838 - 'details' => $this->get_details_dialog_labels(),
2839 - // Where Contact Support goes when the fetch fails and there is no
2840 - // category-tagged URL to use. Untagged, because at that point we do
2841 - // not know which check sent them -- but still a way out: these
2842 - // notices are not dismissible and Contact Support is the only action
2843 - // that retires them.
2844 - 'supportUrl' => $this->get_support_contact_url( '' ),
2845 2835 ]
2846 2836 );
2847 2837 }
2848 2838
2849 2839 /**
2850 - * Serve one failure category's diagnostics, on demand.
2851 - *
2852 - * Hooked - wp_ajax_srfm_action_item_details.
2853 - *
2854 - * The report is built here rather than shipped with the page. Its content
2855 - * comes from the client error log, and that log is filled through a public
2856 - * REST route gated on a submit token any visitor can obtain from a form page
2857 - * rather than on a capability -- so the text is attacker-authored, and putting
2858 - * it in the localisation JSON and a hidden div on every admin screen exposed
2859 - * it far beyond the one admin who opens the dialog.
2860 - *
2861 - * Capability first, then nonce, then the category, matching the ordering of
2862 - * the sibling handlers in this class.
2863 - *
2864 - * @since 2.12.7
2865 - * @return void
2866 - */
2867 - public function handle_action_item_details() {
2868 - if ( ! Helper::current_user_can() ) {
2869 - wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
2870 - return;
2871 - }
2872 -
2873 - if ( ! check_ajax_referer( 'srfm_action_item_details', 'nonce', false ) ) {
2874 - wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
2875 - return;
2876 - }
2877 -
2878 - // sanitize_key() returns '' for anything non-scalar (formatting.php:2194), so
2879 - // a category[]= in the body arrives here as the empty string and falls into
2880 - // the refusal below rather than needing a type branch of its own.
2881 - $category = isset( $_POST['category'] ) ? sanitize_key( wp_unslash( $_POST['category'] ) ) : '';
2882 -
2883 - // The only check the category needs, and the reason there is no separate
2884 - // allowlist above it: get_open_failures() returns nothing but keys in
2885 - // Client_Logger::CATEGORIES, so an absent category, an unrecognised one and
2886 - // a recognised one with nothing wrong all land here. Asking for a category
2887 - // with no fault must not mint a report describing one.
2888 - $open = Client_Logger::get_open_failures();
2889 -
2890 - if ( ! isset( $open[ $category ] ) ) {
2891 - wp_send_json_error( [ 'message' => __( 'Nothing to report.', 'sureforms' ) ], 404 );
2892 - return;
2893 - }
2894 -
2895 - $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' );
2896 -
2897 - wp_send_json_success(
2898 - [
2899 - 'details' => $this->get_support_message( $category, $form_title )
2900 - . "\n\n" . $this->get_support_log_block( 8000 ),
2901 - 'support_url' => $this->get_support_contact_url( $category ),
2902 - ]
2903 - );
2904 - }
2905 -
2906 - /**
2907 2840 * Handle the notice response AJAX request.
2908 2841 *
2909 2842 * Validates the request and records the analytics event
2910 2843 * for the notice button that was clicked.
@@ -2946,23 +2879,17 @@
2946 2879 'dismissed' => 'database_error_notice_dismiss',
2947 2880 ],
2948 2881 // The "Finish setting up" prompt (#3030): three CTAs, plus the ✕.
2949 2882 'form_submission_error' => [
2950 - 'view_details' => 'submission_failure_notice_view',
2951 - 'copy_details' => 'submission_failure_notice_copy',
2952 2883 'contact_support' => 'submission_failure_notice_cta',
2953 2884 'dismissed' => 'submission_failure_notice_dismiss',
2954 2885 ],
2955 2886 'notification_error' => [
2956 - 'view_details' => 'notification_failure_notice_view',
2957 - 'copy_details' => 'notification_failure_notice_copy',
2958 2887 'contact_support' => 'notification_failure_notice_cta',
2959 2888 'help_me_fix' => 'notification_failure_notice_guide',
2960 2889 'dismissed' => 'notification_failure_notice_dismiss',
2961 2890 ],
2962 2891 'integration_error' => [
2963 - 'view_details' => 'integration_failure_notice_view',
2964 - 'copy_details' => 'integration_failure_notice_copy',
2965 2892 'contact_support' => 'integration_failure_notice_cta',
2966 2893 'dismissed' => 'integration_failure_notice_dismiss',
2967 2894 ],
2968 2895 'caching_plugin' => [
@@ -2987,10 +2914,12 @@
2987 2914
2988 2915 $this->track_notice_event( $valid[ $notice_id ][ $button ] );
2989 2916
2990 2917 // Reporting the failures retires the notice until something new fails.
2991 - // Handled here rather than in the browser so it holds for the classic
2992 - // 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.
2993 2922 $categories = [
2994 2923 'form_submission_error' => 'submission',
2995 2924 'notification_error' => 'notification',
2996 2925 'integration_error' => 'integration',
@@ -3147,10 +3076,11 @@
3147 3076 * @since 1.9.1
3148 3077 */
3149 3078 public function maybe_register_dashboard_widget() {
3150 3079
3151 - // Only for users with manage_options capability.
3152 - 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() ) {
3153 3083 return;
3154 3084 }
3155 3085
3156 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.
@@ -3253,9 +3183,9 @@
3253 3183 * @since 2.12.1
3254 3184 */
3255 3185 public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) {
3256 3186 // Only on the main dashboard, and only for capable users (matches the widget gate).
3257 - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
3187 + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) {
3258 3188 return;
3259 3189 }
3260 3190
3261 3191 // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching
@@ -3626,19 +3556,12 @@
3626 3556 class="<?php echo $has_guide ? 'button' : 'button button-primary'; ?>"
3627 3557 data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>"
3628 3558 data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['cta_action'] ?? '' ) ); ?>"
3629 3559 <?php
3630 - // With details to fetch, the click opens them here instead
3631 - // of following the href. The href stays as the no-JS
3632 - // path: it goes to the dashboard, where the same details
3633 - // are readable.
3634 - if ( ! empty( $item['has_details'] ) ) {
3635 - printf(
3636 - 'data-srfm-details-for="%1$s" data-srfm-category="%2$s"',
3637 - esc_attr( Helper::get_string_value( $item['id'] ) ),
3638 - esc_attr( Helper::get_string_value( $item['category'] ?? '' ) )
3639 - );
3640 - } elseif ( 0 !== strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ) {
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:' ) ) {
3641 3564 echo 'target="_blank" rel="noopener noreferrer"';
3642 3565 }
3643 3566 ?>
3644 3567 >
@@ -3785,12 +3708,11 @@
3785 3708 * cta_label, cta_url and dismissible. Only ids in
3786 3709 * handle_dismiss_action_item()'s allowlist can actually be dismissed, so
3787 3710 * adding a dismissible item here also needs a line there.
3788 3711 *
3789 - * The details dialog is not available here: it is served by
3790 - * handle_action_item_details(), which reads SureForms' own client error log
3791 - * and knows nothing about a third-party item. Such an item's cta_url is
3792 - * followed as a link, which is what it does with JavaScript off anyway.
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.
3793 3715 *
3794 3716 * @since 2.12.6
3795 3717 *
3796 3718 * @param array<int,array<string,mixed>> $items Action items.
@@ -3829,13 +3751,14 @@
3829 3751 [ 'http', 'https', 'mailto' ]
3830 3752 );
3831 3753 }
3832 3754
3833 - // The id ends up in a data attribute the dialog matches on with an
3834 - // attribute selector, and in the dismiss allowlist. sanitize_key() is
3835 - // what both dismiss paths already apply, so applying it once here means
3836 - // the value that renders is the value they compare against -- and a
3837 - // filter-contributed id carrying a quote cannot break the selector.
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.
3838 3761 if ( isset( $item['id'] ) ) {
3839 3762 $items[ $index ]['id'] = sanitize_key( Helper::get_string_value( $item['id'] ) );
3840 3763 }
3841 3764 }
@@ -3877,17 +3800,16 @@
3877 3800 wp_send_json_success();
3878 3801 }
3879 3802
3880 3803 /**
3881 - * The stylesheet for the notice carousel and the details dialog.
3804 + * The stylesheet for the notice carousel.
3882 3805 *
3883 3806 * In a stylesheet rather than inline style assignments in
3884 - * notice-response.js, so the rules use logical properties, an RTL sheet can
3885 - * override them, and a site can restyle the dialog without patching a script.
3807 + * notice-response.js, so the rules use logical properties and an RTL sheet can
3808 + * override them.
3886 3809 *
3887 - * Only the classic wp-admin surface needs these. The SureForms dashboard's
3888 - * dialog is force-ui's, styled by the Tailwind build, so nothing here reaches
3889 - * it -- the two surfaces share their strings, not their markup.
3810 + * Only the classic wp-admin surface needs these. The SureForms dashboard is
3811 + * styled by the Tailwind build, so nothing here reaches it.
3890 3812 *
3891 3813 * Attached to a registered handle with no file of its own, which is the WP way
3892 3814 * to ship CSS tied to one script.
3893 3815 *
@@ -3898,15 +3820,8 @@
3898 3820 * rendered expanded before collapsing to one, the carousel controls overlapped
3899 3821 * the notice text, and the defensive `display: none` on the hidden payload was
3900 3822 * inert, which is the exact window that rule exists for.
3901 3823 *
3902 - * The buttons are painted explicitly. They carry core's `button` classes for
3903 - * their shape and focus behaviour, and core paints those with
3904 - * `var(--wp-admin-theme-color)` -- so without this the dialog renders in
3905 - * whichever admin colour scheme the user picked, which on a default install is
3906 - * blue, on a SureForms panel that is otherwise entirely brand orange. Same
3907 - * approach and same values as print_srfm_notice_styles().
3908 - *
3909 3824 * @since 2.12.7
3910 3825 * @return void
3911 3826 */
3912 3827 public function enqueue_action_item_styles() {
@@ -3955,106 +3870,8 @@
3955 3870 display: flex;
3956 3871 align-items: center;
3957 3872 gap: 8px;
3958 3873 }
3959 -.srfm-details-overlay {
3960 - position: fixed;
3961 - inset: 0;
3962 - z-index: 999999;
3963 - display: flex;
3964 - align-items: center;
3965 - justify-content: center;
3966 - background: rgba(0, 0, 0, .5);
3967 - padding: 16px;
3968 -}
3969 -.srfm-details-panel {
3970 - background: #fff;
3971 - border-radius: 8px;
3972 - padding: 16px;
3973 - width: 100%;
3974 - max-width: 800px;
3975 - box-shadow: 0 10px 30px rgba(0, 0, 0, .2);
3976 -}
3977 -.srfm-details-panel h2 { margin: 0 0 4px; font-size: 14px; }
3978 -.srfm-details-panel .srfm-details-description { margin: 0 0 12px; color: #50575e; }
3979 -.srfm-details-panel pre {
3980 - margin: 0;
3981 - max-height: 320px;
3982 - overflow: auto;
3983 - white-space: pre-wrap;
3984 - word-break: break-word;
3985 - background: #f6f7f7;
3986 - padding: 12px;
3987 - border-radius: 6px;
3988 - font-size: 12px;
3989 -}
3990 -.srfm-details-actions {
3991 - display: flex;
3992 - gap: 8px;
3993 - align-items: center;
3994 - flex-wrap: wrap;
3995 - justify-content: flex-end;
3996 - margin: 12px 0 0;
3997 -}
3998 -.srfm-details-hint {
3999 - margin-inline-end: auto;
4000 - font-size: 12px;
4001 - color: #4b5563;
4002 -}
4003 -/* Core paints .button with the admin colour scheme, so these say what they are
4004 - rather than inheriting whichever scheme the user picked. */
4005 -.srfm-details-panel .srfm-details-close.button-link {
4006 - color: #50575e;
4007 - text-decoration: none;
4008 -}
4009 -.srfm-details-panel .srfm-details-close.button-link:hover,
4010 -.srfm-details-panel .srfm-details-close.button-link:focus {
4011 - color: #1e1e1e;
4012 -}
4013 -.srfm-details-panel .srfm-details-copy.button {
4014 - background: #fff;
4015 - border-color: #c3c4c7;
4016 - color: #1e1e1e;
4017 -}
4018 -.srfm-details-panel .srfm-details-copy.button:hover,
4019 -.srfm-details-panel .srfm-details-copy.button:focus {
4020 - background: #f6f7f7;
4021 - border-color: #8c8f94;
4022 - color: #1e1e1e;
4023 -}
4024 -.srfm-details-panel .srfm-details-contact.button-primary,
4025 -.srfm-details-panel .srfm-details-contact.button-primary:hover,
4026 -.srfm-details-panel .srfm-details-contact.button-primary:focus {
4027 - background: #D54407;
4028 - border-color: #D54407;
4029 - color: #fff;
4030 - box-shadow: none;
4031 - text-shadow: none;
4032 - text-decoration: none;
4033 -}
4034 -.srfm-details-panel .srfm-details-contact.button-primary:hover,
4035 -.srfm-details-panel .srfm-details-contact.button-primary:focus {
4036 - background: #C83B00;
4037 - border-color: #C83B00;
4038 -}
4039 -/* Grey rather than a dimmed orange fill. Core sets the disabled text colour with
4040 - !important, so an orange background here leaves grey on orange at 1.31:1 --
4041 - and a control that cannot be used should not wear the primary colour anyway.
4042 - This is what core gives every other disabled button, and what force-ui renders
4043 - for the same state on the dashboard, so the two surfaces agree. */
4044 -.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"],
4045 -.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:hover,
4046 -.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:focus {
4047 - background: #f6f7f7;
4048 - border-color: #dcdcde;
4049 - pointer-events: none;
4050 - box-shadow: none;
4051 -}
4052 -.srfm-details-panel .button:focus {
4053 - outline: 2px solid #D54407;
4054 - outline-offset: 1px;
4055 - box-shadow: none;
4056 -}
4057 3874 CSS;
4058 3875
4059 3876 wp_add_inline_style( 'srfm-action-items', $css );
4060 3877 }
@@ -4059,50 +3876,8 @@
4059 3876 wp_add_inline_style( 'srfm-action-items', $css );
4060 3877 }
4061 3878
4062 3879 /**
4063 - * The details dialog's strings.
4064 - *
4065 - * One array, two consumers: the classic wp-admin dialog in
4066 - * notice-response.js, and the dashboard's force-ui one. Declared here rather
4067 - * than inline in each, because the same sentence written as `__()` in PHP and
4068 - * again in JSX looks identical to translators until the first edit to either,
4069 - * after which one surface silently reverts to English.
4070 - *
4071 - * @since 2.12.7
4072 - * @return array<string,string>
4073 - */
4074 - private function get_details_dialog_labels() {
4075 - return [
4076 - 'title' => __( 'Details', 'sureforms' ),
4077 - 'description' => __( 'What we recorded about this problem. Copy it into your support request so we can start from the cause rather than a description of it.', 'sureforms' ),
4078 - 'copy' => __( 'Copy details', 'sureforms' ),
4079 - 'copied' => __( 'Copied', 'sureforms' ),
4080 - 'contact' => __( 'Contact Support', 'sureforms' ),
4081 - 'close' => __( 'Close', 'sureforms' ),
4082 - // Shown beside the buttons rather than as a title attribute:
4083 - // pointer-events:none suppresses the native tooltip, a title
4084 - // never fires on keyboard focus, and screen readers commonly
4085 - // drop it on an unavailable control -- so the sentence saying
4086 - // why the button is inert could not be read by anyone.
4087 - 'copyFirst' => __( 'Copy the details first, so you have them to paste.', 'sureforms' ),
4088 - // The unlock changes the label, the icon and whether Contact
4089 - // Support works, none of which was announced. This goes in a
4090 - // role="status" node so it is.
4091 - 'unlocked' => __( 'Copied. Contact Support is now available.', 'sureforms' ),
4092 - 'copyFailed' => __( 'Your browser would not let us copy. Select the text above and copy it by hand.', 'sureforms' ),
4093 - // The scrollable diagnostics block is focusable, so it needs a name of
4094 - // its own.
4095 - 'logRegion' => __( 'Recorded diagnostics', 'sureforms' ),
4096 - // The dialog opens before its payload arrives -- see
4097 - // handle_action_item_details() for why the report is not shipped with
4098 - // the page.
4099 - 'loading' => __( 'Collecting the details…', 'sureforms' ),
4100 - 'unavailable' => __( 'We could not collect the details. Contact Support and describe what happened, and we will take it from there.', 'sureforms' ),
4101 - ];
4102 - }
4103 -
4104 - /**
4105 3880 * SureForms' own action items, before the filter.
4106 3881 *
4107 3882 * Split out so the Enable Logs gate in get_action_items() can sit above this
4108 3883 * rather than above `srfm_action_items`. An item contributed through that
@@ -4169,32 +3944,26 @@
4169 3944 'title' => '' !== $form_title
4170 3945 ? sprintf( $copy['title'], $form_title )
4171 3946 : $copy['generic'],
4172 3947 'message' => $copy['message'],
4173 - // Shows what would be sent before anything is sent. Someone reporting
4174 - // a fault on their own site is entitled to read the diagnostics and
4175 - // the log first, and a support agent gets a cleaner paste than a
4176 - // screenshot of a notice.
4177 - 'cta_label' => __( 'View details', 'sureforms' ),
4178 - // Where the classic wp-admin notice sends people, since it cannot open
4179 - // the panel's dialog. The dashboard is where the details are readable.
4180 - 'cta_url' => admin_url( 'admin.php?page=sureforms_menu' ),
4181 - 'cta_action' => 'view_details',
4182 - // Not the payload itself, only that one exists. The diagnostics are
4183 - // fetched when the dialog opens -- see handle_action_item_details().
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.
4184 3951 //
4185 - // They used to ride along in the localisation JSON and in a hidden
4186 - // div on every admin page. The content is authored by whoever
4187 - // triggered the failure, and the client-error-log route is a public
4188 - // endpoint gated on a submit token rather than a capability, so an
4189 - // anonymous visitor can fill that excerpt. Broadcasting it to every
4190 - // admin screen -- read or not -- put attacker-authored text in page
4191 - // source site-wide and made any future escaping slip a
4192 - // manage_options-context problem. On demand, it reaches only the
4193 - // admin who asked for it.
4194 - 'has_details' => true,
4195 - // Which record to fetch. Not the payload, just the key.
4196 - 'category' => $category,
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.
3963 + 'cta_label' => __( 'Contact Support', 'sureforms' ),
3964 + 'cta_url' => $this->get_support_contact_url( $category, $form_title ),
3965 + 'cta_action' => 'contact_support',
4197 3966 'dismissible' => false,
4198 3967 ];
4199 3968
4200 3969 // A second, optional action. Absent keys render nothing, so a category
@@ -4769,11 +4538,13 @@
4769 4538 private function is_admin_pointer_visible() {
4770 4539 global $pagenow;
4771 4540 $allowed_pages = [ 'index.php', 'options-general.php' ];
4772 4541
4773 - // 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.
4774 4544 if (
4775 - ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) )
4545 + Helper::hide_promotions()
4546 + || ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) )
4776 4547 || ! empty( Helper::get_srfm_option( 'pointer_popup_accepted' ) )
4777 4548 || (int) ( wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0 ) > 1
4778 4549 ) {
4779 4550 return false;
@@ -4898,106 +4669,147 @@
4898 4669 Analytics::events()->track( $event_name, (string) $counts[ $event_name ], [], true );
4899 4670 }
4900 4671
4901 4672 /**
4902 - * The contact form's address, tagged with where the click came from.
4673 + * A pre-addressed support email for the failure being reported.
4903 4674 *
4904 - * One campaign, tagged per failure, so the report answers which check actually
4905 - * sends people to support rather than only how many arrive. A submission
4906 - * failure and a caching advisory are different problems and it is worth knowing
4907 - * which one drives the tickets.
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.
4908 4681 *
4909 - * Prefilled with what SureForms already knows -- the admin's address, which
4910 - * failure it is, and the site host -- so the person reporting a fault does not
4911 - * retype it. Worth knowing that the address travels in the query string, so it
4912 - * reaches browser history and any referrer along the way; it is the site
4913 - * owner's own address going to SureForms' own form, which is the flow this
4914 - * button exists for.
4682 + * The log is pasted into the body rather than attached because mailto has no
4683 + * attachment parameter -- browsers drop any attempt to add one -- and it is a
4684 + * tail rather than the whole file because a megabyte of JSON would exceed the
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.
4915 4687 *
4916 - * Built with add_query_arg rather than string concatenation, so it stays
4917 - * correct if the constant ever gains a query string of its own.
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.
4918 4691 *
4919 - * @param string $category One of Client_Logger::CATEGORIES, naming the failure
4920 - * the visitor is reporting.
4921 - * @since 2.12.7
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
4922 4697 * @return string
4923 4698 */
4924 - private function get_support_contact_url( $category ) {
4925 - // Deliberately not translated. These are matched against the options on the
4926 - // troubleshooting form, so they are machine values, not copy -- a German
4927 - // site sending "E-Mail-Benachrichtigungsfehler" would arrive as an
4928 - // unrecognised subject and land in the wrong queue.
4929 - $subjects = [
4930 - 'submission' => 'Form submission failure',
4931 - 'notification' => 'Email notification failure',
4932 - 'integration' => 'Integration failure',
4933 - ];
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 ) );
4934 4702
4935 - $user = wp_get_current_user();
4703 + $subject = sprintf( $copy['subject'], $host );
4936 4704
4937 - $url = add_query_arg(
4938 - [
4939 - // Prefills the form, so the person reporting a fault does not retype
4940 - // what SureForms already knows. Empty rather than absent when the
4941 - // address is unusable, so the form still opens.
4942 - 'mail' => is_email( $user->user_email ) ? $user->user_email : '',
4943 - // Falls back to "Other" for a category SureForms does not define --
4944 - // srfm_action_items is public, so an item can carry any category or
4945 - // none.
4946 - 'subject' => $subjects[ $category ] ?? 'Other',
4947 - 'site_url' => Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ),
4948 - 'utm_source' => 'sureforms',
4949 - 'utm_medium' => 'form_checks',
4950 - 'utm_campaign' => 'contact_support',
4951 - // Which check sent them. The one part that differs per button, and
4952 - // the reason for tagging at all.
4953 - 'utm_content' => $category,
4954 - ],
4955 - self::SUPPORT_CONTACT_URL
4956 - );
4705 + $url = $this->build_support_mailto_within_limit( $category, $form_title, $subject );
4957 4706
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 + }
4711 +
4958 4712 /**
4959 4713 * Filter where the Contact Support action sends people.
4960 4714 *
4961 - * Replaces the `srfm_support_email_address` filter, which pointed at an
4962 - * inbox and has no destination left to change now that the action opens a
4963 - * form. A white-label install wants to point this at its own support page.
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.
4964 4721 *
4965 4722 * @since 2.12.7
4966 4723 *
4967 - * @param string $url Contact form URL, already UTM-tagged.
4968 - * @param string $category The failure being reported.
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 ''.
4969 4727 */
4970 - $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category ) );
4728 + $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category, $form_title ) );
4971 4729
4972 4730 // Escaped after the filter, not before: the point of escaping here is that
4973 - // neither renderer has to trust what comes back. mailto: is allowed because
4974 - // an inbox is a legitimate destination for a white-label support contact,
4975 - // and get_action_items() already allows it on the sibling item URLs.
4731 + // neither renderer has to trust what comes back.
4976 4732 $safe = esc_url_raw( $filtered, [ 'http', 'https', 'mailto' ] );
4977 4733
4978 4734 // Never empty. Contact Support is the only action that retires these
4979 4735 // notices and they are dismissible => false, so returning '' for a filter
4980 4736 // value that cannot survive escaping leaves an undismissable notice with
4981 - // nothing on it that works. Falling back to SureForms' own form is worse
4982 - // for a white-label than their own URL and better than a dead end, and the
4983 - // unfiltered URL is built here rather than supplied, so it always escapes.
4984 - return '' !== $safe ? $safe : esc_url_raw( $url, [ 'http', 'https' ] );
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' ] );
4985 4740 }
4986 4741
4987 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;
4774 + }
4775 + }
4776 +
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,
4797 + '',
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.
4802 + PHP_QUERY_RFC3986
4803 + );
4804 + }
4805 +
4806 + /**
4988 4807 * The log tail, formatted for pasting.
4989 4808 *
4990 - * One builder, so the text someone reads before sending is the text that gets
4991 - * sent. They used to be built separately, which is how a "details" view drifts
4992 - * from what it claims to show.
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.
4993 4811 *
4994 - * The budget is a parameter because nothing here is going into a URL any more.
4995 - * Client_Logger::get_tail()'s 1200-character default existed to fit a compose
4996 - * URL; a clipboard and a <pre> have no such limit, so the dialog asks for more
4997 - * and the note below describes the real constraint rather than a mail client
4998 - * that is not in this flow.
4999 - *
5000 4812 * @param int $max_chars Characters of log to include.
5001 4813 * @since 2.12.7
5002 4814 * @return string
5003 4815 */
@@ -5005,14 +4817,13 @@
5005 4817 $log = Client_Logger::get_tail( $max_chars );
5006 4818 $block = '---' . "\n";
5007 4819
5008 4820 if ( '' === $log['text'] ) {
5009 - return $block . __( 'Debug log: no entries recorded.', 'sureforms' );
4821 + return $block . 'Debug log: no entries recorded.';
5010 4822 }
5011 4823
5012 4824 $block .= sprintf(
5013 - /* translators: 1: entries shown, 2: entries recorded. */
5014 - __( 'Debug log (most recent %1$d of %2$d entries)', 'sureforms' ),
4825 + 'Debug log (most recent %1$d of %2$d entries)',
5015 4826 $log['shown'],
5016 4827 $log['total']
5017 4828 ) . "\n";
5018 4829
@@ -5020,9 +4831,9 @@
5020 4831 // Markdown is rendered.
5021 4832 $block .= '```' . "\n" . $log['text'] . "\n" . '```';
5022 4833
5023 4834 if ( $log['shown'] < $log['total'] ) {
5024 - $block .= "\n\n" . __( 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.', 'sureforms' );
4835 + $block .= "\n\n" . 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.';
5025 4836 }
5026 4837
5027 4838 return $block;
5028 4839 }
@@ -5031,10 +4842,9 @@
5031 4842 * Subject and countless opening line for one kind of failure.
5032 4843 *
5033 4844 * Both come from here so they cannot drift apart: a subject naming one problem
5034 4845 * over a body describing another is worse than either alone. The counted form
5035 - * of the opening line lives in get_support_count_sentence(), which needs
5036 - * `_n()`'s literals and so cannot be an array lookup.
4846 + * of the opening line lives in get_support_count_sentence().
5037 4847 *
5038 4848 * An unknown or absent category gets deliberately neutral wording. The
5039 4849 * alternative -- defaulting to the submission copy -- states something specific
5040 4850 * that may not be true, and an item contributed through srfm_action_items has no
@@ -5046,24 +4856,18 @@
5046 4856 */
5047 4857 private function get_support_copy( $category ) {
5048 4858 $copy = [
5049 4859 'submission' => [
5050 - /* translators: %s: site host. */
5051 - 'subject' => __( 'SureForms: form submissions are failing on %s', 'sureforms' ),
5052 - /* translators: %s: site host. */
5053 - 'anon' => __( 'SureForms has recorded form submissions on %s that could not be completed.', 'sureforms' ),
4860 + 'subject' => 'SureForms: form submissions are failing on %s',
4861 + 'anon' => 'SureForms has recorded form submissions on %s that could not be completed.',
5054 4862 ],
5055 4863 'notification' => [
5056 - /* translators: %s: site host. */
5057 - 'subject' => __( 'SureForms: notification emails are not being sent on %s', 'sureforms' ),
5058 - /* translators: %s: site host. */
5059 - 'anon' => __( 'SureForms saved entries on %s but could not send the notification emails for them.', 'sureforms' ),
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.',
5060 4866 ],
5061 4867 'integration' => [
5062 - /* translators: %s: site host. */
5063 - 'subject' => __( 'SureForms: an integration is not receiving entries on %s', 'sureforms' ),
5064 - /* translators: %s: site host. */
5065 - 'anon' => __( 'SureForms saved entries on %s but could not pass them to a connected service.', 'sureforms' ),
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.',
5066 4870 ],
5067 4871 ];
5068 4872
5069 4873 if ( isset( $copy[ $category ] ) ) {
@@ -5070,12 +4874,10 @@
5070 4874 return $copy[ $category ];
5071 4875 }
5072 4876
5073 4877 return [
5074 - /* translators: %s: site host. */
5075 - 'subject' => __( 'SureForms: a problem with the forms on %s', 'sureforms' ),
5076 - /* translators: %s: site host. */
5077 - 'anon' => __( 'SureForms has recorded a problem with the forms on %s.', 'sureforms' ),
4878 + 'subject' => 'SureForms: a problem with the forms on %s',
4879 + 'anon' => 'SureForms has recorded a problem with the forms on %s.',
5078 4880 ];
5079 4881 }
5080 4882
5081 4883 /**
@@ -5080,14 +4882,10 @@
5080 4882
5081 4883 /**
5082 4884 * The sentence that opens the support email, with the failure count in it.
5083 4885 *
5084 - * A switch with literal `_n()` calls rather than a singular/plural pair looked
5085 - * up from an array. `_n()` has to see its two literals at extraction time to
5086 - * emit an `msgid_plural`, and only that lets a locale supply the number of
5087 - * forms it actually uses -- Polish and Russian need three, Arabic six,
5088 - * Japanese one. Choosing on `1 === $count` in PHP is correct for English and
5089 - * wrong everywhere with a different plural rule.
4886 + * English only, like the rest of the support email, so `1 === $count` is the
4887 + * whole plural rule.
5090 4888 *
5091 4889 * @param string $category One of Client_Logger::CATEGORIES. Unknown or absent
5092 4890 * gets neutral wording rather than a specific claim.
5093 4891 * @param int $count Failures recorded for that category.
@@ -5097,51 +4895,35 @@
5097 4895 private function get_support_count_sentence( $category, $count ) {
5098 4896 switch ( $category ) {
5099 4897 case 'submission':
5100 4898 return sprintf(
5101 - /* translators: %d: number of failed submissions. */
5102 - _n(
5103 - 'SureForms has recorded %d form submission that could not be completed.',
5104 - 'SureForms has recorded %d form submissions that could not be completed.',
5105 - $count,
5106 - 'sureforms'
5107 - ),
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.' ),
5108 4902 $count
5109 4903 );
5110 4904
5111 4905 case 'notification':
5112 4906 return sprintf(
5113 - /* translators: %d: number of failed notifications. */
5114 - _n(
5115 - 'SureForms saved %d entry but could not send the notification email for it.',
5116 - 'SureForms saved %d entries but could not send the notification emails for them.',
5117 - $count,
5118 - 'sureforms'
5119 - ),
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.' ),
5120 4910 $count
5121 4911 );
5122 4912
5123 4913 case 'integration':
5124 4914 return sprintf(
5125 - /* translators: %d: number of failed integration hand-offs. */
5126 - _n(
5127 - 'SureForms saved %d entry but could not pass it to a connected service.',
5128 - 'SureForms saved %d entries but could not pass them to a connected service.',
5129 - $count,
5130 - 'sureforms'
5131 - ),
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.' ),
5132 4918 $count
5133 4919 );
5134 4920
5135 4921 default:
5136 4922 return sprintf(
5137 - /* translators: %d: number of recorded problems. */
5138 - _n(
5139 - 'SureForms has recorded %d problem with the forms on this site.',
5140 - 'SureForms has recorded %d problems with the forms on this site.',
5141 - $count,
5142 - 'sureforms'
5143 - ),
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.' ),
5144 4926 $count
5145 4927 );
5146 4928 }
5147 4929 }
@@ -5173,9 +4955,9 @@
5173 4955 // for a count nobody recorded -- a number support would then chase.
5174 4956 $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) );
5175 4957
5176 4958 $lines = [
5177 - __( 'Hello SureForms support,', 'sureforms' ),
4959 + 'Hello SureForms support,',
5178 4960 '',
5179 4961 $count > 0
5180 4962 ? $this->get_support_count_sentence( $category, $count )
5181 4963 : sprintf( $copy['anon'], $host ),
@@ -5183,10 +4965,9 @@
5183 4965
5184 4966 if ( '' !== $form_title ) {
5185 4967 $lines[] = '';
5186 4968 $lines[] = sprintf(
5187 - /* translators: %s: form title. */
5188 - __( 'Form: %s', 'sureforms' ),
4969 + 'Form: %s',
5189 4970 $form_title
5190 4971 );
5191 4972 }
5192 4973
@@ -5197,35 +4978,24 @@
5197 4978 $lines,
5198 4979 [
5199 4980 '',
5200 4981 '---',
5201 - __( 'Site details', 'sureforms' ),
5202 - // Labels translated, values not. The site owner reads this on screen
5203 - // before sending it, so the labels are copy; the values are machine
5204 - // data -- a version, a URL, a plugin name -- and stay verbatim. The
5205 - // debug log below is left alone entirely for the same reason.
5206 - /* translators: %s: site address. */
5207 - sprintf( __( 'Site: %s', 'sureforms' ), home_url() ),
5208 - /* translators: %s: SureForms version. */
5209 - sprintf( __( 'SureForms: %s', 'sureforms' ), SRFM_VER ),
4982 + 'Site details',
4983 + sprintf( 'Site: %s', home_url() ),
4984 + sprintf( 'SureForms: %s', SRFM_VER ),
5210 4985 sprintf(
5211 - /* translators: %s: SureForms Pro version, or a note that it is not active. */
5212 - __( 'SureForms Pro: %s', 'sureforms' ),
5213 - Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : __( 'not active', 'sureforms' )
4986 + 'SureForms Pro: %s',
4987 + Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : 'not active'
5214 4988 ),
5215 - /* translators: %s: WordPress version. */
5216 - sprintf( __( 'WordPress: %s', 'sureforms' ), Helper::get_string_value( $wp_version ) ),
5217 - /* translators: %s: PHP version. */
5218 - sprintf( __( 'PHP: %s', 'sureforms' ), PHP_VERSION ),
4989 + sprintf( 'WordPress: %s', Helper::get_string_value( $wp_version ) ),
4990 + sprintf( 'PHP: %s', PHP_VERSION ),
5219 4991 sprintf(
5220 - /* translators: %s: caching plugin name, or a note that none was detected. */
5221 - __( 'Caching: %s', 'sureforms' ),
5222 - '' !== $caching ? $caching : __( 'none detected', 'sureforms' )
4992 + 'Caching: %s',
4993 + '' !== $caching ? $caching : 'none detected'
5223 4994 ),
5224 4995 sprintf(
5225 - /* translators: %s: number of recorded failures, or a note that none were. */
5226 - __( 'Recorded failures: %s', 'sureforms' ),
5227 - $count > 0 ? Helper::get_string_value( $count ) : __( 'none recorded', 'sureforms' )
4996 + 'Recorded failures: %s',
4997 + $count > 0 ? Helper::get_string_value( $count ) : 'none recorded'
5228 4998 ),
5229 4999 ]
5230 5000 );
5231 5001
@@ -5246,10 +5016,9 @@
5246 5016 $acked_at = Helper::get_integer_value( $failures[ $category ]['acked_at'] ?? 0 );
5247 5017
5248 5018 if ( $acked_at > 0 ) {
5249 5019 $lines[] = sprintf(
5250 - /* translators: %s: date and time of the previous report, in the site's timezone. */
5251 - __( 'Previously reported: %s', 'sureforms' ),
5020 + 'Previously reported: %s',
5252 5021 Helper::get_string_value( wp_date( 'Y-m-d H:i T', $acked_at ) )
5253 5022 );
5254 5023 }
5255 5024