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

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

1,162 lines 36.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Analytics class helps to connect BSFAnalytics.
4 *
5 * @package sureforms.
6 */
7
8 namespace SRFM\Admin;
9
10 use SRFM\Inc\Database\Register;
11 use SRFM\Inc\Database\Tables\Entries;
12 use SRFM\Inc\Form_Views;
13 use SRFM\Inc\Helper;
14 use SRFM\Inc\Learn;
15 use SRFM\Inc\Traits\Get_Instance;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit; // Exit if accessed directly.
19 }
20 /**
21 * Analytics class.
22 *
23 * @since 1.4.0
24 */
25 class Analytics {
26 use Get_Instance;
27
28 /**
29 * BSF_Analytics_Events instance for one-time event tracking.
30 *
31 * @var \BSF_Analytics_Events|null
32 */
33 private static $events = null;
34
35 /**
36 * Class constructor.
37 *
38 * @return void
39 * @since 1.4.0
40 */
41 public function __construct() {
42 /*
43 * BSF Analytics.
44 */
45 if ( ! class_exists( 'BSF_Analytics_Loader' ) ) {
46 require_once SRFM_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-loader.php';
47 }
48
49 if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
50 require_once SRFM_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
51 }
52
53 add_filter(
54 'uds_survey_allowed_screens',
55 static function () {
56 return [ 'plugins' ];
57 }
58 );
59
60 $srfm_bsf_analytics = \BSF_Analytics_Loader::get_instance();
61
62 $srfm_bsf_analytics->set_entity(
63 [
64 'sureforms' => [
65 'product_name' => 'SureForms',
66 'path' => SRFM_DIR . 'inc/lib/bsf-analytics',
67 'author' => 'SureForms',
68 'time_to_display' => '+24 hours',
69 'deactivation_survey' => apply_filters(
70 'srfm_deactivation_survey_data',
71 [
72 [
73 'id' => 'deactivation-survey-sureforms',
74 'popup_logo' => SRFM_URL . 'admin/assets/sureforms-logo.png',
75 'plugin_slug' => 'sureforms',
76 'popup_title' => 'Quick Feedback',
77 'support_url' => 'https://sureforms.com/contact/',
78 'popup_description' => 'If you have a moment, please share why you are deactivating SureForms:',
79 'show_on_screens' => [ 'plugins' ],
80 'plugin_version' => SRFM_VER,
81 ],
82 ]
83 ),
84 'hide_optin_checkbox' => true,
85 ],
86 ]
87 );
88
89 add_filter( 'bsf_core_stats', [ $this, 'add_srfm_analytics_data' ] );
90
91 // Event tracking hooks.
92 add_action( 'current_screen', [ $this, 'track_first_editor_open' ] );
93 add_action( 'transition_post_status', [ $this, 'track_first_form_published' ], 10, 3 );
94 add_action( 'save_post', [ $this, 'track_embed_styling_configured' ], 10, 2 );
95
96 // Detect state-based events on admin load (dedup prevents repeat tracking).
97 $this->detect_state_events();
98 }
99
100 /**
101 * Get the shared BSF_Analytics_Events instance.
102 *
103 * Uses SureForms' Helper option methods so data stays in the
104 * existing srfm_options row — zero migration required.
105 *
106 * @since 2.7.0
107 * @return \BSF_Analytics_Events
108 */
109 public static function events() {
110 if ( null === self::$events ) {
111 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
112 require_once SRFM_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-events.php';
113 }
114
115 self::$events = new \BSF_Analytics_Events(
116 'sureforms',
117 [
118 'get' => [ Helper::class, 'get_srfm_option' ],
119 'update' => [ Helper::class, 'update_srfm_option' ],
120 ]
121 );
122 }
123 return self::$events;
124 }
125
126 /**
127 * Callback function to add SureForms specific analytics data.
128 *
129 * @param array $stats_data existing stats_data.
130 * @since 1.4.0
131 * @return array
132 */
133 public function add_srfm_analytics_data( $stats_data ) {
134 $stats_data['plugin_data']['sureforms'] = [
135 'free_version' => SRFM_VER,
136 'site_language' => get_locale(),
137 'most_used_anti_spam' => $this->most_used_anti_spam(),
138 'user_status' => $this->user_status(),
139 'pointer_popup_clicked' => $this->pointer_popup_clicked(),
140 ];
141 // Every query against the entries table errors when the table is missing, so
142 // resolve that once here rather than letting each caller below trip over it.
143 $entries_table_missing = Register::is_entries_table_missing();
144
145 $stats_data['plugin_data']['sureforms']['numeric_values'] = [
146 'total_forms' => wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0,
147 'instant_forms_enabled' => $this->instant_forms_enabled(),
148 'forms_using_custom_css' => $this->forms_using_custom_css(),
149 'ai_generated_forms' => $this->ai_generated_forms(),
150 'ai_generated_payment_forms' => $this->ai_generated_forms( 'payments' ),
151 'payment_forms' => $this->get_payment_forms_count(),
152 'total_entries' => $entries_table_missing ? 0 : Entries::get_total_entries_by_status(),
153 'restricted_forms' => $this->get_restricted_forms(),
154 'embed_styling_gb_default' => self::embed_styling_gutenberg_count( 'default' ),
155 'embed_styling_el_default' => self::embed_styling_elementor_count( 'default' ),
156 'embed_styling_br_default' => self::embed_styling_bricks_count( 'default' ),
157 'total_form_views' => $this->total_form_views(),
158 'forms_with_views' => $this->forms_with_views(),
159 ];
160
161 // Whether the entries table is currently missing. An event fires once when a
162 // site first sees the notice; this is what shows the state persisting, and
163 // catches a recurrence that the event's one-time dedup would swallow.
164 $stats_data['plugin_data']['sureforms']['boolean_values']['db_entries_table_missing'] = $entries_table_missing;
165
166 $stats_data['plugin_data']['sureforms'] = array_merge_recursive( $stats_data['plugin_data']['sureforms'], $this->global_settings_data() );
167 // Add KPI tracking data.
168 $kpi_data = $this->get_kpi_tracking_data();
169 if ( ! empty( $kpi_data ) ) {
170 $stats_data['plugin_data']['sureforms']['kpi_records'] = $kpi_data;
171 }
172
173 // Build learn progress snapshot.
174 $this->get_learn_tracking_data();
175
176 // Flush pending events into payload (only if any exist).
177 $pending_events = self::events()->flush_pending();
178 if ( ! empty( $pending_events ) ) {
179 $stats_data['plugin_data']['sureforms']['events_record'] = $pending_events;
180 }
181
182 return $stats_data;
183 }
184
185 /**
186 * Return total number of forms using instant forms.
187 *
188 * @since 1.4.0
189 * @return int
190 */
191 public function instant_forms_enabled() {
192 $meta_query = [
193 [
194 'key' => '_srfm_instant_form_settings',
195 'value' => '"enable_instant_form";b:1;',
196 'compare' => 'LIKE',
197 ],
198 ];
199
200 return $this->custom_wp_query_total_posts( $meta_query );
201 }
202
203 /**
204 * Return total number of ai generated forms.
205 *
206 * @param string $form_type Form type to check.
207 *
208 * @since 1.4.0
209 * @return int
210 */
211 public function ai_generated_forms( $form_type = '' ) {
212 $form_type = empty( $form_type ) || ! is_string( $form_type ) ? '' : $form_type;
213 $meta_query = [
214 [
215 'key' => '_srfm_is_ai_generated',
216 'value' => '',
217 'compare' => '!=', // Checks if the value is NOT empty.
218 ],
219 ];
220
221 if ( 'payments' === $form_type ) {
222 $search = 'wp:srfm/payment';
223 return $this->custom_wp_query_total_posts_with_search( $meta_query, $search );
224 }
225
226 return $this->custom_wp_query_total_posts( $meta_query );
227 }
228
229 /**
230 * Return most used anti-spam type on this site.
231 *
232 * @since 1.4.4
233 * @return int
234 */
235 public function most_used_anti_spam() {
236 global $wpdb;
237
238 // Attempt to get from cache first.
239 $cache_key = 'most_used_anti_spam';
240 $cached_result = wp_cache_get( $cache_key, 'sureforms' );
241
242 if ( false !== $cached_result ) {
243 return $cached_result;
244 }
245
246 $meta_key = '_srfm_captcha_security_type';
247
248 // Query to get the most used captcha type.
249 // PHPCS: Ignore direct database query warning, as there is no built-in alternative.
250 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
251 $result = $wpdb->get_row(
252 $wpdb->prepare(
253 "
254 SELECT meta_value, COUNT(meta_value) as count
255 FROM {$wpdb->postmeta}
256 WHERE meta_key = %s
257 AND meta_value != ''
258 GROUP BY meta_value
259 ORDER BY count DESC
260 LIMIT 1
261 ",
262 $meta_key
263 ),
264 ARRAY_A
265 );
266
267 $output = '';
268 if ( $result && ! empty( $result['meta_value'] ) ) {
269 switch ( $result['meta_value'] ) {
270 case 'g-recaptcha':
271 $output = 'Google reCAPTCHA';
272 break;
273
274 case 'cf-turnstile':
275 $output = 'CloudFlare Turnstile';
276 break;
277
278 case 'hcaptcha':
279 $output = 'hCaptcha';
280 break;
281
282 default:
283 $output = '';
284 break;
285 }
286 }
287
288 // Store result in cache for 1 hour.
289 wp_cache_set( $cache_key, $output, 'sureforms', HOUR_IN_SECONDS );
290
291 return $output;
292 }
293
294 /**
295 * Returns total number of forms using custom css.
296 *
297 * @since 1.4.0
298 * @return int
299 */
300 public function forms_using_custom_css() {
301 $meta_query = [
302 [
303 'key' => '_srfm_form_custom_css',
304 'value' => '',
305 'compare' => '!=', // Checks if the value is NOT empty.
306 ],
307 ];
308
309 return $this->custom_wp_query_total_posts( $meta_query );
310 }
311
312 /**
313 * Count Gutenberg srfm/form embed blocks using a specific formTheme.
314 *
315 * When formTheme is 'inherit' (the block.json default), WordPress does not
316 * serialize it in the block comment. So only 'default' and 'custom' appear.
317 *
318 * Counts individual embed blocks (not pages), since a single page can
319 * contain multiple form embeds each with different styling.
320 *
321 * @param string $theme Theme slug to count ('default' or 'custom').
322 * @since 2.7.0
323 * @return int
324 */
325 public static function embed_styling_gutenberg_count( $theme ) {
326 global $wpdb;
327
328 $cache_key = 'embed_styling_gb_' . $theme;
329 $cached_result = wp_cache_get( $cache_key, 'sureforms' );
330
331 if ( false !== $cached_result ) {
332 return (int) $cached_result;
333 }
334
335 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- No WP_Query alternative for cross-post-type content search with multiple LIKE conditions.
336 $posts = $wpdb->get_col(
337 $wpdb->prepare(
338 "SELECT post_content FROM {$wpdb->posts}
339 WHERE post_status = 'publish'
340 AND post_content LIKE %s
341 AND post_content LIKE %s",
342 '%' . $wpdb->esc_like( 'wp:srfm/form' ) . '%',
343 '%' . $wpdb->esc_like( '"formTheme":"' . $theme . '"' ) . '%'
344 )
345 );
346
347 $count = 0;
348 $escaped_theme = preg_quote( $theme, '/' );
349 foreach ( $posts as $content ) {
350 $count += preg_match_all( '/<!--\s*wp:srfm\/form\s+\{[^}]*"formTheme"\s*:\s*"' . $escaped_theme . '"/', $content );
351 }
352
353 wp_cache_set( $cache_key, $count, 'sureforms', HOUR_IN_SECONDS );
354
355 return $count;
356 }
357
358 /**
359 * Count Elementor sureforms_form widgets using a specific formTheme.
360 *
361 * Searches _elementor_data post meta for sureforms_form widgets with
362 * a non-inherit formTheme. Counts individual widgets (not pages).
363 *
364 * @param string $theme Theme slug to count ('default' or 'custom').
365 * @since 2.7.0
366 * @return int
367 */
368 public static function embed_styling_elementor_count( $theme ) {
369 global $wpdb;
370
371 $cache_key = 'embed_styling_el_' . $theme;
372 $cached_result = wp_cache_get( $cache_key, 'sureforms' );
373
374 if ( false !== $cached_result ) {
375 return (int) $cached_result;
376 }
377
378 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- No WP_Query alternative for post meta content search with multiple LIKE conditions.
379 $meta_values = $wpdb->get_col(
380 $wpdb->prepare(
381 "SELECT pm.meta_value FROM {$wpdb->postmeta} pm
382 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
383 WHERE p.post_status = 'publish'
384 AND pm.meta_key = '_elementor_data'
385 AND pm.meta_value LIKE %s
386 AND pm.meta_value LIKE %s",
387 '%' . $wpdb->esc_like( 'sureforms_form' ) . '%',
388 '%' . $wpdb->esc_like( '"formTheme":"' . $theme . '"' ) . '%'
389 )
390 );
391
392 $count = 0;
393 $escaped_theme = preg_quote( $theme, '/' );
394 foreach ( $meta_values as $json ) {
395 // Count widget instances with the specific formTheme in the JSON.
396 $count += preg_match_all( '/"widgetType"\s*:\s*"sureforms_form"[^}]*"formTheme"\s*:\s*"' . $escaped_theme . '"/', $json );
397 }
398
399 wp_cache_set( $cache_key, $count, 'sureforms', HOUR_IN_SECONDS );
400
401 return $count;
402 }
403
404 /**
405 * Count Bricks sureforms elements using a specific formTheme.
406 *
407 * Searches _bricks_page_content_2 post meta (serialized PHP) for
408 * sureforms elements with a non-inherit formTheme. Counts individual elements.
409 *
410 * @param string $theme Theme slug to count ('default' or 'custom').
411 * @since 2.7.0
412 * @return int
413 */
414 public static function embed_styling_bricks_count( $theme ) {
415 global $wpdb;
416
417 $cache_key = 'embed_styling_br_' . $theme;
418 $cached_result = wp_cache_get( $cache_key, 'sureforms' );
419
420 if ( false !== $cached_result ) {
421 return (int) $cached_result;
422 }
423
424 // Bricks stores element data as serialized PHP in _bricks_page_content_2.
425 // In serialized format: s:9:"formTheme";s:7:"default" (for 'default' theme).
426 $serialized_theme = sprintf( '"formTheme";s:%d:"%s"', strlen( $theme ), $theme );
427
428 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- No WP_Query alternative for post meta content search with multiple LIKE conditions.
429 $meta_values = $wpdb->get_col(
430 $wpdb->prepare(
431 "SELECT pm.meta_value FROM {$wpdb->postmeta} pm
432 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
433 WHERE p.post_status = 'publish'
434 AND pm.meta_key = '_bricks_page_content_2'
435 AND pm.meta_value LIKE %s
436 AND pm.meta_value LIKE %s",
437 '%' . $wpdb->esc_like( '"sureforms"' ) . '%',
438 '%' . $wpdb->esc_like( $serialized_theme ) . '%'
439 )
440 );
441
442 $count = 0;
443 $escaped_serial_theme = preg_quote( $serialized_theme, '/' );
444 foreach ( $meta_values as $data ) {
445 $count += preg_match_all( '/' . $escaped_serial_theme . '/', $data );
446 }
447
448 wp_cache_set( $cache_key, $count, 'sureforms', HOUR_IN_SECONDS );
449
450 return $count;
451 }
452
453 /**
454 * Return total number of restricted forms.
455 *
456 * @since 1.10.1
457 * @return int
458 */
459 public function get_restricted_forms() {
460 $meta_query = [
461 [
462 'key' => '_srfm_form_restriction',
463 'value' => '"status":true',
464 'compare' => 'LIKE',
465 ],
466 ];
467
468 return $this->custom_wp_query_total_posts( $meta_query );
469 }
470
471 /**
472 * Generates global setting data for analytics
473 *
474 * @since 1.4.0
475 * @return array
476 */
477 public function global_settings_data() {
478 $global_data = [];
479
480 $security_settings = get_option( 'srfm_security_settings_options', [] );
481 $global_data['boolean_values']['honeypot_enabled'] = isset( $security_settings['srfm_honeypot'] ) && true === $security_settings['srfm_honeypot'];
482
483 $email_summary_data = get_option( 'srfm_email_summary_settings_options', [] );
484 $global_data['boolean_values']['email_summary_enabled'] = isset( $email_summary_data['srfm_email_summary'] ) && true === $email_summary_data['srfm_email_summary'];
485
486 $global_data['boolean_values']['suretriggers_active'] = is_plugin_active( 'suretriggers/suretriggers.php' );
487
488 $bsf_internal_referrer = get_option( 'bsf_product_referers', [] );
489 if ( ! empty( $bsf_internal_referrer['sureforms'] ) ) {
490 $global_data['internal_referer'] = $bsf_internal_referrer['sureforms'];
491 } else {
492 $global_data['internal_referer'] = '';
493 }
494
495 $general_settings = get_option( 'srfm_general_settings_options', [] );
496 $global_data['boolean_values']['ip_logging_enabled'] = ! empty( $general_settings['srfm_ip_log'] );
497
498 $validation_messages = get_option( 'srfm_default_dynamic_block_option', [] );
499 $global_data['boolean_values']['custom_validation_message'] = ! empty( $validation_messages ) && is_array( $validation_messages );
500
501 // Payment analytics - check if any payment method is enabled.
502 $global_data['boolean_values']['stripe_enabled'] = $this->is_stripe_enabled();
503
504 // Delegated rather than read from $general_settings directly, so the telemetry
505 // can never disagree with what the Forms list actually shows. That matters
506 // because the default has already changed once: reimplementing the check here
507 // means a future change has two places to be made and one to be forgotten.
508 $global_data['boolean_values']['form_views_columns_enabled'] = Form_Views::get_instance()->is_tracking_enabled();
509
510 return $global_data;
511 }
512
513 /**
514 * Total page views counted across all published forms.
515 *
516 * Read alongside `forms_with_views`, which is what makes this figure legible: a
517 * site with views but no forms recording any reads very differently from one
518 * with neither.
519 *
520 * Not a denominator for `total_entries`, and the two must not be divided. This
521 * total starts at the moment tracking opened and leaves out anyone who can edit
522 * the site, while `total_entries` is an unfiltered all-time count of every
523 * entry ever received. A rate built from the pair answers neither question and
524 * will not match the Conversion Rate column, which measures both halves over
525 * the same window with the same exclusion.
526 *
527 * Counts only published forms, so views left behind by a trashed or draft form
528 * do not inflate the total against a denominator that no longer includes them.
529 *
530 * @since 2.12.6
531 * @return int
532 */
533 public function total_form_views() {
534 global $wpdb;
535
536 // Deliberately uncached: the only caller is the analytics payload builder,
537 // which runs once per cron pass. A cache would never see a second read inside
538 // a request, and any TTL would always have expired between passes — while
539 // still going stale if a second caller ever appeared.
540 // PHPCS: Ignore direct database query warning, as there is no built-in alternative.
541 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
542 $total = $wpdb->get_var(
543 $wpdb->prepare(
544 "
545 SELECT SUM( pm.meta_value )
546 FROM {$wpdb->postmeta} pm
547 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
548 WHERE pm.meta_key = %s
549 AND p.post_type = %s
550 AND p.post_status = 'publish'
551 ",
552 Form_Views::META_KEY,
553 SRFM_FORMS_POST_TYPE
554 )
555 );
556
557 return Helper::get_integer_value( $total );
558 }
559
560 /**
561 * Number of published forms that have been viewed at least once.
562 *
563 * Distinguishes "the feature is on but nothing is embedded anywhere" from "the
564 * beacon is firing" — a total alone cannot, because one busy form looks the
565 * same as many quiet ones.
566 *
567 * @since 2.12.6
568 * @return int
569 */
570 public function forms_with_views() {
571 return $this->custom_wp_query_total_posts(
572 [
573 [
574 'key' => Form_Views::META_KEY,
575 'value' => 0,
576 'compare' => '>',
577 'type' => 'NUMERIC',
578 ],
579 ]
580 );
581 }
582
583 /**
584 * Returns user status.
585 *
586 * @since 1.8.0
587 * @return string
588 */
589 public function user_status() {
590 // First, check if user_active is already set in srfm_options.
591 if ( Helper::get_srfm_option( 'user_active', false ) ) {
592 return 'active';
593 }
594 // Get up to 10 published SureForms.
595 $forms = get_posts(
596 [
597 'post_type' => SRFM_FORMS_POST_TYPE,
598 'posts_per_page' => 10,
599 'post_status' => 'publish',
600 ]
601 );
602 if ( empty( $forms ) ) {
603 return 'inactive';
604 }
605 foreach ( $forms as $form ) {
606 if ( ! get_post_meta( $form->ID, '_astra_sites_imported_post', true ) ) {
607 // Mark user as active in srfm_options.
608 Helper::update_srfm_option( 'user_active', true );
609 return 'active';
610 }
611 }
612 return 'inactive';
613 }
614
615 /**
616 * Return pointer popup clicked status.
617 *
618 * @return string pointer click status.
619 * @since 1.8.0
620 */
621 public function pointer_popup_clicked() {
622 // Get both values from srfm_options.
623 $accepted = Helper::get_srfm_option( 'pointer_popup_accepted', false );
624 $dismissed = Helper::get_srfm_option( 'pointer_popup_dismissed', false );
625 // If neither action has occurred.
626 if ( ! $accepted && ! $dismissed ) {
627 return '';
628 }
629
630 // If both are set, return the most recent one.
631 if ( $accepted && $dismissed ) {
632 return $accepted > $dismissed ? 'accepted' : 'dismissed';
633 }
634
635 // If only one is set, return it.
636 return $accepted ? 'accepted' : 'dismissed';
637 }
638
639 /**
640 * Runs a custom WP_Query to fetch the total number of posts matching the given meta query and optional search string.
641 *
642 * This function is used to count SureForms posts based on specific meta query conditions.
643 * Optionally, a search string can be included to further filter results by keyword match.
644 *
645 * @since 2.0.0
646 *
647 * @param array $meta_query Meta query array for WP_Query.
648 * @param string $search Optional. Search string for WP_Query. Default empty.
649 * @return int The number of matching posts.
650 */
651 public function custom_wp_query_total_posts_with_search( $meta_query = [], $search = '' ) {
652 $args = [
653 'post_type' => SRFM_FORMS_POST_TYPE,
654 'post_status' => 'publish',
655 'posts_per_page' => -1,
656 ];
657
658 if ( ! empty( $meta_query ) && is_array( $meta_query ) ) {
659 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query required as we need to fetch count of nested data.
660 $args['meta_query'] = $meta_query;
661 }
662
663 // If search string is provided, add it to the query.
664 if ( ! empty( $search ) ) {
665 $args['s'] = sanitize_text_field( $search );
666 }
667
668 $query = new \WP_Query( $args );
669 $posts_count = $query->found_posts;
670
671 wp_reset_postdata();
672
673 return $posts_count;
674 }
675
676 /**
677 * Get the total number of forms that utilize payment blocks.
678 *
679 * This function searches for forms containing the payment block identifier
680 * ('wp:srfm/payment') to determine how many forms include payment capabilities.
681 *
682 * @since 2.0.0
683 * @return int The number of forms that contain payment blocks.
684 */
685 public function get_payment_forms_count() {
686 $search = 'wp:srfm/payment';
687 // Runs a custom WP_Query to find the count of forms with payment block.
688 return $this->custom_wp_query_total_posts_with_search( [], $search );
689 }
690
691 /**
692 * Track first time a user opens the form editor.
693 *
694 * @since 2.5.1
695 * @return void
696 */
697 public function track_first_editor_open() {
698 $screen = get_current_screen();
699 if ( $screen && 'sureforms_form' === $screen->id ) {
700 self::events()->track( 'first_form_editor_opened' );
701 }
702 }
703
704 /**
705 * Track first time a form is published (activation event).
706 *
707 * @param string $new_status New post status.
708 * @param string $old_status Old post status.
709 * @param \WP_Post $post Post object.
710 * @since 2.5.1
711 * @return void
712 */
713 public function track_first_form_published( $new_status, $old_status, $post ) {
714 if ( 'publish' !== $new_status || 'publish' === $old_status || SRFM_FORMS_POST_TYPE !== $post->post_type ) {
715 return;
716 }
717
718 $is_ai = ! empty( get_post_meta( $post->ID, '_srfm_is_ai_generated', true ) );
719 $block_count = substr_count( $post->post_content, '<!-- wp:srfm/' );
720
721 // Time-to-value: days between install and first form published.
722 $install_time = get_site_option( 'sureforms_usage_installed_time', 0 );
723 $days_since_install = 0;
724 if ( $install_time > 0 ) {
725 $days_since_install = (int) floor( ( time() - $install_time ) / DAY_IN_SECONDS );
726 }
727
728 self::events()->track(
729 'first_form_published',
730 (string) $post->ID,
731 [
732 'is_ai_generated' => (string) (int) $is_ai,
733 'block_count' => (string) $block_count,
734 'days_since_install' => (string) $days_since_install,
735 ]
736 );
737 }
738
739 /**
740 * Track embed styling configuration when a post/page is saved.
741 *
742 * Detects custom formTheme in Gutenberg blocks (post_content),
743 * Elementor widgets (_elementor_data meta), and Bricks elements
744 * (_bricks_page_content_2 meta). Re-tracks on each save by flushing
745 * the dedup flag.
746 *
747 * @param int $post_id Post ID.
748 * @param \WP_Post $post Post object.
749 * @since 2.7.0
750 * @return void
751 */
752 public function track_embed_styling_configured( $post_id, $post ) {
753 if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
754 return;
755 }
756
757 if ( 'publish' !== $post->post_status ) {
758 return;
759 }
760
761 $themes = [];
762 $source = '';
763
764 // Check Gutenberg blocks in post_content.
765 if ( preg_match_all( '/<!--\s*wp:srfm\/form\s+\{[^}]*"formTheme"\s*:\s*"(?!inherit")([^"]*)"/', $post->post_content, $matches ) ) {
766 $themes = array_count_values( $matches[1] );
767 $source = 'gutenberg';
768 }
769
770 // Check Elementor widgets in _elementor_data meta.
771 if ( empty( $themes ) ) {
772 $elementor_data = get_post_meta( $post_id, '_elementor_data', true );
773 if ( is_string( $elementor_data )
774 && preg_match_all( '/"widgetType"\s*:\s*"sureforms_form"[^}]*"formTheme"\s*:\s*"(?!inherit")([^"]*)"/', $elementor_data, $el_matches )
775 ) {
776 $themes = array_count_values( $el_matches[1] );
777 $source = 'elementor';
778 }
779 }
780
781 // Check Bricks elements in _bricks_page_content_2 meta (serialized PHP).
782 if ( empty( $themes ) ) {
783 $bricks_data = get_post_meta( $post_id, '_bricks_page_content_2', true );
784 if ( is_array( $bricks_data ) ) {
785 $bricks_themes = self::extract_bricks_form_themes( $bricks_data );
786 if ( ! empty( $bricks_themes ) ) {
787 $themes = array_count_values( $bricks_themes );
788 $source = 'bricks';
789 }
790 }
791 }
792
793 if ( empty( $themes ) ) {
794 return;
795 }
796
797 // Flush dedup so event is re-tracked on each meaningful save.
798 self::events()->flush_pushed( [ 'embed_styling_configured' ] );
799
800 self::events()->track(
801 'embed_styling_configured',
802 (string) $post_id,
803 [
804 'themes' => $themes,
805 'block_count' => array_sum( $themes ),
806 'source' => $source,
807 ]
808 );
809 }
810
811 /**
812 * Track the plugin_activated event with the correct install referer.
813 *
814 * Dedup in self::events()->track() ensures this fires only once. Runs on
815 * 'shutdown' (see detect_state_events()) so it reads bsf_product_referers
816 * after any late-writing referer call has had a chance to run.
817 *
818 * @since 2.12.6
819 * @return void
820 */
821 public function track_plugin_activated_event() {
822 $bsf_referrers = get_option( 'bsf_product_referers', [] );
823 $source = ! empty( $bsf_referrers['sureforms'] ) ? $bsf_referrers['sureforms'] : 'self';
824 self::events()->track( 'plugin_activated', SRFM_VER, [ 'source' => $source ] );
825 }
826
827 /**
828 * Extract non-inherit formTheme values from Bricks element data.
829 *
830 * Recursively walks the unserialized Bricks elements array looking for
831 * sureforms elements with custom formTheme settings.
832 *
833 * @param array<mixed> $elements Bricks elements array.
834 * @return array<string> List of formTheme values (non-inherit).
835 * @since 2.7.0
836 */
837 private static function extract_bricks_form_themes( $elements ) {
838 $themes = [];
839
840 foreach ( $elements as $element ) {
841 if ( ! is_array( $element ) ) {
842 continue;
843 }
844
845 $name = $element['name'] ?? '';
846 $settings = $element['settings'] ?? [];
847 $form_theme = $settings['formTheme'] ?? '';
848
849 if ( 'sureforms' === $name && ! empty( $form_theme ) && 'inherit' !== $form_theme ) {
850 $themes[] = sanitize_text_field( $form_theme );
851 }
852
853 // Recurse into nested children.
854 if ( ! empty( $element['children'] ) && is_array( $element['children'] ) ) {
855 $themes = array_merge( $themes, self::extract_bricks_form_themes( $element['children'] ) );
856 }
857 }
858
859 return $themes;
860 }
861
862 /**
863 * Check if any payment method is enabled.
864 *
865 * This function checks if any payment gateway is connected and enabled.
866 * Currently supports Stripe, but can be extended for other payment methods in the future.
867 *
868 * @since 2.0.0
869 * @return bool True if any payment method is enabled, false otherwise.
870 */
871 private function is_stripe_enabled() {
872 // Check if Stripe is connected.
873 return class_exists( 'SRFM\Inc\Payments\Stripe\Stripe_Helper' ) && \SRFM\Inc\Payments\Stripe\Stripe_Helper::is_stripe_connected();
874 }
875
876 /**
877 * Runs custom WP_Query to fetch data as per requirement
878 *
879 * @param array $meta_query meta query array for WP_Query.
880 * @since 1.4.0
881 * @return int
882 */
883 private function custom_wp_query_total_posts( $meta_query ) {
884
885 $args = [
886 'post_type' => SRFM_FORMS_POST_TYPE,
887 'post_status' => 'publish',
888 'posts_per_page' => -1,
889 'meta_query' => $meta_query, //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query required as we need to fetch count of nested data.
890 ];
891
892 $query = new \WP_Query( $args );
893 $posts_count = $query->found_posts;
894
895 wp_reset_postdata();
896
897 return $posts_count;
898 }
899
900 /**
901 * Get KPI tracking data for the last 2 days (excluding today).
902 *
903 * @since 2.4.0
904 * @return array KPI data organized by date.
905 */
906 private function get_kpi_tracking_data() {
907 $kpi_data = [];
908 $today = current_time( 'Y-m-d' );
909
910 // Get data for yesterday and day before yesterday.
911 for ( $i = 1; $i <= 2; $i++ ) {
912 $date = gmdate( 'Y-m-d', strtotime( $today . ' -' . $i . ' days' ) );
913
914 $kpi_data[ $date ] = [
915 'numeric_values' => [
916 'submissions' => $this->get_daily_submissions_count( $date ),
917 ],
918 ];
919 }
920
921 return $kpi_data;
922 }
923
924 /**
925 * Get daily submissions count for a specific date.
926 *
927 * @param string $date Date in Y-m-d format.
928 * @since 2.4.0
929 * @return int Daily submissions count.
930 */
931 private function get_daily_submissions_count( $date ) {
932 // Guarded here rather than at the call site: this is the function that runs
933 // the query, so a future caller is covered too. Every read against a missing
934 // entries table raises a DB error, and the daily send would raise one per
935 // day counted on exactly the sites whose breakage we most need reported.
936 if ( Register::is_entries_table_missing() ) {
937 return 0;
938 }
939
940 $start_date = $date . ' 00:00:00';
941 $end_date = $date . ' 23:59:59';
942
943 $where_conditions = [
944 [
945 [
946 'key' => 'created_at',
947 'compare' => '>=',
948 'value' => $start_date,
949 ],
950 [
951 'key' => 'created_at',
952 'compare' => '<=',
953 'value' => $end_date,
954 ],
955 ],
956 ];
957
958 return Entries::get_instance()->get_total_count( $where_conditions );
959 }
960
961 /**
962 * Detect state-based events that can't use direct hooks.
963 * Uses dedup in self::events()->track() — safe to call repeatedly.
964 *
965 * @since 2.5.1
966 * @return void
967 */
968 private function detect_state_events() {
969 // plugin_activated: deferred to 'shutdown' so that a referring plugin/theme's
970 // own BSF_UTM_Analytics::update_referer() call — which some products (incorrectly)
971 // make only after their activate_plugin() call returns, in the same request — has
972 // already run by the time we read bsf_product_referers. Reading this synchronously
973 // here would race that write, since this constructor can execute mid-request while
974 // SureForms itself is being activated by that other plugin.
975 if ( did_action( 'shutdown' ) ) {
976 $this->track_plugin_activated_event();
977 } else {
978 add_action( 'shutdown', [ $this, 'track_plugin_activated_event' ], PHP_INT_MAX );
979 }
980
981 // One-time: re-send onboarding_completed with full properties (v2).
982 if ( ! Helper::get_srfm_option( 'onboarding_event_v2_flushed', false )
983 && \SRFM\Inc\Onboarding::get_instance()->get_onboarding_status() ) {
984 self::events()->flush_pushed( [ 'onboarding_completed' ] );
985 Helper::update_srfm_option( 'onboarding_event_v2_flushed', true );
986 }
987
988 // onboarding_completed: detect completed state with full onboarding details.
989 if ( \SRFM\Inc\Onboarding::get_instance()->get_onboarding_status() ) {
990 $onboarding_props = [];
991 $onboarding_analytics = Helper::get_srfm_option( 'onboarding_analytics', [] );
992
993 if ( ! empty( $onboarding_analytics ) && is_array( $onboarding_analytics ) ) {
994 if ( ! empty( $onboarding_analytics['skippedSteps'] ) && is_array( $onboarding_analytics['skippedSteps'] ) ) {
995 $onboarding_props['skipped_steps'] = implode( ',', $onboarding_analytics['skippedSteps'] );
996 }
997
998 if ( isset( $onboarding_analytics['suremailInstalled'] ) ) {
999 $onboarding_props['suremail_installed'] = (bool) $onboarding_analytics['suremailInstalled'] ? 'yes' : 'no';
1000 }
1001
1002 if ( isset( $onboarding_analytics['accountConnected'] ) ) {
1003 $onboarding_props['account_connected'] = (bool) $onboarding_analytics['accountConnected'] ? 'yes' : 'no';
1004 }
1005
1006 if ( isset( $onboarding_analytics['completed'] ) ) {
1007 $onboarding_props['completed'] = (bool) $onboarding_analytics['completed'] ? 'yes' : 'no';
1008 }
1009
1010 if ( isset( $onboarding_analytics['exitedEarly'] ) ) {
1011 $onboarding_props['exited_early'] = (bool) $onboarding_analytics['exitedEarly'] ? 'yes' : 'no';
1012 }
1013
1014 if ( ! empty( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) && is_array( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) ) {
1015 $premium = array_filter(
1016 $onboarding_analytics['premiumFeatures']['selectedFeatures'],
1017 static function( $f ) {
1018 return 'ai-form-generation' !== $f && 'entries' !== $f;
1019 }
1020 );
1021 $onboarding_props['selected_premium_features'] = implode( ',', $premium );
1022 $onboarding_props['premium_features_count'] = (string) count( $premium );
1023 }
1024 }
1025
1026 self::events()->track( 'onboarding_completed', '', $onboarding_props );
1027 }
1028
1029 // stripe_connected: detect connection state.
1030 if ( class_exists( '\SRFM\Inc\Payments\Stripe\Stripe_Helper' )
1031 && \SRFM\Inc\Payments\Stripe\Stripe_Helper::is_stripe_connected() ) {
1032 $mode = \SRFM\Inc\Payments\Stripe\Stripe_Helper::get_stripe_mode();
1033 self::events()->track( 'stripe_connected', ! empty( $mode ) ? $mode : 'live' );
1034 }
1035
1036 // first_ai_form_generated: detect if any AI-generated form exists.
1037 // Guard with is_tracked() to skip the meta_query after the event is already tracked.
1038 if ( ! self::events()->is_tracked( 'first_ai_form_generated' ) ) {
1039 $ai_forms = get_posts(
1040 [
1041 'post_type' => SRFM_FORMS_POST_TYPE,
1042 'post_status' => 'any',
1043 'posts_per_page' => 1,
1044 'fields' => 'ids',
1045 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Runs once per lifecycle via is_tracked guard.
1046 [
1047 'key' => '_srfm_is_ai_generated',
1048 'value' => '',
1049 'compare' => '!=',
1050 ],
1051 ],
1052 ]
1053 );
1054 if ( ! empty( $ai_forms ) ) {
1055 self::events()->track( 'first_ai_form_generated' );
1056 }
1057 }
1058
1059 // MCP / Abilities API first-enable events.
1060 $mcp_settings = get_option( 'srfm_mcp_settings_options', [] );
1061
1062 if ( ! empty( $mcp_settings['srfm_abilities_api'] ) ) {
1063 self::events()->track( 'abilities_api_enabled' );
1064 }
1065
1066 if ( ! empty( $mcp_settings['srfm_mcp_server'] ) ) {
1067 self::events()->track( 'mcp_server_enabled' );
1068 }
1069 }
1070
1071 /**
1072 * Get learn section progress aggregated across all users.
1073 *
1074 * Queries all users with srfm_learn_progress meta, counts completions
1075 * per step. Called at analytics send time (bsf_core_stats filter).
1076 *
1077 * @since 2.8.0
1078 * @return void
1079 */
1080 private function get_learn_tracking_data() {
1081 // Only send when progress changed or event has never been tracked.
1082 $has_changed = get_transient( 'srfm_learn_progress_changed' );
1083 if ( ! $has_changed && self::events()->is_tracked( 'learn' ) ) {
1084 return;
1085 }
1086
1087 $chapters = Learn::get_chapters_structure();
1088 $step_counts = [];
1089 $total_steps = 0;
1090
1091 // Initialize step counters from canonical structure.
1092 foreach ( $chapters as $chapter ) {
1093 if ( ! isset( $chapter['id'], $chapter['steps'] ) || ! is_array( $chapter['steps'] ) ) {
1094 continue;
1095 }
1096 foreach ( $chapter['steps'] as $step ) {
1097 if ( ! isset( $step['id'] ) ) {
1098 continue;
1099 }
1100 $step_counts[ $chapter['id'] . '/' . $step['id'] ] = 0;
1101 ++$total_steps;
1102 }
1103 }
1104
1105 // Query all users who have learn progress.
1106 $users = get_users(
1107 [
1108 'meta_key' => 'srfm_learn_progress', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Runs once per analytics send cycle; bounded by admin user count.
1109 'fields' => 'ID',
1110 ]
1111 );
1112
1113 $users_with_progress = 0;
1114 $users_fully_completed = 0;
1115 $max_completed = 0;
1116
1117 foreach ( $users as $user_id ) {
1118 $progress = get_user_meta( $user_id, 'srfm_learn_progress', true );
1119 if ( ! is_array( $progress ) || empty( $progress ) ) {
1120 continue;
1121 }
1122
1123 $user_completed = 0;
1124
1125 foreach ( $step_counts as $key => $count ) {
1126 [ $chapter_id, $step_id ] = explode( '/', $key );
1127 if ( ! empty( $progress[ $chapter_id ][ $step_id ] ) ) {
1128 $step_counts[ $key ] = $count + 1;
1129 ++$user_completed;
1130 }
1131 }
1132
1133 if ( $user_completed > 0 ) {
1134 ++$users_with_progress;
1135 }
1136 if ( $user_completed === $total_steps ) {
1137 ++$users_fully_completed;
1138 }
1139 $max_completed = max( $max_completed, $user_completed );
1140 }
1141
1142 // Build flat properties — step keys use snake_case (hyphens → underscores).
1143 $properties = [];
1144 foreach ( $step_counts as $key => $count ) {
1145 $step_id = explode( '/', $key )[1];
1146 $properties[ str_replace( '-', '_', $step_id ) ] = (string) $count;
1147 }
1148
1149 $properties['users_with_progress'] = (string) $users_with_progress;
1150 $properties['users_fully_completed'] = (string) $users_fully_completed;
1151 $properties['total_steps'] = (string) $total_steps;
1152
1153 // Flush dedup so event re-tracks with latest snapshot.
1154 self::events()->flush_pushed( [ 'learn' ] );
1155 self::events()->track( 'learn', (string) $max_completed, $properties );
1156
1157 // Clear the change flag after tracking.
1158 delete_transient( 'srfm_learn_progress_changed' );
1159 }
1160
1161 }
1162