PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.10.0
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.10.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 +1 -1265 2.12.52.10.0 View file →
@@ -40,42 +40,8 @@
40 40 */
41 41 public const RATING_NOTICE_THRESHOLD = 3;
42 42
43 43 /**
44 - * Post meta the Starter Templates (Astra Sites) plugin stamps on every post it
45 - * imports. The "Finish setting up" Thank You prompt (#3030) scopes to these
46 - * forms only. Owned by a plugin that is NOT a SureForms dependency: on installs
47 - * without Starter Templates nothing carries this meta and the prompt never shows.
48 - *
49 - * @since 2.12.4
50 - */
51 - public const ASTRA_SITES_IMPORT_META = '_astra_sites_imported_post';
52 -
53 - /**
54 - * Negative-cache transient: no form on this site carries the import marker.
55 - *
56 - * Set only when the marker query itself returns zero posts, which is a
57 - * site-wide fact rather than a per-user one, and cleared as soon as any post is
58 - * stamped with the marker (see invalidate_starter_template_cache()). This keeps
59 - * the query off the majority of installs without tying the features to whether
60 - * Starter Templates happens to still be active — the marker outlives it.
61 - *
62 - * @since 2.12.4
63 - */
64 - public const NO_IMPORTED_FORMS_TRANSIENT = 'srfm_no_starter_template_forms';
65 -
66 - /**
67 - * Inline CSS for Quill 1.x (react-quill) list markers.
68 - *
69 - * Quill 1.x renders bullet/numbered list markers via CSS ::before pseudo-elements,
70 - * whereas the vendor quill.snow.css targets .ql-ui child elements (Quill 2.x approach).
71 - * This constant is shared by enqueue_styles() and enqueue_scripts() to prevent drift.
72 - *
73 - * @since 2.5.2
74 - */
75 - public const QUILL_1X_INLINE_CSS = '.ql-editor ul,.ql-editor ol{padding-left:1.5em}.ql-editor ul>li,.ql-editor ol>li{list-style-type:none}.ql-editor ol li:not(.ql-direction-rtl),.ql-editor ul li:not(.ql-direction-rtl){padding-left:1.5em}.ql-editor ol li.ql-direction-rtl,.ql-editor ul li.ql-direction-rtl{padding-right:1.5em}.ql-editor ul>li::before{content:"\2022"}.ql-editor li::before{display:inline-block;white-space:nowrap;width:1.2em}.ql-editor li:not(.ql-direction-rtl)::before{margin-left:-1.5em;margin-right:.3em;text-align:right}.ql-editor li.ql-direction-rtl::before{margin-left:.3em;margin-right:-1.5em}.ql-editor ol li{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;counter-increment:list-0}.ql-editor ol li::before{content:counter(list-0,decimal) ". "}.ql-editor ol li.ql-indent-1{counter-increment:list-1;counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-1::before{content:counter(list-1,lower-alpha) ". "}.ql-editor ol li.ql-indent-2{counter-increment:list-2;counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-2::before{content:counter(list-2,lower-roman) ". "}.ql-editor ol li.ql-indent-3{counter-increment:list-3;counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-3::before{content:counter(list-3,decimal) ". "}.ql-editor ol li.ql-indent-4{counter-increment:list-4;counter-reset:list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-4::before{content:counter(list-4,lower-alpha) ". "}.ql-editor ol li.ql-indent-5{counter-increment:list-5;counter-reset:list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-5::before{content:counter(list-5,lower-roman) ". "}.ql-editor ol li.ql-indent-6{counter-increment:list-6;counter-reset:list-7 list-8 list-9}.ql-editor ol li.ql-indent-6::before{content:counter(list-6,decimal) ". "}.ql-editor ol li.ql-indent-7{counter-increment:list-7;counter-reset:list-8 list-9}.ql-editor ol li.ql-indent-7::before{content:counter(list-7,lower-alpha) ". "}.ql-editor ol li.ql-indent-8{counter-increment:list-8;counter-reset:list-9}.ql-editor ol li.ql-indent-8::before{content:counter(list-8,lower-roman) ". "}.ql-editor ol li.ql-indent-9{counter-increment:list-9}.ql-editor ol li.ql-indent-9::before{content:counter(list-9,decimal) ". "}';
76 -
77 - /**
78 44 * Dashboard widget entries data.
79 45 *
80 46 * @var array
81 47 * @since 1.9.1
@@ -98,34 +64,8 @@
98 64 */
99 65 private static $sureforms_page_default_capability = 'manage_options';
100 66
101 67 /**
102 - * Request memo for the "Finish setting up" Thank You prompt (#3030).
103 - *
104 - * A static property (not a function-local static) so tests can reset it via
105 - * reflection / reset_thankyou_prompt_cache() — otherwise the first call pins
106 - * the value for the whole process and the feature is untestable.
107 - *
108 - * @var array<int,array<string,mixed>>|null
109 - * @since 2.12.4
110 - */
111 - private static $thankyou_prompt_cache = null;
112 -
113 - /**
114 - * Request memo for the dashboard setup-checklist card (#3031).
115 - *
116 - * A static property (not a function-local static) so tests can reset it via
117 - * reset_form_setup_card_cache() and exercise the populated path — a
118 - * function-local static pins the first result for the whole process. Keyed by
119 - * user id since the payload derives from that user's capabilities.
120 - * `false` means "not computed yet"; `null`/array is a computed result.
121 - *
122 - * @var array<int,array<string,mixed>|null>
123 - * @since 2.12.4
124 - */
125 - private static $setup_card_cache = [];
126 -
127 - /**
128 68 * Class constructor.
129 69 *
130 70 * @return void
131 71 * @since 0.0.1
@@ -179,110 +119,19 @@
179 119 add_action( 'wp_ajax_should_show_pointer', [ $this, 'pointer_should_show' ] );
180 120 add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] );
181 121 add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] );
182 122 add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] );
183 - add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] );
184 123
185 124 // Register dashboard widget only if there are recent entries.
186 125 add_action( 'admin_init', [ $this, 'maybe_register_dashboard_widget' ] );
187 126
188 - // Enqueue the AI quick draft widget script on the dashboard screen.
189 - add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_ai_dashboard_widget_assets' ] );
190 -
191 - // "Finish setting up" checklist widget on the main WP dashboard (#3031).
192 - add_action( 'wp_dashboard_setup', [ $this, 'register_form_setup_widget' ] );
193 - add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_form_setup_widget_assets' ] );
194 -
195 - // Drop the "no imported forms" negative cache as soon as a post is stamped
196 - // with the import marker, so a template imported after the cache was written
197 - // surfaces immediately instead of waiting for the transient to expire.
198 - add_action( 'added_post_meta', [ $this, 'invalidate_starter_template_cache' ], 10, 3 );
199 - add_action( 'updated_post_meta', [ $this, 'invalidate_starter_template_cache' ], 10, 3 );
200 -
201 127 // Save first form creation time stamp.
202 128 add_action( 'admin_init', [ $this, 'save_first_form_creation_time_stamp' ] );
203 129 add_action( 'admin_notices', [ $this, 'display_srfm_rating_notice' ] );
204 130 add_action( 'admin_notices', [ $this, 'display_srfm_getting_started_notice' ] );
205 -
206 - // "Finish setting up" prompt, shown as an Astra Notices admin notice on
207 - // every admin screen except the dashboard (#3030).
208 - add_action( 'admin_notices', [ $this, 'render_thankyou_prompt_notice' ] );
209 -
210 - /**
211 - * Suppress foreign (third-party) admin notices on SureForms admin screens.
212 - *
213 - * Some plugins (e.g. Ninja Forms) print large promotional banners on every
214 - * admin page via the admin_notices / all_admin_notices / network_admin_notices
215 - * hooks. These bleed onto SureForms' own React admin screens and break the UI.
216 - * We run at the EARLIEST priority on each notice hook (all third-party
217 - * callbacks are registered before these hooks fire, during admin_init /
218 - * plugin load) and strip the foreign ones before they are echoed, while
219 - * preserving SureForms' own notices. Scoped strictly to SureForms screens.
220 - */
221 - add_action( 'admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
222 - add_action( 'all_admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
223 - add_action( 'network_admin_notices', [ $this, 'suppress_foreign_admin_notices' ], PHP_INT_MIN );
224 131 }
225 132
226 133 /**
227 - * Remove third-party admin notices on SureForms admin screens.
228 - *
229 - * Iterates over the callbacks registered on the admin notice hooks and
230 - * removes any that are not owned by SureForms. A callback is considered
231 - * owned by SureForms when it belongs to a class in the `SRFM` / `SRFM_PRO`
232 - * namespaces or to the bundled `BSF_Admin_Notices` / `Astra_Notices`
233 - * notices library. SureForms' own notices are therefore preserved while
234 - * foreign promotional banners are suppressed.
235 - *
236 - * This callback is hooked at `PHP_INT_MIN` so that it runs first on each
237 - * notice hook and removes the foreign callbacks before WordPress echoes
238 - * them (WP_Hook honours removals made during iteration). It is strictly
239 - * scoped to SureForms admin screens via {@see Helper::is_sureforms_admin_page()}
240 - * so no other admin page is affected.
241 - *
242 - * @since 2.10.0
243 - * @return void
244 - */
245 - public function suppress_foreign_admin_notices() {
246 - // Bail early if we are not on a SureForms admin screen. This keeps the
247 - // suppression strictly scoped and avoids touching any other admin page.
248 - // is_sureforms_admin_page() covers the core screens (dashboard, add-new,
249 - // settings, entries, the form CPT); we additionally match any admin page
250 - // whose `page` slug is SureForms-owned (sureforms_* / srfm_*) so the
251 - // suppression also applies to the payments/quiz/survey/learn/SMTP screens.
252 - if ( ! Helper::is_sureforms_admin_page() && ! $this->is_sureforms_owned_admin_page() ) {
253 - return;
254 - }
255 -
256 - global $wp_filter;
257 -
258 - // The hook currently being fired (admin_notices, all_admin_notices or network_admin_notices).
259 - $current_hook = current_action();
260 -
261 - if ( empty( $current_hook ) || empty( $wp_filter[ $current_hook ] ) || ! ( $wp_filter[ $current_hook ] instanceof \WP_Hook ) ) {
262 - return;
263 - }
264 -
265 - foreach ( $wp_filter[ $current_hook ]->callbacks as $priority => $callbacks ) {
266 - foreach ( $callbacks as $callback ) {
267 - $function = $callback['function'] ?? null;
268 -
269 - // Never remove our own suppression callback.
270 - if ( is_array( $function ) && isset( $function[0] ) && $function[0] === $this && 'suppress_foreign_admin_notices' === $function[1] ) {
271 - continue;
272 - }
273 -
274 - // Preserve SureForms-owned notices, remove everything else.
275 - if ( $this->is_sureforms_owned_notice_callback( $function ) ) {
276 - continue;
277 - }
278 -
279 - remove_action( $current_hook, $function, $priority );
280 - }
281 - }
282 - }
283 -
284 - /**
285 134 * Get the first form creation time stamp.
286 135 *
287 136 * @since 1.10.1
288 137 * @return int|false
@@ -310,643 +159,8 @@
310 159 return is_int( $first_form_creation_time_stamp ) && $first_form_creation_time_stamp > 0;
311 160 }
312 161
313 162 /**
314 - * Whether a form's confirmation message is still the shipped default.
315 - *
316 - * Compared on tag-stripped, entity-decoded, whitespace-collapsed text rather
317 - * than raw HTML: the default is stored with a base64 icon on creation but
318 - * regenerated with a URL icon, so the markup differs while the wording does
319 - * not, and a starter-template import can store a literal apostrophe where the
320 - * generated default carries the encoded `&#039;` — decoding entities makes both
321 - * compare equal. Any real edit to the heading or body text changes the text and
322 - * flips this to false, which is exactly when the prompt should stop showing.
323 - *
324 - * Locale caveat: the comparison target is translated at call time, so a form
325 - * whose default was stored under a different active locale won't match. That
326 - * fails safe — the prompt simply doesn't show — never a false nag.
327 - *
328 - * @param int $form_id Form post ID.
329 - *
330 - * @since 2.12.4
331 - * @return bool
332 - */
333 - public static function is_default_confirmation_message( $form_id ) {
334 - $confirmation = get_post_meta( (int) $form_id, '_srfm_form_confirmation', true );
335 -
336 - if ( ! is_array( $confirmation ) || ! isset( $confirmation[0]['message'] ) || ! is_string( $confirmation[0]['message'] ) ) {
337 - return false;
338 - }
339 -
340 - // The default message is only ever shown for a "same page" confirmation.
341 - // A redirect ("different page" / "custom url") never renders it, yet the
342 - // stored settings still seed the default message string — so without this
343 - // guard a redirect form would be nagged forever about a message no visitor
344 - // sees, with no way to clear the prompt by doing what it asks.
345 - if ( ! isset( $confirmation[0]['confirmation_type'] ) || 'same page' !== $confirmation[0]['confirmation_type'] ) {
346 - return false;
347 - }
348 -
349 - $message = $confirmation[0]['message'];
350 -
351 - if ( '' === trim( $message ) ) {
352 - return false;
353 - }
354 -
355 - $normalize = static function ( $html ) {
356 - // Decode entities too, so an encoded apostrophe (&#039;) in the generated
357 - // default matches a literal one stored by a template import.
358 - $text = html_entity_decode( wp_strip_all_tags( (string) $html ), ENT_QUOTES, 'UTF-8' );
359 - return trim( (string) preg_replace( '/\s+/', ' ', $text ) );
360 - };
361 -
362 - return $normalize( $message ) === $normalize( Global_Settings::get_default_confirmation_message() );
363 - }
364 -
365 - /**
366 - * Whether a form has somewhere to send replies (an enabled email notification
367 - * with a non-empty recipient).
368 - *
369 - * @param int $form_id Form post ID.
370 - *
371 - * @since 2.12.4
372 - * @return bool
373 - */
374 - public static function form_has_reply_destination( $form_id ) {
375 - $notifications = get_post_meta( (int) $form_id, '_srfm_email_notification', true );
376 -
377 - if ( ! is_array( $notifications ) ) {
378 - return false;
379 - }
380 -
381 - foreach ( $notifications as $notification ) {
382 - if ( is_array( $notification ) && ! empty( $notification['status'] ) && ! empty( $notification['email_to'] ) ) {
383 - return true;
384 - }
385 - }
386 -
387 - return false;
388 - }
389 -
390 - /**
391 - * The most recently created form still needing setup (default Thank You
392 - * message, or no reply destination).
393 - *
394 - * Powers the "Finish setting up" prompt (#3030). Limited to the single latest
395 - * such form to avoid clutter, and to forms the current user may actually edit.
396 - * A form is a candidate when it still has an unfinished step (default Thank You
397 - * message, or no reply destination). Dismissal is enforced by the caller,
398 - * before this query runs.
399 - *
400 - * @since 2.12.4
401 - * @return array<int,array<string,mixed>> One entry, or none.
402 - */
403 - public static function get_thankyou_prompt_forms() {
404 - // Memoized for the request so repeated reads (e.g. the notice render plus
405 - // any add-on consumer) share a single query. Sentinel is null, not false,
406 - // so a filter returning false (__return_false to disable) still memoizes.
407 - if ( null !== self::$thankyou_prompt_cache ) {
408 - return self::$thankyou_prompt_cache;
409 - }
410 -
411 - /**
412 - * Filter the forms the "Finish setting up" Thank You notice may surface.
413 - *
414 - * @param array<int,array<string,mixed>> $prompts Candidate prompt payloads.
415 - *
416 - * @since 2.12.4
417 - */
418 - $filtered = apply_filters( 'srfm_thankyou_prompt_forms', self::compute_thankyou_prompt_forms() );
419 - self::$thankyou_prompt_cache = is_array( $filtered ) ? $filtered : [];
420 -
421 - return self::$thankyou_prompt_cache;
422 - }
423 -
424 - /**
425 - * Clear the request memo for the Thank You prompt (#3030).
426 - *
427 - * Lets tests exercise the memoized public path, and is a safe hook for anything
428 - * that changes which form qualifies (e.g. a form save).
429 - *
430 - * @since 2.12.4
431 - * @return void
432 - */
433 - public static function reset_thankyou_prompt_cache() {
434 - self::$thankyou_prompt_cache = null;
435 - }
436 -
437 - /**
438 - * Setup-checklist data for the newest starter-template form (#3031).
439 - *
440 - * Picks the most recent form the current user can edit that was created from an
441 - * Astra Sites starter template. The widget
442 - * lists a fixed set of optional next-steps for it — their completion is not
443 - * computed — so the payload carries only the form and the CTA targets. Memoized
444 - * for the request so the widget register/enqueue/render passes share one query.
445 - *
446 - * @since 2.12.4
447 - * @return array<string,mixed>|null Card payload, or null when there is no candidate form.
448 - */
449 - public static function get_form_setup_card() {
450 - $user_id = get_current_user_id();
451 -
452 - // Request memo, keyed per user — the payload derives from that user's
453 - // capabilities. Reset via reset_form_setup_card_cache().
454 - if ( array_key_exists( $user_id, self::$setup_card_cache ) ) {
455 - return self::$setup_card_cache[ $user_id ];
456 - }
457 -
458 - self::$setup_card_cache[ $user_id ] = self::compute_form_setup_card();
459 -
460 - return self::$setup_card_cache[ $user_id ];
461 - }
462 -
463 - /**
464 - * Drop the "no imported forms" negative cache when the marker is written.
465 - *
466 - * Hooked to added_post_meta/updated_post_meta. Without this, a starter template
467 - * imported after the negative cache was written would show neither the Thank You
468 - * prompt nor the setup widget until the transient expired.
469 - *
470 - * Arguments are read from func_get_args() rather than declared: the hook passes
471 - * ( $meta_id, $post_id, $meta_key ) and the meta id is never needed, so declaring
472 - * it would leave an unused parameter that the coding-standards gate rejects.
473 - *
474 - * @since 2.12.4
475 - * @return void
476 - */
477 - public function invalidate_starter_template_cache() {
478 - $args = func_get_args();
479 - $post_id = isset( $args[1] ) ? (int) $args[1] : 0;
480 - $meta_key = isset( $args[2] ) ? (string) $args[2] : '';
481 -
482 - if ( self::ASTRA_SITES_IMPORT_META !== $meta_key ) {
483 - return;
484 - }
485 -
486 - // A full-site import stamps this marker on every post it creates, so narrow to
487 - // our own post type: both features only ever query sureforms_form, and this
488 - // avoids clearing the cache repeatedly for pages and products during an import.
489 - if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || SRFM_FORMS_POST_TYPE !== get_post_type( $post_id ) ) {
490 - return;
491 - }
492 -
493 - delete_transient( self::NO_IMPORTED_FORMS_TRANSIENT );
494 - }
495 -
496 - /**
497 - * Clear the setup-card request memo (#3031).
498 - *
499 - * Lets tests exercise the populated path, and is a safe hook for anything that
500 - * changes which form qualifies (e.g. a form save).
501 - *
502 - * @since 2.12.4
503 - * @return void
504 - */
505 - public static function reset_form_setup_card_cache() {
506 - self::$setup_card_cache = [];
507 - }
508 -
509 - /**
510 - * REST handler: record a "Finish setting up" widget interaction (#3031).
511 - *
512 - * Records the clicked CTA/view action as an analytics event. The request
513 - * carries the displayed form id: capability is re-checked against it here —
514 - * beyond the route's generic permission callback — so only a genuine editor of
515 - * that form can act.
516 - *
517 - * @param \WP_REST_Request<array<string,mixed>> $request Request.
518 - *
519 - * @since 2.12.4
520 - * @return \WP_REST_Response|\WP_Error
521 - */
522 - public function dismiss_form_setup_card( $request ) {
523 - $form_id = absint( $request->get_param( 'form_id' ) );
524 -
525 - 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 ) ) {
526 - return new \WP_Error( 'srfm_setup_card_forbidden', __( 'You are not allowed to update this prompt.', 'sureforms' ), [ 'status' => 403 ] );
527 - }
528 -
529 - $action = sanitize_key( (string) $request->get_param( 'action' ) );
530 -
531 - // Interaction analytics for the setup widget (#3031). Each event carries a
532 - // date automatically (see BSF_Analytics_Events::track()) and dedupes per
533 - // event name, matching the sibling notice's telemetry.
534 - $events = [
535 - 'edit_form' => 'form_setup_widget_edit_form',
536 - 'edit_thankyou' => 'form_setup_widget_edit_thankyou',
537 - 'set_up_email' => 'form_setup_widget_set_up_email',
538 - 'view_form' => 'form_setup_widget_view_form',
539 - ];
540 -
541 - if ( isset( $events[ $action ] ) ) {
542 - Analytics::events()->track( $events[ $action ], (string) $form_id );
543 - }
544 -
545 - return new \WP_REST_Response( [ 'success' => true ], 200 );
546 - }
547 -
548 - /**
549 - * Register the "Finish setting up" checklist widget on the main WP dashboard (#3031).
550 - *
551 - * Only for capable users, and only when there is a form still needing setup —
552 - * so the widget never appears empty. The data is memoized in get_form_setup_card()
553 - * and reused by the enqueue and render passes.
554 - *
555 - * @since 2.12.4
556 - * @return void
557 - */
558 - public function register_form_setup_widget() {
559 - if ( ! Helper::current_user_can() ) {
560 - return;
561 - }
562 -
563 - if ( null === self::get_form_setup_card() ) {
564 - return;
565 - }
566 -
567 - wp_add_dashboard_widget(
568 - 'srfm_form_setup_checklist',
569 - __( 'Finish setting up your form', 'sureforms' ),
570 - [ $this, 'render_form_setup_widget' ],
571 - null,
572 - null,
573 - 'normal',
574 - 'high'
575 - );
576 - }
577 -
578 - /**
579 - * Render the setup-checklist widget content (#3031).
580 - *
581 - * A heading (with a link to view the form), a subtitle, and a fixed list of
582 - * optional next-steps — each always shown with its CTA; completion is not
583 - * computed. Each CTA deep-links into the editor (edit form / Thank You message /
584 - * email notification) and records an analytics event via the REST endpoint
585 - * wired in the enqueued inline script.
586 - *
587 - * @since 2.12.4
588 - * @return void
589 - */
590 - public function render_form_setup_widget() {
591 - $card = self::get_form_setup_card();
592 -
593 - if ( null === $card ) {
594 - return;
595 - }
596 -
597 - // Optional next-steps — always offered, their completion is not computed.
598 - // 'event' is the analytics action key beaconed on click (see the widget JS).
599 - $rows = [
600 - [
601 - 'label' => __( 'Review or edit your form', 'sureforms' ),
602 - 'cta' => __( 'Edit form', 'sureforms' ),
603 - 'url' => $card['edit_url'],
604 - 'event' => 'edit_form',
605 - ],
606 - [
607 - 'label' => __( 'Personalize the Thank You message', 'sureforms' ),
608 - 'cta' => __( 'Edit message', 'sureforms' ),
609 - 'url' => $card['thankyou_url'],
610 - 'event' => 'edit_thankyou',
611 - ],
612 - [
613 - 'label' => __( 'Choose who gets notified of new replies', 'sureforms' ),
614 - 'cta' => __( 'Set up email', 'sureforms' ),
615 - 'url' => $card['email_url'],
616 - 'event' => 'set_up_email',
617 - ],
618 - ];
619 -
620 - // Fall back to a generic label for an untitled form so the heading never
621 - // renders "Finish setting up " with a dangling space.
622 - $card_title = '' !== trim( (string) $card['title'] ) ? $card['title'] : __( 'your form', 'sureforms' );
623 - $heading = sprintf(
624 - /* translators: %s: form title. */
625 - __( 'Finish setting up %s', 'sureforms' ),
626 - $card_title
627 - );
628 - ?>
629 - <div class="srfm-setup-checklist" id="srfm-setup-checklist">
630 - <p class="srfm-setup-checklist__title">
631 - <?php echo esc_html( $heading ); ?>
632 - <?php if ( ! empty( $card['view_url'] ) ) { ?>
633 - <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 ) ); ?>">
634 - <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>
635 - </a>
636 - <?php } ?>
637 - </p>
638 - <p class="srfm-setup-checklist__subtitle"><?php esc_html_e( 'Customize your form to get it ready for real submissions:', 'sureforms' ); ?></p>
639 -
640 - <ul class="srfm-setup-checklist__steps">
641 - <?php foreach ( $rows as $row ) { ?>
642 - <li class="srfm-setup-checklist__step">
643 - <span class="srfm-setup-checklist__label"><?php echo esc_html( $row['label'] ); ?></span>
644 - <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>
645 - </li>
646 - <?php } ?>
647 - </ul>
648 - </div>
649 - <?php
650 - }
651 -
652 - /**
653 - * Enqueue the setup-checklist widget's styles and behavior on the dashboard (#3031).
654 - *
655 - * Mirrors the AI widget convention: an inline-only handle carries the CSS and the
656 - * behavior (CTA click analytics), with server values —
657 - * the REST URL, nonce and form id — passed through wp_localize_script rather than
658 - * printed into the markup, so it stays Plugin-Check clean.
659 - *
660 - * @param string $hook_suffix Current admin page hook suffix.
661 - *
662 - * @since 2.12.4
663 - * @return void
664 - */
665 - public function enqueue_form_setup_widget_assets( $hook_suffix ) {
666 - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
667 - return;
668 - }
669 -
670 - $card = self::get_form_setup_card();
671 -
672 - if ( null === $card ) {
673 - return;
674 - }
675 -
676 - $css = <<<'CSS'
677 -#srfm_form_setup_checklist .inside { margin: 0; padding: 0; }
678 -.srfm-setup-checklist { padding: 12px 16px 16px; }
679 -.srfm-setup-checklist__title { margin: 0 0 4px; font-size: 15px; font-weight: 600; color: #1e1e1e; }
680 -.srfm-setup-checklist__view { display: inline-flex; align-items: center; margin-left: 6px; color: #d54e21; vertical-align: middle; }
681 -.srfm-setup-checklist__view:hover, .srfm-setup-checklist__view:focus { color: #b83c14; }
682 -.srfm-setup-checklist__subtitle { margin: 0 0 12px; color: #646970; font-size: 13px; }
683 -.srfm-setup-checklist__steps { margin: 0; padding: 0; list-style: none; }
684 -.srfm-setup-checklist__step { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; }
685 -.srfm-setup-checklist__step + .srfm-setup-checklist__step { margin-top: 6px; }
686 -.srfm-setup-checklist__step { background: #f6f7f7; }
687 -.srfm-setup-checklist__label { flex: 1 1 auto; font-size: 14px; color: #1e1e1e; }
688 -.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; }
689 -.srfm-setup-checklist__cta:hover { color: #b83c14; }
690 -/* Keep visited links on-brand — WP admin's a:visited would otherwise turn them blue. */
691 -.srfm-setup-checklist a:visited { color: #d54e21; }
692 -.srfm-setup-checklist a:visited:hover, .srfm-setup-checklist a:visited:focus { color: #b83c14; }
693 -/* Drop WP's blue focus ring on the widget's links; keep an accessible, on-brand keyboard outline. */
694 -.srfm-setup-checklist a:focus { outline: none; box-shadow: none; }
695 -.srfm-setup-checklist a:focus-visible { outline: 2px solid #d54e21; outline-offset: 2px; box-shadow: none; }
696 -CSS;
697 -
698 - wp_register_style( 'srfm-setup-checklist-widget', false, [], SRFM_VER );
699 - wp_enqueue_style( 'srfm-setup-checklist-widget' );
700 - wp_add_inline_style( 'srfm-setup-checklist-widget', $css );
701 -
702 - wp_register_script( 'srfm-setup-checklist-widget', '', [], SRFM_VER, true );
703 - wp_enqueue_script( 'srfm-setup-checklist-widget' );
704 -
705 - wp_localize_script(
706 - 'srfm-setup-checklist-widget',
707 - 'srfmSetupChecklist',
708 - [
709 - 'restUrl' => esc_url_raw( rest_url( 'sureforms/v1/dismiss-form-setup-card' ) ),
710 - 'nonce' => wp_create_nonce( 'wp_rest' ),
711 - 'formId' => $card['id'],
712 - ]
713 - );
714 -
715 - $inline_script = <<<'JS'
716 -( function () {
717 - const cfg = window.srfmSetupChecklist || {};
718 - const widget = document.getElementById( 'srfm-setup-checklist' );
719 - if ( ! widget ) {
720 - return;
721 - }
722 -
723 - const persist = function ( action ) {
724 - return fetch( cfg.restUrl, {
725 - method: 'POST',
726 - credentials: 'same-origin',
727 - keepalive: true,
728 - headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
729 - body: JSON.stringify( { form_id: cfg.formId, action: action } ),
730 - } ).catch( function () {} );
731 - };
732 -
733 - // Beacon the CTA / view-form clicks for analytics. keepalive on the fetch lets
734 - // the request finish even though the CTA immediately navigates away.
735 - widget.addEventListener( 'click', function ( e ) {
736 - const target = e.target?.closest?.( '[data-srfm-event]' );
737 - if ( target ) {
738 - persist( target.getAttribute( 'data-srfm-event' ) );
739 - }
740 - } );
741 -}() );
742 -JS;
743 -
744 - wp_add_inline_script( 'srfm-setup-checklist-widget', $inline_script );
745 - }
746 -
747 - /**
748 - * Register the "Finish setting up" prompt as an Astra Notices admin notice (#3030).
749 - *
750 - * Hooked to admin_notices so it registers before the Astra Notices library
751 - * renders (priority 30). Shown on every admin screen EXCEPT the main dashboard,
752 - * for the newest form the current user can edit that still has an unfinished
753 - * step (default Thank You message, or no reply destination). Uses a single
754 - * stable notice id so the library's built-in ✕ dismissal is one persistent
755 - * choice ("stop nudging me"), not a per-form row.
756 - *
757 - * @since 2.12.4
758 - * @return void
759 - */
760 - public function render_thankyou_prompt_notice() {
761 - if ( ! Helper::current_user_can() || ! class_exists( 'Astra_Notices' ) ) {
762 - return;
763 - }
764 -
765 - /**
766 - * Short-circuit the "Finish setting up" Thank You notice.
767 - *
768 - * @param bool $show Whether to show the notice. Default true.
769 - *
770 - * @since 2.12.4
771 - */
772 - if ( ! apply_filters( 'srfm_show_thankyou_prompt', true ) ) {
773 - return;
774 - }
775 -
776 - // Everywhere in wp-admin except the main dashboard. A null screen fails
777 - // closed (return) rather than registering the notice on an unknown screen.
778 - $screen = get_current_screen();
779 -
780 - if ( ! $screen || 'dashboard' === $screen->id ) {
781 - return;
782 - }
783 -
784 - // A single stable notice id (not per-form): keeps both the autoloaded
785 - // `allowed_astra_notices` option and the per-user dismissal meta bounded to
786 - // one row, and lets a dismissed user short-circuit before the query runs.
787 - $notice_id = 'srfm-thankyou-prompt';
788 -
789 - // The library only checks dismissal at render (priority 30, after this
790 - // query would already have run). Check it up front so a user who dismissed
791 - // the prompt never pays for the WP_Query on subsequent admin page views.
792 - if ( 'notice-dismissed' === get_user_meta( get_current_user_id(), $notice_id, true ) ) {
793 - return;
794 - }
795 -
796 - // array_values so a filter returning a key-preserving array (e.g. the
797 - // result of array_filter()) still exposes the newest prompt at index 0.
798 - $prompts = array_values( (array) self::get_thankyou_prompt_forms() );
799 -
800 - // Validate every key build_thankyou_notice_markup() reads, not just id/edit_url
801 - // — a filter returning a partial payload would otherwise trip "Undefined array
802 - // key" warnings and esc_url( null ) deprecations on every admin page.
803 - if (
804 - empty( $prompts[0] ) || ! is_array( $prompts[0] )
805 - || empty( $prompts[0]['id'] ) || empty( $prompts[0]['edit_url'] )
806 - || empty( $prompts[0]['thankyou_url'] ) || empty( $prompts[0]['replies_url'] )
807 - || ! isset( $prompts[0]['title'] )
808 - ) {
809 - return;
810 - }
811 -
812 - $form = $prompts[0];
813 -
814 - \Astra_Notices::add_notice(
815 - [
816 - 'id' => $notice_id,
817 - 'type' => 'info',
818 - 'message' => self::build_thankyou_notice_markup( $form ),
819 - 'class' => 'srfm-thankyou-notice',
820 - 'is_dismissible' => true,
821 - 'display-with-other-notices' => true,
822 - // Render late so this nudge never pre-empts higher-priority notices
823 - // (e.g. Astra's minimum-version warnings, which are display-with-
824 - // other-notices => false and would be skipped once ours renders).
825 - 'priority' => 100,
826 - ]
827 - );
828 -
829 - // The message is wp_kses_post'd by the library, so the brand-orange styling
830 - // is printed through the notice's pre-markup hook instead of inline.
831 - add_action( 'astra_notice_before_markup_' . $notice_id, [ $this, 'print_thankyou_notice_styles' ] );
832 -
833 - // Track clicks on the CTAs and the dismiss ✕ via the shared notice-response
834 - // endpoint, enqueued only when the notice actually renders.
835 - add_action( 'astra_notice_after_markup_' . $notice_id, [ $this, 'enqueue_thankyou_notice_tracking' ] );
836 - }
837 -
838 - /**
839 - * Enqueue the click-tracking for the Thank You notice (#3030).
840 - *
841 - * Sends an analytics beacon to the shared `srfm_notice_response` AJAX handler
842 - * when a CTA or the dismiss ✕ is clicked. Uses `keepalive` so the beacon
843 - * survives the navigation the CTA links trigger.
844 - *
845 - * @since 2.12.4
846 - * @return void
847 - */
848 - public function enqueue_thankyou_notice_tracking() {
849 - if ( wp_script_is( 'srfm-thankyou-notice-track', 'enqueued' ) ) {
850 - return;
851 - }
852 -
853 - wp_register_script( 'srfm-thankyou-notice-track', '', [], SRFM_VER, true );
854 - wp_enqueue_script( 'srfm-thankyou-notice-track' );
855 -
856 - $config = wp_json_encode(
857 - [
858 - 'ajaxurl' => admin_url( 'admin-ajax.php' ),
859 - 'nonce' => wp_create_nonce( 'srfm_notice_response' ),
860 - ]
861 - );
862 -
863 - wp_add_inline_script( 'srfm-thankyou-notice-track', 'window.srfmThankYouNoticeTrack = ' . $config . ';', 'before' );
864 -
865 - $inline_script = <<<'JS'
866 -( function () {
867 - const cfg = window.srfmThankYouNoticeTrack || {};
868 - const wrap = document.querySelector( '.srfm-thankyou-notice' );
869 - if ( ! wrap ) {
870 - return;
871 - }
872 - const noticeId = wrap.id || '';
873 - const send = function ( button ) {
874 - const body = new URLSearchParams();
875 - body.append( 'action', 'srfm_notice_response' );
876 - body.append( 'nonce', cfg.nonce );
877 - body.append( 'notice_id', noticeId );
878 - body.append( 'button', button );
879 - fetch( cfg.ajaxurl, {
880 - method: 'POST',
881 - credentials: 'same-origin',
882 - keepalive: true,
883 - headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
884 - body: body.toString(),
885 - } ).catch( function () {} );
886 - };
887 - // Delegate from the wrapper: this inline script runs at parse time, before
888 - // core's common.js injects the .notice-dismiss ✕ (on DOMContentLoaded), so a
889 - // direct querySelector for it would find nothing and the dismiss beacon would
890 - // never fire. Delegation catches the ✕ and the CTAs whenever they exist.
891 - const ctas = [
892 - [ '.srfm-ty-edit-form', 'edit_form' ],
893 - [ '.srfm-ty-set-replies', 'set_replies' ],
894 - [ '.srfm-ty-edit-thankyou', 'edit_thankyou' ],
895 - ];
896 - wrap.addEventListener( 'click', function ( e ) {
897 - if ( e.target.closest( '.notice-dismiss' ) ) {
898 - send( 'dismissed' );
899 - return;
900 - }
901 - for ( let i = 0; i < ctas.length; i++ ) {
902 - if ( e.target.closest( ctas[ i ][ 0 ] ) ) {
903 - send( ctas[ i ][ 1 ] );
904 - return;
905 - }
906 - }
907 - } );
908 -}() );
909 -JS;
910 -
911 - wp_add_inline_script( 'srfm-thankyou-notice-track', $inline_script );
912 - }
913 -
914 - /**
915 - * Print the Thank You notice's brand-orange styling (#3030).
916 - *
917 - * Fired via astra_notice_before_markup_{id} so it lands right before the notice
918 - * and only when the notice actually renders.
919 - *
920 - * @since 2.12.4
921 - * @return void
922 - */
923 - public function print_thankyou_notice_styles() {
924 - // The library wp_kses_post()'s the message, which strips <svg> and data:
925 - // image srcs, so the SureForms mark is painted as a CSS background here
926 - // (this hook fires outside that kses call). URL-encoded, not base64, so the
927 - // value is fully percent-encoded and safe to pass through esc_url.
928 - $icon = 'data:image/svg+xml,' . rawurlencode(
929 - '<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 32 32"><path fill="#D54407" fill-rule="evenodd" clip-rule="evenodd" d="M32 0H0V32H32V0ZM22.8573 6.85728H9.14304V11.4287V13.7144L11.4288 11.4287H22.8573V6.85728ZM20.5717 13.7146H9.14314V18.286V20.5714V20.5718V25.1428H16.0003V20.5714H9.14351L11.4289 18.286H20.5717V13.7146Z"/></svg>'
930 - );
931 - ?>
932 - <style id="srfm-thankyou-notice-styles">
933 - .srfm-thankyou-notice.notice { border-left-color: #D54407; }
934 - /* Stack our blocks (the library lays the container out as a flex row) and reserve room on the left for the SureForms mark. */
935 - .srfm-thankyou-notice .astra-notice-container { display: block; padding: 4px 0 4px 52px; background: url('<?php echo esc_url( $icon, [ 'data' ] ); ?>') no-repeat 4px 6px; background-size: 32px 32px; }
936 - .srfm-thankyou-notice .srfm-thankyou-notice__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: #1d2327; }
937 - .srfm-thankyou-notice .srfm-thankyou-notice__text { margin: 0 0 10px; color: #50575e; }
938 - .srfm-thankyou-notice .srfm-thankyou-notice__actions { margin: 12px 0 2px; display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; }
939 - .srfm-thankyou-notice .button-primary { background: #D54407; border-color: #D54407; color: #fff; box-shadow: none; text-shadow: none; }
940 - .srfm-thankyou-notice .button-primary:hover, .srfm-thankyou-notice .button-primary:focus { background: #C83B00; border-color: #C83B00; color: #fff; box-shadow: none; }
941 - .srfm-thankyou-notice .button:not(.button-primary) { background: transparent; border-color: transparent; color: #D54407; box-shadow: none; padding: 0; }
942 - .srfm-thankyou-notice .button:not(.button-primary):hover, .srfm-thankyou-notice .button:not(.button-primary):focus { background: transparent; border-color: transparent; color: #C83B00; box-shadow: none; }
943 - .srfm-thankyou-notice .button-primary:focus { outline: 2px solid #D54407; outline-offset: 1px; }
944 - </style>
945 - <?php
946 - }
947 -
948 - /**
949 163 * Check and save the first form creation time stamp.
950 164 * If not already saved.
951 165 *
952 166 * @since 1.10.1
@@ -1606,9 +820,8 @@
1606 820 wp_enqueue_style( SRFM_SLUG . '-backend-blocks', $css_uri . 'blocks/default/backend' . $file_prefix . '.css', [], SRFM_VER );
1607 821 wp_enqueue_style( SRFM_SLUG . '-intl', $vendor_css_uri . 'intl/intlTelInput-backend.min.css', [], SRFM_VER );
1608 822 wp_enqueue_style( SRFM_SLUG . '-common', $css_uri . 'common' . $file_prefix . '.css', [], SRFM_VER );
1609 823 wp_enqueue_style( SRFM_SLUG . '-reactQuill', $vendor_css_uri . 'quill/quill.snow.css', [], SRFM_VER );
1610 - wp_add_inline_style( SRFM_SLUG . '-reactQuill', self::QUILL_1X_INLINE_CSS );
1611 824 wp_enqueue_style( SRFM_SLUG . '-single-form-modal', $css_uri . 'single-form-setting' . $file_prefix . '.css', [], SRFM_VER );
1612 825
1613 826 // if version is equal to or lower than 6.6.2 then add compatibility css.
1614 827 if ( version_compare( $wp_version, '6.6.2', '<=' ) ) {
@@ -1732,10 +945,8 @@
1732 945 'plugin_installed_text' => __( 'Installed', 'sureforms' ),
1733 946 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ),
1734 947 'is_rtl' => $is_rtl,
1735 948 'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false,
1736 - 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false,
1737 - 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ),
1738 949 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection.
1739 950 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ),
1740 951 'general_settings_url' => admin_url( '/options-general.php' ),
1741 952 'additional_header_nav_items' => [],
@@ -2047,9 +1258,8 @@
2047 1258
2048 1259 // Enqueue Tailwind and Quill editor styles for the settings page.
2049 1260 wp_enqueue_style( SRFM_SLUG . '-settings-build', SRFM_URL . 'assets/build/settings.css', [], SRFM_VER, 'all' );
2050 1261 wp_enqueue_style( SRFM_SLUG . '-reactQuill', SRFM_URL . 'assets/css/minified/deps/quill/quill.snow.css', [], SRFM_VER );
2051 - wp_add_inline_style( SRFM_SLUG . '-reactQuill', self::QUILL_1X_INLINE_CSS );
2052 1262
2053 1263 $script_translations_handlers[] = SRFM_SLUG . '-settings';
2054 1264 }
2055 1265
@@ -2353,9 +1563,9 @@
2353 1563 'type' => '',
2354 1564 'message' => $this->build_notice_markup(
2355 1565 esc_html__( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ),
2356 1566 esc_html__( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ),
2357 - esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/' ),
1567 + esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/?filter=5#new-post' ),
2358 1568 esc_html__( 'Rate SureForms', 'sureforms' ),
2359 1569 esc_html__( 'Maybe later', 'sureforms' ),
2360 1570 esc_html__( 'I already did', 'sureforms' ),
2361 1571 WEEK_IN_SECONDS,
@@ -2477,15 +1687,8 @@
2477 1687 'rate_sureforms' => 'rating_notice_cta',
2478 1688 'maybe_later' => 'rating_notice_snooze',
2479 1689 'dismissed' => 'rating_notice_dismiss',
2480 1690 ],
2481 - // The "Finish setting up" prompt (#3030): three CTAs, plus the ✕.
2482 - 'srfm-thankyou-prompt' => [
2483 - 'edit_form' => 'thankyou_notice_edit_form',
2484 - 'set_replies' => 'thankyou_notice_set_replies',
2485 - 'edit_thankyou' => 'thankyou_notice_edit_thankyou',
2486 - 'dismissed' => 'thankyou_notice_dismiss',
2487 - ],
2488 1691 ];
2489 1692
2490 1693 if ( ! isset( $valid[ $notice_id ][ $button ] ) ) {
2491 1694 wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'sureforms' ) ], 400 );
@@ -2642,11 +1845,8 @@
2642 1845 if ( ! Helper::current_user_can() ) {
2643 1846 return;
2644 1847 }
2645 1848
2646 - // 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.
2647 - add_action( 'wp_dashboard_setup', [ $this, 'register_ai_dashboard_widget' ] );
2648 -
2649 1849 // Quick check if there are any entries in the last 7 days.
2650 1850 $seven_days_ago = strtotime( '-7 days' );
2651 1851 $total_entries = Entries::get_entries_count_after( $seven_days_ago );
2652 1852
@@ -2681,180 +1881,8 @@
2681 1881 );
2682 1882 }
2683 1883
2684 1884 /**
2685 - * Register the AI quick draft dashboard widget.
2686 - *
2687 - * @return void
2688 - * @since 2.12.1
2689 - */
2690 - public function register_ai_dashboard_widget() {
2691 - wp_add_dashboard_widget(
2692 - 'sureforms_ai_quick_draft',
2693 - __( 'SureForms AI Quick Draft', 'sureforms' ),
2694 - [ $this, 'render_ai_dashboard_widget' ],
2695 - null,
2696 - null,
2697 - 'normal',
2698 - 'high'
2699 - );
2700 - }
2701 -
2702 - /**
2703 - * Render AI quick draft dashboard widget content.
2704 - *
2705 - * @return void
2706 - * @since 2.12.1
2707 - */
2708 - public function render_ai_dashboard_widget() {
2709 - ?>
2710 - <div class="srfm-ai-dashboard-widget">
2711 - <p>
2712 - <?php esc_html_e( 'Describe the form and let SureForms AI generate it for you.', 'sureforms' ); ?>
2713 - </p>
2714 - <label for="srfm-ai-dashboard-prompt" class="screen-reader-text">
2715 - <?php esc_html_e( 'Describe your form', 'sureforms' ); ?>
2716 - </label>
2717 - <textarea
2718 - id="srfm-ai-dashboard-prompt"
2719 - class="widefat"
2720 - rows="5"
2721 - maxlength="2000"
2722 - placeholder="<?php esc_attr_e( 'Example: Create a contact form with name, email, phone, and message fields.', 'sureforms' ); ?>"
2723 - ></textarea>
2724 - <p style="margin-top:10px;margin-bottom:0;display:flex;align-items:center;gap:10px;">
2725 - <button type="button" class="button button-primary" id="srfm-ai-dashboard-generate" disabled>
2726 - <?php esc_html_e( 'Create New Form', 'sureforms' ); ?>
2727 - </button>
2728 - <span id="srfm-ai-dashboard-char-count" style="color:#646970;">0/2000</span>
2729 - </p>
2730 - </div>
2731 - <?php
2732 - }
2733 -
2734 - /**
2735 - * Enqueue the AI quick draft dashboard widget script on the dashboard screen.
2736 - *
2737 - * The widget's behavior lives here (attached via wp_add_inline_script) rather than as an
2738 - * inline <script> in the render callback, so it passes Plugin Check and keeps server values
2739 - * out of the markup. Server values are passed through wp_localize_script.
2740 - *
2741 - * @param string $hook_suffix The current admin page hook suffix.
2742 - * @return void
2743 - * @since 2.12.1
2744 - */
2745 - public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) {
2746 - // Only on the main dashboard, and only for capable users (matches the widget gate).
2747 - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) {
2748 - return;
2749 - }
2750 -
2751 - // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching
2752 - // localized data plus an inline script without shipping a separate asset file.
2753 - wp_register_script( 'srfm-ai-dashboard-widget', '', [], SRFM_VER, true );
2754 - wp_enqueue_script( 'srfm-ai-dashboard-widget' );
2755 -
2756 - wp_localize_script(
2757 - 'srfm-ai-dashboard-widget',
2758 - 'srfmAiDashboardWidget',
2759 - [
2760 - 'redirectUrl' => admin_url( 'admin.php?page=add-new-form' ),
2761 - 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
2762 - 'nonce' => wp_create_nonce( 'srfm_ai_widget_usage' ),
2763 - 'redirectingTxt' => __( 'Redirecting...', 'sureforms' ),
2764 - ]
2765 - );
2766 -
2767 - $inline_script = <<<'JS'
2768 -( function () {
2769 - const config = window.srfmAiDashboardWidget || {};
2770 - const generateButton = document.getElementById( 'srfm-ai-dashboard-generate' );
2771 - const promptField = document.getElementById( 'srfm-ai-dashboard-prompt' );
2772 - const charCount = document.getElementById( 'srfm-ai-dashboard-char-count' );
2773 - if ( ! generateButton || ! promptField ) {
2774 - return;
2775 - }
2776 -
2777 - const updateWidgetState = function () {
2778 - const promptValue = promptField.value.trim();
2779 - generateButton.disabled = ! promptValue;
2780 - if ( charCount ) {
2781 - charCount.textContent = `${ promptField.value.length }/2000`;
2782 - }
2783 - };
2784 -
2785 - const triggerGeneration = function () {
2786 - const prompt = promptField.value.trim();
2787 - if ( ! prompt ) {
2788 - promptField.focus();
2789 - return;
2790 - }
2791 -
2792 - generateButton.disabled = true;
2793 - generateButton.textContent = config.redirectingTxt;
2794 -
2795 - const redirectUrl = new URL( config.redirectUrl, window.location.origin );
2796 - redirectUrl.searchParams.set( 'srfm_ai_dashboard_prompt', prompt );
2797 -
2798 - const requestBody = new URLSearchParams();
2799 - requestBody.append( 'action', 'srfm_ai_widget_usage' );
2800 - requestBody.append( 'nonce', config.nonce );
2801 -
2802 - fetch( config.ajaxUrl, {
2803 - method: 'POST',
2804 - credentials: 'same-origin',
2805 - headers: {
2806 - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
2807 - },
2808 - body: requestBody.toString(),
2809 - } ).finally( function () {
2810 - window.location.href = redirectUrl.toString();
2811 - } );
2812 - };
2813 -
2814 - promptField.addEventListener( 'input', updateWidgetState );
2815 - generateButton.addEventListener( 'click', triggerGeneration );
2816 - promptField.addEventListener( 'keydown', function ( event ) {
2817 - if ( event.key === 'Enter' && ( event.metaKey || event.ctrlKey ) ) {
2818 - event.preventDefault();
2819 - triggerGeneration();
2820 - }
2821 - } );
2822 -
2823 - updateWidgetState();
2824 -}() );
2825 -JS;
2826 -
2827 - wp_add_inline_script( 'srfm-ai-dashboard-widget', $inline_script );
2828 - }
2829 -
2830 - /**
2831 - * Track AI dashboard widget usage.
2832 - *
2833 - * @return void
2834 - * @since 2.12.1
2835 - */
2836 - public function track_ai_widget_usage() {
2837 - if ( ! check_ajax_referer( 'srfm_ai_widget_usage', 'nonce', false ) ) {
2838 - wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 );
2839 - }
2840 -
2841 - if ( ! Helper::current_user_can() ) {
2842 - wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 );
2843 - }
2844 -
2845 - $current_count = (int) Helper::get_srfm_option( 'ai_dashboard_widget_uses', 0 ) + 1;
2846 - Helper::update_srfm_option( 'ai_dashboard_widget_uses', $current_count );
2847 -
2848 - // Emit an analytics event so usage lands in the warehouse via events_record.
2849 - // $force = true because this is a cumulative counter, not a one-time event —
2850 - // it must re-send the latest count each cycle (bypasses one-time dedup).
2851 - Analytics::events()->track( 'ai_dashboard_widget_used', (string) $current_count, [], true );
2852 -
2853 - wp_send_json_success();
2854 - }
2855 -
2856 - /**
2857 1885 * Render the dashboard widget content.
2858 1886 *
2859 1887 * @return void
2860 1888 * @since 1.9.1
@@ -2900,300 +1928,8 @@
2900 1928 $this->render_dashboard_widget_footer( $entries_data );
2901 1929 ?>
2902 1930 </div>
2903 1931 <?php
2904 - }
2905 -
2906 - /**
2907 - * Build the setup-card payload (uncached). See get_form_setup_card().
2908 - *
2909 - * @since 2.12.4
2910 - * @return array<string,mixed>|null Card payload, or null when there is no candidate.
2911 - */
2912 - private static function compute_form_setup_card() {
2913 - if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || ! post_type_exists( SRFM_FORMS_POST_TYPE ) ) {
2914 - return null;
2915 - }
2916 -
2917 - // Negative cache. Deliberately not a `defined( 'ASTRA_SITES_VER' )` check:
2918 - // Starter Templates defines that constant in its main plugin file, so it only
2919 - // exists while the plugin is active, yet neither its uninstall.php nor its
2920 - // deactivation hook removes the import marker. Gating on the constant would
2921 - // silently switch this feature off for the very people it targets — anyone who
2922 - // imported a starter template and then removed the one-shot import plugin.
2923 - if ( 'no' === get_transient( self::NO_IMPORTED_FORMS_TRANSIENT ) ) {
2924 - return null;
2925 - }
2926 -
2927 - // Only forms created from an Astra Sites starter template — those carry the
2928 - // marker Starter Templates stamps on imported posts (self::ASTRA_SITES_IMPORT_META).
2929 - // Prime post + meta caches (the loop reads title, permalink and edit link
2930 - // per candidate) so this is a single query, not a follow-up per form.
2931 - $query = new \WP_Query(
2932 - [
2933 - 'post_type' => SRFM_FORMS_POST_TYPE,
2934 - 'post_status' => [ 'publish', 'draft', 'pending' ],
2935 - 'posts_per_page' => 10,
2936 - // ID breaks the tie: a starter-template import creates several forms
2937 - // within the same second, so post_date alone leaves "the newest form"
2938 - // up to MySQL and it can differ between page loads.
2939 - 'orderby' => [
2940 - 'date' => 'DESC',
2941 - 'ID' => 'DESC',
2942 - ],
2943 - 'no_found_rows' => true,
2944 - 'update_post_meta_cache' => true,
2945 - 'update_post_term_cache' => false,
2946 - 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to 10 recent forms; dashboard-only.
2947 - [
2948 - 'key' => self::ASTRA_SITES_IMPORT_META,
2949 - 'compare' => 'EXISTS',
2950 - ],
2951 - ],
2952 - ]
2953 - );
2954 -
2955 - // Nothing on this site carries the marker — remember that, so the query does
2956 - // not repeat on every load. Keyed on the query result rather than on anything
2957 - // user-specific, so it is safe to share, and invalidated the moment a post is
2958 - // stamped (see invalidate_starter_template_cache()).
2959 - if ( empty( $query->posts ) ) {
2960 - set_transient( self::NO_IMPORTED_FORMS_TRANSIENT, 'no', WEEK_IN_SECONDS );
2961 - }
2962 -
2963 - foreach ( $query->posts as $post ) {
2964 - $form_id = (int) $post->ID;
2965 -
2966 - if ( ! current_user_can( 'edit_post', $form_id ) ) {
2967 - continue;
2968 - }
2969 -
2970 - $edit_link = get_edit_post_link( $form_id, 'raw' );
2971 -
2972 - if ( empty( $edit_link ) ) {
2973 - continue;
2974 - }
2975 -
2976 - // The steps are shown as optional next-steps — their completion is not
2977 - // computed, so the widget simply lists the actions the owner can take.
2978 - return [
2979 - 'id' => $form_id,
2980 - 'title' => get_the_title( $form_id ),
2981 - 'edit_url' => $edit_link,
2982 - // Deep-links to the email-notification panel where supported; falls
2983 - // back to opening the editor when the focus handler isn't present.
2984 - 'email_url' => add_query_arg( 'srfm_focus', 'notifications', $edit_link ),
2985 - // Deep-links to the Form Confirmation panel (the Thank You message).
2986 - 'thankyou_url' => add_query_arg( 'srfm_focus', 'thankyou', $edit_link ),
2987 - // Front-end instant-form page. get_permalink() only yields a working
2988 - // URL for published forms; a draft/pending form has no public URL, so
2989 - // omit the view link there (the empty() guard hides the icon).
2990 - 'view_url' => 'publish' === $post->post_status ? (string) get_permalink( $form_id ) : '',
2991 - ];
2992 - }
2993 -
2994 - return null;
2995 - }
2996 -
2997 - /**
2998 - * Build the Thank You prompt payload (uncached). See get_thankyou_prompt_forms().
2999 - *
3000 - * @since 2.12.4
3001 - * @return array<int,array<string,mixed>> One entry, or none.
3002 - */
3003 - private static function compute_thankyou_prompt_forms() {
3004 - if ( ! defined( 'SRFM_FORMS_POST_TYPE' ) || ! post_type_exists( SRFM_FORMS_POST_TYPE ) ) {
3005 - return [];
3006 - }
3007 -
3008 - // Negative cache — this notice renders on every admin screen, so keeping the
3009 - // query off installs that can never match is what matters here. See
3010 - // self::NO_IMPORTED_FORMS_TRANSIENT for why this is not gated on whether
3011 - // Starter Templates is still active: the marker outlives the plugin.
3012 - if ( 'no' === get_transient( self::NO_IMPORTED_FORMS_TRANSIENT ) ) {
3013 - return [];
3014 - }
3015 -
3016 - // Only forms imported from a Starter Templates (Astra Sites) starter
3017 - // template — see self::ASTRA_SITES_IMPORT_META. Prime post + meta caches
3018 - // (the loop reads meta, title and creation time per candidate) so this is a
3019 - // single query rather than the main query plus a follow-up per form.
3020 - $query = new \WP_Query(
3021 - [
3022 - 'post_type' => SRFM_FORMS_POST_TYPE,
3023 - 'post_status' => 'publish',
3024 - 'posts_per_page' => 10,
3025 - // ID breaks the tie — an import creates several forms in the same
3026 - // second, so post_date alone makes "newest" MySQL-dependent.
3027 - 'orderby' => [
3028 - 'date' => 'DESC',
3029 - 'ID' => 'DESC',
3030 - ],
3031 - 'no_found_rows' => true,
3032 - 'update_post_meta_cache' => true,
3033 - 'update_post_term_cache' => false,
3034 - 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to 10 recent forms; admin-notice only.
3035 - [
3036 - 'key' => self::ASTRA_SITES_IMPORT_META,
3037 - 'compare' => 'EXISTS',
3038 - ],
3039 - ],
3040 - ]
3041 - );
3042 -
3043 - // Nothing on this site carries the marker — remember that, so the query does
3044 - // not repeat on every load. Keyed on the query result rather than on anything
3045 - // user-specific, so it is safe to share, and invalidated the moment a post is
3046 - // stamped (see invalidate_starter_template_cache()).
3047 - if ( empty( $query->posts ) ) {
3048 - set_transient( self::NO_IMPORTED_FORMS_TRANSIENT, 'no', WEEK_IN_SECONDS );
3049 - }
3050 -
3051 - $prompts = [];
3052 - $now = time();
3053 -
3054 - foreach ( $query->posts as $post ) {
3055 - $form_id = (int) $post->ID;
3056 -
3057 - if ( ! current_user_can( 'edit_post', $form_id ) ) {
3058 - continue;
3059 - }
3060 -
3061 - $steps = [
3062 - // A destination for replies: an enabled notification with a recipient.
3063 - 'replies' => ! self::form_has_reply_destination( $form_id ),
3064 - // The thank-you message is still the shipped default.
3065 - 'thankyou' => self::is_default_confirmation_message( $form_id ),
3066 - ];
3067 -
3068 - // Nothing left to finish — no card for this form.
3069 - if ( ! $steps['replies'] && ! $steps['thankyou'] ) {
3070 - continue;
3071 - }
3072 -
3073 - $edit_link = get_edit_post_link( $form_id, 'raw' );
3074 -
3075 - if ( empty( $edit_link ) ) {
3076 - continue;
3077 - }
3078 -
3079 - $created = get_post_time( 'U', true, $form_id );
3080 - $days_ago = is_int( $created ) ? (int) floor( ( $now - $created ) / DAY_IN_SECONDS ) : 0;
3081 -
3082 - $prompts[] = [
3083 - 'id' => $form_id,
3084 - 'title' => get_the_title( $form_id ),
3085 - 'days_ago' => max( 0, $days_ago ),
3086 - 'steps' => $steps,
3087 - 'edit_url' => $edit_link,
3088 - // The editor reads srfm_focus to open the matching settings tab:
3089 - // "notifications" lands on Email Notification (where the reply
3090 - // destination is set, so the CTA can actually clear that step) and
3091 - // "thankyou" on Form Confirmation.
3092 - 'replies_url' => add_query_arg( 'srfm_focus', 'notifications', $edit_link ),
3093 - 'thankyou_url' => add_query_arg( 'srfm_focus', 'thankyou', $edit_link ),
3094 - ];
3095 -
3096 - // One card is enough — surface only the latest form needing setup.
3097 - break;
3098 - }
3099 -
3100 - return $prompts;
3101 - }
3102 -
3103 - /**
3104 - * Build the Thank You notice's inner markup (title, sentence, action buttons).
3105 - *
3106 - * @param array<string,mixed> $form Prompt payload from get_thankyou_prompt_forms().
3107 - *
3108 - * @since 2.12.4
3109 - * @return string
3110 - */
3111 - private static function build_thankyou_notice_markup( $form ) {
3112 - // The prompt only surfaces starter-template imports (see the meta gate), so
3113 - // the form was created for the user rather than by them. Kept generic — no
3114 - // per-step claim — so it is always accurate whatever the user has since
3115 - // changed, while the action buttons point to the specific things to finish.
3116 - $sentence = __( 'We’ve already created this form for you. Finish customising it so it’s ready to collect real submissions.', 'sureforms' );
3117 -
3118 - ob_start();
3119 - ?>
3120 - <p class="srfm-thankyou-notice__title">
3121 - <?php
3122 - echo esc_html(
3123 - sprintf(
3124 - /* translators: %s: form name. */
3125 - __( 'Finish setting up “%s”', 'sureforms' ),
3126 - $form['title']
3127 - )
3128 - );
3129 - ?>
3130 - </p>
3131 - <p class="srfm-thankyou-notice__text"><?php echo esc_html( $sentence ); ?></p>
3132 - <p class="srfm-thankyou-notice__actions">
3133 - <a class="button button-primary srfm-ty-edit-form" href="<?php echo esc_url( $form['edit_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Edit form', 'sureforms' ); ?></a>
3134 - <a class="button srfm-ty-edit-thankyou" href="<?php echo esc_url( $form['thankyou_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Edit the Thank You message', 'sureforms' ); ?></a>
3135 - <a class="button srfm-ty-set-replies" href="<?php echo esc_url( $form['replies_url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Set where replies go', 'sureforms' ); ?></a>
3136 - </p>
3137 - <?php
3138 - return (string) ob_get_clean();
3139 - }
3140 -
3141 - /**
3142 - * Determine whether a notice callback is owned by SureForms.
3143 - *
3144 - * Recognises object methods on classes in the `SRFM` / `SRFM_PRO` namespaces
3145 - * as well as the bundled notices libraries (`BSF_Admin_Notices` and the
3146 - * legacy `Astra_Notices` alias). Everything else is treated as foreign.
3147 - *
3148 - * @param callable|array|string|null $function The registered callback function.
3149 - * @since 2.10.0
3150 - * @return bool True when the callback belongs to SureForms, false otherwise.
3151 - */
3152 - private function is_sureforms_owned_notice_callback( $function ) {
3153 - $class_name = '';
3154 -
3155 - if ( is_array( $function ) && isset( $function[0] ) ) {
3156 - // Object or static method callback represented as an array. The first
3157 - // element is either the object instance or the fully qualified class name.
3158 - $class_name = is_object( $function[0] ) ? get_class( $function[0] ) : (string) $function[0];
3159 - } elseif ( is_string( $function ) && false !== strpos( $function, '::' ) ) {
3160 - // Static method passed as "Class::method".
3161 - $class_name = strstr( $function, '::', true );
3162 - }
3163 -
3164 - if ( '' === $class_name ) {
3165 - // Plain function callbacks are never owned by SureForms.
3166 - return false;
3167 - }
3168 -
3169 - // SureForms (free and pro) namespaced classes. Pro's real namespace is
3170 - // `SRFM_Pro\` (case-sensitive) — not the all-caps `SRFM_PRO_` constant
3171 - // prefix — so match it case-insensitively to be safe.
3172 - if ( 0 === strpos( $class_name, 'SRFM\\' ) || 0 === stripos( $class_name, 'SRFM_Pro\\' ) ) {
3173 - return true;
3174 - }
3175 -
3176 - // Bundled notices library shipped with SureForms.
3177 - return in_array( $class_name, [ 'BSF_Admin_Notices', 'Astra_Notices' ], true );
3178 - }
3179 -
3180 - /**
3181 - * Whether the current admin page is a SureForms-owned screen identified by a
3182 - * `sureforms_*` / `srfm_*` `page` query slug. Complements
3183 - * {@see Helper::is_sureforms_admin_page()} so foreign-notice suppression also
3184 - * covers the payments / quiz / survey / learn / SMTP / partial-entries screens
3185 - * that the core helper does not enumerate. Read-only screen check.
3186 - *
3187 - * @since 2.10.0
3188 - * @return bool
3189 - */
3190 - private function is_sureforms_owned_admin_page() {
3191 - if ( ! is_admin() || empty( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection, no state change.
3192 - return false;
3193 - }
3194 - $page = sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection, no state change.
3195 - return 0 === strpos( $page, 'sureforms' ) || 0 === strpos( $page, 'srfm' );
3196 1932 }
3197 1933
3198 1934 /**
3199 1935 * Build the shared HTML markup for admin notices.