PluginProbe
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce / 3.3.0
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce v3.3.0
3.3.0 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 trunk 1.0.4 1.1.0 1.1.0.1 1.1.1 1.1.10 1.1.11 1.1.12 1.1.13 1.1.14 1.1.15 1.1.16 1.1.17 1.1.18 1.1.19 1.1.2 All 162 releases
cartflows / classes / class-cartflows-analytics.php

class-cartflows-analytics.php in CartFlows – Funnel Builder & Checkout Plugin for WooCommerce 3.3.0, at classes/class-cartflows-analytics.php

1,102 lines 35.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * CartFlows Analytics — single entry point for all BSF Analytics tracking.
4 *
5 * Owns the complete CartFlows stats payload (default stats, numeric_values,
6 * boolean_values, kpi_records) and one-time milestone events via
7 * BSF_Analytics_Events. Registered on a single bsf_core_stats filter.
8 *
9 * @package CartFlows
10 * @since 2.2.4
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Class Cartflows_Analytics.
20 */
21 class Cartflows_Analytics {
22
23 /**
24 * Instance
25 *
26 * @access private
27 * @var Cartflows_Analytics|null Class object.
28 * @since 2.2.4
29 */
30 private static $instance;
31
32 /**
33 * Shared BSF_Analytics_Events instance.
34 *
35 * @var BSF_Analytics_Events|null
36 */
37 private static $events = null;
38
39 /**
40 * Initiator
41 *
42 * @since 2.2.4
43 * @return Cartflows_Analytics initialized object of class.
44 */
45 public static function get_instance() {
46 if ( ! isset( self::$instance ) ) {
47 self::$instance = new self();
48 }
49 return self::$instance;
50 }
51
52 /**
53 * Constructor.
54 *
55 * Registers the bsf_core_stats filter on every request (frontend and admin)
56 * so events_record rides the BSF_Analytics::send() POST regardless of which
57 * request triggers transmission. State detection runs lazily inside the
58 * filter callback (see get_stats) — same pattern as Astra.
59 *
60 * @since 2.2.4
61 */
62 public function __construct() {
63 // Single bsf_core_stats filter — owns the complete CartFlows payload.
64 add_filter( 'bsf_core_stats', array( $this, 'get_stats' ) );
65
66 // Hook-based event: first flow published.
67 add_action( 'transition_post_status', array( $this, 'track_first_flow_published' ), 10, 3 );
68
69 // Meta-level so template imports and Store Checkout are caught, not just the admin save.
70 add_action( 'added_post_meta', array( $this, 'track_first_checkout_configured' ), 10, 4 );
71 add_action( 'updated_post_meta', array( $this, 'track_first_checkout_configured' ), 10, 4 );
72 }
73
74 /**
75 * Get shared BSF_Analytics_Events instance.
76 *
77 * Returns null if BSF_Analytics_Events is not yet loaded — callers must guard.
78 *
79 * @since 2.2.4
80 * @return BSF_Analytics_Events|null
81 */
82 public static function events() {
83 if ( null === self::$events ) {
84 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
85 return null;
86 }
87 self::$events = new BSF_Analytics_Events( 'cf' );
88 }
89 return self::$events;
90 }
91
92 /**
93 * Whether non-sensitive usage tracking is opted in.
94 *
95 * Reads the site option so multisite network opt-in resolves the same way BSF Analytics does.
96 *
97 * @since x.x.x
98 * @return bool
99 */
100 public static function is_tracking_opted_in() {
101 // Honour the BSF-wide kill switch and white-label first — BSF_Analytics::is_tracking_enabled() checks both.
102 if ( ! apply_filters( 'bsf_usage_tracking_enabled', true ) ) {
103 return false;
104 }
105
106 $white_label = apply_filters( 'cf_white_label_options', array() );
107 if ( is_array( $white_label ) && in_array( true, $white_label, true ) ) {
108 return false;
109 }
110
111 // BSF stores the literal string 'no' on opt-out, which a bare bool cast would treat as opted in.
112 return (bool) apply_filters( 'cartflows_enable_non_sensitive_data_tracking', 'yes' === get_site_option( 'cf_usage_optin', false ) );
113 }
114
115 /**
116 * Whole days elapsed since the plugin was installed, 0 when unknown.
117 *
118 * @since x.x.x
119 * @return int
120 */
121 private function get_days_since_install() {
122 $install_time = Cartflows_Helper::get_analytics_flag( 'usage_installed_time', 0 );
123
124 if ( ! is_numeric( $install_time ) || (int) $install_time <= 0 ) {
125 return 0;
126 }
127
128 return (int) floor( ( time() - (int) $install_time ) / DAY_IN_SECONDS );
129 }
130
131 // -------------------------------------------------------------------------
132 // BSF Analytics stats payload
133 // -------------------------------------------------------------------------
134
135 /**
136 * Build and append the complete CartFlows analytics payload.
137 *
138 * Single bsf_core_stats filter callback — replaces the former
139 * Cartflows_Admin::get_specific_stats() handler.
140 *
141 * @since 2.2.4
142 * @param array $stats_data Existing analytics payload.
143 * @return array
144 */
145 public function get_stats( $stats_data ) {
146 if ( ! self::is_tracking_opted_in() ) {
147 return $stats_data;
148 }
149
150 $theme_data = $this->get_active_theme();
151
152 $stats_data['plugin_data']['cartflows'] = $this->get_default_stats( $theme_data );
153 $stats_data['plugin_data']['cartflows']['kpi_records'] = $this->get_kpi_tracking_data();
154 $stats_data['plugin_data']['cartflows']['numeric_values'] = $this->get_numeric_data_stats();
155 $stats_data['plugin_data']['cartflows']['boolean_values'] = $this->get_boolean_data_stats( $theme_data );
156
157 // Detect state-based events lazily inside the filter so detection runs
158 // in the same request as transmission (BSF_Analytics_Events dedup makes
159 // repeated calls safe). Throttled by a 24h transient.
160 if ( false === get_transient( 'cf_state_events_checked' ) ) {
161 $this->detect_state_events();
162 }
163
164 // Append pending milestone events.
165 $events = self::events();
166 if ( null !== $events ) {
167 $stats_data['plugin_data']['cartflows']['events_record'] = $events->flush_pending();
168 }
169
170 // Allow Pro or other extensions to append additional data.
171 $stats_data = apply_filters( 'cartflows_get_specific_stats', $stats_data );
172
173 return $stats_data;
174 }
175
176 /**
177 * Retrieve default statistics for CartFlows.
178 *
179 * @since 2.2.4
180 * @param array $theme_data Array containing active theme information.
181 * @return array
182 */
183 private function get_default_stats( $theme_data ) {
184 $bsf_internal_referer = get_option( 'bsf_product_referers', array() );
185 $store_location = '';
186 $woo_version = '';
187 $store_currency = '';
188
189 if ( wcf()->is_woo_active ) {
190 $store_location = wc_get_base_location();
191 $woo_version = WC()->version;
192 $store_currency = get_woocommerce_currency();
193 }
194
195 // Sanitize knowledge base search terms before sending.
196 $raw_kb_searches = get_option( 'cartflows_kb_searches', array() );
197 $kb_searches = array_map( 'sanitize_text_field', is_array( $raw_kb_searches ) ? $raw_kb_searches : array() );
198
199 return array(
200 'website-domain' => str_ireplace( array( 'http://', 'https://' ), '', home_url() ),
201 'site_language' => get_locale(),
202 'cartflows-lite-version' => CARTFLOWS_VER,
203 'cartflows-pro-version' => _is_cartflows_pro() ? CARTFLOWS_PRO_VER : '',
204 'woocommerce-version' => $woo_version,
205 'default-page-builder' => Cartflows_Helper::get_common_setting( 'default_page_builder' ),
206 'active-theme' => $theme_data['parent_theme'],
207 'active-gateway-count' => wcf()->is_woo_active ? count( (array) $this->get_active_gateways() ) : 0,
208 'social-tracking' => $this->get_social_tracking_flags(),
209 'store-country' => ! empty( $store_location['country'] ) ? $store_location['country'] : '',
210 // Required to make the fbt_revenue KPI comparable across stores.
211 'store-currency' => $store_currency,
212 'documentation-search-terms' => $kb_searches,
213 'internal_referer' => ! empty( $bsf_internal_referer['cartflows'] ) ? $bsf_internal_referer['cartflows'] : '',
214 // Simplified: boolean flag only — no raw feedback object.
215 'nps_survey_submitted' => ! empty( get_option( 'nps-survey-cartflows', array() ) ),
216 'pro_license_key_exists' => $this->check_pro_license_key_exists() ? true : false,
217 // Resolved count — includes auto-completed modules the option never stores.
218 'learn_modules_completed' => Cartflows_Learn_Progress::get_instance()->get_completed_count(),
219 );
220 }
221
222 /**
223 * Get KPI tracking data for the last 2 days (excluding today).
224 *
225 * @since 2.2.4
226 * @return array KPI records keyed by date.
227 */
228 private function get_kpi_tracking_data() {
229 $kpi_data = array();
230
231 for ( $i = 1; $i <= 2; $i++ ) {
232 $date = wp_date( 'Y-m-d', strtotime( "-{$i} days" ) );
233 // Keys stay present when WooCommerce is inactive so the columns never disappear mid-series.
234 $numeric = array(
235 'order_count' => $this->get_daily_orders_count( $date ),
236 'offer_orders' => $this->get_daily_offer_orders_count( $date ),
237 'fbt_orders' => 0,
238 'fbt_revenue' => '0.00',
239 );
240
241 // FBT only loads when WooCommerce is active, so the class may be absent.
242 if ( class_exists( 'Cartflows_Fbt_Analytics' ) ) {
243 $fbt = Cartflows_Fbt_Analytics::get_daily_stats( (string) $date );
244 $numeric['fbt_orders'] = $fbt['fbt_orders'];
245 $numeric['fbt_revenue'] = $fbt['fbt_revenue'];
246 }
247
248 $kpi_data[ $date ] = array( 'numeric_values' => $numeric );
249 }
250
251 return $kpi_data;
252 }
253
254 /**
255 * Get daily CartFlows order count for a specific date.
256 *
257 * Counts completed/processing orders associated with a CartFlows flow.
258 * Supports both classic post-table and HPOS order storage.
259 *
260 * @since 2.2.4
261 * @param string $date Date in Y-m-d format.
262 * @return int
263 */
264 private function get_daily_orders_count( $date ) {
265 global $wpdb;
266
267 $start_date = $date . ' 00:00:00';
268 $end_date = $date . ' 23:59:59';
269
270 if ( wcf()->utils->is_hpos_enabled() ) {
271 $order_date_key = 'date_created_gmt';
272 $order_status_key = 'status';
273 $order_id_key = 'order_id';
274 $order_table = $wpdb->prefix . 'wc_orders';
275 $order_meta_table = $wpdb->prefix . 'wc_orders_meta';
276 $order_type_key = 'type';
277 $order_table_id = 'id';
278 } else {
279 $order_date_key = 'post_date';
280 $order_status_key = 'post_status';
281 $order_id_key = 'post_id';
282 $order_table = $wpdb->prefix . 'posts';
283 $order_meta_table = $wpdb->prefix . 'postmeta';
284 $order_type_key = 'post_type';
285 $order_table_id = 'ID';
286 }
287
288 //phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
289 $count = $wpdb->get_var(
290 $wpdb->prepare(
291 "SELECT COUNT(*)
292 FROM $order_table o
293 INNER JOIN $order_meta_table om ON o.$order_table_id = om.$order_id_key AND om.meta_key IN ('_wcf_flow_id', '_cartflows_parent_flow_id')
294 WHERE o.$order_type_key = 'shop_order'
295 AND o.$order_status_key IN ('wc-completed', 'wc-processing')
296 AND o.$order_date_key >= %s
297 AND o.$order_date_key <= %s",
298 $start_date,
299 $end_date
300 )
301 );
302 //phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
303
304 return absint( $count );
305 }
306
307 /**
308 * Get daily offer (upsell/downsell) order count for a specific date.
309 *
310 * Counts orders with _cartflows_offer meta set to 'yes'.
311 * Supports both classic post-table and HPOS order storage.
312 *
313 * @since 2.2.4
314 * @param string $date Date in Y-m-d format.
315 * @return int
316 */
317 private function get_daily_offer_orders_count( $date ) {
318 global $wpdb;
319
320 $start_date = $date . ' 00:00:00';
321 $end_date = $date . ' 23:59:59';
322
323 if ( wcf()->utils->is_hpos_enabled() ) {
324 $order_date_key = 'date_created_gmt';
325 $order_status_key = 'status';
326 $order_id_key = 'order_id';
327 $order_table = $wpdb->prefix . 'wc_orders';
328 $order_meta_table = $wpdb->prefix . 'wc_orders_meta';
329 $order_type_key = 'type';
330 $order_table_id = 'id';
331 } else {
332 $order_date_key = 'post_date';
333 $order_status_key = 'post_status';
334 $order_id_key = 'post_id';
335 $order_table = $wpdb->prefix . 'posts';
336 $order_meta_table = $wpdb->prefix . 'postmeta';
337 $order_type_key = 'post_type';
338 $order_table_id = 'ID';
339 }
340
341 //phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
342 $count = $wpdb->get_var(
343 $wpdb->prepare(
344 "SELECT COUNT(*)
345 FROM $order_table o
346 INNER JOIN $order_meta_table om ON o.$order_table_id = om.$order_id_key AND om.meta_key = '_cartflows_offer' AND om.meta_value = 'yes'
347 WHERE o.$order_type_key = 'shop_order'
348 AND o.$order_status_key IN ('wc-completed', 'wc-processing')
349 AND o.$order_date_key >= %s
350 AND o.$order_date_key <= %s",
351 $start_date,
352 $end_date
353 )
354 );
355 //phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
356
357 return absint( $count );
358 }
359
360 /**
361 * Retrieve numeric data statistics for CartFlows.
362 *
363 * @since 2.2.4
364 * @return array
365 */
366 private function get_numeric_data_stats() {
367 $steps_counts = $this->get_all_steps_count();
368 $flows_with_instant_checkout = $this->get_funnels_with_instant_layout();
369 $custom_fields_data = $this->get_custom_fields_enabled_data();
370
371 $funnel_building_behavior = get_option(
372 'cartflows_funnel_creation_method',
373 array(
374 'scratch' => 0,
375 'ready_made_template' => 0,
376 )
377 );
378
379 $numeric_stats = array(
380 'total_flows' => wp_count_posts( CARTFLOWS_FLOW_POST_TYPE )->publish,
381 'total_ic_funnels' => strval( $flows_with_instant_checkout ),
382 'optin_step_count' => strval( $steps_counts['optin'] ),
383 'landing_step_count' => strval( $steps_counts['landing'] ),
384 'checkout_step_count' => strval( $steps_counts['checkout'] ),
385 'upsell_step_count' => strval( $steps_counts['upsell'] ),
386 'downsell_step_count' => strval( $steps_counts['downsell'] ),
387 'thankyou_step_count' => strval( $steps_counts['thankyou'] ),
388 'optin_custom_fields' => strval( $custom_fields_data['optin'] ),
389 'checkout_custom_fields' => strval( $custom_fields_data['checkout'] ),
390 'funnels_from_scratch' => strval( $funnel_building_behavior['scratch'] ),
391 'funnels_from_template' => strval( $funnel_building_behavior['ready_made_template'] ),
392 // Keys stay present when WooCommerce is inactive so the columns never disappear mid-series.
393 'fbt_products_configured' => '0',
394 'fbt_ai_products' => '0',
395 );
396
397 // FBT only loads when WooCommerce is active, so the class may be absent.
398 if ( class_exists( 'Cartflows_Fbt_Analytics' ) ) {
399 $numeric_stats = array_merge( $numeric_stats, Cartflows_Fbt_Analytics::get_numeric_stats() );
400 }
401
402 return $numeric_stats;
403 }
404
405 /**
406 * Retrieve boolean data statistics for CartFlows.
407 *
408 * @since 2.2.4
409 * @param array $theme_data Array containing active theme information.
410 * @return array
411 */
412 private function get_boolean_data_stats( $theme_data ) {
413 $common_settings = Cartflows_Helper::get_common_settings();
414
415 return array(
416 'override-global-checkout' => ! empty( $common_settings['override_global_checkout'] ) && 'enable' === $common_settings['override_global_checkout'] ? true : false,
417 'disallow-indexing' => ! empty( $common_settings['disallow_indexing'] ) && 'enable' === $common_settings['disallow_indexing'] ? true : false,
418 'paypal-reference-transactions' => ! empty( $common_settings['paypal_reference_transactions'] ) && 'enable' === $common_settings['paypal_reference_transactions'],
419 'cartflows-stats-report-emails' => 'enable' === get_option( 'cartflows_stats_report_emails', 'enable' ),
420 'cartflows-delete-plugin-data' => 'enable' === get_option( 'cartflows_delete_plugin_data' ),
421 'pre-checkout-offer' => isset( $common_settings['pre_checkout_offer'] ) && 'enable' === $common_settings['pre_checkout_offer'],
422 'store-checkout-set' => ! empty( intval( Cartflows_Helper::get_global_setting( '_cartflows_store_checkout' ) ) ),
423 'is-child-theme' => $theme_data['child_theme'],
424 'suretriggers_active' => is_plugin_active( 'suretriggers/suretriggers.php' ),
425 // Account-level AI connection — powers FBT Auto Suggest and content generation.
426 'ai_auth_connected' => class_exists( 'Cartflows_Ai_Auth' ) && (bool) Cartflows_Ai_Auth::get_instance()->get_auth_status(),
427 );
428 }
429
430 // -------------------------------------------------------------------------
431 // Helper methods (stats)
432 // -------------------------------------------------------------------------
433
434 /**
435 * Retrieve the active theme name.
436 *
437 * @since 2.2.4
438 * @return array {parent_theme: string, child_theme: bool}
439 */
440 private function get_active_theme() {
441 $theme = wp_get_theme();
442 $parent_theme_name = '';
443 $child_theme_name = '';
444
445 if ( isset( $theme->parent_theme ) && ! empty( $theme->parent_theme ) ) {
446 $parent_theme_name = $theme->parent_theme;
447 $child_theme_name = $theme->name ? $theme->name : '';
448 } else {
449 $parent_theme_name = $theme->name;
450 }
451
452 return array(
453 'parent_theme' => ! empty( $child_theme_name ) ? $child_theme_name : $parent_theme_name,
454 'child_theme' => ! empty( $child_theme_name ) ? true : false,
455 );
456 }
457
458 /**
459 * Get the count of each step type.
460 *
461 * @since 2.2.4
462 * @return array Counts keyed by step type slug.
463 */
464 private function get_all_steps_count() {
465 $default_step_counts = array(
466 'optin' => 0,
467 'landing' => 0,
468 'checkout' => 0,
469 'upsell' => 0,
470 'downsell' => 0,
471 'thankyou' => 0,
472 );
473
474 global $wpdb;
475
476 //phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
477 $step_counts = $wpdb->get_results(
478 $wpdb->prepare(
479 "SELECT JSON_OBJECTAGG(meta_value, count) as counts
480 FROM (
481 SELECT pm.meta_value, COUNT(*) as count
482 FROM $wpdb->posts p
483 INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id
484 WHERE p.post_type = %s
485 AND p.post_status = %s
486 AND pm.meta_key = %s
487 AND pm.meta_value IN ('optin', 'landing', 'checkout', 'upsell', 'downsell', 'thankyou')
488 GROUP BY pm.meta_value
489 ) as subquery",
490 CARTFLOWS_STEP_POST_TYPE,
491 'publish',
492 'wcf-step-type'
493 ),
494 ARRAY_A
495 );
496 //phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
497
498 if ( empty( $step_counts ) || empty( $step_counts[0]['counts'] ) ) {
499 return $default_step_counts;
500 }
501
502 return wp_parse_args( json_decode( $step_counts[0]['counts'], true ), $default_step_counts );
503 }
504
505 /**
506 * Retrieve the count of funnels with instant layout enabled.
507 *
508 * @since 2.2.4
509 * @return int
510 */
511 private function get_funnels_with_instant_layout() {
512 $args = array(
513 'post_type' => CARTFLOWS_FLOW_POST_TYPE,
514 'post_status' => 'publish',
515 'posts_per_page' => 1,
516 'no_found_rows' => false,
517 'fields' => 'ids',
518 'meta_query' => array( //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query required to count instant layout funnels.
519 array(
520 'key' => 'instant-layout-style',
521 'value' => 'yes',
522 'compare' => 'LIKE',
523 ),
524 ),
525 );
526
527 $query = new WP_Query( $args );
528 $posts_count = isset( $query->found_posts ) ? $query->found_posts : 0;
529
530 wp_reset_postdata();
531
532 return $posts_count;
533 }
534
535 /**
536 * Retrieve the count of steps with custom fields enabled, by type.
537 *
538 * @since 2.2.4
539 * @return array {optin: int, checkout: int}
540 */
541 private function get_custom_fields_enabled_data() {
542 $default_step_counts = array(
543 'optin' => 0,
544 'checkout' => 0,
545 );
546
547 global $wpdb;
548
549 //phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
550 $step_counts = $wpdb->get_results(
551 $wpdb->prepare(
552 "SELECT JSON_OBJECTAGG(subquery.step_type, subquery.count) AS counts
553 FROM ( SELECT step.meta_value AS step_type,
554 COUNT(DISTINCT p.ID) AS count
555 FROM $wpdb->posts p
556 INNER JOIN $wpdb->postmeta step
557 ON p.ID = step.post_id
558 AND step.meta_key = %s
559 AND step.meta_value IN ('optin', 'checkout')
560 INNER JOIN $wpdb->postmeta custom
561 ON p.ID = custom.post_id
562 AND (
563 (step.meta_value = 'optin' AND custom.meta_key = %s)
564 OR
565 (step.meta_value = 'checkout' AND custom.meta_key = %s)
566 )
567 AND custom.meta_value = %s
568 WHERE p.post_type = %s
569 AND p.post_status = %s
570 GROUP BY step.meta_value
571 ) AS subquery",
572 'wcf-step-type',
573 'wcf-optin-enable-custom-fields',
574 'wcf-custom-checkout-fields',
575 'yes',
576 CARTFLOWS_STEP_POST_TYPE,
577 'publish'
578 ),
579 ARRAY_A
580 );
581 //phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
582
583 if ( empty( $step_counts ) || empty( $step_counts[0]['counts'] ) ) {
584 return $default_step_counts;
585 }
586
587 return wp_parse_args( json_decode( $step_counts[0]['counts'], true ), $default_step_counts );
588 }
589
590 /**
591 * Get the list of enabled payment gateway slugs.
592 *
593 * @since 2.2.4
594 * @return array
595 */
596 private function get_active_gateways() {
597 if ( ! wcf()->is_woo_active ) {
598 return array();
599 }
600
601 $gateways = WC()->payment_gateways->get_available_payment_gateways();
602 $enabled_gateways = array();
603
604 if ( is_array( $gateways ) ) {
605 foreach ( $gateways as $gateway ) {
606 if ( 'yes' === $gateway->enabled ) {
607 $gateway_key = strtolower( str_replace( array( ' ', '(', ')' ), '_', $gateway->get_title() ) );
608 $enabled_gateways[ $gateway_key ] = $gateway->get_title();
609 }
610 }
611 }
612
613 return $enabled_gateways;
614 }
615
616 /**
617 * Get social tracking enabled/disabled boolean flags for each platform.
618 *
619 * @since 2.2.4
620 * @return array
621 */
622 private function get_social_tracking_flags() {
623 $fb_setting = Cartflows_Helper::get_facebook_settings();
624 $ga_setting = Cartflows_Helper::get_google_analytics_settings();
625 $tik_pixel_settings = Cartflows_Helper::get_tiktok_settings();
626 $pinterest_settings = Cartflows_Helper::get_pinterest_settings();
627 $gads_settings = Cartflows_Helper::get_google_ads_settings();
628 $snapchat_settings = Cartflows_Helper::get_snapchat_settings();
629
630 return array(
631 'fb_pixel_enabled' => ! empty( $fb_setting['facebook_pixel_id'] ),
632 'ga_enabled' => ! empty( $ga_setting['google_analytics_id'] ),
633 'tiktok_enabled' => ! empty( $tik_pixel_settings['tiktok_pixel_id'] ),
634 'pinterest_enabled' => ! empty( $pinterest_settings['pinterest_tag_id'] ),
635 'gads_enabled' => ! empty( $gads_settings['google_ads_id'] ),
636 'snapchat_enabled' => ! empty( $snapchat_settings['snapchat_pixel_id'] ),
637 );
638 }
639
640 /**
641 * Check if CartFlows Pro license key exists.
642 *
643 * @since 2.2.4
644 * @return bool
645 */
646 private function check_pro_license_key_exists() {
647 if ( _is_cartflows_pro() && class_exists( 'CartFlows_Pro_Licence' ) ) {
648 $license_data = get_option( 'wc_am_client_' . \CartFlows_Pro_Licence::get_instance()->product_id . '_api_key', array() );
649 return ! empty( $license_data['api_key'] );
650 }
651 return false;
652 }
653
654 // -------------------------------------------------------------------------
655 // Milestone event tracking
656 // -------------------------------------------------------------------------
657
658 /**
659 * Detect and queue state-based one-time events.
660 *
661 * Called once per day (throttled by transient). BSF_Analytics_Events dedup
662 * prevents duplicate tracking across days.
663 *
664 * @since 2.2.4
665 * @return void
666 */
667 private function detect_state_events() {
668 $events = self::events();
669 if ( null === $events ) {
670 // BSF_Analytics_Events not yet loaded — do NOT set transient so we retry
671 // on the next admin page load.
672 return;
673 }
674
675 // Class is available — throttle for 24 hours.
676 set_transient( 'cf_state_events_checked', 1, DAY_IN_SECONDS );
677
678 // plugin_activated — fires once on first admin load after activation.
679 $bsf_referrers = get_option( 'bsf_product_referers', array() );
680 $source = ! empty( $bsf_referrers['cartflows'] ) ? sanitize_text_field( $bsf_referrers['cartflows'] ) : 'self';
681 $events->track( 'plugin_activated', CARTFLOWS_VER, array( 'source' => $source ) );
682
683 // plugin_updated — fires when version changes; re-tracks on each upgrade.
684 $stored_version = Cartflows_Helper::get_analytics_flag( 'tracked_version', '' );
685 if ( CARTFLOWS_VER !== $stored_version ) {
686 if ( ! empty( $stored_version ) ) {
687 $events->flush_pushed( array( 'plugin_updated' ) );
688 $events->track(
689 'plugin_updated',
690 CARTFLOWS_VER,
691 array( 'from_version' => $stored_version )
692 );
693 }
694 Cartflows_Helper::set_analytics_flag( 'tracked_version', CARTFLOWS_VER, true );
695 }
696
697 // onboarding_completed.
698 if ( get_option( 'wcf_setup_complete', false ) ) {
699 $events->track( 'onboarding_completed' );
700 }
701
702 // onboarding_skipped.
703 if ( get_option( 'wcf_setup_skipped', false ) ) {
704 $exit_step = get_option( 'wcf_exit_setup_step', '' );
705 $events->track(
706 'onboarding_skipped',
707 '',
708 array( 'exit_step' => sanitize_text_field( (string) $exit_step ) )
709 );
710 }
711
712 // pro_license_activated.
713 if ( _is_cartflows_pro() ) {
714 $events->track( 'pro_license_activated' );
715 }
716
717 // ---- Feature usage events ----
718
719 // first_checkout_configured (flag set in admin-core/inc/meta-ops.php).
720 $checkout_step_id = Cartflows_Helper::get_analytics_flag( 'first_checkout_configured' );
721 if ( $checkout_step_id ) {
722 $layout = get_post_meta( intval( $checkout_step_id ), 'wcf-checkout-layout', true );
723 $events->track(
724 'first_checkout_configured',
725 strval( $checkout_step_id ),
726 array( 'layout' => $layout ? sanitize_text_field( $layout ) : 'default' )
727 );
728 }
729
730 // first_template_imported (flag set in admin-core/ajax/importer.php).
731 if ( Cartflows_Helper::get_analytics_flag( 'first_template_imported' ) ) {
732 $page_builder = Cartflows_Helper::get_common_setting( 'default_page_builder' );
733 $events->track(
734 'first_template_imported',
735 '',
736 array( 'page_builder' => $page_builder ? sanitize_text_field( $page_builder ) : 'default' )
737 );
738 }
739
740 // ---- Pro feature events (flags set in cartflows-pro) ----
741
742 if ( Cartflows_Helper::get_analytics_flag( 'first_order_bump_created' ) ) {
743 $events->track( 'first_order_bump_created' );
744 }
745
746 if ( Cartflows_Helper::get_analytics_flag( 'first_upsell_accepted' ) ) {
747 $events->track( 'first_upsell_accepted' );
748 }
749
750 if ( Cartflows_Helper::get_analytics_flag( 'first_ab_test_started' ) ) {
751 $events->track( 'first_ab_test_started' );
752 }
753
754 if ( Cartflows_Helper::get_analytics_flag( 'first_ab_test_winner' ) ) {
755 $events->track( 'first_ab_test_winner_declared' );
756 }
757
758 // ---- Integration events (state-based, no flag-setters needed) ----
759
760 $tracking_flags = $this->get_social_tracking_flags();
761
762 if ( ! empty( $tracking_flags['fb_pixel_enabled'] ) ) {
763 $events->track( 'fb_pixel_connected' );
764 }
765
766 if ( ! empty( $tracking_flags['ga_enabled'] ) ) {
767 $events->track( 'ga_connected' );
768 }
769
770 if ( wcf()->is_woo_active ) {
771 $gateways = WC()->payment_gateways->get_available_payment_gateways();
772 foreach ( $gateways as $gateway_id => $gateway ) {
773 if ( 'yes' !== $gateway->enabled ) {
774 continue;
775 }
776 if ( false !== strpos( $gateway_id, 'stripe' ) ) {
777 $events->track( 'stripe_gateway_enabled' );
778 }
779 if ( false !== strpos( $gateway_id, 'paypal' ) ) {
780 $events->track( 'paypal_gateway_enabled' );
781 }
782 }
783 }
784
785 if ( ! empty( $tracking_flags['tiktok_enabled'] ) ) {
786 $events->track( 'tiktok_connected' );
787 }
788
789 if ( ! empty( $tracking_flags['pinterest_enabled'] ) ) {
790 $events->track( 'pinterest_connected' );
791 }
792
793 if ( ! empty( $tracking_flags['gads_enabled'] ) ) {
794 $events->track( 'gads_connected' );
795 }
796
797 if ( ! empty( $tracking_flags['snapchat_enabled'] ) ) {
798 $events->track( 'snapchat_connected' );
799 }
800
801 // ---- Pro feature state events ----
802
803 // first_downsell_accepted (flag set in cartflows-pro modules/downsell/classes/class-cartflows-downsell-markup.php).
804 if ( Cartflows_Helper::get_analytics_flag( 'first_downsell_accepted' ) ) {
805 $events->track( 'first_downsell_accepted' );
806 }
807
808 // first_instant_layout_enabled (flag set in admin-core/ajax/flows.php and admin-core/ajax/importer.php).
809 if ( Cartflows_Helper::get_analytics_flag( 'first_instant_layout_enabled' ) ) {
810 $events->track( 'first_instant_layout_enabled' );
811 }
812
813 // first_store_checkout_set — detected directly from the store checkout option.
814 $store_checkout_setting = Cartflows_Helper::get_global_setting( '_cartflows_store_checkout' );
815 if ( is_scalar( $store_checkout_setting ) && intval( $store_checkout_setting ) > 0 ) {
816 $events->track( 'first_store_checkout_set' );
817 }
818
819 // first_webhook_configured (flag set in admin-core/api/webhooks.php::create_webhook()).
820 if ( Cartflows_Helper::get_analytics_flag( 'first_webhook_configured' ) ) {
821 $events->track( 'first_webhook_configured' );
822 }
823
824 // first_fbt_configured (flag set in modules/frequently-bought-together/classes/class-cartflows-fbt-product-meta.php).
825 $fbt_configured = Cartflows_Helper::get_analytics_flag( 'first_fbt_configured' );
826 if ( is_array( $fbt_configured ) ) {
827 $events->track(
828 'first_fbt_configured',
829 '',
830 array(
831 'source' => sanitize_text_field( (string) ( $fbt_configured['source'] ?? 'manual' ) ),
832 'days_since_install' => (string) absint( $fbt_configured['days_since_install'] ?? 0 ),
833 )
834 );
835 }
836
837 // first_fbt_widget_added_to_cart (flag set in modules/frequently-bought-together/classes/class-cartflows-fbt-frontend.php).
838 $fbt_atc = Cartflows_Helper::get_analytics_flag( 'first_fbt_widget_added_to_cart' );
839 if ( is_array( $fbt_atc ) ) {
840 $events->track(
841 'first_fbt_widget_added_to_cart',
842 '',
843 array( 'item_count' => (string) absint( $fbt_atc['item_count'] ?? 0 ) )
844 );
845 }
846
847 // ---- Engagement events ----
848
849 $pointer_data = get_option( 'cartflows_pointer_data', array() );
850
851 if ( ! empty( $pointer_data['accepted'] ) ) {
852 $events->track( 'pointer_accepted' );
853 }
854
855 if ( ! empty( $pointer_data['dismissed'] ) ) {
856 $events->track( 'pointer_dismissed' );
857 }
858
859 // weekly_report_notice_dismissed (flag set in class-cartflows-admin-notices.php).
860 if ( Cartflows_Helper::get_analytics_flag( 'weekly_report_notice_dismissed' ) ) {
861 $events->track( 'weekly_report_notice_dismissed' );
862 }
863
864 // instant_checkout_notice_dismissed (option set in admin-core/ajax/flows.php).
865 if ( 'yes' === get_option( 'wcf-instant-checkout-notice-skipped' ) ) {
866 $events->track( 'instant_checkout_notice_dismissed' );
867 }
868 }
869
870 /**
871 * Track first flow published.
872 *
873 * Fires on transition_post_status when a cartflows_flow moves to 'publish'.
874 * BSF_Analytics_Events dedup ensures this fires only once per site lifecycle.
875 *
876 * @since 2.2.4
877 * @param string $new_status New post status.
878 * @param string $old_status Old post status.
879 * @param \WP_Post $post Post object.
880 * @return void
881 */
882 public function track_first_flow_published( $new_status, $old_status, $post ) {
883 if ( CARTFLOWS_FLOW_POST_TYPE !== $post->post_type ) {
884 return;
885 }
886
887 if ( 'publish' !== $new_status || 'publish' === $old_status ) {
888 return;
889 }
890
891 $events = self::events();
892 if ( null === $events ) {
893 return;
894 }
895
896 // days_since_install for time-to-value analysis.
897 $install_time = (int) Cartflows_Helper::get_analytics_flag( 'usage_installed_time', 0 );
898 $days = 0;
899 if ( $install_time > 0 ) {
900 $days = (int) floor( ( time() - $install_time ) / DAY_IN_SECONDS );
901 }
902
903 // Flow source: template or scratch.
904 // Read per-flow post meta written at creation time so each flow reports its own source.
905 $creation_method = get_post_meta( $post->ID, 'wcf-flow-creation-method', true );
906 $source = ( 'ready_made_template' === $creation_method ) ? 'template' : 'scratch';
907
908 // Step count for this flow.
909 $steps = get_post_meta( $post->ID, 'wcf-steps', true );
910 $step_count = is_array( $steps ) ? count( $steps ) : 0;
911
912 $events->track(
913 'first_flow_published',
914 strval( $post->ID ),
915 array(
916 'days_since_install' => (string) $days,
917 'source' => $source,
918 'step_count' => (string) $step_count,
919 )
920 );
921 }
922
923 /**
924 * Track first checkout configured.
925 *
926 * Fires whenever wcf-checkout-products is written on a checkout step, so
927 * template imports and Store Checkout are captured alongside the admin save.
928 *
929 * @since 2.2.4
930 * @param int $meta_id Meta row ID.
931 * @param int $post_id Post ID the meta belongs to.
932 * @param string $meta_key Meta key being written.
933 * @param mixed $meta_value Meta value being written.
934 * @return void
935 */
936 public function track_first_checkout_configured( $meta_id, $post_id, $meta_key, $meta_value ) {
937 if ( 'wcf-checkout-products' !== $meta_key ) {
938 return;
939 }
940
941 if ( empty( $meta_value ) ) {
942 return;
943 }
944
945 if ( CARTFLOWS_STEP_POST_TYPE !== get_post_type( $post_id ) ) {
946 return;
947 }
948
949 // Value is the step ID, not a boolean — get_stats reads it back to resolve the layout.
950 Cartflows_Helper::set_analytics_flag( 'first_checkout_configured', $post_id );
951 }
952
953 // -------------------------------------------------------------------------
954 // Learn section events
955 // -------------------------------------------------------------------------
956
957 /**
958 * Learn event names the browser is allowed to record.
959 *
960 * @since x.x.x
961 * @return array<int, string>
962 */
963 public static function get_trackable_learn_events() {
964 return array( 'learn_page_viewed', 'learn_module_action', 'learn_action_failed', 'learn_video_opened' );
965 }
966
967 /**
968 * Learn CTA action types accepted from the browser.
969 *
970 * @since x.x.x
971 * @return array<int, string>
972 */
973 public static function get_learn_action_types() {
974 return array( 'install_plugin', 'redirect', 'learn_how', 'upgrade', 'activate_pro' );
975 }
976
977 /**
978 * Learn failure reasons accepted from the browser.
979 *
980 * @since x.x.x
981 * @return array<int, string>
982 */
983 public static function get_learn_failure_reasons() {
984 return array( 'install_failed', 'activate_failed', 'progress_save_failed' );
985 }
986
987 /**
988 * Plugin slugs the Learn checklist can install.
989 *
990 * @since x.x.x
991 * @return array<int, string>
992 */
993 public static function get_learn_plugin_slugs() {
994 return array( 'woocommerce', 'woo-cart-abandonment-recovery', 'modern-cart' );
995 }
996
997 /**
998 * Track the first Learn page view as a milestone and refresh the per-module progress snapshot.
999 *
1000 * Fired from the AJAX recorder, not the REST route, so a GET never writes options.
1001 *
1002 * @since x.x.x
1003 * @return void
1004 */
1005 public function track_learn_page_view() {
1006
1007 $events = self::events();
1008 if ( null === $events || ! self::is_tracking_opted_in() ) {
1009 return;
1010 }
1011
1012 $progress = Cartflows_Learn_Progress::get_instance();
1013 $states = $progress->get_module_states();
1014
1015 // Unforced on purpose: a one-time milestone keyed on time-to-value, like first_flow_published.
1016 $events->track(
1017 'learn_page_viewed',
1018 (string) $this->get_days_since_install(),
1019 array(
1020 'total' => (string) $progress->get_total_count(),
1021 'completed' => (string) $progress->get_completed_count(),
1022 )
1023 );
1024
1025 // Forced so the server always holds the latest snapshot — one row per site.
1026 $events->track( 'learn_progress', $progress->get_progress_value(), $states, true );
1027 }
1028
1029 /**
1030 * Track a Learn module CTA click.
1031 *
1032 * @since x.x.x
1033 * @param string $module_id Whitelisted module identifier.
1034 * @param string $action_type Whitelisted action type.
1035 * @param bool $is_pro Whether the module is a Pro feature.
1036 * @return void
1037 */
1038 public function track_learn_module_action( $module_id, $action_type, $is_pro ) {
1039
1040 $events = self::events();
1041 if ( null === $events || ! self::is_tracking_opted_in() ) {
1042 return;
1043 }
1044
1045 // Forced events overwrite by name, so the module is folded in or clicks on two modules collapse to one.
1046 $events->track(
1047 'learn_module_action_' . $module_id,
1048 $module_id,
1049 array(
1050 'action_type' => $action_type,
1051 'is_pro' => $is_pro ? 'yes' : 'no',
1052 ),
1053 true
1054 );
1055 }
1056
1057 /**
1058 * Track a failed Learn module action.
1059 *
1060 * @since x.x.x
1061 * @param string $module_id Whitelisted module identifier.
1062 * @param string $reason Whitelisted failure reason.
1063 * @param string $plugin_slug Whitelisted plugin slug, empty when not plugin related.
1064 * @return void
1065 */
1066 public function track_learn_action_failed( $module_id, $reason, $plugin_slug = '' ) {
1067
1068 $events = self::events();
1069 if ( null === $events || ! self::is_tracking_opted_in() ) {
1070 return;
1071 }
1072
1073 $events->track(
1074 'learn_action_failed_' . $module_id,
1075 $module_id,
1076 array(
1077 'reason' => $reason,
1078 'plugin_slug' => $plugin_slug,
1079 ),
1080 true
1081 );
1082 }
1083
1084 /**
1085 * Track the Learn getting-started video being opened.
1086 *
1087 * @since x.x.x
1088 * @return void
1089 */
1090 public function track_learn_video_opened() {
1091
1092 $events = self::events();
1093 if ( null === $events || ! self::is_tracking_opted_in() ) {
1094 return;
1095 }
1096
1097 $events->track( 'learn_video_opened', CARTFLOWS_VER );
1098 }
1099 }
1100
1101 Cartflows_Analytics::get_instance();
1102