PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.3.0
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.3.0
2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 All 96 releases
← All changes | admin/analytics.php +100 -712 trunk2.3.0 View file →
@@ -6,13 +6,10 @@
6 6 */
7 7
8 8 namespace SRFM\Admin;
9 9
10 -use SRFM\Inc\Database\Register;
11 10 use SRFM\Inc\Database\Tables\Entries;
12 -use SRFM\Inc\Form_Views;
13 11 use SRFM\Inc\Helper;
14 -use SRFM\Inc\Learn;
15 12 use SRFM\Inc\Traits\Get_Instance;
16 13
17 14 if ( ! defined( 'ABSPATH' ) ) {
18 15 exit; // Exit if accessed directly.
@@ -25,15 +22,8 @@
25 22 class Analytics {
26 23 use Get_Instance;
27 24
28 25 /**
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 26 * Class constructor.
37 27 *
38 28 * @return void
39 29 * @since 1.4.0
@@ -45,10 +35,10 @@
45 35 if ( ! class_exists( 'BSF_Analytics_Loader' ) ) {
46 36 require_once SRFM_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-loader.php';
47 37 }
48 38
49 - if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
50 - require_once SRFM_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
39 + if ( ! class_exists( 'Astra_Notices' ) ) {
40 + require_once SRFM_DIR . 'inc/lib/astra-notices/class-astra-notices.php';
51 41 }
52 42
53 43 add_filter(
54 44 'uds_survey_allowed_screens',
@@ -86,45 +76,11 @@
86 76 ]
87 77 );
88 78
89 79 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 80 }
99 81
100 82 /**
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 83 * Callback function to add SureForms specific analytics data.
128 84 *
129 85 * @param array $stats_data existing stats_data.
130 86 * @since 1.4.0
@@ -130,9 +86,9 @@
130 86 * @since 1.4.0
131 87 * @return array
132 88 */
133 89 public function add_srfm_analytics_data( $stats_data ) {
134 - $stats_data['plugin_data']['sureforms'] = [
90 + $stats_data['plugin_data']['sureforms'] = [
135 91 'free_version' => SRFM_VER,
136 92 'site_language' => get_locale(),
137 93 'most_used_anti_spam' => $this->most_used_anti_spam(),
138 94 'user_status' => $this->user_status(),
@@ -137,12 +93,8 @@
137 93 'most_used_anti_spam' => $this->most_used_anti_spam(),
138 94 'user_status' => $this->user_status(),
139 95 'pointer_popup_clicked' => $this->pointer_popup_clicked(),
140 96 ];
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 97 $stats_data['plugin_data']['sureforms']['numeric_values'] = [
146 98 'total_forms' => wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0,
147 99 'instant_forms_enabled' => $this->instant_forms_enabled(),
148 100 'forms_using_custom_css' => $this->forms_using_custom_css(),
@@ -148,38 +100,15 @@
148 100 'forms_using_custom_css' => $this->forms_using_custom_css(),
149 101 'ai_generated_forms' => $this->ai_generated_forms(),
150 102 'ai_generated_payment_forms' => $this->ai_generated_forms( 'payments' ),
151 103 'payment_forms' => $this->get_payment_forms_count(),
152 - 'total_entries' => $entries_table_missing ? 0 : Entries::get_total_entries_by_status(),
104 + 'total_entries' => Entries::get_total_entries_by_status(),
153 105 '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 106 ];
160 107
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 108 $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 -
109 + // Add onboarding analytics data.
110 + $stats_data['plugin_data']['sureforms'] = array_merge_recursive( $stats_data['plugin_data']['sureforms'], $this->onboarding_analytics_data() );
182 111 return $stats_data;
183 112 }
184 113
185 114 /**
@@ -309,149 +238,8 @@
309 238 return $this->custom_wp_query_total_posts( $meta_query );
310 239 }
311 240
312 241 /**
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 242 * Return total number of restricted forms.
455 243 *
456 244 * @since 1.10.1
457 245 * @return int
@@ -500,78 +288,111 @@
500 288
501 289 // Payment analytics - check if any payment method is enabled.
502 290 $global_data['boolean_values']['stripe_enabled'] = $this->is_stripe_enabled();
503 291
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 292 return $global_data;
511 293 }
512 294
513 295 /**
514 - * Total page views counted across all published forms.
296 + * Generates onboarding analytics data
515 297 *
516 - * Paired with `forms_with_views` and the existing `total_entries`, this is what
517 - * makes the conversion figure interpretable in the warehouse: a site with views
518 - * but no entries reads very differently from one with neither.
519 - *
520 - * Counts only published forms, so views left behind by a trashed or draft form
521 - * do not inflate the total against a denominator that no longer includes them.
522 - *
523 - * @since 2.12.6
524 - * @return int
298 + * @since 1.9.1
299 + * @return array
525 300 */
526 - public function total_form_views() {
527 - global $wpdb;
301 + public function onboarding_analytics_data() {
302 + $onboarding_data = [];
303 + $analytics_option = Helper::get_srfm_option( 'onboarding_analytics', [] );
528 304
529 - // Deliberately uncached: the only caller is the analytics payload builder,
530 - // which runs once per cron pass. A cache would never see a second read inside
531 - // a request, and any TTL would always have expired between passes — while
532 - // still going stale if a second caller ever appeared.
533 - // PHPCS: Ignore direct database query warning, as there is no built-in alternative.
534 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
535 - $total = $wpdb->get_var(
536 - $wpdb->prepare(
537 - "
538 - SELECT SUM( pm.meta_value )
539 - FROM {$wpdb->postmeta} pm
540 - INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
541 - WHERE pm.meta_key = %s
542 - AND p.post_type = %s
543 - AND p.post_status = 'publish'
544 - ",
545 - Form_Views::META_KEY,
546 - SRFM_FORMS_POST_TYPE
547 - )
548 - );
305 + if ( empty( $analytics_option ) ) {
306 + return $onboarding_data;
307 + }
549 308
550 - return Helper::get_integer_value( $total );
551 - }
309 + // Process skipped steps - store as an array.
310 + if ( ! empty( $analytics_option['skippedSteps'] ) && is_array( $analytics_option['skippedSteps'] ) ) {
311 + // Map step keys to more descriptive names.
312 + $step_mapping = [
313 + 'welcome' => 'Welcome',
314 + 'connect' => 'Connect',
315 + 'emailDelivery' => 'SureMail',
316 + 'premiumFeatures' => 'Features',
317 + 'done' => 'Done',
318 + ];
552 319
553 - /**
554 - * Number of published forms that have been viewed at least once.
555 - *
556 - * Distinguishes "the feature is on but nothing is embedded anywhere" from "the
557 - * beacon is firing" — a total alone cannot, because one busy form looks the
558 - * same as many quiet ones.
559 - *
560 - * @since 2.12.6
561 - * @return int
562 - */
563 - public function forms_with_views() {
564 - return $this->custom_wp_query_total_posts(
565 - [
566 - [
567 - 'key' => Form_Views::META_KEY,
568 - 'value' => 0,
569 - 'compare' => '>',
570 - 'type' => 'NUMERIC',
571 - ],
572 - ]
573 - );
320 + // Transform the step keys to their descriptive names.
321 + $mapped_steps = array_map(
322 + static function( $step ) use ( $step_mapping ) {
323 + return $step_mapping[ $step ] ?? $step;
324 + },
325 + $analytics_option['skippedSteps']
326 + );
327 +
328 + // Store as an array.
329 + $onboarding_data['onboarding_skipped_steps'] = $mapped_steps;
330 + }
331 +
332 + // SureMail Installation Status.
333 + if ( isset( $analytics_option['suremailInstalled'] ) ) {
334 + $onboarding_data['boolean_values']['onboarding_suremail_installed'] = (bool) $analytics_option['suremailInstalled'];
335 + }
336 +
337 + // Account Connection Status.
338 + if ( isset( $analytics_option['accountConnected'] ) ) {
339 + $onboarding_data['boolean_values']['onboarding_account_connected'] = (bool) $analytics_option['accountConnected'];
340 + }
341 +
342 + // Onboarding Completion Status.
343 + if ( isset( $analytics_option['completed'] ) ) {
344 + $onboarding_data['boolean_values']['onboarding_completed'] = (bool) $analytics_option['completed'];
345 + }
346 +
347 + // Onboarding Early Exit Status.
348 + if ( isset( $analytics_option['exitedEarly'] ) ) {
349 + $onboarding_data['boolean_values']['onboarding_exited_early'] = (bool) $analytics_option['exitedEarly'];
350 + }
351 +
352 + // Onboarding Selected Premium Features.
353 + if ( ! empty( $analytics_option['premiumFeatures'] ) && ! empty( $analytics_option['premiumFeatures']['selectedFeatures'] ) ) {
354 + // Map feature IDs to more descriptive names - exclude free features.
355 + $feature_mapping = [
356 + // Starter features.
357 + 'multi_step_form' => 'Multi-step Forms',
358 + 'conditional_logic' => 'Conditional Fields',
359 + 'webhooks' => 'Webhooks',
360 + 'advanced_fields' => 'Advanced Fields',
361 +
362 + // Pro features.
363 + 'conversational_forms' => 'Conversational Forms',
364 + 'digital_signatures' => 'Digital Signatures',
365 +
366 + // Business features.
367 + 'calculations' => 'Calculators',
368 + 'user_registration' => 'User Registration and Login',
369 + 'custom_app' => 'Custom App',
370 + 'pdf_generation' => 'PDF Generation',
371 + ];
372 +
373 + // Filter out any free features that might have been included.
374 + $premium_features = array_filter(
375 + $analytics_option['premiumFeatures']['selectedFeatures'],
376 + static function( $feature ) {
377 + // Exclude free features (ai-form-generation and entries).
378 + return 'ai-form-generation' !== $feature && 'entries' !== $feature;
379 + }
380 + );
381 +
382 + // Transform the feature IDs to their descriptive names.
383 + $mapped_features = array_map(
384 + static function( $feature ) use ( $feature_mapping ) {
385 + return $feature_mapping[ $feature ] ?? $feature;
386 + },
387 + $premium_features
388 + );
389 +
390 + // Store as an array.
391 + $onboarding_data['onboarding_selected_premium_features'] = $mapped_features;
392 + }
393 +
394 + return $onboarding_data;
574 395 }
575 396
576 397 /**
577 398 * Returns user status.
@@ -681,179 +502,8 @@
681 502 return $this->custom_wp_query_total_posts_with_search( [], $search );
682 503 }
683 504
684 505 /**
685 - * Track first time a user opens the form editor.
686 - *
687 - * @since 2.5.1
688 - * @return void
689 - */
690 - public function track_first_editor_open() {
691 - $screen = get_current_screen();
692 - if ( $screen && 'sureforms_form' === $screen->id ) {
693 - self::events()->track( 'first_form_editor_opened' );
694 - }
695 - }
696 -
697 - /**
698 - * Track first time a form is published (activation event).
699 - *
700 - * @param string $new_status New post status.
701 - * @param string $old_status Old post status.
702 - * @param \WP_Post $post Post object.
703 - * @since 2.5.1
704 - * @return void
705 - */
706 - public function track_first_form_published( $new_status, $old_status, $post ) {
707 - if ( 'publish' !== $new_status || 'publish' === $old_status || SRFM_FORMS_POST_TYPE !== $post->post_type ) {
708 - return;
709 - }
710 -
711 - $is_ai = ! empty( get_post_meta( $post->ID, '_srfm_is_ai_generated', true ) );
712 - $block_count = substr_count( $post->post_content, '<!-- wp:srfm/' );
713 -
714 - // Time-to-value: days between install and first form published.
715 - $install_time = get_site_option( 'sureforms_usage_installed_time', 0 );
716 - $days_since_install = 0;
717 - if ( $install_time > 0 ) {
718 - $days_since_install = (int) floor( ( time() - $install_time ) / DAY_IN_SECONDS );
719 - }
720 -
721 - self::events()->track(
722 - 'first_form_published',
723 - (string) $post->ID,
724 - [
725 - 'is_ai_generated' => (string) (int) $is_ai,
726 - 'block_count' => (string) $block_count,
727 - 'days_since_install' => (string) $days_since_install,
728 - ]
729 - );
730 - }
731 -
732 - /**
733 - * Track embed styling configuration when a post/page is saved.
734 - *
735 - * Detects custom formTheme in Gutenberg blocks (post_content),
736 - * Elementor widgets (_elementor_data meta), and Bricks elements
737 - * (_bricks_page_content_2 meta). Re-tracks on each save by flushing
738 - * the dedup flag.
739 - *
740 - * @param int $post_id Post ID.
741 - * @param \WP_Post $post Post object.
742 - * @since 2.7.0
743 - * @return void
744 - */
745 - public function track_embed_styling_configured( $post_id, $post ) {
746 - if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
747 - return;
748 - }
749 -
750 - if ( 'publish' !== $post->post_status ) {
751 - return;
752 - }
753 -
754 - $themes = [];
755 - $source = '';
756 -
757 - // Check Gutenberg blocks in post_content.
758 - if ( preg_match_all( '/<!--\s*wp:srfm\/form\s+\{[^}]*"formTheme"\s*:\s*"(?!inherit")([^"]*)"/', $post->post_content, $matches ) ) {
759 - $themes = array_count_values( $matches[1] );
760 - $source = 'gutenberg';
761 - }
762 -
763 - // Check Elementor widgets in _elementor_data meta.
764 - if ( empty( $themes ) ) {
765 - $elementor_data = get_post_meta( $post_id, '_elementor_data', true );
766 - if ( is_string( $elementor_data )
767 - && preg_match_all( '/"widgetType"\s*:\s*"sureforms_form"[^}]*"formTheme"\s*:\s*"(?!inherit")([^"]*)"/', $elementor_data, $el_matches )
768 - ) {
769 - $themes = array_count_values( $el_matches[1] );
770 - $source = 'elementor';
771 - }
772 - }
773 -
774 - // Check Bricks elements in _bricks_page_content_2 meta (serialized PHP).
775 - if ( empty( $themes ) ) {
776 - $bricks_data = get_post_meta( $post_id, '_bricks_page_content_2', true );
777 - if ( is_array( $bricks_data ) ) {
778 - $bricks_themes = self::extract_bricks_form_themes( $bricks_data );
779 - if ( ! empty( $bricks_themes ) ) {
780 - $themes = array_count_values( $bricks_themes );
781 - $source = 'bricks';
782 - }
783 - }
784 - }
785 -
786 - if ( empty( $themes ) ) {
787 - return;
788 - }
789 -
790 - // Flush dedup so event is re-tracked on each meaningful save.
791 - self::events()->flush_pushed( [ 'embed_styling_configured' ] );
792 -
793 - self::events()->track(
794 - 'embed_styling_configured',
795 - (string) $post_id,
796 - [
797 - 'themes' => $themes,
798 - 'block_count' => array_sum( $themes ),
799 - 'source' => $source,
800 - ]
801 - );
802 - }
803 -
804 - /**
805 - * Track the plugin_activated event with the correct install referer.
806 - *
807 - * Dedup in self::events()->track() ensures this fires only once. Runs on
808 - * 'shutdown' (see detect_state_events()) so it reads bsf_product_referers
809 - * after any late-writing referer call has had a chance to run.
810 - *
811 - * @since 2.12.6
812 - * @return void
813 - */
814 - public function track_plugin_activated_event() {
815 - $bsf_referrers = get_option( 'bsf_product_referers', [] );
816 - $source = ! empty( $bsf_referrers['sureforms'] ) ? $bsf_referrers['sureforms'] : 'self';
817 - self::events()->track( 'plugin_activated', SRFM_VER, [ 'source' => $source ] );
818 - }
819 -
820 - /**
821 - * Extract non-inherit formTheme values from Bricks element data.
822 - *
823 - * Recursively walks the unserialized Bricks elements array looking for
824 - * sureforms elements with custom formTheme settings.
825 - *
826 - * @param array<mixed> $elements Bricks elements array.
827 - * @return array<string> List of formTheme values (non-inherit).
828 - * @since 2.7.0
829 - */
830 - private static function extract_bricks_form_themes( $elements ) {
831 - $themes = [];
832 -
833 - foreach ( $elements as $element ) {
834 - if ( ! is_array( $element ) ) {
835 - continue;
836 - }
837 -
838 - $name = $element['name'] ?? '';
839 - $settings = $element['settings'] ?? [];
840 - $form_theme = $settings['formTheme'] ?? '';
841 -
842 - if ( 'sureforms' === $name && ! empty( $form_theme ) && 'inherit' !== $form_theme ) {
843 - $themes[] = sanitize_text_field( $form_theme );
844 - }
845 -
846 - // Recurse into nested children.
847 - if ( ! empty( $element['children'] ) && is_array( $element['children'] ) ) {
848 - $themes = array_merge( $themes, self::extract_bricks_form_themes( $element['children'] ) );
849 - }
850 - }
851 -
852 - return $themes;
853 - }
854 -
855 - /**
856 506 * Check if any payment method is enabled.
857 507 *
858 508 * This function checks if any payment gateway is connected and enabled.
859 509 * Currently supports Stripe, but can be extended for other payment methods in the future.
@@ -888,267 +538,5 @@
888 538 wp_reset_postdata();
889 539
890 540 return $posts_count;
891 541 }
892 -
893 - /**
894 - * Get KPI tracking data for the last 2 days (excluding today).
895 - *
896 - * @since 2.4.0
897 - * @return array KPI data organized by date.
898 - */
899 - private function get_kpi_tracking_data() {
900 - $kpi_data = [];
901 - $today = current_time( 'Y-m-d' );
902 -
903 - // Get data for yesterday and day before yesterday.
904 - for ( $i = 1; $i <= 2; $i++ ) {
905 - $date = gmdate( 'Y-m-d', strtotime( $today . ' -' . $i . ' days' ) );
906 -
907 - $kpi_data[ $date ] = [
908 - 'numeric_values' => [
909 - 'submissions' => $this->get_daily_submissions_count( $date ),
910 - ],
911 - ];
912 - }
913 -
914 - return $kpi_data;
915 - }
916 -
917 - /**
918 - * Get daily submissions count for a specific date.
919 - *
920 - * @param string $date Date in Y-m-d format.
921 - * @since 2.4.0
922 - * @return int Daily submissions count.
923 - */
924 - private function get_daily_submissions_count( $date ) {
925 - // Guarded here rather than at the call site: this is the function that runs
926 - // the query, so a future caller is covered too. Every read against a missing
927 - // entries table raises a DB error, and the daily send would raise one per
928 - // day counted on exactly the sites whose breakage we most need reported.
929 - if ( Register::is_entries_table_missing() ) {
930 - return 0;
931 - }
932 -
933 - $start_date = $date . ' 00:00:00';
934 - $end_date = $date . ' 23:59:59';
935 -
936 - $where_conditions = [
937 - [
938 - [
939 - 'key' => 'created_at',
940 - 'compare' => '>=',
941 - 'value' => $start_date,
942 - ],
943 - [
944 - 'key' => 'created_at',
945 - 'compare' => '<=',
946 - 'value' => $end_date,
947 - ],
948 - ],
949 - ];
950 -
951 - return Entries::get_instance()->get_total_count( $where_conditions );
952 - }
953 -
954 - /**
955 - * Detect state-based events that can't use direct hooks.
956 - * Uses dedup in self::events()->track() — safe to call repeatedly.
957 - *
958 - * @since 2.5.1
959 - * @return void
960 - */
961 - private function detect_state_events() {
962 - // plugin_activated: deferred to 'shutdown' so that a referring plugin/theme's
963 - // own BSF_UTM_Analytics::update_referer() call — which some products (incorrectly)
964 - // make only after their activate_plugin() call returns, in the same request — has
965 - // already run by the time we read bsf_product_referers. Reading this synchronously
966 - // here would race that write, since this constructor can execute mid-request while
967 - // SureForms itself is being activated by that other plugin.
968 - if ( did_action( 'shutdown' ) ) {
969 - $this->track_plugin_activated_event();
970 - } else {
971 - add_action( 'shutdown', [ $this, 'track_plugin_activated_event' ], PHP_INT_MAX );
972 - }
973 -
974 - // One-time: re-send onboarding_completed with full properties (v2).
975 - if ( ! Helper::get_srfm_option( 'onboarding_event_v2_flushed', false )
976 - && \SRFM\Inc\Onboarding::get_instance()->get_onboarding_status() ) {
977 - self::events()->flush_pushed( [ 'onboarding_completed' ] );
978 - Helper::update_srfm_option( 'onboarding_event_v2_flushed', true );
979 - }
980 -
981 - // onboarding_completed: detect completed state with full onboarding details.
982 - if ( \SRFM\Inc\Onboarding::get_instance()->get_onboarding_status() ) {
983 - $onboarding_props = [];
984 - $onboarding_analytics = Helper::get_srfm_option( 'onboarding_analytics', [] );
985 -
986 - if ( ! empty( $onboarding_analytics ) && is_array( $onboarding_analytics ) ) {
987 - if ( ! empty( $onboarding_analytics['skippedSteps'] ) && is_array( $onboarding_analytics['skippedSteps'] ) ) {
988 - $onboarding_props['skipped_steps'] = implode( ',', $onboarding_analytics['skippedSteps'] );
989 - }
990 -
991 - if ( isset( $onboarding_analytics['suremailInstalled'] ) ) {
992 - $onboarding_props['suremail_installed'] = (bool) $onboarding_analytics['suremailInstalled'] ? 'yes' : 'no';
993 - }
994 -
995 - if ( isset( $onboarding_analytics['accountConnected'] ) ) {
996 - $onboarding_props['account_connected'] = (bool) $onboarding_analytics['accountConnected'] ? 'yes' : 'no';
997 - }
998 -
999 - if ( isset( $onboarding_analytics['completed'] ) ) {
1000 - $onboarding_props['completed'] = (bool) $onboarding_analytics['completed'] ? 'yes' : 'no';
1001 - }
1002 -
1003 - if ( isset( $onboarding_analytics['exitedEarly'] ) ) {
1004 - $onboarding_props['exited_early'] = (bool) $onboarding_analytics['exitedEarly'] ? 'yes' : 'no';
1005 - }
1006 -
1007 - if ( ! empty( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) && is_array( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) ) {
1008 - $premium = array_filter(
1009 - $onboarding_analytics['premiumFeatures']['selectedFeatures'],
1010 - static function( $f ) {
1011 - return 'ai-form-generation' !== $f && 'entries' !== $f;
1012 - }
1013 - );
1014 - $onboarding_props['selected_premium_features'] = implode( ',', $premium );
1015 - $onboarding_props['premium_features_count'] = (string) count( $premium );
1016 - }
1017 - }
1018 -
1019 - self::events()->track( 'onboarding_completed', '', $onboarding_props );
1020 - }
1021 -
1022 - // stripe_connected: detect connection state.
1023 - if ( class_exists( '\SRFM\Inc\Payments\Stripe\Stripe_Helper' )
1024 - && \SRFM\Inc\Payments\Stripe\Stripe_Helper::is_stripe_connected() ) {
1025 - $mode = \SRFM\Inc\Payments\Stripe\Stripe_Helper::get_stripe_mode();
1026 - self::events()->track( 'stripe_connected', ! empty( $mode ) ? $mode : 'live' );
1027 - }
1028 -
1029 - // first_ai_form_generated: detect if any AI-generated form exists.
1030 - // Guard with is_tracked() to skip the meta_query after the event is already tracked.
1031 - if ( ! self::events()->is_tracked( 'first_ai_form_generated' ) ) {
1032 - $ai_forms = get_posts(
1033 - [
1034 - 'post_type' => SRFM_FORMS_POST_TYPE,
1035 - 'post_status' => 'any',
1036 - 'posts_per_page' => 1,
1037 - 'fields' => 'ids',
1038 - 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Runs once per lifecycle via is_tracked guard.
1039 - [
1040 - 'key' => '_srfm_is_ai_generated',
1041 - 'value' => '',
1042 - 'compare' => '!=',
1043 - ],
1044 - ],
1045 - ]
1046 - );
1047 - if ( ! empty( $ai_forms ) ) {
1048 - self::events()->track( 'first_ai_form_generated' );
1049 - }
1050 - }
1051 -
1052 - // MCP / Abilities API first-enable events.
1053 - $mcp_settings = get_option( 'srfm_mcp_settings_options', [] );
1054 -
1055 - if ( ! empty( $mcp_settings['srfm_abilities_api'] ) ) {
1056 - self::events()->track( 'abilities_api_enabled' );
1057 - }
1058 -
1059 - if ( ! empty( $mcp_settings['srfm_mcp_server'] ) ) {
1060 - self::events()->track( 'mcp_server_enabled' );
1061 - }
1062 - }
1063 -
1064 - /**
1065 - * Get learn section progress aggregated across all users.
1066 - *
1067 - * Queries all users with srfm_learn_progress meta, counts completions
1068 - * per step. Called at analytics send time (bsf_core_stats filter).
1069 - *
1070 - * @since 2.8.0
1071 - * @return void
1072 - */
1073 - private function get_learn_tracking_data() {
1074 - // Only send when progress changed or event has never been tracked.
1075 - $has_changed = get_transient( 'srfm_learn_progress_changed' );
1076 - if ( ! $has_changed && self::events()->is_tracked( 'learn' ) ) {
1077 - return;
1078 - }
1079 -
1080 - $chapters = Learn::get_chapters_structure();
1081 - $step_counts = [];
1082 - $total_steps = 0;
1083 -
1084 - // Initialize step counters from canonical structure.
1085 - foreach ( $chapters as $chapter ) {
1086 - if ( ! isset( $chapter['id'], $chapter['steps'] ) || ! is_array( $chapter['steps'] ) ) {
1087 - continue;
1088 - }
1089 - foreach ( $chapter['steps'] as $step ) {
1090 - if ( ! isset( $step['id'] ) ) {
1091 - continue;
1092 - }
1093 - $step_counts[ $chapter['id'] . '/' . $step['id'] ] = 0;
1094 - ++$total_steps;
1095 - }
1096 - }
1097 -
1098 - // Query all users who have learn progress.
1099 - $users = get_users(
1100 - [
1101 - '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.
1102 - 'fields' => 'ID',
1103 - ]
1104 - );
1105 -
1106 - $users_with_progress = 0;
1107 - $users_fully_completed = 0;
1108 - $max_completed = 0;
1109 -
1110 - foreach ( $users as $user_id ) {
1111 - $progress = get_user_meta( $user_id, 'srfm_learn_progress', true );
1112 - if ( ! is_array( $progress ) || empty( $progress ) ) {
1113 - continue;
1114 - }
1115 -
1116 - $user_completed = 0;
1117 -
1118 - foreach ( $step_counts as $key => $count ) {
1119 - [ $chapter_id, $step_id ] = explode( '/', $key );
1120 - if ( ! empty( $progress[ $chapter_id ][ $step_id ] ) ) {
1121 - $step_counts[ $key ] = $count + 1;
1122 - ++$user_completed;
1123 - }
1124 - }
1125 -
1126 - if ( $user_completed > 0 ) {
1127 - ++$users_with_progress;
1128 - }
1129 - if ( $user_completed === $total_steps ) {
1130 - ++$users_fully_completed;
1131 - }
1132 - $max_completed = max( $max_completed, $user_completed );
1133 - }
1134 -
1135 - // Build flat properties — step keys use snake_case (hyphens → underscores).
1136 - $properties = [];
1137 - foreach ( $step_counts as $key => $count ) {
1138 - $step_id = explode( '/', $key )[1];
1139 - $properties[ str_replace( '-', '_', $step_id ) ] = (string) $count;
1140 - }
1141 -
1142 - $properties['users_with_progress'] = (string) $users_with_progress;
1143 - $properties['users_fully_completed'] = (string) $users_fully_completed;
1144 - $properties['total_steps'] = (string) $total_steps;
1145 -
1146 - // Flush dedup so event re-tracks with latest snapshot.
1147 - self::events()->flush_pushed( [ 'learn' ] );
1148 - self::events()->track( 'learn', (string) $max_completed, $properties );
1149 -
1150 - // Clear the change flag after tracking.
1151 - delete_transient( 'srfm_learn_progress_changed' );
1152 - }
1153 -
1154 542 }