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