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-learn-progress.php

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

504 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * CartFlows Learn progress — resolved completion state for the Learn checklist.
4 *
5 * Single source of truth for module completion (manually checked ∪ auto-derived),
6 * shared by the Learn REST route and the BSF Analytics payload.
7 *
8 * @package CartFlows
9 * @since x.x.x
10 */
11
12 // Exit if accessed directly.
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Class Cartflows_Learn_Progress.
19 *
20 * @since x.x.x
21 */
22 class Cartflows_Learn_Progress {
23
24 /**
25 * Completed module state.
26 */
27 const STATE_COMPLETED = 'completed';
28
29 /**
30 * Pending module state.
31 */
32 const STATE_PENDING = 'pending';
33
34 /**
35 * Instance
36 *
37 * @access private
38 * @var Cartflows_Learn_Progress|null Class object.
39 * @since x.x.x
40 */
41 private static $instance;
42
43 /**
44 * Resolved module states, memoized per request.
45 *
46 * @var array<string, string>|null
47 * @since x.x.x
48 */
49 private $states = null;
50
51 /**
52 * Initiator
53 *
54 * @since x.x.x
55 * @return Cartflows_Learn_Progress initialized object of class.
56 */
57 public static function get_instance() {
58 if ( ! isset( self::$instance ) ) {
59 self::$instance = new self();
60 }
61 return self::$instance;
62 }
63
64 /**
65 * Module ID => the detector that resolves its auto-completion, in checklist order.
66 *
67 * Single declaration of the module set — get_module_ids() and the state map both read it.
68 *
69 * @since x.x.x
70 * @return array<string, string>
71 */
72 private static function get_detector_map() {
73 return array(
74 'install-woocommerce' => 'is_woocommerce_active',
75 'create-your-first-funnel' => 'has_published_flow',
76 'edit-design-funnel-pages-steps' => 'has_step_been_edited',
77 'add-products' => 'has_published_product',
78 'assign-products-to-checkout' => 'is_checkout_product_assigned',
79 'connect-payment-gateway' => 'is_payment_gateway_available',
80 'enable-cart-abandonment-tracking' => 'is_cart_abandonment_active',
81 'setup-recovery-emails' => 'has_recovery_emails',
82 'enable-modern-cart' => 'is_modern_cart_active',
83 'setup-your-cart' => 'is_modern_cart_configured',
84 'add-order-bump' => 'has_published_order_bump',
85 'setup-upsell-downsell-offers' => 'has_published_offer_step',
86 );
87 }
88
89 /**
90 * Learn module identifiers, in checklist order.
91 *
92 * Static and query-free — the AJAX whitelist calls it on every recorded event.
93 *
94 * @since x.x.x
95 * @return array<int, string>
96 */
97 public static function get_module_ids() {
98 return array_keys( self::get_detector_map() );
99 }
100
101 /**
102 * Resolve every module to `completed` or `pending`.
103 *
104 * A module is completed when its detector says so or it is in the stored manual list.
105 *
106 * @since x.x.x
107 * @return array<string, string> Module ID => state.
108 */
109 public function get_module_states() {
110
111 if ( null !== $this->states ) {
112 return $this->states;
113 }
114
115 $checked = get_option( 'wcf_learn_data', array() );
116 $checked = is_array( $checked ) ? $checked : array();
117
118 $states = array();
119
120 foreach ( self::get_module_ids() as $module_id ) {
121 // Manual check first so an already-ticked module skips its detector queries.
122 $is_completed = in_array( $module_id, $checked, true ) || $this->is_auto_completed( $module_id );
123 $states[ $module_id ] = $is_completed ? self::STATE_COMPLETED : self::STATE_PENDING;
124 }
125
126 $this->states = $states;
127
128 return $states;
129 }
130
131 /**
132 * Whether the store itself satisfies a module right now, ignoring the stored list.
133 *
134 * @since x.x.x
135 * @param string $module_id Module identifier.
136 * @return bool
137 */
138 public function is_auto_completed( $module_id ) {
139 $detectors = self::get_detector_map();
140
141 if ( ! isset( $detectors[ $module_id ] ) ) {
142 return false;
143 }
144
145 return (bool) $this->{$detectors[ $module_id ]}();
146 }
147
148 /**
149 * Reduce a list of completed IDs to the ones that must be stored as manual ticks.
150 *
151 * The UI posts every completed module, so auto-completed IDs are dropped here or they
152 * stay completed after the store stops satisfying them (a deactivated plugin, say).
153 *
154 * @since x.x.x
155 * @param array<int, string> $module_ids Completed module IDs as posted by the UI.
156 * @return array<int, string>
157 */
158 public function filter_manual_ids( $module_ids ) {
159 $known = self::get_module_ids();
160
161 $manual = array_filter(
162 $module_ids,
163 function ( $module_id ) use ( $known ) {
164 return in_array( $module_id, $known, true ) && ! $this->is_auto_completed( $module_id );
165 }
166 );
167
168 return array_values( $manual );
169 }
170
171 /**
172 * Whether a single module is completed.
173 *
174 * @since x.x.x
175 * @param string $module_id Module identifier.
176 * @return bool
177 */
178 public function is_completed( $module_id ) {
179 $states = $this->get_module_states();
180 return isset( $states[ $module_id ] ) && self::STATE_COMPLETED === $states[ $module_id ];
181 }
182
183 /**
184 * Count of completed modules.
185 *
186 * @since x.x.x
187 * @return int
188 */
189 public function get_completed_count() {
190 return count( array_keys( $this->get_module_states(), self::STATE_COMPLETED, true ) );
191 }
192
193 /**
194 * Total number of Learn modules.
195 *
196 * @since x.x.x
197 * @return int
198 */
199 public function get_total_count() {
200 return count( self::get_module_ids() );
201 }
202
203 /**
204 * Roll the per-module states up to a single progress value.
205 *
206 * @since x.x.x
207 * @return string One of not_started, in_progress, completed.
208 */
209 public function get_progress_value() {
210
211 $completed = $this->get_completed_count();
212
213 if ( 0 === $completed ) {
214 return 'not_started';
215 }
216
217 return $completed >= $this->get_total_count() ? 'completed' : 'in_progress';
218 }
219
220 /**
221 * Discard memoized states so the next read recomputes.
222 *
223 * @since x.x.x
224 * @return void
225 */
226 public function reset() {
227 $this->states = null;
228 }
229
230 /**
231 * Whether a plugin is installed and active.
232 *
233 * @since x.x.x
234 * @param string $plugin_init Plugin init file, e.g. woocommerce/woocommerce.php.
235 * @return bool
236 */
237 private function is_plugin_active( $plugin_init ) {
238 return 'active' === Cartflows_Helper::get_plugin_status( $plugin_init, true );
239 }
240
241 /**
242 * Whether WooCommerce is active.
243 *
244 * @since x.x.x
245 * @return bool
246 */
247 protected function is_woocommerce_active() {
248 return $this->is_plugin_active( 'woocommerce/woocommerce.php' );
249 }
250
251 /**
252 * Whether Cart Abandonment Recovery is active.
253 *
254 * @since x.x.x
255 * @return bool
256 */
257 protected function is_cart_abandonment_active() {
258 return $this->is_plugin_active( 'woo-cart-abandonment-recovery/woo-cart-abandonment-recovery.php' );
259 }
260
261 /**
262 * Whether Modern Cart is active.
263 *
264 * @since x.x.x
265 * @return bool
266 */
267 protected function is_modern_cart_active() {
268 return $this->is_plugin_active( 'modern-cart/modern-cart.php' );
269 }
270
271 /**
272 * Whether at least one funnel is published.
273 *
274 * @since x.x.x
275 * @return bool
276 */
277 protected function has_published_flow() {
278 return intval( wp_count_posts( CARTFLOWS_FLOW_POST_TYPE )->publish ) > 0;
279 }
280
281 /**
282 * Whether at least one product is published.
283 *
284 * @since x.x.x
285 * @return bool
286 */
287 protected function has_published_product() {
288 // wp_count_posts() returns a bare stdClass when the type is unregistered, so guard the read.
289 if ( ! post_type_exists( 'product' ) ) {
290 return false;
291 }
292
293 return intval( wp_count_posts( 'product' )->publish ) > 0;
294 }
295
296 /**
297 * Whether any recent published checkout step has a product assigned.
298 *
299 * @since x.x.x
300 * @return bool
301 */
302 protected function is_checkout_product_assigned() {
303
304 // Only the 10 most recently modified checkout steps are inspected, for performance.
305 $steps = get_posts(
306 array(
307 'post_type' => CARTFLOWS_STEP_POST_TYPE,
308 'post_status' => array( 'publish' ),
309 'posts_per_page' => 10,
310 'orderby' => 'modified',
311 'order' => 'DESC',
312 'fields' => 'ids',
313 'meta_query' => array( //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
314 array(
315 'key' => 'wcf-step-type',
316 'value' => 'checkout',
317 ),
318 array(
319 'key' => 'wcf-checkout-products',
320 'compare' => 'EXISTS',
321 ),
322 ),
323 )
324 );
325
326 if ( empty( $steps ) ) {
327 return false;
328 }
329
330 foreach ( $steps as $step_id ) {
331 $products = get_post_meta( intval( $step_id ), 'wcf-checkout-products', true );
332
333 if ( ! is_array( $products ) || empty( $products ) || empty( $products[0]['product'] ) ) {
334 continue;
335 }
336
337 $product_id = $products[0]['product'];
338
339 if ( is_numeric( $product_id ) && (int) $product_id > 0 ) {
340 return true;
341 }
342 }
343
344 return false;
345 }
346
347 /**
348 * Whether at least one WooCommerce payment gateway is enabled and ready.
349 *
350 * @since x.x.x
351 * @return bool
352 */
353 protected function is_payment_gateway_available() {
354
355 if ( ! function_exists( 'WC' ) ) {
356 return false;
357 }
358
359 $payment_gateways = WC()->payment_gateways();
360
361 if ( ! is_object( $payment_gateways ) || ! method_exists( $payment_gateways, 'get_available_payment_gateways' ) ) {
362 return false;
363 }
364
365 $available_gateways = $payment_gateways->get_available_payment_gateways();
366
367 return ! empty( $available_gateways ) && is_array( $available_gateways );
368 }
369
370 /**
371 * Whether at least one activated Cart Abandonment follow-up email exists.
372 *
373 * @since x.x.x
374 * @return bool
375 */
376 protected function has_recovery_emails() {
377
378 if ( ! $this->is_cart_abandonment_active() ) {
379 return false;
380 }
381
382 global $wpdb;
383
384 $template_table = $wpdb->prefix . 'cartflows_ca_email_templates';
385
386 $table_exists = $wpdb->get_var( //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
387 $wpdb->prepare( 'SHOW TABLES LIKE %s', $template_table )
388 );
389
390 if ( ! $table_exists ) {
391 return false;
392 }
393
394 $active_count = (int) $wpdb->get_var( //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
395 $wpdb->prepare(
396 "SELECT COUNT(*) FROM {$wpdb->prefix}cartflows_ca_email_templates WHERE is_activated = %d", //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
397 1
398 )
399 );
400
401 return $active_count > 0;
402 }
403
404 /**
405 * Whether Modern Cart is active and its settings have been saved at least once.
406 *
407 * @since x.x.x
408 * @return bool
409 */
410 protected function is_modern_cart_configured() {
411
412 if ( ! $this->is_modern_cart_active() ) {
413 return false;
414 }
415
416 $setting_keys = array( 'moderncart_setting', 'moderncart_cart', 'moderncart_floating', 'moderncart_appearance' );
417
418 foreach ( $setting_keys as $key ) {
419 if ( false !== get_option( $key, false ) ) {
420 return true;
421 }
422 }
423
424 return false;
425 }
426
427 /**
428 * Whether any published checkout step has an order bump configured.
429 *
430 * @since x.x.x
431 * @return bool
432 */
433 protected function has_published_order_bump() {
434
435 $steps = get_posts(
436 array(
437 'post_type' => CARTFLOWS_STEP_POST_TYPE,
438 'post_status' => 'publish',
439 'posts_per_page' => 1,
440 'fields' => 'ids',
441 'meta_query' => array( //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
442 array(
443 'key' => 'wcf-step-type',
444 'value' => 'checkout',
445 ),
446 array(
447 'key' => 'wcf-order-bumps',
448 'compare' => 'EXISTS',
449 ),
450 ),
451 )
452 );
453
454 return ! empty( $steps );
455 }
456
457 /**
458 * Whether any published upsell or downsell step exists.
459 *
460 * @since x.x.x
461 * @return bool
462 */
463 protected function has_published_offer_step() {
464
465 $offer_steps = get_posts(
466 array(
467 'post_type' => CARTFLOWS_STEP_POST_TYPE,
468 'post_status' => 'publish',
469 'posts_per_page' => 1,
470 'fields' => 'ids',
471 'meta_query' => array( //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
472 array(
473 'key' => 'wcf-step-type',
474 'value' => array( 'upsell', 'downsell' ),
475 'compare' => 'IN',
476 ),
477 ),
478 )
479 );
480
481 return ! empty( $offer_steps );
482 }
483
484 /**
485 * Whether any published CartFlows step exists.
486 *
487 * @since x.x.x
488 * @return bool
489 */
490 protected function has_step_been_edited() {
491
492 $steps = get_posts(
493 array(
494 'post_type' => CARTFLOWS_STEP_POST_TYPE,
495 'post_status' => 'publish',
496 'posts_per_page' => 1,
497 'fields' => 'ids',
498 )
499 );
500
501 return ! empty( $steps );
502 }
503 }
504