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

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

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