PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.8.0
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.8.0
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 0.0.3 All 96 releases
← All changes | admin/admin.php +140 -2515 trunk2.8.0 View file →
@@ -8,18 +8,13 @@
8 8 namespace SRFM\Admin;
9 9
10 10 use Astra_Notices;
11 11 use SRFM\Inc\AI_Form_Builder\AI_Helper;
12 -use SRFM\Inc\Client_Logger;
13 -use SRFM\Inc\Database\Register;
14 12 use SRFM\Inc\Database\Tables\Entries;
15 -use SRFM\Inc\Generate_Form_Markup;
16 -use SRFM\Inc\Global_Settings\Global_Settings;
17 13 use SRFM\Inc\Helper;
18 14 use SRFM\Inc\Onboarding;
19 15 use SRFM\Inc\Payments\Payment_Helper;
20 16 use SRFM\Inc\Payments\Stripe\Stripe_Helper;
21 -use SRFM\Inc\Smart_Tags;
22 17 use SRFM\Inc\Traits\Get_Instance;
23 18
24 19 if ( ! defined( 'ABSPATH' ) ) {
25 20 exit; // Exit if accessed directly.
@@ -24,10 +19,10 @@
24 19 if ( ! defined( 'ABSPATH' ) ) {
25 20 exit; // Exit if accessed directly.
26 21 }
27 22
28 -if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
29 - require_once SRFM_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
23 +if ( ! class_exists( 'Astra_Notices' ) ) {
24 + require_once SRFM_DIR . 'inc/lib/astra-notices/class-astra-notices.php';
30 25 }
31 26 /**
32 27 * Admin handler class.
33 28 *
@@ -43,53 +38,8 @@
43 38 */
44 39 public const RATING_NOTICE_THRESHOLD = 3;
45 40
46 41 /**
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 42 * Dashboard widget entries data.
93 43 *
94 44 * @var array
95 45 * @since 1.9.1
@@ -112,34 +62,8 @@
112 62 */
113 63 private static $sureforms_page_default_capability = 'manage_options';
114 64
115 65 /**
116 - * Request memo for the "Finish setting up" Thank You prompt (#3030).
117 - *
118 - * A static property (not a function-local static) so tests can reset it via
119 - * reflection / reset_thankyou_prompt_cache() — otherwise the first call pins
120 - * the value for the whole process and the feature is untestable.
121 - *
122 - * @var array<int,array<string,mixed>>|null
123 - * @since 2.12.4
124 - */
125 - private static $thankyou_prompt_cache = null;
126 -
127 - /**
128 - * Request memo for the dashboard setup-checklist card (#3031).
129 - *
130 - * A static property (not a function-local static) so tests can reset it via
131 - * reset_form_setup_card_cache() and exercise the populated path — a
132 - * function-local static pins the first result for the whole process. Keyed by
133 - * user id since the payload derives from that user's capabilities.
134 - * `false` means "not computed yet"; `null`/array is a computed result.
135 - *
136 - * @var array<int,array<string,mixed>|null>
137 - * @since 2.12.4
138 - */
139 - private static $setup_card_cache = [];
140 -
141 - /**
142 66 * Class constructor.
143 67 *
144 68 * @return void
145 69 * @since 0.0.1
@@ -153,9 +77,8 @@
153 77 add_action( 'admin_menu', [ $this, 'add_suremail_page' ] );
154 78 if ( ! Helper::has_pro() ) {
155 79 add_action( 'admin_menu', [ $this, 'add_quiz_page' ] );
156 80 add_action( 'admin_menu', [ $this, 'add_survey_reports_page' ] );
157 - add_action( 'admin_menu', [ $this, 'add_partial_entries_page' ] );
158 81 add_action( 'admin_menu', [ $this, 'add_upgrade_to_pro' ] );
159 82 add_action( 'admin_footer', [ $this, 'add_upgrade_to_pro_target_attr' ] );
160 83 }
161 84
@@ -169,16 +92,8 @@
169 92
170 93 add_action( 'current_screen', [ $this, 'enable_gutenberg_for_sureforms' ], 100 );
171 94 // Register notices early for React pages (before admin_enqueue_scripts).
172 95 add_action( 'admin_init', [ $this, 'register_pro_compatibility_notices' ], 5 );
173 -
174 - // Database maintenance notice: the entries table is missing, so submissions
175 - // cannot be saved. Registered at admin_init priority 5 so the React notice is
176 - // in place before admin_enqueue_scripts localizes it.
177 - add_action( 'admin_init', [ $this, 'register_database_repair_notice' ], 5 );
178 - add_action( 'admin_notices', [ $this, 'render_action_item_notices' ] );
179 - add_action( 'admin_notices', [ $this, 'render_database_repair_notice' ] );
180 - add_action( 'admin_post_srfm_repair_entries_table', [ $this, 'handle_database_repair' ] );
181 96 // Display notices on traditional WordPress admin pages.
182 97 add_action( 'admin_notices', [ $this, 'srfm_pro_version_compatibility' ] );
183 98
184 99 // Enfold theme compatibility to enable block editor for SureForms post type.
@@ -201,114 +116,19 @@
201 116 add_action( 'wp_ajax_should_show_pointer', [ $this, 'pointer_should_show' ] );
202 117 add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] );
203 118 add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] );
204 119 add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] );
205 - add_action( 'wp_ajax_srfm_dismiss_action_item', [ $this, 'handle_dismiss_action_item' ] );
206 - add_action( 'admin_post_srfm_dismiss_action_item_link', [ $this, 'handle_dismiss_action_item_link' ] );
207 - add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] );
208 - add_action( 'load-post.php', [ $this, 'maybe_track_edit_form_button_click' ] );
209 - add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] );
210 120
211 121 // Register dashboard widget only if there are recent entries.
212 122 add_action( 'admin_init', [ $this, 'maybe_register_dashboard_widget' ] );
213 123
214 - // Enqueue the AI quick draft widget script on the dashboard screen.
215 - add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_ai_dashboard_widget_assets' ] );
216 -
217 - // "Finish setting up" checklist widget on the main WP dashboard (#3031).
218 - add_action( 'wp_dashboard_setup', [ $this, 'register_form_setup_widget' ] );
219 - add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_form_setup_widget_assets' ] );
220 -
221 - // Drop the "no imported forms" negative cache as soon as a post is stamped
222 - // with the import marker, so a template imported after the cache was written
223 - // surfaces immediately instead of waiting for the transient to expire.
224 - add_action( 'added_post_meta', [ $this, 'invalidate_starter_template_cache' ], 10, 3 );
225 - add_action( 'updated_post_meta', [ $this, 'invalidate_starter_template_cache' ], 10, 3 );
226 -
227 124 // Save first form creation time stamp.
228 125 add_action( 'admin_init', [ $this, 'save_first_form_creation_time_stamp' ] );
229 126 add_action( 'admin_notices', [ $this, 'display_srfm_rating_notice' ] );
230 127 add_action( 'admin_notices', [ $this, 'display_srfm_getting_started_notice' ] );
231 -
232 - // "Finish setting up" prompt, shown as an Astra Notices admin notice on
233 - // every admin screen except the dashboard (#3030).
234 - add_action( 'admin_notices', [ $this, 'render_thankyou_prompt_notice' ] );
235 -
236 - /**
237 - * Suppress foreign (third-party) admin notices on SureForms admin screens.
238 - *
239 - * Some plugins (e.g. Ninja Forms) print large promotional banners on every
240 - * admin page via the admin_notices / all_admin_notices / network_admin_notices
241 - * hooks. These bleed onto SureForms' own React admin screens and break the UI.
242 - * We run at the EARLIEST priority on each notice hook (all third-party
243 - * callbacks are registered before these hooks fire, during admin_init /
244 - * plugin load) and strip the foreign ones before they are echoed, while
245 - * preserving SureForms' own notices. Scoped strictly to SureForms screens.
246 - */
247 - add_action( 'admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
248 - add_action( 'all_admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
249 - add_action( 'network_admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
250 128 }
251 129
252 130 /**
253 - * Remove third-party admin notices on SureForms admin screens.
254 - *
255 - * Iterates over the callbacks registered on the admin notice hooks and
256 - * removes any that are not owned by SureForms. A callback is considered
257 - * owned by SureForms when it belongs to a class in the `SRFM` / `SRFM_PRO`
258 - * namespaces or to the bundled `BSF_Admin_Notices` / `Astra_Notices`
259 - * notices library. SureForms' own notices are therefore preserved while
260 - * foreign promotional banners are suppressed.
261 - *
262 - * This callback is hooked at `PHP_INT_MIN` so that it runs first on each
263 - * notice hook and removes the foreign callbacks before WordPress echoes
264 - * them (WP_Hook honours removals made during iteration). It is strictly
265 - * scoped to SureForms admin screens via {@see Helper::is_sureforms_admin_page()}
266 - * so no other admin page is affected.
267 - *
268 - * @since 2.10.0
269 - * @return void
270 - */
271 - public function suppress_foreign_admin_notices() {
272 - // Bail early if we are not on a SureForms admin screen. This keeps the
273 - // suppression strictly scoped and avoids touching any other admin page.
274 - // is_sureforms_admin_page() covers the core screens (dashboard, add-new,
275 - // settings, entries, the form CPT); we additionally match any admin page
276 - // whose `page` slug is SureForms-owned (sureforms_* / srfm_*) so the
277 - // suppression also applies to the payments/quiz/survey/learn/SMTP screens.
278 - if ( ! Helper::is_sureforms_admin_page() && ! $this->is_sureforms_owned_admin_page() ) {
279 - return;
280 - }
281 -
282 - global $wp_filter;
283 -
284 - // The hook currently being fired (admin_notices, all_admin_notices or network_admin_notices).
285 - $current_hook = current_action();
286 -
287 - if ( empty( $current_hook ) || empty( $wp_filter[ $current_hook ] ) || ! ( $wp_filter[ $current_hook ] instanceof \WP_Hook ) ) {
288 - return;
289 - }
290 -
291 - foreach ( $wp_filter[ $current_hook ]->callbacks as $priority => $callbacks ) {
292 - foreach ( $callbacks as $callback ) {
293 - $function = $callback['function'] ?? null;
294 -
295 - // Never remove our own suppression callback.
296 - if ( is_array( $function ) && isset( $function[0] ) && $function[0] === $this && 'suppress_foreign_admin_notices' === $function[1] ) {
297 - continue;
298 - }
299 -
300 - // Preserve SureForms-owned notices, remove everything else.
301 - if ( $this->is_sureforms_owned_notice_callback( $function ) ) {
302 - continue;
303 - }
304 -
305 - remove_action( $current_hook, $function, $priority );
306 - }
307 - }
308 - }
309 -
310 - /**
311 131 * Get the first form creation time stamp.
312 132 *
313 133 * @since 1.10.1
314 134 * @return int|false
@@ -336,665 +156,8 @@
336 156 return is_int( $first_form_creation_time_stamp ) && $first_form_creation_time_stamp > 0;
337 157 }
338 158
339 159 /**
340 - * Whether a form's confirmation message is still the shipped default.
341 - *
342 - * Compared on tag-stripped, entity-decoded, whitespace-collapsed text rather
343 - * than raw HTML: the default is stored with a base64 icon on creation but
344 - * regenerated with a URL icon, so the markup differs while the wording does
345 - * not, and a starter-template import can store a literal apostrophe where the
346 - * generated default carries the encoded `&#039;` — decoding entities makes both
347 - * compare equal. Any real edit to the heading or body text changes the text and
348 - * flips this to false, which is exactly when the prompt should stop showing.
349 - *
350 - * Locale caveat: the comparison target is translated at call time, so a form
351 - * whose default was stored under a different active locale won't match. That
352 - * fails safe — the prompt simply doesn't show — never a false nag.
353 - *
354 - * @param int $form_id Form post ID.
355 - *
356 - * @since 2.12.4
357 - * @return bool
358 - */
359 - public static function is_default_confirmation_message( $form_id ) {
360 - $confirmation = get_post_meta( (int) $form_id, '_srfm_form_confirmation', true );
361 -
362 - if ( ! is_array( $confirmation ) || ! isset( $confirmation[0]['message'] ) || ! is_string( $confirmation[0]['message'] ) ) {
363 - return false;
364 - }
365 -
366 - // The default message is only ever shown for a "same page" confirmation.
367 - // A redirect ("different page" / "custom url") never renders it, yet the
368 - // stored settings still seed the default message string — so without this
369 - // guard a redirect form would be nagged forever about a message no visitor
370 - // sees, with no way to clear the prompt by doing what it asks.
371 - if ( ! isset( $confirmation[0]['confirmation_type'] ) || 'same page' !== $confirmation[0]['confirmation_type'] ) {
372 - return false;
373 - }
374 -
375 - $message = $confirmation[0]['message'];
376 -
377 - if ( '' === trim( $message ) ) {
378 - return false;
379 - }
380 -
381 - $normalize = static function ( $html ) {
382 - // Decode entities too, so an encoded apostrophe (&#039;) in the generated
383 - // default matches a literal one stored by a template import.
384 - $text = html_entity_decode( wp_strip_all_tags( (string) $html ), ENT_QUOTES, 'UTF-8' );
385 - return trim( (string) preg_replace( '/\s+/', ' ', $text ) );
386 - };
387 -
388 - return $normalize( $message ) === $normalize( Global_Settings::get_default_confirmation_message() );
389 - }
390 -
391 - /**
392 - * Whether a form has somewhere to send replies (an enabled email notification
393 - * with a non-empty recipient).
394 - *
395 - * @param int $form_id Form post ID.
396 - *
397 - * @since 2.12.4
398 - * @return bool
399 - */
400 - public static function form_has_reply_destination( $form_id ) {
401 - $notifications = get_post_meta( (int) $form_id, '_srfm_email_notification', true );
402 -
403 - if ( ! is_array( $notifications ) ) {
404 - return false;
405 - }
406 -
407 - foreach ( $notifications as $notification ) {
408 - if ( is_array( $notification ) && ! empty( $notification['status'] ) && ! empty( $notification['email_to'] ) ) {
409 - return true;
410 - }
411 - }
412 -
413 - return false;
414 - }
415 -
416 - /**
417 - * The most recently created form still needing setup (default Thank You
418 - * message, or no reply destination).
419 - *
420 - * Powers the "Finish setting up" prompt (#3030). Limited to the single latest
421 - * such form to avoid clutter, and to forms the current user may actually edit.
422 - * A form is a candidate when it still has an unfinished step (default Thank You
423 - * message, or no reply destination). Dismissal is enforced by the caller,
424 - * before this query runs.
425 - *
426 - * @since 2.12.4
427 - * @return array<int,array<string,mixed>> One entry, or none.
428 - */
429 - public static function get_thankyou_prompt_forms() {
430 - // Memoized for the request so repeated reads (e.g. the notice render plus
431 - // any add-on consumer) share a single query. Sentinel is null, not false,
432 - // so a filter returning false (__return_false to disable) still memoizes.
433 - if ( null !== self::$thankyou_prompt_cache ) {
434 - return self::$thankyou_prompt_cache;
435 - }
436 -
437 - /**
438 - * Filter the forms the "Finish setting up" Thank You notice may surface.
439 - *
440 - * @param array<int,array<string,mixed>> $prompts Candidate prompt payloads.
441 - *
442 - * @since 2.12.4
443 - */
444 - $filtered = apply_filters( 'srfm_thankyou_prompt_forms', self::compute_thankyou_prompt_forms() );
445 - self::$thankyou_prompt_cache = is_array( $filtered ) ? $filtered : [];
446 -
447 - return self::$thankyou_prompt_cache;
448 - }
449 -
450 - /**
451 - * Clear the request memo for the Thank You prompt (#3030).
452 - *
453 - * Lets tests exercise the memoized public path, and is a safe hook for anything
454 - * that changes which form qualifies (e.g. a form save).
455 - *
456 - * @since 2.12.4
457 - * @return void
458 - */
459 - public static function reset_thankyou_prompt_cache() {
460 - self::$thankyou_prompt_cache = null;
461 - }
462 -
463 - /**
464 - * Setup-checklist data for the newest starter-template form (#3031).
465 - *
466 - * Picks the most recent form the current user can edit that was created from an
467 - * Astra Sites starter template. The widget
468 - * lists a fixed set of optional next-steps for it — their completion is not
469 - * computed — so the payload carries only the form and the CTA targets. Memoized
470 - * for the request so the widget register/enqueue/render passes share one query.
471 - *
472 - * @since 2.12.4
473 - * @return array<string,mixed>|null Card payload, or null when there is no candidate form.
474 - */
475 - public static function get_form_setup_card() {
476 - $user_id = get_current_user_id();
477 -
478 - // Request memo, keyed per user — the payload derives from that user's
479 - // capabilities. Reset via reset_form_setup_card_cache().
480 - if ( array_key_exists( $user_id, self::$setup_card_cache ) ) {
481 - return self::$setup_card_cache[ $user_id ];
482 - }
483 -
484 - self::$setup_card_cache[ $user_id ] = self::compute_form_setup_card();
485 -
486 - return self::$setup_card_cache[ $user_id ];
487 - }
488 -
489 - /**
490 - * Drop the "no imported forms" negative cache when the marker is written.
491 - *
492 - * Hooked to added_post_meta/updated_post_meta. Without this, a starter template
493 - * imported after the negative cache was written would show neither the Thank You
494 - * prompt nor the setup widget until the transient expired.
495 - *
496 - * Arguments are read from func_get_args() rather than declared: the hook passes
497 - * ( $meta_id, $post_id, $meta_key ) and the meta id is never needed, so declaring
498 - * it would leave an unused parameter that the coding-standards gate rejects.
499 - *
500 - * @since 2.12.4
501 - * @return void
502 - */
503 - public function invalidate_starter_template_cache() {
504 - $args = func_get_args();
505 - $post_id = isset( $args[1] ) ? (int) $args[1] : 0;
506 - $meta_key = isset( $args[2] ) ? (string) $args[2] : '';
507 -
508 - if ( self::ASTRA_SITES_IMPORT_META !== $meta_key ) {
509 - return;
510 - }
511 -
512 - // A full-site import stamps this marker on every post it creates, so narrow to
513 - // our own post type: both features only ever query sureforms_form, and this
514 - // avoids clearing the cache repeatedly for pages and products during an import.
515 - if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || SRFM_FORMS_POST_TYPE !== get_post_type( $post_id ) ) {
516 - return;
517 - }
518 -
519 - delete_transient( self::NO_IMPORTED_FORMS_TRANSIENT );
520 - }
521 -
522 - /**
523 - * Clear the setup-card request memo (#3031).
524 - *
525 - * Lets tests exercise the populated path, and is a safe hook for anything that
526 - * changes which form qualifies (e.g. a form save).
527 - *
528 - * @since 2.12.4
529 - * @return void
530 - */
531 - public static function reset_form_setup_card_cache() {
532 - self::$setup_card_cache = [];
533 - }
534 -
535 - /**
536 - * REST handler: record a "Finish setting up" widget interaction (#3031).
537 - *
538 - * Records the clicked CTA/view action as an analytics event. The request
539 - * carries the displayed form id: capability is re-checked against it here —
540 - * beyond the route's generic permission callback — so only a genuine editor of
541 - * that form can act.
542 - *
543 - * @param \WP_REST_Request<array<string,mixed>> $request Request.
544 - *
545 - * @since 2.12.4
546 - * @return \WP_REST_Response|\WP_Error
547 - */
548 - public function dismiss_form_setup_card( $request ) {
549 - $form_id = absint( $request->get_param( 'form_id' ) );
550 -
551 - 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 ) ) {
552 - return new \WP_Error( 'srfm_setup_card_forbidden', __( 'You are not allowed to update this prompt.', 'sureforms' ), [ 'status' => 403 ] );
553 - }
554 -
555 - $action = sanitize_key( (string) $request->get_param( 'action' ) );
556 -
557 - // Interaction analytics for the setup widget (#3031). Each event carries a
558 - // date automatically (see BSF_Analytics_Events::track()) and dedupes per
559 - // event name, matching the sibling notice's telemetry.
560 - $events = [
561 - 'edit_form' => 'form_setup_widget_edit_form',
562 - 'edit_thankyou' => 'form_setup_widget_edit_thankyou',
563 - 'set_up_email' => 'form_setup_widget_set_up_email',
564 - 'view_form' => 'form_setup_widget_view_form',
565 - ];
566 -
567 - if ( isset( $events[ $action ] ) ) {
568 - Analytics::events()->track( $events[ $action ], (string) $form_id );
569 - }
570 -
571 - return new \WP_REST_Response( [ 'success' => true ], 200 );
572 - }
573 -
574 - /**
575 - * Register the "Finish setting up" checklist widget on the main WP dashboard (#3031).
576 - *
577 - * Only for capable users, and only when there is a form still needing setup —
578 - * so the widget never appears empty. The data is memoized in get_form_setup_card()
579 - * and reused by the enqueue and render passes.
580 - *
581 - * @since 2.12.4
582 - * @return void
583 - */
584 - public function register_form_setup_widget() {
585 - if ( ! Helper::current_user_can() ) {
586 - return;
587 - }
588 -
589 - if ( null === self::get_form_setup_card() ) {
590 - return;
591 - }
592 -
593 - wp_add_dashboard_widget(
594 - 'srfm_form_setup_checklist',
595 - __( 'Finish setting up your form', 'sureforms' ),
596 - [ $this, 'render_form_setup_widget' ],
597 - null,
598 - null,
599 - 'normal',
600 - 'high'
601 - );
602 - }
603 -
604 - /**
605 - * Render the setup-checklist widget content (#3031).
606 - *
607 - * A heading (with a link to view the form), a subtitle, and a fixed list of
608 - * optional next-steps — each always shown with its CTA; completion is not
609 - * computed. Each CTA deep-links into the editor (edit form / Thank You message /
610 - * email notification) and records an analytics event via the REST endpoint
611 - * wired in the enqueued inline script.
612 - *
613 - * @since 2.12.4
614 - * @return void
615 - */
616 - public function render_form_setup_widget() {
617 - $card = self::get_form_setup_card();
618 -
619 - if ( null === $card ) {
620 - return;
621 - }
622 -
623 - // Optional next-steps — always offered, their completion is not computed.
624 - // 'event' is the analytics action key beaconed on click (see the widget JS).
625 - $rows = [
626 - [
627 - 'label' => __( 'Review or edit your form', 'sureforms' ),
628 - 'cta' => __( 'Edit form', 'sureforms' ),
629 - 'url' => $card['edit_url'],
630 - 'event' => 'edit_form',
631 - ],
632 - [
633 - 'label' => __( 'Personalize the Thank You message', 'sureforms' ),
634 - 'cta' => __( 'Edit message', 'sureforms' ),
635 - 'url' => $card['thankyou_url'],
636 - 'event' => 'edit_thankyou',
637 - ],
638 - [
639 - 'label' => __( 'Choose who gets notified of new replies', 'sureforms' ),
640 - 'cta' => __( 'Set up email', 'sureforms' ),
641 - 'url' => $card['email_url'],
642 - 'event' => 'set_up_email',
643 - ],
644 - ];
645 -
646 - // Fall back to a generic label for an untitled form so the heading never
647 - // renders "Finish setting up " with a dangling space.
648 - $card_title = '' !== trim( (string) $card['title'] ) ? $card['title'] : __( 'your form', 'sureforms' );
649 - $heading = sprintf(
650 - /* translators: %s: form title. */
651 - __( 'Finish setting up %s', 'sureforms' ),
652 - $card_title
653 - );
654 - ?>
655 - <div class="srfm-setup-checklist" id="srfm-setup-checklist">
656 - <p class="srfm-setup-checklist__title">
657 - <?php echo esc_html( $heading ); ?>
658 - <?php if ( ! empty( $card['view_url'] ) ) { ?>
659 - <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 ) ); ?>">
660 - <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>
661 - </a>
662 - <?php } ?>
663 - </p>
664 - <p class="srfm-setup-checklist__subtitle"><?php esc_html_e( 'Customize your form to get it ready for real submissions:', 'sureforms' ); ?></p>
665 -
666 - <ul class="srfm-setup-checklist__steps">
667 - <?php foreach ( $rows as $row ) { ?>
668 - <li class="srfm-setup-checklist__step">
669 - <span class="srfm-setup-checklist__label"><?php echo esc_html( $row['label'] ); ?></span>
670 - <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>
671 - </li>
672 - <?php } ?>
673 - </ul>
674 - </div>
675 - <?php
676 - }
677 -
678 - /**
679 - * Enqueue the setup-checklist widget's styles and behavior on the dashboard (#3031).
680 - *
681 - * Mirrors the AI widget convention: an inline-only handle carries the CSS and the
682 - * behavior (CTA click analytics), with server values —
683 - * the REST URL, nonce and form id — passed through wp_localize_script rather than
684 - * printed into the markup, so it stays Plugin-Check clean.
685 - *
686 - * @param string $hook_suffix Current admin page hook suffix.
687 - *
688 - * @since 2.12.4
689 - * @return void
690 - */
691 - public function enqueue_form_setup_widget_assets( $hook_suffix ) {
692 - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
693 - return;
694 - }
695 -
696 - $card = self::get_form_setup_card();
697 -
698 - if ( null === $card ) {
699 - return;
700 - }
701 -
702 - $css = <<<'CSS'
703 -#srfm_form_setup_checklist .inside { margin: 0; padding: 0; }
704 -.srfm-setup-checklist { padding: 12px 16px 16px; }
705 -.srfm-setup-checklist__title { margin: 0 0 4px; font-size: 15px; font-weight: 600; color: #1e1e1e; }
706 -.srfm-setup-checklist__view { display: inline-flex; align-items: center; margin-left: 6px; color: #d54e21; vertical-align: middle; }
707 -.srfm-setup-checklist__view:hover, .srfm-setup-checklist__view:focus { color: #b83c14; }
708 -.srfm-setup-checklist__subtitle { margin: 0 0 12px; color: #646970; font-size: 13px; }
709 -.srfm-setup-checklist__steps { margin: 0; padding: 0; list-style: none; }
710 -.srfm-setup-checklist__step { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; }
711 -.srfm-setup-checklist__step + .srfm-setup-checklist__step { margin-top: 6px; }
712 -.srfm-setup-checklist__step { background: #f6f7f7; }
713 -.srfm-setup-checklist__label { flex: 1 1 auto; font-size: 14px; color: #1e1e1e; }
714 -.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; }
715 -.srfm-setup-checklist__cta:hover { color: #b83c14; }
716 -/* Keep visited links on-brand — WP admin's a:visited would otherwise turn them blue. */
717 -.srfm-setup-checklist a:visited { color: #d54e21; }
718 -.srfm-setup-checklist a:visited:hover, .srfm-setup-checklist a:visited:focus { color: #b83c14; }
719 -/* Drop WP's blue focus ring on the widget's links; keep an accessible, on-brand keyboard outline. */
720 -.srfm-setup-checklist a:focus { outline: none; box-shadow: none; }
721 -.srfm-setup-checklist a:focus-visible { outline: 2px solid #d54e21; outline-offset: 2px; box-shadow: none; }
722 -CSS;
723 -
724 - wp_register_style( 'srfm-setup-checklist-widget', false, [], SRFM_VER );
725 - wp_enqueue_style( 'srfm-setup-checklist-widget' );
726 - wp_add_inline_style( 'srfm-setup-checklist-widget', $css );
727 -
728 - wp_register_script( 'srfm-setup-checklist-widget', '', [], SRFM_VER, true );
729 - wp_enqueue_script( 'srfm-setup-checklist-widget' );
730 -
731 - wp_localize_script(
732 - 'srfm-setup-checklist-widget',
733 - 'srfmSetupChecklist',
734 - [
735 - 'restUrl' => esc_url_raw( rest_url( 'sureforms/v1/dismiss-form-setup-card' ) ),
736 - 'nonce' => wp_create_nonce( 'wp_rest' ),
737 - 'formId' => $card['id'],
738 - ]
739 - );
740 -
741 - $inline_script = <<<'JS'
742 -( function () {
743 - const cfg = window.srfmSetupChecklist || {};
744 - const widget = document.getElementById( 'srfm-setup-checklist' );
745 - if ( ! widget ) {
746 - return;
747 - }
748 -
749 - const persist = function ( action ) {
750 - return fetch( cfg.restUrl, {
751 - method: 'POST',
752 - credentials: 'same-origin',
753 - keepalive: true,
754 - headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
755 - body: JSON.stringify( { form_id: cfg.formId, action: action } ),
756 - } ).catch( function () {} );
757 - };
758 -
759 - // Beacon the CTA / view-form clicks for analytics. keepalive on the fetch lets
760 - // the request finish even though the CTA immediately navigates away.
761 - widget.addEventListener( 'click', function ( e ) {
762 - const target = e.target?.closest?.( '[data-srfm-event]' );
763 - if ( target ) {
764 - persist( target.getAttribute( 'data-srfm-event' ) );
765 - }
766 - } );
767 -}() );
768 -JS;
769 -
770 - wp_add_inline_script( 'srfm-setup-checklist-widget', $inline_script );
771 - }
772 -
773 - /**
774 - * Register the "Finish setting up" prompt as an Astra Notices admin notice (#3030).
775 - *
776 - * Hooked to admin_notices so it registers before the Astra Notices library
777 - * renders (priority 30). Shown on every admin screen EXCEPT the main dashboard,
778 - * for the newest form the current user can edit that still has an unfinished
779 - * step (default Thank You message, or no reply destination). Uses a single
780 - * stable notice id so the library's built-in ✕ dismissal is one persistent
781 - * choice ("stop nudging me"), not a per-form row.
782 - *
783 - * Split out from the renderer so the decision has exactly one home. The Getting
784 - * Started notice suppresses itself when this returns a form, and duplicating the
785 - * conditions there would have meant two copies drifting apart. Reading it costs
786 - * nothing extra — get_thankyou_prompt_forms() memoizes its query per request.
787 - *
788 - * @since 2.12.6
789 - * @return array<string,mixed>|null The form to prompt for, or null when no prompt should render.
790 - */
791 - public function get_displayable_thankyou_prompt() {
792 - if ( ! Helper::current_user_can() || ! class_exists( 'Astra_Notices' ) ) {
793 - return null;
794 - }
795 -
796 - /**
797 - * Short-circuit the "Finish setting up" Thank You notice.
798 - *
799 - * @param bool $show Whether to show the notice. Default true.
800 - *
801 - * @since 2.12.4
802 - */
803 - if ( ! apply_filters( 'srfm_show_thankyou_prompt', true ) ) {
804 - return null;
805 - }
806 -
807 - // Everywhere in wp-admin except the main dashboard. A null screen fails
808 - // closed (return) rather than registering the notice on an unknown screen.
809 - $screen = get_current_screen();
810 -
811 - if ( ! $screen || 'dashboard' === $screen->id ) {
812 - return null;
813 - }
814 -
815 - // The library only checks dismissal at render (priority 30, after this
816 - // query would already have run). Check it up front so a user who dismissed
817 - // the prompt never pays for the WP_Query on subsequent admin page views.
818 - if ( 'notice-dismissed' === get_user_meta( get_current_user_id(), self::THANKYOU_PROMPT_NOTICE_ID, true ) ) {
819 - return null;
820 - }
821 -
822 - // array_values so a filter returning a key-preserving array (e.g. the
823 - // result of array_filter()) still exposes the newest prompt at index 0.
824 - $prompts = array_values( (array) self::get_thankyou_prompt_forms() );
825 -
826 - // Validate every key build_thankyou_notice_markup() reads, not just id/edit_url
827 - // — a filter returning a partial payload would otherwise trip "Undefined array
828 - // key" warnings and esc_url( null ) deprecations on every admin page.
829 - if (
830 - empty( $prompts[0] ) || ! is_array( $prompts[0] )
831 - || empty( $prompts[0]['id'] ) || empty( $prompts[0]['edit_url'] )
832 - || empty( $prompts[0]['thankyou_url'] ) || empty( $prompts[0]['replies_url'] )
833 - || ! isset( $prompts[0]['title'] )
834 - ) {
835 - return null;
836 - }
837 -
838 - return $prompts[0];
839 - }
840 -
841 - /**
842 - * Render the "Finish setting up" Thank You notice (#3030).
843 - *
844 - * @since 2.12.4
845 - * @return void
846 - */
847 - public function render_thankyou_prompt_notice() {
848 - $notice_id = self::THANKYOU_PROMPT_NOTICE_ID;
849 - $form = $this->get_displayable_thankyou_prompt();
850 -
851 - if ( null === $form ) {
852 - return;
853 - }
854 -
855 - // A broken form outranks a setup prompt. This is the top of the existing
856 - // precedence chain, so the action-item check goes here rather than the
857 - // action items standing down for an engagement notice.
858 - if ( $this->has_action_item_warnings() ) {
859 - return;
860 - }
861 -
862 - \Astra_Notices::add_notice(
863 - [
864 - 'id' => $notice_id,
865 - 'type' => 'info',
866 - 'message' => self::build_thankyou_notice_markup( $form ),
867 - 'class' => 'srfm-notice srfm-thankyou-notice',
868 - 'is_dismissible' => true,
869 - 'display-with-other-notices' => true,
870 - // Render late so this nudge never pre-empts higher-priority notices
871 - // (e.g. Astra's minimum-version warnings, which are display-with-
872 - // other-notices => false and would be skipped once ours renders).
873 - 'priority' => 100,
874 - ]
875 - );
876 -
877 - // The message is wp_kses_post'd by the library, so the brand-orange styling
878 - // is printed through the notice's pre-markup hook instead of inline.
879 - add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
880 -
881 - // Track clicks on the CTAs and the dismiss ✕ via the shared notice-response
882 - // endpoint, enqueued only when the notice actually renders.
883 - add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_thankyou_notice_tracking' ] );
884 - }
885 -
886 - /**
887 - * Enqueue the click-tracking for the Thank You notice (#3030).
888 - *
889 - * Sends an analytics beacon to the shared `srfm_notice_response` AJAX handler
890 - * when a CTA or the dismiss ✕ is clicked. Uses `keepalive` so the beacon
891 - * survives the navigation the CTA links trigger.
892 - *
893 - * @since 2.12.4
894 - * @return void
895 - */
896 - public function enqueue_thankyou_notice_tracking() {
897 - if ( wp_script_is( 'srfm-thankyou-notice-track', 'enqueued' ) ) {
898 - return;
899 - }
900 -
901 - wp_register_script( 'srfm-thankyou-notice-track', '', [], SRFM_VER, true );
902 - wp_enqueue_script( 'srfm-thankyou-notice-track' );
903 -
904 - $config = wp_json_encode(
905 - [
906 - 'ajaxurl' => admin_url( 'admin-ajax.php' ),
907 - 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
908 - ]
909 - );
910 -
911 - wp_add_inline_script( 'srfm-thankyou-notice-track', 'window.srfmThankYouNoticeTrack = ' . $config . ';', 'before' );
912 -
913 - $inline_script = <<<'JS'
914 -( function () {
915 - const cfg = window.srfmThankYouNoticeTrack || {};
916 - const wrap = document.querySelector( '.srfm-thankyou-notice' );
917 - if ( ! wrap ) {
918 - return;
919 - }
920 - const noticeId = wrap.id || '';
921 - const send = function ( button ) {
922 - const body = new URLSearchParams();
923 - body.append( 'action', 'srfm_notice_response' );
924 - body.append( 'nonce', cfg.nonce );
925 - body.append( 'notice_id', noticeId );
926 - body.append( 'button', button );
927 - fetch( cfg.ajaxurl, {
928 - method: 'POST',
929 - credentials: 'same-origin',
930 - keepalive: true,
931 - headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
932 - body: body.toString(),
933 - } ).catch( function () {} );
934 - };
935 - // Delegate from the wrapper: this inline script runs at parse time, before
936 - // core's common.js injects the .notice-dismiss ✕ (on DOMContentLoaded), so a
937 - // direct querySelector for it would find nothing and the dismiss beacon would
938 - // never fire. Delegation catches the ✕ and the CTAs whenever they exist.
939 - const ctas = [
940 - [ '.srfm-ty-edit-form', 'edit_form' ],
941 - [ '.srfm-ty-set-replies', 'set_replies' ],
942 - [ '.srfm-ty-edit-thankyou', 'edit_thankyou' ],
943 - ];
944 - wrap.addEventListener( 'click', function ( e ) {
945 - if ( e.target.closest( '.notice-dismiss' ) ) {
946 - send( 'dismissed' );
947 - return;
948 - }
949 - for ( let i = 0; i < ctas.length; i++ ) {
950 - if ( e.target.closest( ctas[ i ][ 0 ] ) ) {
951 - send( ctas[ i ][ 1 ] );
952 - return;
953 - }
954 - }
955 - } );
956 -}() );
957 -JS;
958 -
959 - wp_add_inline_script( 'srfm-thankyou-notice-track', $inline_script );
960 - }
961 -
962 - /**
963 - * Print the Thank You notice's brand-orange styling (#3030).
964 - *
965 - * Fired via astra_notice_before_markup_{id} so it lands right before the notice
966 - * and only when the notice actually renders.
967 - *
968 - * @since 2.12.4
969 - * @return void
970 - */
971 - public function print_srfm_notice_styles() {
972 - // The library wp_kses_post()'s the message, which strips <svg> and data:
973 - // image srcs, so the SureForms mark is painted as a CSS background here
974 - // (this hook fires outside that kses call). URL-encoded, not base64, so the
975 - // value is fully percent-encoded and safe to pass through esc_url.
976 - $icon = 'data:image/svg+xml,' . rawurlencode(
977 - '<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>'
978 - );
979 - ?>
980 - <style id="srfm-notice-styles">
981 - .srfm-notice.notice { border-left-color: #D54407; }
982 - /* Stack our blocks (the library lays the container out as a flex row) and reserve room on the left for the SureForms mark. */
983 - .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; }
984 - .srfm-notice .srfm-notice__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: #1d2327; }
985 - .srfm-notice .srfm-notice__text { margin: 0 0 10px; color: #50575e; }
986 - .srfm-notice .srfm-notice__actions { margin: 12px 0 2px; display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; }
987 - .srfm-notice .button-primary { background: #D54407; border-color: #D54407; color: #fff; box-shadow: none; text-shadow: none; }
988 - .srfm-notice .button-primary:hover, .srfm-notice .button-primary:focus { background: #C83B00; border-color: #C83B00; color: #fff; box-shadow: none; }
989 - .srfm-notice .button:not(.button-primary) { background: transparent; border-color: transparent; color: #D54407; box-shadow: none; padding: 0; }
990 - .srfm-notice .button:not(.button-primary):hover, .srfm-notice .button:not(.button-primary):focus { background: transparent; border-color: transparent; color: #C83B00; box-shadow: none; }
991 - .srfm-notice .button-primary:focus { outline: 2px solid #D54407; outline-offset: 1px; }
992 - </style>
993 - <?php
994 - }
995 -
996 - /**
997 160 * Check and save the first form creation time stamp.
998 161 * If not already saved.
999 162 *
1000 163 * @since 1.10.1
@@ -1066,9 +229,14 @@
1066 229 */
1067 230 public function add_action_links( $links ) {
1068 231 if ( ! Helper::has_pro() ) {
1069 232 // Display upsell link if SureForms Pro is not installed.
1070 - $upsell_link = Helper::get_sureforms_website_url( 'pricing', [ 'utm_medium' => 'plugin-list' ] );
233 + $upsell_link = add_query_arg(
234 + [
235 + 'utm_medium' => 'plugin-list',
236 + ],
237 + Helper::get_sureforms_website_url( 'pricing' )
238 + );
1071 239
1072 240 ob_start();
1073 241 ?>
1074 242 <a href="<?php echo esc_url( $upsell_link ); ?>" target="_blank" rel="noreferrer" class="sureforms-plugins-go-pro">
@@ -1228,9 +396,14 @@
1228 396 */
1229 397 public function add_upgrade_to_pro() {
1230 398 // The url used here is used as a selector for css to style the upgrade to pro submenu.
1231 399 // If you are changing this url, please make sure to update the css as well.
1232 - $upgrade_url = Helper::get_sureforms_website_url( 'upgrade', [ 'utm_medium' => 'submenu_link_upgrade' ] );
400 + $upgrade_url = add_query_arg(
401 + [
402 + 'utm_medium' => 'submenu_link_upgrade',
403 + ],
404 + Helper::get_sureforms_website_url( 'upgrade' )
405 + );
1233 406
1234 407 add_submenu_page(
1235 408 'sureforms_menu',
1236 409 __( 'Upgrade', 'sureforms' ),
@@ -1306,41 +479,8 @@
1306 479 <?php
1307 480 }
1308 481
1309 482 /**
1310 - * Add Partial Entries promotional submenu page for free users.
1311 - *
1312 - * @return void
1313 - * @since 2.9.0
1314 - */
1315 - public function add_partial_entries_page() {
1316 - add_submenu_page(
1317 - 'sureforms_menu',
1318 - __( 'Partial Entries', 'sureforms' ),
1319 - __( 'Partial Entries', 'sureforms' ) .
1320 - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' .
1321 - esc_html__( 'New', 'sureforms' ) .
1322 - '</span>',
1323 - self::$sureforms_page_default_capability,
1324 - 'sureforms_partial_entries',
1325 - [ $this, 'render_partial_entries_empty_state' ],
1326 - 7
1327 - );
1328 - }
1329 -
1330 - /**
1331 - * Partial Entries empty state page callback.
1332 - *
1333 - * @return void
1334 - * @since 2.9.0
1335 - */
1336 - public function render_partial_entries_empty_state() {
1337 - ?>
1338 - <div id="srfm-partial-entries-empty-state-root" class="srfm-admin-wrapper"></div>
1339 - <?php
1340 - }
1341 -
1342 - /**
1343 483 * Add SMTP promotional submenu page.
1344 484 *
1345 485 * @return void
1346 486 * @since 1.7.1
@@ -1654,9 +794,8 @@
1654 794 wp_enqueue_style( SRFM_SLUG . '-backend-blocks', $css_uri . 'blocks/default/backend' . $file_prefix . '.css', [], SRFM_VER );
1655 795 wp_enqueue_style( SRFM_SLUG . '-intl', $vendor_css_uri . 'intl/intlTelInput-backend.min.css', [], SRFM_VER );
1656 796 wp_enqueue_style( SRFM_SLUG . '-common', $css_uri . 'common' . $file_prefix . '.css', [], SRFM_VER );
1657 797 wp_enqueue_style( SRFM_SLUG . '-reactQuill', $vendor_css_uri . 'quill/quill.snow.css', [], SRFM_VER );
1658 - wp_add_inline_style( SRFM_SLUG . '-reactQuill', self::QUILL_1X_INLINE_CSS );
1659 798 wp_enqueue_style( SRFM_SLUG . '-single-form-modal', $css_uri . 'single-form-setting' . $file_prefix . '.css', [], SRFM_VER );
1660 799
1661 800 // if version is equal to or lower than 6.6.2 then add compatibility css.
1662 801 if ( version_compare( $wp_version, '6.6.2', '<=' ) ) {
@@ -1747,60 +886,46 @@
1747 886 $onboarding_instance = Onboarding::get_instance();
1748 887 $current_user = wp_get_current_user();
1749 888
1750 889 $localization_data = [
1751 - 'site_url' => get_site_url(),
1752 - 'current_user_login' => $current_user->user_login ?? '',
1753 - 'website_lead_details' => [
890 + 'site_url' => get_site_url(),
891 + 'current_user_login' => $current_user->user_login ?? '',
892 + 'website_lead_details' => [
1754 893 'first_name' => $current_user->first_name ?? '',
1755 894 'last_name' => $current_user->last_name ?? '',
1756 895 'email' => $current_user->user_email ?? '',
1757 896 ],
1758 - 'breadcrumbs' => $this->get_breadcrumbs_for_current_page(),
1759 - 'sureforms_dashboard_url' => admin_url( '/admin.php?page=sureforms_menu' ),
1760 - 'plugin_version' => SRFM_VER,
1761 - 'global_settings_nonce' => Helper::current_user_can() ? wp_create_nonce( 'wp_rest' ) : '',
1762 - 'is_pro_active' => Helper::has_pro(),
1763 - 'is_first_form_created' => self::is_first_form_created(),
1764 - 'check_three_days_threshold' => self::check_first_form_creation_threshold(),
1765 - 'check_eight_days_threshold' => self::check_first_form_creation_threshold( 8 ),
1766 - 'pro_plugin_version' => Helper::has_pro() ? SRFM_PRO_VER : '',
1767 - 'pro_plugin_name' => Helper::has_pro() && defined( 'SRFM_PRO_PRODUCT' ) ? SRFM_PRO_PRODUCT : 'SureForms Pro',
1768 - 'sureforms_pricing_page' => Helper::get_sureforms_website_url( 'pricing' ),
1769 - 'field_spacing_vars' => Helper::get_css_vars(),
1770 - 'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ),
1771 - 'integrations' => Helper::sureforms_get_integration(),
1772 - 'rotating_plugin_banner' => Helper::get_rotating_plugin_banner(),
1773 - 'ajax_url' => admin_url( 'admin-ajax.php' ),
1774 - 'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '',
1775 - 'action_items' => $this->get_action_items(),
1776 - 'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '',
1777 - 'dismiss_action_item_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_dismiss_action_item' ) : '',
1778 - 'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ),
1779 - 'plugin_installer_nonce' => wp_create_nonce( 'updates' ),
1780 - 'plugin_activating_text' => __( 'Activating...', 'sureforms' ),
1781 - 'plugin_activated_text' => __( 'Activated', 'sureforms' ),
1782 - 'plugin_activate_text' => __( 'Activate', 'sureforms' ),
1783 - 'plugin_installing_text' => __( 'Installing...', 'sureforms' ),
1784 - 'plugin_installed_text' => __( 'Installed', 'sureforms' ),
1785 - 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ),
1786 - 'is_rtl' => $is_rtl,
1787 - 'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false,
1788 - 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false,
1789 - 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ),
1790 - 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection.
1791 - 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ),
1792 - 'general_settings_url' => admin_url( '/options-general.php' ),
1793 - 'additional_header_nav_items' => [],
1794 - // Smart tags for the Global Defaults email notification fields.
1795 - // srfm_block_data is only available in the block editor; these keys
1796 - // make the same data accessible on the settings page via srfm_admin.
1797 - 'smart_tags_array' => Smart_Tags::smart_tag_list(),
1798 - 'smart_tags_array_email' => Smart_Tags::email_smart_tag_list(),
1799 - // Default confirmation message HTML (icon + heading + text) used as
1800 - // the initial React state before the settings API response arrives.
1801 - 'default_confirmation_message' => Global_Settings::get_default_confirmation_message(),
1802 - 'payments' => apply_filters(
897 + 'breadcrumbs' => $this->get_breadcrumbs_for_current_page(),
898 + 'sureforms_dashboard_url' => admin_url( '/admin.php?page=sureforms_menu' ),
899 + 'plugin_version' => SRFM_VER,
900 + 'global_settings_nonce' => Helper::current_user_can() ? wp_create_nonce( 'wp_rest' ) : '',
901 + 'is_pro_active' => Helper::has_pro(),
902 + 'is_first_form_created' => self::is_first_form_created(),
903 + 'check_three_days_threshold' => self::check_first_form_creation_threshold(),
904 + 'check_eight_days_threshold' => self::check_first_form_creation_threshold( 8 ),
905 + 'pro_plugin_version' => Helper::has_pro() ? SRFM_PRO_VER : '',
906 + 'pro_plugin_name' => Helper::has_pro() && defined( 'SRFM_PRO_PRODUCT' ) ? SRFM_PRO_PRODUCT : 'SureForms Pro',
907 + 'sureforms_pricing_page' => Helper::get_sureforms_website_url( 'pricing' ),
908 + 'field_spacing_vars' => Helper::get_css_vars(),
909 + 'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ),
910 + 'integrations' => Helper::sureforms_get_integration(),
911 + 'rotating_plugin_banner' => Helper::get_rotating_plugin_banner(),
912 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
913 + 'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ),
914 + 'plugin_installer_nonce' => wp_create_nonce( 'updates' ),
915 + 'plugin_activating_text' => __( 'Activating...', 'sureforms' ),
916 + 'plugin_activated_text' => __( 'Activated', 'sureforms' ),
917 + 'plugin_activate_text' => __( 'Activate', 'sureforms' ),
918 + 'plugin_installing_text' => __( 'Installing...', 'sureforms' ),
919 + 'plugin_installed_text' => __( 'Installed', 'sureforms' ),
920 + 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ),
921 + 'is_rtl' => $is_rtl,
922 + 'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false,
923 + 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection.
924 + 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ),
925 + 'general_settings_url' => admin_url( '/options-general.php' ),
926 + 'additional_header_nav_items' => [],
927 + 'payments' => apply_filters(
1803 928 'srfm_admin_localize_payments_data',
1804 929 [
1805 930 'stripe_connected' => Stripe_Helper::is_stripe_connected(),
1806 931 'stripe_mode' => Stripe_Helper::get_stripe_mode(),
@@ -1814,25 +939,23 @@
1814 939 'payment_currency' => Payment_Helper::get_currency(),
1815 940 'currency_sign_position' => Payment_Helper::get_currency_sign_position(),
1816 941 ]
1817 942 ),
1818 - 'mcp_adapter_status' => file_exists( WP_PLUGIN_DIR . '/mcp-adapter/mcp-adapter.php' )
943 + 'mcp_adapter_status' => file_exists( WP_PLUGIN_DIR . '/mcp-adapter/mcp-adapter.php' )
1819 944 ? ( is_plugin_active( 'mcp-adapter/mcp-adapter.php' ) ? 'active' : 'installed' )
1820 945 : 'not_installed',
1821 - 'mcp_endpoint_url' => esc_url_raw( rest_url( 'sureforms/v1/mcp' ) ),
1822 946 ];
1823 947
1824 - $is_screen_sureforms_menu = Helper::validate_request_context( 'sureforms_menu', 'page' );
1825 - $is_screen_add_new_form = Helper::validate_request_context( 'add-new-form', 'page' );
1826 - $is_screen_sureforms_forms = Helper::validate_request_context( 'sureforms_forms', 'page' );
1827 - $is_screen_sureforms_form_settings = Helper::validate_request_context( 'sureforms_form_settings', 'page' );
1828 - $is_screen_sureforms_payments = Helper::validate_request_context( 'sureforms_payments', 'page' );
1829 - $is_screen_sureforms_entries = Helper::validate_request_context( SRFM_ENTRIES, 'page' );
1830 - $is_screen_sureforms_learn = Helper::validate_request_context( 'sureforms_learn', 'page' );
1831 - $is_screen_quiz_empty_state = Helper::validate_request_context( 'sureforms_quiz_entries', 'page' );
1832 - $is_screen_survey_empty_state = Helper::validate_request_context( 'sureforms_survey_reports', 'page' );
1833 - $is_screen_partial_entries_empty_state = Helper::validate_request_context( 'sureforms_partial_entries', 'page' );
1834 - $is_post_type_sureforms_form = SRFM_FORMS_POST_TYPE === $current_screen->post_type;
948 + $is_screen_sureforms_menu = Helper::validate_request_context( 'sureforms_menu', 'page' );
949 + $is_screen_add_new_form = Helper::validate_request_context( 'add-new-form', 'page' );
950 + $is_screen_sureforms_forms = Helper::validate_request_context( 'sureforms_forms', 'page' );
951 + $is_screen_sureforms_form_settings = Helper::validate_request_context( 'sureforms_form_settings', 'page' );
952 + $is_screen_sureforms_payments = Helper::validate_request_context( 'sureforms_payments', 'page' );
953 + $is_screen_sureforms_entries = Helper::validate_request_context( SRFM_ENTRIES, 'page' );
954 + $is_screen_sureforms_learn = Helper::validate_request_context( 'sureforms_learn', 'page' );
955 + $is_screen_quiz_empty_state = Helper::validate_request_context( 'sureforms_quiz_entries', 'page' );
956 + $is_screen_survey_empty_state = Helper::validate_request_context( 'sureforms_survey_reports', 'page' );
957 + $is_post_type_sureforms_form = SRFM_FORMS_POST_TYPE === $current_screen->post_type;
1835 958
1836 959 /**
1837 960 * Check if the current screen is the SureForms Menu and AI Auth Email is present then we will add user type as registered.
1838 961 * Compatibility with existing UI code that checks for this condition.
@@ -1855,16 +978,11 @@
1855 978 'slug' => 'sureforms_survey_reports',
1856 979 'text' => __( 'Survey Reports', 'sureforms' ),
1857 980 'link' => admin_url( 'admin.php?page=sureforms_survey_reports' ),
1858 981 ];
1859 - $localization_data['additional_header_nav_items'][] = [
1860 - 'slug' => 'sureforms_partial_entries',
1861 - 'text' => __( 'Partial Entries', 'sureforms' ),
1862 - 'link' => admin_url( 'admin.php?page=sureforms_partial_entries' ),
1863 - ];
1864 982 }
1865 983
1866 - $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;
984 + $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;
1867 985
1868 986 /**
1869 987 * Filter to allow extending the SureForms dashboard screen check.
1870 988 *
@@ -2054,26 +1172,8 @@
2054 1172
2055 1173 $script_translations_handlers[] = SRFM_SLUG . '-survey-empty-state';
2056 1174 }
2057 1175
2058 - // Enqueue scripts for the Partial Entries empty state page (free users only).
2059 - if ( $is_screen_partial_entries_empty_state && ! Helper::has_pro() ) {
2060 - $asset_handle = 'partialEntriesEmptyState';
2061 -
2062 - $script_asset_path = SRFM_DIR . 'assets/build/' . $asset_handle . '.asset.php';
2063 - $script_info = file_exists( $script_asset_path )
2064 - ? include $script_asset_path
2065 - : [
2066 - 'dependencies' => [],
2067 - 'version' => SRFM_VER,
2068 - ];
2069 -
2070 - wp_enqueue_script( SRFM_SLUG . '-partial-entries-empty-state', SRFM_URL . 'assets/build/' . $asset_handle . '.js', $script_info['dependencies'], SRFM_VER, true );
2071 - wp_enqueue_style( SRFM_SLUG . '-partial-entries-empty-state', SRFM_URL . 'assets/build/' . $asset_handle . '.css', [], SRFM_VER, 'all' );
2072 -
2073 - $script_translations_handlers[] = SRFM_SLUG . '-partial-entries-empty-state';
2074 - }
2075 -
2076 1176 // Admin Submenu Styles.
2077 1177 wp_enqueue_style( SRFM_SLUG . '-admin', $css_uri . 'backend/admin' . $file_prefix . $rtl . '.css', [], SRFM_VER );
2078 1178
2079 1179 if ( $is_screen_sureforms_form_settings ) {
@@ -2096,13 +1196,8 @@
2096 1196 $localization_data
2097 1197 )
2098 1198 );
2099 1199
2100 - // Enqueue Tailwind and Quill editor styles for the settings page.
2101 - wp_enqueue_style( SRFM_SLUG . '-settings-build', SRFM_URL . 'assets/build/settings.css', [], SRFM_VER, 'all' );
2102 - wp_enqueue_style( SRFM_SLUG . '-reactQuill', SRFM_URL . 'assets/css/minified/deps/quill/quill.snow.css', [], SRFM_VER );
2103 - wp_add_inline_style( SRFM_SLUG . '-reactQuill', self::QUILL_1X_INLINE_CSS );
2104 -
2105 1200 $script_translations_handlers[] = SRFM_SLUG . '-settings';
2106 1201 }
2107 1202
2108 1203 if ( $is_screen_add_new_form ) {
@@ -2302,234 +1397,8 @@
2302 1397 }
2303 1398 }
2304 1399
2305 1400 /**
2306 - * Register the React notice when the entries table is missing.
2307 - *
2308 - * Hooked - admin_init, priority 5.
2309 - *
2310 - * Priority 5 is load-bearing: Notice_Manager hands notices to the front end
2311 - * through the `srfm_admin_filter` applied during admin_enqueue_scripts, so
2312 - * anything registering later never reaches the page.
2313 - *
2314 - * @since 2.12.6
2315 - * @return void
2316 - */
2317 - public function register_database_repair_notice() {
2318 - // admin_init also fires on admin-ajax.php. Nothing there renders a notice, so
2319 - // skip the work rather than reading a transient on every AJAX request.
2320 - if ( wp_doing_ajax() ) {
2321 - return;
2322 - }
2323 -
2324 - if ( ! Helper::current_user_can() ) {
2325 - return;
2326 - }
2327 -
2328 - if ( ! class_exists( 'SRFM\Admin\Notice_Manager' ) ) {
2329 - return;
2330 - }
2331 -
2332 - // A just-completed repair reports its outcome instead of the warning.
2333 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag; the repair itself is nonce-checked in handle_database_repair().
2334 - $result = isset( $_GET['srfm_db_repair'] ) ? sanitize_key( wp_unslash( $_GET['srfm_db_repair'] ) ) : '';
2335 -
2336 - if ( 'done' === $result ) {
2337 - Notice_Manager::register_notice(
2338 - [
2339 - 'id' => 'srfm-database-repaired',
2340 - 'variant' => 'success',
2341 - 'message' => __( 'Your SureForms database is up to date. New form entries will be saved as usual.', 'sureforms' ),
2342 - 'pages' => [ 'all' ],
2343 - ]
2344 - );
2345 - return;
2346 - }
2347 -
2348 - if ( 'failed' === $result ) {
2349 - Notice_Manager::register_notice(
2350 - [
2351 - 'id' => 'srfm-database-repair-failed',
2352 - // Still a warning, not an error: a host that does not allow
2353 - // SureForms to create tables is not the user's mistake.
2354 - 'variant' => 'warning',
2355 - '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' ),
2356 - 'actions' => [
2357 - [
2358 - 'label' => __( 'Contact support', 'sureforms' ),
2359 - 'url' => 'https://sureforms.com/contact/',
2360 - ],
2361 - ],
2362 - 'pages' => [ 'all' ],
2363 - ]
2364 - );
2365 - return;
2366 - }
2367 -
2368 - if ( ! Register::is_entries_table_missing() ) {
2369 - return;
2370 - }
2371 -
2372 - $this->track_database_notice_impression();
2373 -
2374 - Notice_Manager::register_notice(
2375 - [
2376 - 'id' => 'srfm-database-maintenance',
2377 - 'variant' => 'warning',
2378 - 'title' => __( 'Database update needed', 'sureforms' ),
2379 - // Plain text only. AdminNotice.js renders this as a React child, so
2380 - // any markup here would show up as literal characters.
2381 - 'message' => $this->get_database_notice_message(),
2382 - 'actions' => [
2383 - [
2384 - 'label' => __( 'Fix now', 'sureforms' ),
2385 - // Opaque identifier, resolved to a handler in AdminNotice.js.
2386 - // Deliberately not a URL or endpoint: the server never tells
2387 - // the browser which address to call.
2388 - 'action' => 'repair-entries-table',
2389 - 'url' => $this->get_database_repair_url(),
2390 - ],
2391 - ],
2392 - 'pages' => [ 'all' ],
2393 - ]
2394 - );
2395 - }
2396 -
2397 - /**
2398 - * Render the classic warning on the WordPress dashboard.
2399 - *
2400 - * Hooked - admin_notices.
2401 - *
2402 - * Scoped to index.php on purpose. The React notice already covers the SureForms
2403 - * screens, so leaving this one admin-wide would stack two warnings on the same
2404 - * page and nag on every screen in wp-admin.
2405 - *
2406 - * Registered as [ $this, 'method' ] rather than a closure because
2407 - * suppress_foreign_admin_notices() strips any callback it cannot attribute to a
2408 - * SureForms class — a closure here would be silently removed.
2409 - *
2410 - * @since 2.12.6
2411 - * @return void
2412 - */
2413 - public function render_database_repair_notice() {
2414 - if ( ! Helper::current_user_can() ) {
2415 - return;
2416 - }
2417 -
2418 - $screen = get_current_screen();
2419 -
2420 - if ( ! $screen || 'dashboard' !== $screen->base ) {
2421 - return;
2422 - }
2423 -
2424 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag; the repair itself is nonce-checked in handle_database_repair().
2425 - $result = isset( $_GET['srfm_db_repair'] ) ? sanitize_key( wp_unslash( $_GET['srfm_db_repair'] ) ) : '';
2426 -
2427 - if ( 'done' === $result ) {
2428 - ?>
2429 - <div class="notice notice-success is-dismissible">
2430 - <p><?php esc_html_e( 'Your SureForms database is up to date. New form entries will be saved as usual.', 'sureforms' ); ?></p>
2431 - </div>
2432 - <?php
2433 - return;
2434 - }
2435 -
2436 - if ( 'failed' === $result ) {
2437 - ?>
2438 - <div class="notice notice-warning is-dismissible">
2439 - <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>
2440 - </div>
2441 - <?php
2442 - return;
2443 - }
2444 -
2445 - if ( ! Register::is_entries_table_missing() ) {
2446 - return;
2447 - }
2448 -
2449 - $this->track_database_notice_impression();
2450 - ?>
2451 - <div class="notice notice-warning">
2452 - <p>
2453 - <strong><?php esc_html_e( 'SureForms — database update needed', 'sureforms' ); ?></strong>
2454 - </p>
2455 - <p><?php echo esc_html( $this->get_database_notice_message() ); ?></p>
2456 - <p>
2457 - <a href="<?php echo esc_url( $this->get_database_repair_url() ); ?>" class="button button-primary">
2458 - <?php esc_html_e( 'Update database', 'sureforms' ); ?>
2459 - </a>
2460 - </p>
2461 - </div>
2462 - <?php
2463 - }
2464 -
2465 - /**
2466 - * Repair the entries table, then redirect back with the outcome.
2467 - *
2468 - * Hooked - admin_post_srfm_repair_entries_table.
2469 - *
2470 - * A nonce-protected GET that changes state matches how core's own plugin
2471 - * activate / deactivate / delete links work.
2472 - *
2473 - * @since 2.12.6
2474 - * @return void
2475 - */
2476 - public function handle_database_repair() {
2477 - if ( ! Helper::current_user_can() ) {
2478 - wp_die( esc_html__( 'You do not have permission to update the database.', 'sureforms' ), 403 );
2479 - }
2480 -
2481 - check_admin_referer( 'srfm_repair_entries_table' );
2482 -
2483 - $repaired = $this->do_database_repair();
2484 - $referer = wp_get_referer();
2485 -
2486 - wp_safe_redirect(
2487 - add_query_arg(
2488 - 'srfm_db_repair',
2489 - $repaired ? 'done' : 'failed',
2490 - $referer ? $referer : admin_url()
2491 - )
2492 - );
2493 - exit;
2494 - }
2495 -
2496 - /**
2497 - * Repair the entries table and record what happened.
2498 - *
2499 - * The single place the repair is performed and counted, shared by the
2500 - * admin-post handler and the REST endpoint. One user action reaches exactly one
2501 - * of those, so the click counter cannot double-count across the two surfaces.
2502 - *
2503 - * @since 2.12.6
2504 - * @return bool True when the table exists afterwards.
2505 - */
2506 - public function do_database_repair() {
2507 - // Cumulative counter, so $force = true: each new count is a new value and is
2508 - // re-sent, while an identical repeat short-circuits inside track().
2509 - $attempts = Helper::get_integer_value( Helper::get_srfm_option( 'db_repair_attempts', 0 ) ) + 1;
2510 - Helper::update_srfm_option( 'db_repair_attempts', $attempts );
2511 -
2512 - // Event name is the `database_error` => `fix_now` entry in the $valid
2513 - // allowlist in handle_notice_response(). Kept in sync by hand; that array is
2514 - // where the team looks notice event names up.
2515 - Analytics::events()->track( 'database_error_notice_cta', (string) $attempts, [], true );
2516 -
2517 - $repaired = Register::repair_entries_table();
2518 -
2519 - // The failure case is the more valuable signal: it means the host refuses to
2520 - // let SureForms create tables, which no amount of retrying will fix.
2521 - Analytics::events()->track(
2522 - 'database_repair_result',
2523 - $repaired ? 'success' : 'failed',
2524 - [],
2525 - true
2526 - );
2527 -
2528 - return $repaired;
2529 - }
2530 -
2531 - /**
2532 1401 * Admin Notice Callback if sureforms pro is out of date.
2533 1402 *
2534 1403 * Hooked - admin_notices
2535 1404 *
@@ -2624,52 +1493,29 @@
2624 1493 if ( ! apply_filters( 'srfm_show_rating_notice', true ) ) {
2625 1494 return;
2626 1495 }
2627 1496
2628 - $notice_id = 'srfm-plugin-review-notice';
2629 -
2630 1497 Astra_Notices::add_notice(
2631 1498 [
2632 - 'id' => $notice_id,
1499 + 'id' => 'srfm-plugin-review-notice',
2633 1500 'type' => '',
2634 - 'message' => self::build_srfm_notice_markup(
2635 - __( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ),
2636 - __( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ),
2637 - [
2638 - [
2639 - 'text' => __( 'Rate SureForms', 'sureforms' ),
2640 - 'url' => esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/' ),
2641 - 'primary' => true,
2642 - // Leaves wp-admin, so it also dismisses on the way out.
2643 - 'dismiss' => true,
2644 - 'external' => true,
2645 - ],
2646 - [
2647 - 'text' => __( 'Maybe later', 'sureforms' ),
2648 - 'url' => '#',
2649 - 'dismiss' => true,
2650 - 'snooze' => WEEK_IN_SECONDS,
2651 - ],
2652 - [
2653 - 'text' => __( 'I already did', 'sureforms' ),
2654 - 'url' => '#',
2655 - 'dismiss' => true,
2656 - ],
2657 - ]
1501 + 'message' => $this->build_notice_markup(
1502 + esc_html__( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ),
1503 + esc_html__( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ),
1504 + esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/?filter=5#new-post' ),
1505 + esc_html__( 'Rate SureForms', 'sureforms' ),
1506 + esc_html__( 'Maybe later', 'sureforms' ),
1507 + esc_html__( 'I already did', 'sureforms' ),
1508 + WEEK_IN_SECONDS,
1509 + true
2658 1510 ),
2659 - 'class' => 'srfm-notice srfm-rating-notice',
2660 1511 'repeat-notice-after' => WEEK_IN_SECONDS,
2661 - // Yields to the Thank You prompt for the same reason the Getting Started
2662 - // notice does: a specific form to finish beats a recurring review ask,
2663 - // and a user with three forms who then imports a template would
2664 - // otherwise see both at once.
2665 - 'show_if' => $this->maybe_display_rating_notice() && null === $this->get_displayable_thankyou_prompt() && ! $this->has_action_item_warnings(),
1512 + 'show_if' => $this->maybe_display_rating_notice(),
2666 1513 'display-with-other-notices' => true,
2667 1514 ]
2668 1515 );
2669 1516
2670 - add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
2671 - add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_notice_response_script' ] );
1517 + add_action( 'astra_notice_after_markup_srfm-plugin-review-notice', [ $this, 'enqueue_notice_response_script' ] );
2672 1518 }
2673 1519
2674 1520 /**
2675 1521 * Display a "Getting Started" admin notice for new users who haven't yet
@@ -2691,52 +1537,29 @@
2691 1537 if ( ! apply_filters( 'srfm_show_getting_started_notice', true ) ) {
2692 1538 return;
2693 1539 }
2694 1540
2695 - $notice_id = 'srfm-getting-started-notice';
2696 -
2697 1541 Astra_Notices::add_notice(
2698 1542 [
2699 - 'id' => $notice_id,
1543 + 'id' => 'srfm-getting-started-notice',
2700 1544 'type' => '',
2701 - 'message' => self::build_srfm_notice_markup(
2702 - __( 'SureForms is ready to power your forms — explore what\'s possible!', 'sureforms' ),
2703 - __( 'Manage your forms, track submissions, and discover features like AI Form Builder, payment integrations, and more from the SureForms dashboard.', 'sureforms' ),
2704 - [
2705 - [
2706 - 'text' => __( 'Go to Dashboard', 'sureforms' ),
2707 - 'url' => esc_url( admin_url( 'admin.php?page=sureforms_menu' ) ),
2708 - 'primary' => true,
2709 - ],
2710 - [
2711 - 'text' => __( 'Maybe later', 'sureforms' ),
2712 - 'url' => '#',
2713 - 'dismiss' => true,
2714 - 'snooze' => WEEK_IN_SECONDS,
2715 - ],
2716 - [
2717 - 'text' => __( 'I already know', 'sureforms' ),
2718 - 'url' => '#',
2719 - 'dismiss' => true,
2720 - ],
2721 - ]
1545 + 'message' => $this->build_notice_markup(
1546 + esc_html__( 'SureForms is ready to power your forms — explore what\'s possible!', 'sureforms' ),
1547 + esc_html__( 'Manage your forms, track submissions, and discover features like AI Form Builder, payment integrations, and more from the SureForms dashboard.', 'sureforms' ),
1548 + esc_url( admin_url( 'admin.php?page=sureforms_menu' ) ),
1549 + esc_html__( 'Go to Dashboard', 'sureforms' ),
1550 + esc_html__( 'Maybe later', 'sureforms' ),
1551 + esc_html__( 'I already know', 'sureforms' ),
1552 + WEEK_IN_SECONDS
2722 1553 ),
2723 - 'class' => 'srfm-notice srfm-getting-started-notice',
2724 1554 'repeat-notice-after' => WEEK_IN_SECONDS,
2725 - // Yields to both of the other SureForms notices, so only one of ours is
2726 - // ever on screen. The rating notice supersedes it once the user has real
2727 - // usage; the Thank You prompt supersedes it because "finish this specific
2728 - // form" is a concrete next step and this is a generic tour invitation.
2729 - 'show_if' => ! $this->maybe_display_rating_notice() && null === $this->get_displayable_thankyou_prompt() && ! $this->has_action_item_warnings(),
1555 + 'show_if' => ! $this->maybe_display_rating_notice(),
2730 1556 'display-notice-after' => WEEK_IN_SECONDS,
2731 1557 'display-with-other-notices' => true,
2732 1558 ]
2733 1559 );
2734 1560
2735 - // Same pre-markup hook the Thank You prompt uses, so both notices are painted
2736 - // by one stylesheet instead of two that drift apart.
2737 - add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_srfm_notice_styles' ] );
2738 - add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_notice_response_script' ] );
1561 + add_action( 'astra_notice_after_markup_srfm-getting-started-notice', [ $this, 'enqueue_notice_response_script' ] );
2739 1562 }
2740 1563
2741 1564 /**
2742 1565 * Enqueue the notice response analytics script.
@@ -2781,14 +1604,12 @@
2781 1604 */
2782 1605 public function handle_notice_response() {
2783 1606 if ( ! check_ajax_referer( 'srfm_notice_response', 'nonce', false ) ) {
2784 1607 wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
2785 - return;
2786 1608 }
2787 1609
2788 1610 if ( ! Helper::current_user_can() ) {
2789 1611 wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
2790 - return;
2791 1612 }
2792 1613
2793 1614 $notice_id = isset( $_POST['notice_id'] ) ? sanitize_text_field( wp_unslash( $_POST['notice_id'] ) ) : '';
2794 1615 $button = isset( $_POST['button'] ) ? sanitize_text_field( wp_unslash( $_POST['button'] ) ) : '';
@@ -2803,69 +1624,17 @@
2803 1624 'rate_sureforms' => 'rating_notice_cta',
2804 1625 'maybe_later' => 'rating_notice_snooze',
2805 1626 'dismissed' => 'rating_notice_dismiss',
2806 1627 ],
2807 - // Database maintenance notice. Keyed `database_error` for the warehouse;
2808 - // the user-facing copy deliberately reads as a routine update, not an
2809 - // error. `dismissed` is registered but unreachable today — a missing
2810 - // entries table is not something we let people dismiss.
2811 - 'database_error' => [
2812 - 'fix_now' => 'database_error_notice_cta',
2813 - 'dismissed' => 'database_error_notice_dismiss',
2814 - ],
2815 - // The "Finish setting up" prompt (#3030): three CTAs, plus the ✕.
2816 - 'form_submission_error' => [
2817 - 'contact_support' => 'submission_failure_notice_cta',
2818 - 'dismissed' => 'submission_failure_notice_dismiss',
2819 - ],
2820 - 'notification_error' => [
2821 - 'contact_support' => 'notification_failure_notice_cta',
2822 - 'dismissed' => 'notification_failure_notice_dismiss',
2823 - ],
2824 - 'integration_error' => [
2825 - 'contact_support' => 'integration_failure_notice_cta',
2826 - 'dismissed' => 'integration_failure_notice_dismiss',
2827 - ],
2828 - 'caching_plugin' => [
2829 - 'help_me_fix' => 'caching_plugin_notice_cta',
2830 - 'dismissed' => 'caching_plugin_notice_dismiss',
2831 - ],
2832 - 'srfm-thankyou-prompt' => [
2833 - 'edit_form' => 'thankyou_notice_edit_form',
2834 - 'set_replies' => 'thankyou_notice_set_replies',
2835 - 'edit_thankyou' => 'thankyou_notice_edit_thankyou',
2836 - 'dismissed' => 'thankyou_notice_dismiss',
2837 - ],
2838 1628 ];
2839 1629
2840 1630 if ( ! isset( $valid[ $notice_id ][ $button ] ) ) {
2841 1631 wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 );
2842 - // wp_send_json_error() ends the request in production. The explicit return
2843 - // keeps the guard a guard rather than something that only works because of
2844 - // a side effect in a function elsewhere.
2845 - return;
2846 1632 }
2847 1633
2848 1634 $event_name = $valid[ $notice_id ][ $button ];
2849 1635 Analytics::events()->track( $event_name, $button );
2850 1636
2851 - // Reporting the failures retires the notice until something new fails.
2852 - // Handled here rather than in the browser so it holds for the classic
2853 - // wp-admin notice too, which is a plain link with no JavaScript.
2854 - $categories = [
2855 - 'form_submission_error' => 'submission',
2856 - 'notification_error' => 'notification',
2857 - 'integration_error' => 'integration',
2858 - ];
2859 -
2860 - if ( 'contact_support' === $button && isset( $categories[ $notice_id ] ) ) {
2861 - Client_Logger::acknowledge_category( $categories[ $notice_id ] );
2862 -
2863 - if ( 'form_submission_error' === $notice_id ) {
2864 - Client_Logger::acknowledge_failures();
2865 - }
2866 - }
2867 -
2868 1637 wp_send_json_success();
2869 1638 }
2870 1639
2871 1640 /**
@@ -3013,11 +1782,8 @@
3013 1782 if ( ! Helper::current_user_can() ) {
3014 1783 return;
3015 1784 }
3016 1785
3017 - // 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.
3018 - add_action( 'wp_dashboard_setup', [ $this, 'register_ai_dashboard_widget' ] );
3019 -
3020 1786 // Quick check if there are any entries in the last 7 days.
3021 1787 $seven_days_ago = strtotime( '-7 days' );
3022 1788 $total_entries = Entries::get_entries_count_after( $seven_days_ago );
3023 1789
@@ -3052,290 +1818,8 @@
3052 1818 );
3053 1819 }
3054 1820
3055 1821 /**
3056 - * Register the AI quick draft dashboard widget.
3057 - *
3058 - * @return void
3059 - * @since 2.12.1
3060 - */
3061 - public function register_ai_dashboard_widget() {
3062 - wp_add_dashboard_widget(
3063 - 'sureforms_ai_quick_draft',
3064 - __( 'SureForms AI Quick Draft', 'sureforms' ),
3065 - [ $this, 'render_ai_dashboard_widget' ],
3066 - null,
3067 - null,
3068 - 'normal',
3069 - 'high'
3070 - );
3071 - }
3072 -
3073 - /**
3074 - * Render AI quick draft dashboard widget content.
3075 - *
3076 - * @return void
3077 - * @since 2.12.1
3078 - */
3079 - public function render_ai_dashboard_widget() {
3080 - ?>
3081 - <div class="srfm-ai-dashboard-widget">
3082 - <p>
3083 - <?php esc_html_e( 'Describe the form and let SureForms AI generate it for you.', 'sureforms' ); ?>
3084 - </p>
3085 - <label for="srfm-ai-dashboard-prompt" class="screen-reader-text">
3086 - <?php esc_html_e( 'Describe your form', 'sureforms' ); ?>
3087 - </label>
3088 - <textarea
3089 - id="srfm-ai-dashboard-prompt"
3090 - class="widefat"
3091 - rows="5"
3092 - maxlength="2000"
3093 - placeholder="<?php esc_attr_e( 'Example: Create a contact form with name, email, phone, and message fields.', 'sureforms' ); ?>"
3094 - ></textarea>
3095 - <p style="margin-top:10px;margin-bottom:0;display:flex;align-items:center;gap:10px;">
3096 - <button type="button" class="button button-primary" id="srfm-ai-dashboard-generate" disabled>
3097 - <?php esc_html_e( 'Create New Form', 'sureforms' ); ?>
3098 - </button>
3099 - <span id="srfm-ai-dashboard-char-count" style="color:#646970;">0/2000</span>
3100 - </p>
3101 - </div>
3102 - <?php
3103 - }
3104 -
3105 - /**
3106 - * Enqueue the AI quick draft dashboard widget script on the dashboard screen.
3107 - *
3108 - * The widget's behavior lives here (attached via wp_add_inline_script) rather than as an
3109 - * inline <script> in the render callback, so it passes Plugin Check and keeps server values
3110 - * out of the markup. Server values are passed through wp_localize_script.
3111 - *
3112 - * @param string $hook_suffix The current admin page hook suffix.
3113 - * @return void
3114 - * @since 2.12.1
3115 - */
3116 - public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) {
3117 - // Only on the main dashboard, and only for capable users (matches the widget gate).
3118 - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
3119 - return;
3120 - }
3121 -
3122 - // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching
3123 - // localized data plus an inline script without shipping a separate asset file.
3124 - wp_register_script( 'srfm-ai-dashboard-widget', '', [], SRFM_VER, true );
3125 - wp_enqueue_script( 'srfm-ai-dashboard-widget' );
3126 -
3127 - wp_localize_script(
3128 - 'srfm-ai-dashboard-widget',
3129 - 'srfmAiDashboardWidget',
3130 - [
3131 - 'redirectUrl' => admin_url( 'admin.php?page=add-new-form' ),
3132 - 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
3133 - 'nonce' => wp_create_nonce( 'srfm_ai_widget_usage' ),
3134 - 'redirectingTxt' => __( 'Redirecting...', 'sureforms' ),
3135 - ]
3136 - );
3137 -
3138 - $inline_script = <<<'JS'
3139 -( function () {
3140 - const config = window.srfmAiDashboardWidget || {};
3141 - const generateButton = document.getElementById( 'srfm-ai-dashboard-generate' );
3142 - const promptField = document.getElementById( 'srfm-ai-dashboard-prompt' );
3143 - const charCount = document.getElementById( 'srfm-ai-dashboard-char-count' );
3144 - if ( ! generateButton || ! promptField ) {
3145 - return;
3146 - }
3147 -
3148 - const updateWidgetState = function () {
3149 - const promptValue = promptField.value.trim();
3150 - generateButton.disabled = ! promptValue;
3151 - if ( charCount ) {
3152 - charCount.textContent = `${ promptField.value.length }/2000`;
3153 - }
3154 - };
3155 -
3156 - const triggerGeneration = function () {
3157 - const prompt = promptField.value.trim();
3158 - if ( ! prompt ) {
3159 - promptField.focus();
3160 - return;
3161 - }
3162 -
3163 - generateButton.disabled = true;
3164 - generateButton.textContent = config.redirectingTxt;
3165 -
3166 - const redirectUrl = new URL( config.redirectUrl, window.location.origin );
3167 - redirectUrl.searchParams.set( 'srfm_ai_dashboard_prompt', prompt );
3168 -
3169 - const requestBody = new URLSearchParams();
3170 - requestBody.append( 'action', 'srfm_ai_widget_usage' );
3171 - requestBody.append( 'nonce', config.nonce );
3172 -
3173 - fetch( config.ajaxUrl, {
3174 - method: 'POST',
3175 - credentials: 'same-origin',
3176 - headers: {
3177 - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
3178 - },
3179 - body: requestBody.toString(),
3180 - } ).finally( function () {
3181 - window.location.href = redirectUrl.toString();
3182 - } );
3183 - };
3184 -
3185 - promptField.addEventListener( 'input', updateWidgetState );
3186 - generateButton.addEventListener( 'click', triggerGeneration );
3187 - promptField.addEventListener( 'keydown', function ( event ) {
3188 - if ( event.key === 'Enter' && ( event.metaKey || event.ctrlKey ) ) {
3189 - event.preventDefault();
3190 - triggerGeneration();
3191 - }
3192 - } );
3193 -
3194 - updateWidgetState();
3195 -}() );
3196 -JS;
3197 -
3198 - wp_add_inline_script( 'srfm-ai-dashboard-widget', $inline_script );
3199 - }
3200 -
3201 - /**
3202 - * Count an editor visit that came from the front-end "Edit Form" pill.
3203 - *
3204 - * The pill is a plain link, so the click is attributed by the marker query arg
3205 - * it carries rather than by a front-end click handler. That keeps the front end
3206 - * script-free and adds no AJAX endpoint: the only thing on the page is still an
3207 - * anchor. It also measures the outcome that matters — the editor actually
3208 - * opening — instead of a click that may never land.
3209 - *
3210 - * Every decision here comes from server state. The query arg selects the code
3211 - * path; what gets counted is derived from the resolved post and the current
3212 - * user's capability on it. An absent, empty, misspelled or reused arg, a post
3213 - * that is not a SureForms form, and a user without `edit_post` on that form all
3214 - * fall through to no-op without an explicit branch.
3215 - *
3216 - * No nonce, deliberately: the pill is rendered into front-end HTML that may be
3217 - * page-cached, so a nonce would either be baked into the cache or be stale on
3218 - * arrival. The effect is a private usage counter for a user who can already edit
3219 - * the form, and nothing attacker-controlled reaches the analytics payload — the
3220 - * value sent is an integer read back from stored state.
3221 - *
3222 - * Because the marker is just a query arg, the invariant that bounds this is the
3223 - * dedup transient below, not the arg: a given editor moves the counter at most
3224 - * once per form per hour, no matter how many times the URL is requested. That is
3225 - * also what keeps the metric honest — without it a refresh or a back-navigation
3226 - * would count again, and each count is a read-modify-write of the whole
3227 - * `srfm_options` row, which holds unrelated settings.
3228 - *
3229 - * @return void
3230 - * @since 2.12.6
3231 - */
3232 - public function maybe_track_edit_form_button_click() {
3233 - // is_string() before sanitize_key(): `?srfm_edit_src[]=x` satisfies isset(),
3234 - // and wp_unslash() hands the array straight through. sanitize_key() only grew
3235 - // its is_scalar() guard after this plugin's minimum WordPress, so on the older
3236 - // supported versions that reaches strtolower( array ) — a TypeError on PHP 8,
3237 - // i.e. the one input shape that ended in a fatal rather than in the no-op the
3238 - // rest of this method guarantees.
3239 - $arg = Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG;
3240 -
3241 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only attribution marker; see docblock for why a nonce is neither possible nor needed.
3242 - $source = isset( $_GET[ $arg ] ) && is_string( $_GET[ $arg ] ) ? sanitize_key( wp_unslash( $_GET[ $arg ] ) ) : '';
3243 -
3244 - if ( 'embed' !== $source ) {
3245 - return;
3246 - }
3247 -
3248 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Same read-only path as above.
3249 - $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
3250 -
3251 - // Resolve the post type from the stored post, never from the request.
3252 - //
3253 - // The capability below reads as per-post but is not: sureforms_form is
3254 - // registered with an explicit capabilities map and no `map_meta_cap`
3255 - // (inc/post-types.php), so core short-circuits `edit_post` to the post type's
3256 - // `edit_post` capability — `manage_options` — without ever consulting $post_id.
3257 - // The real gate is therefore "site administrator", which is stricter than a
3258 - // per-form check, not weaker. Written down because a later `map_meta_cap` on
3259 - // the CPT would silently change what this line means with no diff here.
3260 - if ( 0 === $post_id || SRFM_FORMS_POST_TYPE !== get_post_type( $post_id ) ) {
3261 - return;
3262 - }
3263 -
3264 - if ( ! current_user_can( 'edit_post', $post_id ) ) {
3265 - return;
3266 - }
3267 -
3268 - // One count per editor per form per hour. Without this the metric measures
3269 - // "editor loads carrying the marker" rather than pill clicks — a refresh or a
3270 - // back-navigation re-counts — and a forged page could drive the counter, and
3271 - // the writes behind it, without bound.
3272 - $dedup_key = 'srfm_pill_click_' . get_current_user_id() . '_' . $post_id;
3273 -
3274 - if ( false !== get_transient( $dedup_key ) ) {
3275 - return;
3276 - }
3277 -
3278 - set_transient( $dedup_key, 1, HOUR_IN_SECONDS );
3279 -
3280 - $count = Helper::get_integer_value( Helper::get_srfm_option( 'edit_form_button_clicks', 0 ) ) + 1;
3281 - Helper::update_srfm_option( 'edit_form_button_clicks', $count );
3282 -
3283 - // $force = true because this is a cumulative counter, not a one-time event —
3284 - // it must re-send the latest count each cycle (bypasses one-time dedup).
3285 - Analytics::events()->track( 'edit_form_button_clicked', (string) $count, [], true );
3286 - }
3287 -
3288 - /**
3289 - * Let core strip the edit-attribution marker from the admin URL.
3290 - *
3291 - * Core's wp_admin_canonical_url() rewrites the address bar via replaceState() on
3292 - * admin_head, which runs after load-post.php — so the marker has already been
3293 - * counted by the time it is removed and no attribution is lost. Without this it
3294 - * lingers in the address bar, in bookmarks, and in the Referer header sent to
3295 - * every subresource the editor loads.
3296 - *
3297 - * @param array<string> $args Query args core already removes.
3298 - * @since 2.12.6
3299 - * @return array<string> Args with the marker appended.
3300 - */
3301 - public function add_removable_query_args( $args ) {
3302 - if ( ! is_array( $args ) ) {
3303 - return [ Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG ];
3304 - }
3305 -
3306 - $args[] = Generate_Form_Markup::EDIT_FORM_BUTTON_SOURCE_ARG;
3307 -
3308 - return $args;
3309 - }
3310 -
3311 - /**
3312 - * Track AI dashboard widget usage.
3313 - *
3314 - * @return void
3315 - * @since 2.12.1
3316 - */
3317 - public function track_ai_widget_usage() {
3318 - if ( ! check_ajax_referer( 'srfm_ai_widget_usage', 'nonce', false ) ) {
3319 - wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3320 - }
3321 -
3322 - if ( ! Helper::current_user_can() ) {
3323 - wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3324 - }
3325 -
3326 - $current_count = (int) Helper::get_srfm_option( 'ai_dashboard_widget_uses', 0 ) + 1;
3327 - Helper::update_srfm_option( 'ai_dashboard_widget_uses', $current_count );
3328 -
3329 - // Emit an analytics event so usage lands in the warehouse via events_record.
3330 - // $force = true because this is a cumulative counter, not a one-time event —
3331 - // it must re-send the latest count each cycle (bypasses one-time dedup).
3332 - Analytics::events()->track( 'ai_dashboard_widget_used', (string) $current_count, [], true );
3333 -
3334 - wp_send_json_success();
3335 - }
3336 -
3337 - /**
3338 1822 * Render the dashboard widget content.
3339 1823 *
3340 1824 * @return void
3341 1825 * @since 1.9.1
@@ -3384,721 +1868,68 @@
3384 1868 <?php
3385 1869 }
3386 1870
3387 1871 /**
3388 - * Classic dashboard notice when submissions keep failing.
1872 + * Build the shared HTML markup for admin notices.
3389 1873 *
3390 - * Hooked - admin_notices.
1874 + * @since 2.5.2
3391 1875 *
3392 - * Gated to the WP dashboard. The React notice already covers SureForms' own
3393 - * screens, so leaving this admin-wide would stack two warnings on one page.
1876 + * All text parameters must be pre-escaped by the caller (e.g. via esc_html__()).
1877 + * URL parameters must be pre-escaped via esc_url().
3394 1878 *
3395 - * Registered as [ $this, 'method' ] rather than a closure because
3396 - * suppress_foreign_admin_notices() strips any callback it cannot attribute to
3397 - * a SureForms class -- a closure here would be silently removed.
3398 - *
3399 - * @since 2.12.6
3400 - * @return void
1879 + * @param string $heading The notice heading text (pre-escaped).
1880 + * @param string $message The notice body text (pre-escaped).
1881 + * @param string $cta_url The primary CTA URL (pre-escaped).
1882 + * @param string $cta_text The primary CTA button text (pre-escaped).
1883 + * @param string $snooze_text The snooze button text (pre-escaped).
1884 + * @param string $dismiss_text The dismiss button text (pre-escaped).
1885 + * @param int $snooze_duration Snooze duration in seconds for the data-repeat-notice-after attribute.
1886 + * @param bool $external_cta Whether the CTA opens in a new tab and also dismisses the notice
1887 + * via the astra-notice-close class. Default false.
1888 + * @return string The notice HTML markup.
3401 1889 */
3402 - public function render_action_item_notices() {
3403 - // Shown across wp-admin, because someone whose forms are silently failing
3404 - // may not open the WP dashboard or SureForms for days.
3405 - //
3406 - // The one exclusion is SureForms' own dashboard: the Form Checks panel in
3407 - // its sidebar already lists these, and a banner above it would say the same
3408 - // thing twice on one screen.
3409 - if ( Helper::validate_request_context( 'sureforms_menu', 'page' ) ) {
3410 - return;
3411 - }
1890 + private function build_notice_markup( $heading, $message, $cta_url, $cta_text, $snooze_text, $dismiss_text, $snooze_duration, $external_cta = false ) {
1891 + $image_path = esc_url( SRFM_URL . 'admin/assets/sureforms-logo.png' );
1892 + $cta_class = $external_cta ? 'astra-notice-close button-primary' : 'button-primary';
1893 + $cta_attrs = $external_cta ? ' target="_blank" rel="noopener noreferrer"' : '';
3412 1894
3413 - $this->enqueue_notice_response_script();
3414 -
3415 - foreach ( $this->get_action_items() as $item ) {
3416 - $status = Helper::get_string_value( $item['status'] ?? '' );
3417 -
3418 - // Passing checks belong in the SureForms panel, not in wp-admin. A
3419 - // notice that says nothing is wrong is noise on every page load.
3420 - if ( 'success' === $status || '' === $status ) {
3421 - continue;
3422 - }
3423 -
3424 - // A fault reads as an error; advice reads as a warning. Both are shown,
3425 - // but they are not the same kind of message and should not look alike.
3426 - $class = 'error' === $status ? 'notice-error' : 'notice-warning';
3427 - ?>
3428 - <div class="notice <?php echo esc_attr( $class ); ?>">
3429 - <p><strong><?php echo esc_html( $item['title'] ); ?></strong></p>
3430 - <p><?php echo esc_html( $item['message'] ); ?></p>
3431 - <p>
3432 - <a
3433 - href="<?php echo esc_url( Helper::get_string_value( $item['cta_url'] ) ); ?>"
3434 - class="button button-primary"
3435 - data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>"
3436 - data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['cta_action'] ?? '' ) ); ?>"
3437 - <?php echo 0 === strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ? '' : 'target="_blank" rel="noopener noreferrer"'; ?>
3438 - >
3439 - <?php echo esc_html( $item['cta_label'] ); ?>
3440 - </a>
3441 - <?php if ( ! empty( $item['dismissible'] ) ) { ?>
3442 - <a href="<?php echo esc_url( $this->get_dismiss_action_item_url( Helper::get_string_value( $item['id'] ) ) ); ?>" class="button">
3443 - <?php esc_html_e( 'Dismiss', 'sureforms' ); ?>
3444 - </a>
3445 - <?php } ?>
3446 - </p>
3447 - </div>
3448 - <?php
3449 - }
3450 - }
3451 -
3452 - /**
3453 - * Dismiss an action item from the classic notice's link.
3454 - *
3455 - * Hooked - admin_post_srfm_dismiss_action_item_link.
3456 - *
3457 - * @since 2.12.6
3458 - * @return void
3459 - */
3460 - public function handle_dismiss_action_item_link() {
3461 - if ( ! Helper::current_user_can() ) {
3462 - wp_die( esc_html__( 'You do not have permission to do this.', 'sureforms' ), 403 );
3463 - }
3464 -
3465 - check_admin_referer( 'srfm_dismiss_action_item' );
3466 -
3467 - $item_id = isset( $_GET['item'] ) ? sanitize_key( wp_unslash( $_GET['item'] ) ) : '';
3468 -
3469 - $this->dismiss_action_item( $item_id );
3470 -
3471 - $referer = wp_get_referer();
3472 -
3473 - wp_safe_redirect( $referer ? $referer : admin_url() );
3474 - exit;
3475 - }
3476 -
3477 - /**
3478 - * Whether anything is currently wrong enough to warrant a notice.
3479 - *
3480 - * Deliberately re-derives the two conditions rather than calling
3481 - * get_action_items(), which records an impression as a side effect and must not
3482 - * run from a show_if callback.
3483 - *
3484 - * @since 2.12.6
3485 - * @return bool
3486 - */
3487 - public function has_action_item_warnings() {
3488 - if ( Client_Logger::has_persistent_failures() ) {
3489 - return true;
3490 - }
3491 -
3492 - if ( '' === Helper::get_active_caching_plugin() ) {
3493 - return false;
3494 - }
3495 -
3496 - $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
3497 -
3498 - return ! in_array( 'caching_plugin', $dismissed, true );
3499 - }
3500 -
3501 - /**
3502 - * Things on this site that need the owner's attention, newest concern first.
3503 - *
3504 - * Fed to the dashboard sidebar carousel. Each entry is self-describing so the
3505 - * front end has no rules of its own to keep in sync -- adding a new item here
3506 - * makes it appear with no JavaScript change.
3507 - *
3508 - * `dismissible` separates a fault from advice. A run of failed submissions is
3509 - * not something to wave away, and clears itself when a submission succeeds. A
3510 - * caching plugin being present is information, so it can be dismissed.
3511 - *
3512 - * @since 2.12.6
3513 - * @return array<int,array<string,mixed>>
3514 - */
3515 - public function get_action_items() {
3516 - if ( ! Helper::current_user_can() ) {
3517 - return [];
3518 - }
3519 -
3520 - $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
3521 - $warnings = [];
3522 - $passing = [];
3523 -
3524 - $open = Client_Logger::get_open_failures();
3525 -
3526 - // One item per category. They read differently to a site owner and must not
3527 - // be collapsed: submissions failing means visitors cannot reach you, a
3528 - // notification failing means you are not hearing about entries that did
3529 - // save, an integration failing means a third party is not receiving them.
3530 - $categories = [
3531 - 'submission' => [
3532 - 'id' => 'form_submission_error',
3533 - /* translators: %s: form title. */
3534 - 'title' => __( 'We noticed a form submission failure on %s.', 'sureforms' ),
3535 - 'generic' => __( 'We noticed a form submission failure.', 'sureforms' ),
3536 - 'message' => __( 'Visitors may be unable to reach you, and those entries were not saved.', 'sureforms' ),
3537 - 'passing' => __( 'Form submissions are completing normally.', 'sureforms' ),
3538 - ],
3539 - 'notification' => [
3540 - 'id' => 'notification_error',
3541 - /* translators: %s: form title. */
3542 - 'title' => __( 'We noticed a notification failure on %s.', 'sureforms' ),
3543 - 'generic' => __( 'We noticed a notification failure.', 'sureforms' ),
3544 - 'message' => __( 'The entry was saved, but the email telling you about it could not be sent — so new entries may be arriving without you hearing about them.', 'sureforms' ),
3545 - 'passing' => __( 'Notification emails are sending normally.', 'sureforms' ),
3546 - ],
3547 - 'integration' => [
3548 - 'id' => 'integration_error',
3549 - /* translators: %s: form title. */
3550 - 'title' => __( 'We noticed an integration failure on %s.', 'sureforms' ),
3551 - 'generic' => __( 'We noticed an integration failure.', 'sureforms' ),
3552 - 'message' => __( 'The entry was saved, but it could not be passed on to a connected service.', 'sureforms' ),
3553 - 'passing' => __( 'Integrations are running normally.', 'sureforms' ),
3554 - ],
3555 - ];
3556 -
3557 - foreach ( $categories as $category => $copy ) {
3558 - if ( ! isset( $open[ $category ] ) ) {
3559 - $passing[] = [
3560 - 'id' => $copy['id'],
3561 - 'status' => 'success',
3562 - 'title' => $copy['passing'],
3563 - 'message' => '',
3564 - 'cta_label' => '',
3565 - 'cta_url' => '',
3566 - 'dismissible' => false,
3567 - ];
3568 - continue;
3569 - }
3570 -
3571 - // Name the form. "A form is failing" is not actionable on a site with
3572 - // twenty of them, and the title is the first thing anyone asks for.
3573 - $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' );
3574 -
3575 - $warnings[] = [
3576 - 'id' => $copy['id'],
3577 - 'status' => 'error',
3578 - 'title' => '' !== $form_title
3579 - ? sprintf( $copy['title'], $form_title )
3580 - : $copy['generic'],
3581 - 'message' => $copy['message'],
3582 - 'cta_label' => __( 'Contact Support', 'sureforms' ),
3583 - 'cta_url' => $this->get_support_mailto_url(),
3584 - 'cta_action' => 'contact_support',
3585 - 'dismissible' => false,
3586 - ];
3587 - }
3588 -
3589 - $caching_plugin = Helper::get_active_caching_plugin();
3590 -
3591 - if ( '' === $caching_plugin ) {
3592 - $passing[] = [
3593 - 'id' => 'caching_plugin',
3594 - 'status' => 'success',
3595 - 'title' => __( 'No caching plugin that needs configuring was found.', 'sureforms' ),
3596 - 'message' => '',
3597 - 'cta_label' => '',
3598 - 'cta_url' => '',
3599 - 'dismissible' => false,
3600 - ];
3601 - } elseif ( ! in_array( 'caching_plugin', $dismissed, true ) ) {
3602 - $warnings[] = [
3603 - 'id' => 'caching_plugin',
3604 - 'status' => 'warning',
3605 - 'title' => sprintf(
3606 - /* translators: %s: caching plugin name. */
3607 - __( '%s may interfere with your forms.', 'sureforms' ),
3608 - $caching_plugin
3609 - ),
3610 - 'message' => __( 'Caching and JavaScript optimisation can serve a stale copy of your form or load its scripts out of order.', 'sureforms' ),
3611 - 'cta_label' => __( 'Help Me Fix', 'sureforms' ),
3612 - 'cta_url' => 'https://sureforms.com/docs/how-to-set-up-sureforms-with-caching-plugins/',
3613 - 'cta_action' => 'help_me_fix',
3614 - 'dismissible' => true,
3615 - ];
3616 - }
3617 -
3618 - // Warnings first: the point of the panel is what needs attention, with the
3619 - // passing checks below as reassurance rather than as the headline.
3620 - $items = array_merge( $warnings, $passing );
3621 -
3622 - $this->track_action_item_impressions( $warnings );
3623 -
3624 - /**
3625 - * Filter the dashboard action items.
3626 - *
3627 - * Each entry needs id, status ('warning' or 'success'), title, message,
3628 - * cta_label, cta_url and dismissible. Only ids in
3629 - * handle_dismiss_action_item()'s allowlist can actually be dismissed, so
3630 - * adding a dismissible item here also needs a line there.
3631 - *
3632 - * @since 2.12.6
3633 - *
3634 - * @param array<int,array<string,mixed>> $items Action items.
3635 - */
3636 - return Helper::apply_filters_as_array( 'srfm_action_items', $items );
3637 - }
3638 -
3639 - /**
3640 - * Dismiss one action item.
3641 - *
3642 - * Hooked - wp_ajax_srfm_dismiss_action_item.
3643 - *
3644 - * Only items get_action_items() marks dismissible can be dismissed, so a
3645 - * crafted request cannot silence a genuine fault.
3646 - *
3647 - * @since 2.12.6
3648 - * @return void
3649 - */
3650 - public function handle_dismiss_action_item() {
3651 - if ( ! Helper::current_user_can() ) {
3652 - wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
3653 - return;
3654 - }
3655 -
3656 - if ( ! check_ajax_referer( 'srfm_dismiss_action_item', 'nonce', false ) ) {
3657 - wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
3658 - return;
3659 - }
3660 -
3661 - $item_id = isset( $_POST['item_id'] ) ? sanitize_key( wp_unslash( $_POST['item_id'] ) ) : '';
3662 -
3663 - if ( ! $this->dismiss_action_item( $item_id ) ) {
3664 - wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 );
3665 - return;
3666 - }
3667 -
3668 - wp_send_json_success();
3669 - }
3670 -
3671 - /**
3672 - * Nonce-protected URL that repairs the entries table.
3673 - *
3674 - * Shared by both notice surfaces so there is one repair route, one nonce and one
3675 - * place that counts the click. Private, so it stays off the public API and out of
3676 - * the test-coverage gate.
3677 - *
3678 - * @since 2.12.6
3679 - * @return string
3680 - */
3681 - private function get_database_repair_url() {
3682 - return wp_nonce_url(
3683 - admin_url( 'admin-post.php?action=srfm_repair_entries_table' ),
3684 - 'srfm_repair_entries_table'
1895 + return sprintf(
1896 + '<div class="notice-image">
1897 + <img src="%1$s" class="custom-logo" alt="SureForms" itemprop="logo">
1898 + </div>
1899 + <div class="notice-content">
1900 + <div class="notice-heading">
1901 + %2$s
1902 + </div>
1903 + %3$s<br />
1904 + <div class="astra-review-notice-container">
1905 + <a href="%4$s" class="%5$s"%6$s>
1906 + %7$s
1907 + </a>
1908 + <span class="dashicons dashicons-clock" aria-hidden="true"></span>
1909 + <a href="#" data-repeat-notice-after="%8$s" class="astra-notice-close">
1910 + %9$s
1911 + </a>
1912 + <span class="dashicons dashicons-smiley" aria-hidden="true"></span>
1913 + <a href="#" class="astra-notice-close">
1914 + %10$s
1915 + </a>
1916 + </div>
1917 + </div>',
1918 + $image_path,
1919 + $heading,
1920 + $message,
1921 + $cta_url,
1922 + esc_attr( $cta_class ),
1923 + $cta_attrs,
1924 + $cta_text,
1925 + $snooze_duration,
1926 + $snooze_text,
1927 + $dismiss_text
3685 1928 );
3686 1929 }
3687 1930
3688 1931 /**
3689 - * The database notice body, which differs by what the repair will actually do.
3690 - *
3691 - * Two outcomes are possible and they are not equivalent to the person clicking:
3692 - * when the entries table exists under a different prefix — a changed
3693 - * `$table_prefix`, a restored dump, a security plugin that renamed tables and
3694 - * skipped ours — the repair renames it back and every stored entry comes with
3695 - * it. When there is nothing to adopt, the repair creates an empty table and the
3696 - * old submissions are not recoverable from here.
3697 - *
3698 - * Promising the wrong one is how a maintenance prompt turns into a complaint, so
3699 - * the copy states which is about to happen.
3700 - *
3701 - * @since 2.12.6
3702 - * @return string
3703 - */
3704 - private function get_database_notice_message() {
3705 - if ( '' !== Register::get_adoptable_entries_table() ) {
3706 - 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' );
3707 - }
3708 -
3709 - 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' );
3710 - }
3711 -
3712 - /**
3713 - * Count one sighting of the database notice, at most once per user per day.
3714 - *
3715 - * While the table is missing the notice renders on every admin page load, on two
3716 - * surfaces. Counting each render would rewrite the autoloaded `srfm_options` blob
3717 - * on every pageview of a site that is already broken, and one site left unfixed
3718 - * would dominate the aggregate. Throttling to a day per user answers the question
3719 - * that matters — how many people are seeing this — for one write.
3720 - *
3721 - * @since 2.12.6
3722 - * @return void
3723 - */
3724 - private function track_database_notice_impression() {
3725 - $user_id = get_current_user_id();
3726 -
3727 - if ( ! $user_id ) {
3728 - return;
3729 - }
3730 -
3731 - $key = 'srfm_db_notice_seen_' . $user_id;
3732 -
3733 - if ( get_transient( $key ) ) {
3734 - return;
3735 - }
3736 -
3737 - set_transient( $key, 1, DAY_IN_SECONDS );
3738 -
3739 - Analytics::events()->track( 'database_error_notice_shown', 'entries' );
3740 - }
3741 -
3742 - /**
3743 - * Build the setup-card payload (uncached). See get_form_setup_card().
3744 - *
3745 - * @since 2.12.4
3746 - * @return array<string,mixed>|null Card payload, or null when there is no candidate.
3747 - */
3748 - private static function compute_form_setup_card() {
3749 - if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || ! post_type_exists( SRFM_FORMS_POST_TYPE ) ) {
3750 - return null;
3751 - }
3752 -
3753 - // Negative cache. Deliberately not a `defined( 'ASTRA_SITES_VER' )` check:
3754 - // Starter Templates defines that constant in its main plugin file, so it only
3755 - // exists while the plugin is active, yet neither its uninstall.php nor its
3756 - // deactivation hook removes the import marker. Gating on the constant would
3757 - // silently switch this feature off for the very people it targets — anyone who
3758 - // imported a starter template and then removed the one-shot import plugin.
3759 - if ( 'no' === get_transient( self::NO_IMPORTED_FORMS_TRANSIENT ) ) {
3760 - return null;
3761 - }
3762 -
3763 - // Only forms created from an Astra Sites starter template — those carry the
3764 - // marker Starter Templates stamps on imported posts (self::ASTRA_SITES_IMPORT_META).
3765 - // Prime post + meta caches (the loop reads title, permalink and edit link
3766 - // per candidate) so this is a single query, not a follow-up per form.
3767 - $query = new \WP_Query(
3768 - [
3769 - 'post_type' => SRFM_FORMS_POST_TYPE,
3770 - 'post_status' => [ 'publish', 'draft', 'pending' ],
3771 - 'posts_per_page' => 10,
3772 - // ID breaks the tie: a starter-template import creates several forms
3773 - // within the same second, so post_date alone leaves "the newest form"
3774 - // up to MySQL and it can differ between page loads.
3775 - 'orderby' => [
3776 - 'date' => 'DESC',
3777 - 'ID' => 'DESC',
3778 - ],
3779 - 'no_found_rows' => true,
3780 - 'update_post_meta_cache' => true,
3781 - 'update_post_term_cache' => false,
3782 - 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to 10 recent forms; dashboard-only.
3783 - [
3784 - 'key' => self::ASTRA_SITES_IMPORT_META,
3785 - 'compare' => 'EXISTS',
3786 - ],
3787 - ],
3788 - ]
3789 - );
3790 -
3791 - // Nothing on this site carries the marker — remember that, so the query does
3792 - // not repeat on every load. Keyed on the query result rather than on anything
3793 - // user-specific, so it is safe to share, and invalidated the moment a post is
3794 - // stamped (see invalidate_starter_template_cache()).
3795 - if ( empty( $query->posts ) ) {
3796 - set_transient( self::NO_IMPORTED_FORMS_TRANSIENT, 'no', WEEK_IN_SECONDS );
3797 - }
3798 -
3799 - foreach ( $query->posts as $post ) {
3800 - $form_id = (int) $post->ID;
3801 -
3802 - if ( ! current_user_can( 'edit_post', $form_id ) ) {
3803 - continue;
3804 - }
3805 -
3806 - $edit_link = get_edit_post_link( $form_id, 'raw' );
3807 -
3808 - if ( empty( $edit_link ) ) {
3809 - continue;
3810 - }
3811 -
3812 - // The steps are shown as optional next-steps — their completion is not
3813 - // computed, so the widget simply lists the actions the owner can take.
3814 - return [
3815 - 'id' => $form_id,
3816 - 'title' => get_the_title( $form_id ),
3817 - 'edit_url' => $edit_link,
3818 - // Deep-links to the email-notification panel where supported; falls
3819 - // back to opening the editor when the focus handler isn't present.
3820 - 'email_url' => add_query_arg( 'srfm_focus', 'notifications', $edit_link ),
3821 - // Deep-links to the Form Confirmation panel (the Thank You message).
3822 - 'thankyou_url' => add_query_arg( 'srfm_focus', 'thankyou', $edit_link ),
3823 - // Front-end instant-form page. get_permalink() only yields a working
3824 - // URL for published forms; a draft/pending form has no public URL, so
3825 - // omit the view link there (the empty() guard hides the icon).
3826 - 'view_url' => 'publish' === $post->post_status ? (string) get_permalink( $form_id ) : '',
3827 - ];
3828 - }
3829 -
3830 - return null;
3831 - }
3832 -
3833 - /**
3834 - * Build the Thank You prompt payload (uncached). See get_thankyou_prompt_forms().
3835 - *
3836 - * @since 2.12.4
3837 - * @return array<int,array<string,mixed>> One entry, or none.
3838 - */
3839 - private static function compute_thankyou_prompt_forms() {
3840 - if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || ! post_type_exists( SRFM_FORMS_POST_TYPE ) ) {
3841 - return [];
3842 - }
3843 -
3844 - // Negative cache — this notice renders on every admin screen, so keeping the
3845 - // query off installs that can never match is what matters here. See
3846 - // self::NO_IMPORTED_FORMS_TRANSIENT for why this is not gated on whether
3847 - // Starter Templates is still active: the marker outlives the plugin.
3848 - if ( 'no' === get_transient( self::NO_IMPORTED_FORMS_TRANSIENT ) ) {
3849 - return [];
3850 - }
3851 -
3852 - // Only forms imported from a Starter Templates (Astra Sites) starter
3853 - // template — see self::ASTRA_SITES_IMPORT_META. Prime post + meta caches
3854 - // (the loop reads meta, title and creation time per candidate) so this is a
3855 - // single query rather than the main query plus a follow-up per form.
3856 - $query = new \WP_Query(
3857 - [
3858 - 'post_type' => SRFM_FORMS_POST_TYPE,
3859 - 'post_status' => 'publish',
3860 - 'posts_per_page' => 10,
3861 - // ID breaks the tie — an import creates several forms in the same
3862 - // second, so post_date alone makes "newest" MySQL-dependent.
3863 - 'orderby' => [
3864 - 'date' => 'DESC',
3865 - 'ID' => 'DESC',
3866 - ],
3867 - 'no_found_rows' => true,
3868 - 'update_post_meta_cache' => true,
3869 - 'update_post_term_cache' => false,
3870 - 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to 10 recent forms; admin-notice only.
3871 - [
3872 - 'key' => self::ASTRA_SITES_IMPORT_META,
3873 - 'compare' => 'EXISTS',
3874 - ],
3875 - ],
3876 - ]
3877 - );
3878 -
3879 - // Nothing on this site carries the marker — remember that, so the query does
3880 - // not repeat on every load. Keyed on the query result rather than on anything
3881 - // user-specific, so it is safe to share, and invalidated the moment a post is
3882 - // stamped (see invalidate_starter_template_cache()).
3883 - if ( empty( $query->posts ) ) {
3884 - set_transient( self::NO_IMPORTED_FORMS_TRANSIENT, 'no', WEEK_IN_SECONDS );
3885 - }
3886 -
3887 - $prompts = [];
3888 - $now = time();
3889 -
3890 - foreach ( $query->posts as $post ) {
3891 - $form_id = (int) $post->ID;
3892 -
3893 - if ( ! current_user_can( 'edit_post', $form_id ) ) {
3894 - continue;
3895 - }
3896 -
3897 - $steps = [
3898 - // A destination for replies: an enabled notification with a recipient.
3899 - 'replies' => ! self::form_has_reply_destination( $form_id ),
3900 - // The thank-you message is still the shipped default.
3901 - 'thankyou' => self::is_default_confirmation_message( $form_id ),
3902 - ];
3903 -
3904 - // Nothing left to finish — no card for this form.
3905 - if ( ! $steps['replies'] && ! $steps['thankyou'] ) {
3906 - continue;
3907 - }
3908 -
3909 - $edit_link = get_edit_post_link( $form_id, 'raw' );
3910 -
3911 - if ( empty( $edit_link ) ) {
3912 - continue;
3913 - }
3914 -
3915 - $created = get_post_time( 'U', true, $form_id );
3916 - $days_ago = is_int( $created ) ? (int) floor( ( $now - $created ) / DAY_IN_SECONDS ) : 0;
3917 -
3918 - $prompts[] = [
3919 - 'id' => $form_id,
3920 - 'title' => get_the_title( $form_id ),
3921 - 'days_ago' => max( 0, $days_ago ),
3922 - 'steps' => $steps,
3923 - 'edit_url' => $edit_link,
3924 - // The editor reads srfm_focus to open the matching settings tab:
3925 - // "notifications" lands on Email Notification (where the reply
3926 - // destination is set, so the CTA can actually clear that step) and
3927 - // "thankyou" on Form Confirmation.
3928 - 'replies_url' => add_query_arg( 'srfm_focus', 'notifications', $edit_link ),
3929 - 'thankyou_url' => add_query_arg( 'srfm_focus', 'thankyou', $edit_link ),
3930 - ];
3931 -
3932 - // One card is enough — surface only the latest form needing setup.
3933 - break;
3934 - }
3935 -
3936 - return $prompts;
3937 - }
3938 -
3939 - /**
3940 - * Build the Thank You notice's inner markup (title, sentence, action buttons).
3941 - *
3942 - * @param array<string,mixed> $form Prompt payload from get_thankyou_prompt_forms().
3943 - *
3944 - * @since 2.12.4
3945 - * @return string
3946 - */
3947 - private static function build_thankyou_notice_markup( $form ) {
3948 - // The prompt only surfaces starter-template imports (see the meta gate), so
3949 - // the form was created for the user rather than by them. Kept generic — no
3950 - // per-step claim — so it is always accurate whatever the user has since
3951 - // changed, while the action buttons point to the specific things to finish.
3952 - $sentence = __( 'We’ve already created this form for you. Finish customising it so it’s ready to collect real submissions.', 'sureforms' );
3953 -
3954 - return self::build_srfm_notice_markup(
3955 - sprintf(
3956 - /* translators: %s: form name. */
3957 - __( 'Finish setting up “%s”', 'sureforms' ),
3958 - $form['title']
3959 - ),
3960 - $sentence,
3961 - [
3962 - [
3963 - 'text' => __( 'Edit form', 'sureforms' ),
3964 - 'url' => $form['edit_url'],
3965 - 'primary' => true,
3966 - 'class' => 'srfm-ty-edit-form',
3967 - 'external' => true,
3968 - ],
3969 - [
3970 - 'text' => __( 'Edit the Thank You message', 'sureforms' ),
3971 - 'url' => $form['thankyou_url'],
3972 - 'class' => 'srfm-ty-edit-thankyou',
3973 - 'external' => true,
3974 - ],
3975 - [
3976 - 'text' => __( 'Set where replies go', 'sureforms' ),
3977 - 'url' => $form['replies_url'],
3978 - 'class' => 'srfm-ty-set-replies',
3979 - 'external' => true,
3980 - ],
3981 - ]
3982 - );
3983 - }
3984 -
3985 - /**
3986 - * Build the shared SureForms admin-notice body: title, sentence, action row.
3987 - *
3988 - * One builder for every SureForms notice so they cannot drift into looking like
3989 - * two different plugins. Everything is escaped here rather than by the caller —
3990 - * the notices library runs the result through wp_kses_post(), which would strip
3991 - * anything richer anyway.
3992 - *
3993 - * @param string $title Notice heading.
3994 - * @param string $text Supporting sentence.
3995 - * @param array<int,array<string,mixed>> $actions Action links. Each accepts
3996 - * text, url, and optionally
3997 - * primary, class, external,
3998 - * dismiss and snooze (seconds).
3999 - * @since 2.12.6
4000 - * @return string
4001 - */
4002 - private static function build_srfm_notice_markup( $title, $text, $actions ) {
4003 - ob_start();
4004 - ?>
4005 - <p class="srfm-notice__title"><?php echo esc_html( $title ); ?></p>
4006 - <p class="srfm-notice__text"><?php echo esc_html( $text ); ?></p>
4007 - <p class="srfm-notice__actions">
4008 - <?php
4009 - foreach ( $actions as $action ) {
4010 - if ( empty( $action['text'] ) || ! isset( $action['url'] ) ) {
4011 - continue;
4012 - }
4013 -
4014 - $classes = [ 'button' ];
4015 -
4016 - if ( ! empty( $action['primary'] ) ) {
4017 - $classes[] = 'button-primary';
4018 - }
4019 -
4020 - // astra-notice-close is what the library binds its dismiss handler to.
4021 - if ( ! empty( $action['dismiss'] ) ) {
4022 - $classes[] = 'astra-notice-close';
4023 - }
4024 -
4025 - if ( ! empty( $action['class'] ) ) {
4026 - $classes[] = $action['class'];
4027 - }
4028 - ?>
4029 - <a
4030 - class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
4031 - href="<?php echo esc_url( $action['url'] ); ?>"
4032 - <?php echo empty( $action['snooze'] ) ? '' : ' data-repeat-notice-after="' . esc_attr( (string) $action['snooze'] ) . '"'; ?>
4033 - <?php echo empty( $action['external'] ) ? '' : ' target="_blank" rel="noopener noreferrer"'; ?>
4034 - ><?php echo esc_html( $action['text'] ); ?></a>
4035 - <?php
4036 - }
4037 - ?>
4038 - </p>
4039 - <?php
4040 - return (string) ob_get_clean();
4041 - }
4042 -
4043 - /**
4044 - * Determine whether a notice callback is owned by SureForms.
4045 - *
4046 - * Recognises object methods on classes in the `SRFM` / `SRFM_PRO` namespaces
4047 - * as well as the bundled notices libraries (`BSF_Admin_Notices` and the
4048 - * legacy `Astra_Notices` alias). Everything else is treated as foreign.
4049 - *
4050 - * @param callable|array|string|null $function The registered callback function.
4051 - * @since 2.10.0
4052 - * @return bool True when the callback belongs to SureForms, false otherwise.
4053 - */
4054 - private function is_sureforms_owned_notice_callback( $function ) {
4055 - $class_name = '';
4056 -
4057 - if ( is_array( $function ) && isset( $function[0] ) ) {
4058 - // Object or static method callback represented as an array. The first
4059 - // element is either the object instance or the fully qualified class name.
4060 - $class_name = is_object( $function[0] ) ? get_class( $function[0] ) : (string) $function[0];
4061 - } elseif ( is_string( $function ) && false !== strpos( $function, '::' ) ) {
4062 - // Static method passed as "Class::method".
4063 - $class_name = strstr( $function, '::', true );
4064 - }
4065 -
4066 - if ( '' === $class_name ) {
4067 - // Plain function callbacks are never owned by SureForms.
4068 - return false;
4069 - }
4070 -
4071 - // SureForms (free and pro) namespaced classes. Pro's real namespace is
4072 - // `SRFM_Pro\` (case-sensitive) — not the all-caps `SRFM_PRO_` constant
4073 - // prefix — so match it case-insensitively to be safe.
4074 - if ( 0 === strpos( $class_name, 'SRFM\\' ) || 0 === stripos( $class_name, 'SRFM_Pro\\' ) ) {
4075 - return true;
4076 - }
4077 -
4078 - // Bundled notices library shipped with SureForms.
4079 - return in_array( $class_name, [ 'BSF_Admin_Notices', 'Astra_Notices' ], true );
4080 - }
4081 -
4082 - /**
4083 - * Whether the current admin page is a SureForms-owned screen identified by a
4084 - * `sureforms_*` / `srfm_*` `page` query slug. Complements
4085 - * {@see Helper::is_sureforms_admin_page()} so foreign-notice suppression also
4086 - * covers the payments / quiz / survey / learn / SMTP / partial-entries screens
4087 - * that the core helper does not enumerate. Read-only screen check.
4088 - *
4089 - * @since 2.10.0
4090 - * @return bool
4091 - */
4092 - private function is_sureforms_owned_admin_page() {
4093 - if ( ! is_admin() || empty( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection, no state change.
4094 - return false;
4095 - }
4096 - $page = sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection, no state change.
4097 - return 0 === strpos( $page, 'sureforms' ) || 0 === strpos( $page, 'srfm' );
4098 - }
4099 -
4100 - /**
4101 1932 * Callback for displaying the rating notice conditionally.
4102 1933 *
4103 1934 * Returns true if the user has 3 or more published forms or 3 or more form entries.
4104 1935 *
@@ -4178,9 +2009,14 @@
4178 2009 </svg>
4179 2010 <span><?php echo esc_html( $this->get_random_premium_feature_text() ); ?></span>
4180 2011 </div>
4181 2012 <?php
4182 - $upgrade_url = Helper::get_sureforms_website_url( 'pricing', [ 'utm_medium' => 'dashboard-widget' ] );
2013 + $upgrade_url = add_query_arg(
2014 + [
2015 + 'utm_medium' => 'dashboard-widget',
2016 + ],
2017 + Helper::get_sureforms_website_url( 'pricing' )
2018 + );
4183 2019 ?>
4184 2020 <a href="<?php echo esc_url( $upgrade_url ); ?>" class="srfm-upgrade-link" target="_blank">
4185 2021 <?php esc_html_e( 'Upgrade', 'sureforms' ); ?>
4186 2022 </a>
@@ -4214,216 +2050,5 @@
4214 2050
4215 2051 return false;
4216 2052 }
4217 2053
4218 - /**
4219 - * Nonced URL that dismisses one action item without JavaScript.
4220 - *
4221 - * The classic notice cannot use the AJAX dismissal the carousel uses, and
4222 - * WordPress's own `is-dismissible` only hides the notice for that pageview.
4223 - *
4224 - * @param string $item_id Item to dismiss.
4225 - * @since 2.12.6
4226 - * @return string
4227 - */
4228 - private function get_dismiss_action_item_url( $item_id ) {
4229 - return wp_nonce_url(
4230 - add_query_arg(
4231 - [
4232 - 'action' => 'srfm_dismiss_action_item_link',
4233 - 'item' => $item_id,
4234 - ],
4235 - admin_url( 'admin-post.php' )
4236 - ),
4237 - 'srfm_dismiss_action_item'
4238 - );
4239 - }
4240 -
4241 - /**
4242 - * Count one sighting of each warning, at most once per user per day.
4243 - *
4244 - * Throttled because the classic notice renders on every admin page: counting
4245 - * each render would measure how much wp-admin someone browses, not how many
4246 - * sites are affected. A day per user answers the question that matters -- how
4247 - * many people are seeing this -- for one option write.
4248 - *
4249 - * Passing checks are not counted. "Nothing is wrong" is not an impression.
4250 - *
4251 - * @param array<int,array<string,mixed>> $warnings Warning items only.
4252 - * @since 2.12.6
4253 - * @return void
4254 - */
4255 - private function track_action_item_impressions( $warnings ) {
4256 - if ( empty( $warnings ) || wp_doing_ajax() ) {
4257 - return;
4258 - }
4259 -
4260 - $user_id = get_current_user_id();
4261 -
4262 - if ( ! $user_id ) {
4263 - return;
4264 - }
4265 -
4266 - $counts = Helper::get_array_value( Helper::get_srfm_option( 'action_item_impressions', [] ) );
4267 - $changed = false;
4268 -
4269 - foreach ( $warnings as $warning ) {
4270 - $item_id = Helper::get_string_value( $warning['id'] ?? '' );
4271 -
4272 - if ( '' === $item_id ) {
4273 - continue;
4274 - }
4275 -
4276 - $seen_key = 'srfm_action_item_seen_' . $item_id . '_' . $user_id;
4277 -
4278 - if ( get_transient( $seen_key ) ) {
4279 - continue;
4280 - }
4281 -
4282 - set_transient( $seen_key, 1, DAY_IN_SECONDS );
4283 -
4284 - $counts[ $item_id ] = Helper::get_integer_value( $counts[ $item_id ] ?? 0 ) + 1;
4285 - $changed = true;
4286 -
4287 - // Cumulative, so $force = true: each new count is a new value and is
4288 - // re-sent, while an identical repeat short-circuits inside track().
4289 - Analytics::events()->track(
4290 - $item_id . '_notice_shown',
4291 - (string) $counts[ $item_id ],
4292 - [],
4293 - true
4294 - );
4295 - }
4296 -
4297 - if ( $changed ) {
4298 - Helper::update_srfm_option( 'action_item_impressions', $counts );
4299 - }
4300 - }
4301 -
4302 - /**
4303 - * Pre-addressed support email for a run of failed submissions.
4304 - *
4305 - * Carries the details support would otherwise have to ask for, so the first
4306 - * reply can be an answer rather than a questionnaire, along with the recent log
4307 - * entries inline.
4308 - *
4309 - * The log is pasted into the body rather than attached because mailto has no
4310 - * attachment parameter -- browsers drop any attempt to add one -- and it is a
4311 - * tail rather than the whole file because a megabyte of JSON would exceed the
4312 - * URL length every mail client enforces.
4313 - *
4314 - * @since 2.12.6
4315 - * @return string
4316 - */
4317 - private function get_support_mailto_url() {
4318 - $subject = sprintf(
4319 - /* translators: %s: site host. */
4320 - __( 'SureForms: form submissions are failing on %s', 'sureforms' ),
4321 - Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) )
4322 - );
4323 -
4324 - $log = Client_Logger::get_tail();
4325 - $body = $this->get_support_message();
4326 - $body .= "\r\n\r\n" . '---' . "\r\n";
4327 -
4328 - if ( '' === $log['text'] ) {
4329 - $body .= __( 'Debug log: no entries recorded.', 'sureforms' );
4330 - } else {
4331 - $body .= sprintf(
4332 - /* translators: 1: entries shown, 2: entries recorded. */
4333 - __( 'Debug log (most recent %1$d of %2$d entries)', 'sureforms' ),
4334 - $log['shown'],
4335 - $log['total']
4336 - ) . "\r\n";
4337 -
4338 - // Fenced so it survives a reply and reads as data rather than prose in
4339 - // clients that render Markdown.
4340 - $body .= '```' . "\r\n" . str_replace( "\n", "\r\n", $log['text'] ) . "\r\n" . '```';
4341 -
4342 - if ( $log['shown'] < $log['total'] ) {
4343 - $body .= "\r\n\r\n" . __( 'Older entries were left out to keep this email within the length a mail client accepts. The full log can be downloaded from SureForms → Settings → General.', 'sureforms' );
4344 - }
4345 - }
4346 -
4347 - return 'mailto:support@sureforms.com?' . http_build_query(
4348 - [
4349 - 'subject' => $subject,
4350 - 'body' => $body,
4351 - ],
4352 - '',
4353 - '&',
4354 - PHP_QUERY_RFC3986
4355 - );
4356 - }
4357 -
4358 - /**
4359 - * Diagnostics block for the support email.
4360 - *
4361 - * Carries what support would otherwise have to ask for, so the first reply can
4362 - * be an answer rather than a questionnaire.
4363 - *
4364 - * @since 2.12.6
4365 - * @return string
4366 - */
4367 - private function get_support_message() {
4368 - global $wp_version;
4369 -
4370 - $count = Client_Logger::get_fault_streak();
4371 -
4372 - $lines = [
4373 - __( 'Hello SureForms support,', 'sureforms' ),
4374 - '',
4375 - sprintf(
4376 - /* translators: %d: number of consecutive failed submissions. */
4377 - _n(
4378 - 'SureForms has recorded %d form submission in a row that could not be completed.',
4379 - 'SureForms has recorded %d form submissions in a row that could not be completed.',
4380 - $count,
4381 - 'sureforms'
4382 - ),
4383 - $count
4384 - ),
4385 - '',
4386 - '---',
4387 - __( 'Site details', 'sureforms' ),
4388 - 'Site: ' . home_url(),
4389 - 'SureForms: ' . SRFM_VER,
4390 - 'SureForms Pro: ' . ( Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : __( 'not active', 'sureforms' ) ),
4391 - 'WordPress: ' . Helper::get_string_value( $wp_version ),
4392 - 'PHP: ' . PHP_VERSION,
4393 - 'Caching: ' . ( '' !== Helper::get_active_caching_plugin() ? Helper::get_active_caching_plugin() : __( 'none detected', 'sureforms' ) ),
4394 - 'Consecutive failures: ' . $count,
4395 - ];
4396 -
4397 - return implode( "\r\n", $lines );
4398 - }
4399 -
4400 - /**
4401 - * Record one dismissal, shared by the AJAX and no-JS entry points.
4402 - *
4403 - * Allowlisted, so only advisory items can be dismissed. A run of failed
4404 - * submissions is a fault and must stay put until it actually resolves --
4405 - * otherwise a crafted request could silence the one message that matters.
4406 - *
4407 - * @param string $item_id Item to dismiss.
4408 - * @since 2.12.6
4409 - * @return bool False when the id is not dismissible.
4410 - */
4411 - private function dismiss_action_item( $item_id ) {
4412 - if ( ! in_array( $item_id, [ 'caching_plugin' ], true ) ) {
4413 - return false;
4414 - }
4415 -
4416 - $dismissed = Helper::get_array_value( Helper::get_srfm_option( 'dismissed_action_items', [] ) );
4417 -
4418 - if ( ! in_array( $item_id, $dismissed, true ) ) {
4419 - $dismissed[] = $item_id;
4420 - Helper::update_srfm_option( 'dismissed_action_items', $dismissed );
4421 -
4422 - // Recorded here rather than at each caller: both the cross in the
4423 - // dashboard panel and the no-JS link in the classic notice land here.
4424 - Analytics::events()->track( $item_id . '_notice_dismiss', 'dismissed' );
4425 - }
4426 -
4427 - return true;
4428 - }
4429 2054 }