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 +1833 -152 2.12.4 → 2.12.8 View file →
@@ -8,9 +8,12 @@
8 8 namespace SRFM\Admin;
9 9
10 10 use Astra_Notices;
11 11 use SRFM\Inc\AI_Form_Builder\AI_Helper;
12 +use SRFM\Inc\Client_Logger;
13 +use SRFM\Inc\Database\Register;
12 14 use SRFM\Inc\Database\Tables\Entries;
15 +use SRFM\Inc\Generate_Form_Markup;
13 16 use SRFM\Inc\Global_Settings\Global_Settings;
14 17 use SRFM\Inc\Helper;
15 18 use SRFM\Inc\Onboarding;
16 19 use SRFM\Inc\Payments\Payment_Helper;
@@ -74,8 +77,42 @@
74 77 */
75 78 public const QUILL_1X_INLINE_CSS = '.ql-editor ul,.ql-editor ol{padding-left:1.5em}.ql-editor ul>li,.ql-editor ol>li{list-style-type:none}.ql-editor ol li:not(.ql-direction-rtl),.ql-editor ul li:not(.ql-direction-rtl){padding-left:1.5em}.ql-editor ol li.ql-direction-rtl,.ql-editor ul li.ql-direction-rtl{padding-right:1.5em}.ql-editor ul>li::before{content:"\2022"}.ql-editor li::before{display:inline-block;white-space:nowrap;width:1.2em}.ql-editor li:not(.ql-direction-rtl)::before{margin-left:-1.5em;margin-right:.3em;text-align:right}.ql-editor li.ql-direction-rtl::before{margin-left:.3em;margin-right:-1.5em}.ql-editor ol li{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;counter-increment:list-0}.ql-editor ol li::before{content:counter(list-0,decimal) ". "}.ql-editor ol li.ql-indent-1{counter-increment:list-1;counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-1::before{content:counter(list-1,lower-alpha) ". "}.ql-editor ol li.ql-indent-2{counter-increment:list-2;counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-2::before{content:counter(list-2,lower-roman) ". "}.ql-editor ol li.ql-indent-3{counter-increment:list-3;counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-3::before{content:counter(list-3,decimal) ". "}.ql-editor ol li.ql-indent-4{counter-increment:list-4;counter-reset:list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-4::before{content:counter(list-4,lower-alpha) ". "}.ql-editor ol li.ql-indent-5{counter-increment:list-5;counter-reset:list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-5::before{content:counter(list-5,lower-roman) ". "}.ql-editor ol li.ql-indent-6{counter-increment:list-6;counter-reset:list-7 list-8 list-9}.ql-editor ol li.ql-indent-6::before{content:counter(list-6,decimal) ". "}.ql-editor ol li.ql-indent-7{counter-increment:list-7;counter-reset:list-8 list-9}.ql-editor ol li.ql-indent-7::before{content:counter(list-7,lower-alpha) ". "}.ql-editor ol li.ql-indent-8{counter-increment:list-8;counter-reset:list-9}.ql-editor ol li.ql-indent-8::before{content:counter(list-8,lower-roman) ". "}.ql-editor ol li.ql-indent-9{counter-increment:list-9}.ql-editor ol li.ql-indent-9::before{content:counter(list-9,decimal) ". "}';
76 79
77 80 /**
81 + * Notice id for the "Finish setting up" Thank You prompt (#3030).
82 + *
83 + * A single stable id (not per-form): keeps both the autoloaded
84 + * `allowed_astra_notices` option and the per-user dismissal meta bounded to one
85 + * row, and lets a dismissed user short-circuit before the query runs.
86 + *
87 + * @since 2.12.6
88 + */
89 + public const THANKYOU_PROMPT_NOTICE_ID = 'srfm-thankyou-prompt';
90 +
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 + /**
78 115 * Dashboard widget entries data.
79 116 *
80 117 * @var array
81 118 * @since 1.9.1
@@ -124,8 +161,25 @@
124 161 */
125 162 private static $setup_card_cache = [];
126 163
127 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 + /**
128 182 * Class constructor.
129 183 *
130 184 * @return void
131 185 * @since 0.0.1
@@ -135,9 +189,11 @@
135 189 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
136 190 add_action( 'admin_menu', [ $this, 'settings_page' ] );
137 191 add_action( 'admin_menu', [ $this, 'add_learn_page' ] );
138 192 add_action( 'admin_menu', [ $this, 'add_new_form' ] );
139 - add_action( 'admin_menu', [ $this, 'add_suremail_page' ] );
193 + if ( ! Helper::hide_promotions() ) {
194 + add_action( 'admin_menu', [ $this, 'add_suremail_page' ] );
195 + }
140 196 if ( ! Helper::has_pro() ) {
141 197 add_action( 'admin_menu', [ $this, 'add_quiz_page' ] );
142 198 add_action( 'admin_menu', [ $this, 'add_survey_reports_page' ] );
143 199 add_action( 'admin_menu', [ $this, 'add_partial_entries_page' ] );
@@ -155,8 +211,19 @@
155 211
156 212 add_action( 'current_screen', [ $this, 'enable_gutenberg_for_sureforms' ], 100 );
157 213 // Register notices early for React pages (before admin_enqueue_scripts).
158 214 add_action( 'admin_init', [ $this, 'register_pro_compatibility_notices' ], 5 );
215 +
216 + // Database maintenance notice: the entries table is missing, so submissions
217 + // cannot be saved. Registered at admin_init priority 5 so the React notice is
218 + // in place before admin_enqueue_scripts localizes it.
219 + add_action( 'admin_init', [ $this, 'register_database_repair_notice' ], 5 );
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 );
224 + add_action( 'admin_notices', [ $this, 'render_database_repair_notice' ] );
225 + add_action( 'admin_post_srfm_repair_entries_table', [ $this, 'handle_database_repair' ] );
159 226 // Display notices on traditional WordPress admin pages.
160 227 add_action( 'admin_notices', [ $this, 'srfm_pro_version_compatibility' ] );
161 228
162 229 // Enfold theme compatibility to enable block editor for SureForms post type.
@@ -179,9 +246,13 @@
179 246 add_action( 'wp_ajax_should_show_pointer', [ $this, 'pointer_should_show' ] );
180 247 add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] );
181 248 add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] );
182 249 add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] );
250 + add_action( 'wp_ajax_srfm_dismiss_action_item', [ $this, 'handle_dismiss_action_item' ] );
251 + add_action( 'admin_post_srfm_dismiss_action_item_link', [ $this, 'handle_dismiss_action_item_link' ] );
183 252 add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] );
253 + add_action( 'load-post.php', [ $this, 'maybe_track_edit_form_button_click' ] );
254 + add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] );
184 255
185 256 // Register dashboard widget only if there are recent entries.
186 257 add_action( 'admin_init', [ $this, 'maybe_register_dashboard_widget' ] );
187 258
@@ -421,8 +492,22 @@
421 492 return self::$thankyou_prompt_cache;
422 493 }
423 494
424 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 + /**
425 510 * Clear the request memo for the Thank You prompt (#3030).
426 511 *
427 512 * Lets tests exercise the memoized public path, and is a safe hook for anything
428 513 * that changes which form qualifies (e.g. a form save).
@@ -555,9 +640,9 @@
555 640 * @since 2.12.4
556 641 * @return void
557 642 */
558 643 public function register_form_setup_widget() {
559 - if ( ! Helper::current_user_can() ) {
644 + if ( ! Helper::current_user_can() || Helper::hide_promotions() ) {
560 645 return;
561 646 }
562 647
563 648 if ( null === self::get_form_setup_card() ) {
@@ -662,9 +747,9 @@
662 747 * @since 2.12.4
663 748 * @return void
664 749 */
665 750 public function enqueue_form_setup_widget_assets( $hook_suffix ) {
666 - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
751 + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) {
667 752 return;
668 753 }
669 754
670 755 $card = self::get_form_setup_card();
@@ -753,14 +838,19 @@
753 838 * step (default Thank You message, or no reply destination). Uses a single
754 839 * stable notice id so the library's built-in ✕ dismissal is one persistent
755 840 * choice ("stop nudging me"), not a per-form row.
756 841 *
757 - * @since 2.12.4
758 - * @return void
842 + * Split out from the renderer so the decision has exactly one home. The Getting
843 + * Started notice suppresses itself when this returns a form, and duplicating the
844 + * conditions there would have meant two copies drifting apart. Reading it costs
845 + * nothing extra — get_thankyou_prompt_forms() memoizes its query per request.
846 + *
847 + * @since 2.12.6
848 + * @return array<string,mixed>|null The form to prompt for, or null when no prompt should render.
759 849 */
760 - public function render_thankyou_prompt_notice() {
850 + public function get_displayable_thankyou_prompt() {
761 851 if ( ! Helper::current_user_can() || ! class_exists( 'Astra_Notices' ) ) {
762 - return;
852 + return null;
763 853 }
764 854
765 855 /**
766 856 * Short-circuit the "Finish setting up" Thank You notice.
@@ -769,9 +859,9 @@
769 859 *
770 860 * @since 2.12.4
771 861 */
772 862 if ( ! apply_filters( 'srfm_show_thankyou_prompt', true ) ) {
773 - return;
863 + return null;
774 864 }
775 865
776 866 // Everywhere in wp-admin except the main dashboard. A null screen fails
777 867 // closed (return) rather than registering the notice on an unknown screen.
@@ -777,21 +867,16 @@
777 867 // closed (return) rather than registering the notice on an unknown screen.
778 868 $screen = get_current_screen();
779 869
780 870 if ( ! $screen || 'dashboard' === $screen->id ) {
781 - return;
871 + return null;
782 872 }
783 873
784 - // A single stable notice id (not per-form): keeps both the autoloaded
785 - // `allowed_astra_notices` option and the per-user dismissal meta bounded to
786 - // one row, and lets a dismissed user short-circuit before the query runs.
787 - $notice_id = 'srfm-thankyou-prompt';
788 -
789 874 // The library only checks dismissal at render (priority 30, after this
790 875 // query would already have run). Check it up front so a user who dismissed
791 876 // the prompt never pays for the WP_Query on subsequent admin page views.
792 - if ( 'notice-dismissed' === get_user_meta( get_current_user_id(), $notice_id, true ) ) {
793 - return;
877 + if ( 'notice-dismissed' === get_user_meta( get_current_user_id(), self::THANKYOU_PROMPT_NOTICE_ID, true ) ) {
878 + return null;
794 879 }
795 880
796 881 // array_values so a filter returning a key-preserving array (e.g. the
797 882 // result of array_filter()) still exposes the newest prompt at index 0.
@@ -805,12 +890,34 @@
805 890 || empty( $prompts[0]['id'] ) || empty( $prompts[0]['edit_url'] )
806 891 || empty( $prompts[0]['thankyou_url'] ) || empty( $prompts[0]['replies_url'] )
807 892 || ! isset( $prompts[0]['title'] )
808 893 ) {
894 + return null;
895 + }
896 +
897 + return $prompts[0];
898 + }
899 +
900 + /**
901 + * Render the "Finish setting up" Thank You notice (#3030).
902 + *
903 + * @since 2.12.4
904 + * @return void
905 + */
906 + public function render_thankyou_prompt_notice() {
907 + $notice_id = self::THANKYOU_PROMPT_NOTICE_ID;
908 + $form = $this->get_displayable_thankyou_prompt();
909 +
910 + if ( null === $form ) {
809 911 return;
810 912 }
811 913
812 - $form = $prompts[0];
914 + // A broken form outranks a setup prompt. This is the top of the existing
915 + // precedence chain, so the action-item check goes here rather than the
916 + // action items standing down for an engagement notice.
917 + if ( $this->has_action_item_warnings() ) {
918 + return;
919 + }
813 920
814 921 \Astra_Notices::add_notice(
815 922 [
816 923 'id' => $notice_id,
@@ -815,9 +922,9 @@
815 922 [
816 923 'id' => $notice_id,
817 924 'type' => 'info',
818 925 'message' => self::build_thankyou_notice_markup( $form ),
819 - 'class' => 'srfm-thankyou-notice',
926 + 'class' => 'srfm-notice srfm-thankyou-notice',
820 927 'is_dismissible' => true,
821 928 'display-with-other-notices' => true,
822 929 // Render late so this nudge never pre-empts higher-priority notices
823 930 // (e.g. Astra's minimum-version warnings, which are display-with-
@@ -827,9 +934,9 @@
827 934 );
828 935
829 936 // The message is wp_kses_post'd by the library, so the brand-orange styling
830 937 // is printed through the notice's pre-markup hook instead of inline.
831 - add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_thankyou_notice_styles' ] );
938 + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
832 939
833 940 // Track clicks on the CTAs and the dismiss ✕ via the shared notice-response
834 941 // endpoint, enqueued only when the notice actually renders.
835 942 add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_thankyou_notice_tracking' ] );
@@ -919,9 +1026,9 @@
919 1026 *
920 1027 * @since 2.12.4
921 1028 * @return void
922 1029 */
923 - public function print_thankyou_notice_styles() {
1030 + public function print_srfm_notice_styles() {
924 1031 // The library wp_kses_post()'s the message, which strips <svg> and data:
925 1032 // image srcs, so the SureForms mark is painted as a CSS background here
926 1033 // (this hook fires outside that kses call). URL-encoded, not base64, so the
927 1034 // value is fully percent-encoded and safe to pass through esc_url.
@@ -928,20 +1035,20 @@
928 1035 $icon = 'data:image/svg+xml,' . rawurlencode(
929 1036 '<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 32 32"><path fill="#D54407" fill-rule="evenodd" clip-rule="evenodd" d="M32 0H0V32H32V0ZM22.8573 6.85728H9.14304V11.4287V13.7144L11.4288 11.4287H22.8573V6.85728ZM20.5717 13.7146H9.14314V18.286V20.5714V20.5718V25.1428H16.0003V20.5714H9.14351L11.4289 18.286H20.5717V13.7146Z"/></svg>'
930 1037 );
931 1038 ?>
932 - <style id="srfm-thankyou-notice-styles">
933 - .srfm-thankyou-notice.notice { border-left-color: #D54407; }
1039 + <style id="srfm-notice-styles">
1040 + .srfm-notice.notice { border-left-color: #D54407; }
934 1041 /* Stack our blocks (the library lays the container out as a flex row) and reserve room on the left for the SureForms mark. */
935 - .srfm-thankyou-notice .astra-notice-container { display: block; padding: 4px 0 4px 52px; background: url('<?php echo esc_url( $icon, [ 'data' ] ); ?>') no-repeat 4px 6px; background-size: 32px 32px; }
936 - .srfm-thankyou-notice .srfm-thankyou-notice__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: #1d2327; }
937 - .srfm-thankyou-notice .srfm-thankyou-notice__text { margin: 0 0 10px; color: #50575e; }
938 - .srfm-thankyou-notice .srfm-thankyou-notice__actions { margin: 12px 0 2px; display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; }
939 - .srfm-thankyou-notice .button-primary { background: #D54407; border-color: #D54407; color: #fff; box-shadow: none; text-shadow: none; }
940 - .srfm-thankyou-notice .button-primary:hover, .srfm-thankyou-notice .button-primary:focus { background: #C83B00; border-color: #C83B00; color: #fff; box-shadow: none; }
941 - .srfm-thankyou-notice .button:not(.button-primary) { background: transparent; border-color: transparent; color: #D54407; box-shadow: none; padding: 0; }
942 - .srfm-thankyou-notice .button:not(.button-primary):hover, .srfm-thankyou-notice .button:not(.button-primary):focus { background: transparent; border-color: transparent; color: #C83B00; box-shadow: none; }
943 - .srfm-thankyou-notice .button-primary:focus { outline: 2px solid #D54407; outline-offset: 1px; }
1042 + .srfm-notice .astra-notice-container { display: block; padding: 4px 0 4px 52px; background: url('<?php echo esc_url( $icon, [ 'data' ] ); ?>') no-repeat 4px 6px; background-size: 32px 32px; }
1043 + .srfm-notice .srfm-notice__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: #1d2327; }
1044 + .srfm-notice .srfm-notice__text { margin: 0 0 10px; color: #50575e; }
1045 + .srfm-notice .srfm-notice__actions { margin: 12px 0 2px; display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; }
1046 + .srfm-notice .button-primary { background: #D54407; border-color: #D54407; color: #fff; box-shadow: none; text-shadow: none; }
1047 + .srfm-notice .button-primary:hover, .srfm-notice .button-primary:focus { background: #C83B00; border-color: #C83B00; color: #fff; box-shadow: none; }
1048 + .srfm-notice .button:not(.button-primary) { background: transparent; border-color: transparent; color: #D54407; box-shadow: none; padding: 0; }
1049 + .srfm-notice .button:not(.button-primary):hover, .srfm-notice .button:not(.button-primary):focus { background: transparent; border-color: transparent; color: #C83B00; box-shadow: none; }
1050 + .srfm-notice .button-primary:focus { outline: 2px solid #D54407; outline-offset: 1px; }
944 1051 </style>
945 1052 <?php
946 1053 }
947 1054
@@ -1201,12 +1308,9 @@
1201 1308 public function add_quiz_page() {
1202 1309 add_submenu_page(
1203 1310 'sureforms_menu',
1204 1311 __( 'Quiz Entries', 'sureforms' ),
1205 - __( 'Quizzes', 'sureforms' ) .
1206 - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1207 - esc_html__( 'New', 'sureforms' ) .
1208 - '</span>',
1312 + __( 'Quizzes', 'sureforms' ),
1209 1313 self::$sureforms_page_default_capability,
1210 1314 'sureforms_quiz_entries',
1211 1315 [ $this, 'render_quiz_empty_state' ],
1212 1316 5
@@ -1234,12 +1338,9 @@
1234 1338 public function add_survey_reports_page() {
1235 1339 add_submenu_page(
1236 1340 'sureforms_menu',
1237 1341 __( 'Survey Reports', 'sureforms' ),
1238 - __( 'Survey Reports', 'sureforms' ) .
1239 - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1240 - esc_html__( 'New', 'sureforms' ) .
1241 - '</span>',
1342 + __( 'Survey Reports', 'sureforms' ),
1242 1343 self::$sureforms_page_default_capability,
1243 1344 'sureforms_survey_reports',
1244 1345 [ $this, 'render_survey_empty_state' ],
1245 1346 6
@@ -1267,12 +1368,9 @@
1267 1368 public function add_partial_entries_page() {
1268 1369 add_submenu_page(
1269 1370 'sureforms_menu',
1270 1371 __( 'Partial Entries', 'sureforms' ),
1271 - __( 'Partial Entries', 'sureforms' ) .
1272 - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1273 - esc_html__( 'New', 'sureforms' ) .
1274 - '</span>',
1372 + __( 'Partial Entries', 'sureforms' ),
1275 1373 self::$sureforms_page_default_capability,
1276 1374 'sureforms_partial_entries',
1277 1375 [ $this, 'render_partial_entries_empty_state' ],
1278 1376 7
@@ -1720,10 +1818,16 @@
1720 1818 'sureforms_pricing_page' => Helper::get_sureforms_website_url( 'pricing' ),
1721 1819 'field_spacing_vars' => Helper::get_css_vars(),
1722 1820 'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ),
1723 1821 'integrations' => Helper::sureforms_get_integration(),
1724 - '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(),
1725 1825 'ajax_url' => admin_url( 'admin-ajax.php' ),
1826 + 'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '',
1827 + 'action_items' => $this->get_action_items(),
1828 + 'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '',
1829 + 'dismiss_action_item_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_dismiss_action_item' ) : '',
1726 1830 'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ),
1727 1831 'plugin_installer_nonce' => wp_create_nonce( 'updates' ),
1728 1832 'plugin_activating_text' => __( 'Activating...', 'sureforms' ),
1729 1833 'plugin_activated_text' => __( 'Activated', 'sureforms' ),
@@ -1732,8 +1836,12 @@
1732 1836 'plugin_installed_text' => __( 'Installed', 'sureforms' ),
1733 1837 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ),
1734 1838 'is_rtl' => $is_rtl,
1735 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' ),
1736 1844 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false,
1737 1845 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ),
1738 1846 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection.
1739 1847 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ),
@@ -2250,8 +2358,234 @@
2250 2358 }
2251 2359 }
2252 2360
2253 2361 /**
2362 + * Register the React notice when the entries table is missing.
2363 + *
2364 + * Hooked - admin_init, priority 5.
2365 + *
2366 + * Priority 5 is load-bearing: Notice_Manager hands notices to the front end
2367 + * through the `srfm_admin_filter` applied during admin_enqueue_scripts, so
2368 + * anything registering later never reaches the page.
2369 + *
2370 + * @since 2.12.6
2371 + * @return void
2372 + */
2373 + public function register_database_repair_notice() {
2374 + // admin_init also fires on admin-ajax.php. Nothing there renders a notice, so
2375 + // skip the work rather than reading a transient on every AJAX request.
2376 + if ( wp_doing_ajax() ) {
2377 + return;
2378 + }
2379 +
2380 + if ( ! Helper::current_user_can() ) {
2381 + return;
2382 + }
2383 +
2384 + if ( ! class_exists( 'SRFM\Admin\Notice_Manager' ) ) {
2385 + return;
2386 + }
2387 +
2388 + // A just-completed repair reports its outcome instead of the warning.
2389 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag; the repair itself is nonce-checked in handle_database_repair().
2390 + $result = isset( $_GET['srfm_db_repair'] ) ? sanitize_key( wp_unslash( $_GET['srfm_db_repair'] ) ) : '';
2391 +
2392 + if ( 'done' === $result ) {
2393 + Notice_Manager::register_notice(
2394 + [
2395 + 'id' => 'srfm-database-repaired',
2396 + 'variant' => 'success',
2397 + 'message' => __( 'Your SureForms database is up to date. New form entries will be saved as usual.', 'sureforms' ),
2398 + 'pages' => [ 'all' ],
2399 + ]
2400 + );
2401 + return;
2402 + }
2403 +
2404 + if ( 'failed' === $result ) {
2405 + Notice_Manager::register_notice(
2406 + [
2407 + 'id' => 'srfm-database-repair-failed',
2408 + // Still a warning, not an error: a host that does not allow
2409 + // SureForms to create tables is not the user's mistake.
2410 + 'variant' => 'warning',
2411 + 'message' => __( 'SureForms could not finish updating the database. Your hosting may not allow SureForms to create database tables — please contact your hosting provider or SureForms support.', 'sureforms' ),
2412 + 'actions' => [
2413 + [
2414 + 'label' => __( 'Contact support', 'sureforms' ),
2415 + 'url' => 'https://sureforms.com/contact/',
2416 + ],
2417 + ],
2418 + 'pages' => [ 'all' ],
2419 + ]
2420 + );
2421 + return;
2422 + }
2423 +
2424 + if ( ! Register::is_entries_table_missing() ) {
2425 + return;
2426 + }
2427 +
2428 + $this->track_database_notice_impression();
2429 +
2430 + Notice_Manager::register_notice(
2431 + [
2432 + 'id' => 'srfm-database-maintenance',
2433 + 'variant' => 'warning',
2434 + 'title' => __( 'Database update needed', 'sureforms' ),
2435 + // Plain text only. AdminNotice.js renders this as a React child, so
2436 + // any markup here would show up as literal characters.
2437 + 'message' => $this->get_database_notice_message(),
2438 + 'actions' => [
2439 + [
2440 + 'label' => __( 'Fix now', 'sureforms' ),
2441 + // Opaque identifier, resolved to a handler in AdminNotice.js.
2442 + // Deliberately not a URL or endpoint: the server never tells
2443 + // the browser which address to call.
2444 + 'action' => 'repair-entries-table',
2445 + 'url' => $this->get_database_repair_url(),
2446 + ],
2447 + ],
2448 + 'pages' => [ 'all' ],
2449 + ]
2450 + );
2451 + }
2452 +
2453 + /**
2454 + * Render the classic warning on the WordPress dashboard.
2455 + *
2456 + * Hooked - admin_notices.
2457 + *
2458 + * Scoped to index.php on purpose. The React notice already covers the SureForms
2459 + * screens, so leaving this one admin-wide would stack two warnings on the same
2460 + * page and nag on every screen in wp-admin.
2461 + *
2462 + * Registered as [ $this, 'method' ] rather than a closure because
2463 + * suppress_foreign_admin_notices() strips any callback it cannot attribute to a
2464 + * SureForms class — a closure here would be silently removed.
2465 + *
2466 + * @since 2.12.6
2467 + * @return void
2468 + */
2469 + public function render_database_repair_notice() {
2470 + if ( ! Helper::current_user_can() ) {
2471 + return;
2472 + }
2473 +
2474 + $screen = get_current_screen();
2475 +
2476 + if ( ! $screen || 'dashboard' !== $screen->base ) {
2477 + return;
2478 + }
2479 +
2480 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag; the repair itself is nonce-checked in handle_database_repair().
2481 + $result = isset( $_GET['srfm_db_repair'] ) ? sanitize_key( wp_unslash( $_GET['srfm_db_repair'] ) ) : '';
2482 +
2483 + if ( 'done' === $result ) {
2484 + ?>
2485 + <div class="notice notice-success is-dismissible">
2486 + <p><?php esc_html_e( 'Your SureForms database is up to date. New form entries will be saved as usual.', 'sureforms' ); ?></p>
2487 + </div>
2488 + <?php
2489 + return;
2490 + }
2491 +
2492 + if ( 'failed' === $result ) {
2493 + ?>
2494 + <div class="notice notice-warning is-dismissible">
2495 + <p><?php esc_html_e( 'SureForms could not finish updating the database. Your hosting may not allow SureForms to create database tables — please contact your hosting provider or SureForms support.', 'sureforms' ); ?></p>
2496 + </div>
2497 + <?php
2498 + return;
2499 + }
2500 +
2501 + if ( ! Register::is_entries_table_missing() ) {
2502 + return;
2503 + }
2504 +
2505 + $this->track_database_notice_impression();
2506 + ?>
2507 + <div class="notice notice-warning">
2508 + <p>
2509 + <strong><?php esc_html_e( 'SureForms — database update needed', 'sureforms' ); ?></strong>
2510 + </p>
2511 + <p><?php echo esc_html( $this->get_database_notice_message() ); ?></p>
2512 + <p>
2513 + <a href="<?php echo esc_url( $this->get_database_repair_url() ); ?>" class="button button-primary">
2514 + <?php esc_html_e( 'Update database', 'sureforms' ); ?>
2515 + </a>
2516 + </p>
2517 + </div>
2518 + <?php
2519 + }
2520 +
2521 + /**
2522 + * Repair the entries table, then redirect back with the outcome.
2523 + *
2524 + * Hooked - admin_post_srfm_repair_entries_table.
2525 + *
2526 + * A nonce-protected GET that changes state matches how core's own plugin
2527 + * activate / deactivate / delete links work.
2528 + *
2529 + * @since 2.12.6
2530 + * @return void
2531 + */
2532 + public function handle_database_repair() {
2533 + if ( ! Helper::current_user_can() ) {
2534 + wp_die( esc_html__( 'You do not have permission to update the database.', 'sureforms' ), 403 );
2535 + }
2536 +
2537 + check_admin_referer( 'srfm_repair_entries_table' );
2538 +
2539 + $repaired = $this->do_database_repair();
2540 + $referer = wp_get_referer();
2541 +
2542 + wp_safe_redirect(
2543 + add_query_arg(
2544 + 'srfm_db_repair',
2545 + $repaired ? 'done' : 'failed',
2546 + $referer ? $referer : admin_url()
2547 + )
2548 + );
2549 + exit;
2550 + }
2551 +
2552 + /**
2553 + * Repair the entries table and record what happened.
2554 + *
2555 + * The single place the repair is performed and counted, shared by the
2556 + * admin-post handler and the REST endpoint. One user action reaches exactly one
2557 + * of those, so the click counter cannot double-count across the two surfaces.
2558 + *
2559 + * @since 2.12.6
2560 + * @return bool True when the table exists afterwards.
2561 + */
2562 + public function do_database_repair() {
2563 + // Cumulative counter, so $force = true: each new count is a new value and is
2564 + // re-sent, while an identical repeat short-circuits inside track().
2565 + $attempts = Helper::get_integer_value( Helper::get_srfm_option( 'db_repair_attempts', 0 ) ) + 1;
2566 + Helper::update_srfm_option( 'db_repair_attempts', $attempts );
2567 +
2568 + // Event name is the `database_error` => `fix_now` entry in the $valid
2569 + // allowlist in handle_notice_response(). Kept in sync by hand; that array is
2570 + // where the team looks notice event names up.
2571 + Analytics::events()->track( 'database_error_notice_cta', (string) $attempts, [], true );
2572 +
2573 + $repaired = Register::repair_entries_table();
2574 +
2575 + // The failure case is the more valuable signal: it means the host refuses to
2576 + // let SureForms create tables, which no amount of retrying will fix.
2577 + Analytics::events()->track(
2578 + 'database_repair_result',
2579 + $repaired ? 'success' : 'failed',
2580 + [],
2581 + true
2582 + );
2583 +
2584 + return $repaired;
2585 + }
2586 +
2587 + /**
2254 2588 * Admin Notice Callback if sureforms pro is out of date.
2255 2589 *
2256 2590 * Hooked - admin_notices
2257 2591 *
@@ -2341,34 +2675,57 @@
2341 2675 if ( ! Helper::current_user_can() ) {
2342 2676 return;
2343 2677 }
2344 2678
2345 - // Allow the notice to be disabled.
2346 - 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 ) ) {
2347 2681 return;
2348 2682 }
2349 2683
2684 + $notice_id = 'srfm-plugin-review-notice';
2685 +
2350 2686 Astra_Notices::add_notice(
2351 2687 [
2352 - 'id' => 'srfm-plugin-review-notice',
2688 + 'id' => $notice_id,
2353 2689 'type' => '',
2354 - 'message' => $this->build_notice_markup(
2355 - esc_html__( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ),
2356 - esc_html__( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ),
2357 - esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/' ),
2358 - esc_html__( 'Rate SureForms', 'sureforms' ),
2359 - esc_html__( 'Maybe later', 'sureforms' ),
2360 - esc_html__( 'I already did', 'sureforms' ),
2361 - WEEK_IN_SECONDS,
2362 - true
2690 + 'message' => self::build_srfm_notice_markup(
2691 + __( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ),
2692 + __( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ),
2693 + [
2694 + [
2695 + 'text' => __( 'Rate SureForms', 'sureforms' ),
2696 + 'url' => esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/' ),
2697 + 'primary' => true,
2698 + // Leaves wp-admin, so it also dismisses on the way out.
2699 + 'dismiss' => true,
2700 + 'external' => true,
2701 + ],
2702 + [
2703 + 'text' => __( 'Maybe later', 'sureforms' ),
2704 + 'url' => '#',
2705 + 'dismiss' => true,
2706 + 'snooze' => WEEK_IN_SECONDS,
2707 + ],
2708 + [
2709 + 'text' => __( 'I already did', 'sureforms' ),
2710 + 'url' => '#',
2711 + 'dismiss' => true,
2712 + ],
2713 + ]
2363 2714 ),
2715 + 'class' => 'srfm-notice srfm-rating-notice',
2364 2716 'repeat-notice-after' => WEEK_IN_SECONDS,
2365 - 'show_if' => $this->maybe_display_rating_notice(),
2717 + // Yields to the Thank You prompt for the same reason the Getting Started
2718 + // notice does: a specific form to finish beats a recurring review ask,
2719 + // and a user with three forms who then imports a template would
2720 + // otherwise see both at once.
2721 + 'show_if' => $this->maybe_display_rating_notice() && null === $this->get_displayable_thankyou_prompt() && ! $this->has_action_item_warnings(),
2366 2722 'display-with-other-notices' => true,
2367 2723 ]
2368 2724 );
2369 2725
2370 - add_action( 'astra_notice_after_markup_srfm-plugin-review-notice', [ $this, 'enqueue_notice_response_script' ] );
2726 + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
2727 + add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_notice_response_script' ] );
2371 2728 }
2372 2729
2373 2730 /**
2374 2731 * Display a "Getting Started" admin notice for new users who haven't yet
@@ -2390,29 +2747,52 @@
2390 2747 if ( ! apply_filters( 'srfm_show_getting_started_notice', true ) ) {
2391 2748 return;
2392 2749 }
2393 2750
2751 + $notice_id = 'srfm-getting-started-notice';
2752 +
2394 2753 Astra_Notices::add_notice(
2395 2754 [
2396 - 'id' => 'srfm-getting-started-notice',
2755 + 'id' => $notice_id,
2397 2756 'type' => '',
2398 - 'message' => $this->build_notice_markup(
2399 - esc_html__( 'SureForms is ready to power your forms — explore what\'s possible!', 'sureforms' ),
2400 - esc_html__( 'Manage your forms, track submissions, and discover features like AI Form Builder, payment integrations, and more from the SureForms dashboard.', 'sureforms' ),
2401 - esc_url( admin_url( 'admin.php?page=sureforms_menu' ) ),
2402 - esc_html__( 'Go to Dashboard', 'sureforms' ),
2403 - esc_html__( 'Maybe later', 'sureforms' ),
2404 - esc_html__( 'I already know', 'sureforms' ),
2405 - WEEK_IN_SECONDS
2757 + 'message' => self::build_srfm_notice_markup(
2758 + __( 'SureForms is ready to power your forms — explore what\'s possible!', 'sureforms' ),
2759 + __( 'Manage your forms, track submissions, and discover features like AI Form Builder, payment integrations, and more from the SureForms dashboard.', 'sureforms' ),
2760 + [
2761 + [
2762 + 'text' => __( 'Go to Dashboard', 'sureforms' ),
2763 + 'url' => esc_url( admin_url( 'admin.php?page=sureforms_menu' ) ),
2764 + 'primary' => true,
2765 + ],
2766 + [
2767 + 'text' => __( 'Maybe later', 'sureforms' ),
2768 + 'url' => '#',
2769 + 'dismiss' => true,
2770 + 'snooze' => WEEK_IN_SECONDS,
2771 + ],
2772 + [
2773 + 'text' => __( 'I already know', 'sureforms' ),
2774 + 'url' => '#',
2775 + 'dismiss' => true,
2776 + ],
2777 + ]
2406 2778 ),
2779 + 'class' => 'srfm-notice srfm-getting-started-notice',
2407 2780 'repeat-notice-after' => WEEK_IN_SECONDS,
2408 - 'show_if' => ! $this->maybe_display_rating_notice(),
2781 + // Yields to both of the other SureForms notices, so only one of ours is
2782 + // ever on screen. The rating notice supersedes it once the user has real
2783 + // usage; the Thank You prompt supersedes it because "finish this specific
2784 + // form" is a concrete next step and this is a generic tour invitation.
2785 + 'show_if' => ! $this->maybe_display_rating_notice() && null === $this->get_displayable_thankyou_prompt() && ! $this->has_action_item_warnings(),
2409 2786 'display-notice-after' => WEEK_IN_SECONDS,
2410 2787 'display-with-other-notices' => true,
2411 2788 ]
2412 2789 );
2413 2790
2414 - add_action( 'astra_notice_after_markup_srfm-getting-started-notice', [ $this, 'enqueue_notice_response_script' ] );
2791 + // Same pre-markup hook the Thank You prompt uses, so both notices are painted
2792 + // by one stylesheet instead of two that drift apart.
2793 + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
2794 + add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_notice_response_script' ] );
2415 2795 }
2416 2796
2417 2797 /**
2418 2798 * Enqueue the notice response analytics script.
@@ -2439,10 +2819,20 @@
2439 2819 wp_localize_script(
2440 2820 'srfm-notice-response',
2441 2821 'srfmNoticeResponse',
2442 2822 [
2443 - 'ajaxurl' => admin_url( 'admin-ajax.php' ),
2444 - '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 + ],
2445 2835 ]
2446 2836 );
2447 2837 }
2448 2838
@@ -2457,12 +2847,14 @@
2457 2847 */
2458 2848 public function handle_notice_response() {
2459 2849 if ( ! check_ajax_referer( 'srfm_notice_response', 'nonce', false ) ) {
2460 2850 wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
2851 + return;
2461 2852 }
2462 2853
2463 2854 if ( ! Helper::current_user_can() ) {
2464 2855 wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
2856 + return;
2465 2857 }
2466 2858
2467 2859 $notice_id = isset( $_POST['notice_id'] ) ? sanitize_text_field( wp_unslash( $_POST['notice_id'] ) ) : '';
2468 2860 $button = isset( $_POST['button'] ) ? sanitize_text_field( wp_unslash( $_POST['button'] ) ) : '';
@@ -2477,9 +2869,34 @@
2477 2869 'rate_sureforms' => 'rating_notice_cta',
2478 2870 'maybe_later' => 'rating_notice_snooze',
2479 2871 'dismissed' => 'rating_notice_dismiss',
2480 2872 ],
2873 + // Database maintenance notice. Keyed `database_error` for the warehouse;
2874 + // the user-facing copy deliberately reads as a routine update, not an
2875 + // error. `dismissed` is registered but unreachable today — a missing
2876 + // entries table is not something we let people dismiss.
2877 + 'database_error' => [
2878 + 'fix_now' => 'database_error_notice_cta',
2879 + 'dismissed' => 'database_error_notice_dismiss',
2880 + ],
2481 2881 // The "Finish setting up" prompt (#3030): three CTAs, plus the ✕.
2882 + 'form_submission_error' => [
2883 + 'contact_support' => 'submission_failure_notice_cta',
2884 + 'dismissed' => 'submission_failure_notice_dismiss',
2885 + ],
2886 + 'notification_error' => [
2887 + 'contact_support' => 'notification_failure_notice_cta',
2888 + 'help_me_fix' => 'notification_failure_notice_guide',
2889 + 'dismissed' => 'notification_failure_notice_dismiss',
2890 + ],
2891 + 'integration_error' => [
2892 + 'contact_support' => 'integration_failure_notice_cta',
2893 + 'dismissed' => 'integration_failure_notice_dismiss',
2894 + ],
2895 + 'caching_plugin' => [
2896 + 'help_me_fix' => 'caching_plugin_notice_cta',
2897 + 'dismissed' => 'caching_plugin_notice_dismiss',
2898 + ],
2482 2899 'srfm-thankyou-prompt' => [
2483 2900 'edit_form' => 'thankyou_notice_edit_form',
2484 2901 'set_replies' => 'thankyou_notice_set_replies',
2485 2902 'edit_thankyou' => 'thankyou_notice_edit_thankyou',
@@ -2488,13 +2905,35 @@
2488 2905 ];
2489 2906
2490 2907 if ( ! isset( $valid[ $notice_id ][ $button ] ) ) {
2491 2908 wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 );
2909 + // wp_send_json_error() ends the request in production. The explicit return
2910 + // keeps the guard a guard rather than something that only works because of
2911 + // a side effect in a function elsewhere.
2912 + return;
2492 2913 }
2493 2914
2494 - $event_name = $valid[ $notice_id ][ $button ];
2495 - Analytics::events()->track( $event_name, $button );
2915 + $this->track_notice_event( $valid[ $notice_id ][ $button ] );
2496 2916
2917 + // Reporting the failures retires the notice until something new fails.
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.
2922 + $categories = [
2923 + 'form_submission_error' => 'submission',
2924 + 'notification_error' => 'notification',
2925 + 'integration_error' => 'integration',
2926 + ];
2927 +
2928 + if ( 'contact_support' === $button && isset( $categories[ $notice_id ] ) ) {
2929 + Client_Logger::acknowledge_category( $categories[ $notice_id ] );
2930 +
2931 + if ( 'form_submission_error' === $notice_id ) {
2932 + Client_Logger::acknowledge_failures();
2933 + }
2934 + }
2935 +
2497 2936 wp_send_json_success();
2498 2937 }
2499 2938
2500 2939 /**
@@ -2637,10 +3076,11 @@
2637 3076 * @since 1.9.1
2638 3077 */
2639 3078 public function maybe_register_dashboard_widget() {
2640 3079
2641 - // Only for users with manage_options capability.
2642 - 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() ) {
2643 3083 return;
2644 3084 }
2645 3085
2646 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.
@@ -2743,9 +3183,9 @@
2743 3183 * @since 2.12.1
2744 3184 */
2745 3185 public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) {
2746 3186 // Only on the main dashboard, and only for capable users (matches the widget gate).
2747 - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
3187 + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) {
2748 3188 return;
2749 3189 }
2750 3190
2751 3191 // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching
@@ -2827,8 +3267,118 @@
2827 3267 wp_add_inline_script( 'srfm-ai-dashboard-widget', $inline_script );
2828 3268 }
2829 3269
2830 3270 /**
3271 + * Count an editor visit that came from the front-end "Edit Form" pill.
3272 + *
3273 + * The pill is a plain link, so the click is attributed by the marker query arg
3274 + * it carries rather than by a front-end click handler. That keeps the front end
3275 + * script-free and adds no AJAX endpoint: the only thing on the page is still an
3276 + * anchor. It also measures the outcome that matters — the editor actually
3277 + * opening — instead of a click that may never land.
3278 + *
3279 + * Every decision here comes from server state. The query arg selects the code
3280 + * path; what gets counted is derived from the resolved post and the current
3281 + * user's capability on it. An absent, empty, misspelled or reused arg, a post
3282 + * that is not a SureForms form, and a user without `edit_post` on that form all
3283 + * fall through to no-op without an explicit branch.
3284 + *
3285 + * No nonce, deliberately: the pill is rendered into front-end HTML that may be
3286 + * page-cached, so a nonce would either be baked into the cache or be stale on
3287 + * arrival. The effect is a private usage counter for a user who can already edit
3288 + * the form, and nothing attacker-controlled reaches the analytics payload — the
3289 + * value sent is an integer read back from stored state.
3290 + *
3291 + * Because the marker is just a query arg, the invariant that bounds this is the
3292 + * dedup transient below, not the arg: a given editor moves the counter at most
3293 + * once per form per hour, no matter how many times the URL is requested. That is
3294 + * also what keeps the metric honest — without it a refresh or a back-navigation
3295 + * would count again, and each count is a read-modify-write of the whole
3296 + * `srfm_options` row, which holds unrelated settings.
3297 + *
3298 + * @return void
3299 + * @since 2.12.6
3300 + */
3301 + public function maybe_track_edit_form_button_click() {
3302 + // is_string() before sanitize_key(): `?srfm_edit_src[]=x` satisfies isset(),
3303 + // and wp_unslash() hands the array straight through. sanitize_key() only grew
3304 + // its is_scalar() guard after this plugin's minimum WordPress, so on the older
3305 + // supported versions that reaches strtolower( array ) — a TypeError on PHP 8,
3306 + // i.e. the one input shape that ended in a fatal rather than in the no-op the
3307 + // rest of this method guarantees.
3308 + $arg = Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG;
3309 +
3310 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only attribution marker; see docblock for why a nonce is neither possible nor needed.
3311 + $source = isset( $_GET[ $arg ] ) && is_string( $_GET[ $arg ] ) ? sanitize_key( wp_unslash( $_GET[ $arg ] ) ) : '';
3312 +
3313 + if ( 'embed' !== $source ) {
3314 + return;
3315 + }
3316 +
3317 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Same read-only path as above.
3318 + $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
3319 +
3320 + // Resolve the post type from the stored post, never from the request.
3321 + //
3322 + // The capability below reads as per-post but is not: sureforms_form is
3323 + // registered with an explicit capabilities map and no `map_meta_cap`
3324 + // (inc/post-types.php), so core short-circuits `edit_post` to the post type's
3325 + // `edit_post` capability — `manage_options` — without ever consulting $post_id.
3326 + // The real gate is therefore "site administrator", which is stricter than a
3327 + // per-form check, not weaker. Written down because a later `map_meta_cap` on
3328 + // the CPT would silently change what this line means with no diff here.
3329 + if ( 0 === $post_id || SRFM_FORMS_POST_TYPE !== get_post_type( $post_id ) ) {
3330 + return;
3331 + }
3332 +
3333 + if ( ! current_user_can( 'edit_post', $post_id ) ) {
3334 + return;
3335 + }
3336 +
3337 + // One count per editor per form per hour. Without this the metric measures
3338 + // "editor loads carrying the marker" rather than pill clicks — a refresh or a
3339 + // back-navigation re-counts — and a forged page could drive the counter, and
3340 + // the writes behind it, without bound.
3341 + $dedup_key = 'srfm_pill_click_' . get_current_user_id() . '_' . $post_id;
3342 +
3343 + if ( false !== get_transient( $dedup_key ) ) {
3344 + return;
3345 + }
3346 +
3347 + set_transient( $dedup_key, 1, HOUR_IN_SECONDS );
3348 +
3349 + $count = Helper::get_integer_value( Helper::get_srfm_option( 'edit_form_button_clicks', 0 ) ) + 1;
3350 + Helper::update_srfm_option( 'edit_form_button_clicks', $count );
3351 +
3352 + // $force = true because this is a cumulative counter, not a one-time event —
3353 + // it must re-send the latest count each cycle (bypasses one-time dedup).
3354 + Analytics::events()->track( 'edit_form_button_clicked', (string) $count, [], true );
3355 + }
3356 +
3357 + /**
3358 + * Let core strip the edit-attribution marker from the admin URL.
3359 + *
3360 + * Core's wp_admin_canonical_url() rewrites the address bar via replaceState() on
3361 + * admin_head, which runs after load-post.php — so the marker has already been
3362 + * counted by the time it is removed and no attribution is lost. Without this it
3363 + * lingers in the address bar, in bookmarks, and in the Referer header sent to
3364 + * every subresource the editor loads.
3365 + *
3366 + * @param array<string> $args Query args core already removes.
3367 + * @since 2.12.6
3368 + * @return array<string> Args with the marker appended.
3369 + */
3370 + public function add_removable_query_args( $args ) {
3371 + if ( ! is_array( $args ) ) {
3372 + return [ Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG ];
3373 + }
3374 +
3375 + $args[] = Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG;
3376 +
3377 + return $args;
3378 + }
3379 +
3380 + /**
2831 3381 * Track AI dashboard widget usage.
2832 3382 *
2833 3383 * @return void
2834 3384 * @since 2.12.1
@@ -2903,8 +3453,634 @@
2903 3453 <?php
2904 3454 }
2905 3455
2906 3456 /**
3457 + * Classic dashboard notice when submissions keep failing.
3458 + *
3459 + * Hooked - admin_notices.
3460 + *
3461 + * Gated to the WP dashboard. The React notice already covers SureForms' own
3462 + * screens, so leaving this admin-wide would stack two warnings on one page.
3463 + *
3464 + * Registered as [ $this, 'method' ] rather than a closure because
3465 + * suppress_foreign_admin_notices() strips any callback it cannot attribute to
3466 + * a SureForms class -- a closure here would be silently removed.
3467 + *
3468 + * @since 2.12.6
3469 + * @return void
3470 + */
3471 + public function render_action_item_notices() {
3472 + // Shown across wp-admin, because someone whose forms are silently failing
3473 + // may not open the WP dashboard or SureForms for days.
3474 + //
3475 + // The one exclusion is SureForms' own dashboard: the Form Checks panel in
3476 + // its sidebar already lists these, and a banner above it would say the same
3477 + // thing twice on one screen.
3478 + if ( Helper::validate_request_context( 'sureforms_menu', 'page' ) ) {
3479 + return;
3480 + }
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 +
3500 + $this->enqueue_notice_response_script();
3501 +
3502 + foreach ( $items as $item ) {
3503 + $status = Helper::get_string_value( $item['status'] ?? '' );
3504 +
3505 + // Passing checks belong in the SureForms panel, not in wp-admin. A
3506 + // notice that says nothing is wrong is noise on every page load.
3507 + if ( 'success' === $status || '' === $status ) {
3508 + continue;
3509 + }
3510 +
3511 + // A fault reads as an error; advice reads as a warning. Both are shown,
3512 + // but they are not the same kind of message and should not look alike.
3513 + $class = 'error' === $status ? 'notice-error' : 'notice-warning';
3514 + ?>
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 + ?>
3540 + <p>
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 } ?>
3571 + <?php if ( ! empty( $item['dismissible'] ) ) { ?>
3572 + <a href="<?php echo esc_url( $this->get_dismiss_action_item_url( Helper::get_string_value( $item['id'] ) ) ); ?>" class="button">
3573 + <?php esc_html_e( 'Dismiss', 'sureforms' ); ?>
3574 + </a>
3575 + <?php } ?>
3576 + </p>
3577 + </div>
3578 + <?php
3579 + }
3580 + }
3581 +
3582 + /**
3583 + * Dismiss an action item from the classic notice's link.
3584 + *
3585 + * Hooked - admin_post_srfm_dismiss_action_item_link.
3586 + *
3587 + * @since 2.12.6
3588 + * @return void
3589 + */
3590 + public function handle_dismiss_action_item_link() {
3591 + if ( ! Helper::current_user_can() ) {
3592 + wp_die( esc_html__( 'You do not have permission to do this.', 'sureforms' ), 403 );
3593 + }
3594 +
3595 + check_admin_referer( 'srfm_dismiss_action_item' );
3596 +
3597 + $item_id = isset( $_GET['item'] ) ? sanitize_key( wp_unslash( $_GET['item'] ) ) : '';
3598 +
3599 + $this->dismiss_action_item( $item_id );
3600 +
3601 + $referer = wp_get_referer();
3602 +
3603 + wp_safe_redirect( $referer ? $referer : admin_url() );
3604 + exit;
3605 + }
3606 +
3607 + /**
3608 + * Whether a first-party warning is currently on screen.
3609 + *
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.
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 + *
3636 + * @since 2.12.6
3637 + * @return bool
3638 + */
3639 + public function has_action_item_warnings() {
3640 + if ( ! Client_Logger::is_enabled() ) {
3641 + return false;
3642 + }
3643 +
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 + }
3656 + }
3657 +
3658 + return false;
3659 + }
3660 +
3661 + /**
3662 + * Things on this site that need the owner's attention, newest concern first.
3663 + *
3664 + * Fed to the dashboard sidebar carousel. Each entry is self-describing so the
3665 + * front end has no rules of its own to keep in sync -- adding a new item here
3666 + * makes it appear with no JavaScript change.
3667 + *
3668 + * `dismissible` separates a fault from advice. A run of failed submissions is
3669 + * not something to wave away, and clears itself when a submission succeeds. A
3670 + * caching plugin being present is information, so it can be dismissed.
3671 + *
3672 + * @since 2.12.6
3673 + * @return array<int,array<string,mixed>>
3674 + */
3675 + public function get_action_items() {
3676 + if ( ! Helper::current_user_can() ) {
3677 + return [];
3678 + }
3679 +
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 + }
3688 +
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 = [];
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 +
3894 + // One item per category. They read differently to a site owner and must not
3895 + // be collapsed: submissions failing means visitors cannot reach you, a
3896 + // notification failing means you are not hearing about entries that did
3897 + // save, an integration failing means a third party is not receiving them.
3898 + $categories = [
3899 + 'submission' => [
3900 + 'id' => 'form_submission_error',
3901 + /* translators: %s: form title. */
3902 + 'title' => __( 'We noticed a form submission failure on %s.', 'sureforms' ),
3903 + 'generic' => __( 'We noticed a form submission failure.', 'sureforms' ),
3904 + 'message' => __( 'Visitors may not be able to reach you, and their entries were not saved.', 'sureforms' ),
3905 + ],
3906 + 'notification' => [
3907 + 'id' => 'notification_error',
3908 + /* translators: %s: form title. */
3909 + 'title' => __( 'We noticed a notification failure on %s.', 'sureforms' ),
3910 + 'generic' => __( 'We noticed a notification failure.', '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 + ),
3922 + ],
3923 + 'integration' => [
3924 + 'id' => 'integration_error',
3925 + /* translators: %s: form title. */
3926 + 'title' => __( 'We noticed an integration failure on %s.', 'sureforms' ),
3927 + 'generic' => __( 'We noticed an integration failure.', 'sureforms' ),
3928 + 'message' => __( 'The entry was saved, but we could not send it to a connected service.', 'sureforms' ),
3929 + ],
3930 + ];
3931 +
3932 + foreach ( $categories as $category => $copy ) {
3933 + if ( ! isset( $open[ $category ] ) ) {
3934 + continue;
3935 + }
3936 +
3937 + // Name the form. "A form is failing" is not actionable on a site with
3938 + // twenty of them, and the title is the first thing anyone asks for.
3939 + $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' );
3940 +
3941 + $warning = [
3942 + 'id' => $copy['id'],
3943 + 'status' => 'error',
3944 + 'title' => '' !== $form_title
3945 + ? sprintf( $copy['title'], $form_title )
3946 + : $copy['generic'],
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.
3963 + 'cta_label' => __( 'Contact Support', 'sureforms' ),
3964 + 'cta_url' => $this->get_support_contact_url( $category, $form_title ),
3965 + 'cta_action' => 'contact_support',
3966 + 'dismissible' => false,
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;
3979 + }
3980 +
3981 + $caching_plugin = Helper::get_active_caching_plugin();
3982 +
3983 + if ( '' === $caching_plugin ) {
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 ) ) {
3992 + $warnings[] = [
3993 + 'id' => 'caching_plugin',
3994 + 'status' => 'warning',
3995 + 'title' => sprintf(
3996 + /* translators: %s: caching plugin name. */
3997 + __( '%s may interfere with your forms.', 'sureforms' ),
3998 + $caching_plugin
3999 + ),
4000 + 'message' => __( 'Caching can show visitors an old copy of your form, or load its scripts in the wrong order.', 'sureforms' ),
4001 + 'cta_label' => __( 'Help Me Fix', 'sureforms' ),
4002 + 'cta_url' => Helper::get_caching_plugin_doc_url(),
4003 + 'cta_action' => 'help_me_fix',
4004 + 'dismissible' => true,
4005 + ];
4006 + }
4007 +
4008 + return $warnings;
4009 + }
4010 +
4011 + /**
4012 + * Nonce-protected URL that repairs the entries table.
4013 + *
4014 + * Shared by both notice surfaces so there is one repair route, one nonce and one
4015 + * place that counts the click. Private, so it stays off the public API and out of
4016 + * the test-coverage gate.
4017 + *
4018 + * @since 2.12.6
4019 + * @return string
4020 + */
4021 + private function get_database_repair_url() {
4022 + return wp_nonce_url(
4023 + admin_url( 'admin-post.php?action=srfm_repair_entries_table' ),
4024 + 'srfm_repair_entries_table'
4025 + );
4026 + }
4027 +
4028 + /**
4029 + * The database notice body, which differs by what the repair will actually do.
4030 + *
4031 + * Two outcomes are possible and they are not equivalent to the person clicking:
4032 + * when the entries table exists under a different prefix — a changed
4033 + * `$table_prefix`, a restored dump, a security plugin that renamed tables and
4034 + * skipped ours — the repair renames it back and every stored entry comes with
4035 + * it. When there is nothing to adopt, the repair creates an empty table and the
4036 + * old submissions are not recoverable from here.
4037 + *
4038 + * Promising the wrong one is how a maintenance prompt turns into a complaint, so
4039 + * the copy states which is about to happen.
4040 + *
4041 + * @since 2.12.6
4042 + * @return string
4043 + */
4044 + private function get_database_notice_message() {
4045 + if ( '' !== Register::get_adoptable_entries_table() ) {
4046 + return __( 'SureForms found your form entries stored under a different database table prefix. Reconnecting them takes a moment, and your existing entries will be kept.', 'sureforms' );
4047 + }
4048 +
4049 + return __( 'SureForms needs to update your database before it can save new form entries. This only takes a moment and will not change your forms or existing content. Entries submitted before now cannot be recovered from here.', 'sureforms' );
4050 + }
4051 +
4052 + /**
4053 + * Count one sighting of the database notice, at most once per user per day.
4054 + *
4055 + * While the table is missing the notice renders on every admin page load, on two
4056 + * surfaces. Counting each render would rewrite the autoloaded `srfm_options` blob
4057 + * on every pageview of a site that is already broken, and one site left unfixed
4058 + * would dominate the aggregate. Throttling to a day per user answers the question
4059 + * that matters — how many people are seeing this — for one write.
4060 + *
4061 + * @since 2.12.6
4062 + * @return void
4063 + */
4064 + private function track_database_notice_impression() {
4065 + $user_id = get_current_user_id();
4066 +
4067 + if ( ! $user_id ) {
4068 + return;
4069 + }
4070 +
4071 + $key = 'srfm_db_notice_seen_' . $user_id;
4072 +
4073 + if ( get_transient( $key ) ) {
4074 + return;
4075 + }
4076 +
4077 + set_transient( $key, 1, DAY_IN_SECONDS );
4078 +
4079 + Analytics::events()->track( 'database_error_notice_shown', 'entries' );
4080 + }
4081 +
4082 + /**
2907 4083 * Build the setup-card payload (uncached). See get_form_setup_card().
2908 4084 *
2909 4085 * @since 2.12.4
2910 4086 * @return array<string,mixed>|null Card payload, or null when there is no candidate.
@@ -3114,27 +4290,93 @@
3114 4290 // per-step claim — so it is always accurate whatever the user has since
3115 4291 // changed, while the action buttons point to the specific things to finish.
3116 4292 $sentence = __( 'We’ve already created this form for you. Finish customising it so it’s ready to collect real submissions.', 'sureforms' );
3117 4293
4294 + return self::build_srfm_notice_markup(
4295 + sprintf(
4296 + /* translators: %s: form name. */
4297 + __( 'Finish setting up “%s”', 'sureforms' ),
4298 + $form['title']
4299 + ),
4300 + $sentence,
4301 + [
4302 + [
4303 + 'text' => __( 'Edit form', 'sureforms' ),
4304 + 'url' => $form['edit_url'],
4305 + 'primary' => true,
4306 + 'class' => 'srfm-ty-edit-form',
4307 + 'external' => true,
4308 + ],
4309 + [
4310 + 'text' => __( 'Edit the Thank You message', 'sureforms' ),
4311 + 'url' => $form['thankyou_url'],
4312 + 'class' => 'srfm-ty-edit-thankyou',
4313 + 'external' => true,
4314 + ],
4315 + [
4316 + 'text' => __( 'Set where replies go', 'sureforms' ),
4317 + 'url' => $form['replies_url'],
4318 + 'class' => 'srfm-ty-set-replies',
4319 + 'external' => true,
4320 + ],
4321 + ]
4322 + );
4323 + }
4324 +
4325 + /**
4326 + * Build the shared SureForms admin-notice body: title, sentence, action row.
4327 + *
4328 + * One builder for every SureForms notice so they cannot drift into looking like
4329 + * two different plugins. Everything is escaped here rather than by the caller —
4330 + * the notices library runs the result through wp_kses_post(), which would strip
4331 + * anything richer anyway.
4332 + *
4333 + * @param string $title Notice heading.
4334 + * @param string $text Supporting sentence.
4335 + * @param array<int,array<string,mixed>> $actions Action links. Each accepts
4336 + * text, url, and optionally
4337 + * primary, class, external,
4338 + * dismiss and snooze (seconds).
4339 + * @since 2.12.6
4340 + * @return string
4341 + */
4342 + private static function build_srfm_notice_markup( $title, $text, $actions ) {
3118 4343 ob_start();
3119 4344 ?>
3120 - <p class="srfm-thankyou-notice__title">
4345 + <p class="srfm-notice__title"><?php echo esc_html( $title ); ?></p>
4346 + <p class="srfm-notice__text"><?php echo esc_html( $text ); ?></p>
4347 + <p class="srfm-notice__actions">
3121 4348 <?php
3122 - echo esc_html(
3123 - sprintf(
3124 - /* translators: %s: form name. */
3125 - __( 'Finish setting up “%s”', 'sureforms' ),
3126 - $form['title']
3127 - )
3128 - );
4349 + foreach ( $actions as $action ) {
4350 + if ( empty( $action['text'] ) || ! isset( $action['url'] ) ) {
4351 + continue;
4352 + }
4353 +
4354 + $classes = [ 'button' ];
4355 +
4356 + if ( ! empty( $action['primary'] ) ) {
4357 + $classes[] = 'button-primary';
4358 + }
4359 +
4360 + // astra-notice-close is what the library binds its dismiss handler to.
4361 + if ( ! empty( $action['dismiss'] ) ) {
4362 + $classes[] = 'astra-notice-close';
4363 + }
4364 +
4365 + if ( ! empty( $action['class'] ) ) {
4366 + $classes[] = $action['class'];
4367 + }
4368 + ?>
4369 + <a
4370 + class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
4371 + href="<?php echo esc_url( $action['url'] ); ?>"
4372 + <?php echo empty( $action['snooze'] ) ? '' : ' data-repeat-notice-after="' . esc_attr( (string) $action['snooze'] ) . '"'; ?>
4373 + <?php echo empty( $action['external'] ) ? '' : ' target="_blank" rel="noopener noreferrer"'; ?>
4374 + ><?php echo esc_html( $action['text'] ); ?></a>
4375 + <?php
4376 + }
3129 4377 ?>
3130 4378 </p>
3131 - <p class="srfm-thankyou-notice__text"><?php echo esc_html( $sentence ); ?></p>
3132 - <p class="srfm-thankyou-notice__actions">
3133 - <a class="button button-primary srfm-ty-edit-form" href="<?php echo esc_url( $form['edit_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Edit form', 'sureforms' ); ?></a>
3134 - <a class="button srfm-ty-edit-thankyou" href="<?php echo esc_url( $form['thankyou_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Edit the Thank You message', 'sureforms' ); ?></a>
3135 - <a class="button srfm-ty-set-replies" href="<?php echo esc_url( $form['replies_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Set where replies go', 'sureforms' ); ?></a>
3136 - </p>
3137 4379 <?php
3138 4380 return (string) ob_get_clean();
3139 4381 }
3140 4382
@@ -3195,68 +4437,8 @@
3195 4437 return 0 === strpos( $page, 'sureforms' ) || 0 === strpos( $page, 'srfm' );
3196 4438 }
3197 4439
3198 4440 /**
3199 - * Build the shared HTML markup for admin notices.
3200 - *
3201 - * @since 2.5.2
3202 - *
3203 - * All text parameters must be pre-escaped by the caller (e.g. via esc_html__()).
3204 - * URL parameters must be pre-escaped via esc_url().
3205 - *
3206 - * @param string $heading The notice heading text (pre-escaped).
3207 - * @param string $message The notice body text (pre-escaped).
3208 - * @param string $cta_url The primary CTA URL (pre-escaped).
3209 - * @param string $cta_text The primary CTA button text (pre-escaped).
3210 - * @param string $snooze_text The snooze button text (pre-escaped).
3211 - * @param string $dismiss_text The dismiss button text (pre-escaped).
3212 - * @param int $snooze_duration Snooze duration in seconds for the data-repeat-notice-after attribute.
3213 - * @param bool $external_cta Whether the CTA opens in a new tab and also dismisses the notice
3214 - * via the astra-notice-close class. Default false.
3215 - * @return string The notice HTML markup.
3216 - */
3217 - private function build_notice_markup( $heading, $message, $cta_url, $cta_text, $snooze_text, $dismiss_text, $snooze_duration, $external_cta = false ) {
3218 - $image_path = esc_url( SRFM_URL . 'admin/assets/sureforms-logo.png' );
3219 - $cta_class = $external_cta ? 'astra-notice-close button-primary' : 'button-primary';
3220 - $cta_attrs = $external_cta ? ' target="_blank" rel="noopener noreferrer"' : '';
3221 -
3222 - return sprintf(
3223 - '<div class="notice-image">
3224 - <img src="%1$s" class="custom-logo" alt="SureForms" itemprop="logo">
3225 - </div>
3226 - <div class="notice-content">
3227 - <div class="notice-heading">
3228 - %2$s
3229 - </div>
3230 - %3$s<br />
3231 - <div class="astra-review-notice-container">
3232 - <a href="%4$s" class="%5$s"%6$s>
3233 - %7$s
3234 - </a>
3235 - <span class="dashicons dashicons-clock" aria-hidden="true"></span>
3236 - <a href="#" data-repeat-notice-after="%8$s" class="astra-notice-close">
3237 - %9$s
3238 - </a>
3239 - <span class="dashicons dashicons-smiley" aria-hidden="true"></span>
3240 - <a href="#" class="astra-notice-close">
3241 - %10$s
3242 - </a>
3243 - </div>
3244 - </div>',
3245 - $image_path,
3246 - $heading,
3247 - $message,
3248 - $cta_url,
3249 - esc_attr( $cta_class ),
3250 - $cta_attrs,
3251 - $cta_text,
3252 - $snooze_duration,
3253 - $snooze_text,
3254 - $dismiss_text
3255 - );
3256 - }
3257 -
3258 - /**
3259 4441 * Callback for displaying the rating notice conditionally.
3260 4442 *
3261 4443 * Returns true if the user has 3 or more published forms or 3 or more form entries.
3262 4444 *
@@ -3356,11 +4538,13 @@
3356 4538 private function is_admin_pointer_visible() {
3357 4539 global $pagenow;
3358 4540 $allowed_pages = [ 'index.php', 'options-general.php' ];
3359 4541
3360 - // 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.
3361 4544 if (
3362 - ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) )
4545 + Helper::hide_promotions()
4546 + || ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) )
3363 4547 || ! empty( Helper::get_srfm_option( 'pointer_popup_accepted' ) )
3364 4548 || (int) ( wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0 ) > 1
3365 4549 ) {
3366 4550 return false;
@@ -3372,5 +4556,502 @@
3372 4556
3373 4557 return false;
3374 4558 }
3375 4559
4560 + /**
4561 + * Nonced URL that dismisses one action item without JavaScript.
4562 + *
4563 + * The classic notice cannot use the AJAX dismissal the carousel uses, and
4564 + * WordPress's own `is-dismissible` only hides the notice for that pageview.
4565 + *
4566 + * @param string $item_id Item to dismiss.
4567 + * @since 2.12.6
4568 + * @return string
4569 + */
4570 + private function get_dismiss_action_item_url( $item_id ) {
4571 + return wp_nonce_url(
4572 + add_query_arg(
4573 + [
4574 + 'action' => 'srfm_dismiss_action_item_link',
4575 + 'item' => $item_id,
4576 + ],
4577 + admin_url( 'admin-post.php' )
4578 + ),
4579 + 'srfm_dismiss_action_item'
4580 + );
4581 + }
4582 +
4583 + /**
4584 + * Count one sighting of each warning, at most once per user per day.
4585 + *
4586 + * Throttled because the classic notice renders on every admin page: counting
4587 + * each render would measure how much wp-admin someone browses, not how many
4588 + * sites are affected. A day per user answers the question that matters -- how
4589 + * many people are seeing this -- for one option write.
4590 + *
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.
4594 + *
4595 + * @param array<int,array<string,mixed>> $warnings SureForms' own items.
4596 + * @since 2.12.6
4597 + * @return void
4598 + */
4599 + private function track_action_item_impressions( $warnings ) {
4600 + if ( empty( $warnings ) || wp_doing_ajax() ) {
4601 + return;
4602 + }
4603 +
4604 + $user_id = get_current_user_id();
4605 +
4606 + if ( ! $user_id ) {
4607 + return;
4608 + }
4609 +
4610 + $counts = Helper::get_array_value( Helper::get_srfm_option( 'action_item_impressions', [] ) );
4611 + $changed = false;
4612 +
4613 + foreach ( $warnings as $warning ) {
4614 + $item_id = Helper::get_string_value( $warning['id'] ?? '' );
4615 +
4616 + if ( '' === $item_id ) {
4617 + continue;
4618 + }
4619 +
4620 + $seen_key = 'srfm_action_item_seen_' . $item_id . '_' . $user_id;
4621 +
4622 + if ( get_transient( $seen_key ) ) {
4623 + continue;
4624 + }
4625 +
4626 + set_transient( $seen_key, 1, DAY_IN_SECONDS );
4627 +
4628 + $counts[ $item_id ] = Helper::get_integer_value( $counts[ $item_id ] ?? 0 ) + 1;
4629 + $changed = true;
4630 +
4631 + // Cumulative, so $force = true: each new count is a new value and is
4632 + // re-sent, while an identical repeat short-circuits inside track().
4633 + Analytics::events()->track(
4634 + $item_id . '_notice_shown',
4635 + (string) $counts[ $item_id ],
4636 + [],
4637 + true
4638 + );
4639 + }
4640 +
4641 + if ( $changed ) {
4642 + Helper::update_srfm_option( 'action_item_impressions', $counts );
4643 + }
4644 + }
4645 +
4646 + /**
4647 + * Record one interaction with a Form Checks notice, cumulatively.
4648 + *
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().
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 + *
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.
4687 + *
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
4697 + * @return string
4698 + */
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 ) );
4702 +
4703 + $subject = sprintf( $copy['subject'], $host );
4704 +
4705 + $url = $this->build_support_mailto_within_limit( $category, $form_title, $subject );
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 +
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;
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 + /**
4807 + * The log tail, formatted for pasting.
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 + *
4934 + * Carries what support would otherwise have to ask for, so the first reply can
4935 + * be an answer rather than a questionnaire.
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.
4943 + * @since 2.12.6
4944 + * @return string
4945 + */
4946 + private function get_support_message( $category = '', $form_title = '' ) {
4947 + global $wp_version;
4948 +
4949 + $failures = Client_Logger::get_failures();
4950 + $count = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 );
4951 + $copy = $this->get_support_copy( $category );
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 +
4958 + $lines = [
4959 + 'Hello SureForms support,',
4960 + '',
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'
4988 + ),
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 + );
5001 +
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 );
5026 + }
5027 +
5028 + /**
5029 + * Record one dismissal, shared by the AJAX and no-JS entry points.
5030 + *
5031 + * Allowlisted, so only advisory items can be dismissed. A run of failed
5032 + * submissions is a fault and must stay put until it actually resolves --
5033 + * otherwise a crafted request could silence the one message that matters.
5034 + *
5035 + * @param string $item_id Item to dismiss.
5036 + * @since 2.12.6
5037 + * @return bool False when the id is not dismissible.
5038 + */
5039 + private function dismiss_action_item( $item_id ) {
5040 + if ( ! in_array( $item_id, [ 'caching_plugin' ], true ) ) {
5041 + return false;
5042 + }
5043 +
5044 + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
5045 +
5046 + if ( ! in_array( $item_id, $dismissed, true ) ) {
5047 + $dismissed[] = $item_id;
5048 + Helper::update_srfm_option( 'dismissed_action_items', $dismissed );
5049 +
5050 + // Recorded here rather than at each caller: both the cross in the
5051 + // dashboard panel and the no-JS link in the classic notice land here.
5052 + $this->track_notice_event( $item_id . '_notice_dismiss' );
5053 + }
5054 +
5055 + return true;
5056 + }
3376 5057 }