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