PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
sureforms / admin / admin.php

admin.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.12.8, at admin/admin.php

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