PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.2
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.2
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / includes / core / rest-api / Controllers / class-storecheckout-controller.php

class-storecheckout-controller.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.2, at includes/core/rest-api/Controllers/class-storecheckout-controller.php

1,201 lines 33.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Store Checkout Controller
4 *
5 * @package WPFunnels\Rest\Controllers
6 * @since 3.5.0
7 */
8
9 namespace WPFunnels\Rest\Controllers;
10
11 use WP_Error;
12 use WP_REST_Request;
13 use WP_REST_Response;
14 use WP_REST_Server;
15 use WPFunnels\Wpfnl_functions;
16 use WPFunnels\WooCommerce\Wpfnl_Store_Checkout_Conditions;
17
18 /**
19 * Class StoreCheckoutController
20 * Handles Store Checkout specific operations
21 *
22 * @package WPFunnels\Rest\Controllers
23 * @since 3.5.0
24 */
25 class StoreCheckoutController extends Wpfnl_REST_Controller {
26
27 /**
28 * Endpoint namespace.
29 *
30 * @var string
31 */
32 protected $namespace = 'wpfunnels/v1';
33
34 /**
35 * Route base.
36 *
37 * @var string
38 */
39 protected $rest_base = 'store-checkout';
40
41
42 /**
43 * Check user permission
44 *
45 * @param $request
46 *
47 * @return bool|WP_Error
48 */
49 public function update_items_permissions_check( $request ) {
50 if ( ! Wpfnl_functions::wpfnl_rest_check_manager_permissions( 'funnels' ) ) {
51 return new WP_Error( 'wpfunnels_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'wpfnl' ), array( 'status' => rest_authorization_required_code() ) );
52 }
53 return true;
54 }
55
56
57 /**
58 * Check user read permission
59 *
60 * @param WP_REST_Request $request
61 *
62 * @return WP_Error|boolean
63 */
64 public function get_items_permissions_check( $request ) {
65 if ( ! Wpfnl_functions::wpfnl_rest_check_manager_permissions( 'funnels' ) ) {
66 return new WP_Error( 'wpfunnels_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'wpfnl' ), array( 'status' => rest_authorization_required_code() ) );
67 }
68 return true;
69 }
70
71
72 /**
73 * Register routes
74 */
75 public function register_routes() {
76 // Check if Store Checkout exists
77 register_rest_route(
78 $this->namespace,
79 '/' . $this->rest_base . '/exists',
80 array(
81 array(
82 'methods' => WP_REST_Server::READABLE,
83 'callback' => array( $this, 'check_exists' ),
84 'permission_callback' => array( $this, 'get_items_permissions_check' ),
85 ),
86 )
87 );
88
89 // Get Store Checkout details (first / any)
90 register_rest_route(
91 $this->namespace,
92 '/' . $this->rest_base . '/get',
93 array(
94 array(
95 'methods' => WP_REST_Server::READABLE,
96 'callback' => array( $this, 'get_store_checkout' ),
97 'permission_callback' => array( $this, 'get_items_permissions_check' ),
98 ),
99 )
100 );
101
102 // List all Store Checkout funnels
103 register_rest_route(
104 $this->namespace,
105 '/' . $this->rest_base . '/list',
106 array(
107 array(
108 'methods' => WP_REST_Server::READABLE,
109 'callback' => array( $this, 'list_store_checkouts' ),
110 'permission_callback' => array( $this, 'get_items_permissions_check' ),
111 ),
112 )
113 );
114
115 // One page of the React Store Checkout screen (`#/store-checkout`).
116 register_rest_route(
117 $this->namespace,
118 '/' . $this->rest_base . '/listing',
119 array(
120 array(
121 'methods' => WP_REST_Server::READABLE,
122 'callback' => array( $this, 'get_listing' ),
123 'permission_callback' => array( $this, 'get_items_permissions_check' ),
124 'args' => array(
125 'page' => array(
126 'type' => 'integer',
127 'default' => 1,
128 'sanitize_callback' => 'absint',
129 ),
130 'per_page' => array(
131 'type' => 'integer',
132 'default' => 25,
133 'sanitize_callback' => 'absint',
134 ),
135 'search' => array(
136 'type' => 'string',
137 'default' => '',
138 'sanitize_callback' => 'sanitize_text_field',
139 ),
140 /*
141 * Trash is a value of this filter rather than a tab of
142 * its own, the way the React funnel list reads it — the
143 * legacy screen had a separate `sc_status=trash` view.
144 */
145 'funnel_status' => array(
146 'type' => 'string',
147 'default' => 'all',
148 'enum' => array( 'all', 'publish', 'draft', 'trash' ),
149 ),
150 'orderby' => array(
151 'type' => 'string',
152 'default' => 'date',
153 'enum' => array( 'date', 'title', 'modified' ),
154 ),
155 'order' => array(
156 'type' => 'string',
157 'default' => 'desc',
158 'enum' => array( 'asc', 'desc' ),
159 ),
160 ),
161 ),
162 )
163 );
164
165 // Validate Store Checkout creation (always allowed in multi-checkout mode)
166 register_rest_route(
167 $this->namespace,
168 '/' . $this->rest_base . '/validate-creation',
169 array(
170 array(
171 'methods' => WP_REST_Server::READABLE,
172 'callback' => array( $this, 'validate_creation' ),
173 'permission_callback' => array( $this, 'get_items_permissions_check' ),
174 ),
175 )
176 );
177
178 // Save conditions for a store checkout step
179 register_rest_route(
180 $this->namespace,
181 '/' . $this->rest_base . '/conditions/save',
182 array(
183 array(
184 'methods' => WP_REST_Server::EDITABLE,
185 'callback' => array( $this, 'save_conditions' ),
186 'permission_callback' => array( $this, 'update_items_permissions_check' ),
187 'args' => array(
188 'step_id' => array(
189 'description' => __( 'Checkout step ID.', 'wpfnl' ),
190 'type' => 'integer',
191 'required' => true,
192 'validate_callback' => function( $value ) {
193 return is_numeric( $value );
194 },
195 ),
196 'condition' => array(
197 'description' => __( 'Condition configuration object.', 'wpfnl' ),
198 'type' => 'object',
199 'required' => true,
200 ),
201 ),
202 ),
203 )
204 );
205
206 // Get conditions for a store checkout step
207 register_rest_route(
208 $this->namespace,
209 '/' . $this->rest_base . '/conditions/get',
210 array(
211 array(
212 'methods' => WP_REST_Server::READABLE,
213 'callback' => array( $this, 'get_conditions' ),
214 'permission_callback' => array( $this, 'get_items_permissions_check' ),
215 'args' => array(
216 'step_id' => array(
217 'description' => __( 'Checkout step ID.', 'wpfnl' ),
218 'type' => 'integer',
219 'required' => true,
220 'validate_callback' => function( $value ) {
221 return is_numeric( $value );
222 },
223 ),
224 ),
225 ),
226 )
227 );
228
229 // Search products for condition UI
230 register_rest_route(
231 $this->namespace,
232 '/' . $this->rest_base . '/search/products',
233 array(
234 array(
235 'methods' => WP_REST_Server::READABLE,
236 'callback' => array( $this, 'search_products' ),
237 'permission_callback' => array( $this, 'get_items_permissions_check' ),
238 'args' => array(
239 'search' => array(
240 'type' => 'string',
241 'default' => '',
242 'sanitize_callback' => 'sanitize_text_field',
243 ),
244 ),
245 ),
246 )
247 );
248
249 // Search product terms (categories or tags) for condition UI
250 register_rest_route(
251 $this->namespace,
252 '/' . $this->rest_base . '/search/terms',
253 array(
254 array(
255 'methods' => WP_REST_Server::READABLE,
256 'callback' => array( $this, 'search_terms' ),
257 'permission_callback' => array( $this, 'get_items_permissions_check' ),
258 'args' => array(
259 'taxonomy' => array(
260 'type' => 'string',
261 'required' => true,
262 'enum' => array( 'product_cat', 'product_tag' ),
263 'sanitize_callback' => 'sanitize_key',
264 ),
265 'search' => array(
266 'type' => 'string',
267 'default' => '',
268 'sanitize_callback' => 'sanitize_text_field',
269 ),
270 ),
271 ),
272 )
273 );
274 }
275
276
277 /**
278 * Check if Store Checkout exists
279 *
280 * @param WP_REST_Request $request
281 * @return WP_REST_Response
282 */
283 public function check_exists( $request ) {
284 $store_checkout = $this->get_store_checkout_funnel();
285
286 $response = array(
287 'exists' => !empty( $store_checkout ),
288 'funnel_id' => $store_checkout ? $store_checkout->ID : null,
289 );
290
291 return rest_ensure_response( $response );
292 }
293
294
295 /**
296 * Get Store Checkout funnel details (first one found — legacy endpoint)
297 *
298 * @param WP_REST_Request $request
299 * @return WP_REST_Response|WP_Error
300 */
301 public function get_store_checkout( $request ) {
302 $store_checkout = $this->get_store_checkout_funnel();
303
304 if ( !$store_checkout ) {
305 return new WP_Error(
306 'no_store_checkout',
307 __( 'No Store Checkout found.', 'wpfnl' ),
308 array( 'status' => 404 )
309 );
310 }
311
312 $response = array(
313 'success' => true,
314 'funnel_id' => $store_checkout->ID,
315 'funnel_name' => get_the_title( $store_checkout->ID ),
316 'edit_url' => add_query_arg(
317 array(
318 'page' => WPFNL_EDIT_FUNNEL_SLUG,
319 'id' => $store_checkout->ID,
320 'step_id' => 0,
321 ),
322 admin_url( 'admin.php' )
323 ),
324 );
325
326 return rest_ensure_response( $response );
327 }
328
329
330 /**
331 * List all Store Checkout funnels with their condition info.
332 *
333 * @param WP_REST_Request $request
334 * @return WP_REST_Response
335 * @since 3.6.0
336 */
337 public function list_store_checkouts( $request ) {
338 $this->ensure_conditions_class();
339
340 $funnels = Wpfnl_Store_Checkout_Conditions::get_all_store_checkout_funnels();
341 $items = array();
342
343 foreach ( $funnels as $funnel ) {
344 $step_id = Wpfnl_Store_Checkout_Conditions::get_checkout_step_id_for_funnel( $funnel->ID );
345 $condition = $step_id
346 ? Wpfnl_Store_Checkout_Conditions::get_condition( $step_id )
347 : array( 'condition_type' => 'all' );
348
349 $items[] = array(
350 'funnel_id' => $funnel->ID,
351 'funnel_name' => get_the_title( $funnel->ID ),
352 'status' => $funnel->post_status,
353 'created_at' => $funnel->post_date,
354 'step_id' => $step_id,
355 'condition' => $condition,
356 'edit_url' => add_query_arg(
357 array(
358 'page' => WPFNL_EDIT_FUNNEL_SLUG,
359 'id' => $funnel->ID,
360 'step_id' => 0,
361 ),
362 admin_url( 'admin.php' )
363 ),
364 );
365 }
366
367 return rest_ensure_response( array(
368 'success' => true,
369 'items' => $items,
370 ) );
371 }
372
373
374 /**
375 * Validate if Store Checkout can be created.
376 *
377 * Since 3.6.0 multiple store checkouts are allowed — creation is always
378 * permitted.
379 *
380 * @param WP_REST_Request $request
381 * @return WP_REST_Response
382 */
383 public function validate_creation( $request ) {
384 return rest_ensure_response( array(
385 'can_create' => true,
386 'message' => __( 'Store Checkout can be created.', 'wpfnl' ),
387 ) );
388 }
389
390
391 /**
392 * Save conditions for a store-checkout step.
393 *
394 * @param WP_REST_Request $request
395 * @return WP_REST_Response|WP_Error
396 * @since 3.6.0
397 */
398 public function save_conditions( $request ) {
399 $this->ensure_conditions_class();
400
401 $step_id = (int) $request->get_param( 'step_id' );
402 $condition = $request->get_param( 'condition' );
403
404 if ( ! $step_id || WPFNL_STEPS_POST_TYPE !== get_post_type( $step_id ) ) {
405 return new WP_Error(
406 'invalid_step',
407 __( 'Invalid step ID provided.', 'wpfnl' ),
408 array( 'status' => 400 )
409 );
410 }
411
412 if ( ! is_array( $condition ) ) {
413 return new WP_Error(
414 'invalid_condition',
415 __( 'Condition must be an object.', 'wpfnl' ),
416 array( 'status' => 400 )
417 );
418 }
419
420 $saved = Wpfnl_Store_Checkout_Conditions::save_condition( $step_id, $condition );
421
422 if ( ! $saved ) {
423 return new WP_Error(
424 'save_failed',
425 __( 'Failed to save condition.', 'wpfnl' ),
426 array( 'status' => 500 )
427 );
428 }
429
430 return rest_ensure_response( array(
431 'success' => true,
432 'condition' => Wpfnl_Store_Checkout_Conditions::get_condition( $step_id ),
433 'message' => __( 'Condition saved successfully.', 'wpfnl' ),
434 ) );
435 }
436
437
438 /**
439 * Get conditions for a store-checkout step.
440 *
441 * @param WP_REST_Request $request
442 * @return WP_REST_Response|WP_Error
443 * @since 3.6.0
444 */
445 public function get_conditions( $request ) {
446 $this->ensure_conditions_class();
447
448 $step_id = (int) $request->get_param( 'step_id' );
449
450 if ( ! $step_id || WPFNL_STEPS_POST_TYPE !== get_post_type( $step_id ) ) {
451 // If the step_id is actually a funnel ID, try to resolve the checkout step from it.
452 $resolved_step_id = null;
453 if ( $step_id && WPFNL_FUNNELS_POST_TYPE === get_post_type( $step_id ) ) {
454 $resolved_step_id = Wpfnl_Store_Checkout_Conditions::get_checkout_step_id_for_funnel( $step_id );
455 }
456
457 if ( ! $resolved_step_id ) {
458 return new WP_Error(
459 'invalid_step',
460 __( 'Invalid step ID provided.', 'wpfnl' ),
461 array( 'status' => 400 )
462 );
463 }
464
465 $step_id = $resolved_step_id;
466 }
467
468 $condition = Wpfnl_Store_Checkout_Conditions::get_condition( $step_id );
469
470 return rest_ensure_response( array(
471 'success' => true,
472 'step_id' => $step_id,
473 'condition' => $condition,
474 ) );
475 }
476
477
478 /**
479 * Get Store Checkout funnel if exists (returns first one — legacy helper)
480 *
481 * @return WP_Post|false
482 */
483 private function get_store_checkout_funnel() {
484 $args = array(
485 'post_type' => WPFNL_FUNNELS_POST_TYPE,
486 'posts_per_page' => 1,
487 'post_status' => array( 'publish', 'draft' ),
488 'meta_query' => array(
489 array(
490 'key' => '_wpfnl_funnel_type',
491 'value' => 'store_checkout',
492 ),
493 ),
494 );
495
496 $funnels = get_posts( $args );
497 return !empty( $funnels ) ? $funnels[0] : false;
498 }
499
500
501 /**
502 * Search WooCommerce products for the conditions UI.
503 *
504 * @param WP_REST_Request $request
505 * @return WP_REST_Response
506 * @since 3.6.0
507 */
508 public function search_products( $request ) {
509 $search = $request->get_param( 'search' );
510 $results = array();
511
512 $args = array(
513 'post_type' => 'product',
514 'post_status' => 'publish',
515 'posts_per_page' => 30,
516 'fields' => 'ids',
517 );
518
519 if ( ! empty( $search ) ) {
520 $args['s'] = $search;
521 }
522
523 $ids = get_posts( $args );
524
525 foreach ( $ids as $id ) {
526 $product = wc_get_product( $id );
527 if ( ! $product ) {
528 continue;
529 }
530 $results[] = array(
531 'id' => $id,
532 'name' => $product->get_name(),
533 );
534 }
535
536 return rest_ensure_response( array(
537 'success' => true,
538 'items' => $results,
539 ) );
540 }
541
542
543 /**
544 * Search WooCommerce product taxonomy terms for the conditions UI.
545 *
546 * @param WP_REST_Request $request
547 * @return WP_REST_Response|WP_Error
548 * @since 3.6.0
549 */
550 public function search_terms( $request ) {
551 $taxonomy = $request->get_param( 'taxonomy' );
552 $search = $request->get_param( 'search' );
553
554 if ( ! in_array( $taxonomy, array( 'product_cat', 'product_tag' ), true ) ) {
555 return new WP_Error(
556 'invalid_taxonomy',
557 __( 'Invalid taxonomy. Must be product_cat or product_tag.', 'wpfnl' ),
558 array( 'status' => 400 )
559 );
560 }
561
562 $args = array(
563 'taxonomy' => $taxonomy,
564 'hide_empty' => false,
565 'number' => 50,
566 );
567
568 if ( ! empty( $search ) ) {
569 $args['search'] = $search;
570 }
571
572 $terms = get_terms( $args );
573 $results = array();
574
575 if ( ! is_wp_error( $terms ) ) {
576 foreach ( $terms as $term ) {
577 $results[] = array(
578 'id' => $term->term_id,
579 'name' => $term->name,
580 );
581 }
582 }
583
584 return rest_ensure_response( array(
585 'success' => true,
586 'items' => $results,
587 ) );
588 }
589
590
591 /**
592 * Free installs may keep this many store checkouts before the screen stops
593 * offering to build another. Same number the legacy view hard-coded.
594 */
595 const FREE_STORE_CHECKOUT_LIMIT = 3;
596
597
598 /**
599 * One page of the React Store Checkout screen.
600 *
601 * Everything the screen needs travels with the listing — the analytics
602 * strip, the status counts and the free-plan allowance — because all three
603 * change with the rows and a second round trip would let them disagree with
604 * what is on screen.
605 *
606 * Priority is worked out over the whole set rather than the page: it is a
607 * funnel's position in the evaluation order of every store checkout on the
608 * site, so paging must not renumber it.
609 *
610 * @param WP_REST_Request $request Full data about the request.
611 *
612 * @return WP_REST_Response
613 * @since 3.13.6
614 */
615 public function get_listing( $request ) {
616 $this->ensure_conditions_class();
617
618 $page = max( 1, (int) $request->get_param( 'page' ) );
619 $per_page = (int) $request->get_param( 'per_page' );
620 $per_page = $per_page > 0 ? min( $per_page, 100 ) : 25;
621 $search = (string) $request->get_param( 'search' );
622 $status = (string) $request->get_param( 'funnel_status' );
623 $orderby = (string) $request->get_param( 'orderby' );
624 $order = 'asc' === $request->get_param( 'order' ) ? 'ASC' : 'DESC';
625
626 $post_status = in_array( $status, array( 'publish', 'draft', 'trash' ), true )
627 ? array( $status )
628 : array( 'publish', 'draft', 'trash' );
629
630 $matching = $this->query_store_checkouts( $post_status, $search, $orderby, $order );
631 $total = count( $matching );
632 $posts = array_slice( $matching, ( $page - 1 ) * $per_page, $per_page );
633
634 /*
635 * The evaluation order is a property of the live set, not of the filter
636 * being looked at: a trashed checkout is evaluated by nobody, and a
637 * search must not move the numbers of the rows it happens to match.
638 */
639 $priority = $this->get_priority_map();
640
641 $items = array();
642
643 foreach ( $posts as $funnel ) {
644 $items[] = $this->prepare_row( $funnel, $priority );
645 }
646
647 return rest_ensure_response(
648 array(
649 'success' => true,
650 'items' => $items,
651 'total' => $total,
652 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
653 'page' => $page,
654 'per_page' => $per_page,
655 'counts' => $this->get_listing_counts(),
656 'analytics' => $this->get_listing_analytics(),
657 'meta' => $this->get_listing_meta(),
658 )
659 );
660 }
661
662
663 /**
664 * Every store checkout matching a status and a search, newest first.
665 *
666 * Resolved in full rather than paged by WP_Query because the screen also
667 * needs the whole set to number the evaluation order, and asking twice for
668 * the same posts is the more expensive of the two.
669 *
670 * @param array $post_status Post statuses to match.
671 * @param string $search Search term, or an empty string.
672 * @param string $orderby Order column.
673 * @param string $order `ASC` or `DESC`.
674 *
675 * @return \WP_Post[]
676 * @since 3.13.6
677 */
678 private function query_store_checkouts( $post_status, $search = '', $orderby = 'date', $order = 'DESC' ) {
679 $args = array(
680 'post_type' => WPFNL_FUNNELS_POST_TYPE,
681 'posts_per_page' => -1,
682 'post_status' => $post_status,
683 'orderby' => $orderby,
684 'order' => $order,
685 'no_found_rows' => true,
686 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
687 array(
688 'key' => '_wpfnl_funnel_type',
689 'value' => 'store_checkout',
690 ),
691 ),
692 );
693
694 if ( $search ) {
695 $args['s'] = $search;
696 }
697
698 return get_posts( $args );
699 }
700
701
702 /**
703 * Where each live store checkout sits in the evaluation order.
704 *
705 * The same ordering the matcher itself applies: conditional checkouts are
706 * tried newest first, and the catch-all is the fallback when none of them
707 * match. Only the newest catch-all can ever be reached — the ones behind it
708 * are overridden, and the screen says so rather than showing a number that
709 * means nothing.
710 *
711 * @return array `[ 'position' => int[], 'overridden' => int[], 'catchAll' => int[] ]`.
712 * @since 3.13.6
713 */
714 private function get_priority_map() {
715 $funnels = Wpfnl_Store_Checkout_Conditions::get_all_store_checkout_funnels();
716 $conditional = array();
717 $catch_all = array();
718
719 foreach ( $funnels as $funnel ) {
720 $step_id = Wpfnl_Store_Checkout_Conditions::get_checkout_step_id_for_funnel( $funnel->ID );
721 $condition = $step_id
722 ? Wpfnl_Store_Checkout_Conditions::get_condition( $step_id )
723 : array( 'condition_type' => 'all' );
724
725 if ( Wpfnl_Store_Checkout_Conditions::is_catch_all( $condition ) ) {
726 $catch_all[] = (int) $funnel->ID;
727 } else {
728 $conditional[] = (int) $funnel->ID;
729 }
730 }
731
732 $position = array();
733 $overridden = array();
734 $next = 1;
735
736 foreach ( $conditional as $funnel_id ) {
737 $position[ $funnel_id ] = $next++;
738 }
739
740 foreach ( $catch_all as $index => $funnel_id ) {
741 $position[ $funnel_id ] = $next++;
742
743 if ( $index > 0 ) {
744 $overridden[] = $funnel_id;
745 }
746 }
747
748 return array(
749 'position' => $position,
750 'overridden' => $overridden,
751 'catchAll' => $catch_all,
752 );
753 }
754
755
756 /**
757 * Shape one row of the listing.
758 *
759 * @param \WP_Post $funnel Store checkout funnel.
760 * @param array $priority Output of get_priority_map().
761 *
762 * @return array
763 * @since 3.13.6
764 */
765 private function prepare_row( $funnel, $priority ) {
766 $funnel_id = (int) $funnel->ID;
767 $step_id = Wpfnl_Store_Checkout_Conditions::get_checkout_step_id_for_funnel( $funnel_id );
768 $condition = $step_id
769 ? Wpfnl_Store_Checkout_Conditions::get_condition( $step_id )
770 : array( 'condition_type' => 'all' );
771
772 $is_trash = 'trash' === $funnel->post_status;
773 $badges = $this->get_condition_badges( $condition );
774
775 /*
776 * A catch-all has no badge of its own, so the fallback one is added
777 * here and the row is marked as having no real condition — which is
778 * also what decides the wording of its priority.
779 */
780 $is_catch_all = empty( $badges );
781
782 if ( $is_catch_all ) {
783 $badges[] = array(
784 'label' => __( 'No Condition', 'wpfnl' ),
785 'tooltip' => __( 'This store checkout will apply as the default WooCommerce checkout without any condition.', 'wpfnl' ),
786 'isDate' => false,
787 );
788 }
789
790 $summary = '';
791
792 foreach ( $badges as $index => $badge ) {
793 if ( $index > 0 ) {
794 $summary .= empty( $badge['isDate'] ) ? ' OR ' : ' AND ';
795 }
796
797 $summary .= $badge['label'];
798 }
799
800 $view_url = '';
801
802 if ( ! $is_trash && $step_id ) {
803 $permalink = get_the_permalink( $step_id );
804 $view_url = $permalink ? esc_url_raw( $permalink ) : '';
805 }
806
807 return array(
808 'id' => $funnel_id,
809 'name' => $funnel->post_title ? $funnel->post_title : __( '(no title)', 'wpfnl' ),
810 'status' => $funnel->post_status,
811 'statusLabel' => $this->get_status_label( $funnel->post_status ),
812 'builder' => Wpfnl_functions::get_page_builder_by_step_id( $funnel_id ),
813 'stepId' => $step_id ? (int) $step_id : 0,
814 'createdAt' => $funnel->post_date_gmt,
815 // Rendered server-side so the column reads in the site's own
816 // locale and date format rather than the browser's.
817 'createdLabel' => get_the_date( 'Y-m-d g:i A', $funnel_id ),
818 'conditionSummary' => $summary,
819 'conditionTooltip' => implode( ' ', wp_list_pluck( $badges, 'tooltip' ) ),
820 'hasCondition' => ! $is_catch_all,
821 'editUrl' => $is_trash ? '' : $this->get_funnel_edit_url( $funnel_id ),
822 'viewUrl' => $view_url,
823 ) + $this->get_row_priority( $funnel_id, $priority, $is_catch_all, $is_trash );
824 }
825
826
827 /**
828 * The priority badge of one row: its wording, its tone and why.
829 *
830 * @param int $funnel_id Funnel id.
831 * @param array $priority Output of get_priority_map().
832 * @param bool $is_catch_all Whether the checkout carries no condition.
833 * @param bool $is_trash Whether the checkout is in the trash.
834 *
835 * @return array
836 * @since 3.13.6
837 */
838 private function get_row_priority( $funnel_id, $priority, $is_catch_all, $is_trash ) {
839 // Nothing in the trash is ever evaluated, so it has no place in the order.
840 if ( $is_trash || ! isset( $priority['position'][ $funnel_id ] ) ) {
841 return array(
842 'priorityLabel' => '—',
843 'priorityTone' => 'none',
844 'priorityTooltip' => __( 'Trashed store checkouts are never evaluated.', 'wpfnl' ),
845 );
846 }
847
848 if ( in_array( $funnel_id, $priority['overridden'], true ) ) {
849 return array(
850 'priorityLabel' => __( 'Overridden', 'wpfnl' ),
851 'priorityTone' => 'overridden',
852 'priorityTooltip' => __( 'This fallback checkout is overridden by a newer fallback. Only the most recently created fallback is active when no conditional checkout matches.', 'wpfnl' ),
853 );
854 }
855
856 if ( $is_catch_all ) {
857 return array(
858 'priorityLabel' => __( 'Default', 'wpfnl' ),
859 'priorityTone' => 'fallback',
860 'priorityTooltip' => __( 'This checkout has no condition and acts as the default fallback. It will only be used when no other conditional store checkout matches the customer\'s cart.', 'wpfnl' ),
861 );
862 }
863
864 $position = (int) $priority['position'][ $funnel_id ];
865
866 if ( 1 === $position ) {
867 return array(
868 'priorityLabel' => __( 'Highest', 'wpfnl' ),
869 'priorityTone' => 'active',
870 'priorityTooltip' => __( 'This is the most recently created conditional checkout. It will be evaluated first and takes priority over all other store checkouts.', 'wpfnl' ),
871 );
872 }
873
874 return array(
875 /* translators: %d: priority position number. */
876 'priorityLabel' => sprintf( __( '#%d', 'wpfnl' ), $position ),
877 'priorityTone' => 'active',
878 /* translators: %d: priority position number. */
879 'priorityTooltip' => sprintf( __( 'This checkout has evaluation priority #%d. Store checkouts with conditions are evaluated from newest to oldest. The first match wins.', 'wpfnl' ), $position ),
880 );
881 }
882
883
884 /**
885 * One badge per rule a condition carries, plus the date range when it has
886 * one. An empty list means the checkout is a catch-all.
887 *
888 * @param array $condition Stored condition.
889 *
890 * @return array List of `[ 'label' => string, 'tooltip' => string, 'isDate' => bool ]`.
891 * @since 3.13.6
892 */
893 private function get_condition_badges( $condition ) {
894 $labels = array(
895 'all' => __( 'No Condition', 'wpfnl' ),
896 'products' => __( 'Specific Products', 'wpfnl' ),
897 'categories' => __( 'Product Categories', 'wpfnl' ),
898 'tags' => __( 'Product Tags', 'wpfnl' ),
899 'date_range' => __( 'Date Range', 'wpfnl' ),
900 'rules' => __( 'Custom Rules', 'wpfnl' ),
901 );
902
903 $type = isset( $condition['condition_type'] ) ? $condition['condition_type'] : 'all';
904 $badges = array();
905 $rules = array();
906
907 if ( 'rules' === $type && ! empty( $condition['rules'] ) && is_array( $condition['rules'] ) ) {
908 $rules = $condition['rules'];
909
910 foreach ( $rules as $rule ) {
911 $rule_type = isset( $rule['type'] ) ? $rule['type'] : 'all';
912
913 $badges[] = array(
914 'label' => isset( $labels[ $rule_type ] ) ? $labels[ $rule_type ] : $labels['all'],
915 'tooltip' => $this->get_rule_tooltip( $rule, count( $rules ) ),
916 'isDate' => false,
917 );
918 }
919 }
920
921 if ( empty( $condition['use_date_range'] ) ) {
922 return $badges;
923 }
924
925 $badges[] = array(
926 'label' => $labels['date_range'],
927 'tooltip' => $this->get_date_tooltip( $condition, ! empty( $rules ) ),
928 'isDate' => true,
929 );
930
931 return $badges;
932 }
933
934
935 /**
936 * What one rule means, with its product, category or tag ids resolved to
937 * the names the reader gave them.
938 *
939 * @param array $rule One rule of a condition.
940 * @param int $total_rules How many rules the condition carries.
941 *
942 * @return string
943 * @since 3.13.6
944 */
945 private function get_rule_tooltip( $rule, $total_rules = 1 ) {
946 $type = isset( $rule['type'] ) ? $rule['type'] : 'all';
947 $values = isset( $rule['values'] ) ? (array) $rule['values'] : array();
948 $or_note = $total_rules > 1 ? ' ' . __( '(OR with other rules)', 'wpfnl' ) : '';
949
950 if ( ! $values ) {
951 return __( 'This checkout will appear when the condition is met.', 'wpfnl' ) . $or_note;
952 }
953
954 $names = array();
955
956 foreach ( $values as $value_id ) {
957 $value_id = (int) $value_id;
958
959 if ( 'products' === $type ) {
960 $names[] = get_the_title( $value_id );
961 continue;
962 }
963
964 $taxonomy = 'categories' === $type ? 'product_cat' : ( 'tags' === $type ? 'product_tag' : '' );
965
966 if ( ! $taxonomy ) {
967 continue;
968 }
969
970 $term = get_term( $value_id, $taxonomy );
971 $names[] = ( $term && ! is_wp_error( $term ) ) ? $term->name : '#' . $value_id;
972 }
973
974 $names = implode( ', ', $names );
975
976 switch ( $type ) {
977 case 'products':
978 /* translators: %s: comma separated product names. */
979 return sprintf( __( 'This checkout will appear when the cart contains: %s.', 'wpfnl' ), $names ) . $or_note;
980
981 case 'categories':
982 /* translators: %s: comma separated category names. */
983 return sprintf( __( 'This checkout will appear when the cart has products from: %s.', 'wpfnl' ), $names ) . $or_note;
984
985 case 'tags':
986 /* translators: %s: comma separated tag names. */
987 return sprintf( __( 'This checkout will appear when the cart has products tagged: %s.', 'wpfnl' ), $names ) . $or_note;
988 }
989
990 return __( 'This checkout will appear when the condition is met.', 'wpfnl' ) . $or_note;
991 }
992
993
994 /**
995 * What the date range of a condition means.
996 *
997 * A date range narrows whatever the rules already matched rather than
998 * offering another way to match, so its wording says "additionally" when
999 * there are rules alongside it.
1000 *
1001 * @param array $condition Stored condition.
1002 * @param bool $has_rules Whether the condition also carries rules.
1003 *
1004 * @return string
1005 * @since 3.13.6
1006 */
1007 private function get_date_tooltip( $condition, $has_rules ) {
1008 $from = isset( $condition['date_from'] ) ? $condition['date_from'] : '';
1009 $to = isset( $condition['date_to'] ) ? $condition['date_to'] : '';
1010
1011 if ( $from && $to ) {
1012 return $has_rules
1013 /* translators: 1: start date, 2: end date. */
1014 ? sprintf( __( 'Additionally, this checkout will only be active between %1$s and %2$s.', 'wpfnl' ), $from, $to )
1015 /* translators: 1: start date, 2: end date. */
1016 : sprintf( __( 'This checkout will only be active between %1$s and %2$s.', 'wpfnl' ), $from, $to );
1017 }
1018
1019 if ( $from ) {
1020 return $has_rules
1021 /* translators: %s: start date. */
1022 ? sprintf( __( 'Additionally, this checkout will only be active from %s onwards.', 'wpfnl' ), $from )
1023 /* translators: %s: start date. */
1024 : sprintf( __( 'This checkout will only be active from %s onwards.', 'wpfnl' ), $from );
1025 }
1026
1027 if ( $to ) {
1028 return $has_rules
1029 /* translators: %s: end date. */
1030 ? sprintf( __( 'Additionally, this checkout will only be active until %s.', 'wpfnl' ), $to )
1031 /* translators: %s: end date. */
1032 : sprintf( __( 'This checkout will only be active until %s.', 'wpfnl' ), $to );
1033 }
1034
1035 return __( 'This checkout has a date range condition.', 'wpfnl' );
1036 }
1037
1038
1039 /**
1040 * Orders, revenue, average order value and bump revenue across every live
1041 * store checkout.
1042 *
1043 * @return array
1044 * @since 3.13.6
1045 */
1046 private function get_listing_analytics() {
1047 global $wpdb;
1048
1049 $analytics = array(
1050 'totalOrders' => 0,
1051 'totalRevenue' => 0,
1052 'aov' => 0,
1053 'orderBumpRevenue' => 0,
1054 );
1055
1056 $ids = wp_list_pluck( Wpfnl_Store_Checkout_Conditions::get_all_store_checkout_funnels(), 'ID' );
1057 $ids = array_filter( array_map( 'absint', (array) $ids ) );
1058
1059 if ( ! $ids ) {
1060 return $analytics;
1061 }
1062
1063 $stats_table = $wpdb->prefix . 'wpfnl_stats';
1064
1065 // A site that has never tracked an order has no stats table, and that
1066 // is not an error worth failing the whole screen over.
1067 if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $stats_table ) ) ) { // phpcs:ignore
1068 return $analytics;
1069 }
1070
1071 // Safe to interpolate: every id has been through absint().
1072 $placeholders = implode( ',', $ids );
1073
1074 $row = $wpdb->get_row(
1075 $wpdb->prepare(
1076 "SELECT COUNT(DISTINCT id) AS total_orders,
1077 COALESCE(SUM(total_sales), 0) AS total_revenue,
1078 COALESCE(SUM(orderbump_sales), 0) AS orderbump_revenue
1079 FROM {$stats_table}
1080 WHERE funnel_id IN ({$placeholders}) AND status IN (%s, %s)",
1081 'completed',
1082 'processing'
1083 )
1084 ); // phpcs:ignore
1085
1086 if ( ! $row ) {
1087 return $analytics;
1088 }
1089
1090 $analytics['totalOrders'] = (int) $row->total_orders;
1091 $analytics['totalRevenue'] = round( (float) $row->total_revenue, 2 );
1092 $analytics['orderBumpRevenue'] = round( (float) $row->orderbump_revenue, 2 );
1093 $analytics['aov'] = $analytics['totalOrders'] > 0
1094 ? round( $analytics['totalRevenue'] / $analytics['totalOrders'], 2 )
1095 : 0;
1096
1097 return $analytics;
1098 }
1099
1100
1101 /**
1102 * How many store checkouts each value of the status filter would find.
1103 *
1104 * @return array
1105 * @since 3.13.6
1106 */
1107 private function get_listing_counts() {
1108 $publish = count( $this->query_store_checkouts( array( 'publish' ) ) );
1109 $draft = count( $this->query_store_checkouts( array( 'draft' ) ) );
1110 $trash = count( $this->query_store_checkouts( array( 'trash' ) ) );
1111
1112 return array(
1113 'all' => $publish + $draft + $trash,
1114 'publish' => $publish,
1115 'draft' => $draft,
1116 'trash' => $trash,
1117 );
1118 }
1119
1120
1121 /**
1122 * State the screen's chrome depends on: the plan, the store checkout
1123 * allowance and where the creation journey starts.
1124 *
1125 * @return array
1126 * @since 3.13.6
1127 */
1128 private function get_listing_meta() {
1129 $is_pro_active = (bool) apply_filters( 'wpfunnels/is_pro_license_activated', false );
1130 $count = (int) Wpfnl_functions::count_store_checkout_funnels();
1131
1132 return array(
1133 'isProActive' => $is_pro_active,
1134 'isWooActive' => Wpfnl_functions::is_wc_active(),
1135 'storeCheckoutCount' => $count,
1136 'maxStoreCheckouts' => $is_pro_active ? -1 : self::FREE_STORE_CHECKOUT_LIMIT,
1137 'limitReached' => ! $is_pro_active && $count >= self::FREE_STORE_CHECKOUT_LIMIT,
1138 'upgradeUrl' => 'https://getwpfunnels.com/pricing/',
1139 );
1140 }
1141
1142
1143 /**
1144 * Word for a post status, in this screen's terms.
1145 *
1146 * A store checkout is either taking over the WooCommerce checkout or it is
1147 * not, so published and draft read as Enabled and Disabled here — which is
1148 * what the legacy screen called them.
1149 *
1150 * @param string $status Post status.
1151 *
1152 * @return string
1153 * @since 3.13.6
1154 */
1155 private function get_status_label( $status ) {
1156 if ( 'publish' === $status ) {
1157 return __( 'Enabled', 'wpfnl' );
1158 }
1159
1160 if ( 'trash' === $status ) {
1161 return __( 'Trashed', 'wpfnl' );
1162 }
1163
1164 return __( 'Disabled', 'wpfnl' );
1165 }
1166
1167
1168 /**
1169 * Canvas URL of a store checkout funnel.
1170 *
1171 * @param int $funnel_id Funnel id.
1172 *
1173 * @return string
1174 * @since 3.13.6
1175 */
1176 private function get_funnel_edit_url( $funnel_id ) {
1177 return esc_url_raw(
1178 add_query_arg(
1179 array(
1180 'page' => WPFNL_EDIT_FUNNEL_SLUG,
1181 'id' => $funnel_id,
1182 'step_id' => 0,
1183 ),
1184 admin_url( 'admin.php' )
1185 )
1186 );
1187 }
1188
1189
1190 /**
1191 * Ensure the conditions class is loaded.
1192 *
1193 * @since 3.6.0
1194 */
1195 private function ensure_conditions_class() {
1196 if ( ! class_exists( 'WPFunnels\\WooCommerce\\Wpfnl_Store_Checkout_Conditions' ) ) {
1197 require_once WPFNL_DIR . 'includes/core/woocommerce/class-wpfnl-store-checkout-conditions.php';
1198 }
1199 }
1200 }
1201