PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
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 0.0.2 All 97 releases
← All changes | admin/admin.php +4896 -121 0.0.22.12.7 View file →
@@ -6,13 +6,29 @@
6 6 */
7 7
8 8 namespace SRFM\Admin;
9 9
10 +use Astra_Notices;
11 +use SRFM\Inc\AI_Form_Builder\AI_Helper;
12 +use SRFM\Inc\Client_Logger;
13 +use SRFM\Inc\Database\Register;
14 +use SRFM\Inc\Database\Tables\Entries;
15 +use SRFM\Inc\Generate_Form_Markup;
16 +use SRFM\Inc\Global_Settings\Global_Settings;
17 +use SRFM\Inc\Helper;
18 +use SRFM\Inc\Onboarding;
19 +use SRFM\Inc\Payments\Payment_Helper;
20 +use SRFM\Inc\Payments\Stripe\Stripe_Helper;
21 +use SRFM\Inc\Smart_Tags;
10 22 use SRFM\Inc\Traits\Get_Instance;
11 23
12 24 if ( ! defined( 'ABSPATH' ) ) {
13 25 exit; // Exit if accessed directly.
14 26 }
27 +
28 +if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
29 + require_once SRFM_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
30 +}
15 31 /**
16 32 * Admin handler class.
17 33 *
18 34 * @since 0.0.1
@@ -17,12 +33,141 @@
17 33 *
18 34 * @since 0.0.1
19 35 */
20 36 class Admin {
37 + use Get_Instance;
21 38
22 - use Get_Instance;
39 + /**
40 + * Minimum number of forms or entries required to show the rating notice.
41 + *
42 + * @since 2.5.2
43 + */
44 + public const RATING_NOTICE_THRESHOLD = 3;
23 45
24 46 /**
47 + * Post meta the Starter Templates (Astra Sites) plugin stamps on every post it
48 + * imports. The "Finish setting up" Thank You prompt (#3030) scopes to these
49 + * forms only. Owned by a plugin that is NOT a SureForms dependency: on installs
50 + * without Starter Templates nothing carries this meta and the prompt never shows.
51 + *
52 + * @since 2.12.4
53 + */
54 + public const ASTRA_SITES_IMPORT_META = '_astra_sites_imported_post';
55 +
56 + /**
57 + * Negative-cache transient: no form on this site carries the import marker.
58 + *
59 + * Set only when the marker query itself returns zero posts, which is a
60 + * site-wide fact rather than a per-user one, and cleared as soon as any post is
61 + * stamped with the marker (see invalidate_starter_template_cache()). This keeps
62 + * the query off the majority of installs without tying the features to whether
63 + * Starter Templates happens to still be active — the marker outlives it.
64 + *
65 + * @since 2.12.4
66 + */
67 + public const NO_IMPORTED_FORMS_TRANSIENT = 'srfm_no_starter_template_forms';
68 +
69 + /**
70 + * Inline CSS for Quill 1.x (react-quill) list markers.
71 + *
72 + * Quill 1.x renders bullet/numbered list markers via CSS ::before pseudo-elements,
73 + * whereas the vendor quill.snow.css targets .ql-ui child elements (Quill 2.x approach).
74 + * This constant is shared by enqueue_styles() and enqueue_scripts() to prevent drift.
75 + *
76 + * @since 2.5.2
77 + */
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) ". "}';
79 +
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 dialog's Contact Support button goes.
93 + *
94 + * A form rather than an inbox: it collects the licence and site details support
95 + * would otherwise have to ask for, and the diagnostics are already on the
96 + * clipboard by the time someone gets here.
97 + *
98 + * @since 2.12.7
99 + */
100 + private const SUPPORT_CONTACT_URL = 'https://sureforms.com/form/troubleshooting-form/';
101 +
102 + /**
103 + * Dashboard widget entries data.
104 + *
105 + * @var array
106 + * @since 1.9.1
107 + */
108 + private $dashboard_widget_data = [];
109 +
110 + /**
111 + * Cached result for whether the rating notice should display.
112 + *
113 + * @var bool|null
114 + * @since 2.5.2
115 + */
116 + private $should_show_rating = null;
117 +
118 + /**
119 + * SureForms Page Default permission.
120 + *
121 + * @var string
122 + * @since 1.12.2
123 + */
124 + private static $sureforms_page_default_capability = 'manage_options';
125 +
126 + /**
127 + * Request memo for the "Finish setting up" Thank You prompt (#3030).
128 + *
129 + * A static property (not a function-local static) so tests can reset it via
130 + * reflection / reset_thankyou_prompt_cache() — otherwise the first call pins
131 + * the value for the whole process and the feature is untestable.
132 + *
133 + * @var array<int,array<string,mixed>>|null
134 + * @since 2.12.4
135 + */
136 + private static $thankyou_prompt_cache = null;
137 +
138 + /**
139 + * Request memo for the dashboard setup-checklist card (#3031).
140 + *
141 + * A static property (not a function-local static) so tests can reset it via
142 + * reset_form_setup_card_cache() and exercise the populated path — a
143 + * function-local static pins the first result for the whole process. Keyed by
144 + * user id since the payload derives from that user's capabilities.
145 + * `false` means "not computed yet"; `null`/array is a computed result.
146 + *
147 + * @var array<int,array<string,mixed>|null>
148 + * @since 2.12.4
149 + */
150 + private static $setup_card_cache = [];
151 +
152 + /**
153 + * Action items for this request, or null before the first build.
154 + *
155 + * Built twice on every admin page without this -- once for the localisation
156 + * payload, once in the classic renderer -- and each open failure category reads
157 + * a log excerpt. get_action_items() also records an impression, which running
158 + * twice counted twice.
159 + *
160 + * Reset with reset_action_items_cache(). Admin is a singleton, so without that
161 + * the first build pins the answer for the whole process and any test that
162 + * records a failure and then asks again is testing the memo.
163 + *
164 + * @var array<int,array<string,mixed>>|null
165 + * @since 2.12.7
166 + */
167 + private static $action_items_cache = null;
168 +
169 + /**
25 170 * Class constructor.
26 171 *
27 172 * @return void
28 173 * @since 0.0.1
@@ -30,19 +175,995 @@
30 175 public function __construct() {
31 176 add_action( 'admin_menu', [ $this, 'add_menu_page' ], 9 );
32 177 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
33 178 add_action( 'admin_menu', [ $this, 'settings_page' ] );
179 + add_action( 'admin_menu', [ $this, 'add_learn_page' ] );
34 180 add_action( 'admin_menu', [ $this, 'add_new_form' ] );
181 + add_action( 'admin_menu', [ $this, 'add_suremail_page' ] );
182 + if ( ! Helper::has_pro() ) {
183 + add_action( 'admin_menu', [ $this, 'add_quiz_page' ] );
184 + add_action( 'admin_menu', [ $this, 'add_survey_reports_page' ] );
185 + add_action( 'admin_menu', [ $this, 'add_partial_entries_page' ] );
186 + add_action( 'admin_menu', [ $this, 'add_upgrade_to_pro' ] );
187 + add_action( 'admin_footer', [ $this, 'add_upgrade_to_pro_target_attr' ] );
188 + }
189 +
35 190 add_filter( 'plugin_action_links', [ $this, 'add_settings_link' ], 10, 2 );
36 191 add_action( 'enqueue_block_assets', [ $this, 'enqueue_styles' ] );
37 192 add_action( 'admin_head', [ $this, 'enqueue_header_styles' ] );
38 - add_action( 'admin_body_class', [ $this, 'admin_template_picker_body_class' ] );
193 + add_filter( 'admin_body_class', [ $this, 'admin_template_picker_body_class' ] );
39 194
40 195 // this action is used to restrict Spectra's quick action bar on SureForms CPTS.
41 196 add_action( 'uag_enable_quick_action_sidebar', [ $this, 'restrict_spectra_quick_action_bar' ] );
197 +
198 + add_action( 'current_screen', [ $this, 'enable_gutenberg_for_sureforms' ], 100 );
199 + // Register notices early for React pages (before admin_enqueue_scripts).
200 + add_action( 'admin_init', [ $this, 'register_pro_compatibility_notices' ], 5 );
201 +
202 + // Database maintenance notice: the entries table is missing, so submissions
203 + // cannot be saved. Registered at admin_init priority 5 so the React notice is
204 + // in place before admin_enqueue_scripts localizes it.
205 + add_action( 'admin_init', [ $this, 'register_database_repair_notice' ], 5 );
206 + add_action( 'admin_notices', [ $this, 'render_action_item_notices' ] );
207 + // Late priority so the items are built after anything hooking
208 + // srfm_action_items has had a chance to register.
209 + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_action_item_styles' ], 20 );
210 + add_action( 'admin_notices', [ $this, 'render_database_repair_notice' ] );
211 + add_action( 'admin_post_srfm_repair_entries_table', [ $this, 'handle_database_repair' ] );
212 + // Display notices on traditional WordPress admin pages.
213 + add_action( 'admin_notices', [ $this, 'srfm_pro_version_compatibility' ] );
214 +
215 + // Enfold theme compatibility to enable block editor for SureForms post type.
216 + add_filter( 'avf_use_block_editor_for_post', [ $this, 'enable_block_editor_in_enfold_theme' ] );
217 +
218 + // Add action links to the plugin page.
219 + add_filter( 'plugin_action_links_' . SRFM_BASENAME, [ $this, 'add_action_links' ] );
220 + // Check if admin notification is enabled and add entries badge.
221 + $general_options = get_option( 'srfm_general_settings_options', [] );
222 + $admin_notification_on = isset( $general_options['srfm_admin_notification'] ) ? (bool) $general_options['srfm_admin_notification'] : true;
223 +
224 + if ( $admin_notification_on ) {
225 + add_action( 'admin_menu', [ $this, 'maybe_add_entries_badge' ], 99 );
226 + }
227 +
228 + add_filter( 'wpforms_current_user_can', [ $this, 'disable_wpforms_capabilities' ], 10, 3 );
229 +
230 + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_pointer' ] );
231 + // Ajax callbacks for wp-pointer functionality.
232 + add_action( 'wp_ajax_should_show_pointer', [ $this, 'pointer_should_show' ] );
233 + add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] );
234 + add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] );
235 + add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] );
236 + add_action( 'wp_ajax_srfm_action_item_details', [ $this, 'handle_action_item_details' ] );
237 + add_action( 'wp_ajax_srfm_dismiss_action_item', [ $this, 'handle_dismiss_action_item' ] );
238 + add_action( 'admin_post_srfm_dismiss_action_item_link', [ $this, 'handle_dismiss_action_item_link' ] );
239 + add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] );
240 + add_action( 'load-post.php', [ $this, 'maybe_track_edit_form_button_click' ] );
241 + add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] );
242 +
243 + // Register dashboard widget only if there are recent entries.
244 + add_action( 'admin_init', [ $this, 'maybe_register_dashboard_widget' ] );
245 +
246 + // Enqueue the AI quick draft widget script on the dashboard screen.
247 + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_ai_dashboard_widget_assets' ] );
248 +
249 + // "Finish setting up" checklist widget on the main WP dashboard (#3031).
250 + add_action( 'wp_dashboard_setup', [ $this, 'register_form_setup_widget' ] );
251 + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_form_setup_widget_assets' ] );
252 +
253 + // Drop the "no imported forms" negative cache as soon as a post is stamped
254 + // with the import marker, so a template imported after the cache was written
255 + // surfaces immediately instead of waiting for the transient to expire.
256 + add_action( 'added_post_meta', [ $this, 'invalidate_starter_template_cache' ], 10, 3 );
257 + add_action( 'updated_post_meta', [ $this, 'invalidate_starter_template_cache' ], 10, 3 );
258 +
259 + // Save first form creation time stamp.
260 + add_action( 'admin_init', [ $this, 'save_first_form_creation_time_stamp' ] );
261 + add_action( 'admin_notices', [ $this, 'display_srfm_rating_notice' ] );
262 + add_action( 'admin_notices', [ $this, 'display_srfm_getting_started_notice' ] );
263 +
264 + // "Finish setting up" prompt, shown as an Astra Notices admin notice on
265 + // every admin screen except the dashboard (#3030).
266 + add_action( 'admin_notices', [ $this, 'render_thankyou_prompt_notice' ] );
267 +
268 + /**
269 + * Suppress foreign (third-party) admin notices on SureForms admin screens.
270 + *
271 + * Some plugins (e.g. Ninja Forms) print large promotional banners on every
272 + * admin page via the admin_notices / all_admin_notices / network_admin_notices
273 + * hooks. These bleed onto SureForms' own React admin screens and break the UI.
274 + * We run at the EARLIEST priority on each notice hook (all third-party
275 + * callbacks are registered before these hooks fire, during admin_init /
276 + * plugin load) and strip the foreign ones before they are echoed, while
277 + * preserving SureForms' own notices. Scoped strictly to SureForms screens.
278 + */
279 + add_action( 'admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
280 + add_action( 'all_admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
281 + add_action( 'network_admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
42 282 }
43 283
44 284 /**
285 + * Remove third-party admin notices on SureForms admin screens.
286 + *
287 + * Iterates over the callbacks registered on the admin notice hooks and
288 + * removes any that are not owned by SureForms. A callback is considered
289 + * owned by SureForms when it belongs to a class in the `SRFM` / `SRFM_PRO`
290 + * namespaces or to the bundled `BSF_Admin_Notices` / `Astra_Notices`
291 + * notices library. SureForms' own notices are therefore preserved while
292 + * foreign promotional banners are suppressed.
293 + *
294 + * This callback is hooked at `PHP_INT_MIN` so that it runs first on each
295 + * notice hook and removes the foreign callbacks before WordPress echoes
296 + * them (WP_Hook honours removals made during iteration). It is strictly
297 + * scoped to SureForms admin screens via {@see Helper::is_sureforms_admin_page()}
298 + * so no other admin page is affected.
299 + *
300 + * @since 2.10.0
301 + * @return void
302 + */
303 + public function suppress_foreign_admin_notices() {
304 + // Bail early if we are not on a SureForms admin screen. This keeps the
305 + // suppression strictly scoped and avoids touching any other admin page.
306 + // is_sureforms_admin_page() covers the core screens (dashboard, add-new,
307 + // settings, entries, the form CPT); we additionally match any admin page
308 + // whose `page` slug is SureForms-owned (sureforms_* / srfm_*) so the
309 + // suppression also applies to the payments/quiz/survey/learn/SMTP screens.
310 + if ( ! Helper::is_sureforms_admin_page() && ! $this->is_sureforms_owned_admin_page() ) {
311 + return;
312 + }
313 +
314 + global $wp_filter;
315 +
316 + // The hook currently being fired (admin_notices, all_admin_notices or network_admin_notices).
317 + $current_hook = current_action();
318 +
319 + if ( empty( $current_hook ) || empty( $wp_filter[ $current_hook ] ) || ! ( $wp_filter[ $current_hook ] instanceof \WP_Hook ) ) {
320 + return;
321 + }
322 +
323 + foreach ( $wp_filter[ $current_hook ]->callbacks as $priority => $callbacks ) {
324 + foreach ( $callbacks as $callback ) {
325 + $function = $callback['function'] ?? null;
326 +
327 + // Never remove our own suppression callback.
328 + if ( is_array( $function ) && isset( $function[0] ) && $function[0] === $this && 'suppress_foreign_admin_notices' === $function[1] ) {
329 + continue;
330 + }
331 +
332 + // Preserve SureForms-owned notices, remove everything else.
333 + if ( $this->is_sureforms_owned_notice_callback( $function ) ) {
334 + continue;
335 + }
336 +
337 + remove_action( $current_hook, $function, $priority );
338 + }
339 + }
340 + }
341 +
342 + /**
343 + * Get the first form creation time stamp.
344 + *
345 + * @since 1.10.1
346 + * @return int|false
347 + */
348 + public static function get_first_form_creation_time_stamp() {
349 + return Helper::get_srfm_option( 'first_form_created_at', false );
350 + }
351 +
352 + /**
353 + * Check if the first form has been created.
354 + *
355 + * @since 1.10.1
356 + * @return bool
357 + */
358 + public static function is_first_form_created() {
359 + // Convert the first form creation time stamp to a boolean. If it exists, it will return true, otherwise false.
360 + $first_form_creation_time_stamp = self::get_first_form_creation_time_stamp();
361 +
362 + // If the first form creation time stamp is not set, return false.
363 + if ( ! $first_form_creation_time_stamp ) {
364 + return false; // No forms created yet.
365 + }
366 +
367 + // Check if the first form creation time stamp is a valid integer and greater than zero.
368 + return is_int( $first_form_creation_time_stamp ) && $first_form_creation_time_stamp > 0;
369 + }
370 +
371 + /**
372 + * Whether a form's confirmation message is still the shipped default.
373 + *
374 + * Compared on tag-stripped, entity-decoded, whitespace-collapsed text rather
375 + * than raw HTML: the default is stored with a base64 icon on creation but
376 + * regenerated with a URL icon, so the markup differs while the wording does
377 + * not, and a starter-template import can store a literal apostrophe where the
378 + * generated default carries the encoded `&#039;` — decoding entities makes both
379 + * compare equal. Any real edit to the heading or body text changes the text and
380 + * flips this to false, which is exactly when the prompt should stop showing.
381 + *
382 + * Locale caveat: the comparison target is translated at call time, so a form
383 + * whose default was stored under a different active locale won't match. That
384 + * fails safe — the prompt simply doesn't show — never a false nag.
385 + *
386 + * @param int $form_id Form post ID.
387 + *
388 + * @since 2.12.4
389 + * @return bool
390 + */
391 + public static function is_default_confirmation_message( $form_id ) {
392 + $confirmation = get_post_meta( (int) $form_id, '_srfm_form_confirmation', true );
393 +
394 + if ( ! is_array( $confirmation ) || ! isset( $confirmation[0]['message'] ) || ! is_string( $confirmation[0]['message'] ) ) {
395 + return false;
396 + }
397 +
398 + // The default message is only ever shown for a "same page" confirmation.
399 + // A redirect ("different page" / "custom url") never renders it, yet the
400 + // stored settings still seed the default message string — so without this
401 + // guard a redirect form would be nagged forever about a message no visitor
402 + // sees, with no way to clear the prompt by doing what it asks.
403 + if ( ! isset( $confirmation[0]['confirmation_type'] ) || 'same page' !== $confirmation[0]['confirmation_type'] ) {
404 + return false;
405 + }
406 +
407 + $message = $confirmation[0]['message'];
408 +
409 + if ( '' === trim( $message ) ) {
410 + return false;
411 + }
412 +
413 + $normalize = static function ( $html ) {
414 + // Decode entities too, so an encoded apostrophe (&#039;) in the generated
415 + // default matches a literal one stored by a template import.
416 + $text = html_entity_decode( wp_strip_all_tags( (string) $html ), ENT_QUOTES, 'UTF-8' );
417 + return trim( (string) preg_replace( '/\s+/', ' ', $text ) );
418 + };
419 +
420 + return $normalize( $message ) === $normalize( Global_Settings::get_default_confirmation_message() );
421 + }
422 +
423 + /**
424 + * Whether a form has somewhere to send replies (an enabled email notification
425 + * with a non-empty recipient).
426 + *
427 + * @param int $form_id Form post ID.
428 + *
429 + * @since 2.12.4
430 + * @return bool
431 + */
432 + public static function form_has_reply_destination( $form_id ) {
433 + $notifications = get_post_meta( (int) $form_id, '_srfm_email_notification', true );
434 +
435 + if ( ! is_array( $notifications ) ) {
436 + return false;
437 + }
438 +
439 + foreach ( $notifications as $notification ) {
440 + if ( is_array( $notification ) && ! empty( $notification['status'] ) && ! empty( $notification['email_to'] ) ) {
441 + return true;
442 + }
443 + }
444 +
445 + return false;
446 + }
447 +
448 + /**
449 + * The most recently created form still needing setup (default Thank You
450 + * message, or no reply destination).
451 + *
452 + * Powers the "Finish setting up" prompt (#3030). Limited to the single latest
453 + * such form to avoid clutter, and to forms the current user may actually edit.
454 + * A form is a candidate when it still has an unfinished step (default Thank You
455 + * message, or no reply destination). Dismissal is enforced by the caller,
456 + * before this query runs.
457 + *
458 + * @since 2.12.4
459 + * @return array<int,array<string,mixed>> One entry, or none.
460 + */
461 + public static function get_thankyou_prompt_forms() {
462 + // Memoized for the request so repeated reads (e.g. the notice render plus
463 + // any add-on consumer) share a single query. Sentinel is null, not false,
464 + // so a filter returning false (__return_false to disable) still memoizes.
465 + if ( null !== self::$thankyou_prompt_cache ) {
466 + return self::$thankyou_prompt_cache;
467 + }
468 +
469 + /**
470 + * Filter the forms the "Finish setting up" Thank You notice may surface.
471 + *
472 + * @param array<int,array<string,mixed>> $prompts Candidate prompt payloads.
473 + *
474 + * @since 2.12.4
475 + */
476 + $filtered = apply_filters( 'srfm_thankyou_prompt_forms', self::compute_thankyou_prompt_forms() );
477 + self::$thankyou_prompt_cache = is_array( $filtered ) ? $filtered : [];
478 +
479 + return self::$thankyou_prompt_cache;
480 + }
481 +
482 + /**
483 + * Clear the request memo for the action items.
484 + *
485 + * Admin is a singleton, so the memo outlives a request in a test process.
486 + * Anything that records or clears a failure inside one process has to call
487 + * this, or it reads the answer from before the change.
488 + *
489 + * @since 2.12.7
490 + * @return void
491 + */
492 + public static function reset_action_items_cache() {
493 + self::$action_items_cache = null;
494 + }
495 +
496 + /**
497 + * Clear the request memo for the Thank You prompt (#3030).
498 + *
499 + * Lets tests exercise the memoized public path, and is a safe hook for anything
500 + * that changes which form qualifies (e.g. a form save).
501 + *
502 + * @since 2.12.4
503 + * @return void
504 + */
505 + public static function reset_thankyou_prompt_cache() {
506 + self::$thankyou_prompt_cache = null;
507 + }
508 +
509 + /**
510 + * Setup-checklist data for the newest starter-template form (#3031).
511 + *
512 + * Picks the most recent form the current user can edit that was created from an
513 + * Astra Sites starter template. The widget
514 + * lists a fixed set of optional next-steps for it — their completion is not
515 + * computed — so the payload carries only the form and the CTA targets. Memoized
516 + * for the request so the widget register/enqueue/render passes share one query.
517 + *
518 + * @since 2.12.4
519 + * @return array<string,mixed>|null Card payload, or null when there is no candidate form.
520 + */
521 + public static function get_form_setup_card() {
522 + $user_id = get_current_user_id();
523 +
524 + // Request memo, keyed per user — the payload derives from that user's
525 + // capabilities. Reset via reset_form_setup_card_cache().
526 + if ( array_key_exists( $user_id, self::$setup_card_cache ) ) {
527 + return self::$setup_card_cache[ $user_id ];
528 + }
529 +
530 + self::$setup_card_cache[ $user_id ] = self::compute_form_setup_card();
531 +
532 + return self::$setup_card_cache[ $user_id ];
533 + }
534 +
535 + /**
536 + * Drop the "no imported forms" negative cache when the marker is written.
537 + *
538 + * Hooked to added_post_meta/updated_post_meta. Without this, a starter template
539 + * imported after the negative cache was written would show neither the Thank You
540 + * prompt nor the setup widget until the transient expired.
541 + *
542 + * Arguments are read from func_get_args() rather than declared: the hook passes
543 + * ( $meta_id, $post_id, $meta_key ) and the meta id is never needed, so declaring
544 + * it would leave an unused parameter that the coding-standards gate rejects.
545 + *
546 + * @since 2.12.4
547 + * @return void
548 + */
549 + public function invalidate_starter_template_cache() {
550 + $args = func_get_args();
551 + $post_id = isset( $args[1] ) ? (int) $args[1] : 0;
552 + $meta_key = isset( $args[2] ) ? (string) $args[2] : '';
553 +
554 + if ( self::ASTRA_SITES_IMPORT_META !== $meta_key ) {
555 + return;
556 + }
557 +
558 + // A full-site import stamps this marker on every post it creates, so narrow to
559 + // our own post type: both features only ever query sureforms_form, and this
560 + // avoids clearing the cache repeatedly for pages and products during an import.
561 + if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || SRFM_FORMS_POST_TYPE !== get_post_type( $post_id ) ) {
562 + return;
563 + }
564 +
565 + delete_transient( self::NO_IMPORTED_FORMS_TRANSIENT );
566 + }
567 +
568 + /**
569 + * Clear the setup-card request memo (#3031).
570 + *
571 + * Lets tests exercise the populated path, and is a safe hook for anything that
572 + * changes which form qualifies (e.g. a form save).
573 + *
574 + * @since 2.12.4
575 + * @return void
576 + */
577 + public static function reset_form_setup_card_cache() {
578 + self::$setup_card_cache = [];
579 + }
580 +
581 + /**
582 + * REST handler: record a "Finish setting up" widget interaction (#3031).
583 + *
584 + * Records the clicked CTA/view action as an analytics event. The request
585 + * carries the displayed form id: capability is re-checked against it here —
586 + * beyond the route's generic permission callback — so only a genuine editor of
587 + * that form can act.
588 + *
589 + * @param \WP_REST_Request<array<string,mixed>> $request Request.
590 + *
591 + * @since 2.12.4
592 + * @return \WP_REST_Response|\WP_Error
593 + */
594 + public function dismiss_form_setup_card( $request ) {
595 + $form_id = absint( $request->get_param( 'form_id' ) );
596 +
597 + if ( $form_id <= 0 || ! defined( 'SRFM_FORMS_POST_TYPE' ) || SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) || ! current_user_can( 'edit_post', $form_id ) ) {
598 + return new \WP_Error( 'srfm_setup_card_forbidden', __( 'You are not allowed to update this prompt.', 'sureforms' ), [ 'status' => 403 ] );
599 + }
600 +
601 + $action = sanitize_key( (string) $request->get_param( 'action' ) );
602 +
603 + // Interaction analytics for the setup widget (#3031). Each event carries a
604 + // date automatically (see BSF_Analytics_Events::track()) and dedupes per
605 + // event name, matching the sibling notice's telemetry.
606 + $events = [
607 + 'edit_form' => 'form_setup_widget_edit_form',
608 + 'edit_thankyou' => 'form_setup_widget_edit_thankyou',
609 + 'set_up_email' => 'form_setup_widget_set_up_email',
610 + 'view_form' => 'form_setup_widget_view_form',
611 + ];
612 +
613 + if ( isset( $events[ $action ] ) ) {
614 + Analytics::events()->track( $events[ $action ], (string) $form_id );
615 + }
616 +
617 + return new \WP_REST_Response( [ 'success' => true ], 200 );
618 + }
619 +
620 + /**
621 + * Register the "Finish setting up" checklist widget on the main WP dashboard (#3031).
622 + *
623 + * Only for capable users, and only when there is a form still needing setup —
624 + * so the widget never appears empty. The data is memoized in get_form_setup_card()
625 + * and reused by the enqueue and render passes.
626 + *
627 + * @since 2.12.4
628 + * @return void
629 + */
630 + public function register_form_setup_widget() {
631 + if ( ! Helper::current_user_can() ) {
632 + return;
633 + }
634 +
635 + if ( null === self::get_form_setup_card() ) {
636 + return;
637 + }
638 +
639 + wp_add_dashboard_widget(
640 + 'srfm_form_setup_checklist',
641 + __( 'Finish setting up your form', 'sureforms' ),
642 + [ $this, 'render_form_setup_widget' ],
643 + null,
644 + null,
645 + 'normal',
646 + 'high'
647 + );
648 + }
649 +
650 + /**
651 + * Render the setup-checklist widget content (#3031).
652 + *
653 + * A heading (with a link to view the form), a subtitle, and a fixed list of
654 + * optional next-steps — each always shown with its CTA; completion is not
655 + * computed. Each CTA deep-links into the editor (edit form / Thank You message /
656 + * email notification) and records an analytics event via the REST endpoint
657 + * wired in the enqueued inline script.
658 + *
659 + * @since 2.12.4
660 + * @return void
661 + */
662 + public function render_form_setup_widget() {
663 + $card = self::get_form_setup_card();
664 +
665 + if ( null === $card ) {
666 + return;
667 + }
668 +
669 + // Optional next-steps — always offered, their completion is not computed.
670 + // 'event' is the analytics action key beaconed on click (see the widget JS).
671 + $rows = [
672 + [
673 + 'label' => __( 'Review or edit your form', 'sureforms' ),
674 + 'cta' => __( 'Edit form', 'sureforms' ),
675 + 'url' => $card['edit_url'],
676 + 'event' => 'edit_form',
677 + ],
678 + [
679 + 'label' => __( 'Personalize the Thank You message', 'sureforms' ),
680 + 'cta' => __( 'Edit message', 'sureforms' ),
681 + 'url' => $card['thankyou_url'],
682 + 'event' => 'edit_thankyou',
683 + ],
684 + [
685 + 'label' => __( 'Choose who gets notified of new replies', 'sureforms' ),
686 + 'cta' => __( 'Set up email', 'sureforms' ),
687 + 'url' => $card['email_url'],
688 + 'event' => 'set_up_email',
689 + ],
690 + ];
691 +
692 + // Fall back to a generic label for an untitled form so the heading never
693 + // renders "Finish setting up " with a dangling space.
694 + $card_title = '' !== trim( (string) $card['title'] ) ? $card['title'] : __( 'your form', 'sureforms' );
695 + $heading = sprintf(
696 + /* translators: %s: form title. */
697 + __( 'Finish setting up %s', 'sureforms' ),
698 + $card_title
699 + );
700 + ?>
701 + <div class="srfm-setup-checklist" id="srfm-setup-checklist">
702 + <p class="srfm-setup-checklist__title">
703 + <?php echo esc_html( $heading ); ?>
704 + <?php if ( ! empty( $card['view_url'] ) ) { ?>
705 + <a class="srfm-setup-checklist__view" data-srfm-event="view_form" href="<?php echo esc_url( $card['view_url'] ); ?>" target="_blank" rel="noopener noreferrer" aria-label="<?php echo esc_attr( sprintf( /* translators: %s: form title. */ __( 'View %s (opens in a new tab)', 'sureforms' ), $card_title ) ); ?>">
706 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
707 + </a>
708 + <?php } ?>
709 + </p>
710 + <p class="srfm-setup-checklist__subtitle"><?php esc_html_e( 'Customize your form to get it ready for real submissions:', 'sureforms' ); ?></p>
711 +
712 + <ul class="srfm-setup-checklist__steps">
713 + <?php foreach ( $rows as $row ) { ?>
714 + <li class="srfm-setup-checklist__step">
715 + <span class="srfm-setup-checklist__label"><?php echo esc_html( $row['label'] ); ?></span>
716 + <a class="srfm-setup-checklist__cta" data-srfm-event="<?php echo esc_attr( $row['event'] ); ?>" href="<?php echo esc_url( $row['url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( $row['cta'] ); ?></a>
717 + </li>
718 + <?php } ?>
719 + </ul>
720 + </div>
721 + <?php
722 + }
723 +
724 + /**
725 + * Enqueue the setup-checklist widget's styles and behavior on the dashboard (#3031).
726 + *
727 + * Mirrors the AI widget convention: an inline-only handle carries the CSS and the
728 + * behavior (CTA click analytics), with server values —
729 + * the REST URL, nonce and form id — passed through wp_localize_script rather than
730 + * printed into the markup, so it stays Plugin-Check clean.
731 + *
732 + * @param string $hook_suffix Current admin page hook suffix.
733 + *
734 + * @since 2.12.4
735 + * @return void
736 + */
737 + public function enqueue_form_setup_widget_assets( $hook_suffix ) {
738 + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
739 + return;
740 + }
741 +
742 + $card = self::get_form_setup_card();
743 +
744 + if ( null === $card ) {
745 + return;
746 + }
747 +
748 + $css = <<<'CSS'
749 +#srfm_form_setup_checklist .inside { margin: 0; padding: 0; }
750 +.srfm-setup-checklist { padding: 12px 16px 16px; }
751 +.srfm-setup-checklist__title { margin: 0 0 4px; font-size: 15px; font-weight: 600; color: #1e1e1e; }
752 +.srfm-setup-checklist__view { display: inline-flex; align-items: center; margin-left: 6px; color: #d54e21; vertical-align: middle; }
753 +.srfm-setup-checklist__view:hover, .srfm-setup-checklist__view:focus { color: #b83c14; }
754 +.srfm-setup-checklist__subtitle { margin: 0 0 12px; color: #646970; font-size: 13px; }
755 +.srfm-setup-checklist__steps { margin: 0; padding: 0; list-style: none; }
756 +.srfm-setup-checklist__step { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; }
757 +.srfm-setup-checklist__step + .srfm-setup-checklist__step { margin-top: 6px; }
758 +.srfm-setup-checklist__step { background: #f6f7f7; }
759 +.srfm-setup-checklist__label { flex: 1 1 auto; font-size: 14px; color: #1e1e1e; }
760 +.srfm-setup-checklist__cta { margin-left: auto; border: 0; background: transparent; padding: 0; font-size: 14px; font-weight: 600; color: #d54e21; text-decoration: underline; cursor: pointer; }
761 +.srfm-setup-checklist__cta:hover { color: #b83c14; }
762 +/* Keep visited links on-brand — WP admin's a:visited would otherwise turn them blue. */
763 +.srfm-setup-checklist a:visited { color: #d54e21; }
764 +.srfm-setup-checklist a:visited:hover, .srfm-setup-checklist a:visited:focus { color: #b83c14; }
765 +/* Drop WP's blue focus ring on the widget's links; keep an accessible, on-brand keyboard outline. */
766 +.srfm-setup-checklist a:focus { outline: none; box-shadow: none; }
767 +.srfm-setup-checklist a:focus-visible { outline: 2px solid #d54e21; outline-offset: 2px; box-shadow: none; }
768 +CSS;
769 +
770 + wp_register_style( 'srfm-setup-checklist-widget', false, [], SRFM_VER );
771 + wp_enqueue_style( 'srfm-setup-checklist-widget' );
772 + wp_add_inline_style( 'srfm-setup-checklist-widget', $css );
773 +
774 + wp_register_script( 'srfm-setup-checklist-widget', '', [], SRFM_VER, true );
775 + wp_enqueue_script( 'srfm-setup-checklist-widget' );
776 +
777 + wp_localize_script(
778 + 'srfm-setup-checklist-widget',
779 + 'srfmSetupChecklist',
780 + [
781 + 'restUrl' => esc_url_raw( rest_url( 'sureforms/v1/dismiss-form-setup-card' ) ),
782 + 'nonce' => wp_create_nonce( 'wp_rest' ),
783 + 'formId' => $card['id'],
784 + ]
785 + );
786 +
787 + $inline_script = <<<'JS'
788 +( function () {
789 + const cfg = window.srfmSetupChecklist || {};
790 + const widget = document.getElementById( 'srfm-setup-checklist' );
791 + if ( ! widget ) {
792 + return;
793 + }
794 +
795 + const persist = function ( action ) {
796 + return fetch( cfg.restUrl, {
797 + method: 'POST',
798 + credentials: 'same-origin',
799 + keepalive: true,
800 + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
801 + body: JSON.stringify( { form_id: cfg.formId, action: action } ),
802 + } ).catch( function () {} );
803 + };
804 +
805 + // Beacon the CTA / view-form clicks for analytics. keepalive on the fetch lets
806 + // the request finish even though the CTA immediately navigates away.
807 + widget.addEventListener( 'click', function ( e ) {
808 + const target = e.target?.closest?.( '[data-srfm-event]' );
809 + if ( target ) {
810 + persist( target.getAttribute( 'data-srfm-event' ) );
811 + }
812 + } );
813 +}() );
814 +JS;
815 +
816 + wp_add_inline_script( 'srfm-setup-checklist-widget', $inline_script );
817 + }
818 +
819 + /**
820 + * Register the "Finish setting up" prompt as an Astra Notices admin notice (#3030).
821 + *
822 + * Hooked to admin_notices so it registers before the Astra Notices library
823 + * renders (priority 30). Shown on every admin screen EXCEPT the main dashboard,
824 + * for the newest form the current user can edit that still has an unfinished
825 + * step (default Thank You message, or no reply destination). Uses a single
826 + * stable notice id so the library's built-in ✕ dismissal is one persistent
827 + * choice ("stop nudging me"), not a per-form row.
828 + *
829 + * Split out from the renderer so the decision has exactly one home. The Getting
830 + * Started notice suppresses itself when this returns a form, and duplicating the
831 + * conditions there would have meant two copies drifting apart. Reading it costs
832 + * nothing extra — get_thankyou_prompt_forms() memoizes its query per request.
833 + *
834 + * @since 2.12.6
835 + * @return array<string,mixed>|null The form to prompt for, or null when no prompt should render.
836 + */
837 + public function get_displayable_thankyou_prompt() {
838 + if ( ! Helper::current_user_can() || ! class_exists( 'Astra_Notices' ) ) {
839 + return null;
840 + }
841 +
842 + /**
843 + * Short-circuit the "Finish setting up" Thank You notice.
844 + *
845 + * @param bool $show Whether to show the notice. Default true.
846 + *
847 + * @since 2.12.4
848 + */
849 + if ( ! apply_filters( 'srfm_show_thankyou_prompt', true ) ) {
850 + return null;
851 + }
852 +
853 + // Everywhere in wp-admin except the main dashboard. A null screen fails
854 + // closed (return) rather than registering the notice on an unknown screen.
855 + $screen = get_current_screen();
856 +
857 + if ( ! $screen || 'dashboard' === $screen->id ) {
858 + return null;
859 + }
860 +
861 + // The library only checks dismissal at render (priority 30, after this
862 + // query would already have run). Check it up front so a user who dismissed
863 + // the prompt never pays for the WP_Query on subsequent admin page views.
864 + if ( 'notice-dismissed' === get_user_meta( get_current_user_id(), self::THANKYOU_PROMPT_NOTICE_ID, true ) ) {
865 + return null;
866 + }
867 +
868 + // array_values so a filter returning a key-preserving array (e.g. the
869 + // result of array_filter()) still exposes the newest prompt at index 0.
870 + $prompts = array_values( (array) self::get_thankyou_prompt_forms() );
871 +
872 + // Validate every key build_thankyou_notice_markup() reads, not just id/edit_url
873 + // — a filter returning a partial payload would otherwise trip "Undefined array
874 + // key" warnings and esc_url( null ) deprecations on every admin page.
875 + if (
876 + empty( $prompts[0] ) || ! is_array( $prompts[0] )
877 + || empty( $prompts[0]['id'] ) || empty( $prompts[0]['edit_url'] )
878 + || empty( $prompts[0]['thankyou_url'] ) || empty( $prompts[0]['replies_url'] )
879 + || ! isset( $prompts[0]['title'] )
880 + ) {
881 + return null;
882 + }
883 +
884 + return $prompts[0];
885 + }
886 +
887 + /**
888 + * Render the "Finish setting up" Thank You notice (#3030).
889 + *
890 + * @since 2.12.4
891 + * @return void
892 + */
893 + public function render_thankyou_prompt_notice() {
894 + $notice_id = self::THANKYOU_PROMPT_NOTICE_ID;
895 + $form = $this->get_displayable_thankyou_prompt();
896 +
897 + if ( null === $form ) {
898 + return;
899 + }
900 +
901 + // A broken form outranks a setup prompt. This is the top of the existing
902 + // precedence chain, so the action-item check goes here rather than the
903 + // action items standing down for an engagement notice.
904 + if ( $this->has_action_item_warnings() ) {
905 + return;
906 + }
907 +
908 + \Astra_Notices::add_notice(
909 + [
910 + 'id' => $notice_id,
911 + 'type' => 'info',
912 + 'message' => self::build_thankyou_notice_markup( $form ),
913 + 'class' => 'srfm-notice srfm-thankyou-notice',
914 + 'is_dismissible' => true,
915 + 'display-with-other-notices' => true,
916 + // Render late so this nudge never pre-empts higher-priority notices
917 + // (e.g. Astra's minimum-version warnings, which are display-with-
918 + // other-notices => false and would be skipped once ours renders).
919 + 'priority' => 100,
920 + ]
921 + );
922 +
923 + // The message is wp_kses_post'd by the library, so the brand-orange styling
924 + // is printed through the notice's pre-markup hook instead of inline.
925 + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
926 +
927 + // Track clicks on the CTAs and the dismiss ✕ via the shared notice-response
928 + // endpoint, enqueued only when the notice actually renders.
929 + add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_thankyou_notice_tracking' ] );
930 + }
931 +
932 + /**
933 + * Enqueue the click-tracking for the Thank You notice (#3030).
934 + *
935 + * Sends an analytics beacon to the shared `srfm_notice_response` AJAX handler
936 + * when a CTA or the dismiss ✕ is clicked. Uses `keepalive` so the beacon
937 + * survives the navigation the CTA links trigger.
938 + *
939 + * @since 2.12.4
940 + * @return void
941 + */
942 + public function enqueue_thankyou_notice_tracking() {
943 + if ( wp_script_is( 'srfm-thankyou-notice-track', 'enqueued' ) ) {
944 + return;
945 + }
946 +
947 + wp_register_script( 'srfm-thankyou-notice-track', '', [], SRFM_VER, true );
948 + wp_enqueue_script( 'srfm-thankyou-notice-track' );
949 +
950 + $config = wp_json_encode(
951 + [
952 + 'ajaxurl' => admin_url( 'admin-ajax.php' ),
953 + 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
954 + ]
955 + );
956 +
957 + wp_add_inline_script( 'srfm-thankyou-notice-track', 'window.srfmThankYouNoticeTrack = ' . $config . ';', 'before' );
958 +
959 + $inline_script = <<<'JS'
960 +( function () {
961 + const cfg = window.srfmThankYouNoticeTrack || {};
962 + const wrap = document.querySelector( '.srfm-thankyou-notice' );
963 + if ( ! wrap ) {
964 + return;
965 + }
966 + const noticeId = wrap.id || '';
967 + const send = function ( button ) {
968 + const body = new URLSearchParams();
969 + body.append( 'action', 'srfm_notice_response' );
970 + body.append( 'nonce', cfg.nonce );
971 + body.append( 'notice_id', noticeId );
972 + body.append( 'button', button );
973 + fetch( cfg.ajaxurl, {
974 + method: 'POST',
975 + credentials: 'same-origin',
976 + keepalive: true,
977 + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
978 + body: body.toString(),
979 + } ).catch( function () {} );
980 + };
981 + // Delegate from the wrapper: this inline script runs at parse time, before
982 + // core's common.js injects the .notice-dismiss ✕ (on DOMContentLoaded), so a
983 + // direct querySelector for it would find nothing and the dismiss beacon would
984 + // never fire. Delegation catches the ✕ and the CTAs whenever they exist.
985 + const ctas = [
986 + [ '.srfm-ty-edit-form', 'edit_form' ],
987 + [ '.srfm-ty-set-replies', 'set_replies' ],
988 + [ '.srfm-ty-edit-thankyou', 'edit_thankyou' ],
989 + ];
990 + wrap.addEventListener( 'click', function ( e ) {
991 + if ( e.target.closest( '.notice-dismiss' ) ) {
992 + send( 'dismissed' );
993 + return;
994 + }
995 + for ( let i = 0; i < ctas.length; i++ ) {
996 + if ( e.target.closest( ctas[ i ][ 0 ] ) ) {
997 + send( ctas[ i ][ 1 ] );
998 + return;
999 + }
1000 + }
1001 + } );
1002 +}() );
1003 +JS;
1004 +
1005 + wp_add_inline_script( 'srfm-thankyou-notice-track', $inline_script );
1006 + }
1007 +
1008 + /**
1009 + * Print the Thank You notice's brand-orange styling (#3030).
1010 + *
1011 + * Fired via astra_notice_before_markup_{id} so it lands right before the notice
1012 + * and only when the notice actually renders.
1013 + *
1014 + * @since 2.12.4
1015 + * @return void
1016 + */
1017 + public function print_srfm_notice_styles() {
1018 + // The library wp_kses_post()'s the message, which strips <svg> and data:
1019 + // image srcs, so the SureForms mark is painted as a CSS background here
1020 + // (this hook fires outside that kses call). URL-encoded, not base64, so the
1021 + // value is fully percent-encoded and safe to pass through esc_url.
1022 + $icon = 'data:image/svg+xml,' . rawurlencode(
1023 + '<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>'
1024 + );
1025 + ?>
1026 + <style id="srfm-notice-styles">
1027 + .srfm-notice.notice { border-left-color: #D54407; }
1028 + /* Stack our blocks (the library lays the container out as a flex row) and reserve room on the left for the SureForms mark. */
1029 + .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; }
1030 + .srfm-notice .srfm-notice__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: #1d2327; }
1031 + .srfm-notice .srfm-notice__text { margin: 0 0 10px; color: #50575e; }
1032 + .srfm-notice .srfm-notice__actions { margin: 12px 0 2px; display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; }
1033 + .srfm-notice .button-primary { background: #D54407; border-color: #D54407; color: #fff; box-shadow: none; text-shadow: none; }
1034 + .srfm-notice .button-primary:hover, .srfm-notice .button-primary:focus { background: #C83B00; border-color: #C83B00; color: #fff; box-shadow: none; }
1035 + .srfm-notice .button:not(.button-primary) { background: transparent; border-color: transparent; color: #D54407; box-shadow: none; padding: 0; }
1036 + .srfm-notice .button:not(.button-primary):hover, .srfm-notice .button:not(.button-primary):focus { background: transparent; border-color: transparent; color: #C83B00; box-shadow: none; }
1037 + .srfm-notice .button-primary:focus { outline: 2px solid #D54407; outline-offset: 1px; }
1038 + </style>
1039 + <?php
1040 + }
1041 +
1042 + /**
1043 + * Check and save the first form creation time stamp.
1044 + * If not already saved.
1045 + *
1046 + * @since 1.10.1
1047 + * @return void
1048 + */
1049 + public static function save_first_form_creation_time_stamp() {
1050 + if ( ! Helper::current_user_can() || self::is_first_form_created() || ! defined( 'SRFM_FORMS_POST_TYPE' ) || ! post_type_exists( SRFM_FORMS_POST_TYPE ) ) {
1051 + return;
1052 + }
1053 +
1054 + // Get the first form creation time from the database that is published.
1055 + $query = new \WP_Query(
1056 + [
1057 + 'post_type' => SRFM_FORMS_POST_TYPE,
1058 + 'posts_per_page' => 1,
1059 + 'orderby' => 'date',
1060 + 'order' => 'ASC',
1061 + 'fields' => 'ids',
1062 + 'post_status' => 'publish',
1063 + ]
1064 + );
1065 +
1066 + if ( ! empty( $query->posts ) && isset( $query->posts[0] ) ) {
1067 + // Get the first post from the query result.
1068 + $post_id = $query->posts[0];
1069 + // Get the post creation time in GMT.
1070 + $creation_time = get_post_field( 'post_date_gmt', $post_id );
1071 + // Convert the creation time to a timestamp.
1072 + $timestamp = strtotime( $creation_time );
1073 +
1074 + if ( ! $timestamp ) {
1075 + return;
1076 + }
1077 +
1078 + Helper::update_srfm_option( 'first_form_created_at', $timestamp );
1079 + }
1080 + }
1081 +
1082 + /**
1083 + * Check if n days have passed since the first form creation.
1084 + * This is used to determine if the dynamic nudges should be shown.
1085 + *
1086 + * @param int $days Number of days to check.
1087 + * @since 1.10.1
1088 + * @return bool
1089 + */
1090 + public static function check_first_form_creation_threshold( $days = 3 ) {
1091 + $first_form_creation_time_stamp = self::get_first_form_creation_time_stamp();
1092 +
1093 + if ( ! $first_form_creation_time_stamp ) {
1094 + return false; // No forms created yet.
1095 + }
1096 +
1097 + /**
1098 + * Calculate the number of days since the first form was created.
1099 + */
1100 + $days_from_creation = ( strtotime( current_time( 'mysql' ) ) - $first_form_creation_time_stamp ) / DAY_IN_SECONDS;
1101 +
1102 + // Return a boolean indicating if the number of days since creation is greater than the specified days.
1103 + return $days_from_creation > $days;
1104 + }
1105 +
1106 + /**
1107 + * Show action on plugin page.
1108 + *
1109 + * @param array $links links.
1110 + * @return array
1111 + * @since 1.4.2
1112 + */
1113 + public function add_action_links( $links ) {
1114 + if ( ! Helper::has_pro() ) {
1115 + // Display upsell link if SureForms Pro is not installed.
1116 + $upsell_link = Helper::get_sureforms_website_url( 'pricing', [ 'utm_medium' => 'plugin-list' ] );
1117 +
1118 + ob_start();
1119 + ?>
1120 + <a href="<?php echo esc_url( $upsell_link ); ?>" target="_blank" rel="noreferrer" class="sureforms-plugins-go-pro">
1121 + <?php echo esc_html__( 'Get SureForms Pro', 'sureforms' ); ?>
1122 + </a>
1123 + <?php
1124 + $links[] = trim( ob_get_clean() );
1125 + }
1126 +
1127 + return $links;
1128 + }
1129 +
1130 + /**
1131 + * Enable block editor in Enfold theme for SureForms post type.
1132 + *
1133 + * @param bool $use_block_editor Whether to use block editor.
1134 + * @since 1.3.1
1135 + */
1136 + public function enable_block_editor_in_enfold_theme( $use_block_editor ) {
1137 + // if SureForms form post type then return true.
1138 + if ( SRFM_FORMS_POST_TYPE === get_current_screen()->post_type ) {
1139 + return true;
1140 + }
1141 + return $use_block_editor;
1142 + }
1143 +
1144 + /**
1145 + * Enable Gutenberg for SureForms associated post types.
1146 + *
1147 + * @since 0.0.10
1148 + */
1149 + public function enable_gutenberg_for_sureforms() {
1150 + /**
1151 + * Check if the classic editor is enabled from Classic Editor plugin settings or Divi settings.
1152 + */
1153 + if ( 'block' === get_option( 'classic-editor-replace' ) || 'on' === get_option( 'et_enable_classic_editor' ) ) {
1154 + return;
1155 + }
1156 +
1157 + $srfm_post_types = apply_filters( 'srfm_enable_gutenberg_post_types', [ SRFM_FORMS_POST_TYPE ] );
1158 +
1159 + if ( in_array( get_current_screen()->post_type, $srfm_post_types, true ) ) {
1160 + add_filter( 'use_block_editor_for_post_type', '__return_true', 110 );
1161 + add_filter( 'gutenberg_can_edit_post_type', '__return_true', 110 );
1162 + }
1163 + }
1164 +
1165 + /**
45 1166 * Sureforms editor header styles.
46 1167 *
47 1168 * @since 0.0.1
48 1169 */
@@ -69,22 +1190,18 @@
69 1190 * @return void
70 1191 * @since 0.0.1
71 1192 */
72 1193 public function add_menu_page() {
73 - if ( ! current_user_can( 'manage_options' ) ) {
74 - return;
75 - }
1194 + $menu_slug = 'sureforms_menu';
76 1195
77 - $capability = 'manage_options';
78 - $menu_slug = 'sureforms_menu';
79 -
80 1196 $logo = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/icon.svg' );
81 1197 add_menu_page(
82 1198 __( 'SureForms', 'sureforms' ),
83 1199 __( 'SureForms', 'sureforms' ),
84 - 'edit_others_posts',
1200 + self::$sureforms_page_default_capability,
85 1201 $menu_slug,
86 - function () {},
1202 + static function () {
1203 + },
87 1204 'data:image/svg+xml;base64,' . base64_encode( $logo ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
88 1205 30
89 1206 );
90 1207
@@ -92,9 +1209,9 @@
92 1209 add_submenu_page(
93 1210 $menu_slug,
94 1211 __( 'Dashboard', 'sureforms' ),
95 1212 __( 'Dashboard', 'sureforms' ),
96 - $capability,
1213 + self::$sureforms_page_default_capability,
97 1214 $menu_slug,
98 1215 [ $this, 'render_dashboard' ]
99 1216 );
100 1217 }
@@ -110,9 +1227,9 @@
110 1227 add_submenu_page(
111 1228 'sureforms_menu',
112 1229 __( 'Settings', 'sureforms' ),
113 1230 __( 'Settings', 'sureforms' ),
114 - 'edit_others_posts',
1231 + self::$sureforms_page_default_capability,
115 1232 'sureforms_form_settings',
116 1233 $callback
117 1234 );
118 1235
@@ -125,8 +1242,190 @@
125 1242 }
126 1243 }
127 1244
128 1245 /**
1246 + * Open to Upgrade to Pro submenu link in new tab.
1247 + *
1248 + * @return void
1249 + * @since 1.6.1
1250 + */
1251 + public function add_upgrade_to_pro_target_attr() {
1252 + ?>
1253 + <script type="text/javascript">
1254 + document.addEventListener('DOMContentLoaded', function () {
1255 + // Upgrade link handler.
1256 + // IMPORTANT: If this URL changes, also update it in the `add_upgrade_to_pro` function.
1257 + const upgradeLink = document.querySelector('a[href*="https://sureforms.com/upgrade"]');
1258 + if (upgradeLink) {
1259 + upgradeLink.addEventListener('click', e => {
1260 + e.preventDefault();
1261 + window.open(upgradeLink.href, '_blank');
1262 + });
1263 + }
1264 + });
1265 + </script>
1266 + <?php
1267 + }
1268 +
1269 + /**
1270 + * Add Upgrade to pro menu item.
1271 + *
1272 + * @return void
1273 + * @since 1.6.1
1274 + */
1275 + public function add_upgrade_to_pro() {
1276 + // The url used here is used as a selector for css to style the upgrade to pro submenu.
1277 + // If you are changing this url, please make sure to update the css as well.
1278 + $upgrade_url = Helper::get_sureforms_website_url( 'upgrade', [ 'utm_medium' => 'submenu_link_upgrade' ] );
1279 +
1280 + add_submenu_page(
1281 + 'sureforms_menu',
1282 + __( 'Upgrade', 'sureforms' ),
1283 + __( 'Upgrade', 'sureforms' ),
1284 + self::$sureforms_page_default_capability,
1285 + $upgrade_url
1286 + );
1287 + }
1288 +
1289 + /**
1290 + * Add Quiz empty state submenu page for free users.
1291 + *
1292 + * @return void
1293 + * @since 2.7.0
1294 + */
1295 + public function add_quiz_page() {
1296 + add_submenu_page(
1297 + 'sureforms_menu',
1298 + __( 'Quiz Entries', 'sureforms' ),
1299 + __( 'Quizzes', 'sureforms' ) .
1300 + ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1301 + esc_html__( 'New', 'sureforms' ) .
1302 + '</span>',
1303 + self::$sureforms_page_default_capability,
1304 + 'sureforms_quiz_entries',
1305 + [ $this, 'render_quiz_empty_state' ],
1306 + 5
1307 + );
1308 + }
1309 +
1310 + /**
1311 + * Quiz empty state page callback.
1312 + *
1313 + * @return void
1314 + * @since 2.7.0
1315 + */
1316 + public function render_quiz_empty_state() {
1317 + ?>
1318 + <div id="srfm-quiz-entries-root" class="srfm-admin-wrapper"></div>
1319 + <?php
1320 + }
1321 +
1322 + /**
1323 + * Add Survey Reports promotional submenu page for free users.
1324 + *
1325 + * @return void
1326 + * @since 2.8.0
1327 + */
1328 + public function add_survey_reports_page() {
1329 + add_submenu_page(
1330 + 'sureforms_menu',
1331 + __( 'Survey Reports', 'sureforms' ),
1332 + __( 'Survey Reports', 'sureforms' ) .
1333 + ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1334 + esc_html__( 'New', 'sureforms' ) .
1335 + '</span>',
1336 + self::$sureforms_page_default_capability,
1337 + 'sureforms_survey_reports',
1338 + [ $this, 'render_survey_empty_state' ],
1339 + 6
1340 + );
1341 + }
1342 +
1343 + /**
1344 + * Survey empty state page callback.
1345 + *
1346 + * @return void
1347 + * @since 2.8.0
1348 + */
1349 + public function render_survey_empty_state() {
1350 + ?>
1351 + <div id="srfm-survey-empty-state-root" class="srfm-admin-wrapper"></div>
1352 + <?php
1353 + }
1354 +
1355 + /**
1356 + * Add Partial Entries promotional submenu page for free users.
1357 + *
1358 + * @return void
1359 + * @since 2.9.0
1360 + */
1361 + public function add_partial_entries_page() {
1362 + add_submenu_page(
1363 + 'sureforms_menu',
1364 + __( 'Partial Entries', 'sureforms' ),
1365 + __( 'Partial Entries', 'sureforms' ) .
1366 + ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1367 + esc_html__( 'New', 'sureforms' ) .
1368 + '</span>',
1369 + self::$sureforms_page_default_capability,
1370 + 'sureforms_partial_entries',
1371 + [ $this, 'render_partial_entries_empty_state' ],
1372 + 7
1373 + );
1374 + }
1375 +
1376 + /**
1377 + * Partial Entries empty state page callback.
1378 + *
1379 + * @return void
1380 + * @since 2.9.0
1381 + */
1382 + public function render_partial_entries_empty_state() {
1383 + ?>
1384 + <div id="srfm-partial-entries-empty-state-root" class="srfm-admin-wrapper"></div>
1385 + <?php
1386 + }
1387 +
1388 + /**
1389 + * Add SMTP promotional submenu page.
1390 + *
1391 + * @return void
1392 + * @since 1.7.1
1393 + */
1394 + public function add_suremail_page() {
1395 + add_submenu_page(
1396 + 'sureforms_menu',
1397 + __( 'SMTP', 'sureforms' ),
1398 + __( 'SMTP', 'sureforms' ),
1399 + self::$sureforms_page_default_capability,
1400 + 'sureforms_smtp',
1401 + [ $this, 'suremail_page_callback' ]
1402 + );
1403 +
1404 + // Get the current submenu page.
1405 + $submenu_page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- $_GET['page'] does not provide nonce.
1406 +
1407 + // Check if SureMail is installed and active.
1408 + if ( 'sureforms_smtp' === $submenu_page && file_exists( WP_PLUGIN_DIR . '/suremails/suremails.php' ) && is_plugin_active( 'suremails/suremails.php' ) ) {
1409 + // Plugin is installed and active - redirect to SureMail dashboard.
1410 + wp_safe_redirect( admin_url( 'options-general.php?page=suremail#/dashboard' ) );
1411 + exit;
1412 + }
1413 + }
1414 +
1415 + /**
1416 + * SMTP promotional page callback.
1417 + *
1418 + * @return void
1419 + * @since 1.7.1
1420 + */
1421 + public function suremail_page_callback() {
1422 + ?>
1423 + <div id="srfm-suremail-container" class="srfm-admin-wrapper"></div>
1424 + <?php
1425 + }
1426 +
1427 + /**
129 1428 * Render Admin Dashboard.
130 1429 *
131 1430 * @return void
132 1431 * @since 0.0.1
@@ -131,9 +1430,11 @@
131 1430 * @return void
132 1431 * @since 0.0.1
133 1432 */
134 1433 public function render_dashboard() {
135 - echo '<div id="srfm-dashboard-container"></div>';
1434 + ?>
1435 + <div id="srfm-dashboard-container" class="srfm-admin-wrapper"></div>
1436 + <?php
136 1437 }
137 1438
138 1439 /**
139 1440 * Settings page callback.
@@ -141,12 +1442,43 @@
141 1442 * @return void
142 1443 * @since 0.0.1
143 1444 */
144 1445 public function settings_page_callback() {
145 - echo '<div id="srfm-settings-container"></div>';
1446 + ?>
1447 + <div id="srfm-settings-container" class="srfm-admin-wrapper"></div>
1448 + <?php
146 1449 }
147 1450
148 1451 /**
1452 + * Add Learn submenu page.
1453 + *
1454 + * @return void
1455 + * @since 2.5.2
1456 + */
1457 + public function add_learn_page() {
1458 + add_submenu_page(
1459 + 'sureforms_menu',
1460 + __( 'Learn', 'sureforms' ),
1461 + __( 'Learn', 'sureforms' ),
1462 + self::$sureforms_page_default_capability,
1463 + 'sureforms_learn',
1464 + [ $this, 'render_learn' ]
1465 + );
1466 + }
1467 +
1468 + /**
1469 + * Learn page callback.
1470 + *
1471 + * @return void
1472 + * @since 2.5.2
1473 + */
1474 + public function render_learn() {
1475 + ?>
1476 + <div id="srfm-learn-root" class="srfm-admin-wrapper"></div>
1477 + <?php
1478 + }
1479 +
1480 + /**
149 1481 * Add new form menu item.
150 1482 *
151 1483 * @return void
152 1484 * @since 0.0.1
@@ -153,18 +1485,62 @@
153 1485 */
154 1486 public function add_new_form() {
155 1487 add_submenu_page(
156 1488 'sureforms_menu',
1489 + __( 'Forms', 'sureforms' ),
1490 + __( 'Forms', 'sureforms' ),
1491 + self::$sureforms_page_default_capability,
1492 + 'sureforms_forms',
1493 + [ $this, 'render_forms' ],
1494 + 1
1495 + );
1496 + add_submenu_page(
1497 + 'sureforms_menu',
157 1498 __( 'New Form', 'sureforms' ),
158 1499 __( 'New Form', 'sureforms' ),
159 - 'edit_others_posts',
1500 + self::$sureforms_page_default_capability,
160 1501 'add-new-form',
161 1502 [ $this, 'add_new_form_callback' ],
162 1503 2
163 1504 );
1505 + $entries_hook = add_submenu_page(
1506 + 'sureforms_menu',
1507 + __( 'Entries', 'sureforms' ),
1508 + __( 'Entries', 'sureforms' ),
1509 + self::$sureforms_page_default_capability,
1510 + SRFM_ENTRIES,
1511 + [ $this, 'render_entries' ],
1512 + 3
1513 + );
1514 +
1515 + add_submenu_page(
1516 + 'sureforms_menu',
1517 + __( 'Payments', 'sureforms' ),
1518 + __( 'Payments', 'sureforms' ),
1519 + self::$sureforms_page_default_capability,
1520 + SRFM_PAYMENTS,
1521 + [ $this, 'render_payments' ],
1522 + 4
1523 + );
1524 +
1525 + if ( $entries_hook ) {
1526 + add_action( 'load-' . $entries_hook, [ $this, 'mark_entries_page_visit' ] );
1527 + }
164 1528 }
165 1529
166 1530 /**
1531 + * Payments page callback.
1532 + *
1533 + * @return void
1534 + * @since 2.0.0
1535 + */
1536 + public function render_payments() {
1537 + ?>
1538 + <div id="srfm-payments-react-container" class="srfm-admin-wrapper"></div>
1539 + <?php
1540 + }
1541 +
1542 + /**
167 1543 * Add new form mentu item callback.
168 1544 *
169 1545 * @return void
170 1546 * @since 0.0.1
@@ -169,12 +1545,109 @@
169 1545 * @return void
170 1546 * @since 0.0.1
171 1547 */
172 1548 public function add_new_form_callback() {
173 - echo '<div id="srfm-add-new-form-container"></div>';
1549 + ?>
1550 + <div id="srfm-add-new-form-container" class="srfm-admin-wrapper"></div>
1551 + <?php
174 1552 }
175 1553
176 1554 /**
1555 + * Forms page callback.
1556 + *
1557 + * @return void
1558 + * @since 2.0.0
1559 + */
1560 + public function render_forms() {
1561 + ?>
1562 + <div id="srfm-forms-root" class="srfm-admin-wrapper"></div>
1563 + <?php
1564 + }
1565 +
1566 + /**
1567 + * Entries page callback.
1568 + *
1569 + * @since 0.0.13
1570 + * @since 2.0.0 - Updated the entries UI and the function definition.
1571 + * @return void
1572 + */
1573 + public function render_entries() {
1574 + echo '<div id="srfm-entries-root"></div>';
1575 + }
1576 +
1577 + /**
1578 + * Add notification badge to SureForms menu when there are new entries.
1579 + *
1580 + * @since 1.7.3
1581 + * @return void
1582 + */
1583 + public function maybe_add_entries_badge() {
1584 + if ( ! Helper::current_user_can() ) {
1585 + return;
1586 + }
1587 +
1588 + // If currently viewing the entries listing page, mark it as visited and skip the badge.
1589 + if ( isset( $_GET['page'] ) && SRFM_ENTRIES === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checking the page slug.
1590 + $this->mark_entries_page_visit();
1591 + return;
1592 + }
1593 +
1594 + $srfm_options = get_option( 'srfm_options', [] );
1595 + $last_visit = isset( $srfm_options['entries_last_visited'] ) ? absint( $srfm_options['entries_last_visited'] ) : 0;
1596 + $new_entries = Entries::get_entries_count_after( $last_visit );
1597 +
1598 + if ( $new_entries <= 0 ) {
1599 + return;
1600 + }
1601 +
1602 + global $menu;
1603 + foreach ( $menu as $index => $item ) {
1604 + if ( isset( $item[2] ) && 'sureforms_menu' === $item[2] ) {
1605 + ob_start();
1606 + ?>
1607 + <span class="srfm-update-dot"></span>
1608 + <?php
1609 + $dot_html = ob_get_clean();
1610 + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Adding notifications for menu item.
1611 + $menu[ $index ][0] .= $dot_html;
1612 + break;
1613 + }
1614 + }
1615 +
1616 + global $submenu;
1617 + if ( isset( $submenu['sureforms_menu'] ) ) {
1618 + foreach ( $submenu['sureforms_menu'] as $index => $sub_item ) {
1619 + if ( isset( $sub_item[2] ) && SRFM_ENTRIES === $sub_item[2] ) {
1620 + ob_start();
1621 + ?>
1622 + <span class="update-plugins count-<?php echo absint( $new_entries ); ?>">
1623 + <span class="plugin-count"><?php echo absint( $new_entries ); ?></span>
1624 + </span>
1625 + <?php
1626 + $badge_html = ob_get_clean();
1627 + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Adding notifications for submenu item.
1628 + $submenu['sureforms_menu'][ $index ][0] .= $badge_html;
1629 + break;
1630 + }
1631 + }
1632 + }
1633 + }
1634 +
1635 + /**
1636 + * Mark the user's visit to the entries page.
1637 + *
1638 + * @since 1.7.3
1639 + * @return void
1640 + */
1641 + public function mark_entries_page_visit() {
1642 + if ( Helper::current_user_can() ) {
1643 + $srfm_options = get_option( 'srfm_options', [] );
1644 + $srfm_options['entries_last_visited'] = time();
1645 + \SRFM\Inc\Helper::update_admin_settings_option( 'srfm_options', $srfm_options );
1646 + }
1647 + }
1648 +
1649 + /**
177 1650 * Adds a settings link to the plugin action links on the plugins page.
178 1651 *
179 1652 * @param array $links An array of plugin action links.
180 1653 * @param string $file The plugin file path.
@@ -182,10 +1655,22 @@
182 1655 * @since 0.0.1
183 1656 */
184 1657 public function add_settings_link( $links, $file ) {
185 1658 if ( 'sureforms/sureforms.php' === $file ) {
186 - $settings_link = '<a href="' . esc_url( admin_url( 'admin.php?page=sureforms_form_settings&tab=general-settings' ) ) . '">' . esc_html__( 'Settings', 'sureforms' ) . '</a>';
187 - array_push( $links, $settings_link );
1659 + ob_start();
1660 + ?>
1661 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=sureforms_form_settings&tab=general-settings' ) ); ?>">
1662 + <?php echo esc_html__( 'Settings', 'sureforms' ); ?>
1663 + </a>
1664 + <?php
1665 + $settings_link_html = ob_get_clean();
1666 + $plugin_links = apply_filters(
1667 + 'sureforms_plugin_action_links',
1668 + [
1669 + 'sureforms_settings' => $settings_link_html,
1670 + ]
1671 + );
1672 + $links = array_merge( $plugin_links, $links );
188 1673 }
189 1674 return $links;
190 1675 }
191 1676
@@ -195,8 +1680,9 @@
195 1680 * @since 0.0.1
196 1681 */
197 1682 public function enqueue_styles() {
198 1683 $current_screen = get_current_screen();
1684 + global $wp_version;
199 1685
200 1686 $file_prefix = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? '' : '.min';
201 1687 $dir_name = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? 'unminified' : 'minified';
202 1688
@@ -214,8 +1700,18 @@
214 1700 wp_enqueue_style( SRFM_SLUG . '-backend-blocks', $css_uri . 'blocks/default/backend' . $file_prefix . '.css', [], SRFM_VER );
215 1701 wp_enqueue_style( SRFM_SLUG . '-intl', $vendor_css_uri . 'intl/intlTelInput-backend.min.css', [], SRFM_VER );
216 1702 wp_enqueue_style( SRFM_SLUG . '-common', $css_uri . 'common' . $file_prefix . '.css', [], SRFM_VER );
217 1703 wp_enqueue_style( SRFM_SLUG . '-reactQuill', $vendor_css_uri . 'quill/quill.snow.css', [], SRFM_VER );
1704 + wp_add_inline_style( SRFM_SLUG . '-reactQuill', self::QUILL_1X_INLINE_CSS );
1705 + wp_enqueue_style( SRFM_SLUG . '-single-form-modal', $css_uri . 'single-form-setting' . $file_prefix . '.css', [], SRFM_VER );
1706 +
1707 + // if version is equal to or lower than 6.6.2 then add compatibility css.
1708 + if ( version_compare( $wp_version, '6.6.2', '<=' ) ) {
1709 + $srfm_inline_css = '.srfm-settings-modal .srfm-setting-modal-container .components-toggle-control .components-base-control__help{
1710 + margin-left: 4em;
1711 + }';
1712 + wp_add_inline_style( SRFM_SLUG . '-single-form-modal', $srfm_inline_css );
1713 + }
218 1714 }
219 1715
220 1716 wp_enqueue_style( SRFM_SLUG . '-form-selector', $css_uri . 'srfm-form-selector' . $file_prefix . '.css', [], SRFM_VER );
221 1717 wp_enqueue_style( SRFM_SLUG . '-common-editor', SRFM_URL . 'assets/build/common-editor.css', [], SRFM_VER, 'all' );
@@ -230,9 +1726,9 @@
230 1726 public function get_breadcrumbs_for_current_page() {
231 1727 global $post, $pagenow;
232 1728 $breadcrumbs = [];
233 1729
234 - if ( 'admin.php' === $pagenow && isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1730 + if ( 'admin.php' === $pagenow && isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We don't need nonce verification here.
235 1731 $page_title = get_admin_page_title();
236 1732 $breadcrumbs[] = [
237 1733 'title' => $page_title,
238 1734 'link' => '',
@@ -245,9 +1741,9 @@
245 1741 'title' => $post_type_plural,
246 1742 'link' => admin_url( 'edit.php?post_type=' . $post_type_obj->name ),
247 1743 ];
248 1744
249 - if ( 'edit.php' === $pagenow && ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1745 + if ( 'edit.php' === $pagenow && ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We don't need nonce verification here.
250 1746 $breadcrumbs[ count( $breadcrumbs ) - 1 ]['link'] = '';
251 1747 } else {
252 1748 $breadcrumbs[] = [
253 1749 /* Translators: Post Title. */
@@ -262,13 +1758,8 @@
262 1758 $breadcrumbs[] = [
263 1759 'title' => 'Forms',
264 1760 'link' => '',
265 1761 ];
266 - } elseif ( $current_screen && 'sureforms_entry' === $current_screen->post_type ) {
267 - $breadcrumbs[] = [
268 - 'title' => 'Entries',
269 - 'link' => '',
270 - ];
271 1762 } else {
272 1763 $breadcrumbs[] = [
273 1764 'title' => '',
274 1765 'link' => '',
@@ -278,9 +1769,8 @@
278 1769
279 1770 return $breadcrumbs;
280 1771 }
281 1772
282 -
283 1773 /**
284 1774 * Enqueue Admin Scripts.
285 1775 *
286 1776 * @return void
@@ -287,22 +1777,159 @@
287 1777 * @since 0.0.1
288 1778 */
289 1779 public function enqueue_scripts() {
290 1780 $current_screen = get_current_screen();
1781 + global $wp_version;
291 1782
292 - $file_prefix = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? '' : '.min';
293 - $dir_name = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? 'unminified' : 'minified';
294 - $js_uri = SRFM_URL . 'assets/js/' . $dir_name . '/';
295 - $css_uri = SRFM_URL . 'assets/css/' . $dir_name . '/';
1783 + $file_prefix = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? '' : '.min';
1784 + $dir_name = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? 'unminified' : 'minified';
1785 + $css_uri = SRFM_URL . 'assets/css/' . $dir_name . '/';
1786 + $is_rtl = is_rtl();
1787 + $rtl = $is_rtl ? '-rtl' : '';
296 1788
297 - /* RTL */
298 - if ( is_rtl() ) {
299 - $file_prefix .= '-rtl';
1789 + /**
1790 + * List of the handles in which we need to add translation compatibility.
1791 + */
1792 + $script_translations_handlers = [];
1793 + $onboarding_instance = Onboarding::get_instance();
1794 + $current_user = wp_get_current_user();
1795 +
1796 + $localization_data = [
1797 + 'site_url' => get_site_url(),
1798 + 'current_user_login' => $current_user->user_login ?? '',
1799 + 'website_lead_details' => [
1800 + 'first_name' => $current_user->first_name ?? '',
1801 + 'last_name' => $current_user->last_name ?? '',
1802 + 'email' => $current_user->user_email ?? '',
1803 + ],
1804 + 'breadcrumbs' => $this->get_breadcrumbs_for_current_page(),
1805 + 'sureforms_dashboard_url' => admin_url( '/admin.php?page=sureforms_menu' ),
1806 + 'plugin_version' => SRFM_VER,
1807 + 'global_settings_nonce' => Helper::current_user_can() ? wp_create_nonce( 'wp_rest' ) : '',
1808 + 'is_pro_active' => Helper::has_pro(),
1809 + 'is_first_form_created' => self::is_first_form_created(),
1810 + 'check_three_days_threshold' => self::check_first_form_creation_threshold(),
1811 + 'check_eight_days_threshold' => self::check_first_form_creation_threshold( 8 ),
1812 + 'pro_plugin_version' => Helper::has_pro() ? SRFM_PRO_VER : '',
1813 + 'pro_plugin_name' => Helper::has_pro() && defined( 'SRFM_PRO_PRODUCT' ) ? SRFM_PRO_PRODUCT : 'SureForms Pro',
1814 + 'sureforms_pricing_page' => Helper::get_sureforms_website_url( 'pricing' ),
1815 + 'field_spacing_vars' => Helper::get_css_vars(),
1816 + 'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ),
1817 + 'integrations' => Helper::sureforms_get_integration(),
1818 + 'rotating_plugin_banner' => Helper::get_rotating_plugin_banner(),
1819 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
1820 + 'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '',
1821 + 'action_items' => $this->get_action_items(),
1822 + 'details_dialog' => $this->get_details_dialog_labels(),
1823 + // Where Contact Support goes when the details fetch fails and there is
1824 + // no category-tagged URL to use. Untagged, because at that point we do
1825 + // not know which check sent them -- but still a way out: these notices
1826 + // are not dismissible and Contact Support is the only action that
1827 + // retires them.
1828 + 'support_url' => $this->get_support_contact_url( '' ),
1829 + 'action_item_details_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_action_item_details' ) : '',
1830 + 'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '',
1831 + 'dismiss_action_item_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_dismiss_action_item' ) : '',
1832 + 'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ),
1833 + 'plugin_installer_nonce' => wp_create_nonce( 'updates' ),
1834 + 'plugin_activating_text' => __( 'Activating...', 'sureforms' ),
1835 + 'plugin_activated_text' => __( 'Activated', 'sureforms' ),
1836 + 'plugin_activate_text' => __( 'Activate', 'sureforms' ),
1837 + 'plugin_installing_text' => __( 'Installing...', 'sureforms' ),
1838 + 'plugin_installed_text' => __( 'Installed', 'sureforms' ),
1839 + 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ),
1840 + 'is_rtl' => $is_rtl,
1841 + 'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false,
1842 + 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false,
1843 + 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ),
1844 + 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection.
1845 + 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ),
1846 + 'general_settings_url' => admin_url( '/options-general.php' ),
1847 + 'additional_header_nav_items' => [],
1848 + // Smart tags for the Global Defaults email notification fields.
1849 + // srfm_block_data is only available in the block editor; these keys
1850 + // make the same data accessible on the settings page via srfm_admin.
1851 + 'smart_tags_array' => Smart_Tags::smart_tag_list(),
1852 + 'smart_tags_array_email' => Smart_Tags::email_smart_tag_list(),
1853 + // Default confirmation message HTML (icon + heading + text) used as
1854 + // the initial React state before the settings API response arrives.
1855 + 'default_confirmation_message' => Global_Settings::get_default_confirmation_message(),
1856 + 'payments' => apply_filters(
1857 + 'srfm_admin_localize_payments_data',
1858 + [
1859 + 'stripe_connected' => Stripe_Helper::is_stripe_connected(),
1860 + 'stripe_mode' => Stripe_Helper::get_stripe_mode(),
1861 + 'stripe_connect_url' => Stripe_Helper::get_stripe_settings_url(),
1862 + 'currencies_data' => Payment_Helper::get_all_currencies_data(),
1863 + 'zero_decimal_currencies' => Payment_Helper::get_zero_decimal_currencies(),
1864 + 'webhook_url' => Stripe_Helper::get_webhook_url(),
1865 + 'webhook_test_connected' => Stripe_Helper::is_webhook_configured( 'test', true ),
1866 + 'webhook_live_connected' => Stripe_Helper::is_webhook_configured( 'live', true ),
1867 + 'is_transaction_present' => Stripe_Helper::is_transaction_present(),
1868 + 'payment_currency' => Payment_Helper::get_currency(),
1869 + 'currency_sign_position' => Payment_Helper::get_currency_sign_position(),
1870 + ]
1871 + ),
1872 + 'mcp_adapter_status' => file_exists( WP_PLUGIN_DIR . '/mcp-adapter/mcp-adapter.php' )
1873 + ? ( is_plugin_active( 'mcp-adapter/mcp-adapter.php' ) ? 'active' : 'installed' )
1874 + : 'not_installed',
1875 + 'mcp_endpoint_url' => esc_url_raw( rest_url( 'sureforms/v1/mcp' ) ),
1876 + ];
1877 +
1878 + $is_screen_sureforms_menu = Helper::validate_request_context( 'sureforms_menu', 'page' );
1879 + $is_screen_add_new_form = Helper::validate_request_context( 'add-new-form', 'page' );
1880 + $is_screen_sureforms_forms = Helper::validate_request_context( 'sureforms_forms', 'page' );
1881 + $is_screen_sureforms_form_settings = Helper::validate_request_context( 'sureforms_form_settings', 'page' );
1882 + $is_screen_sureforms_payments = Helper::validate_request_context( 'sureforms_payments', 'page' );
1883 + $is_screen_sureforms_entries = Helper::validate_request_context( SRFM_ENTRIES, 'page' );
1884 + $is_screen_sureforms_learn = Helper::validate_request_context( 'sureforms_learn', 'page' );
1885 + $is_screen_quiz_empty_state = Helper::validate_request_context( 'sureforms_quiz_entries', 'page' );
1886 + $is_screen_survey_empty_state = Helper::validate_request_context( 'sureforms_survey_reports', 'page' );
1887 + $is_screen_partial_entries_empty_state = Helper::validate_request_context( 'sureforms_partial_entries', 'page' );
1888 + $is_post_type_sureforms_form = SRFM_FORMS_POST_TYPE === $current_screen->post_type;
1889 +
1890 + /**
1891 + * Check if the current screen is the SureForms Menu and AI Auth Email is present then we will add user type as registered.
1892 + * Compatibility with existing UI code that checks for this condition.
1893 + */
1894 + if ( $is_screen_sureforms_menu ) {
1895 + // If email is stored send the user type as registered else non-registered.
1896 + $localization_data['srfm_ai_details'] = [
1897 + 'type' => ! empty( get_option( 'srfm_ai_auth_user_email' ) ) ? 'registered' : 'non-registered',
1898 + ];
300 1899 }
301 1900
302 - if ( SRFM_FORMS_POST_TYPE === $current_screen->post_type || 'toplevel_page_sureforms_menu' === $current_screen->base || SRFM_ENTRIES_POST_TYPE === $current_screen->post_type
303 - || 'sureforms_page_sureforms_form_settings' === $current_screen->id
304 - ) {
1901 + // Add the Quizzes and Survey Reports nav items when pro is not active.
1902 + if ( ! Helper::has_pro() ) {
1903 + $localization_data['additional_header_nav_items'][] = [
1904 + 'slug' => 'sureforms_quiz_entries',
1905 + 'text' => __( 'Quizzes', 'sureforms' ),
1906 + 'link' => admin_url( 'admin.php?page=sureforms_quiz_entries' ),
1907 + ];
1908 + $localization_data['additional_header_nav_items'][] = [
1909 + 'slug' => 'sureforms_survey_reports',
1910 + 'text' => __( 'Survey Reports', 'sureforms' ),
1911 + 'link' => admin_url( 'admin.php?page=sureforms_survey_reports' ),
1912 + ];
1913 + $localization_data['additional_header_nav_items'][] = [
1914 + 'slug' => 'sureforms_partial_entries',
1915 + 'text' => __( 'Partial Entries', 'sureforms' ),
1916 + 'link' => admin_url( 'admin.php?page=sureforms_partial_entries' ),
1917 + ];
1918 + }
1919 +
1920 + $is_sureforms_screen = $is_screen_sureforms_menu || $is_post_type_sureforms_form || $is_screen_add_new_form || $is_screen_sureforms_forms || $is_screen_sureforms_form_settings || $is_screen_sureforms_entries || $is_screen_sureforms_payments || $is_screen_sureforms_learn || $is_screen_quiz_empty_state || $is_screen_survey_empty_state || $is_screen_partial_entries_empty_state;
1921 +
1922 + /**
1923 + * Filter to allow extending the SureForms dashboard screen check.
1924 + *
1925 + * @since 2.6.0
1926 + *
1927 + * @param bool $is_sureforms_screen Whether the current screen is a SureForms dashboard screen.
1928 + */
1929 + $is_sureforms_screen = apply_filters( 'srfm_is_dashboard_screen', $is_sureforms_screen );
1930 +
1931 + if ( $is_sureforms_screen ) {
305 1932 $asset_handle = '-dashboard';
306 1933
307 1934 wp_enqueue_style( SRFM_SLUG . $asset_handle . '-font', 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap', [], SRFM_VER );
308 1935
@@ -315,49 +1942,196 @@
315 1942 ];
316 1943 wp_enqueue_script( SRFM_SLUG . $asset_handle, SRFM_URL . 'assets/build/dashboard.js', $script_info['dependencies'], SRFM_VER, true );
317 1944
318 1945 wp_localize_script( SRFM_SLUG . $asset_handle, 'scIcons', [ 'path' => SRFM_URL . 'assets/build/icon-assets' ] );
1946 +
1947 + $script_translations_handlers[] = SRFM_SLUG . $asset_handle;
1948 +
1949 + if ( class_exists( 'SRFM_PRO\Admin\Licensing' ) ) {
1950 + $license_active = \SRFM_PRO\Admin\Licensing::is_license_active();
1951 + $localization_data['is_license_active'] = $license_active;
1952 +
1953 + // Updating current licensing status.
1954 + $srfm_pro_license_status = get_option( 'srfm_pro_license_status', '' );
1955 + $current_license_status = $license_active ? 'licensed' : 'unlicensed';
1956 + if ( $current_license_status !== $srfm_pro_license_status ) {
1957 + update_option( 'srfm_pro_license_status', $current_license_status );
1958 + }
1959 + }
1960 +
1961 + $localization_data['security_settings_url'] = admin_url( '/admin.php?page=sureforms_form_settings&tab=security-settings&subpage=recaptcha' );
1962 + $localization_data['integration_settings_url'] = admin_url( '/admin.php?page=sureforms_form_settings&tab=integration-settings' );
319 1963 wp_localize_script(
320 1964 SRFM_SLUG . $asset_handle,
321 1965 SRFM_SLUG . '_admin',
322 1966 apply_filters(
323 1967 SRFM_SLUG . '_admin_filter',
324 - [
325 - 'site_url' => get_site_url(),
326 - 'breadcrumbs' => $this->get_breadcrumbs_for_current_page(),
327 - 'sureforms_dashboard_url' => admin_url( '/admin.php?page=sureforms_menu' ),
328 - 'plugin_version' => SRFM_VER,
329 - 'global_settings_nonce' => ( current_user_can( 'manage_options' ) ) ? wp_create_nonce( 'wp_rest' ) : '',
330 - 'security_settings_url' => admin_url( '/admin.php?page=sureforms_form_settings&tab=security-settings' ),
331 - ]
1968 + $localization_data
332 1969 )
333 1970 );
334 1971 wp_enqueue_style( SRFM_SLUG . '-dashboard', SRFM_URL . 'assets/build/dashboard.css', [], SRFM_VER, 'all' );
1972 + }
335 1973
1974 + if ( $is_screen_sureforms_form_settings || $is_screen_sureforms_forms ) {
1975 + wp_enqueue_style( SRFM_SLUG . '-settings', $css_uri . 'backend/settings' . $file_prefix . $rtl . '.css', [], SRFM_VER );
336 1976 }
337 1977
338 - if ( 'sureforms_page_sureforms_form_settings' === $current_screen->id ) {
339 - wp_enqueue_style( SRFM_SLUG . '-settings', $css_uri . 'backend/settings' . $file_prefix . '.css', [], SRFM_VER );
1978 + // Enqueue styles for the entries page.
1979 + if ( $is_screen_sureforms_entries ) {
1980 + $asset_handle = '-entries';
1981 + wp_enqueue_script( SRFM_SLUG . $asset_handle, SRFM_URL . 'assets/build/entries.js', $script_info['dependencies'], SRFM_VER, true );
1982 +
1983 + wp_localize_script(
1984 + SRFM_SLUG . $asset_handle,
1985 + SRFM_SLUG . '_admin',
1986 + apply_filters(
1987 + SRFM_SLUG . '_admin_filter',
1988 + $localization_data
1989 + )
1990 + );
1991 + $script_translations_handlers[] = SRFM_SLUG . $asset_handle;
340 1992 }
341 1993
342 - // Admin Submenu Styles.
343 - wp_enqueue_style( SRFM_SLUG . '-admin', $css_uri . 'backend/admin' . $file_prefix . '.css', [], SRFM_VER );
344 - wp_enqueue_style( SRFM_SLUG . '-single-form-modal', $css_uri . 'single-form-setting' . $file_prefix . '.css', [], SRFM_VER );
1994 + // Enqueue scripts for the learn page.
1995 + if ( $is_screen_sureforms_learn ) {
1996 + $asset_handle = '-learn';
1997 + wp_enqueue_script( SRFM_SLUG . $asset_handle, SRFM_URL . 'assets/build/learn.js', $script_info['dependencies'], SRFM_VER, true );
345 1998
346 - if ( 'edit-' . SRFM_FORMS_POST_TYPE === $current_screen->id || 'edit-' . SRFM_ENTRIES_POST_TYPE === $current_screen->id ) {
347 - $asset_handle = 'page_header';
1999 + wp_localize_script(
2000 + SRFM_SLUG . $asset_handle,
2001 + SRFM_SLUG . '_admin',
2002 + apply_filters(
2003 + SRFM_SLUG . '_admin_filter',
2004 + $localization_data
2005 + )
2006 + );
2007 + $script_translations_handlers[] = SRFM_SLUG . $asset_handle;
2008 + }
348 2009
2010 + // Enqueue scripts for the forms page.
2011 + if ( $is_screen_sureforms_forms ) {
2012 + $asset_handle = '-forms';
2013 +
2014 + $script_asset_path = SRFM_DIR . 'assets/build/forms.asset.php';
2015 + $script_info = file_exists( $script_asset_path )
2016 + ? include $script_asset_path
2017 + : [
2018 + 'dependencies' => [],
2019 + 'version' => SRFM_VER,
2020 + ];
2021 +
2022 + wp_enqueue_script( SRFM_SLUG . $asset_handle, SRFM_URL . 'assets/build/forms.js', $script_info['dependencies'], SRFM_VER, true );
2023 + wp_localize_script(
2024 + SRFM_SLUG . $asset_handle,
2025 + SRFM_SLUG . '_admin',
2026 + apply_filters(
2027 + SRFM_SLUG . '_admin_filter',
2028 + $localization_data
2029 + )
2030 + );
2031 + wp_enqueue_style( SRFM_SLUG . $asset_handle, SRFM_URL . 'assets/build/forms.css', [], SRFM_VER, 'all' );
2032 +
2033 + $script_translations_handlers[] = SRFM_SLUG . $asset_handle;
2034 + }
2035 +
2036 + // Enqueue scripts for the SureMail promotional page.
2037 + $is_screen_sureforms_smtp = Helper::validate_request_context( 'sureforms_smtp', 'page' );
2038 + if ( $is_screen_sureforms_smtp ) {
2039 + $asset_handle = 'suremail';
2040 +
349 2041 $script_asset_path = SRFM_DIR . 'assets/build/' . $asset_handle . '.asset.php';
350 2042 $script_info = file_exists( $script_asset_path )
351 - ? include $script_asset_path
352 - : [
353 - 'dependencies' => [],
354 - 'version' => SRFM_VER,
2043 + ? include $script_asset_path
2044 + : [
2045 + 'dependencies' => [],
2046 + 'version' => SRFM_VER,
2047 + ];
2048 +
2049 + wp_enqueue_script( SRFM_SLUG . '-suremail', SRFM_URL . 'assets/build/' . $asset_handle . '.js', $script_info['dependencies'], SRFM_VER, true );
2050 + wp_enqueue_style( SRFM_SLUG . '-suremail', SRFM_URL . 'assets/build/suremail.css', [], SRFM_VER, 'all' );
2051 +
2052 + // Localize script for SureMail page.
2053 + $suremail_localization_data = [
2054 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
2055 + 'admin_url' => admin_url(),
2056 + 'suremail_url' => 'https://sureforms.com/suremail/',
2057 + 'plugin_installer_nonce' => wp_create_nonce( 'updates' ),
2058 + 'sfPluginManagerNonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ),
2059 + 'suremail_status' => file_exists( WP_PLUGIN_DIR . '/suremails/suremails.php' )
2060 + ? ( is_plugin_active( 'suremails/suremails.php' ) ? 'active' : 'installed' )
2061 + : 'not_installed',
355 2062 ];
356 - wp_enqueue_script( SRFM_SLUG . '-form-page-header', SRFM_URL . 'assets/build/' . $asset_handle . '.js', $script_info['dependencies'], SRFM_VER, true );
357 - wp_enqueue_style( SRFM_SLUG . '-form-archive-styles', $css_uri . 'form-archive-styles' . $file_prefix . '.css', [], SRFM_VER );
2063 +
2064 + wp_localize_script(
2065 + SRFM_SLUG . '-suremail',
2066 + SRFM_SLUG . '_admin',
2067 + apply_filters(
2068 + SRFM_SLUG . '_suremail_admin_filter',
2069 + $suremail_localization_data
2070 + )
2071 + );
2072 +
2073 + $script_translations_handlers[] = SRFM_SLUG . '-suremail';
358 2074 }
359 - if ( 'sureforms_page_' . SRFM_FORMS_POST_TYPE . '_settings' === $current_screen->base ) {
2075 +
2076 + // Enqueue scripts for the Quiz empty state page (free users only).
2077 + if ( $is_screen_quiz_empty_state && ! Helper::has_pro() ) {
2078 + $asset_handle = 'quizEmptyState';
2079 +
2080 + $script_asset_path = SRFM_DIR . 'assets/build/' . $asset_handle . '.asset.php';
2081 + $script_info = file_exists( $script_asset_path )
2082 + ? include $script_asset_path
2083 + : [
2084 + 'dependencies' => [],
2085 + 'version' => SRFM_VER,
2086 + ];
2087 +
2088 + wp_enqueue_script( SRFM_SLUG . '-quiz-empty-state', SRFM_URL . 'assets/build/' . $asset_handle . '.js', $script_info['dependencies'], SRFM_VER, true );
2089 + wp_enqueue_style( SRFM_SLUG . '-quiz-empty-state', SRFM_URL . 'assets/build/' . $asset_handle . '.css', [], SRFM_VER, 'all' );
2090 +
2091 + $script_translations_handlers[] = SRFM_SLUG . '-quiz-empty-state';
2092 + }
2093 +
2094 + // Enqueue scripts for the Survey Reports empty state page (free users only).
2095 + if ( $is_screen_survey_empty_state && ! Helper::has_pro() ) {
2096 + $asset_handle = 'surveyEmptyState';
2097 +
2098 + $script_asset_path = SRFM_DIR . 'assets/build/' . $asset_handle . '.asset.php';
2099 + $script_info = file_exists( $script_asset_path )
2100 + ? include $script_asset_path
2101 + : [
2102 + 'dependencies' => [],
2103 + 'version' => SRFM_VER,
2104 + ];
2105 +
2106 + wp_enqueue_script( SRFM_SLUG . '-survey-empty-state', SRFM_URL . 'assets/build/' . $asset_handle . '.js', $script_info['dependencies'], SRFM_VER, true );
2107 + wp_enqueue_style( SRFM_SLUG . '-survey-empty-state', SRFM_URL . 'assets/build/' . $asset_handle . '.css', [], SRFM_VER, 'all' );
2108 +
2109 + $script_translations_handlers[] = SRFM_SLUG . '-survey-empty-state';
2110 + }
2111 +
2112 + // Enqueue scripts for the Partial Entries empty state page (free users only).
2113 + if ( $is_screen_partial_entries_empty_state && ! Helper::has_pro() ) {
2114 + $asset_handle = 'partialEntriesEmptyState';
2115 +
2116 + $script_asset_path = SRFM_DIR . 'assets/build/' . $asset_handle . '.asset.php';
2117 + $script_info = file_exists( $script_asset_path )
2118 + ? include $script_asset_path
2119 + : [
2120 + 'dependencies' => [],
2121 + 'version' => SRFM_VER,
2122 + ];
2123 +
2124 + wp_enqueue_script( SRFM_SLUG . '-partial-entries-empty-state', SRFM_URL . 'assets/build/' . $asset_handle . '.js', $script_info['dependencies'], SRFM_VER, true );
2125 + wp_enqueue_style( SRFM_SLUG . '-partial-entries-empty-state', SRFM_URL . 'assets/build/' . $asset_handle . '.css', [], SRFM_VER, 'all' );
2126 +
2127 + $script_translations_handlers[] = SRFM_SLUG . '-partial-entries-empty-state';
2128 + }
2129 +
2130 + // Admin Submenu Styles.
2131 + wp_enqueue_style( SRFM_SLUG . '-admin', $css_uri . 'backend/admin' . $file_prefix . $rtl . '.css', [], SRFM_VER );
2132 +
2133 + if ( $is_screen_sureforms_form_settings ) {
360 2134 $asset_handle = 'settings';
361 2135
362 2136 $script_asset_path = SRFM_DIR . 'assets/build/' . $asset_handle . '.asset.php';
363 2137 $script_info = file_exists( $script_asset_path )
@@ -365,62 +2139,30 @@
365 2139 : [
366 2140 'dependencies' => [],
367 2141 'version' => SRFM_VER,
368 2142 ];
2143 +
369 2144 wp_enqueue_script( SRFM_SLUG . '-settings', SRFM_URL . 'assets/build/' . $asset_handle . '.js', $script_info['dependencies'], SRFM_VER, true );
370 - wp_enqueue_style( SRFM_SLUG . '-setting-styles', SRFM_URL . 'assets/build/' . $asset_handle . '.css', [ 'wp-components' ], SRFM_VER, 'all' );
371 2145 wp_localize_script(
372 2146 SRFM_SLUG . '-settings',
373 2147 SRFM_SLUG . '_admin',
374 - [
375 - 'site_url' => get_site_url(),
376 - 'breadcrumbs' => $this->get_breadcrumbs_for_current_page(),
377 - 'sureforms_dashboard_url' => admin_url( '/admin.php?page=sureforms_menu' ),
378 - 'plugin_version' => SRFM_VER,
379 - 'global_settings_nonce' => current_user_can( 'manage_options' ) ? wp_create_nonce( 'wp_rest' ) : '',
380 - ]
2148 + apply_filters(
2149 + SRFM_SLUG . '_admin_filter',
2150 + $localization_data
2151 + )
381 2152 );
382 - }
383 - if ( 'edit-' . SRFM_FORMS_POST_TYPE === $current_screen->id ) {
384 - wp_enqueue_script( SRFM_SLUG . '-form-archive', $js_uri . 'form-archive' . $file_prefix . '.js', [], SRFM_VER, true );
385 - wp_enqueue_script( SRFM_SLUG . '-export', $js_uri . 'export' . $file_prefix . '.js', [], SRFM_VER, true );
386 - wp_localize_script(
387 - SRFM_SLUG . '-export',
388 - SRFM_SLUG . '_export',
389 - [
390 - 'ajaxurl' => admin_url( 'admin-ajax.php' ),
391 - 'srfm_export_nonce' => wp_create_nonce( 'export_form_nonce' ),
392 - 'site_url' => get_site_url(),
393 - 'srfm_import_endpoint' => '/wp-json/sureforms/v1/sureforms_import',
394 - 'import_form_nonce' => ( current_user_can( 'edit_posts' ) ) ? wp_create_nonce( 'wp_rest' ) : '',
395 - ]
396 - );
397 2153
398 - wp_enqueue_script( SRFM_SLUG . '-backend', $js_uri . 'backend' . $file_prefix . '.js', [], SRFM_VER, true );
399 - wp_localize_script(
400 - SRFM_SLUG . '-backend',
401 - SRFM_SLUG . '_backend',
402 - [
403 - 'site_url' => get_site_url(),
404 - ]
405 - );
2154 + // Enqueue Tailwind and Quill editor styles for the settings page.
2155 + wp_enqueue_style( SRFM_SLUG . '-settings-build', SRFM_URL . 'assets/build/settings.css', [], SRFM_VER, 'all' );
2156 + wp_enqueue_style( SRFM_SLUG . '-reactQuill', SRFM_URL . 'assets/css/minified/deps/quill/quill.snow.css', [], SRFM_VER );
2157 + wp_add_inline_style( SRFM_SLUG . '-reactQuill', self::QUILL_1X_INLINE_CSS );
406 2158
2159 + $script_translations_handlers[] = SRFM_SLUG . '-settings';
407 2160 }
408 2161
409 - if ( 'sureforms_page_add-new-form' === $current_screen->id ) {
2162 + if ( $is_screen_add_new_form ) {
2163 + wp_enqueue_style( SRFM_SLUG . '-template-picker', $css_uri . 'template-picker' . $file_prefix . $rtl . '.css', [], SRFM_VER );
410 2164
411 - $file_prefix = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? '' : '.min';
412 - $dir_name = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? 'unminified' : 'minified';
413 -
414 - $css_uri = SRFM_URL . 'assets/css/' . $dir_name . '/';
415 -
416 - /* RTL */
417 - if ( is_rtl() ) {
418 - $file_prefix .= '-rtl';
419 - }
420 -
421 - wp_enqueue_style( SRFM_SLUG . '-template-picker', $css_uri . 'template-picker' . $file_prefix . '.css', [], SRFM_VER );
422 -
423 2165 $sureforms_admin = 'templatePicker';
424 2166
425 2167 $script_asset_path = SRFM_DIR . 'assets/build/' . $sureforms_admin . '.asset.php';
426 2168 $script_info = file_exists( $script_asset_path )
@@ -436,16 +2178,22 @@
436 2178 SRFM_SLUG . '_admin',
437 2179 [
438 2180 'site_url' => get_site_url(),
439 2181 'plugin_url' => SRFM_URL,
440 - 'preview_images_url' => SRFM_URL . 'images/template-previews/',
441 2182 'admin_url' => admin_url( 'admin.php' ),
442 2183 'new_template_picker_base_url' => admin_url( 'post-new.php?post_type=sureforms_form' ),
443 - 'capability' => current_user_can( 'edit_posts' ),
444 - 'template_picker_nonce' => current_user_can( 'edit_posts' ) ? wp_create_nonce( 'wp_rest' ) : '',
445 - 'is_pro_active' => defined( 'SRFM_PRO_VER' ),
2184 + 'capability' => Helper::current_user_can(),
2185 + 'template_picker_nonce' => Helper::current_user_can() ? wp_create_nonce( 'wp_rest' ) : '',
2186 + 'is_pro_active' => Helper::has_pro(),
2187 + 'srfm_ai_usage_details' => AI_Helper::get_current_usage_details(),
2188 + 'is_pro_license_active' => AI_Helper::is_pro_license_active(),
2189 + 'srfm_ai_auth_user_email' => get_option( 'srfm_ai_auth_user_email' ),
2190 + 'pricing_page_url' => Helper::get_sureforms_website_url( 'pricing' ),
2191 + 'licensing_nonce' => wp_create_nonce( 'srfm_pro_licensing_nonce' ),
446 2192 ]
447 2193 );
2194 +
2195 + $script_translations_handlers[] = SRFM_SLUG . '-template-picker';
448 2196 }
449 2197 // Quick action sidebar.
450 2198 $default_allowed_quick_sidebar_blocks = apply_filters(
451 2199 'srfm_quick_sidebar_allowed_blocks',
@@ -452,10 +2200,13 @@
452 2200 [
453 2201 'srfm/input',
454 2202 'srfm/email',
455 2203 'srfm/textarea',
2204 + 'srfm/checkbox',
456 2205 'srfm/number',
457 - 'srfm/address',
2206 + 'srfm/inline-button',
2207 + 'srfm/advanced-heading',
2208 + 'srfm/payment',
458 2209 ]
459 2210 );
460 2211 if ( ! is_array( $default_allowed_quick_sidebar_blocks ) ) {
461 2212 $default_allowed_quick_sidebar_blocks = [];
@@ -467,33 +2218,80 @@
467 2218 }
468 2219 $quick_sidebar_allowed_blocks = get_option( 'srfm_quick_sidebar_allowed_blocks' );
469 2220 $quick_sidebar_allowed_blocks = ! empty( $quick_sidebar_allowed_blocks ) && is_array( $quick_sidebar_allowed_blocks ) ? $quick_sidebar_allowed_blocks : $default_allowed_quick_sidebar_blocks;
470 2221 $srfm_ajax_nonce = wp_create_nonce( 'srfm_ajax_nonce' );
471 - wp_enqueue_script( SRFM_SLUG . '-quick-action-siderbar', SRFM_URL . 'assets/build/quickActionSidebar.js', [], SRFM_VER, true );
472 - wp_localize_script(
473 - SRFM_SLUG . '-quick-action-siderbar',
474 - SRFM_SLUG . '_quick_sidebar_blocks',
475 - [
476 - 'allowed_blocks' => $quick_sidebar_allowed_blocks,
477 - 'srfm_enable_quick_action_sidebar' => $srfm_enable_quick_action_sidebar,
478 - 'srfm_ajax_nonce' => $srfm_ajax_nonce,
479 - 'srfm_ajax_url' => admin_url( 'admin-ajax.php' ),
480 - ]
481 - );
2222 +
2223 + if ( Helper::is_sureforms_admin_page() ) {
2224 + wp_enqueue_script( SRFM_SLUG . '-quick-action-siderbar', SRFM_URL . 'assets/build/quickActionSidebar.js', [], SRFM_VER, true );
2225 + wp_localize_script(
2226 + SRFM_SLUG . '-quick-action-siderbar',
2227 + SRFM_SLUG . '_quick_sidebar_blocks',
2228 + [
2229 + 'allowed_blocks' => $quick_sidebar_allowed_blocks,
2230 + 'srfm_enable_quick_action_sidebar' => $srfm_enable_quick_action_sidebar,
2231 + 'srfm_ajax_nonce' => $srfm_ajax_nonce,
2232 + 'srfm_ajax_url' => admin_url( 'admin-ajax.php' ),
2233 + ]
2234 + );
2235 +
2236 + $script_translations_handlers[] = SRFM_SLUG . '-quick-action-siderbar';
2237 + }
2238 +
2239 + /**
2240 + * Enqueuing SureTriggers Integration script.
2241 + * This script loads suretriggers iframe in Intergations tab.
2242 + */
2243 + if ( $is_post_type_sureforms_form ) {
2244 + wp_enqueue_script( SRFM_SLUG . '-suretriggers-integration', SRFM_SURETRIGGERS_INTEGRATION_BASE_URL . 'js/v2/embed.js', [], SRFM_VER, true );
2245 + }
2246 +
2247 + // Check $script_translations_handlers is not empty before calling the function.
2248 + if ( ! empty( $script_translations_handlers ) ) {
2249 + // Remove duplicates values from the array.
2250 + $script_translations_handlers = array_unique( $script_translations_handlers );
2251 +
2252 + foreach ( $script_translations_handlers as $script_handle ) {
2253 + Helper::register_script_translations( $script_handle );
2254 + }
2255 + }
482 2256 }
483 2257
484 2258 /**
485 2259 * Form Template Picker Admin Body Classes
2260 + * WordPress sometimes translates class names in the admin body tag, which can result in
2261 + * incorrect or missing class names when rendering the admin pages. This function ensures
2262 + * that essential class names are manually added to the body tag to maintain proper functionality.
486 2263 *
487 2264 * @since 0.0.1
488 2265 * @param string $classes Space separated class string.
489 2266 */
490 2267 public function admin_template_picker_body_class( $classes = '' ) {
491 - $theme_builder_class = isset( $_GET['page'] ) && 'add-new-form' === $_GET['page'] ? 'srfm-template-picker' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Fetching a $_GET value, no nonce available to validate.
492 - $classes .= ' ' . $theme_builder_class . ' ';
2268 + // Define an associative array of class names and their corresponding conditions.
2269 + // Each condition checks whether a specific request context matches.
2270 + $srfm_classes = [
2271 + 'sureforms_page_sureforms_entries' => Helper::validate_request_context( SRFM_ENTRIES, 'page' ),
2272 + 'sureforms_page_sureforms_form_settings' => Helper::validate_request_context( 'sureforms_form_settings', 'page' ),
2273 + 'srfm-template-picker' => Helper::validate_request_context( 'add-new-form', 'page' ),
2274 + ];
493 2275
2276 + $add_srfm_classes = '';
2277 +
2278 + // Loop through the defined classes and conditions.
2279 + foreach ( $srfm_classes as $class => $condition ) {
2280 + // Check if the condition evaluates to true.
2281 + if ( $condition ) {
2282 + // Append the class to the existing classes string, followed by a space.
2283 + $add_srfm_classes .= empty( $add_srfm_classes ) ? $class : ' ' . $class;
2284 + }
2285 + }
2286 +
2287 + // Append the new classes to the existing classes string.
2288 + if ( ! empty( $add_srfm_classes ) ) {
2289 + $classes .= ' ' . $add_srfm_classes;
2290 + }
2291 +
2292 + // Return the updated list of classes.
494 2293 return $classes;
495 -
496 2294 }
497 2295
498 2296 /**
499 2297 * Disable spectra's quick action bar in sureforms CPT.
@@ -508,6 +2306,2983 @@
508 2306 $status = 'disabled';
509 2307 }
510 2308
511 2309 return $status;
2310 + }
2311 +
2312 + /**
2313 + * Register Pro compatibility notices early for React pages.
2314 + *
2315 + * This method runs on admin_init (priority 5) to ensure notices are
2316 + * registered BEFORE admin_enqueue_scripts, so they're available when
2317 + * wp_localize_script runs.
2318 + *
2319 + * Hooked - admin_init (priority 5)
2320 + *
2321 + * @return void
2322 + * @since 2.5.0
2323 + */
2324 + public function register_pro_compatibility_notices() {
2325 + // Early exit if Pro is not active, user lacks permissions, or Notice_Manager is unavailable.
2326 + if ( ! Helper::has_pro() || ! Helper::current_user_can() || ! class_exists( 'SRFM\Admin\Notice_Manager' ) ) {
2327 + return;
2328 + }
2329 +
2330 + // Register version outdated notice for React pages.
2331 + if ( ! version_compare( SRFM_PRO_VER, SRFM_PRO_RECOMMENDED_VER, '>=' ) ) {
2332 + $pro_plugin_name = defined( 'SRFM_PRO_PRODUCT' ) ? SRFM_PRO_PRODUCT : 'SureForms Pro';
2333 + $react_outdated_message = sprintf(
2334 + // translators: %1$s: SureForms version, %2$s: SureForms Pro Plugin Name, %3$s: SureForms Pro Version.
2335 + esc_html__( 'SureForms %1$s requires minimum %2$s %3$s to work properly. Please update to the latest version.', 'sureforms' ),
2336 + esc_html( SRFM_VER ),
2337 + esc_html( $pro_plugin_name ),
2338 + esc_html( SRFM_PRO_RECOMMENDED_VER )
2339 + );
2340 +
2341 + \SRFM\Admin\Notice_Manager::register_notice(
2342 + [
2343 + 'id' => 'sureforms-pro-version-outdated',
2344 + 'variant' => 'warning',
2345 + 'message' => $react_outdated_message,
2346 + 'actions' => [
2347 + [
2348 + 'label' => esc_html__( 'Update Now', 'sureforms' ),
2349 + 'url' => admin_url( 'update-core.php' ),
2350 + 'variant' => 'primary',
2351 + ],
2352 + ],
2353 + 'pages' => [ 'all' ],
2354 + ]
2355 + );
2356 + }
2357 + }
2358 +
2359 + /**
2360 + * Register the React notice when the entries table is missing.
2361 + *
2362 + * Hooked - admin_init, priority 5.
2363 + *
2364 + * Priority 5 is load-bearing: Notice_Manager hands notices to the front end
2365 + * through the `srfm_admin_filter` applied during admin_enqueue_scripts, so
2366 + * anything registering later never reaches the page.
2367 + *
2368 + * @since 2.12.6
2369 + * @return void
2370 + */
2371 + public function register_database_repair_notice() {
2372 + // admin_init also fires on admin-ajax.php. Nothing there renders a notice, so
2373 + // skip the work rather than reading a transient on every AJAX request.
2374 + if ( wp_doing_ajax() ) {
2375 + return;
2376 + }
2377 +
2378 + if ( ! Helper::current_user_can() ) {
2379 + return;
2380 + }
2381 +
2382 + if ( ! class_exists( 'SRFM\Admin\Notice_Manager' ) ) {
2383 + return;
2384 + }
2385 +
2386 + // A just-completed repair reports its outcome instead of the warning.
2387 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag; the repair itself is nonce-checked in handle_database_repair().
2388 + $result = isset( $_GET['srfm_db_repair'] ) ? sanitize_key( wp_unslash( $_GET['srfm_db_repair'] ) ) : '';
2389 +
2390 + if ( 'done' === $result ) {
2391 + Notice_Manager::register_notice(
2392 + [
2393 + 'id' => 'srfm-database-repaired',
2394 + 'variant' => 'success',
2395 + 'message' => __( 'Your SureForms database is up to date. New form entries will be saved as usual.', 'sureforms' ),
2396 + 'pages' => [ 'all' ],
2397 + ]
2398 + );
2399 + return;
2400 + }
2401 +
2402 + if ( 'failed' === $result ) {
2403 + Notice_Manager::register_notice(
2404 + [
2405 + 'id' => 'srfm-database-repair-failed',
2406 + // Still a warning, not an error: a host that does not allow
2407 + // SureForms to create tables is not the user's mistake.
2408 + 'variant' => 'warning',
2409 + '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' ),
2410 + 'actions' => [
2411 + [
2412 + 'label' => __( 'Contact support', 'sureforms' ),
2413 + 'url' => 'https://sureforms.com/contact/',
2414 + ],
2415 + ],
2416 + 'pages' => [ 'all' ],
2417 + ]
2418 + );
2419 + return;
2420 + }
2421 +
2422 + if ( ! Register::is_entries_table_missing() ) {
2423 + return;
2424 + }
2425 +
2426 + $this->track_database_notice_impression();
2427 +
2428 + Notice_Manager::register_notice(
2429 + [
2430 + 'id' => 'srfm-database-maintenance',
2431 + 'variant' => 'warning',
2432 + 'title' => __( 'Database update needed', 'sureforms' ),
2433 + // Plain text only. AdminNotice.js renders this as a React child, so
2434 + // any markup here would show up as literal characters.
2435 + 'message' => $this->get_database_notice_message(),
2436 + 'actions' => [
2437 + [
2438 + 'label' => __( 'Fix now', 'sureforms' ),
2439 + // Opaque identifier, resolved to a handler in AdminNotice.js.
2440 + // Deliberately not a URL or endpoint: the server never tells
2441 + // the browser which address to call.
2442 + 'action' => 'repair-entries-table',
2443 + 'url' => $this->get_database_repair_url(),
2444 + ],
2445 + ],
2446 + 'pages' => [ 'all' ],
2447 + ]
2448 + );
2449 + }
2450 +
2451 + /**
2452 + * Render the classic warning on the WordPress dashboard.
2453 + *
2454 + * Hooked - admin_notices.
2455 + *
2456 + * Scoped to index.php on purpose. The React notice already covers the SureForms
2457 + * screens, so leaving this one admin-wide would stack two warnings on the same
2458 + * page and nag on every screen in wp-admin.
2459 + *
2460 + * Registered as [ $this, 'method' ] rather than a closure because
2461 + * suppress_foreign_admin_notices() strips any callback it cannot attribute to a
2462 + * SureForms class — a closure here would be silently removed.
2463 + *
2464 + * @since 2.12.6
2465 + * @return void
2466 + */
2467 + public function render_database_repair_notice() {
2468 + if ( ! Helper::current_user_can() ) {
2469 + return;
2470 + }
2471 +
2472 + $screen = get_current_screen();
2473 +
2474 + if ( ! $screen || 'dashboard' !== $screen->base ) {
2475 + return;
2476 + }
2477 +
2478 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag; the repair itself is nonce-checked in handle_database_repair().
2479 + $result = isset( $_GET['srfm_db_repair'] ) ? sanitize_key( wp_unslash( $_GET['srfm_db_repair'] ) ) : '';
2480 +
2481 + if ( 'done' === $result ) {
2482 + ?>
2483 + <div class="notice notice-success is-dismissible">
2484 + <p><?php esc_html_e( 'Your SureForms database is up to date. New form entries will be saved as usual.', 'sureforms' ); ?></p>
2485 + </div>
2486 + <?php
2487 + return;
2488 + }
2489 +
2490 + if ( 'failed' === $result ) {
2491 + ?>
2492 + <div class="notice notice-warning is-dismissible">
2493 + <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>
2494 + </div>
2495 + <?php
2496 + return;
2497 + }
2498 +
2499 + if ( ! Register::is_entries_table_missing() ) {
2500 + return;
2501 + }
2502 +
2503 + $this->track_database_notice_impression();
2504 + ?>
2505 + <div class="notice notice-warning">
2506 + <p>
2507 + <strong><?php esc_html_e( 'SureForms — database update needed', 'sureforms' ); ?></strong>
2508 + </p>
2509 + <p><?php echo esc_html( $this->get_database_notice_message() ); ?></p>
2510 + <p>
2511 + <a href="<?php echo esc_url( $this->get_database_repair_url() ); ?>" class="button button-primary">
2512 + <?php esc_html_e( 'Update database', 'sureforms' ); ?>
2513 + </a>
2514 + </p>
2515 + </div>
2516 + <?php
2517 + }
2518 +
2519 + /**
2520 + * Repair the entries table, then redirect back with the outcome.
2521 + *
2522 + * Hooked - admin_post_srfm_repair_entries_table.
2523 + *
2524 + * A nonce-protected GET that changes state matches how core's own plugin
2525 + * activate / deactivate / delete links work.
2526 + *
2527 + * @since 2.12.6
2528 + * @return void
2529 + */
2530 + public function handle_database_repair() {
2531 + if ( ! Helper::current_user_can() ) {
2532 + wp_die( esc_html__( 'You do not have permission to update the database.', 'sureforms' ), 403 );
2533 + }
2534 +
2535 + check_admin_referer( 'srfm_repair_entries_table' );
2536 +
2537 + $repaired = $this->do_database_repair();
2538 + $referer = wp_get_referer();
2539 +
2540 + wp_safe_redirect(
2541 + add_query_arg(
2542 + 'srfm_db_repair',
2543 + $repaired ? 'done' : 'failed',
2544 + $referer ? $referer : admin_url()
2545 + )
2546 + );
2547 + exit;
2548 + }
2549 +
2550 + /**
2551 + * Repair the entries table and record what happened.
2552 + *
2553 + * The single place the repair is performed and counted, shared by the
2554 + * admin-post handler and the REST endpoint. One user action reaches exactly one
2555 + * of those, so the click counter cannot double-count across the two surfaces.
2556 + *
2557 + * @since 2.12.6
2558 + * @return bool True when the table exists afterwards.
2559 + */
2560 + public function do_database_repair() {
2561 + // Cumulative counter, so $force = true: each new count is a new value and is
2562 + // re-sent, while an identical repeat short-circuits inside track().
2563 + $attempts = Helper::get_integer_value( Helper::get_srfm_option( 'db_repair_attempts', 0 ) ) + 1;
2564 + Helper::update_srfm_option( 'db_repair_attempts', $attempts );
2565 +
2566 + // Event name is the `database_error` => `fix_now` entry in the $valid
2567 + // allowlist in handle_notice_response(). Kept in sync by hand; that array is
2568 + // where the team looks notice event names up.
2569 + Analytics::events()->track( 'database_error_notice_cta', (string) $attempts, [], true );
2570 +
2571 + $repaired = Register::repair_entries_table();
2572 +
2573 + // The failure case is the more valuable signal: it means the host refuses to
2574 + // let SureForms create tables, which no amount of retrying will fix.
2575 + Analytics::events()->track(
2576 + 'database_repair_result',
2577 + $repaired ? 'success' : 'failed',
2578 + [],
2579 + true
2580 + );
2581 +
2582 + return $repaired;
2583 + }
2584 +
2585 + /**
2586 + * Admin Notice Callback if sureforms pro is out of date.
2587 + *
2588 + * Hooked - admin_notices
2589 + *
2590 + * @return void
2591 + * @since 1.0.4
2592 + */
2593 + public function srfm_pro_version_compatibility() {
2594 + if ( ! Helper::has_pro() ) {
2595 + return;
2596 + }
2597 +
2598 + if ( empty( get_current_screen() ) ) {
2599 + return;
2600 + }
2601 +
2602 + if ( ! Helper::current_user_can() ) {
2603 + return;
2604 + }
2605 +
2606 + $srfm_pro_license_status = get_option( 'srfm_pro_license_status', '' );
2607 + /**
2608 + * If the license status is not set then get the license status and update the option accordingly.
2609 + * This will be executed only once. Subsequently, the option status is updated by the licensing class on license activation or deactivation.
2610 + */
2611 + if ( empty( $srfm_pro_license_status ) && class_exists( 'SRFM_PRO\Admin\Licensing' ) ) {
2612 + $srfm_pro_license_status = \SRFM_PRO\Admin\Licensing::is_license_active() ? 'licensed' : 'unlicensed';
2613 + update_option( 'srfm_pro_license_status', $srfm_pro_license_status );
2614 + }
2615 +
2616 + $pro_plugin_name = defined( 'SRFM_PRO_PRODUCT' ) ? SRFM_PRO_PRODUCT : 'SureForms Pro';
2617 + $message = '';
2618 + $url = admin_url( 'admin.php?page=sureforms_form_settings&tab=account-settings' );
2619 + if ( 'unlicensed' === $srfm_pro_license_status ) {
2620 + ob_start();
2621 + ?>
2622 + <p>
2623 + <?php
2624 + printf(
2625 + // translators: %1$s: Opening anchor tag with URL, %2$s: Closing anchor tag, %3$s: SureForms Pro Plugin Name.
2626 + esc_html__( 'Please %1$sactivate%2$s your copy of %3$s to get new features, access support, receive update notifications, and more.', 'sureforms' ),
2627 + '<a href="' . esc_url( $url ) . '">',
2628 + '</a>',
2629 + '<i>' . esc_html( $pro_plugin_name ) . '</i>'
2630 + );
2631 + ?>
2632 + </p>
2633 + <?php
2634 + $message = ob_get_clean();
2635 + }
2636 +
2637 + if ( ! version_compare( SRFM_PRO_VER, SRFM_PRO_RECOMMENDED_VER, '>=' ) ) {
2638 + ob_start();
2639 + ?>
2640 + <p>
2641 + <?php
2642 + printf(
2643 + // translators: %1$s: SureForms version, %2$s: SureForms Pro Plugin Name, %3$s: SureForms Pro Version, %4$s: Anchor tag open, %5$s: Closing anchor tag.
2644 + esc_html__( 'SureForms %1$s requires minimum %2$s %3$s to work properly. Please update to the latest version from %4$shere%5$s.', 'sureforms' ),
2645 + esc_html( SRFM_VER ),
2646 + esc_html( $pro_plugin_name ),
2647 + esc_html( SRFM_PRO_RECOMMENDED_VER ),
2648 + '<a href="' . esc_url( admin_url( 'update-core.php' ) ) . '">',
2649 + '</a>'
2650 + );
2651 + ?>
2652 + </p>
2653 + <?php
2654 + $message .= ob_get_clean();
2655 + }
2656 +
2657 + if ( ! empty( $message ) ) {
2658 + // Phpcs ignore comment is required as $message variable is already escaped.
2659 + ?>
2660 + <div class="notice notice-warning"><?php echo wp_kses_post( $message ); ?></div>
2661 + <?php
2662 + }
2663 + }
2664 +
2665 + /**
2666 + * Display a notice to the user about providing a review.
2667 + *
2668 + * @since 2.5.2
2669 + * @return void
2670 + */
2671 + public function display_srfm_rating_notice() {
2672 + // Only show to admins.
2673 + if ( ! Helper::current_user_can() ) {
2674 + return;
2675 + }
2676 +
2677 + // Allow the notice to be disabled.
2678 + if ( ! apply_filters( 'srfm_show_rating_notice', true ) ) {
2679 + return;
2680 + }
2681 +
2682 + $notice_id = 'srfm-plugin-review-notice';
2683 +
2684 + Astra_Notices::add_notice(
2685 + [
2686 + 'id' => $notice_id,
2687 + 'type' => '',
2688 + 'message' => self::build_srfm_notice_markup(
2689 + __( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ),
2690 + __( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ),
2691 + [
2692 + [
2693 + 'text' => __( 'Rate SureForms', 'sureforms' ),
2694 + 'url' => esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/' ),
2695 + 'primary' => true,
2696 + // Leaves wp-admin, so it also dismisses on the way out.
2697 + 'dismiss' => true,
2698 + 'external' => true,
2699 + ],
2700 + [
2701 + 'text' => __( 'Maybe later', 'sureforms' ),
2702 + 'url' => '#',
2703 + 'dismiss' => true,
2704 + 'snooze' => WEEK_IN_SECONDS,
2705 + ],
2706 + [
2707 + 'text' => __( 'I already did', 'sureforms' ),
2708 + 'url' => '#',
2709 + 'dismiss' => true,
2710 + ],
2711 + ]
2712 + ),
2713 + 'class' => 'srfm-notice srfm-rating-notice',
2714 + 'repeat-notice-after' => WEEK_IN_SECONDS,
2715 + // Yields to the Thank You prompt for the same reason the Getting Started
2716 + // notice does: a specific form to finish beats a recurring review ask,
2717 + // and a user with three forms who then imports a template would
2718 + // otherwise see both at once.
2719 + 'show_if' => $this->maybe_display_rating_notice() && null === $this->get_displayable_thankyou_prompt() && ! $this->has_action_item_warnings(),
2720 + 'display-with-other-notices' => true,
2721 + ]
2722 + );
2723 +
2724 + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
2725 + add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_notice_response_script' ] );
2726 + }
2727 +
2728 + /**
2729 + * Display a "Getting Started" admin notice for new users who haven't yet
2730 + * reached the rating-notice milestone (3+ forms or 3+ entries).
2731 + *
2732 + * The Astra Notices library handles the 7-day delay via the
2733 + * `display-notice-after` parameter.
2734 + *
2735 + * @since 2.5.2
2736 + * @return void
2737 + */
2738 + public function display_srfm_getting_started_notice() {
2739 + // Only show to admins.
2740 + if ( ! Helper::current_user_can() ) {
2741 + return;
2742 + }
2743 +
2744 + // Allow the notice to be disabled programmatically.
2745 + if ( ! apply_filters( 'srfm_show_getting_started_notice', true ) ) {
2746 + return;
2747 + }
2748 +
2749 + $notice_id = 'srfm-getting-started-notice';
2750 +
2751 + Astra_Notices::add_notice(
2752 + [
2753 + 'id' => $notice_id,
2754 + 'type' => '',
2755 + 'message' => self::build_srfm_notice_markup(
2756 + __( 'SureForms is ready to power your forms — explore what\'s possible!', 'sureforms' ),
2757 + __( 'Manage your forms, track submissions, and discover features like AI Form Builder, payment integrations, and more from the SureForms dashboard.', 'sureforms' ),
2758 + [
2759 + [
2760 + 'text' => __( 'Go to Dashboard', 'sureforms' ),
2761 + 'url' => esc_url( admin_url( 'admin.php?page=sureforms_menu' ) ),
2762 + 'primary' => true,
2763 + ],
2764 + [
2765 + 'text' => __( 'Maybe later', 'sureforms' ),
2766 + 'url' => '#',
2767 + 'dismiss' => true,
2768 + 'snooze' => WEEK_IN_SECONDS,
2769 + ],
2770 + [
2771 + 'text' => __( 'I already know', 'sureforms' ),
2772 + 'url' => '#',
2773 + 'dismiss' => true,
2774 + ],
2775 + ]
2776 + ),
2777 + 'class' => 'srfm-notice srfm-getting-started-notice',
2778 + 'repeat-notice-after' => WEEK_IN_SECONDS,
2779 + // Yields to both of the other SureForms notices, so only one of ours is
2780 + // ever on screen. The rating notice supersedes it once the user has real
2781 + // usage; the Thank You prompt supersedes it because "finish this specific
2782 + // form" is a concrete next step and this is a generic tour invitation.
2783 + 'show_if' => ! $this->maybe_display_rating_notice() && null === $this->get_displayable_thankyou_prompt() && ! $this->has_action_item_warnings(),
2784 + 'display-notice-after' => WEEK_IN_SECONDS,
2785 + 'display-with-other-notices' => true,
2786 + ]
2787 + );
2788 +
2789 + // Same pre-markup hook the Thank You prompt uses, so both notices are painted
2790 + // by one stylesheet instead of two that drift apart.
2791 + add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
2792 + add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_notice_response_script' ] );
2793 + }
2794 +
2795 + /**
2796 + * Enqueue the notice response analytics script.
2797 + *
2798 + * Called via the astra_notice_after_markup_{id} hook so the script
2799 + * only loads when a SureForms notice is actually rendered.
2800 + *
2801 + * @since 2.5.2
2802 + * @return void
2803 + */
2804 + public function enqueue_notice_response_script() {
2805 + if ( wp_script_is( 'srfm-notice-response', 'enqueued' ) ) {
2806 + return;
2807 + }
2808 +
2809 + wp_enqueue_script(
2810 + 'srfm-notice-response',
2811 + SRFM_URL . 'admin/assets/js/notice-response.js',
2812 + [],
2813 + SRFM_VER,
2814 + true
2815 + );
2816 +
2817 + wp_localize_script(
2818 + 'srfm-notice-response',
2819 + 'srfmNoticeResponse',
2820 + [
2821 + 'ajaxurl' => admin_url( 'admin-ajax.php' ),
2822 + 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
2823 + // The diagnostics are fetched when the dialog opens rather than
2824 + // shipped with every page, so the dialog needs its own nonce.
2825 + 'detailsNonce' => wp_create_nonce( 'srfm_action_item_details' ),
2826 + // Carousel chrome. Built in the browser rather than printed here so
2827 + // that with JavaScript off every notice simply stays visible, which
2828 + // is the behaviour this replaced -- controls that cannot work must
2829 + // not be what hides a warning.
2830 + 'carousel' => [
2831 + 'previous' => __( 'Previous notice', 'sureforms' ),
2832 + 'next' => __( 'Next notice', 'sureforms' ),
2833 + /* translators: 1: current position, 2: total notices. */
2834 + 'counter' => __( '%1$d of %2$d', 'sureforms' ),
2835 + ],
2836 + // Details modal chrome, translated here so the script carries no
2837 + // user-facing English of its own.
2838 + 'details' => $this->get_details_dialog_labels(),
2839 + // Where Contact Support goes when the fetch fails and there is no
2840 + // category-tagged URL to use. Untagged, because at that point we do
2841 + // not know which check sent them -- but still a way out: these
2842 + // notices are not dismissible and Contact Support is the only action
2843 + // that retires them.
2844 + 'supportUrl' => $this->get_support_contact_url( '' ),
2845 + ]
2846 + );
2847 + }
2848 +
2849 + /**
2850 + * Serve one failure category's diagnostics, on demand.
2851 + *
2852 + * Hooked - wp_ajax_srfm_action_item_details.
2853 + *
2854 + * The report is built here rather than shipped with the page. Its content
2855 + * comes from the client error log, and that log is filled through a public
2856 + * REST route gated on a submit token any visitor can obtain from a form page
2857 + * rather than on a capability -- so the text is attacker-authored, and putting
2858 + * it in the localisation JSON and a hidden div on every admin screen exposed
2859 + * it far beyond the one admin who opens the dialog.
2860 + *
2861 + * Capability first, then nonce, then the category, matching the ordering of
2862 + * the sibling handlers in this class.
2863 + *
2864 + * @since 2.12.7
2865 + * @return void
2866 + */
2867 + public function handle_action_item_details() {
2868 + if ( ! Helper::current_user_can() ) {
2869 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
2870 + return;
2871 + }
2872 +
2873 + if ( ! check_ajax_referer( 'srfm_action_item_details', 'nonce', false ) ) {
2874 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
2875 + return;
2876 + }
2877 +
2878 + // sanitize_key() returns '' for anything non-scalar (formatting.php:2194), so
2879 + // a category[]= in the body arrives here as the empty string and falls into
2880 + // the refusal below rather than needing a type branch of its own.
2881 + $category = isset( $_POST['category'] ) ? sanitize_key( wp_unslash( $_POST['category'] ) ) : '';
2882 +
2883 + // The only check the category needs, and the reason there is no separate
2884 + // allowlist above it: get_open_failures() returns nothing but keys in
2885 + // Client_Logger::CATEGORIES, so an absent category, an unrecognised one and
2886 + // a recognised one with nothing wrong all land here. Asking for a category
2887 + // with no fault must not mint a report describing one.
2888 + $open = Client_Logger::get_open_failures();
2889 +
2890 + if ( ! isset( $open[ $category ] ) ) {
2891 + wp_send_json_error( [ 'message' => __( 'Nothing to report.', 'sureforms' ) ], 404 );
2892 + return;
2893 + }
2894 +
2895 + $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' );
2896 +
2897 + wp_send_json_success(
2898 + [
2899 + 'details' => $this->get_support_message( $category, $form_title )
2900 + . "\n\n" . $this->get_support_log_block( 8000 ),
2901 + 'support_url' => $this->get_support_contact_url( $category ),
2902 + ]
2903 + );
2904 + }
2905 +
2906 + /**
2907 + * Handle the notice response AJAX request.
2908 + *
2909 + * Validates the request and records the analytics event
2910 + * for the notice button that was clicked.
2911 + *
2912 + * @since 2.5.2
2913 + * @return void
2914 + */
2915 + public function handle_notice_response() {
2916 + if ( ! check_ajax_referer( 'srfm_notice_response', 'nonce', false ) ) {
2917 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
2918 + return;
2919 + }
2920 +
2921 + if ( ! Helper::current_user_can() ) {
2922 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
2923 + return;
2924 + }
2925 +
2926 + $notice_id = isset( $_POST['notice_id'] ) ? sanitize_text_field( wp_unslash( $_POST['notice_id'] ) ) : '';
2927 + $button = isset( $_POST['button'] ) ? sanitize_text_field( wp_unslash( $_POST['button'] ) ) : '';
2928 +
2929 + $valid = [
2930 + 'srfm-getting-started-notice' => [
2931 + 'go_to_dashboard' => 'getting_started_notice_cta',
2932 + 'maybe_later' => 'getting_started_notice_snooze',
2933 + 'dismissed' => 'getting_started_notice_dismiss',
2934 + ],
2935 + 'srfm-plugin-review-notice' => [
2936 + 'rate_sureforms' => 'rating_notice_cta',
2937 + 'maybe_later' => 'rating_notice_snooze',
2938 + 'dismissed' => 'rating_notice_dismiss',
2939 + ],
2940 + // Database maintenance notice. Keyed `database_error` for the warehouse;
2941 + // the user-facing copy deliberately reads as a routine update, not an
2942 + // error. `dismissed` is registered but unreachable today — a missing
2943 + // entries table is not something we let people dismiss.
2944 + 'database_error' => [
2945 + 'fix_now' => 'database_error_notice_cta',
2946 + 'dismissed' => 'database_error_notice_dismiss',
2947 + ],
2948 + // The "Finish setting up" prompt (#3030): three CTAs, plus the ✕.
2949 + 'form_submission_error' => [
2950 + 'view_details' => 'submission_failure_notice_view',
2951 + 'copy_details' => 'submission_failure_notice_copy',
2952 + 'contact_support' => 'submission_failure_notice_cta',
2953 + 'dismissed' => 'submission_failure_notice_dismiss',
2954 + ],
2955 + 'notification_error' => [
2956 + 'view_details' => 'notification_failure_notice_view',
2957 + 'copy_details' => 'notification_failure_notice_copy',
2958 + 'contact_support' => 'notification_failure_notice_cta',
2959 + 'help_me_fix' => 'notification_failure_notice_guide',
2960 + 'dismissed' => 'notification_failure_notice_dismiss',
2961 + ],
2962 + 'integration_error' => [
2963 + 'view_details' => 'integration_failure_notice_view',
2964 + 'copy_details' => 'integration_failure_notice_copy',
2965 + 'contact_support' => 'integration_failure_notice_cta',
2966 + 'dismissed' => 'integration_failure_notice_dismiss',
2967 + ],
2968 + 'caching_plugin' => [
2969 + 'help_me_fix' => 'caching_plugin_notice_cta',
2970 + 'dismissed' => 'caching_plugin_notice_dismiss',
2971 + ],
2972 + 'srfm-thankyou-prompt' => [
2973 + 'edit_form' => 'thankyou_notice_edit_form',
2974 + 'set_replies' => 'thankyou_notice_set_replies',
2975 + 'edit_thankyou' => 'thankyou_notice_edit_thankyou',
2976 + 'dismissed' => 'thankyou_notice_dismiss',
2977 + ],
2978 + ];
2979 +
2980 + if ( ! isset( $valid[ $notice_id ][ $button ] ) ) {
2981 + wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 );
2982 + // wp_send_json_error() ends the request in production. The explicit return
2983 + // keeps the guard a guard rather than something that only works because of
2984 + // a side effect in a function elsewhere.
2985 + return;
2986 + }
2987 +
2988 + $this->track_notice_event( $valid[ $notice_id ][ $button ] );
2989 +
2990 + // Reporting the failures retires the notice until something new fails.
2991 + // Handled here rather than in the browser so it holds for the classic
2992 + // wp-admin notice too, which is a plain link with no JavaScript.
2993 + $categories = [
2994 + 'form_submission_error' => 'submission',
2995 + 'notification_error' => 'notification',
2996 + 'integration_error' => 'integration',
2997 + ];
2998 +
2999 + if ( 'contact_support' === $button && isset( $categories[ $notice_id ] ) ) {
3000 + Client_Logger::acknowledge_category( $categories[ $notice_id ] );
3001 +
3002 + if ( 'form_submission_error' === $notice_id ) {
3003 + Client_Logger::acknowledge_failures();
3004 + }
3005 + }
3006 +
3007 + wp_send_json_success();
3008 + }
3009 +
3010 + /**
3011 + * Disables the capabilities for WPForms to avoid conflicts when enqueueing
3012 + * scripts and styles for WPForms.
3013 + *
3014 + * This function is intended to prevent any potential conflicts that may arise
3015 + * when WPForms scripts and styles are enqueued. By disabling certain capabilities,
3016 + * it ensures that WPForms does not interfere with other functionalities.
3017 + *
3018 + * @param bool $user_can A boolean indicating whether the user has the capability.
3019 + * @return bool Returns true if the capabilities are successfully disabled, false otherwise.
3020 + * @since 1.4.2
3021 + */
3022 + public function disable_wpforms_capabilities( $user_can ) {
3023 + // Note: Nonce verification is intentionally omitted here as no database operations are performed.
3024 + // The values of the $_REQUEST variables are strictly validated, ensuring security without the need for nonce verification.
3025 +
3026 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended
3027 + $post_id = ! empty( $_REQUEST['post'] ) && ! empty( $_REQUEST['action'] ) ? absint( $_REQUEST['post'] ) : 0;
3028 +
3029 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended
3030 + $post_type = $post_id ? get_post_type( $post_id ) : sanitize_text_field( wp_unslash( $_REQUEST['post_type'] ?? '' ) );
3031 + return SRFM_FORMS_POST_TYPE === $post_type ? false : $user_can;
3032 + }
3033 +
3034 + /**
3035 + * Enqueueus the admin pointer script and styles.
3036 + *
3037 + * @return void
3038 + * @since 1.8.0
3039 + */
3040 + public function enqueue_admin_pointer() {
3041 + if ( ! $this->is_admin_pointer_visible() ) {
3042 + return;
3043 + }
3044 + wp_enqueue_style( 'wp-pointer' );
3045 + wp_enqueue_script( 'wp-pointer' );
3046 + wp_enqueue_script(
3047 + 'sureforms-admin-pointer',
3048 + plugins_url( 'admin/assets/js/sureforms-pointer.js', SRFM_FILE ),
3049 + [ 'wp-pointer', 'jquery' ],
3050 + SRFM_VER,
3051 + true
3052 + );
3053 + wp_localize_script(
3054 + 'sureforms-admin-pointer',
3055 + 'sureformsPointerData',
3056 + [
3057 + 'ajaxurl' => admin_url( 'admin-ajax.php' ),
3058 + 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ),
3059 + ]
3060 + );
3061 + }
3062 +
3063 + /**
3064 + * Ajax handler for pointer popup visibility.
3065 + *
3066 + * @return void
3067 + * @since 1.8.0
3068 + */
3069 + public function pointer_should_show() {
3070 + // Security: Check user capability.
3071 + if ( ! Helper::current_user_can() ) {
3072 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3073 + }
3074 + // Security: Nonce check.
3075 + if ( empty( $_POST['pointer_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['pointer_nonce'] ) ), 'sureforms_pointer_action' ) ) {
3076 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3077 + }
3078 +
3079 + $content_markup = sprintf(
3080 + /* translators: 1: opening span, 2: opening strong (inline), 3: closing strong, 4: closing span, 5: opening strong (block), 6: closing strong */
3081 + __( '%1$sGet started by %2$sbuilding your first form%3$s.%4$s%5$sExperience the power of our intuitive AI Form Builder%6$s', 'sureforms' ),
3082 + '<span>',
3083 + '<strong>',
3084 + '</strong>',
3085 + '</span><br/>',
3086 + '<strong style="font-size:1.1em;">',
3087 + '</strong>'
3088 + );
3089 + wp_send_json(
3090 + [
3091 + 'show' => true,
3092 + 'title' => esc_html( __( 'SureForms is waiting for you!', 'sureforms' ) ),
3093 + 'content' => wp_kses_post( $content_markup ),
3094 + 'button_text' => esc_html( __( 'Build My First Form', 'sureforms' ) ),
3095 + 'dismiss' => esc_html( __( 'Dismiss', 'sureforms' ) ),
3096 + 'button_url' => admin_url( 'admin.php?page=add-new-form' ),
3097 + ]
3098 + );
3099 + }
3100 +
3101 + /**
3102 + * Ajax callback for pointer popup dismissed action.
3103 + *
3104 + * @return void
3105 + * @since 1.8.0
3106 + */
3107 + public function pointer_dismissed() {
3108 + // Security: Check user capability.
3109 + if ( ! Helper::current_user_can() ) {
3110 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3111 + }
3112 + // Security: Nonce check.
3113 + if ( empty( $_POST['pointer_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['pointer_nonce'] ) ), 'sureforms_pointer_action' ) ) {
3114 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3115 + }
3116 + // Use Helper to update srfm_options key.
3117 + Helper::update_srfm_option( 'pointer_popup_dismissed', time() );
3118 +
3119 + wp_send_json_success();
3120 + }
3121 +
3122 + /**
3123 + * Ajax pointer accepted CTA callback.
3124 + *
3125 + * @return void
3126 + * @since 1.8.0
3127 + */
3128 + public function pointer_accepted_cta() {
3129 + // Security: Check user capability.
3130 + if ( ! Helper::current_user_can() ) {
3131 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3132 + }
3133 + // Security: Nonce check.
3134 + if ( empty( $_POST['pointer_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['pointer_nonce'] ) ), 'sureforms_pointer_action' ) ) {
3135 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3136 + }
3137 + // Use Helper to update srfm_options key.
3138 + Helper::update_srfm_option( 'pointer_popup_accepted', time() );
3139 +
3140 + wp_send_json_success();
3141 + }
3142 +
3143 + /**
3144 + * Maybe register the dashboard widget based on entries.
3145 + *
3146 + * @return void
3147 + * @since 1.9.1
3148 + */
3149 + public function maybe_register_dashboard_widget() {
3150 +
3151 + // Only for users with manage_options capability.
3152 + if ( ! Helper::current_user_can() ) {
3153 + return;
3154 + }
3155 +
3156 + // 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.
3157 + add_action( 'wp_dashboard_setup', [ $this, 'register_ai_dashboard_widget' ] );
3158 +
3159 + // Quick check if there are any entries in the last 7 days.
3160 + $seven_days_ago = strtotime( '-7 days' );
3161 + $total_entries = Entries::get_entries_count_after( $seven_days_ago );
3162 +
3163 + // Only add the dashboard setup hook if there are entries.
3164 + if ( $total_entries > 0 ) {
3165 + // Get forms with entries (limit 4 for dashboard widget).
3166 + $this->dashboard_widget_data = Helper::get_forms_with_entry_counts( $seven_days_ago, 4 );
3167 +
3168 + // Only show dashboard widget if there are forms with entries.
3169 + if ( ! empty( $this->dashboard_widget_data ) ) {
3170 + add_action( 'wp_dashboard_setup', [ $this, 'register_dashboard_widget' ] );
3171 + }
3172 + }
3173 + }
3174 +
3175 + /**
3176 + * Register the dashboard widget.
3177 + *
3178 + * @return void
3179 + * @since 1.9.1
3180 + */
3181 + public function register_dashboard_widget() {
3182 + // Add the widget with high priority to position it at the top.
3183 + wp_add_dashboard_widget(
3184 + 'sureforms_recent_entries',
3185 + __( 'SureForms', 'sureforms' ),
3186 + [ $this, 'render_dashboard_widget' ],
3187 + null,
3188 + null,
3189 + 'normal',
3190 + 'high'
3191 + );
3192 + }
3193 +
3194 + /**
3195 + * Register the AI quick draft dashboard widget.
3196 + *
3197 + * @return void
3198 + * @since 2.12.1
3199 + */
3200 + public function register_ai_dashboard_widget() {
3201 + wp_add_dashboard_widget(
3202 + 'sureforms_ai_quick_draft',
3203 + __( 'SureForms AI Quick Draft', 'sureforms' ),
3204 + [ $this, 'render_ai_dashboard_widget' ],
3205 + null,
3206 + null,
3207 + 'normal',
3208 + 'high'
3209 + );
3210 + }
3211 +
3212 + /**
3213 + * Render AI quick draft dashboard widget content.
3214 + *
3215 + * @return void
3216 + * @since 2.12.1
3217 + */
3218 + public function render_ai_dashboard_widget() {
3219 + ?>
3220 + <div class="srfm-ai-dashboard-widget">
3221 + <p>
3222 + <?php esc_html_e( 'Describe the form and let SureForms AI generate it for you.', 'sureforms' ); ?>
3223 + </p>
3224 + <label for="srfm-ai-dashboard-prompt" class="screen-reader-text">
3225 + <?php esc_html_e( 'Describe your form', 'sureforms' ); ?>
3226 + </label>
3227 + <textarea
3228 + id="srfm-ai-dashboard-prompt"
3229 + class="widefat"
3230 + rows="5"
3231 + maxlength="2000"
3232 + placeholder="<?php esc_attr_e( 'Example: Create a contact form with name, email, phone, and message fields.', 'sureforms' ); ?>"
3233 + ></textarea>
3234 + <p style="margin-top:10px;margin-bottom:0;display:flex;align-items:center;gap:10px;">
3235 + <button type="button" class="button button-primary" id="srfm-ai-dashboard-generate" disabled>
3236 + <?php esc_html_e( 'Create New Form', 'sureforms' ); ?>
3237 + </button>
3238 + <span id="srfm-ai-dashboard-char-count" style="color:#646970;">0/2000</span>
3239 + </p>
3240 + </div>
3241 + <?php
3242 + }
3243 +
3244 + /**
3245 + * Enqueue the AI quick draft dashboard widget script on the dashboard screen.
3246 + *
3247 + * The widget's behavior lives here (attached via wp_add_inline_script) rather than as an
3248 + * inline <script> in the render callback, so it passes Plugin Check and keeps server values
3249 + * out of the markup. Server values are passed through wp_localize_script.
3250 + *
3251 + * @param string $hook_suffix The current admin page hook suffix.
3252 + * @return void
3253 + * @since 2.12.1
3254 + */
3255 + public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) {
3256 + // Only on the main dashboard, and only for capable users (matches the widget gate).
3257 + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
3258 + return;
3259 + }
3260 +
3261 + // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching
3262 + // localized data plus an inline script without shipping a separate asset file.
3263 + wp_register_script( 'srfm-ai-dashboard-widget', '', [], SRFM_VER, true );
3264 + wp_enqueue_script( 'srfm-ai-dashboard-widget' );
3265 +
3266 + wp_localize_script(
3267 + 'srfm-ai-dashboard-widget',
3268 + 'srfmAiDashboardWidget',
3269 + [
3270 + 'redirectUrl' => admin_url( 'admin.php?page=add-new-form' ),
3271 + 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
3272 + 'nonce' => wp_create_nonce( 'srfm_ai_widget_usage' ),
3273 + 'redirectingTxt' => __( 'Redirecting...', 'sureforms' ),
3274 + ]
3275 + );
3276 +
3277 + $inline_script = <<<'JS'
3278 +( function () {
3279 + const config = window.srfmAiDashboardWidget || {};
3280 + const generateButton = document.getElementById( 'srfm-ai-dashboard-generate' );
3281 + const promptField = document.getElementById( 'srfm-ai-dashboard-prompt' );
3282 + const charCount = document.getElementById( 'srfm-ai-dashboard-char-count' );
3283 + if ( ! generateButton || ! promptField ) {
3284 + return;
3285 + }
3286 +
3287 + const updateWidgetState = function () {
3288 + const promptValue = promptField.value.trim();
3289 + generateButton.disabled = ! promptValue;
3290 + if ( charCount ) {
3291 + charCount.textContent = `${ promptField.value.length }/2000`;
3292 + }
3293 + };
3294 +
3295 + const triggerGeneration = function () {
3296 + const prompt = promptField.value.trim();
3297 + if ( ! prompt ) {
3298 + promptField.focus();
3299 + return;
3300 + }
3301 +
3302 + generateButton.disabled = true;
3303 + generateButton.textContent = config.redirectingTxt;
3304 +
3305 + const redirectUrl = new URL( config.redirectUrl, window.location.origin );
3306 + redirectUrl.searchParams.set( 'srfm_ai_dashboard_prompt', prompt );
3307 +
3308 + const requestBody = new URLSearchParams();
3309 + requestBody.append( 'action', 'srfm_ai_widget_usage' );
3310 + requestBody.append( 'nonce', config.nonce );
3311 +
3312 + fetch( config.ajaxUrl, {
3313 + method: 'POST',
3314 + credentials: 'same-origin',
3315 + headers: {
3316 + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
3317 + },
3318 + body: requestBody.toString(),
3319 + } ).finally( function () {
3320 + window.location.href = redirectUrl.toString();
3321 + } );
3322 + };
3323 +
3324 + promptField.addEventListener( 'input', updateWidgetState );
3325 + generateButton.addEventListener( 'click', triggerGeneration );
3326 + promptField.addEventListener( 'keydown', function ( event ) {
3327 + if ( event.key === 'Enter' && ( event.metaKey || event.ctrlKey ) ) {
3328 + event.preventDefault();
3329 + triggerGeneration();
3330 + }
3331 + } );
3332 +
3333 + updateWidgetState();
3334 +}() );
3335 +JS;
3336 +
3337 + wp_add_inline_script( 'srfm-ai-dashboard-widget', $inline_script );
3338 + }
3339 +
3340 + /**
3341 + * Count an editor visit that came from the front-end "Edit Form" pill.
3342 + *
3343 + * The pill is a plain link, so the click is attributed by the marker query arg
3344 + * it carries rather than by a front-end click handler. That keeps the front end
3345 + * script-free and adds no AJAX endpoint: the only thing on the page is still an
3346 + * anchor. It also measures the outcome that matters — the editor actually
3347 + * opening — instead of a click that may never land.
3348 + *
3349 + * Every decision here comes from server state. The query arg selects the code
3350 + * path; what gets counted is derived from the resolved post and the current
3351 + * user's capability on it. An absent, empty, misspelled or reused arg, a post
3352 + * that is not a SureForms form, and a user without `edit_post` on that form all
3353 + * fall through to no-op without an explicit branch.
3354 + *
3355 + * No nonce, deliberately: the pill is rendered into front-end HTML that may be
3356 + * page-cached, so a nonce would either be baked into the cache or be stale on
3357 + * arrival. The effect is a private usage counter for a user who can already edit
3358 + * the form, and nothing attacker-controlled reaches the analytics payload — the
3359 + * value sent is an integer read back from stored state.
3360 + *
3361 + * Because the marker is just a query arg, the invariant that bounds this is the
3362 + * dedup transient below, not the arg: a given editor moves the counter at most
3363 + * once per form per hour, no matter how many times the URL is requested. That is
3364 + * also what keeps the metric honest — without it a refresh or a back-navigation
3365 + * would count again, and each count is a read-modify-write of the whole
3366 + * `srfm_options` row, which holds unrelated settings.
3367 + *
3368 + * @return void
3369 + * @since 2.12.6
3370 + */
3371 + public function maybe_track_edit_form_button_click() {
3372 + // is_string() before sanitize_key(): `?srfm_edit_src[]=x` satisfies isset(),
3373 + // and wp_unslash() hands the array straight through. sanitize_key() only grew
3374 + // its is_scalar() guard after this plugin's minimum WordPress, so on the older
3375 + // supported versions that reaches strtolower( array ) — a TypeError on PHP 8,
3376 + // i.e. the one input shape that ended in a fatal rather than in the no-op the
3377 + // rest of this method guarantees.
3378 + $arg = Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG;
3379 +
3380 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only attribution marker; see docblock for why a nonce is neither possible nor needed.
3381 + $source = isset( $_GET[ $arg ] ) && is_string( $_GET[ $arg ] ) ? sanitize_key( wp_unslash( $_GET[ $arg ] ) ) : '';
3382 +
3383 + if ( 'embed' !== $source ) {
3384 + return;
3385 + }
3386 +
3387 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Same read-only path as above.
3388 + $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
3389 +
3390 + // Resolve the post type from the stored post, never from the request.
3391 + //
3392 + // The capability below reads as per-post but is not: sureforms_form is
3393 + // registered with an explicit capabilities map and no `map_meta_cap`
3394 + // (inc/post-types.php), so core short-circuits `edit_post` to the post type's
3395 + // `edit_post` capability — `manage_options` — without ever consulting $post_id.
3396 + // The real gate is therefore "site administrator", which is stricter than a
3397 + // per-form check, not weaker. Written down because a later `map_meta_cap` on
3398 + // the CPT would silently change what this line means with no diff here.
3399 + if ( 0 === $post_id || SRFM_FORMS_POST_TYPE !== get_post_type( $post_id ) ) {
3400 + return;
3401 + }
3402 +
3403 + if ( ! current_user_can( 'edit_post', $post_id ) ) {
3404 + return;
3405 + }
3406 +
3407 + // One count per editor per form per hour. Without this the metric measures
3408 + // "editor loads carrying the marker" rather than pill clicks — a refresh or a
3409 + // back-navigation re-counts — and a forged page could drive the counter, and
3410 + // the writes behind it, without bound.
3411 + $dedup_key = 'srfm_pill_click_' . get_current_user_id() . '_' . $post_id;
3412 +
3413 + if ( false !== get_transient( $dedup_key ) ) {
3414 + return;
3415 + }
3416 +
3417 + set_transient( $dedup_key, 1, HOUR_IN_SECONDS );
3418 +
3419 + $count = Helper::get_integer_value( Helper::get_srfm_option( 'edit_form_button_clicks', 0 ) ) + 1;
3420 + Helper::update_srfm_option( 'edit_form_button_clicks', $count );
3421 +
3422 + // $force = true because this is a cumulative counter, not a one-time event —
3423 + // it must re-send the latest count each cycle (bypasses one-time dedup).
3424 + Analytics::events()->track( 'edit_form_button_clicked', (string) $count, [], true );
3425 + }
3426 +
3427 + /**
3428 + * Let core strip the edit-attribution marker from the admin URL.
3429 + *
3430 + * Core's wp_admin_canonical_url() rewrites the address bar via replaceState() on
3431 + * admin_head, which runs after load-post.php — so the marker has already been
3432 + * counted by the time it is removed and no attribution is lost. Without this it
3433 + * lingers in the address bar, in bookmarks, and in the Referer header sent to
3434 + * every subresource the editor loads.
3435 + *
3436 + * @param array<string> $args Query args core already removes.
3437 + * @since 2.12.6
3438 + * @return array<string> Args with the marker appended.
3439 + */
3440 + public function add_removable_query_args( $args ) {
3441 + if ( ! is_array( $args ) ) {
3442 + return [ Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG ];
3443 + }
3444 +
3445 + $args[] = Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG;
3446 +
3447 + return $args;
3448 + }
3449 +
3450 + /**
3451 + * Track AI dashboard widget usage.
3452 + *
3453 + * @return void
3454 + * @since 2.12.1
3455 + */
3456 + public function track_ai_widget_usage() {
3457 + if ( ! check_ajax_referer( 'srfm_ai_widget_usage', 'nonce', false ) ) {
3458 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3459 + }
3460 +
3461 + if ( ! Helper::current_user_can() ) {
3462 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3463 + }
3464 +
3465 + $current_count = (int) Helper::get_srfm_option( 'ai_dashboard_widget_uses', 0 ) + 1;
3466 + Helper::update_srfm_option( 'ai_dashboard_widget_uses', $current_count );
3467 +
3468 + // Emit an analytics event so usage lands in the warehouse via events_record.
3469 + // $force = true because this is a cumulative counter, not a one-time event —
3470 + // it must re-send the latest count each cycle (bypasses one-time dedup).
3471 + Analytics::events()->track( 'ai_dashboard_widget_used', (string) $current_count, [], true );
3472 +
3473 + wp_send_json_success();
3474 + }
3475 +
3476 + /**
3477 + * Render the dashboard widget content.
3478 + *
3479 + * @return void
3480 + * @since 1.9.1
3481 + */
3482 + public function render_dashboard_widget() {
3483 + // Use the pre-fetched data to avoid duplicate queries.
3484 + $entries_data = $this->dashboard_widget_data;
3485 +
3486 + // Display the widget content.
3487 + ?>
3488 + <div class="srfm-dashboard-widget">
3489 + <div class="srfm-widget-header">
3490 + <h3 class="srfm-widget-title">
3491 + <?php esc_html_e( 'Recent Entries', 'sureforms' ); ?>
3492 + <span class="srfm-widget-subtitle"><?php esc_html_e( '( Last 7 days )', 'sureforms' ); ?></span>
3493 + </h3>
3494 + <a href="<?php echo esc_url( admin_url( 'admin.php?page=sureforms_entries' ) ); ?>" class="srfm-widget-view-link">
3495 + <?php esc_html_e( 'View', 'sureforms' ); ?>
3496 + </a>
3497 + </div>
3498 +
3499 + <div class="srfm-table-wrapper">
3500 + <table class="srfm-entries-table">
3501 + <thead>
3502 + <tr>
3503 + <th><?php esc_html_e( 'Form Name', 'sureforms' ); ?></th>
3504 + <th><?php esc_html_e( 'Entries', 'sureforms' ); ?></th>
3505 + </tr>
3506 + </thead>
3507 + <tbody>
3508 + <?php foreach ( $entries_data as $form_data ) { ?>
3509 + <tr>
3510 + <td class="form-name"><?php echo esc_html( $form_data['title'] ); ?></td>
3511 + <td class="entry-count"><?php echo esc_html( $form_data['count'] ); ?></td>
3512 + </tr>
3513 + <?php } ?>
3514 + </tbody>
3515 + </table>
3516 + </div>
3517 +
3518 + <?php
3519 + // Render footer if applicable.
3520 + $this->render_dashboard_widget_footer( $entries_data );
3521 + ?>
3522 + </div>
3523 + <?php
3524 + }
3525 +
3526 + /**
3527 + * Classic dashboard notice when submissions keep failing.
3528 + *
3529 + * Hooked - admin_notices.
3530 + *
3531 + * Gated to the WP dashboard. The React notice already covers SureForms' own
3532 + * screens, so leaving this admin-wide would stack two warnings on one page.
3533 + *
3534 + * Registered as [ $this, 'method' ] rather than a closure because
3535 + * suppress_foreign_admin_notices() strips any callback it cannot attribute to
3536 + * a SureForms class -- a closure here would be silently removed.
3537 + *
3538 + * @since 2.12.6
3539 + * @return void
3540 + */
3541 + public function render_action_item_notices() {
3542 + // Shown across wp-admin, because someone whose forms are silently failing
3543 + // may not open the WP dashboard or SureForms for days.
3544 + //
3545 + // The one exclusion is SureForms' own dashboard: the Form Checks panel in
3546 + // its sidebar already lists these, and a banner above it would say the same
3547 + // thing twice on one screen.
3548 + if ( Helper::validate_request_context( 'sureforms_menu', 'page' ) ) {
3549 + return;
3550 + }
3551 +
3552 + $items = $this->get_action_items();
3553 +
3554 + // Only the faults reach this surface, so count those before deciding
3555 + // whether the carousel stylesheet is worth printing.
3556 + $rendered = 0;
3557 +
3558 + foreach ( $items as $item ) {
3559 + $status = Helper::get_string_value( $item['status'] ?? '' );
3560 +
3561 + if ( 'success' !== $status && '' !== $status ) {
3562 + $rendered++;
3563 + }
3564 + }
3565 +
3566 + if ( 0 === $rendered ) {
3567 + return;
3568 + }
3569 +
3570 + $this->enqueue_notice_response_script();
3571 +
3572 + foreach ( $items as $item ) {
3573 + $status = Helper::get_string_value( $item['status'] ?? '' );
3574 +
3575 + // Passing checks belong in the SureForms panel, not in wp-admin. A
3576 + // notice that says nothing is wrong is noise on every page load.
3577 + if ( 'success' === $status || '' === $status ) {
3578 + continue;
3579 + }
3580 +
3581 + // A fault reads as an error; advice reads as a warning. Both are shown,
3582 + // but they are not the same kind of message and should not look alike.
3583 + $class = 'error' === $status ? 'notice-error' : 'notice-warning';
3584 + ?>
3585 + <div class="notice srfm-action-item-notice <?php echo esc_attr( $class ); ?>">
3586 + <?php
3587 + /*
3588 + * Guarded like every sibling key. A filter item carrying only
3589 + * id/status/cta_* is a shape this surface designs for, and reading
3590 + * these unguarded is two PHP 8 undefined-key warnings plus an
3591 + * esc_html( null ) deprecation on 8.1+. React tolerates the absence,
3592 + * so leaving it would keep the two renderers disagreeing.
3593 + */
3594 + ?>
3595 + <p><strong><?php echo esc_html( Helper::get_string_value( $item['title'] ?? '' ) ); ?></strong></p>
3596 + <p><?php echo esc_html( Helper::get_string_value( $item['message'] ?? '' ) ); ?></p>
3597 + <?php
3598 + // Self-serve first, so the emphasis follows the order rather than the
3599 + // identity: whichever action leads is the primary button, and an item
3600 + // with no guide still leads with Contact Support.
3601 + $has_guide = ! empty( $item['guide_label'] ) && ! empty( $item['guide_url'] );
3602 +
3603 + // Both keys, not either. An item contributed through
3604 + // srfm_action_items may carry only guide_* keys -- reading cta_url
3605 + // unguarded emits two PHP 8 undefined-key warnings and renders
3606 + // href="" -- and a label without a URL renders an anchor that is not
3607 + // keyboard focusable. React gates on the same pair.
3608 + $has_cta = ! empty( $item['cta_label'] ) && ! empty( $item['cta_url'] );
3609 + ?>
3610 + <p>
3611 + <?php if ( $has_guide ) { ?>
3612 + <a
3613 + href="<?php echo esc_url( Helper::get_string_value( $item['guide_url'] ) ); ?>"
3614 + class="button button-primary"
3615 + data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>"
3616 + data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['guide_action'] ?? '' ) ); ?>"
3617 + target="_blank"
3618 + rel="noopener noreferrer"
3619 + >
3620 + <?php echo esc_html( $item['guide_label'] ); ?>
3621 + </a>
3622 + <?php } ?>
3623 + <?php if ( $has_cta ) { ?>
3624 + <a
3625 + href="<?php echo esc_url( Helper::get_string_value( $item['cta_url'] ) ); ?>"
3626 + class="<?php echo $has_guide ? 'button' : 'button button-primary'; ?>"
3627 + data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>"
3628 + data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['cta_action'] ?? '' ) ); ?>"
3629 + <?php
3630 + // With details to fetch, the click opens them here instead
3631 + // of following the href. The href stays as the no-JS
3632 + // path: it goes to the dashboard, where the same details
3633 + // are readable.
3634 + if ( ! empty( $item['has_details'] ) ) {
3635 + printf(
3636 + 'data-srfm-details-for="%1$s" data-srfm-category="%2$s"',
3637 + esc_attr( Helper::get_string_value( $item['id'] ) ),
3638 + esc_attr( Helper::get_string_value( $item['category'] ?? '' ) )
3639 + );
3640 + } elseif ( 0 !== strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ) {
3641 + echo 'target="_blank" rel="noopener noreferrer"';
3642 + }
3643 + ?>
3644 + >
3645 + <?php echo esc_html( $item['cta_label'] ); ?>
3646 + </a>
3647 + <?php } ?>
3648 + <?php if ( ! empty( $item['dismissible'] ) ) { ?>
3649 + <a href="<?php echo esc_url( $this->get_dismiss_action_item_url( Helper::get_string_value( $item['id'] ) ) ); ?>" class="button">
3650 + <?php esc_html_e( 'Dismiss', 'sureforms' ); ?>
3651 + </a>
3652 + <?php } ?>
3653 + </p>
3654 + </div>
3655 + <?php
3656 + }
3657 + }
3658 +
3659 + /**
3660 + * Dismiss an action item from the classic notice's link.
3661 + *
3662 + * Hooked - admin_post_srfm_dismiss_action_item_link.
3663 + *
3664 + * @since 2.12.6
3665 + * @return void
3666 + */
3667 + public function handle_dismiss_action_item_link() {
3668 + if ( ! Helper::current_user_can() ) {
3669 + wp_die( esc_html__( 'You do not have permission to do this.', 'sureforms' ), 403 );
3670 + }
3671 +
3672 + check_admin_referer( 'srfm_dismiss_action_item' );
3673 +
3674 + $item_id = isset( $_GET['item'] ) ? sanitize_key( wp_unslash( $_GET['item'] ) ) : '';
3675 +
3676 + $this->dismiss_action_item( $item_id );
3677 +
3678 + $referer = wp_get_referer();
3679 +
3680 + wp_safe_redirect( $referer ? $referer : admin_url() );
3681 + exit;
3682 + }
3683 +
3684 + /**
3685 + * Whether a first-party warning is currently on screen.
3686 + *
3687 + * Asked from the show_if of the rating, Getting Started and Thank You notices,
3688 + * all of which are gated on nothing being wrong. "Wrong" has to mean the same
3689 + * thing here as it does to the person looking at the screen.
3690 + *
3691 + * It used to re-state the conditions instead of reading them, and the
3692 + * restatement was narrower than the display: has_persistent_failures() reads
3693 + * the `submission` counter alone, while the notices and the Form Checks panel
3694 + * warn on any open failure in any of the three categories. So an open
3695 + * notification or integration failure left this false, and the review ask
3696 + * appeared directly beneath "We noticed a notification failure on Contact
3697 + * Form". Submission was covered only incidentally, by FAULT_THRESHOLD being 1 --
3698 + * raise that and it would have joined them.
3699 + *
3700 + * Derived from get_first_party_action_items() now, which is the thing that
3701 + * builds those warnings, so the gate cannot drift from the display again.
3702 + *
3703 + * Two constraints kept from the previous version. It must not call
3704 + * get_action_items(): that records an impression as a side effect and must
3705 + * never run from a show_if. And it reads the first-party set specifically, so
3706 + * an item contributed through `srfm_action_items` cannot suppress notices that
3707 + * have nothing to do with it.
3708 + *
3709 + * Returns false with logging disabled, which is what makes those notices
3710 + * eligible again on a site that has turned this surface off. Intended: with the
3711 + * surface off there is nothing being reported.
3712 + *
3713 + * @since 2.12.6
3714 + * @return bool
3715 + */
3716 + public function has_action_item_warnings() {
3717 + if ( ! Client_Logger::is_enabled() ) {
3718 + return false;
3719 + }
3720 +
3721 + foreach ( $this->get_first_party_action_items() as $item ) {
3722 + if ( ! is_array( $item ) ) {
3723 + continue;
3724 + }
3725 +
3726 + $status = Helper::get_string_value( $item['status'] ?? '' );
3727 +
3728 + // Matches the renderers: 'success' is a passing check and an empty
3729 + // status is not a warning either, so neither suppresses anything.
3730 + if ( 'success' !== $status && '' !== $status ) {
3731 + return true;
3732 + }
3733 + }
3734 +
3735 + return false;
3736 + }
3737 +
3738 + /**
3739 + * Things on this site that need the owner's attention, newest concern first.
3740 + *
3741 + * Fed to the dashboard sidebar carousel. Each entry is self-describing so the
3742 + * front end has no rules of its own to keep in sync -- adding a new item here
3743 + * makes it appear with no JavaScript change.
3744 + *
3745 + * `dismissible` separates a fault from advice. A run of failed submissions is
3746 + * not something to wave away, and clears itself when a submission succeeds. A
3747 + * caching plugin being present is information, so it can be dismissed.
3748 + *
3749 + * @since 2.12.6
3750 + * @return array<int,array<string,mixed>>
3751 + */
3752 + public function get_action_items() {
3753 + if ( ! Helper::current_user_can() ) {
3754 + return [];
3755 + }
3756 +
3757 + // Memoised for the request. This runs twice on every admin page -- once
3758 + // building the localisation payload and once in the classic renderer -- and
3759 + // each open category reads a log excerpt. It also records an impression, so
3760 + // running twice counted twice. Matches the $thankyou_prompt_cache and
3761 + // $setup_card_cache pattern already in this class.
3762 + if ( null !== self::$action_items_cache ) {
3763 + return self::$action_items_cache;
3764 + }
3765 +
3766 + // Logging off is the opt-out for this surface. Not because the counters go
3767 + // stale -- Client_Logger::record_failure() has no enabled check, and the
3768 + // notification and integration categories are written by direct calls in
3769 + // inc/form-submit.php that keep counting accurately with logging off. It is
3770 + // simply the switch a site owner has to turn these notices off, and it
3771 + // covers our own items only: the filter below still runs, because a third
3772 + // party's advisory has nothing to do with SureForms' logging toggle.
3773 + $warnings = [];
3774 +
3775 + if ( Client_Logger::is_enabled() ) {
3776 + $warnings = $this->get_first_party_action_items();
3777 + }
3778 +
3779 + $this->track_action_item_impressions( $warnings );
3780 +
3781 + /**
3782 + * Filter the dashboard action items.
3783 + *
3784 + * Each entry needs id, status ('warning' or 'success'), title, message,
3785 + * cta_label, cta_url and dismissible. Only ids in
3786 + * handle_dismiss_action_item()'s allowlist can actually be dismissed, so
3787 + * adding a dismissible item here also needs a line there.
3788 + *
3789 + * The details dialog is not available here: it is served by
3790 + * handle_action_item_details(), which reads SureForms' own client error log
3791 + * and knows nothing about a third-party item. Such an item's cta_url is
3792 + * followed as a link, which is what it does with JavaScript off anyway.
3793 + *
3794 + * @since 2.12.6
3795 + *
3796 + * @param array<int,array<string,mixed>> $items Action items.
3797 + */
3798 + $items = Helper::apply_filters_as_array( 'srfm_action_items', $warnings );
3799 +
3800 + // Both URLs normalised once, here, rather than trusting each renderer to do
3801 + // it. Two things are being fixed at once.
3802 + //
3803 + // The scheme: the classic notice runs esc_url() and drops anything outside
3804 + // the allowlist, while React assigns href directly and react-dom 18 leaves
3805 + // a javascript: URL intact -- its sanitizeURL() only warns, and the warning
3806 + // is compiled out of the production build. esc_url_raw() with the same
3807 + // allowlist closes both.
3808 + //
3809 + // The ampersands: Helper::get_sureforms_website_url() returns an esc_url()'d
3810 + // string, so a URL with UTM parameters arrives with &#038; in it. In an HTML
3811 + // href the browser decodes that; React sets the property directly, so the
3812 + // entity would be sent to the server verbatim. Decoded to one raw form here,
3813 + // and each renderer escapes it for its own context.
3814 + foreach ( $items as $index => $item ) {
3815 + // A filter may hand back an object. isset() on it returns false, which
3816 + // would slip the item past both the URL normalisation and the
3817 + // sanitize_key() below without any sign that it had.
3818 + if ( ! is_array( $item ) ) {
3819 + continue;
3820 + }
3821 +
3822 + foreach ( [ 'cta_url', 'guide_url' ] as $key ) {
3823 + if ( ! isset( $item[ $key ] ) ) {
3824 + continue;
3825 + }
3826 +
3827 + $items[ $index ][ $key ] = esc_url_raw(
3828 + wp_specialchars_decode( Helper::get_string_value( $item[ $key ] ), ENT_QUOTES ),
3829 + [ 'http', 'https', 'mailto' ]
3830 + );
3831 + }
3832 +
3833 + // The id ends up in a data attribute the dialog matches on with an
3834 + // attribute selector, and in the dismiss allowlist. sanitize_key() is
3835 + // what both dismiss paths already apply, so applying it once here means
3836 + // the value that renders is the value they compare against -- and a
3837 + // filter-contributed id carrying a quote cannot break the selector.
3838 + if ( isset( $item['id'] ) ) {
3839 + $items[ $index ]['id'] = sanitize_key( Helper::get_string_value( $item['id'] ) );
3840 + }
3841 + }
3842 +
3843 + self::$action_items_cache = $items;
3844 +
3845 + return $items;
3846 + }
3847 +
3848 + /**
3849 + * Dismiss one action item.
3850 + *
3851 + * Hooked - wp_ajax_srfm_dismiss_action_item.
3852 + *
3853 + * Only items get_action_items() marks dismissible can be dismissed, so a
3854 + * crafted request cannot silence a genuine fault.
3855 + *
3856 + * @since 2.12.6
3857 + * @return void
3858 + */
3859 + public function handle_dismiss_action_item() {
3860 + if ( ! Helper::current_user_can() ) {
3861 + wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3862 + return;
3863 + }
3864 +
3865 + if ( ! check_ajax_referer( 'srfm_dismiss_action_item', 'nonce', false ) ) {
3866 + wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3867 + return;
3868 + }
3869 +
3870 + $item_id = isset( $_POST['item_id'] ) ? sanitize_key( wp_unslash( $_POST['item_id'] ) ) : '';
3871 +
3872 + if ( ! $this->dismiss_action_item( $item_id ) ) {
3873 + wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 );
3874 + return;
3875 + }
3876 +
3877 + wp_send_json_success();
3878 + }
3879 +
3880 + /**
3881 + * The stylesheet for the notice carousel and the details dialog.
3882 + *
3883 + * In a stylesheet rather than inline style assignments in
3884 + * notice-response.js, so the rules use logical properties, an RTL sheet can
3885 + * override them, and a site can restyle the dialog without patching a script.
3886 + *
3887 + * Only the classic wp-admin surface needs these. The SureForms dashboard's
3888 + * dialog is force-ui's, styled by the Tailwind build, so nothing here reaches
3889 + * it -- the two surfaces share their strings, not their markup.
3890 + *
3891 + * Attached to a registered handle with no file of its own, which is the WP way
3892 + * to ship CSS tied to one script.
3893 + *
3894 + * Hooked to admin_enqueue_scripts rather than called from the renderer.
3895 + * admin_notices fires from admin-header.php after admin_print_styles has
3896 + * flushed the head, so enqueuing there reached the page only through core's
3897 + * late-styles pass in the footer -- and until that parsed, every stacked notice
3898 + * rendered expanded before collapsing to one, the carousel controls overlapped
3899 + * the notice text, and the defensive `display: none` on the hidden payload was
3900 + * inert, which is the exact window that rule exists for.
3901 + *
3902 + * The buttons are painted explicitly. They carry core's `button` classes for
3903 + * their shape and focus behaviour, and core paints those with
3904 + * `var(--wp-admin-theme-color)` -- so without this the dialog renders in
3905 + * whichever admin colour scheme the user picked, which on a default install is
3906 + * blue, on a SureForms panel that is otherwise entirely brand orange. Same
3907 + * approach and same values as print_srfm_notice_styles().
3908 + *
3909 + * @since 2.12.7
3910 + * @return void
3911 + */
3912 + public function enqueue_action_item_styles() {
3913 + if ( wp_style_is( 'srfm-action-items', 'enqueued' ) ) {
3914 + return;
3915 + }
3916 +
3917 + if ( ! Helper::current_user_can() ) {
3918 + return;
3919 + }
3920 +
3921 + // Nothing to style unless the carousel is actually going to build. Cheap to
3922 + // ask: get_action_items() is memoised for the request.
3923 + //
3924 + // Two, not one: notice-response.js bails below two cards, so these rules
3925 + // have no consumer on a site with a single open fault.
3926 + $notices = 0;
3927 +
3928 + foreach ( $this->get_action_items() as $item ) {
3929 + $status = Helper::get_string_value( is_array( $item ) ? $item['status'] ?? '' : '' );
3930 +
3931 + if ( 'success' !== $status && '' !== $status ) {
3932 + $notices++;
3933 + }
3934 + }
3935 +
3936 + if ( $notices < 2 ) {
3937 + return;
3938 + }
3939 +
3940 + wp_register_style( 'srfm-action-items', false, [], SRFM_VER );
3941 + wp_enqueue_style( 'srfm-action-items' );
3942 +
3943 + $css = <<<'CSS'
3944 +.srfm-action-item-carousel { position: relative; }
3945 +.srfm-action-item-carousel .srfm-action-item-notice { padding-inline-end: var(--srfm-carousel-reserve, 130px); }
3946 +/* [hidden] is only a UA rule, and WordPress sets display on .notice, so a
3947 + third-party admin sheet can otherwise put a notice the carousel has hidden back
3948 + on screen. */
3949 +.srfm-action-item-carousel .srfm-action-item-notice[hidden] { display: none; }
3950 +.srfm-action-item-carousel-nav {
3951 + position: absolute;
3952 + top: 8px;
3953 + inset-inline-end: 12px;
3954 + margin: 0;
3955 + display: flex;
3956 + align-items: center;
3957 + gap: 8px;
3958 +}
3959 +.srfm-details-overlay {
3960 + position: fixed;
3961 + inset: 0;
3962 + z-index: 999999;
3963 + display: flex;
3964 + align-items: center;
3965 + justify-content: center;
3966 + background: rgba(0, 0, 0, .5);
3967 + padding: 16px;
3968 +}
3969 +.srfm-details-panel {
3970 + background: #fff;
3971 + border-radius: 8px;
3972 + padding: 16px;
3973 + width: 100%;
3974 + max-width: 800px;
3975 + box-shadow: 0 10px 30px rgba(0, 0, 0, .2);
3976 +}
3977 +.srfm-details-panel h2 { margin: 0 0 4px; font-size: 14px; }
3978 +.srfm-details-panel .srfm-details-description { margin: 0 0 12px; color: #50575e; }
3979 +.srfm-details-panel pre {
3980 + margin: 0;
3981 + max-height: 320px;
3982 + overflow: auto;
3983 + white-space: pre-wrap;
3984 + word-break: break-word;
3985 + background: #f6f7f7;
3986 + padding: 12px;
3987 + border-radius: 6px;
3988 + font-size: 12px;
3989 +}
3990 +.srfm-details-actions {
3991 + display: flex;
3992 + gap: 8px;
3993 + align-items: center;
3994 + flex-wrap: wrap;
3995 + justify-content: flex-end;
3996 + margin: 12px 0 0;
3997 +}
3998 +.srfm-details-hint {
3999 + margin-inline-end: auto;
4000 + font-size: 12px;
4001 + color: #4b5563;
4002 +}
4003 +/* Core paints .button with the admin colour scheme, so these say what they are
4004 + rather than inheriting whichever scheme the user picked. */
4005 +.srfm-details-panel .srfm-details-close.button-link {
4006 + color: #50575e;
4007 + text-decoration: none;
4008 +}
4009 +.srfm-details-panel .srfm-details-close.button-link:hover,
4010 +.srfm-details-panel .srfm-details-close.button-link:focus {
4011 + color: #1e1e1e;
4012 +}
4013 +.srfm-details-panel .srfm-details-copy.button {
4014 + background: #fff;
4015 + border-color: #c3c4c7;
4016 + color: #1e1e1e;
4017 +}
4018 +.srfm-details-panel .srfm-details-copy.button:hover,
4019 +.srfm-details-panel .srfm-details-copy.button:focus {
4020 + background: #f6f7f7;
4021 + border-color: #8c8f94;
4022 + color: #1e1e1e;
4023 +}
4024 +.srfm-details-panel .srfm-details-contact.button-primary,
4025 +.srfm-details-panel .srfm-details-contact.button-primary:hover,
4026 +.srfm-details-panel .srfm-details-contact.button-primary:focus {
4027 + background: #D54407;
4028 + border-color: #D54407;
4029 + color: #fff;
4030 + box-shadow: none;
4031 + text-shadow: none;
4032 + text-decoration: none;
4033 +}
4034 +.srfm-details-panel .srfm-details-contact.button-primary:hover,
4035 +.srfm-details-panel .srfm-details-contact.button-primary:focus {
4036 + background: #C83B00;
4037 + border-color: #C83B00;
4038 +}
4039 +/* Grey rather than a dimmed orange fill. Core sets the disabled text colour with
4040 + !important, so an orange background here leaves grey on orange at 1.31:1 --
4041 + and a control that cannot be used should not wear the primary colour anyway.
4042 + This is what core gives every other disabled button, and what force-ui renders
4043 + for the same state on the dashboard, so the two surfaces agree. */
4044 +.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"],
4045 +.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:hover,
4046 +.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:focus {
4047 + background: #f6f7f7;
4048 + border-color: #dcdcde;
4049 + pointer-events: none;
4050 + box-shadow: none;
4051 +}
4052 +.srfm-details-panel .button:focus {
4053 + outline: 2px solid #D54407;
4054 + outline-offset: 1px;
4055 + box-shadow: none;
4056 +}
4057 +CSS;
4058 +
4059 + wp_add_inline_style( 'srfm-action-items', $css );
4060 + }
4061 +
4062 + /**
4063 + * The details dialog's strings.
4064 + *
4065 + * One array, two consumers: the classic wp-admin dialog in
4066 + * notice-response.js, and the dashboard's force-ui one. Declared here rather
4067 + * than inline in each, because the same sentence written as `__()` in PHP and
4068 + * again in JSX looks identical to translators until the first edit to either,
4069 + * after which one surface silently reverts to English.
4070 + *
4071 + * @since 2.12.7
4072 + * @return array<string,string>
4073 + */
4074 + private function get_details_dialog_labels() {
4075 + return [
4076 + 'title' => __( 'Details', 'sureforms' ),
4077 + 'description' => __( 'What we recorded about this problem. Copy it into your support request so we can start from the cause rather than a description of it.', 'sureforms' ),
4078 + 'copy' => __( 'Copy details', 'sureforms' ),
4079 + 'copied' => __( 'Copied', 'sureforms' ),
4080 + 'contact' => __( 'Contact Support', 'sureforms' ),
4081 + 'close' => __( 'Close', 'sureforms' ),
4082 + // Shown beside the buttons rather than as a title attribute:
4083 + // pointer-events:none suppresses the native tooltip, a title
4084 + // never fires on keyboard focus, and screen readers commonly
4085 + // drop it on an unavailable control -- so the sentence saying
4086 + // why the button is inert could not be read by anyone.
4087 + 'copyFirst' => __( 'Copy the details first, so you have them to paste.', 'sureforms' ),
4088 + // The unlock changes the label, the icon and whether Contact
4089 + // Support works, none of which was announced. This goes in a
4090 + // role="status" node so it is.
4091 + 'unlocked' => __( 'Copied. Contact Support is now available.', 'sureforms' ),
4092 + 'copyFailed' => __( 'Your browser would not let us copy. Select the text above and copy it by hand.', 'sureforms' ),
4093 + // The scrollable diagnostics block is focusable, so it needs a name of
4094 + // its own.
4095 + 'logRegion' => __( 'Recorded diagnostics', 'sureforms' ),
4096 + // The dialog opens before its payload arrives -- see
4097 + // handle_action_item_details() for why the report is not shipped with
4098 + // the page.
4099 + 'loading' => __( 'Collecting the details…', 'sureforms' ),
4100 + 'unavailable' => __( 'We could not collect the details. Contact Support and describe what happened, and we will take it from there.', 'sureforms' ),
4101 + ];
4102 + }
4103 +
4104 + /**
4105 + * SureForms' own action items, before the filter.
4106 + *
4107 + * Split out so the Enable Logs gate in get_action_items() can sit above this
4108 + * rather than above `srfm_action_items`. An item contributed through that
4109 + * filter has nothing to do with SureForms' logging toggle, and was being
4110 + * silenced by it.
4111 + *
4112 + * @since 2.12.7
4113 + * @return array<int,array<string,mixed>>
4114 + */
4115 + private function get_first_party_action_items() {
4116 + $warnings = [];
4117 + $open = Client_Logger::get_open_failures();
4118 +
4119 + // One item per category. They read differently to a site owner and must not
4120 + // be collapsed: submissions failing means visitors cannot reach you, a
4121 + // notification failing means you are not hearing about entries that did
4122 + // save, an integration failing means a third party is not receiving them.
4123 + $categories = [
4124 + 'submission' => [
4125 + 'id' => 'form_submission_error',
4126 + /* translators: %s: form title. */
4127 + 'title' => __( 'We noticed a form submission failure on %s.', 'sureforms' ),
4128 + 'generic' => __( 'We noticed a form submission failure.', 'sureforms' ),
4129 + 'message' => __( 'Visitors may not be able to reach you, and their entries were not saved.', 'sureforms' ),
4130 + ],
4131 + 'notification' => [
4132 + 'id' => 'notification_error',
4133 + /* translators: %s: form title. */
4134 + 'title' => __( 'We noticed a notification failure on %s.', 'sureforms' ),
4135 + 'generic' => __( 'We noticed a notification failure.', 'sureforms' ),
4136 + 'message' => __( 'The entry was saved, but we could not send the email about it. New entries may be coming in without you knowing.', 'sureforms' ),
4137 + // Email is the one failure here a site owner can usually fix without
4138 + // us: it is almost always SMTP not being configured. Offer the guide
4139 + // alongside support rather than making them wait for a reply.
4140 + 'guide' => Helper::get_sureforms_website_url(
4141 + 'docs/troubleshooting-email-sending-in-sureforms/',
4142 + [
4143 + 'utm_medium' => 'form_checks_notice',
4144 + 'utm_content' => 'notification_error',
4145 + ]
4146 + ),
4147 + ],
4148 + 'integration' => [
4149 + 'id' => 'integration_error',
4150 + /* translators: %s: form title. */
4151 + 'title' => __( 'We noticed an integration failure on %s.', 'sureforms' ),
4152 + 'generic' => __( 'We noticed an integration failure.', 'sureforms' ),
4153 + 'message' => __( 'The entry was saved, but we could not send it to a connected service.', 'sureforms' ),
4154 + ],
4155 + ];
4156 +
4157 + foreach ( $categories as $category => $copy ) {
4158 + if ( ! isset( $open[ $category ] ) ) {
4159 + continue;
4160 + }
4161 +
4162 + // Name the form. "A form is failing" is not actionable on a site with
4163 + // twenty of them, and the title is the first thing anyone asks for.
4164 + $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' );
4165 +
4166 + $warning = [
4167 + 'id' => $copy['id'],
4168 + 'status' => 'error',
4169 + 'title' => '' !== $form_title
4170 + ? sprintf( $copy['title'], $form_title )
4171 + : $copy['generic'],
4172 + 'message' => $copy['message'],
4173 + // Shows what would be sent before anything is sent. Someone reporting
4174 + // a fault on their own site is entitled to read the diagnostics and
4175 + // the log first, and a support agent gets a cleaner paste than a
4176 + // screenshot of a notice.
4177 + 'cta_label' => __( 'View details', 'sureforms' ),
4178 + // Where the classic wp-admin notice sends people, since it cannot open
4179 + // the panel's dialog. The dashboard is where the details are readable.
4180 + 'cta_url' => admin_url( 'admin.php?page=sureforms_menu' ),
4181 + 'cta_action' => 'view_details',
4182 + // Not the payload itself, only that one exists. The diagnostics are
4183 + // fetched when the dialog opens -- see handle_action_item_details().
4184 + //
4185 + // They used to ride along in the localisation JSON and in a hidden
4186 + // div on every admin page. The content is authored by whoever
4187 + // triggered the failure, and the client-error-log route is a public
4188 + // endpoint gated on a submit token rather than a capability, so an
4189 + // anonymous visitor can fill that excerpt. Broadcasting it to every
4190 + // admin screen -- read or not -- put attacker-authored text in page
4191 + // source site-wide and made any future escaping slip a
4192 + // manage_options-context problem. On demand, it reaches only the
4193 + // admin who asked for it.
4194 + 'has_details' => true,
4195 + // Which record to fetch. Not the payload, just the key.
4196 + 'category' => $category,
4197 + 'dismissible' => false,
4198 + ];
4199 +
4200 + // A second, optional action. Absent keys render nothing, so a category
4201 + // without a guide needs no branch in either renderer, and neither does
4202 + // an item contributed through srfm_action_items.
4203 + if ( ! empty( $copy['guide'] ) ) {
4204 + $warning['guide_label'] = __( 'Help Me Fix', 'sureforms' );
4205 + $warning['guide_url'] = $copy['guide'];
4206 + $warning['guide_action'] = 'help_me_fix';
4207 + }
4208 +
4209 + $warnings[] = $warning;
4210 + }
4211 +
4212 + $caching_plugin = Helper::get_active_caching_plugin();
4213 +
4214 + if ( '' === $caching_plugin ) {
4215 + return $warnings;
4216 + }
4217 +
4218 + // Read here rather than at the top: with no caching plugin active nothing
4219 + // consults it, and this is the only dismissible item.
4220 + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
4221 +
4222 + if ( ! in_array( 'caching_plugin', $dismissed, true ) ) {
4223 + $warnings[] = [
4224 + 'id' => 'caching_plugin',
4225 + 'status' => 'warning',
4226 + 'title' => sprintf(
4227 + /* translators: %s: caching plugin name. */
4228 + __( '%s may interfere with your forms.', 'sureforms' ),
4229 + $caching_plugin
4230 + ),
4231 + 'message' => __( 'Caching can show visitors an old copy of your form, or load its scripts in the wrong order.', 'sureforms' ),
4232 + 'cta_label' => __( 'Help Me Fix', 'sureforms' ),
4233 + 'cta_url' => Helper::get_caching_plugin_doc_url(),
4234 + 'cta_action' => 'help_me_fix',
4235 + 'dismissible' => true,
4236 + ];
4237 + }
4238 +
4239 + return $warnings;
4240 + }
4241 +
4242 + /**
4243 + * Nonce-protected URL that repairs the entries table.
4244 + *
4245 + * Shared by both notice surfaces so there is one repair route, one nonce and one
4246 + * place that counts the click. Private, so it stays off the public API and out of
4247 + * the test-coverage gate.
4248 + *
4249 + * @since 2.12.6
4250 + * @return string
4251 + */
4252 + private function get_database_repair_url() {
4253 + return wp_nonce_url(
4254 + admin_url( 'admin-post.php?action=srfm_repair_entries_table' ),
4255 + 'srfm_repair_entries_table'
4256 + );
4257 + }
4258 +
4259 + /**
4260 + * The database notice body, which differs by what the repair will actually do.
4261 + *
4262 + * Two outcomes are possible and they are not equivalent to the person clicking:
4263 + * when the entries table exists under a different prefix — a changed
4264 + * `$table_prefix`, a restored dump, a security plugin that renamed tables and
4265 + * skipped ours — the repair renames it back and every stored entry comes with
4266 + * it. When there is nothing to adopt, the repair creates an empty table and the
4267 + * old submissions are not recoverable from here.
4268 + *
4269 + * Promising the wrong one is how a maintenance prompt turns into a complaint, so
4270 + * the copy states which is about to happen.
4271 + *
4272 + * @since 2.12.6
4273 + * @return string
4274 + */
4275 + private function get_database_notice_message() {
4276 + if ( '' !== Register::get_adoptable_entries_table() ) {
4277 + 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' );
4278 + }
4279 +
4280 + 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' );
4281 + }
4282 +
4283 + /**
4284 + * Count one sighting of the database notice, at most once per user per day.
4285 + *
4286 + * While the table is missing the notice renders on every admin page load, on two
4287 + * surfaces. Counting each render would rewrite the autoloaded `srfm_options` blob
4288 + * on every pageview of a site that is already broken, and one site left unfixed
4289 + * would dominate the aggregate. Throttling to a day per user answers the question
4290 + * that matters — how many people are seeing this — for one write.
4291 + *
4292 + * @since 2.12.6
4293 + * @return void
4294 + */
4295 + private function track_database_notice_impression() {
4296 + $user_id = get_current_user_id();
4297 +
4298 + if ( ! $user_id ) {
4299 + return;
4300 + }
4301 +
4302 + $key = 'srfm_db_notice_seen_' . $user_id;
4303 +
4304 + if ( get_transient( $key ) ) {
4305 + return;
4306 + }
4307 +
4308 + set_transient( $key, 1, DAY_IN_SECONDS );
4309 +
4310 + Analytics::events()->track( 'database_error_notice_shown', 'entries' );
4311 + }
4312 +
4313 + /**
4314 + * Build the setup-card payload (uncached). See get_form_setup_card().
4315 + *
4316 + * @since 2.12.4
4317 + * @return array<string,mixed>|null Card payload, or null when there is no candidate.
4318 + */
4319 + private static function compute_form_setup_card() {
4320 + if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || ! post_type_exists( SRFM_FORMS_POST_TYPE ) ) {
4321 + return null;
4322 + }
4323 +
4324 + // Negative cache. Deliberately not a `defined( 'ASTRA_SITES_VER' )` check:
4325 + // Starter Templates defines that constant in its main plugin file, so it only
4326 + // exists while the plugin is active, yet neither its uninstall.php nor its
4327 + // deactivation hook removes the import marker. Gating on the constant would
4328 + // silently switch this feature off for the very people it targets — anyone who
4329 + // imported a starter template and then removed the one-shot import plugin.
4330 + if ( 'no' === get_transient( self::NO_IMPORTED_FORMS_TRANSIENT ) ) {
4331 + return null;
4332 + }
4333 +
4334 + // Only forms created from an Astra Sites starter template — those carry the
4335 + // marker Starter Templates stamps on imported posts (self::ASTRA_SITES_IMPORT_META).
4336 + // Prime post + meta caches (the loop reads title, permalink and edit link
4337 + // per candidate) so this is a single query, not a follow-up per form.
4338 + $query = new \WP_Query(
4339 + [
4340 + 'post_type' => SRFM_FORMS_POST_TYPE,
4341 + 'post_status' => [ 'publish', 'draft', 'pending' ],
4342 + 'posts_per_page' => 10,
4343 + // ID breaks the tie: a starter-template import creates several forms
4344 + // within the same second, so post_date alone leaves "the newest form"
4345 + // up to MySQL and it can differ between page loads.
4346 + 'orderby' => [
4347 + 'date' => 'DESC',
4348 + 'ID' => 'DESC',
4349 + ],
4350 + 'no_found_rows' => true,
4351 + 'update_post_meta_cache' => true,
4352 + 'update_post_term_cache' => false,
4353 + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to 10 recent forms; dashboard-only.
4354 + [
4355 + 'key' => self::ASTRA_SITES_IMPORT_META,
4356 + 'compare' => 'EXISTS',
4357 + ],
4358 + ],
4359 + ]
4360 + );
4361 +
4362 + // Nothing on this site carries the marker — remember that, so the query does
4363 + // not repeat on every load. Keyed on the query result rather than on anything
4364 + // user-specific, so it is safe to share, and invalidated the moment a post is
4365 + // stamped (see invalidate_starter_template_cache()).
4366 + if ( empty( $query->posts ) ) {
4367 + set_transient( self::NO_IMPORTED_FORMS_TRANSIENT, 'no', WEEK_IN_SECONDS );
4368 + }
4369 +
4370 + foreach ( $query->posts as $post ) {
4371 + $form_id = (int) $post->ID;
4372 +
4373 + if ( ! current_user_can( 'edit_post', $form_id ) ) {
4374 + continue;
4375 + }
4376 +
4377 + $edit_link = get_edit_post_link( $form_id, 'raw' );
4378 +
4379 + if ( empty( $edit_link ) ) {
4380 + continue;
4381 + }
4382 +
4383 + // The steps are shown as optional next-steps — their completion is not
4384 + // computed, so the widget simply lists the actions the owner can take.
4385 + return [
4386 + 'id' => $form_id,
4387 + 'title' => get_the_title( $form_id ),
4388 + 'edit_url' => $edit_link,
4389 + // Deep-links to the email-notification panel where supported; falls
4390 + // back to opening the editor when the focus handler isn't present.
4391 + 'email_url' => add_query_arg( 'srfm_focus', 'notifications', $edit_link ),
4392 + // Deep-links to the Form Confirmation panel (the Thank You message).
4393 + 'thankyou_url' => add_query_arg( 'srfm_focus', 'thankyou', $edit_link ),
4394 + // Front-end instant-form page. get_permalink() only yields a working
4395 + // URL for published forms; a draft/pending form has no public URL, so
4396 + // omit the view link there (the empty() guard hides the icon).
4397 + 'view_url' => 'publish' === $post->post_status ? (string) get_permalink( $form_id ) : '',
4398 + ];
4399 + }
4400 +
4401 + return null;
4402 + }
4403 +
4404 + /**
4405 + * Build the Thank You prompt payload (uncached). See get_thankyou_prompt_forms().
4406 + *
4407 + * @since 2.12.4
4408 + * @return array<int,array<string,mixed>> One entry, or none.
4409 + */
4410 + private static function compute_thankyou_prompt_forms() {
4411 + if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || ! post_type_exists( SRFM_FORMS_POST_TYPE ) ) {
4412 + return [];
4413 + }
4414 +
4415 + // Negative cache — this notice renders on every admin screen, so keeping the
4416 + // query off installs that can never match is what matters here. See
4417 + // self::NO_IMPORTED_FORMS_TRANSIENT for why this is not gated on whether
4418 + // Starter Templates is still active: the marker outlives the plugin.
4419 + if ( 'no' === get_transient( self::NO_IMPORTED_FORMS_TRANSIENT ) ) {
4420 + return [];
4421 + }
4422 +
4423 + // Only forms imported from a Starter Templates (Astra Sites) starter
4424 + // template — see self::ASTRA_SITES_IMPORT_META. Prime post + meta caches
4425 + // (the loop reads meta, title and creation time per candidate) so this is a
4426 + // single query rather than the main query plus a follow-up per form.
4427 + $query = new \WP_Query(
4428 + [
4429 + 'post_type' => SRFM_FORMS_POST_TYPE,
4430 + 'post_status' => 'publish',
4431 + 'posts_per_page' => 10,
4432 + // ID breaks the tie — an import creates several forms in the same
4433 + // second, so post_date alone makes "newest" MySQL-dependent.
4434 + 'orderby' => [
4435 + 'date' => 'DESC',
4436 + 'ID' => 'DESC',
4437 + ],
4438 + 'no_found_rows' => true,
4439 + 'update_post_meta_cache' => true,
4440 + 'update_post_term_cache' => false,
4441 + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to 10 recent forms; admin-notice only.
4442 + [
4443 + 'key' => self::ASTRA_SITES_IMPORT_META,
4444 + 'compare' => 'EXISTS',
4445 + ],
4446 + ],
4447 + ]
4448 + );
4449 +
4450 + // Nothing on this site carries the marker — remember that, so the query does
4451 + // not repeat on every load. Keyed on the query result rather than on anything
4452 + // user-specific, so it is safe to share, and invalidated the moment a post is
4453 + // stamped (see invalidate_starter_template_cache()).
4454 + if ( empty( $query->posts ) ) {
4455 + set_transient( self::NO_IMPORTED_FORMS_TRANSIENT, 'no', WEEK_IN_SECONDS );
4456 + }
4457 +
4458 + $prompts = [];
4459 + $now = time();
4460 +
4461 + foreach ( $query->posts as $post ) {
4462 + $form_id = (int) $post->ID;
4463 +
4464 + if ( ! current_user_can( 'edit_post', $form_id ) ) {
4465 + continue;
4466 + }
4467 +
4468 + $steps = [
4469 + // A destination for replies: an enabled notification with a recipient.
4470 + 'replies' => ! self::form_has_reply_destination( $form_id ),
4471 + // The thank-you message is still the shipped default.
4472 + 'thankyou' => self::is_default_confirmation_message( $form_id ),
4473 + ];
4474 +
4475 + // Nothing left to finish — no card for this form.
4476 + if ( ! $steps['replies'] && ! $steps['thankyou'] ) {
4477 + continue;
4478 + }
4479 +
4480 + $edit_link = get_edit_post_link( $form_id, 'raw' );
4481 +
4482 + if ( empty( $edit_link ) ) {
4483 + continue;
4484 + }
4485 +
4486 + $created = get_post_time( 'U', true, $form_id );
4487 + $days_ago = is_int( $created ) ? (int) floor( ( $now - $created ) / DAY_IN_SECONDS ) : 0;
4488 +
4489 + $prompts[] = [
4490 + 'id' => $form_id,
4491 + 'title' => get_the_title( $form_id ),
4492 + 'days_ago' => max( 0, $days_ago ),
4493 + 'steps' => $steps,
4494 + 'edit_url' => $edit_link,
4495 + // The editor reads srfm_focus to open the matching settings tab:
4496 + // "notifications" lands on Email Notification (where the reply
4497 + // destination is set, so the CTA can actually clear that step) and
4498 + // "thankyou" on Form Confirmation.
4499 + 'replies_url' => add_query_arg( 'srfm_focus', 'notifications', $edit_link ),
4500 + 'thankyou_url' => add_query_arg( 'srfm_focus', 'thankyou', $edit_link ),
4501 + ];
4502 +
4503 + // One card is enough — surface only the latest form needing setup.
4504 + break;
4505 + }
4506 +
4507 + return $prompts;
4508 + }
4509 +
4510 + /**
4511 + * Build the Thank You notice's inner markup (title, sentence, action buttons).
4512 + *
4513 + * @param array<string,mixed> $form Prompt payload from get_thankyou_prompt_forms().
4514 + *
4515 + * @since 2.12.4
4516 + * @return string
4517 + */
4518 + private static function build_thankyou_notice_markup( $form ) {
4519 + // The prompt only surfaces starter-template imports (see the meta gate), so
4520 + // the form was created for the user rather than by them. Kept generic — no
4521 + // per-step claim — so it is always accurate whatever the user has since
4522 + // changed, while the action buttons point to the specific things to finish.
4523 + $sentence = __( 'We’ve already created this form for you. Finish customising it so it’s ready to collect real submissions.', 'sureforms' );
4524 +
4525 + return self::build_srfm_notice_markup(
4526 + sprintf(
4527 + /* translators: %s: form name. */
4528 + __( 'Finish setting up “%s”', 'sureforms' ),
4529 + $form['title']
4530 + ),
4531 + $sentence,
4532 + [
4533 + [
4534 + 'text' => __( 'Edit form', 'sureforms' ),
4535 + 'url' => $form['edit_url'],
4536 + 'primary' => true,
4537 + 'class' => 'srfm-ty-edit-form',
4538 + 'external' => true,
4539 + ],
4540 + [
4541 + 'text' => __( 'Edit the Thank You message', 'sureforms' ),
4542 + 'url' => $form['thankyou_url'],
4543 + 'class' => 'srfm-ty-edit-thankyou',
4544 + 'external' => true,
4545 + ],
4546 + [
4547 + 'text' => __( 'Set where replies go', 'sureforms' ),
4548 + 'url' => $form['replies_url'],
4549 + 'class' => 'srfm-ty-set-replies',
4550 + 'external' => true,
4551 + ],
4552 + ]
4553 + );
4554 + }
4555 +
4556 + /**
4557 + * Build the shared SureForms admin-notice body: title, sentence, action row.
4558 + *
4559 + * One builder for every SureForms notice so they cannot drift into looking like
4560 + * two different plugins. Everything is escaped here rather than by the caller —
4561 + * the notices library runs the result through wp_kses_post(), which would strip
4562 + * anything richer anyway.
4563 + *
4564 + * @param string $title Notice heading.
4565 + * @param string $text Supporting sentence.
4566 + * @param array<int,array<string,mixed>> $actions Action links. Each accepts
4567 + * text, url, and optionally
4568 + * primary, class, external,
4569 + * dismiss and snooze (seconds).
4570 + * @since 2.12.6
4571 + * @return string
4572 + */
4573 + private static function build_srfm_notice_markup( $title, $text, $actions ) {
4574 + ob_start();
4575 + ?>
4576 + <p class="srfm-notice__title"><?php echo esc_html( $title ); ?></p>
4577 + <p class="srfm-notice__text"><?php echo esc_html( $text ); ?></p>
4578 + <p class="srfm-notice__actions">
4579 + <?php
4580 + foreach ( $actions as $action ) {
4581 + if ( empty( $action['text'] ) || ! isset( $action['url'] ) ) {
4582 + continue;
4583 + }
4584 +
4585 + $classes = [ 'button' ];
4586 +
4587 + if ( ! empty( $action['primary'] ) ) {
4588 + $classes[] = 'button-primary';
4589 + }
4590 +
4591 + // astra-notice-close is what the library binds its dismiss handler to.
4592 + if ( ! empty( $action['dismiss'] ) ) {
4593 + $classes[] = 'astra-notice-close';
4594 + }
4595 +
4596 + if ( ! empty( $action['class'] ) ) {
4597 + $classes[] = $action['class'];
4598 + }
4599 + ?>
4600 + <a
4601 + class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
4602 + href="<?php echo esc_url( $action['url'] ); ?>"
4603 + <?php echo empty( $action['snooze'] ) ? '' : ' data-repeat-notice-after="' . esc_attr( (string) $action['snooze'] ) . '"'; ?>
4604 + <?php echo empty( $action['external'] ) ? '' : ' target="_blank" rel="noopener noreferrer"'; ?>
4605 + ><?php echo esc_html( $action['text'] ); ?></a>
4606 + <?php
4607 + }
4608 + ?>
4609 + </p>
4610 + <?php
4611 + return (string) ob_get_clean();
4612 + }
4613 +
4614 + /**
4615 + * Determine whether a notice callback is owned by SureForms.
4616 + *
4617 + * Recognises object methods on classes in the `SRFM` / `SRFM_PRO` namespaces
4618 + * as well as the bundled notices libraries (`BSF_Admin_Notices` and the
4619 + * legacy `Astra_Notices` alias). Everything else is treated as foreign.
4620 + *
4621 + * @param callable|array|string|null $function The registered callback function.
4622 + * @since 2.10.0
4623 + * @return bool True when the callback belongs to SureForms, false otherwise.
4624 + */
4625 + private function is_sureforms_owned_notice_callback( $function ) {
4626 + $class_name = '';
4627 +
4628 + if ( is_array( $function ) && isset( $function[0] ) ) {
4629 + // Object or static method callback represented as an array. The first
4630 + // element is either the object instance or the fully qualified class name.
4631 + $class_name = is_object( $function[0] ) ? get_class( $function[0] ) : (string) $function[0];
4632 + } elseif ( is_string( $function ) && false !== strpos( $function, '::' ) ) {
4633 + // Static method passed as "Class::method".
4634 + $class_name = strstr( $function, '::', true );
4635 + }
4636 +
4637 + if ( '' === $class_name ) {
4638 + // Plain function callbacks are never owned by SureForms.
4639 + return false;
4640 + }
4641 +
4642 + // SureForms (free and pro) namespaced classes. Pro's real namespace is
4643 + // `SRFM_Pro\` (case-sensitive) — not the all-caps `SRFM_PRO_` constant
4644 + // prefix — so match it case-insensitively to be safe.
4645 + if ( 0 === strpos( $class_name, 'SRFM\\' ) || 0 === stripos( $class_name, 'SRFM_Pro\\' ) ) {
4646 + return true;
4647 + }
4648 +
4649 + // Bundled notices library shipped with SureForms.
4650 + return in_array( $class_name, [ 'BSF_Admin_Notices', 'Astra_Notices' ], true );
4651 + }
4652 +
4653 + /**
4654 + * Whether the current admin page is a SureForms-owned screen identified by a
4655 + * `sureforms_*` / `srfm_*` `page` query slug. Complements
4656 + * {@see Helper::is_sureforms_admin_page()} so foreign-notice suppression also
4657 + * covers the payments / quiz / survey / learn / SMTP / partial-entries screens
4658 + * that the core helper does not enumerate. Read-only screen check.
4659 + *
4660 + * @since 2.10.0
4661 + * @return bool
4662 + */
4663 + private function is_sureforms_owned_admin_page() {
4664 + if ( ! is_admin() || empty( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection, no state change.
4665 + return false;
4666 + }
4667 + $page = sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection, no state change.
4668 + return 0 === strpos( $page, 'sureforms' ) || 0 === strpos( $page, 'srfm' );
4669 + }
4670 +
4671 + /**
4672 + * Callback for displaying the rating notice conditionally.
4673 + *
4674 + * Returns true if the user has 3 or more published forms or 3 or more form entries.
4675 + *
4676 + * @since 2.5.2
4677 + * @return bool
4678 + */
4679 + private function maybe_display_rating_notice() {
4680 + if ( null === $this->should_show_rating ) {
4681 + $entries_count = Entries::get_total_entries_by_status( 'all' );
4682 + $form_count = wp_count_posts( SRFM_FORMS_POST_TYPE );
4683 + $this->should_show_rating = $entries_count >= self::RATING_NOTICE_THRESHOLD || Helper::get_integer_value( $form_count->publish ?? 0 ) >= self::RATING_NOTICE_THRESHOLD;
4684 + }
4685 +
4686 + return $this->should_show_rating;
4687 + }
4688 +
4689 + /**
4690 + * Get random premium feature text.
4691 + *
4692 + * @return string Random feature text.
4693 + * @since 1.9.1
4694 + */
4695 + private function get_random_premium_feature_text() {
4696 + $features = [
4697 + __( 'Use Conditional Logic to show only what matters', 'sureforms' ),
4698 + __( 'Split your form into steps to keep it easy', 'sureforms' ),
4699 + __( 'Let people upload files directly to your form', 'sureforms' ),
4700 + __( 'Turn responses into downloadable PDFs automatically', 'sureforms' ),
4701 + __( 'Let users sign with a simple signature field', 'sureforms' ),
4702 + __( 'Connect your form to other tools using webhooks', 'sureforms' ),
4703 + __( 'Use Conversational Forms for a chat-like experience', 'sureforms' ),
4704 + __( 'Let users register or log in through your form', 'sureforms' ),
4705 + __( 'Build forms that create WordPress user accounts', 'sureforms' ),
4706 + __( 'Add calculations to auto-total scores or prices', 'sureforms' ),
4707 + ];
4708 +
4709 + // Get a random feature.
4710 + $random_key = array_rand( $features );
4711 + return $features[ $random_key ];
4712 + }
4713 +
4714 + /**
4715 + * Render the dashboard widget footer for upsell.
4716 + *
4717 + * @param array $entries_data The entries data array.
4718 + * @return void
4719 + * @since 1.9.1
4720 + */
4721 + private function render_dashboard_widget_footer( $entries_data ) {
4722 + // Only show footer if Pro is not active.
4723 + if ( Helper::has_pro() ) {
4724 + return;
4725 + }
4726 +
4727 + // Count total entries in last 7 days.
4728 + $total_entries = 0;
4729 + foreach ( $entries_data as $form_data ) {
4730 + $total_entries += $form_data['count'];
4731 + }
4732 +
4733 + // Count total published forms.
4734 + $published_forms_count = wp_count_posts( SRFM_FORMS_POST_TYPE )->publish;
4735 +
4736 + // Show footer only if 3+ entries received OR 3+ forms published.
4737 + if ( $total_entries >= 3 || $published_forms_count >= 3 ) {
4738 + ?>
4739 + <div class="srfm-widget-footer">
4740 + <div class="srfm-upgrade-content">
4741 + <svg class="srfm-logo-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
4742 + <rect width="20" height="20" fill="#D54407"/>
4743 + <path d="M5.7139 4.2854H14.2853V7.1425H7.1424L5.7139 8.5711V7.1425V4.2854Z" fill="white"/>
4744 + <path d="M5.7139 4.2854H14.2853V7.1425H7.1424L5.7139 8.5711V7.1425V4.2854Z" fill="white"/>
4745 + <path d="M5.7148 8.5713H12.8577V11.4284H7.1434L5.7148 12.857V11.4284V8.5713Z" fill="white"/>
4746 + <path d="M5.7148 8.5713H12.8577V11.4284H7.1434L5.7148 12.857V11.4284V8.5713Z" fill="white"/>
4747 + <path d="M5.7148 12.8569H10.0006V15.7141H5.7148V12.8569Z" fill="white"/>
4748 + <path d="M5.7148 12.8569H10.0006V15.7141H5.7148V12.8569Z" fill="white"/>
4749 + </svg>
4750 + <span><?php echo esc_html( $this->get_random_premium_feature_text() ); ?></span>
4751 + </div>
4752 + <?php
4753 + $upgrade_url = Helper::get_sureforms_website_url( 'pricing', [ 'utm_medium' => 'dashboard-widget' ] );
4754 + ?>
4755 + <a href="<?php echo esc_url( $upgrade_url ); ?>" class="srfm-upgrade-link" target="_blank">
4756 + <?php esc_html_e( 'Upgrade', 'sureforms' ); ?>
4757 + </a>
4758 + </div>
4759 + <?php
4760 + }
4761 + }
4762 +
4763 + /**
4764 + * Determine if the admin pointer should be visible on this page.
4765 + *
4766 + * @since 1.8.0
4767 + * @return bool
4768 + */
4769 + private function is_admin_pointer_visible() {
4770 + global $pagenow;
4771 + $allowed_pages = [ 'index.php', 'options-general.php' ];
4772 +
4773 + // Do not show if pointer dismissed, accepted, or more than 1 form exists.
4774 + if (
4775 + ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) )
4776 + || ! empty( Helper::get_srfm_option( 'pointer_popup_accepted' ) )
4777 + || (int) ( wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0 ) > 1
4778 + ) {
4779 + return false;
4780 + }
4781 +
4782 + if ( in_array( $pagenow, $allowed_pages, true ) ) {
4783 + return true;
4784 + }
4785 +
4786 + return false;
4787 + }
4788 +
4789 + /**
4790 + * Nonced URL that dismisses one action item without JavaScript.
4791 + *
4792 + * The classic notice cannot use the AJAX dismissal the carousel uses, and
4793 + * WordPress's own `is-dismissible` only hides the notice for that pageview.
4794 + *
4795 + * @param string $item_id Item to dismiss.
4796 + * @since 2.12.6
4797 + * @return string
4798 + */
4799 + private function get_dismiss_action_item_url( $item_id ) {
4800 + return wp_nonce_url(
4801 + add_query_arg(
4802 + [
4803 + 'action' => 'srfm_dismiss_action_item_link',
4804 + 'item' => $item_id,
4805 + ],
4806 + admin_url( 'admin-post.php' )
4807 + ),
4808 + 'srfm_dismiss_action_item'
4809 + );
4810 + }
4811 +
4812 + /**
4813 + * Count one sighting of each warning, at most once per user per day.
4814 + *
4815 + * Throttled because the classic notice renders on every admin page: counting
4816 + * each render would measure how much wp-admin someone browses, not how many
4817 + * sites are affected. A day per user answers the question that matters -- how
4818 + * many people are seeing this -- for one option write.
4819 + *
4820 + * Counts SureForms' own items only. It runs before `srfm_action_items`, so a
4821 + * third party's contribution is not counted here -- SureForms has no name for
4822 + * it and no analytics key that would mean anything.
4823 + *
4824 + * @param array<int,array<string,mixed>> $warnings SureForms' own items.
4825 + * @since 2.12.6
4826 + * @return void
4827 + */
4828 + private function track_action_item_impressions( $warnings ) {
4829 + if ( empty( $warnings ) || wp_doing_ajax() ) {
4830 + return;
4831 + }
4832 +
4833 + $user_id = get_current_user_id();
4834 +
4835 + if ( ! $user_id ) {
4836 + return;
4837 + }
4838 +
4839 + $counts = Helper::get_array_value( Helper::get_srfm_option( 'action_item_impressions', [] ) );
4840 + $changed = false;
4841 +
4842 + foreach ( $warnings as $warning ) {
4843 + $item_id = Helper::get_string_value( $warning['id'] ?? '' );
4844 +
4845 + if ( '' === $item_id ) {
4846 + continue;
4847 + }
4848 +
4849 + $seen_key = 'srfm_action_item_seen_' . $item_id . '_' . $user_id;
4850 +
4851 + if ( get_transient( $seen_key ) ) {
4852 + continue;
4853 + }
4854 +
4855 + set_transient( $seen_key, 1, DAY_IN_SECONDS );
4856 +
4857 + $counts[ $item_id ] = Helper::get_integer_value( $counts[ $item_id ] ?? 0 ) + 1;
4858 + $changed = true;
4859 +
4860 + // Cumulative, so $force = true: each new count is a new value and is
4861 + // re-sent, while an identical repeat short-circuits inside track().
4862 + Analytics::events()->track(
4863 + $item_id . '_notice_shown',
4864 + (string) $counts[ $item_id ],
4865 + [],
4866 + true
4867 + );
4868 + }
4869 +
4870 + if ( $changed ) {
4871 + Helper::update_srfm_option( 'action_item_impressions', $counts );
4872 + }
4873 + }
4874 +
4875 + /**
4876 + * Record one interaction with a Form Checks notice, cumulatively.
4877 + *
4878 + * Both the value and `$force` matter. Analytics_Events::track() returns early
4879 + * when the event name is already in `usage_events_pushed`, so a call with
4880 + * `$force` omitted records each name at most once per site, ever -- the report
4881 + * could then say whether a button had ever been clicked but not how often, and
4882 + * these events exist to answer the second question. Sending a running total
4883 + * with `$force = true` re-sends each new value while an identical repeat still
4884 + * short-circuits inside track(). Same reasoning as
4885 + * track_action_item_impressions().
4886 + *
4887 + * @param string $event_name Analytics key from the allowlist.
4888 + * @since 2.12.7
4889 + * @return void
4890 + */
4891 + private function track_notice_event( $event_name ) {
4892 + $counts = Helper::get_array_value( Helper::get_srfm_option( 'action_item_events', [] ) );
4893 +
4894 + $counts[ $event_name ] = Helper::get_integer_value( $counts[ $event_name ] ?? 0 ) + 1;
4895 +
4896 + Helper::update_srfm_option( 'action_item_events', $counts );
4897 +
4898 + Analytics::events()->track( $event_name, (string) $counts[ $event_name ], [], true );
4899 + }
4900 +
4901 + /**
4902 + * The contact form's address, tagged with where the click came from.
4903 + *
4904 + * One campaign, tagged per failure, so the report answers which check actually
4905 + * sends people to support rather than only how many arrive. A submission
4906 + * failure and a caching advisory are different problems and it is worth knowing
4907 + * which one drives the tickets.
4908 + *
4909 + * Prefilled with what SureForms already knows -- the admin's address, which
4910 + * failure it is, and the site host -- so the person reporting a fault does not
4911 + * retype it. Worth knowing that the address travels in the query string, so it
4912 + * reaches browser history and any referrer along the way; it is the site
4913 + * owner's own address going to SureForms' own form, which is the flow this
4914 + * button exists for.
4915 + *
4916 + * Built with add_query_arg rather than string concatenation, so it stays
4917 + * correct if the constant ever gains a query string of its own.
4918 + *
4919 + * @param string $category One of Client_Logger::CATEGORIES, naming the failure
4920 + * the visitor is reporting.
4921 + * @since 2.12.7
4922 + * @return string
4923 + */
4924 + private function get_support_contact_url( $category ) {
4925 + // Deliberately not translated. These are matched against the options on the
4926 + // troubleshooting form, so they are machine values, not copy -- a German
4927 + // site sending "E-Mail-Benachrichtigungsfehler" would arrive as an
4928 + // unrecognised subject and land in the wrong queue.
4929 + $subjects = [
4930 + 'submission' => 'Form submission failure',
4931 + 'notification' => 'Email notification failure',
4932 + 'integration' => 'Integration failure',
4933 + ];
4934 +
4935 + $user = wp_get_current_user();
4936 +
4937 + $url = add_query_arg(
4938 + [
4939 + // Prefills the form, so the person reporting a fault does not retype
4940 + // what SureForms already knows. Empty rather than absent when the
4941 + // address is unusable, so the form still opens.
4942 + 'mail' => is_email( $user->user_email ) ? $user->user_email : '',
4943 + // Falls back to "Other" for a category SureForms does not define --
4944 + // srfm_action_items is public, so an item can carry any category or
4945 + // none.
4946 + 'subject' => $subjects[ $category ] ?? 'Other',
4947 + 'site_url' => Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ),
4948 + 'utm_source' => 'sureforms',
4949 + 'utm_medium' => 'form_checks',
4950 + 'utm_campaign' => 'contact_support',
4951 + // Which check sent them. The one part that differs per button, and
4952 + // the reason for tagging at all.
4953 + 'utm_content' => $category,
4954 + ],
4955 + self::SUPPORT_CONTACT_URL
4956 + );
4957 +
4958 + /**
4959 + * Filter where the Contact Support action sends people.
4960 + *
4961 + * Replaces the `srfm_support_email_address` filter, which pointed at an
4962 + * inbox and has no destination left to change now that the action opens a
4963 + * form. A white-label install wants to point this at its own support page.
4964 + *
4965 + * @since 2.12.7
4966 + *
4967 + * @param string $url Contact form URL, already UTM-tagged.
4968 + * @param string $category The failure being reported.
4969 + */
4970 + $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category ) );
4971 +
4972 + // Escaped after the filter, not before: the point of escaping here is that
4973 + // neither renderer has to trust what comes back. mailto: is allowed because
4974 + // an inbox is a legitimate destination for a white-label support contact,
4975 + // and get_action_items() already allows it on the sibling item URLs.
4976 + $safe = esc_url_raw( $filtered, [ 'http', 'https', 'mailto' ] );
4977 +
4978 + // Never empty. Contact Support is the only action that retires these
4979 + // notices and they are dismissible => false, so returning '' for a filter
4980 + // value that cannot survive escaping leaves an undismissable notice with
4981 + // nothing on it that works. Falling back to SureForms' own form is worse
4982 + // for a white-label than their own URL and better than a dead end, and the
4983 + // unfiltered URL is built here rather than supplied, so it always escapes.
4984 + return '' !== $safe ? $safe : esc_url_raw( $url, [ 'http', 'https' ] );
4985 + }
4986 +
4987 + /**
4988 + * The log tail, formatted for pasting.
4989 + *
4990 + * One builder, so the text someone reads before sending is the text that gets
4991 + * sent. They used to be built separately, which is how a "details" view drifts
4992 + * from what it claims to show.
4993 + *
4994 + * The budget is a parameter because nothing here is going into a URL any more.
4995 + * Client_Logger::get_tail()'s 1200-character default existed to fit a compose
4996 + * URL; a clipboard and a <pre> have no such limit, so the dialog asks for more
4997 + * and the note below describes the real constraint rather than a mail client
4998 + * that is not in this flow.
4999 + *
5000 + * @param int $max_chars Characters of log to include.
5001 + * @since 2.12.7
5002 + * @return string
5003 + */
5004 + private function get_support_log_block( $max_chars = 1200 ) {
5005 + $log = Client_Logger::get_tail( $max_chars );
5006 + $block = '---' . "\n";
5007 +
5008 + if ( '' === $log['text'] ) {
5009 + return $block . __( 'Debug log: no entries recorded.', 'sureforms' );
5010 + }
5011 +
5012 + $block .= sprintf(
5013 + /* translators: 1: entries shown, 2: entries recorded. */
5014 + __( 'Debug log (most recent %1$d of %2$d entries)', 'sureforms' ),
5015 + $log['shown'],
5016 + $log['total']
5017 + ) . "\n";
5018 +
5019 + // Fenced so it survives a reply and reads as data rather than prose wherever
5020 + // Markdown is rendered.
5021 + $block .= '```' . "\n" . $log['text'] . "\n" . '```';
5022 +
5023 + if ( $log['shown'] < $log['total'] ) {
5024 + $block .= "\n\n" . __( 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.', 'sureforms' );
5025 + }
5026 +
5027 + return $block;
5028 + }
5029 +
5030 + /**
5031 + * Subject and countless opening line for one kind of failure.
5032 + *
5033 + * Both come from here so they cannot drift apart: a subject naming one problem
5034 + * over a body describing another is worse than either alone. The counted form
5035 + * of the opening line lives in get_support_count_sentence(), which needs
5036 + * `_n()`'s literals and so cannot be an array lookup.
5037 + *
5038 + * An unknown or absent category gets deliberately neutral wording. The
5039 + * alternative -- defaulting to the submission copy -- states something specific
5040 + * that may not be true, and an item contributed through srfm_action_items has no
5041 + * category at all.
5042 + *
5043 + * @param string $category One of Client_Logger::CATEGORIES.
5044 + * @since 2.12.7
5045 + * @return array{subject:string,anon:string}
5046 + */
5047 + private function get_support_copy( $category ) {
5048 + $copy = [
5049 + 'submission' => [
5050 + /* translators: %s: site host. */
5051 + 'subject' => __( 'SureForms: form submissions are failing on %s', 'sureforms' ),
5052 + /* translators: %s: site host. */
5053 + 'anon' => __( 'SureForms has recorded form submissions on %s that could not be completed.', 'sureforms' ),
5054 + ],
5055 + 'notification' => [
5056 + /* translators: %s: site host. */
5057 + 'subject' => __( 'SureForms: notification emails are not being sent on %s', 'sureforms' ),
5058 + /* translators: %s: site host. */
5059 + 'anon' => __( 'SureForms saved entries on %s but could not send the notification emails for them.', 'sureforms' ),
5060 + ],
5061 + 'integration' => [
5062 + /* translators: %s: site host. */
5063 + 'subject' => __( 'SureForms: an integration is not receiving entries on %s', 'sureforms' ),
5064 + /* translators: %s: site host. */
5065 + 'anon' => __( 'SureForms saved entries on %s but could not pass them to a connected service.', 'sureforms' ),
5066 + ],
5067 + ];
5068 +
5069 + if ( isset( $copy[ $category ] ) ) {
5070 + return $copy[ $category ];
5071 + }
5072 +
5073 + return [
5074 + /* translators: %s: site host. */
5075 + 'subject' => __( 'SureForms: a problem with the forms on %s', 'sureforms' ),
5076 + /* translators: %s: site host. */
5077 + 'anon' => __( 'SureForms has recorded a problem with the forms on %s.', 'sureforms' ),
5078 + ];
5079 + }
5080 +
5081 + /**
5082 + * The sentence that opens the support email, with the failure count in it.
5083 + *
5084 + * A switch with literal `_n()` calls rather than a singular/plural pair looked
5085 + * up from an array. `_n()` has to see its two literals at extraction time to
5086 + * emit an `msgid_plural`, and only that lets a locale supply the number of
5087 + * forms it actually uses -- Polish and Russian need three, Arabic six,
5088 + * Japanese one. Choosing on `1 === $count` in PHP is correct for English and
5089 + * wrong everywhere with a different plural rule.
5090 + *
5091 + * @param string $category One of Client_Logger::CATEGORIES. Unknown or absent
5092 + * gets neutral wording rather than a specific claim.
5093 + * @param int $count Failures recorded for that category.
5094 + * @since 2.12.7
5095 + * @return string
5096 + */
5097 + private function get_support_count_sentence( $category, $count ) {
5098 + switch ( $category ) {
5099 + case 'submission':
5100 + return sprintf(
5101 + /* translators: %d: number of failed submissions. */
5102 + _n(
5103 + 'SureForms has recorded %d form submission that could not be completed.',
5104 + 'SureForms has recorded %d form submissions that could not be completed.',
5105 + $count,
5106 + 'sureforms'
5107 + ),
5108 + $count
5109 + );
5110 +
5111 + case 'notification':
5112 + return sprintf(
5113 + /* translators: %d: number of failed notifications. */
5114 + _n(
5115 + 'SureForms saved %d entry but could not send the notification email for it.',
5116 + 'SureForms saved %d entries but could not send the notification emails for them.',
5117 + $count,
5118 + 'sureforms'
5119 + ),
5120 + $count
5121 + );
5122 +
5123 + case 'integration':
5124 + return sprintf(
5125 + /* translators: %d: number of failed integration hand-offs. */
5126 + _n(
5127 + 'SureForms saved %d entry but could not pass it to a connected service.',
5128 + 'SureForms saved %d entries but could not pass them to a connected service.',
5129 + $count,
5130 + 'sureforms'
5131 + ),
5132 + $count
5133 + );
5134 +
5135 + default:
5136 + return sprintf(
5137 + /* translators: %d: number of recorded problems. */
5138 + _n(
5139 + 'SureForms has recorded %d problem with the forms on this site.',
5140 + 'SureForms has recorded %d problems with the forms on this site.',
5141 + $count,
5142 + 'sureforms'
5143 + ),
5144 + $count
5145 + );
5146 + }
5147 + }
5148 +
5149 + /**
5150 + * Diagnostics block for the support report.
5151 + *
5152 + * Carries what support would otherwise have to ask for, so the first reply can
5153 + * be an answer rather than a questionnaire.
5154 + *
5155 + * The count is the one for this category, not get_fault_streak(), which reports
5156 + * submissions only -- so a notification failure used to quote a number from an
5157 + * unrelated counter, often zero.
5158 + *
5159 + * @param string $category One of Client_Logger::CATEGORIES.
5160 + * @param string $form_title Form the failure was recorded against, when known.
5161 + * @since 2.12.6
5162 + * @return string
5163 + */
5164 + private function get_support_message( $category = '', $form_title = '' ) {
5165 + global $wp_version;
5166 +
5167 + $failures = Client_Logger::get_failures();
5168 + $count = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 );
5169 + $copy = $this->get_support_copy( $category );
5170 +
5171 + // With nothing recorded, describe the failure without a number. The old
5172 + // max( 1, $count ) reported "recorded 1 problem" and "Recorded failures: 1"
5173 + // for a count nobody recorded -- a number support would then chase.
5174 + $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) );
5175 +
5176 + $lines = [
5177 + __( 'Hello SureForms support,', 'sureforms' ),
5178 + '',
5179 + $count > 0
5180 + ? $this->get_support_count_sentence( $category, $count )
5181 + : sprintf( $copy['anon'], $host ),
5182 + ];
5183 +
5184 + if ( '' !== $form_title ) {
5185 + $lines[] = '';
5186 + $lines[] = sprintf(
5187 + /* translators: %s: form title. */
5188 + __( 'Form: %s', 'sureforms' ),
5189 + $form_title
5190 + );
5191 + }
5192 +
5193 + // Once: each call reads an option and a site option.
5194 + $caching = Helper::get_active_caching_plugin();
5195 +
5196 + $lines = array_merge(
5197 + $lines,
5198 + [
5199 + '',
5200 + '---',
5201 + __( 'Site details', 'sureforms' ),
5202 + // Labels translated, values not. The site owner reads this on screen
5203 + // before sending it, so the labels are copy; the values are machine
5204 + // data -- a version, a URL, a plugin name -- and stay verbatim. The
5205 + // debug log below is left alone entirely for the same reason.
5206 + /* translators: %s: site address. */
5207 + sprintf( __( 'Site: %s', 'sureforms' ), home_url() ),
5208 + /* translators: %s: SureForms version. */
5209 + sprintf( __( 'SureForms: %s', 'sureforms' ), SRFM_VER ),
5210 + sprintf(
5211 + /* translators: %s: SureForms Pro version, or a note that it is not active. */
5212 + __( 'SureForms Pro: %s', 'sureforms' ),
5213 + Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : __( 'not active', 'sureforms' )
5214 + ),
5215 + /* translators: %s: WordPress version. */
5216 + sprintf( __( 'WordPress: %s', 'sureforms' ), Helper::get_string_value( $wp_version ) ),
5217 + /* translators: %s: PHP version. */
5218 + sprintf( __( 'PHP: %s', 'sureforms' ), PHP_VERSION ),
5219 + sprintf(
5220 + /* translators: %s: caching plugin name, or a note that none was detected. */
5221 + __( 'Caching: %s', 'sureforms' ),
5222 + '' !== $caching ? $caching : __( 'none detected', 'sureforms' )
5223 + ),
5224 + sprintf(
5225 + /* translators: %s: number of recorded failures, or a note that none were. */
5226 + __( 'Recorded failures: %s', 'sureforms' ),
5227 + $count > 0 ? Helper::get_string_value( $count ) : __( 'none recorded', 'sureforms' )
5228 + ),
5229 + ]
5230 + );
5231 +
5232 + // Only when there is one. A repeat report is worth knowing about: the same
5233 + // category having been reported before means the last answer did not hold,
5234 + // which is a different conversation from a first report. Appended with the
5235 + // rest of the site details rather than raised to the top, because it is
5236 + // context for them rather than a headline.
5237 + //
5238 + // Survives only until the next success in that category, because
5239 + // clear_category() unsets the whole record -- so in practice it is
5240 + // reachable for 'integration', which has no success signal, and transient
5241 + // for the other two.
5242 + //
5243 + // Stored as time(), a UTC epoch comparable with the sibling 'at', and
5244 + // formatted here with wp_date() so it reads in the site's timezone rather
5245 + // than the server's.
5246 + $acked_at = Helper::get_integer_value( $failures[ $category ]['acked_at'] ?? 0 );
5247 +
5248 + if ( $acked_at > 0 ) {
5249 + $lines[] = sprintf(
5250 + /* translators: %s: date and time of the previous report, in the site's timezone. */
5251 + __( 'Previously reported: %s', 'sureforms' ),
5252 + Helper::get_string_value( wp_date( 'Y-m-d H:i T', $acked_at ) )
5253 + );
5254 + }
5255 +
5256 + return implode( "\n", $lines );
5257 + }
5258 +
5259 + /**
5260 + * Record one dismissal, shared by the AJAX and no-JS entry points.
5261 + *
5262 + * Allowlisted, so only advisory items can be dismissed. A run of failed
5263 + * submissions is a fault and must stay put until it actually resolves --
5264 + * otherwise a crafted request could silence the one message that matters.
5265 + *
5266 + * @param string $item_id Item to dismiss.
5267 + * @since 2.12.6
5268 + * @return bool False when the id is not dismissible.
5269 + */
5270 + private function dismiss_action_item( $item_id ) {
5271 + if ( ! in_array( $item_id, [ 'caching_plugin' ], true ) ) {
5272 + return false;
5273 + }
5274 +
5275 + $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
5276 +
5277 + if ( ! in_array( $item_id, $dismissed, true ) ) {
5278 + $dismissed[] = $item_id;
5279 + Helper::update_srfm_option( 'dismissed_action_items', $dismissed );
5280 +
5281 + // Recorded here rather than at each caller: both the cross in the
5282 + // dashboard panel and the no-JS link in the classic notice land here.
5283 + $this->track_notice_event( $item_id . '_notice_dismiss' );
5284 + }
5285 +
5286 + return true;
512 5287 }
513 5288 }