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 / FunnelListController.php

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

1,323 lines 36.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Funnel listing controller.
4 *
5 * Serves the React funnel list (`?page=wpfunnels#/funnels`), which replaced the
6 * server-rendered `?page=wp_funnels` screen and its jQuery/admin-ajax actions.
7 * Everything that screen did through `wp_ajax_helper` — duplicate, trash,
8 * restore, delete, publish/draft — is a REST route here, so the React app never
9 * touches admin-ajax.
10 *
11 * @package WPFunnels\Rest\Controllers
12 */
13
14 namespace WPFunnels\Rest\Controllers;
15
16 use WP_Error;
17 use WP_Query;
18 use WP_REST_Request;
19 use WP_REST_Server;
20 use WPFunnels\Export\Wpfnl_Export;
21 use WPFunnels\Modules\Admin\Funnel\Module as FunnelModule;
22 use WPFunnels\Wpfnl;
23 use WPFunnels\Wpfnl_functions;
24
25 class FunnelListController extends Wpfnl_REST_Controller
26 {
27
28 /**
29 * Endpoint namespace.
30 *
31 * @var string
32 */
33 protected $namespace = 'wpfunnels/v1';
34
35 /**
36 * Route base.
37 *
38 * @var string
39 */
40 protected $rest_base = 'funnels';
41
42 /**
43 * Funnels a free install may keep before the list stops offering to make
44 * more. Same number the legacy view hard-coded.
45 */
46 const FREE_FUNNEL_LIMIT = 3;
47
48 /**
49 * Makes sure the current user may read the funnel list.
50 *
51 * @param WP_REST_Request $request Full data about the request.
52 *
53 * @return bool|WP_Error
54 * @since 3.13.4
55 */
56 public function get_items_permissions_check($request)
57 {
58 if (!Wpfnl_functions::wpfnl_rest_check_manager_permissions('funnels')) {
59 return new WP_Error(
60 'wpfunnels_rest_cannot_view',
61 __('Sorry, you cannot list resources.', 'wpfnl'),
62 array('status' => rest_authorization_required_code())
63 );
64 }
65
66 return true;
67 }
68
69
70 /**
71 * Makes sure the current user may act on a funnel.
72 *
73 * @param WP_REST_Request $request Full data about the request.
74 *
75 * @return bool|WP_Error
76 * @since 3.13.4
77 */
78 public function update_items_permissions_check($request)
79 {
80 if (!Wpfnl_functions::wpfnl_rest_check_manager_permissions('funnels', 'edit')) {
81 return new WP_Error(
82 'wpfunnels_rest_cannot_edit',
83 __('Sorry, you cannot edit this resource.', 'wpfnl'),
84 array('status' => rest_authorization_required_code())
85 );
86 }
87
88 return true;
89 }
90
91
92 /**
93 * Register rest routes.
94 *
95 * @return void
96 * @since 3.13.4
97 */
98 public function register_routes()
99 {
100 register_rest_route(
101 $this->namespace,
102 '/' . $this->rest_base,
103 array(
104 array(
105 'methods' => WP_REST_Server::READABLE,
106 'callback' => array($this, 'get_funnels'),
107 'permission_callback' => array($this, 'get_items_permissions_check'),
108 'args' => array(
109 'page' => array(
110 'type' => 'integer',
111 'default' => 1,
112 'sanitize_callback' => 'absint',
113 ),
114 'per_page' => array(
115 'type' => 'integer',
116 'default' => 25,
117 'sanitize_callback' => 'absint',
118 ),
119 'search' => array(
120 'type' => 'string',
121 'default' => '',
122 'sanitize_callback' => 'sanitize_text_field',
123 ),
124 /*
125 * The one status the listing filters on. Trash is a
126 * value here rather than a separate listing: it is a
127 * post status like the others, and `all` means all of
128 * them, trash included — a count labelled "All" that
129 * quietly left a status out is a count nobody can
130 * reconcile.
131 *
132 * Applied in the query rather than in the browser so
133 * `total` counts what the filter actually matched;
134 * filtering a single page of results client-side left
135 * the pager describing a set nobody could see.
136 */
137 'funnel_status' => array(
138 'type' => 'string',
139 'default' => 'all',
140 'enum' => array('all', 'publish', 'draft', 'trash'),
141 ),
142 /*
143 * Retired in 3.13.4, when the Live and Trash tabs became
144 * one filter. Still honoured so a bookmarked request (or
145 * an older bundle served from a cache) keeps working.
146 */
147 'status' => array(
148 'type' => 'string',
149 'default' => '',
150 'enum' => array('', 'live', 'trash'),
151 ),
152 /*
153 * `conversions` and `sales` are not post columns — they
154 * come from the wpfnl_stats aggregate — so those two are
155 * ordered outside WP_Query. See sort_by_analytics().
156 */
157 'orderby' => array(
158 'type' => 'string',
159 'default' => 'id',
160 'enum' => array('id', 'modified', 'title', 'date', 'conversions', 'sales'),
161 ),
162 'order' => array(
163 'type' => 'string',
164 'default' => 'desc',
165 'enum' => array('asc', 'desc'),
166 ),
167 ),
168 ),
169 /*
170 * Creating a funnel from nothing — "Start from scratch" on the
171 * template library screen. The layout is the skeleton of steps
172 * to lay out on the canvas; without one the funnel is created
173 * empty.
174 */
175 array(
176 'methods' => WP_REST_Server::CREATABLE,
177 'callback' => array($this, 'create_funnel'),
178 'permission_callback' => array($this, 'update_items_permissions_check'),
179 'args' => array(
180 'funnelName' => array(
181 'required' => true,
182 'type' => 'string',
183 'sanitize_callback' => 'sanitize_text_field',
184 ),
185 'type' => array(
186 'type' => 'string',
187 'default' => 'wc',
188 'sanitize_callback' => 'sanitize_key',
189 ),
190 ),
191 ),
192 )
193 );
194
195 register_rest_route(
196 $this->namespace,
197 '/' . $this->rest_base . '/(?P<funnel_id>\d+)/duplicate',
198 array(
199 array(
200 'methods' => WP_REST_Server::CREATABLE,
201 'callback' => array($this, 'duplicate_funnel'),
202 'permission_callback' => array($this, 'update_items_permissions_check'),
203 'args' => $this->get_funnel_id_arg(),
204 ),
205 )
206 );
207
208 register_rest_route(
209 $this->namespace,
210 '/' . $this->rest_base . '/(?P<funnel_id>\d+)/status',
211 array(
212 array(
213 'methods' => WP_REST_Server::CREATABLE,
214 'callback' => array($this, 'update_status'),
215 'permission_callback' => array($this, 'update_items_permissions_check'),
216 'args' => array_merge(
217 $this->get_funnel_id_arg(),
218 array(
219 'status' => array(
220 'required' => true,
221 'type' => 'string',
222 'enum' => array('publish', 'draft'),
223 ),
224 )
225 ),
226 ),
227 )
228 );
229
230 register_rest_route(
231 $this->namespace,
232 '/' . $this->rest_base . '/(?P<funnel_id>\d+)/trash',
233 array(
234 array(
235 'methods' => WP_REST_Server::CREATABLE,
236 'callback' => array($this, 'trash_funnel'),
237 'permission_callback' => array($this, 'update_items_permissions_check'),
238 'args' => $this->get_funnel_id_arg(),
239 ),
240 )
241 );
242
243 register_rest_route(
244 $this->namespace,
245 '/' . $this->rest_base . '/(?P<funnel_id>\d+)/restore',
246 array(
247 array(
248 'methods' => WP_REST_Server::CREATABLE,
249 'callback' => array($this, 'restore_funnel'),
250 'permission_callback' => array($this, 'update_items_permissions_check'),
251 'args' => $this->get_funnel_id_arg(),
252 ),
253 )
254 );
255
256 register_rest_route(
257 $this->namespace,
258 '/' . $this->rest_base . '/(?P<funnel_id>\d+)',
259 array(
260 array(
261 'methods' => WP_REST_Server::DELETABLE,
262 'callback' => array($this, 'delete_funnel'),
263 'permission_callback' => array($this, 'update_items_permissions_check'),
264 'args' => $this->get_funnel_id_arg(),
265 ),
266 )
267 );
268
269 register_rest_route(
270 $this->namespace,
271 '/' . $this->rest_base . '/(?P<funnel_id>\d+)/export',
272 array(
273 array(
274 'methods' => WP_REST_Server::READABLE,
275 'callback' => array($this, 'export_funnel'),
276 'permission_callback' => array($this, 'get_items_permissions_check'),
277 'args' => $this->get_funnel_id_arg(),
278 ),
279 )
280 );
281
282 register_rest_route(
283 $this->namespace,
284 '/' . $this->rest_base . '/bulk',
285 array(
286 array(
287 'methods' => WP_REST_Server::CREATABLE,
288 'callback' => array($this, 'bulk_action'),
289 'permission_callback' => array($this, 'update_items_permissions_check'),
290 'args' => array(
291 'action' => array(
292 'required' => true,
293 'type' => 'string',
294 'enum' => array('trash', 'restore', 'delete', 'draft', 'publish'),
295 ),
296 'ids' => array(
297 'required' => true,
298 'type' => 'array',
299 'items' => array('type' => 'integer'),
300 ),
301 ),
302 ),
303 )
304 );
305 }
306
307
308 /**
309 * Shared `funnel_id` route argument.
310 *
311 * @return array
312 * @since 3.13.4
313 */
314 private function get_funnel_id_arg()
315 {
316 return array(
317 'funnel_id' => array(
318 'required' => true,
319 'type' => 'integer',
320 'sanitize_callback' => 'absint',
321 ),
322 );
323 }
324
325
326 /**
327 * One page of funnels, plus everything the screen's chrome needs.
328 *
329 * The counts and the free-plan limit travel with the listing on purpose:
330 * the tab headers and the "New Funnel" button both change with them, and a
331 * second round trip would let them disagree with the rows on screen.
332 *
333 * @param WP_REST_Request $request Request object.
334 *
335 * @return \WP_REST_Response
336 * @since 3.13.4
337 */
338 /**
339 * Create an empty funnel, optionally laid out from a layout skeleton.
340 *
341 * The REST twin of the `create-funnel` admin-ajax handler, which the legacy
342 * screens still post to. Both run the same module method, so the funnel is
343 * built one way whichever door it came through.
344 *
345 * @param WP_REST_Request $request Full data about the request.
346 *
347 * @return WP_REST_Response The module's own response, carrying `redirectUrl`.
348 * @since 3.13.5
349 */
350 public function create_funnel($request)
351 {
352 $module = Wpfnl::$instance->module_manager->get_admin_modules('create-funnel');
353
354 if (!$module) {
355 return new WP_Error(
356 'wpfunnels_rest_cannot_create',
357 __('Funnels cannot be created right now.', 'wpfnl'),
358 array('status' => 500)
359 );
360 }
361
362 $payload = array(
363 'funnelName' => $request->get_param('funnelName'),
364 'type' => $request->get_param('type'),
365 // The layout arrives as the nested skeleton the module reads to lay
366 // the canvas out, so it is passed through rather than sanitised
367 // field by field; an absent or empty one creates a bare funnel.
368 'selectedFunnelLayout' => $request->get_param('selectedFunnelLayout'),
369 /*
370 * A store checkout is created through this same route, and only
371 * this flag tells the module so: it stamps the funnel type and lays
372 * out the checkout/thank-you pair instead of a blank canvas. The
373 * legacy screens were recognised by their `?page=store_checkout`
374 * query string, which the React admin no longer has.
375 */
376 'is_store_checkout' => filter_var($request->get_param('is_store_checkout'), FILTER_VALIDATE_BOOLEAN),
377 );
378
379 return rest_ensure_response($module->create_funnel($payload));
380 }
381
382
383 public function get_funnels($request)
384 {
385 $page = max(1, (int) $request->get_param('page'));
386 $per_page = (int) $request->get_param('per_page');
387 $per_page = $per_page > 0 ? min($per_page, 100) : 25;
388 $search = (string) $request->get_param('search');
389
390 $funnel_status = (string) $request->get_param('funnel_status');
391
392 // The retired `status=trash` still means the same thing.
393 if ('trash' === $request->get_param('status')) {
394 $funnel_status = 'trash';
395 }
396
397 $post_status = in_array($funnel_status, array('publish', 'draft', 'trash'), true)
398 ? array($funnel_status)
399 : array('publish', 'draft', 'trash');
400
401 $orderby = (string) $request->get_param('orderby');
402 $order = 'asc' === $request->get_param('order') ? 'ASC' : 'DESC';
403
404 $args = array(
405 'post_type' => WPFNL_FUNNELS_POST_TYPE,
406 'post_status' => $post_status,
407 );
408
409 if ($search) {
410 $args['s'] = $search;
411 }
412
413 $exclude_store_checkout = $this->get_store_checkout_exclusion();
414
415 if ($exclude_store_checkout) {
416 $args['meta_query'] = $exclude_store_checkout; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
417 }
418
419 if (in_array($orderby, array('conversions', 'sales'), true)) {
420 list($posts, $total) = $this->query_by_analytics($args, $orderby, $order, $page, $per_page);
421 } else {
422 $query = new WP_Query(
423 array_merge(
424 $args,
425 array(
426 'posts_per_page' => $per_page,
427 'paged' => $page,
428 // WP_Query matches its order keys case-sensitively, and
429 // the post id column is the one spelled in capitals.
430 'orderby' => 'id' === $orderby ? 'ID' : $orderby,
431 'order' => $order,
432 )
433 )
434 );
435
436 $posts = $query->posts;
437 $total = (int) $query->found_posts;
438 }
439
440 $funnels = array();
441
442 foreach ($posts as $funnel) {
443 $funnels[] = $this->prepare_funnel($funnel);
444 }
445
446 $total_pages = $per_page > 0 ? (int) ceil($total / $per_page) : 0;
447
448 return rest_ensure_response(
449 array(
450 'success' => true,
451 'items' => $funnels,
452 'total' => $total,
453 'total_pages' => $total_pages,
454 'page' => $page,
455 'per_page' => $per_page,
456 'counts' => $this->get_counts(),
457 'meta' => $this->get_screen_meta(),
458 )
459 );
460 }
461
462
463 /**
464 * One page of funnels ordered by a number the posts table does not hold.
465 *
466 * Conversions and sales are aggregates over `wpfnl_stats`, so they cannot be
467 * an ORDER BY on a post query. The matching funnels are resolved first (ids
468 * only), their totals fetched in a single grouped query, and the ordering
469 * and paging done over that list — which also means funnels with no orders
470 * at all still sort, at zero, instead of dropping out of a join.
471 *
472 * @param array $args Base query arguments: post type, status, search
473 * and the Store Checkout exclusion.
474 * @param string $orderby `conversions` or `sales`.
475 * @param string $order `ASC` or `DESC`.
476 * @param int $page Page being asked for.
477 * @param int $per_page Page size.
478 *
479 * @return array `[ WP_Post[], int $total ]`.
480 * @since 3.13.4
481 */
482 private function query_by_analytics($args, $orderby, $order, $page, $per_page)
483 {
484 $ids = get_posts(
485 array_merge(
486 $args,
487 array(
488 'posts_per_page' => -1,
489 'fields' => 'ids',
490 'no_found_rows' => true,
491 )
492 )
493 );
494
495 if (!$ids) {
496 return array(array(), 0);
497 }
498
499 $totals = $this->get_analytics_map($ids);
500 $key = 'sales' === $orderby ? 'total_revenue' : 'total_orders';
501
502 usort(
503 $ids,
504 function ($left, $right) use ($totals, $key, $order) {
505 $a = isset($totals[$left][$key]) ? $totals[$left][$key] : 0;
506 $b = isset($totals[$right][$key]) ? $totals[$right][$key] : 0;
507
508 if ($a === $b) {
509 // Newest first among equals, so the zeroes are not shuffled
510 // into a different order on every request.
511 return $right - $left;
512 }
513
514 return 'ASC' === $order ? ($a < $b ? -1 : 1) : ($a > $b ? -1 : 1);
515 }
516 );
517
518 $total = count($ids);
519 $page_ids = array_slice($ids, ($page - 1) * $per_page, $per_page);
520
521 if (!$page_ids) {
522 return array(array(), $total);
523 }
524
525 $posts = get_posts(
526 array(
527 'post_type' => WPFNL_FUNNELS_POST_TYPE,
528 'post_status' => isset($args['post_status']) ? $args['post_status'] : 'any',
529 'post__in' => $page_ids,
530 'orderby' => 'post__in',
531 'posts_per_page' => count($page_ids),
532 'no_found_rows' => true,
533 'suppress_filters' => true,
534 )
535 );
536
537 return array($posts, $total);
538 }
539
540
541 /**
542 * Conversions and sales for a set of funnels, in one query.
543 *
544 * The per-row reader below answers for a single funnel and caches per
545 * funnel; ordering needs every matching funnel at once, and doing that one
546 * funnel at a time is a query per funnel.
547 *
548 * @param array $funnel_ids Funnel ids.
549 *
550 * @return array Map of funnel id to `[ 'total_orders' => int, 'total_revenue' => float ]`.
551 * @since 3.13.4
552 */
553 private function get_analytics_map($funnel_ids)
554 {
555 global $wpdb;
556
557 $map = array();
558
559 $stats_table = $wpdb->prefix . 'wpfnl_stats';
560 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $stats_table)); // phpcs:ignore
561
562 if (!$table_exists) {
563 return $map;
564 }
565
566 $ids = array_map('absint', (array) $funnel_ids);
567
568 if (!$ids) {
569 return $map;
570 }
571
572 // Safe to interpolate: every id has been through absint().
573 $placeholders = implode(',', $ids);
574
575 $rows = $wpdb->get_results(
576 $wpdb->prepare(
577 "SELECT funnel_id,
578 COUNT(DISTINCT id) AS total_orders,
579 COALESCE(SUM(total_sales), 0) AS total_revenue
580 FROM {$stats_table}
581 WHERE funnel_id IN ({$placeholders}) AND status IN (%s, %s)
582 GROUP BY funnel_id",
583 'completed',
584 'processing'
585 )
586 ); // phpcs:ignore
587
588 foreach ((array) $rows as $row) {
589 $map[(int) $row->funnel_id] = array(
590 'total_orders' => (int) $row->total_orders,
591 'total_revenue' => round((float) $row->total_revenue, 2),
592 );
593 }
594
595 return $map;
596 }
597
598
599 /**
600 * Meta query that keeps Store Checkout funnels out of this listing.
601 *
602 * Mirrors the legacy view: the remote-funnel build lists everything, every
603 * other install hides the Store Checkout funnel, which has its own screen.
604 *
605 * @return array Meta query, or an empty array when nothing is excluded.
606 * @since 3.13.4
607 */
608 private function get_store_checkout_exclusion()
609 {
610 if (defined('REMOTE_FUNNEL_VERSION')) {
611 return array();
612 }
613
614 return array(
615 'relation' => 'OR',
616 array(
617 'key' => '_wpfnl_funnel_type',
618 'compare' => 'NOT EXISTS',
619 ),
620 array(
621 'key' => '_wpfnl_funnel_type',
622 'value' => 'store_checkout',
623 'compare' => '!=',
624 ),
625 );
626 }
627
628
629 /**
630 * How many funnels each value of the status filter would find.
631 *
632 * @return array Counts keyed by filter value.
633 * @since 3.13.4
634 */
635 private function get_counts()
636 {
637 $publish = $this->count_funnels(array('publish'));
638 $draft = $this->count_funnels(array('draft'));
639 $trash = $this->count_funnels(array('trash'));
640
641 return array(
642 // What each value of the status filter would find, so the filter can
643 // show its own numbers without a request per option. `all` is the
644 // sum of the three below it, which is the only way the four numbers
645 // add up on screen.
646 'all' => $publish + $draft + $trash,
647 'publish' => $publish,
648 'draft' => $draft,
649 'trash' => $trash,
650 // Retired alias for the Live tab, which counted everything but the
651 // trash. Kept for older bundles.
652 'live' => $publish + $draft,
653 );
654 }
655
656
657 /**
658 * Count funnels in the given post statuses, Store Checkout excluded.
659 *
660 * @param array $post_status Post statuses to count.
661 *
662 * @return int
663 * @since 3.13.4
664 */
665 private function count_funnels($post_status)
666 {
667 $args = array(
668 'post_type' => WPFNL_FUNNELS_POST_TYPE,
669 'post_status' => $post_status,
670 'posts_per_page' => -1,
671 'fields' => 'ids',
672 'no_found_rows' => true,
673 'suppress_filters' => true,
674 );
675
676 $exclusion = $this->get_store_checkout_exclusion();
677
678 if ($exclusion) {
679 $args['meta_query'] = $exclusion; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
680 }
681
682 return count(get_posts($args));
683 }
684
685
686 /**
687 * State the screen's chrome depends on: the plan, the funnel allowance, and
688 * the URLs of the journeys that still live outside the React admin.
689 *
690 * @return array
691 * @since 3.13.4
692 */
693 private function get_screen_meta()
694 {
695 $is_pro_active = (bool) apply_filters('wpfunnels/is_pro_license_activated', false);
696 $funnel_count = (int) Wpfnl_functions::count_non_store_checkout_funnels();
697 $limit_reached = !$is_pro_active && $funnel_count >= self::FREE_FUNNEL_LIMIT;
698
699 return array(
700 'isProActive' => $is_pro_active,
701 'funnelCount' => $funnel_count,
702 'maxFunnels' => $is_pro_active ? -1 : self::FREE_FUNNEL_LIMIT,
703 'limitReached' => $limit_reached,
704 'isWooActive' => Wpfnl_functions::is_wc_active(),
705 'isLmsActive' => Wpfnl_functions::is_lms_addon_active(),
706 // Where "New Funnel" goes: the template library opens its own modal
707 // when it is loaded with this page in the query string.
708 // The template library is a route of the React admin now; the legacy
709 // page slug only redirects here, so link straight at the route.
710 'createFunnelUrl' => esc_url_raw(add_query_arg(array('page' => WPFNL_MAIN_PAGE_SLUG), admin_url('admin.php'))) . '#/funnel-templates',
711 'aiFunnelUrl' => esc_url_raw(add_query_arg(array('page' => WPFNL_AI_FUNNEL_PAGE_SLUG), admin_url('admin.php'))),
712 'upgradeUrl' => 'https://getwpfunnels.com/pricing/',
713 'canCreateWithAi' => $is_pro_active,
714 'canBulkExport' => $is_pro_active && defined('WPFNL_PRO_VERSION') && version_compare(WPFNL_PRO_VERSION, '1.9.3', '>='),
715 );
716 }
717
718
719 /**
720 * Shape one funnel row.
721 *
722 * @param \WP_Post $funnel Funnel post.
723 *
724 * @return array
725 * @since 3.13.4
726 */
727 private function prepare_funnel($funnel)
728 {
729 $funnel_id = (int) $funnel->ID;
730 $type = $this->get_funnel_type($funnel_id);
731 $steps = $this->get_steps_order($funnel_id);
732 $analytics = $this->get_funnel_analytics($funnel_id);
733 $is_trash = 'trash' === $funnel->post_status;
734
735 /*
736 * A funnel whose stack is switched off must not be opened: the legacy
737 * row greyed itself out and said why, and the reason is resolved here
738 * because only PHP knows whether WooCommerce or the LMS addon is
739 * running.
740 */
741 $disabled_reason = '';
742
743 if ('wc' === $type['key'] && !Wpfnl_functions::is_wc_active()) {
744 $disabled_reason = __('To run/edit this funnel, please Activate WooCommerce.', 'wpfnl');
745 } elseif ('lms' === $type['key'] && !Wpfnl_functions::is_lms_addon_active()) {
746 $disabled_reason = __('To run/edit this funnel, please Activate LearnDash & WPFunnels Pro - LMS Funnel', 'wpfnl');
747 }
748
749 return array(
750 'id' => $funnel_id,
751 'name' => $funnel->post_title ? $funnel->post_title : __('(no title)', 'wpfnl'),
752 'status' => $funnel->post_status,
753 // `get_formatted_status()` knows publish and draft; the trash tab is
754 // this screen's own state and needs its own word.
755 'statusLabel' => $is_trash
756 ? __('Trashed', 'wpfnl')
757 : Wpfnl_functions::get_formatted_status($funnel->post_status),
758 'steps' => count($steps),
759 'stepsLabel' => count($steps) . ' ' . Wpfnl_functions::get_formatted_data_with_phrase(count($steps), 'step', 'steps'),
760 'type' => $type['key'],
761 'typeLabel' => $type['label'],
762 'builder' => Wpfnl_functions::get_page_builder_by_step_id($funnel_id),
763 'createdAt' => $funnel->post_date_gmt,
764 'modifiedAt' => $funnel->post_modified_gmt,
765 // Rendered server-side so the row reads "5 hours ago" in the site's
766 // own locale rather than the browser's.
767 'modifiedHuman' => $this->get_relative_time($funnel->post_modified_gmt),
768 // The relative wording loses precision as it ages ("4 days ago"),
769 // so the exact moment rides along for the hover bubble.
770 'modifiedExact' => $this->get_absolute_time($funnel->post_modified_gmt),
771 'conversions' => $analytics['total_orders'],
772 'sales' => $analytics['total_revenue'],
773 'editUrl' => $is_trash ? '' : $this->get_edit_url($funnel_id),
774 'viewUrl' => $is_trash ? '' : $this->get_view_url($funnel_id),
775 'viewDisabled' => (bool) apply_filters('wpfunnels/disable_funnel_view_button', false, $funnel_id),
776 'disabled' => (bool) $disabled_reason,
777 'disabledReason' => $disabled_reason,
778 );
779 }
780
781
782 /**
783 * Steps order meta of a funnel, always as an array.
784 *
785 * @param int $funnel_id Funnel id.
786 *
787 * @return array
788 * @since 3.13.4
789 */
790 private function get_steps_order($funnel_id)
791 {
792 $steps = get_post_meta($funnel_id, '_steps_order', true);
793 $steps = Wpfnl_functions::unserialize_array_data($steps);
794
795 return is_array($steps) ? $steps : array();
796 }
797
798
799 /**
800 * Funnel type and its label, with the same fallbacks the legacy row used.
801 *
802 * @param int $funnel_id Funnel id.
803 *
804 * @return array `[ 'key' => string, 'label' => string ]`.
805 * @since 3.13.4
806 */
807 private function get_funnel_type($funnel_id)
808 {
809 $type = get_post_meta($funnel_id, '_wpfnl_funnel_type', true);
810
811 if ('lead' === $type) {
812 return array('key' => 'lead', 'label' => __('Lead', 'wpfnl'));
813 }
814
815 if ('lms' === $type) {
816 return array('key' => 'lms', 'label' => __('LMS', 'wpfnl'));
817 }
818
819 if (defined('WC_PLUGIN_FILE')) {
820 return array('key' => 'wc', 'label' => __('Woo', 'wpfnl'));
821 }
822
823 return array('key' => 'lead', 'label' => __('Lead', 'wpfnl'));
824 }
825
826
827 /**
828 * Canvas URL of a funnel.
829 *
830 * @param int $funnel_id Funnel id.
831 *
832 * @return string
833 * @since 3.13.4
834 */
835 private function get_edit_url($funnel_id)
836 {
837 return esc_url_raw(
838 add_query_arg(
839 array(
840 'page' => WPFNL_EDIT_FUNNEL_SLUG,
841 'id' => $funnel_id,
842 'step_id' => Wpfnl_functions::get_first_step($funnel_id),
843 ),
844 admin_url('admin.php')
845 )
846 );
847 }
848
849
850 /**
851 * Public URL of a funnel's first step, UTM parameters included.
852 *
853 * @param int $funnel_id Funnel id.
854 *
855 * @return string
856 * @since 3.13.4
857 */
858 private function get_view_url($funnel_id)
859 {
860 Wpfnl_functions::generate_first_step($funnel_id);
861 $first_step_id = Wpfnl_functions::get_first_step($funnel_id);
862
863 if (!$first_step_id) {
864 return '';
865 }
866
867 $view_link = apply_filters(
868 'wpfunnels/modify_funnel_view_link',
869 get_the_permalink($first_step_id),
870 $first_step_id,
871 $funnel_id
872 );
873
874 $utm_settings = Wpfnl_functions::get_funnel_utm_settings($funnel_id);
875
876 if (!empty($utm_settings['utm_enable']) && 'on' === $utm_settings['utm_enable']) {
877 unset($utm_settings['utm_enable']);
878 $view_link = strtolower(add_query_arg($utm_settings, $view_link));
879 }
880
881 return esc_url_raw($view_link);
882 }
883
884
885 /**
886 * Conversions and sales of a funnel.
887 *
888 * Reads the same `wpfnl_stats` aggregate the canvas header shows, and stops
889 * at zero when the table is not there — a site that has never tracked an
890 * order has no stats table, and that is not an error worth failing the
891 * whole listing over.
892 *
893 * @param int $funnel_id Funnel id.
894 *
895 * @return array `[ 'total_orders' => int, 'total_revenue' => float ]`.
896 * @since 3.13.4
897 */
898 private function get_funnel_analytics($funnel_id)
899 {
900 global $wpdb;
901
902 $analytics = array('total_orders' => 0, 'total_revenue' => 0);
903
904 $cache_key = 'wpfnl_funnel_list_analytics_' . $funnel_id;
905 $cached = wp_cache_get($cache_key, 'wpfnl');
906
907 if (false !== $cached) {
908 return $cached;
909 }
910
911 $stats_table = $wpdb->prefix . 'wpfnl_stats';
912 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $stats_table)); // phpcs:ignore
913
914 if (!$table_exists) {
915 return $analytics;
916 }
917
918 $row = $wpdb->get_row(
919 $wpdb->prepare(
920 "SELECT COUNT(DISTINCT id) AS total_orders, COALESCE(SUM(total_sales), 0) AS total_revenue
921 FROM {$stats_table}
922 WHERE funnel_id = %d AND status IN (%s, %s)",
923 $funnel_id,
924 'completed',
925 'processing'
926 )
927 ); // phpcs:ignore
928
929 if ($row) {
930 $analytics['total_orders'] = (int) $row->total_orders;
931 $analytics['total_revenue'] = round((float) $row->total_revenue, 2);
932 }
933
934 wp_cache_set($cache_key, $analytics, 'wpfnl', 5 * MINUTE_IN_SECONDS);
935
936 return $analytics;
937 }
938
939
940 /**
941 * "5 hours ago" for a GMT date string.
942 *
943 * @param string $gmt_date Post date in GMT.
944 *
945 * @return string
946 * @since 3.13.4
947 */
948 private function get_relative_time($gmt_date)
949 {
950 $timestamp = strtotime($gmt_date . ' GMT');
951
952 if (!$timestamp) {
953 return '';
954 }
955
956 /* translators: %s: human readable time difference. */
957 return sprintf(__('%s ago', 'wpfnl'), human_time_diff($timestamp, time()));
958 }
959
960
961 /**
962 * Exact modification time of a funnel, in the site's own timezone.
963 *
964 * Shown on hover beside the relative wording, which is readable but vague
965 * once a funnel is more than a day old.
966 *
967 * @param string $gmt_date Date in GMT.
968 *
969 * @return string
970 * @since 3.13.4
971 */
972 private function get_absolute_time($gmt_date)
973 {
974 if (empty($gmt_date) || '0000-00-00 00:00:00' === $gmt_date) {
975 return '';
976 }
977
978 return get_date_from_gmt($gmt_date, 'Y-m-d H:i:s');
979 }
980
981
982 /**
983 * Whether the named trashed funnels may come back.
984 *
985 * Restoring is how a free install would otherwise walk past its three-funnel
986 * allowance, so the module refuses quietly below that line. It checks this
987 * privately and answers "success" either way, which would have this endpoint
988 * reporting a restore that never happened — hence the same check here, where
989 * it can be answered with an error the screen can show.
990 *
991 * A store checkout has its own allowance of three, counted separately: these
992 * routes serve the Store Checkout screen too (see StoreCheckoutController),
993 * and measuring a store checkout against the funnel count refuses restores
994 * that are within its own allowance — and lets through ones that are not.
995 *
996 * @param array $funnel_ids Funnels about to be restored. An empty list is
997 * read as ordinary funnels, which is what the
998 * allowance meant before store checkouts had one.
999 *
1000 * @return bool
1001 * @since 3.13.4
1002 */
1003 private function can_restore($funnel_ids = array())
1004 {
1005 if (Wpfnl_functions::is_pro_license_activated()) {
1006 return true;
1007 }
1008
1009 $ids = array_filter(array_map('absint', (array) $funnel_ids));
1010
1011 $has_funnel = !$ids;
1012 $has_store_checkout = false;
1013
1014 foreach ($ids as $id) {
1015 if ('store_checkout' === get_post_meta($id, '_wpfnl_funnel_type', true)) {
1016 $has_store_checkout = true;
1017 } else {
1018 $has_funnel = true;
1019 }
1020 }
1021
1022 if ($has_funnel && (int) Wpfnl_functions::count_non_store_checkout_funnels(array('publish', 'draft')) >= self::FREE_FUNNEL_LIMIT) {
1023 return false;
1024 }
1025
1026 if ($has_store_checkout && (int) Wpfnl_functions::count_store_checkout_funnels(array('publish', 'draft')) >= self::FREE_FUNNEL_LIMIT) {
1027 return false;
1028 }
1029
1030 return true;
1031 }
1032
1033
1034 /**
1035 * The error a restore is refused with.
1036 *
1037 * @param array $funnel_ids Funnels the restore was refused for, so the
1038 * message names the allowance that was reached.
1039 *
1040 * @return WP_Error
1041 * @since 3.13.4
1042 */
1043 private function get_restore_limit_error($funnel_ids = array())
1044 {
1045 $ids = array_filter(array_map('absint', (array) $funnel_ids));
1046 $is_store_checkout = (bool) $ids;
1047
1048 foreach ($ids as $id) {
1049 if ('store_checkout' !== get_post_meta($id, '_wpfnl_funnel_type', true)) {
1050 $is_store_checkout = false;
1051 break;
1052 }
1053 }
1054
1055 return new WP_Error(
1056 'wpfunnels_funnel_limit_reached',
1057 $is_store_checkout
1058 ? sprintf(
1059 /* translators: %d: number of store checkouts allowed on the free plan. */
1060 __('The free plan allows %d live store checkouts. Trash a store checkout or upgrade to Pro before restoring this one.', 'wpfnl'),
1061 self::FREE_FUNNEL_LIMIT
1062 )
1063 : sprintf(
1064 /* translators: %d: number of funnels allowed on the free plan. */
1065 __('The free plan allows %d live funnels. Trash a funnel or upgrade to Pro before restoring this one.', 'wpfnl'),
1066 self::FREE_FUNNEL_LIMIT
1067 ),
1068 array('status' => 403)
1069 );
1070 }
1071
1072
1073 /**
1074 * Duplicate a funnel.
1075 *
1076 * @param WP_REST_Request $request Request object.
1077 *
1078 * @return \WP_REST_Response|WP_Error
1079 * @since 3.13.4
1080 */
1081 public function duplicate_funnel($request)
1082 {
1083 $funnel_id = absint($request->get_param('funnel_id'));
1084 $response = FunnelModule::instance()->clone_funnel(array('funnel_id' => $funnel_id));
1085
1086 if (empty($response['success'])) {
1087 return new WP_Error(
1088 'wpfunnels_duplicate_failed',
1089 !empty($response['message']) ? $response['message'] : __('Could not duplicate this funnel.', 'wpfnl'),
1090 array('status' => 500)
1091 );
1092 }
1093
1094 return rest_ensure_response(
1095 array(
1096 'success' => true,
1097 'message' => __('Funnel has been duplicated.', 'wpfnl'),
1098 )
1099 );
1100 }
1101
1102
1103 /**
1104 * Publish or unpublish a funnel and its steps.
1105 *
1106 * @param WP_REST_Request $request Request object.
1107 *
1108 * @return \WP_REST_Response
1109 * @since 3.13.4
1110 */
1111 public function update_status($request)
1112 {
1113 $response = FunnelModule::instance()->update_funnel_status(
1114 array(
1115 'funnel_id' => absint($request->get_param('funnel_id')),
1116 'status' => sanitize_text_field($request->get_param('status')),
1117 )
1118 );
1119
1120 return rest_ensure_response($response);
1121 }
1122
1123
1124 /**
1125 * Move a funnel to trash.
1126 *
1127 * @param WP_REST_Request $request Request object.
1128 *
1129 * @return \WP_REST_Response
1130 * @since 3.13.4
1131 */
1132 public function trash_funnel($request)
1133 {
1134 FunnelModule::instance()->trash_funnel(array('funnel_id' => absint($request->get_param('funnel_id'))));
1135
1136 return rest_ensure_response(
1137 array(
1138 'success' => true,
1139 'message' => __('Funnel has been moved to trash.', 'wpfnl'),
1140 )
1141 );
1142 }
1143
1144
1145 /**
1146 * Restore a trashed funnel.
1147 *
1148 * @param WP_REST_Request $request Request object.
1149 *
1150 * @return \WP_REST_Response
1151 * @since 3.13.4
1152 */
1153 public function restore_funnel($request)
1154 {
1155 $funnel_id = absint($request->get_param('funnel_id'));
1156
1157 if (!$this->can_restore(array($funnel_id))) {
1158 return $this->get_restore_limit_error(array($funnel_id));
1159 }
1160
1161 FunnelModule::instance()->restore_funnel(array('funnel_id' => $funnel_id));
1162
1163 return rest_ensure_response(
1164 array(
1165 'success' => true,
1166 'message' => __('Funnel has been restored.', 'wpfnl'),
1167 )
1168 );
1169 }
1170
1171
1172 /**
1173 * Delete a funnel and its steps for good.
1174 *
1175 * @param WP_REST_Request $request Request object.
1176 *
1177 * @return \WP_REST_Response
1178 * @since 3.13.4
1179 */
1180 public function delete_funnel($request)
1181 {
1182 FunnelModule::instance()->delete_marked_funnels(
1183 array('ids' => array(absint($request->get_param('funnel_id'))))
1184 );
1185
1186 return rest_ensure_response(
1187 array(
1188 'success' => true,
1189 'message' => __('Funnel has been deleted.', 'wpfnl'),
1190 )
1191 );
1192 }
1193
1194
1195 /**
1196 * Export one funnel as a JSON payload.
1197 *
1198 * The REST twin of the `wpfnl-export-funnel` admin-ajax action the kebab
1199 * menu used to post to, and the same exporter behind it — unlike the bulk
1200 * route, this one exports a funnel whatever its post status, so a draft can
1201 * be exported too.
1202 *
1203 * @param WP_REST_Request $request Request object.
1204 *
1205 * @return \WP_REST_Response|WP_Error
1206 * @since 3.13.4
1207 */
1208 public function export_funnel($request)
1209 {
1210 $funnel_id = absint($request->get_param('funnel_id'));
1211
1212 /*
1213 * The exporter answers "success" for any id it is handed, so the funnel
1214 * has to be confirmed here — otherwise a stale row exports a file
1215 * describing nothing.
1216 */
1217 if (WPFNL_FUNNELS_POST_TYPE !== get_post_type($funnel_id)) {
1218 return new WP_Error(
1219 'wpfunnels_funnel_not_found',
1220 __('That funnel no longer exists.', 'wpfnl'),
1221 array('status' => 404)
1222 );
1223 }
1224
1225 $response = Wpfnl_Export::getInstance()->export_funnel(array('funnel_id' => $funnel_id));
1226
1227 if (empty($response['success'])) {
1228 return new WP_Error(
1229 'wpfunnels_export_failed',
1230 __('There is nothing to export for this funnel.', 'wpfnl'),
1231 array('status' => 404)
1232 );
1233 }
1234
1235 return rest_ensure_response($response);
1236 }
1237
1238
1239 /**
1240 * Apply one action to several funnels.
1241 *
1242 * @param WP_REST_Request $request Request object.
1243 *
1244 * @return \WP_REST_Response|WP_Error
1245 * @since 3.13.4
1246 */
1247 public function bulk_action($request)
1248 {
1249 $ids = array_filter(array_map('absint', (array) $request->get_param('ids')));
1250 $action = sanitize_text_field($request->get_param('action'));
1251
1252 if (!$ids) {
1253 return new WP_Error(
1254 'wpfunnels_no_funnel_selected',
1255 __('No funnel was selected.', 'wpfnl'),
1256 array('status' => 400)
1257 );
1258 }
1259
1260 $module = FunnelModule::instance();
1261
1262 switch ($action) {
1263 case 'trash':
1264 $module->trash_marked_funnels(array('ids' => $ids));
1265 $message = __('Selected funnels have been moved to trash.', 'wpfnl');
1266 break;
1267
1268 case 'restore':
1269 if (!$this->can_restore($ids)) {
1270 return $this->get_restore_limit_error($ids);
1271 }
1272
1273 $module->restore_marked_funnels(array('ids' => $ids));
1274 $message = __('Selected funnels have been restored.', 'wpfnl');
1275 break;
1276
1277 case 'draft':
1278 case 'publish':
1279 /*
1280 * One funnel at a time on purpose: the status change carries the
1281 * funnel's steps with it, which the module already knows how to
1282 * do — a bulk version would be the same loop with its own copy
1283 * of that rule.
1284 */
1285 foreach ($ids as $id) {
1286 $module->update_funnel_status(array('funnel_id' => $id, 'status' => $action));
1287 }
1288
1289 $message = 'draft' === $action
1290 ? __('Selected funnels have been moved to draft.', 'wpfnl')
1291 : __('Selected funnels have been published.', 'wpfnl');
1292 break;
1293
1294 default:
1295 $module->delete_marked_funnels(array('ids' => $ids));
1296 $message = __('Selected funnels have been deleted.', 'wpfnl');
1297 break;
1298 }
1299
1300 return rest_ensure_response(
1301 array(
1302 'success' => true,
1303 'message' => $message,
1304 )
1305 );
1306 }
1307
1308
1309 /**
1310 * Prepare a single item for response.
1311 *
1312 * @param mixed $item Item to prepare.
1313 * @param WP_REST_Request $request Request object.
1314 *
1315 * @return \WP_REST_Response
1316 * @since 3.13.4
1317 */
1318 public function prepare_item_for_response($item, $request)
1319 {
1320 return rest_ensure_response($this->add_additional_fields_to_object($item, $request));
1321 }
1322 }
1323