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 / abilities / class-cartflows-ability-runtime.php

class-cartflows-ability-runtime.php in CartFlows – Funnel Builder & Checkout Plugin for WooCommerce 3.3.0, at abilities/class-cartflows-ability-runtime.php

3,498 lines 112.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * CartFlows Ability Runtime.
4 *
5 * Execute callbacks and helper methods for all registered CartFlows abilities.
6 *
7 * @package CartFlows
8 */
9
10 // Exit if accessed directly.
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use CartflowsAdmin\AdminCore\Inc\AdminHelper;
16 use CartflowsAdmin\AdminCore\Inc\FlowMeta;
17 use CartflowsAdmin\AdminCore\Inc\StepMeta;
18
19 /**
20 * Class Cartflows_Ability_Runtime
21 *
22 * Handles category registration, ability registration, and all execute callbacks.
23 */
24 class Cartflows_Ability_Runtime {
25
26 /**
27 * Parsed input values for the current execution.
28 *
29 * @var array|false
30 */
31 protected $input = false;
32
33 // ============================================================
34 // Registration
35 // ============================================================
36
37 /**
38 * Register the CartFlows ability category.
39 *
40 * Hooked to: wp_abilities_api_categories_init
41 *
42 * @return void
43 */
44 public function register_categories() {
45
46 wp_register_ability_category(
47 'cartflows',
48 array(
49 'label' => __( 'CartFlows', 'cartflows' ),
50 'description' => __( 'Abilities for CartFlows — funnel and step management.', 'cartflows' ),
51 )
52 );
53 }
54
55 /**
56 * Register all CartFlows abilities.
57 *
58 * Hooked to: wp_abilities_api_init
59 *
60 * @return void
61 */
62 public function register() {
63
64 $abilities = Cartflows_Ability_Config::get_abilities();
65
66 foreach ( $abilities as $ability_name => $ability ) {
67 wp_register_ability(
68 $ability_name,
69 array(
70 'label' => $ability['label'],
71 'description' => $ability['description'],
72 'category' => $ability['category'],
73 'input_schema' => $ability['input_schema'],
74 'output_schema' => $ability['output_schema'],
75 'execute_callback' => $ability['execute_callback'],
76 'permission_callback' => $ability['permission_callback'],
77 'meta' => $ability['meta'],
78 )
79 );
80 }
81 }
82
83 // ============================================================
84 // Execute Callbacks — Flow Read
85 // ============================================================
86
87 /**
88 * List funnels with pagination and optional status/search filters.
89 *
90 * @param array $input Validated input.
91 * @return array
92 */
93 public function list_flows( $input ) {
94
95 try {
96 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'list-flows' );
97
98 $status = $this->input_get( 'status' );
99 $search = $this->input_get( 'search' );
100 $page = $this->clamp_page( $this->input_get( 'paged' ) );
101 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
102
103 // Exclude the store checkout flow.
104 $store_checkout_id = intval( \Cartflows_Helper::get_global_setting( '_cartflows_store_checkout' ) );
105
106 $args = array(
107 'post_type' => CARTFLOWS_FLOW_POST_TYPE,
108 'post_status' => $status,
109 'posts_per_page' => $per_page,
110 'paged' => $page,
111 'orderby' => 'ID',
112 'order' => 'DESC',
113 );
114
115 if ( ! empty( $search ) ) {
116 $args['s'] = $search;
117 }
118
119 if ( $store_checkout_id > 0 ) {
120 $args['post__not_in'] = array( $store_checkout_id ); //phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
121 }
122
123 $query = new \WP_Query( $args );
124
125 $flows = array();
126
127 if ( $query->have_posts() ) {
128 while ( $query->have_posts() ) {
129 $query->the_post();
130 global $post;
131
132 $flows[] = array(
133 'id' => intval( $post->ID ),
134 'title' => esc_html( $post->post_title ),
135 'status' => esc_html( $post->post_status ),
136 'date_created' => esc_html( $post->post_date ),
137 'date_modified' => esc_html( $post->post_modified ),
138 'url_view' => esc_url( get_permalink( $post->ID ) ),
139 'url_edit' => esc_url( admin_url( 'admin.php?page=cartflows&path=flows&action=wcf-edit-flow&flow_id=' . $post->ID ) ),
140 'flow_test_mode' => ( 'yes' === wcf()->options->get_flow_meta_value( $post->ID, 'wcf-testing' ) ),
141 );
142 }
143 wp_reset_postdata();
144 }
145
146 $total = intval( $query->found_posts );
147 $total_pages = ( $per_page > 0 ) ? intval( ceil( $total / $per_page ) ) : 1;
148
149 return array(
150 'flows' => $flows,
151 'total' => $total,
152 'total_pages' => $total_pages,
153 'page' => $page,
154 );
155
156 } catch ( \Exception $e ) {
157 return $this->error( $e );
158 }
159 }
160
161 /**
162 * Get a single funnel by ID with full step list, settings, and links.
163 *
164 * @param array $input Validated input.
165 * @return array
166 * @throws \Exception On failure.
167 */
168 public function get_flow( $input ) {
169
170 try {
171 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-flow' );
172
173 $flow_id = $this->input_get( 'id' );
174
175 if ( 0 === $flow_id ) {
176 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
177 }
178
179 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
180 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
181 }
182
183 $meta_options = AdminHelper::get_flow_meta_options( $flow_id );
184 $steps_raw = AdminHelper::prepare_step_data( $flow_id, $meta_options );
185 $settings_data = FlowMeta::get_meta_settings( $flow_id );
186
187 $steps = array();
188 if ( is_array( $steps_raw ) ) {
189 foreach ( $steps_raw as $step ) {
190 $step_id = isset( $step['id'] ) ? intval( $step['id'] ) : 0;
191 $steps[] = array(
192 'id' => $step_id,
193 'title' => esc_html( isset( $step['title'] ) ? $step['title'] : get_the_title( $step_id ) ),
194 'type' => esc_html( isset( $step['type'] ) ? $step['type'] : '' ),
195 'status' => esc_html( get_post_status( $step_id ) ),
196 'is_disabled' => ( 'yes' === get_post_meta( $step_id, 'wcf-disable-step', true ) ),
197 'url' => esc_url( get_permalink( $step_id ) ),
198 );
199 }
200 }
201
202 return array(
203 'id' => intval( $flow_id ),
204 'title' => esc_html( get_the_title( $flow_id ) ),
205 'slug' => esc_html( get_post_field( 'post_name', $flow_id, 'edit' ) ),
206 'status' => esc_html( get_post_status( $flow_id ) ),
207 'url_view' => esc_url( get_permalink( $flow_id ) ),
208 'url_edit' => esc_url( admin_url( 'admin.php?page=cartflows&path=flows&action=wcf-edit-flow&flow_id=' . $flow_id ) ),
209 'steps' => $steps,
210 'settings_data' => $settings_data,
211 );
212
213 } catch ( \Exception $e ) {
214 return $this->error( $e );
215 }
216 }
217
218 /**
219 * Get a single step by ID with type, settings, and page-builder links.
220 *
221 * @param array $input Validated input.
222 * @return array
223 * @throws \Exception On failure.
224 */
225 public function get_step( $input ) {
226
227 try {
228 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-step' );
229
230 $step_id = $this->input_get( 'id' );
231
232 if ( 0 === $step_id ) {
233 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
234 }
235
236 if ( CARTFLOWS_STEP_POST_TYPE !== get_post_type( $step_id ) ) {
237 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
238 }
239
240 $meta_options = AdminHelper::get_step_meta_options( $step_id );
241 $step_type = isset( $meta_options['type'] ) ? $meta_options['type'] : '';
242 $flow_id = intval( get_post_meta( $step_id, 'wcf-flow-id', true ) );
243
244 $settings_data = StepMeta::get_meta_settings( $step_id, $step_type );
245
246 return array(
247 'id' => intval( $step_id ),
248 'title' => esc_html( get_the_title( $step_id ) ),
249 'type' => esc_html( $step_type ),
250 'flow_id' => $flow_id,
251 'flow_title' => esc_html( get_the_title( $flow_id ) ),
252 'url_view' => esc_url( get_permalink( $step_id ) ),
253 'url_edit' => esc_url( get_edit_post_link( $step_id, 'edit' ) ),
254 'url_page_builder' => esc_url( (string) \Cartflows_Helper::get_page_builder_edit_link( $step_id ) ),
255 'settings_data' => isset( $settings_data['settings'] ) ? $settings_data['settings'] : array(),
256 );
257
258 } catch ( \Exception $e ) {
259 return $this->error( $e );
260 }
261 }
262
263 // ============================================================
264 // Execute Callbacks — Analytics
265 // ============================================================
266
267 /**
268 * Get flow analytics: revenue and order data for a date range.
269 *
270 * @param array $input Validated input.
271 * @return array
272 * @throws \Exception On failure.
273 */
274 public function get_flow_stats( $input ) {
275
276 try {
277 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-flow-stats' );
278
279 if ( ! wcf()->is_woo_active ) {
280 throw new \Exception( esc_html__( 'WooCommerce is required for analytics.', 'cartflows' ) );
281 }
282
283 $date_from = $this->input_get( 'date_from' );
284 $date_to = $this->input_get( 'date_to' );
285 $flow_id_raw = $this->input_get( 'flow_id' );
286 $flow_id = intval( $flow_id_raw );
287
288 // Validate flow if specified.
289 if ( $flow_id > 0 && CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
290 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
291 }
292
293 $flow_id_param = ( $flow_id > 0 ) ? $flow_id : '';
294
295 $earnings_data = AdminHelper::get_earnings( $date_from, $date_to, $flow_id_param, 'funnels', '' );
296
297 // Collect recent orders (last 4 CartFlows orders).
298 $recent_orders_raw = wc_get_orders(
299 array(
300 'limit' => 4,
301 'orderby' => 'date',
302 'order' => 'DESC',
303 'meta_key' => '_wcf_flow_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
304 'meta_compare' => 'EXISTS',
305 )
306 );
307
308 $recent_orders = array();
309 foreach ( $recent_orders_raw as $order ) {
310 $recent_orders[] = array(
311 'order_id' => intval( $order->get_id() ),
312 'customer_name' => esc_html( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ),
313 'customer_email' => sanitize_email( $order->get_billing_email() ),
314 'order_total' => esc_html( get_woocommerce_currency_symbol( $order->get_currency() ) . $order->get_total() ),
315 'order_status' => esc_html( ucfirst( $order->get_status() ) ),
316 'order_date' => esc_html( wc_format_datetime( $order->get_date_created(), 'Y-m-d' ) ),
317 );
318 }
319
320 return array(
321 'flow_stats' => is_array( $earnings_data ) ? $earnings_data : array(),
322 'recent_orders' => $recent_orders,
323 );
324
325 } catch ( \Exception $e ) {
326 return $this->error( $e );
327 }
328 }
329
330 // ============================================================
331 // Execute Callbacks — Flow Lifecycle
332 // ============================================================
333
334 /**
335 * Publish a funnel and all its steps.
336 *
337 * @param array $input Validated input.
338 * @return array
339 * @throws \Exception On failure.
340 */
341 public function publish_flow( $input ) {
342
343 try {
344 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'publish-flow' );
345
346 $flow_id = $this->input_get( 'id' );
347
348 if ( 0 === $flow_id ) {
349 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
350 }
351
352 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
353 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
354 }
355
356 $this->set_flow_status( $flow_id, 'publish' );
357
358 return array(
359 'id' => intval( $flow_id ),
360 'status' => 'publish',
361 'message' => esc_html__( 'Funnel published successfully.', 'cartflows' ),
362 );
363
364 } catch ( \Exception $e ) {
365 return $this->error( $e );
366 }
367 }
368
369 /**
370 * Unpublish (draft) a funnel and all its steps.
371 *
372 * @param array $input Validated input.
373 * @return array
374 * @throws \Exception On failure.
375 */
376 public function unpublish_flow( $input ) {
377
378 try {
379 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'unpublish-flow' );
380
381 $flow_id = $this->input_get( 'id' );
382
383 if ( 0 === $flow_id ) {
384 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
385 }
386
387 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
388 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
389 }
390
391 $this->set_flow_status( $flow_id, 'draft' );
392
393 return array(
394 'id' => intval( $flow_id ),
395 'status' => 'draft',
396 'message' => esc_html__( 'Funnel unpublished (set to draft).', 'cartflows' ),
397 );
398
399 } catch ( \Exception $e ) {
400 return $this->error( $e );
401 }
402 }
403
404 /**
405 * Clone a funnel with all its steps and settings.
406 *
407 * @param array $input Validated input.
408 * @return array
409 * @throws \Exception On failure.
410 */
411 public function clone_flow( $input ) {
412
413 try {
414 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'clone-flow' );
415
416 $flow_id = $this->input_get( 'id' );
417
418 if ( 0 === $flow_id ) {
419 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
420 }
421
422 $post = get_post( $flow_id );
423
424 if ( ! $post || CARTFLOWS_FLOW_POST_TYPE !== $post->post_type ) {
425 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
426 }
427
428 global $wpdb;
429
430 $current_user = wp_get_current_user();
431 $new_post_author = intval( $current_user->ID );
432
433 // Insert the cloned flow post.
434 $new_flow_id = wp_insert_post(
435 array(
436 'comment_status' => $post->comment_status,
437 'ping_status' => $post->ping_status,
438 'post_author' => $new_post_author,
439 'post_content' => $post->post_content,
440 'post_excerpt' => $post->post_excerpt,
441 'post_name' => $post->post_name,
442 'post_parent' => $post->post_parent,
443 'post_password' => $post->post_password,
444 'post_status' => $post->post_status,
445 'post_title' => $post->post_title . ' Clone',
446 'post_type' => $post->post_type,
447 'to_ping' => $post->to_ping,
448 'menu_order' => $post->menu_order,
449 )
450 );
451
452 if ( is_wp_error( $new_flow_id ) || ! $new_flow_id ) {
453 throw new \Exception( esc_html__( 'Failed to create funnel clone.', 'cartflows' ) );
454 }
455
456 // Copy taxonomies.
457 foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) {
458 $post_terms = wp_get_object_terms( $flow_id, $taxonomy, array( 'fields' => 'slugs' ) );
459 wp_set_object_terms( $new_flow_id, $post_terms, $taxonomy, false );
460 }
461
462 // Copy post meta.
463 $post_meta_infos = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
464 $wpdb->prepare( "SELECT meta_key, meta_value FROM $wpdb->postmeta WHERE post_id=%d", $flow_id )
465 );
466
467 if ( ! empty( $post_meta_infos ) ) {
468 $sql_query_sel = array();
469 foreach ( $post_meta_infos as $meta_info ) {
470 if ( '_wp_old_slug' === $meta_info->meta_key ) {
471 continue;
472 }
473 $sql_query_sel[] = $wpdb->prepare( '( %d, %s, %s )', $new_flow_id, $meta_info->meta_key, $meta_info->meta_value );
474 }
475 if ( ! empty( $sql_query_sel ) ) {
476 $wpdb->query( "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) VALUES " . implode( ',', $sql_query_sel ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
477 }
478 }
479
480 // Clone all steps.
481 $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true );
482 $new_flow_steps = array();
483
484 update_post_meta( $new_flow_id, 'wcf-steps', array() );
485
486 if ( is_array( $flow_steps ) && ! empty( $flow_steps ) ) {
487 foreach ( $flow_steps as $step_data ) {
488 $step_id = intval( $step_data['id'] );
489 $step_object = get_post( $step_id );
490 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
491
492 if ( ! $step_object ) {
493 continue;
494 }
495
496 $new_step_id = wp_insert_post(
497 array(
498 'comment_status' => $step_object->comment_status,
499 'ping_status' => $step_object->ping_status,
500 'post_author' => $new_post_author,
501 'post_content' => $step_object->post_content,
502 'post_excerpt' => $step_object->post_excerpt,
503 'post_name' => $step_object->post_name,
504 'post_parent' => $step_object->post_parent,
505 'post_password' => $step_object->post_password,
506 'post_status' => $step_object->post_status,
507 'post_title' => $step_object->post_title,
508 'post_type' => $step_object->post_type,
509 'to_ping' => $step_object->to_ping,
510 'menu_order' => $step_object->menu_order,
511 )
512 );
513
514 if ( is_wp_error( $new_step_id ) || ! $new_step_id ) {
515 continue;
516 }
517
518 $step_meta_infos = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
519 $wpdb->prepare( "SELECT meta_key, meta_value FROM $wpdb->postmeta WHERE post_id=%d", $step_id )
520 );
521
522 if ( ! empty( $step_meta_infos ) ) {
523 $step_sql_sel = array();
524 foreach ( $step_meta_infos as $meta_info ) {
525 $step_sql_sel[] = $wpdb->prepare( '( %d, %s, %s )', $new_step_id, $meta_info->meta_key, $meta_info->meta_value );
526 }
527 if ( ! empty( $step_sql_sel ) ) {
528 $wpdb->query( "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) VALUES " . implode( ',', $step_sql_sel ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
529 }
530 }
531
532 update_post_meta( $new_step_id, 'wcf-flow-id', $new_flow_id );
533 update_post_meta( $new_step_id, 'wcf-step-type', $step_type );
534 wp_set_object_terms( $new_step_id, $step_type, CARTFLOWS_TAXONOMY_STEP_TYPE );
535 wp_set_object_terms( $new_step_id, 'flow-' . $new_flow_id, CARTFLOWS_TAXONOMY_STEP_FLOW );
536
537 $new_flow_steps[] = array(
538 'id' => intval( $new_step_id ),
539 'title' => esc_html( $step_object->post_title ),
540 'type' => esc_html( $step_type ),
541 );
542 }
543 }
544
545 update_post_meta( $new_flow_id, 'wcf-steps', $new_flow_steps );
546 AdminHelper::clear_cache();
547
548 return array(
549 'id' => intval( $new_flow_id ),
550 'source_id' => intval( $flow_id ),
551 'title' => esc_html( get_the_title( $new_flow_id ) ),
552 'url_edit' => esc_url( admin_url( 'admin.php?page=cartflows&path=flows&action=wcf-edit-flow&flow_id=' . $new_flow_id ) ),
553 'steps_count' => count( $new_flow_steps ),
554 'message' => esc_html__( 'Funnel cloned successfully.', 'cartflows' ),
555 );
556
557 } catch ( \Exception $e ) {
558 return $this->error( $e );
559 }
560 }
561
562 /**
563 * Move a funnel and all its steps to trash.
564 *
565 * @param array $input Validated input.
566 * @return array
567 * @throws \Exception On failure.
568 */
569 public function trash_flow( $input ) {
570
571 try {
572 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'trash-flow' );
573
574 $flow_id = $this->input_get( 'id' );
575
576 if ( 0 === $flow_id ) {
577 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
578 }
579
580 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
581 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
582 }
583
584 // Trash all steps.
585 $steps = get_post_meta( $flow_id, 'wcf-steps', true );
586 if ( is_array( $steps ) ) {
587 foreach ( $steps as $step ) {
588 wp_trash_post( intval( $step['id'] ) );
589 }
590 }
591
592 // Trash the flow taxonomy term.
593 $term_data = term_exists( 'flow-' . $flow_id, CARTFLOWS_TAXONOMY_STEP_FLOW );
594 if ( is_array( $term_data ) ) {
595 wp_delete_term( intval( $term_data['term_id'] ), CARTFLOWS_TAXONOMY_STEP_FLOW );
596 }
597
598 // Trash the flow.
599 wp_trash_post( $flow_id );
600
601 return array(
602 'id' => intval( $flow_id ),
603 'status' => 'trash',
604 'message' => esc_html__( 'Funnel moved to trash.', 'cartflows' ),
605 );
606
607 } catch ( \Exception $e ) {
608 return $this->error( $e );
609 }
610 }
611
612 /**
613 * Restore a trashed funnel and all its steps.
614 *
615 * @param array $input Validated input.
616 * @return array
617 * @throws \Exception On failure.
618 */
619 public function restore_flow( $input ) {
620
621 try {
622 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'restore-flow' );
623
624 $flow_id = $this->input_get( 'id' );
625
626 if ( 0 === $flow_id ) {
627 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
628 }
629
630 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
631 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
632 }
633
634 // Restore all steps.
635 $steps = get_post_meta( $flow_id, 'wcf-steps', true );
636 if ( is_array( $steps ) ) {
637 foreach ( $steps as $step ) {
638 wp_untrash_post( intval( $step['id'] ) );
639 }
640 }
641
642 // Restore the flow.
643 wp_untrash_post( $flow_id );
644
645 return array(
646 'id' => intval( $flow_id ),
647 'status' => 'publish',
648 'message' => esc_html__( 'Funnel restored from trash.', 'cartflows' ),
649 );
650
651 } catch ( \Exception $e ) {
652 return $this->error( $e );
653 }
654 }
655
656 // ============================================================
657 // Execute Callbacks — Flow Update
658 // ============================================================
659
660 /**
661 * Update a funnel's title and/or slug.
662 *
663 * @param array $input Validated input.
664 * @return array
665 * @throws \Exception On failure.
666 */
667 public function update_flow( $input ) {
668
669 try {
670 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-flow' );
671
672 $flow_id = $this->input_get( 'id' );
673 $title = $this->input_get( 'title' );
674 $slug = $this->input_get( 'slug' );
675
676 if ( 0 === $flow_id ) {
677 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
678 }
679
680 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
681 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
682 }
683
684 if ( empty( $title ) ) {
685 throw new \Exception( esc_html__( 'Funnel title cannot be empty.', 'cartflows' ) );
686 }
687
688 $post_data = array(
689 'ID' => $flow_id,
690 'post_title' => sanitize_text_field( $title ),
691 );
692
693 if ( ! empty( $slug ) ) {
694 $post_data['post_name'] = sanitize_title( $slug );
695 }
696
697 $result = wp_update_post( $post_data );
698
699 if ( is_wp_error( $result ) ) {
700 throw new \Exception( esc_html__( 'Failed to update funnel.', 'cartflows' ) );
701 }
702
703 return array(
704 'id' => intval( $flow_id ),
705 'title' => esc_html( get_the_title( $flow_id ) ),
706 'slug' => esc_html( get_post_field( 'post_name', $flow_id, 'edit' ) ),
707 'url_edit' => esc_url( admin_url( 'admin.php?page=cartflows&path=flows&action=wcf-edit-flow&flow_id=' . $flow_id ) ),
708 'message' => esc_html__( 'Funnel updated successfully.', 'cartflows' ),
709 );
710
711 } catch ( \Exception $e ) {
712 return $this->error( $e );
713 }
714 }
715
716 // ============================================================
717 // Execute Callbacks — Flow Relationships
718 // ============================================================
719
720 /**
721 * Reorder the steps within a funnel.
722 *
723 * @param array $input Validated input.
724 * @return array
725 * @throws \Exception On failure.
726 */
727 public function reorder_flow_steps( $input ) {
728
729 try {
730 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'reorder-flow-steps' );
731
732 $flow_id = $this->input_get( 'flow_id' );
733 $step_ids = $this->input_get( 'step_ids' );
734
735 if ( 0 === $flow_id ) {
736 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
737 }
738
739 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
740 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
741 }
742
743 if ( empty( $step_ids ) || ! is_array( $step_ids ) ) {
744 throw new \Exception( esc_html__( 'Step IDs array is required.', 'cartflows' ) );
745 }
746
747 $step_ids = array_map( 'intval', $step_ids );
748
749 // Build a lookup of current steps.
750 $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true );
751 $flow_steps_map = array();
752
753 if ( is_array( $flow_steps ) ) {
754 foreach ( $flow_steps as $value ) {
755 $flow_steps_map[ intval( $value['id'] ) ] = $value;
756 }
757 }
758
759 $new_flow_steps = array();
760
761 foreach ( $step_ids as $step_id ) {
762 $new_step_data = isset( $flow_steps_map[ $step_id ] ) ? $flow_steps_map[ $step_id ] : array();
763 $new_step_data['id'] = $step_id;
764 $new_step_data['title'] = esc_html( get_the_title( $step_id ) );
765 $new_step_data['type'] = esc_html( get_post_meta( $step_id, 'wcf-step-type', true ) );
766 $new_flow_steps[] = $new_step_data;
767 }
768
769 update_post_meta( $flow_id, 'wcf-steps', $new_flow_steps );
770
771 $ordered_steps = array();
772 foreach ( $new_flow_steps as $step ) {
773 $ordered_steps[] = array(
774 'id' => intval( $step['id'] ),
775 'title' => esc_html( $step['title'] ),
776 'type' => esc_html( $step['type'] ),
777 );
778 }
779
780 return array(
781 'flow_id' => intval( $flow_id ),
782 'steps' => $ordered_steps,
783 'message' => esc_html__( 'Funnel steps reordered successfully.', 'cartflows' ),
784 );
785
786 } catch ( \Exception $e ) {
787 return $this->error( $e );
788 }
789 }
790
791 // ============================================================
792 // Execute Callbacks — Flow Output
793 // ============================================================
794
795 /**
796 * Export one or more funnels as JSON data.
797 *
798 * @param array $input Validated input.
799 * @return array
800 * @throws \Exception On failure.
801 */
802 public function export_flow( $input ) {
803
804 try {
805 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'export-flow' );
806
807 $flow_ids = $this->input_get( 'flow_ids' );
808
809 if ( empty( $flow_ids ) || ! is_array( $flow_ids ) ) {
810 throw new \Exception( esc_html__( 'At least one funnel ID is required.', 'cartflows' ) );
811 }
812
813 $flow_ids = array_map( 'intval', $flow_ids );
814
815 $exporter = \CartFlows_Importer::get_instance();
816 $flows = array();
817
818 foreach ( $flow_ids as $flow_id ) {
819 if ( $flow_id <= 0 ) {
820 continue;
821 }
822 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
823 continue;
824 }
825 $flows[] = $exporter->get_flow_export_data( $flow_id );
826 }
827
828 if ( empty( $flows ) ) {
829 throw new \Exception( esc_html__( 'No valid funnel IDs provided for export.', 'cartflows' ) );
830 }
831
832 return array(
833 'flows' => $flows,
834 'export_count' => count( $flows ),
835 'message' => esc_html__( 'Funnel data exported successfully.', 'cartflows' ),
836 );
837
838 } catch ( \Exception $e ) {
839 return $this->error( $e );
840 }
841 }
842
843 // ============================================================
844 // Execute Callbacks — Step Lifecycle
845 // ============================================================
846
847 /**
848 * Clone a step within the same funnel.
849 *
850 * @param array $input Validated input.
851 * @return array
852 * @throws \Exception On failure.
853 */
854 public function clone_step( $input ) {
855
856 try {
857 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'clone-step' );
858
859 $flow_id = $this->input_get( 'flow_id' );
860 $step_id = $this->input_get( 'step_id' );
861
862 if ( 0 === $flow_id || 0 === $step_id ) {
863 throw new \Exception( esc_html__( 'Invalid funnel or step ID.', 'cartflows' ) );
864 }
865
866 if ( get_post_type( $flow_id ) !== CARTFLOWS_FLOW_POST_TYPE ) {
867 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
868 }
869
870 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
871 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
872 }
873
874 $step_object = get_post( $step_id );
875 $current_user = wp_get_current_user();
876 $new_post_author = intval( $current_user->ID );
877
878 global $wpdb;
879
880 // Insert the cloned step.
881 $new_step_id = wp_insert_post(
882 array(
883 'comment_status' => $step_object->comment_status,
884 'ping_status' => $step_object->ping_status,
885 'post_author' => $new_post_author,
886 'post_content' => $step_object->post_content,
887 'post_excerpt' => $step_object->post_excerpt,
888 'post_name' => $step_object->post_name,
889 'post_parent' => $step_object->post_parent,
890 'post_password' => $step_object->post_password,
891 'post_status' => $step_object->post_status,
892 'post_title' => $step_object->post_title . ' Clone',
893 'post_type' => $step_object->post_type,
894 'to_ping' => $step_object->to_ping,
895 'menu_order' => $step_object->menu_order,
896 )
897 );
898
899 if ( is_wp_error( $new_step_id ) || ! $new_step_id ) {
900 throw new \Exception( esc_html__( 'Failed to create step clone.', 'cartflows' ) );
901 }
902
903 // Copy taxonomies.
904 foreach ( get_object_taxonomies( $step_object->post_type ) as $taxonomy ) {
905 $post_terms = wp_get_object_terms( $step_id, $taxonomy, array( 'fields' => 'slugs' ) );
906 wp_set_object_terms( $new_step_id, $post_terms, $taxonomy, false );
907 }
908
909 // Copy step meta.
910 $step_meta_infos = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
911 $wpdb->prepare( "SELECT meta_key, meta_value FROM $wpdb->postmeta WHERE post_id=%d", $step_id )
912 );
913
914 if ( ! empty( $step_meta_infos ) ) {
915 $step_sql_sel = array();
916 foreach ( $step_meta_infos as $meta_info ) {
917 if ( '_wp_old_slug' === $meta_info->meta_key ) {
918 continue;
919 }
920 $step_sql_sel[] = $wpdb->prepare( '( %d, %s, %s )', $new_step_id, $meta_info->meta_key, $meta_info->meta_value );
921 }
922 if ( ! empty( $step_sql_sel ) ) {
923 $wpdb->query( "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) VALUES " . implode( ',', $step_sql_sel ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
924 }
925 }
926
927 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
928
929 update_post_meta( $new_step_id, 'wcf-flow-id', $flow_id );
930 update_post_meta( $new_step_id, 'wcf-step-type', $step_type );
931 wp_set_object_terms( $new_step_id, $step_type, CARTFLOWS_TAXONOMY_STEP_TYPE );
932 wp_set_object_terms( $new_step_id, 'flow-' . $flow_id, CARTFLOWS_TAXONOMY_STEP_FLOW );
933
934 // Append the new step to the flow's step list.
935 $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true );
936 if ( ! is_array( $flow_steps ) ) {
937 $flow_steps = array();
938 }
939
940 $flow_steps[] = array(
941 'id' => intval( $new_step_id ),
942 'title' => esc_html( $step_object->post_title ),
943 'type' => esc_html( $step_type ),
944 );
945
946 if ( method_exists( '\Cartflows_Helper', 'get_instance' ) ) {
947 $flow_steps = \Cartflows_Helper::get_instance()->maybe_update_flow_steps( $flow_id, $flow_steps );
948 }
949
950 update_post_meta( $flow_id, 'wcf-steps', $flow_steps );
951 AdminHelper::clear_cache();
952
953 return array(
954 'id' => intval( $new_step_id ),
955 'source_id' => intval( $step_id ),
956 'flow_id' => intval( $flow_id ),
957 'title' => esc_html( get_the_title( $new_step_id ) ),
958 'type' => esc_html( $step_type ),
959 'message' => esc_html__( 'Step cloned successfully.', 'cartflows' ),
960 );
961
962 } catch ( \Exception $e ) {
963 return $this->error( $e );
964 }
965 }
966
967 // ============================================================
968 // Execute Callbacks — Step Update
969 // ============================================================
970
971 /**
972 * Rename a CartFlows step.
973 *
974 * @param array $input Validated input.
975 * @return array
976 * @throws \Exception On failure.
977 */
978 public function update_step_title( $input ) {
979
980 try {
981 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-step-title' );
982
983 $step_id = $this->input_get( 'step_id' );
984 $title = $this->input_get( 'title' );
985
986 if ( 0 === $step_id ) {
987 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
988 }
989
990 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
991 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
992 }
993
994 if ( empty( $title ) ) {
995 throw new \Exception( esc_html__( 'Step title cannot be empty.', 'cartflows' ) );
996 }
997
998 $result = wp_update_post(
999 array(
1000 'ID' => $step_id,
1001 'post_title' => sanitize_text_field( $title ),
1002 )
1003 );
1004
1005 if ( is_wp_error( $result ) ) {
1006 throw new \Exception( esc_html__( 'Failed to update step title.', 'cartflows' ) );
1007 }
1008
1009 return array(
1010 'id' => intval( $step_id ),
1011 'title' => esc_html( get_the_title( $step_id ) ),
1012 'message' => esc_html__( 'Step title updated successfully.', 'cartflows' ),
1013 );
1014
1015 } catch ( \Exception $e ) {
1016 return $this->error( $e );
1017 }
1018 }
1019
1020 // ============================================================
1021 // Execute Callbacks — Admin / Settings Read
1022 // ============================================================
1023
1024 /**
1025 * Return CartFlows general settings (page builder, checkout, flags).
1026 *
1027 * @param array $input Validated input.
1028 * @return array
1029 * @throws \Exception On failure.
1030 */
1031 public function get_general_settings( $input ) {
1032
1033 try {
1034 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-general-settings' );
1035
1036 $settings = \Cartflows_Helper::get_common_settings();
1037
1038 return array(
1039 'default_page_builder' => esc_html( isset( $settings['default_page_builder'] ) ? $settings['default_page_builder'] : '' ),
1040 'global_checkout' => esc_html( isset( $settings['global_checkout'] ) ? $settings['global_checkout'] : '' ),
1041 'override_global_checkout' => esc_html( isset( $settings['override_global_checkout'] ) ? $settings['override_global_checkout'] : '' ),
1042 'override_store_order_pay' => esc_html( isset( $settings['override_store_order_pay'] ) ? $settings['override_store_order_pay'] : '' ),
1043 'disallow_indexing' => esc_html( isset( $settings['disallow_indexing'] ) ? $settings['disallow_indexing'] : '' ),
1044 );
1045
1046 } catch ( \Exception $e ) {
1047 return $this->error( $e );
1048 }
1049 }
1050
1051 /**
1052 * Return the flow configured as the CartFlows store checkout.
1053 *
1054 * @param array $input Validated input.
1055 * @return array
1056 * @throws \Exception On failure.
1057 */
1058 public function get_store_checkout( $input ) {
1059
1060 try {
1061 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-store-checkout' );
1062
1063 $flow_id = intval( \Cartflows_Helper::get_global_setting( '_cartflows_store_checkout' ) );
1064
1065 if ( $flow_id > 0 && CARTFLOWS_FLOW_POST_TYPE === get_post_type( $flow_id ) ) {
1066 return array(
1067 'flow_id' => $flow_id,
1068 'flow_title' => esc_html( get_the_title( $flow_id ) ),
1069 'url_edit' => esc_url( admin_url( 'admin.php?page=cartflows&path=flows&action=wcf-edit-flow&flow_id=' . $flow_id ) ),
1070 'is_configured' => true,
1071 );
1072 }
1073
1074 return array(
1075 'flow_id' => 0,
1076 'flow_title' => '',
1077 'url_edit' => '',
1078 'is_configured' => false,
1079 );
1080
1081 } catch ( \Exception $e ) {
1082 return $this->error( $e );
1083 }
1084 }
1085
1086 /**
1087 * Return CartFlows permalink (URL slug) settings.
1088 *
1089 * @param array $input Validated input.
1090 * @return array
1091 * @throws \Exception On failure.
1092 */
1093 public function get_permalink_settings( $input ) {
1094
1095 try {
1096 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-permalink-settings' );
1097
1098 $settings = \Cartflows_Helper::get_permalink_settings();
1099
1100 return array(
1101 'permalink' => esc_html( isset( $settings['permalink'] ) ? $settings['permalink'] : '' ),
1102 'permalink_flow_base' => esc_html( isset( $settings['permalink_flow_base'] ) ? $settings['permalink_flow_base'] : '' ),
1103 'permalink_structure' => esc_html( isset( $settings['permalink_structure'] ) ? $settings['permalink_structure'] : '' ),
1104 );
1105
1106 } catch ( \Exception $e ) {
1107 return $this->error( $e );
1108 }
1109 }
1110
1111 /**
1112 * Return pixel / analytics integration settings, optionally filtered to one group.
1113 *
1114 * @param array $input Validated input.
1115 * @return array
1116 * @throws \Exception On failure.
1117 */
1118 public function get_integration_settings( $input ) {
1119
1120 try {
1121 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-integration-settings' );
1122
1123 $integration = $this->input_get( 'integration' );
1124
1125 $map = array(
1126 'facebook' => '_cartflows_facebook',
1127 'google_analytics' => '_cartflows_google_analytics',
1128 'google_ads' => '_cartflows_google_ads',
1129 'tiktok' => '_cartflows_tiktok',
1130 'pinterest' => '_cartflows_pinterest',
1131 'snapchat' => '_cartflows_snapchat',
1132 );
1133
1134 $result = array();
1135
1136 if ( 'all' === $integration ) {
1137 foreach ( $map as $key => $option_name ) {
1138 $value = \Cartflows_Helper::get_admin_settings_option( $option_name, array() );
1139 $result[ $key ] = is_array( $value ) ? $value : array();
1140 }
1141 } else {
1142 if ( ! isset( $map[ $integration ] ) ) {
1143 throw new \Exception( esc_html__( 'Invalid integration group.', 'cartflows' ) );
1144 }
1145 $value = \Cartflows_Helper::get_admin_settings_option( $map[ $integration ], array() );
1146 $result[ $integration ] = is_array( $value ) ? $value : array();
1147 }
1148
1149 return $result;
1150
1151 } catch ( \Exception $e ) {
1152 return $this->error( $e );
1153 }
1154 }
1155
1156 // ============================================================
1157 // Execute Callbacks — Admin / Settings Write
1158 // ============================================================
1159
1160 /**
1161 * Update CartFlows general settings.
1162 *
1163 * Only fields present in $input (non-default) are merged in; omitted fields keep existing values.
1164 *
1165 * @param array $input Validated input.
1166 * @return array
1167 * @throws \Exception On failure.
1168 */
1169 public function update_general_settings( $input ) {
1170
1171 try {
1172 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-general-settings' );
1173
1174 $current = \Cartflows_Helper::get_common_settings();
1175
1176 $allowed_keys = array( 'default_page_builder', 'global_checkout', 'override_global_checkout', 'disallow_indexing' );
1177
1178 $updates = array();
1179 foreach ( $allowed_keys as $key ) {
1180 $value = $this->input_get( $key, null );
1181 if ( null !== $value && '' !== $value ) {
1182 $updates[ $key ] = sanitize_text_field( $value );
1183 }
1184 }
1185
1186 if ( empty( $updates ) ) {
1187 throw new \Exception( esc_html__( 'No settings provided to update.', 'cartflows' ) );
1188 }
1189
1190 $new_settings = wp_parse_args( $updates, $current );
1191
1192 \Cartflows_Helper::update_admin_settings_option( '_cartflows_common', $new_settings, false );
1193
1194 return array(
1195 'message' => esc_html__( 'General settings updated successfully.', 'cartflows' ),
1196 'settings' => array(
1197 'default_page_builder' => esc_html( isset( $new_settings['default_page_builder'] ) ? $new_settings['default_page_builder'] : '' ),
1198 'global_checkout' => esc_html( isset( $new_settings['global_checkout'] ) ? $new_settings['global_checkout'] : '' ),
1199 'override_global_checkout' => esc_html( isset( $new_settings['override_global_checkout'] ) ? $new_settings['override_global_checkout'] : '' ),
1200 'override_store_order_pay' => esc_html( isset( $new_settings['override_store_order_pay'] ) ? $new_settings['override_store_order_pay'] : '' ),
1201 'disallow_indexing' => esc_html( isset( $new_settings['disallow_indexing'] ) ? $new_settings['disallow_indexing'] : '' ),
1202 ),
1203 );
1204
1205 } catch ( \Exception $e ) {
1206 return $this->error( $e );
1207 }
1208 }
1209
1210 /**
1211 * Update CartFlows permalink (URL slug) settings.
1212 *
1213 * Forces a permalink flush after saving.
1214 *
1215 * @param array $input Validated input.
1216 * @return array
1217 * @throws \Exception On failure.
1218 */
1219 public function update_permalink_settings( $input ) {
1220
1221 try {
1222 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-permalink-settings' );
1223
1224 $current = \Cartflows_Helper::get_permalink_settings();
1225
1226 $permalink = $this->input_get( 'permalink', null );
1227 $permalink_flow_base = $this->input_get( 'permalink_flow_base', null );
1228 $permalink_structure = $this->input_get( 'permalink_structure', null );
1229
1230 if ( null === $permalink && null === $permalink_flow_base && null === $permalink_structure ) {
1231 throw new \Exception( esc_html__( 'No permalink settings provided to update.', 'cartflows' ) );
1232 }
1233
1234 $new_settings = $current;
1235
1236 if ( null !== $permalink ) {
1237 $new_settings['permalink'] = ! empty( $permalink )
1238 ? sanitize_title( $permalink )
1239 : CARTFLOWS_STEP_PERMALINK_SLUG;
1240 }
1241
1242 if ( null !== $permalink_flow_base ) {
1243 $new_settings['permalink_flow_base'] = ! empty( $permalink_flow_base )
1244 ? sanitize_title( $permalink_flow_base )
1245 : CARTFLOWS_FLOW_PERMALINK_SLUG;
1246 }
1247
1248 if ( null !== $permalink_structure ) {
1249 $new_settings['permalink_structure'] = sanitize_text_field( $permalink_structure );
1250 }
1251
1252 \Cartflows_Helper::update_admin_settings_option( '_cartflows_permalink', $new_settings, true );
1253 update_option( 'cartflows_permalink_refresh', true );
1254
1255 return array(
1256 'message' => esc_html__( 'Permalink settings updated successfully.', 'cartflows' ),
1257 'permalink' => esc_html( $new_settings['permalink'] ),
1258 'permalink_flow_base' => esc_html( $new_settings['permalink_flow_base'] ),
1259 );
1260
1261 } catch ( \Exception $e ) {
1262 return $this->error( $e );
1263 }
1264 }
1265
1266 // ============================================================
1267 // Execute Callbacks — Checkout Read
1268 // ============================================================
1269
1270 /**
1271 * Get checkout step settings: layout, products, form toggles, button, and design options.
1272 *
1273 * @param array $input Validated input.
1274 * @return array
1275 * @throws \Exception On failure.
1276 */
1277 public function get_checkout_settings( $input ) {
1278
1279 try {
1280 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-checkout-settings' );
1281
1282 if ( ! function_exists( 'WC' ) ) {
1283 throw new \Exception( esc_html__( 'WooCommerce is required for checkout abilities.', 'cartflows' ) );
1284 }
1285
1286 $step_id = $this->input_get( 'step_id' );
1287
1288 if ( null === $step_id || 0 === intval( $step_id ) ) {
1289 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1290 }
1291
1292 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1293 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1294 }
1295
1296 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1297 if ( 'checkout' !== $step_type ) {
1298 throw new \Exception( esc_html__( 'Step is not a checkout type.', 'cartflows' ) );
1299 }
1300
1301 // Layout.
1302 $layout = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-layout' );
1303
1304 // Primary color and font.
1305 $primary_color = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-primary-color' );
1306 $font_family = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-base-font-family' );
1307
1308 // Products.
1309 $products_raw = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-products' );
1310 $products = array();
1311
1312 if ( is_array( $products_raw ) && ! empty( $products_raw ) ) {
1313 foreach ( $products_raw as $product ) {
1314 $product_id = isset( $product['product'] ) ? intval( $product['product'] ) : 0;
1315 $product_obj = ( $product_id > 0 ) ? wc_get_product( $product_id ) : null;
1316
1317 $products[] = array(
1318 'product_id' => $product_id,
1319 'name' => $product_obj ? esc_html( $product_obj->get_name() ) : '',
1320 'quantity' => isset( $product['quantity'] ) ? intval( $product['quantity'] ) : 1,
1321 'discount_type' => isset( $product['discount_type'] ) ? esc_html( $product['discount_type'] ) : '',
1322 'discount_value' => isset( $product['discount_value'] ) ? esc_html( $product['discount_value'] ) : '',
1323 'add_to_cart' => isset( $product['add_to_cart'] ) ? esc_html( $product['add_to_cart'] ) : 'yes',
1324 );
1325 }
1326 }
1327
1328 // Form settings.
1329 $form_settings = array(
1330 'show_coupon_field' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-show-coupon-field' ) ),
1331 'optimize_coupon_field' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-optimize-coupon-field' ) ),
1332 'show_additional_fields' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-additional-fields' ) ),
1333 'optimize_order_note' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-optimize-order-note-field' ) ),
1334 'ship_to_different_address' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-shipto-diff-addr-fields' ) ),
1335 'google_autoaddress' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-google-autoaddress' ) ),
1336 );
1337
1338 // Button settings.
1339 $button_settings = array(
1340 'button_text' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-place-order-button-text' ) ),
1341 'show_lock_icon' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-place-order-button-lock' ) ),
1342 'show_price' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-place-order-button-price-display' ) ),
1343 );
1344
1345 // Advanced settings.
1346 $advanced_settings = array(
1347 'show_product_images' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-order-review-show-product-images' ) ),
1348 'enable_cart_editing' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-remove-product-field' ) ),
1349 );
1350
1351 return array(
1352 'step_id' => intval( $step_id ),
1353 'layout' => esc_html( $layout ),
1354 'primary_color' => esc_html( $primary_color ),
1355 'font_family' => esc_html( $font_family ),
1356 'products' => $products,
1357 'form_settings' => $form_settings,
1358 'button_settings' => $button_settings,
1359 'advanced_settings' => $advanced_settings,
1360 );
1361
1362 } catch ( \Exception $e ) {
1363 return $this->error( $e );
1364 }
1365 }
1366
1367 /**
1368 * Get products assigned to a checkout step.
1369 *
1370 * @param array $input Validated input.
1371 * @return array
1372 * @throws \Exception On failure.
1373 */
1374 public function get_checkout_products( $input ) {
1375
1376 try {
1377 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-checkout-products' );
1378
1379 if ( ! function_exists( 'WC' ) ) {
1380 throw new \Exception( esc_html__( 'WooCommerce is required for checkout abilities.', 'cartflows' ) );
1381 }
1382
1383 $step_id = $this->input_get( 'step_id' );
1384
1385 if ( null === $step_id || 0 === intval( $step_id ) ) {
1386 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1387 }
1388
1389 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1390 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1391 }
1392
1393 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1394 if ( 'checkout' !== $step_type ) {
1395 throw new \Exception( esc_html__( 'Step is not a checkout type.', 'cartflows' ) );
1396 }
1397
1398 $products_raw = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-products' );
1399 $products = array();
1400
1401 if ( is_array( $products_raw ) && ! empty( $products_raw ) ) {
1402 foreach ( $products_raw as $product ) {
1403 $product_id = isset( $product['product'] ) ? intval( $product['product'] ) : 0;
1404 $product_obj = ( $product_id > 0 ) ? wc_get_product( $product_id ) : null;
1405
1406 $products[] = array(
1407 'product_id' => $product_id,
1408 'name' => $product_obj ? esc_html( $product_obj->get_name() ) : '',
1409 'quantity' => isset( $product['quantity'] ) ? intval( $product['quantity'] ) : 1,
1410 'discount_type' => isset( $product['discount_type'] ) ? esc_html( $product['discount_type'] ) : '',
1411 'discount_value' => isset( $product['discount_value'] ) ? esc_html( $product['discount_value'] ) : '',
1412 'add_to_cart' => isset( $product['add_to_cart'] ) ? esc_html( $product['add_to_cart'] ) : 'yes',
1413 'img_url' => $product_obj ? esc_url( (string) get_the_post_thumbnail_url( $product_id ) ) : '',
1414 'regular_price' => $product_obj ? esc_html( \Cartflows_Helper::get_product_original_price( $product_obj ) ) : '',
1415 );
1416 }
1417 }
1418
1419 return array(
1420 'step_id' => intval( $step_id ),
1421 'products' => $products,
1422 );
1423
1424 } catch ( \Exception $e ) {
1425 return $this->error( $e );
1426 }
1427 }
1428
1429 /**
1430 * Get checkout form field configuration.
1431 *
1432 * @param array $input Validated input.
1433 * @return array
1434 * @throws \Exception On failure.
1435 */
1436 public function get_checkout_fields( $input ) {
1437
1438 try {
1439 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-checkout-fields' );
1440
1441 if ( ! function_exists( 'WC' ) ) {
1442 throw new \Exception( esc_html__( 'WooCommerce is required for checkout abilities.', 'cartflows' ) );
1443 }
1444
1445 $step_id = $this->input_get( 'step_id' );
1446 $field_type = $this->input_get( 'field_type' );
1447
1448 if ( null === $step_id || 0 === intval( $step_id ) ) {
1449 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1450 }
1451
1452 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1453 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1454 }
1455
1456 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1457 if ( 'checkout' !== $step_type ) {
1458 throw new \Exception( esc_html__( 'Step is not a checkout type.', 'cartflows' ) );
1459 }
1460
1461 $billing_fields = array();
1462 $shipping_fields = array();
1463
1464 if ( 'all' === $field_type || 'billing' === $field_type ) {
1465 $billing_raw = wcf()->options->get_checkout_meta_value( $step_id, 'wcf_field_order_billing' );
1466 if ( ! is_array( $billing_raw ) || empty( $billing_raw ) ) {
1467 $billing_raw = \Cartflows_Helper::get_checkout_fields( 'billing', $step_id );
1468 }
1469 $billing_fields = is_array( $billing_raw ) ? $billing_raw : array();
1470 }
1471
1472 if ( 'all' === $field_type || 'shipping' === $field_type ) {
1473 $shipping_raw = wcf()->options->get_checkout_meta_value( $step_id, 'wcf_field_order_shipping' );
1474 if ( ! is_array( $shipping_raw ) || empty( $shipping_raw ) ) {
1475 $shipping_raw = \Cartflows_Helper::get_checkout_fields( 'shipping', $step_id );
1476 }
1477 $shipping_fields = is_array( $shipping_raw ) ? $shipping_raw : array();
1478 }
1479
1480 $form_settings = array(
1481 'show_coupon_field' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-show-coupon-field' ) ),
1482 'optimize_coupon_field' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-optimize-coupon-field' ) ),
1483 'show_additional_fields' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-additional-fields' ) ),
1484 'optimize_order_note' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-optimize-order-note-field' ) ),
1485 'ship_to_different_address' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-shipto-diff-addr-fields' ) ),
1486 'google_autoaddress' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-google-autoaddress' ) ),
1487 );
1488
1489 return array(
1490 'step_id' => intval( $step_id ),
1491 'billing_fields' => $billing_fields,
1492 'shipping_fields' => $shipping_fields,
1493 'form_settings' => $form_settings,
1494 );
1495
1496 } catch ( \Exception $e ) {
1497 return $this->error( $e );
1498 }
1499 }
1500
1501 // ============================================================
1502 // Execute Callbacks — Checkout Write
1503 // ============================================================
1504
1505 /**
1506 * Update products assigned to a checkout step.
1507 *
1508 * @param array $input Validated input.
1509 * @return array
1510 * @throws \Exception On failure.
1511 */
1512 public function update_checkout_products( $input ) {
1513
1514 try {
1515 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-checkout-products' );
1516
1517 if ( ! function_exists( 'WC' ) ) {
1518 throw new \Exception( esc_html__( 'WooCommerce is required for checkout abilities.', 'cartflows' ) );
1519 }
1520
1521 $step_id = $this->input_get( 'step_id' );
1522 $products = $this->input_get( 'products' );
1523
1524 if ( null === $step_id || 0 === intval( $step_id ) ) {
1525 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1526 }
1527
1528 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1529 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1530 }
1531
1532 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1533 if ( 'checkout' !== $step_type ) {
1534 throw new \Exception( esc_html__( 'Step is not a checkout type.', 'cartflows' ) );
1535 }
1536
1537 if ( ! is_array( $products ) ) {
1538 throw new \Exception( esc_html__( 'Products must be an array.', 'cartflows' ) );
1539 }
1540
1541 $sanitized_products = array();
1542
1543 foreach ( $products as $index => $product ) {
1544 $product_id = isset( $product['product'] ) ? intval( $product['product'] ) : 0;
1545
1546 if ( $product_id <= 0 ) {
1547 continue;
1548 }
1549
1550 $product_obj = wc_get_product( $product_id );
1551 if ( ! $product_obj ) {
1552 throw new \Exception(
1553 sprintf(
1554 /* translators: %d: product ID */
1555 esc_html__( 'WooCommerce product %d not found.', 'cartflows' ),
1556 $product_id
1557 )
1558 );
1559 }
1560
1561 $unique_id = isset( $product['unique_id'] ) ? sanitize_text_field( $product['unique_id'] ) : md5( $product_id . '_' . $index . '_' . time() );
1562
1563 $sanitized_products[] = array(
1564 'product' => $product_id,
1565 'quantity' => isset( $product['quantity'] ) ? max( 1, intval( $product['quantity'] ) ) : 1,
1566 'discount_type' => isset( $product['discount_type'] ) ? sanitize_text_field( $product['discount_type'] ) : '',
1567 'discount_value' => isset( $product['discount_value'] ) ? sanitize_text_field( $product['discount_value'] ) : '',
1568 'add_to_cart' => isset( $product['add_to_cart'] ) ? sanitize_text_field( $product['add_to_cart'] ) : 'yes',
1569 'unique_id' => $unique_id,
1570 );
1571 }
1572
1573 update_post_meta( $step_id, 'wcf-checkout-products', $sanitized_products );
1574
1575 return array(
1576 'step_id' => intval( $step_id ),
1577 'products' => $sanitized_products,
1578 'message' => esc_html__( 'Checkout products updated successfully.', 'cartflows' ),
1579 );
1580
1581 } catch ( \Exception $e ) {
1582 return $this->error( $e );
1583 }
1584 }
1585
1586 /**
1587 * Update checkout layout/skin.
1588 *
1589 * @param array $input Validated input.
1590 * @return array
1591 * @throws \Exception On failure.
1592 */
1593 public function update_checkout_layout( $input ) {
1594
1595 try {
1596 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-checkout-layout' );
1597
1598 if ( ! function_exists( 'WC' ) ) {
1599 throw new \Exception( esc_html__( 'WooCommerce is required for checkout abilities.', 'cartflows' ) );
1600 }
1601
1602 $step_id = $this->input_get( 'step_id' );
1603 $layout = $this->input_get( 'layout' );
1604
1605 if ( null === $step_id || 0 === intval( $step_id ) ) {
1606 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1607 }
1608
1609 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1610 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1611 }
1612
1613 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1614 if ( 'checkout' !== $step_type ) {
1615 throw new \Exception( esc_html__( 'Step is not a checkout type.', 'cartflows' ) );
1616 }
1617
1618 update_post_meta( $step_id, 'wcf-checkout-layout', sanitize_text_field( $layout ) );
1619
1620 return array(
1621 'step_id' => intval( $step_id ),
1622 'layout' => esc_html( $layout ),
1623 'message' => esc_html__( 'Checkout layout updated successfully.', 'cartflows' ),
1624 );
1625
1626 } catch ( \Exception $e ) {
1627 return $this->error( $e );
1628 }
1629 }
1630
1631 /**
1632 * Update place order button settings.
1633 *
1634 * @param array $input Validated input.
1635 * @return array
1636 * @throws \Exception On failure.
1637 */
1638 public function update_checkout_place_order_button( $input ) {
1639
1640 try {
1641 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-checkout-place-order-button' );
1642
1643 if ( ! function_exists( 'WC' ) ) {
1644 throw new \Exception( esc_html__( 'WooCommerce is required for checkout abilities.', 'cartflows' ) );
1645 }
1646
1647 $step_id = $this->input_get( 'step_id' );
1648
1649 if ( null === $step_id || 0 === intval( $step_id ) ) {
1650 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1651 }
1652
1653 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1654 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1655 }
1656
1657 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1658 if ( 'checkout' !== $step_type ) {
1659 throw new \Exception( esc_html__( 'Step is not a checkout type.', 'cartflows' ) );
1660 }
1661
1662 $button_text = $this->input_get( 'button_text', null );
1663 $show_lock_icon = $this->input_get( 'show_lock_icon', null );
1664 $show_price = $this->input_get( 'show_price', null );
1665
1666 $updated = false;
1667
1668 if ( null !== $button_text && '' !== $button_text ) {
1669 update_post_meta( $step_id, 'wcf-checkout-place-order-button-text', sanitize_text_field( $button_text ) );
1670 $updated = true;
1671 }
1672
1673 if ( null !== $show_lock_icon && '' !== $show_lock_icon ) {
1674 update_post_meta( $step_id, 'wcf-checkout-place-order-button-lock', sanitize_text_field( $show_lock_icon ) );
1675 $updated = true;
1676 }
1677
1678 if ( null !== $show_price && '' !== $show_price ) {
1679 update_post_meta( $step_id, 'wcf-checkout-place-order-button-price-display', sanitize_text_field( $show_price ) );
1680 $updated = true;
1681 }
1682
1683 if ( ! $updated ) {
1684 throw new \Exception( esc_html__( 'No button settings provided to update.', 'cartflows' ) );
1685 }
1686
1687 return array(
1688 'step_id' => intval( $step_id ),
1689 'button_text' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-place-order-button-text' ) ),
1690 'show_lock_icon' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-place-order-button-lock' ) ),
1691 'show_price' => esc_html( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-place-order-button-price-display' ) ),
1692 'message' => esc_html__( 'Place order button updated successfully.', 'cartflows' ),
1693 );
1694
1695 } catch ( \Exception $e ) {
1696 return $this->error( $e );
1697 }
1698 }
1699
1700 /**
1701 * Update checkout form-level settings (toggles).
1702 *
1703 * @param array $input Validated input.
1704 * @return array
1705 * @throws \Exception On failure.
1706 */
1707 public function update_checkout_form_settings( $input ) {
1708
1709 try {
1710 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-checkout-form-settings' );
1711
1712 if ( ! function_exists( 'WC' ) ) {
1713 throw new \Exception( esc_html__( 'WooCommerce is required for checkout abilities.', 'cartflows' ) );
1714 }
1715
1716 $step_id = $this->input_get( 'step_id' );
1717
1718 if ( null === $step_id || 0 === intval( $step_id ) ) {
1719 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1720 }
1721
1722 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1723 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1724 }
1725
1726 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1727 if ( 'checkout' !== $step_type ) {
1728 throw new \Exception( esc_html__( 'Step is not a checkout type.', 'cartflows' ) );
1729 }
1730
1731 // Map input field names to post meta keys.
1732 $field_meta_map = array(
1733 'show_coupon_field' => 'wcf-show-coupon-field',
1734 'optimize_coupon_field' => 'wcf-optimize-coupon-field',
1735 'show_additional_fields' => 'wcf-checkout-additional-fields',
1736 'optimize_order_note' => 'wcf-optimize-order-note-field',
1737 'ship_to_different_address' => 'wcf-shipto-diff-addr-fields',
1738 'google_autoaddress' => 'wcf-google-autoaddress',
1739 'show_product_images' => 'wcf-order-review-show-product-images',
1740 'enable_cart_editing' => 'wcf-remove-product-field',
1741 );
1742
1743 $updated = false;
1744 $settings = array();
1745
1746 foreach ( $field_meta_map as $input_key => $meta_key ) {
1747 $value = $this->input_get( $input_key, null );
1748
1749 if ( null !== $value && '' !== $value ) {
1750 update_post_meta( $step_id, $meta_key, sanitize_text_field( $value ) );
1751 $updated = true;
1752 $settings[ $input_key ] = esc_html( $value );
1753 } else {
1754 $settings[ $input_key ] = esc_html( wcf()->options->get_checkout_meta_value( $step_id, $meta_key ) );
1755 }
1756 }
1757
1758 if ( ! $updated ) {
1759 throw new \Exception( esc_html__( 'No form settings provided to update.', 'cartflows' ) );
1760 }
1761
1762 return array(
1763 'step_id' => intval( $step_id ),
1764 'settings' => $settings,
1765 'message' => esc_html__( 'Checkout form settings updated successfully.', 'cartflows' ),
1766 );
1767
1768 } catch ( \Exception $e ) {
1769 return $this->error( $e );
1770 }
1771 }
1772
1773 // ============================================================
1774 // Execute Callbacks — Thank You Read
1775 // ============================================================
1776
1777 /**
1778 * Get thank you step settings: layout, section visibility, custom text, redirect, and design.
1779 *
1780 * @param array $input Validated input.
1781 * @return array
1782 * @throws \Exception On failure.
1783 */
1784 public function get_thankyou_settings( $input ) {
1785
1786 try {
1787 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-thankyou-settings' );
1788
1789 if ( ! function_exists( 'WC' ) ) {
1790 throw new \Exception( esc_html__( 'WooCommerce is required for thank you abilities.', 'cartflows' ) );
1791 }
1792
1793 $step_id = $this->input_get( 'step_id' );
1794
1795 if ( null === $step_id || 0 === intval( $step_id ) ) {
1796 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1797 }
1798
1799 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1800 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1801 }
1802
1803 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1804 if ( 'thankyou' !== $step_type ) {
1805 throw new \Exception( esc_html__( 'Step is not a thank you type.', 'cartflows' ) );
1806 }
1807
1808 // Layout.
1809 $layout = wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-layout' );
1810
1811 // Section visibility.
1812 $sections = array(
1813 'show_overview' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-show-overview-section' ) ),
1814 'show_details' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-show-details-section' ) ),
1815 'show_billing' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-show-billing-section' ) ),
1816 'show_shipping' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-show-shipping-section' ) ),
1817 );
1818
1819 // Custom text.
1820 $custom_text = wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-text' );
1821
1822 // Redirect settings.
1823 $redirect = array(
1824 'enabled' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-show-tq-redirect-section' ) ),
1825 'redirect_url' => esc_url( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-redirect-link' ) ),
1826 );
1827
1828 // Design settings.
1829 $design = array(
1830 'primary_color' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-primary-color' ) ),
1831 'text_color' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-text-color' ) ),
1832 'text_font_family' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-font-family' ) ),
1833 'text_font_size' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-font-size' ) ),
1834 'heading_color' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-heading-color' ) ),
1835 'heading_font_family' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-heading-font-family' ) ),
1836 'heading_font_weight' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-heading-font-wt' ) ),
1837 'container_width' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-container-width' ) ),
1838 'section_bg_color' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-section-bg-color' ) ),
1839 );
1840
1841 return array(
1842 'step_id' => intval( $step_id ),
1843 'layout' => esc_html( $layout ),
1844 'sections' => $sections,
1845 'custom_text' => esc_html( $custom_text ),
1846 'redirect' => $redirect,
1847 'design' => $design,
1848 );
1849
1850 } catch ( \Exception $e ) {
1851 return $this->error( $e );
1852 }
1853 }
1854
1855 // ============================================================
1856 // Execute Callbacks — Thank You Write
1857 // ============================================================
1858
1859 /**
1860 * Update thank you layout/skin.
1861 *
1862 * @param array $input Validated input.
1863 * @return array
1864 * @throws \Exception On failure.
1865 */
1866 public function update_thankyou_layout( $input ) {
1867
1868 try {
1869 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-thankyou-layout' );
1870
1871 if ( ! function_exists( 'WC' ) ) {
1872 throw new \Exception( esc_html__( 'WooCommerce is required for thank you abilities.', 'cartflows' ) );
1873 }
1874
1875 $step_id = $this->input_get( 'step_id' );
1876 $layout = $this->input_get( 'layout' );
1877
1878 if ( null === $step_id || 0 === intval( $step_id ) ) {
1879 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1880 }
1881
1882 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1883 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1884 }
1885
1886 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1887 if ( 'thankyou' !== $step_type ) {
1888 throw new \Exception( esc_html__( 'Step is not a thank you type.', 'cartflows' ) );
1889 }
1890
1891 update_post_meta( $step_id, 'wcf-tq-layout', sanitize_text_field( $layout ) );
1892
1893 return array(
1894 'step_id' => intval( $step_id ),
1895 'layout' => esc_html( $layout ),
1896 'message' => esc_html__( 'Thank you layout updated successfully.', 'cartflows' ),
1897 );
1898
1899 } catch ( \Exception $e ) {
1900 return $this->error( $e );
1901 }
1902 }
1903
1904 /**
1905 * Update thank you section visibility toggles.
1906 *
1907 * @param array $input Validated input.
1908 * @return array
1909 * @throws \Exception On failure.
1910 */
1911 public function update_thankyou_sections( $input ) {
1912
1913 try {
1914 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-thankyou-sections' );
1915
1916 if ( ! function_exists( 'WC' ) ) {
1917 throw new \Exception( esc_html__( 'WooCommerce is required for thank you abilities.', 'cartflows' ) );
1918 }
1919
1920 $step_id = $this->input_get( 'step_id' );
1921
1922 if ( null === $step_id || 0 === intval( $step_id ) ) {
1923 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1924 }
1925
1926 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1927 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1928 }
1929
1930 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1931 if ( 'thankyou' !== $step_type ) {
1932 throw new \Exception( esc_html__( 'Step is not a thank you type.', 'cartflows' ) );
1933 }
1934
1935 $field_meta_map = array(
1936 'show_overview' => 'wcf-show-overview-section',
1937 'show_details' => 'wcf-show-details-section',
1938 'show_billing' => 'wcf-show-billing-section',
1939 'show_shipping' => 'wcf-show-shipping-section',
1940 );
1941
1942 $updated = false;
1943 $sections = array();
1944
1945 foreach ( $field_meta_map as $input_key => $meta_key ) {
1946 $value = $this->input_get( $input_key, null );
1947
1948 if ( null !== $value && '' !== $value ) {
1949 update_post_meta( $step_id, $meta_key, sanitize_text_field( $value ) );
1950 $updated = true;
1951 $sections[ $input_key ] = esc_html( $value );
1952 } else {
1953 $sections[ $input_key ] = esc_html( wcf()->options->get_thankyou_meta_value( $step_id, $meta_key ) );
1954 }
1955 }
1956
1957 if ( ! $updated ) {
1958 throw new \Exception( esc_html__( 'No section settings provided to update.', 'cartflows' ) );
1959 }
1960
1961 return array(
1962 'step_id' => intval( $step_id ),
1963 'sections' => $sections,
1964 'message' => esc_html__( 'Thank you sections updated successfully.', 'cartflows' ),
1965 );
1966
1967 } catch ( \Exception $e ) {
1968 return $this->error( $e );
1969 }
1970 }
1971
1972 /**
1973 * Update thank you redirect settings.
1974 *
1975 * @param array $input Validated input.
1976 * @return array
1977 * @throws \Exception On failure.
1978 */
1979 public function update_thankyou_redirect( $input ) {
1980
1981 try {
1982 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-thankyou-redirect' );
1983
1984 if ( ! function_exists( 'WC' ) ) {
1985 throw new \Exception( esc_html__( 'WooCommerce is required for thank you abilities.', 'cartflows' ) );
1986 }
1987
1988 $step_id = $this->input_get( 'step_id' );
1989
1990 if ( null === $step_id || 0 === intval( $step_id ) ) {
1991 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
1992 }
1993
1994 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
1995 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
1996 }
1997
1998 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
1999 if ( 'thankyou' !== $step_type ) {
2000 throw new \Exception( esc_html__( 'Step is not a thank you type.', 'cartflows' ) );
2001 }
2002
2003 $enabled = $this->input_get( 'enabled', null );
2004 $redirect_url = $this->input_get( 'redirect_url', null );
2005 $updated = false;
2006
2007 if ( null !== $enabled && '' !== $enabled ) {
2008 update_post_meta( $step_id, 'wcf-show-tq-redirect-section', sanitize_text_field( $enabled ) );
2009 $updated = true;
2010 }
2011
2012 if ( null !== $redirect_url && '' !== $redirect_url ) {
2013 update_post_meta( $step_id, 'wcf-tq-redirect-link', esc_url_raw( $redirect_url ) );
2014 $updated = true;
2015 }
2016
2017 if ( ! $updated ) {
2018 throw new \Exception( esc_html__( 'No redirect settings provided to update.', 'cartflows' ) );
2019 }
2020
2021 return array(
2022 'step_id' => intval( $step_id ),
2023 'enabled' => esc_html( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-show-tq-redirect-section' ) ),
2024 'redirect_url' => esc_url( wcf()->options->get_thankyou_meta_value( $step_id, 'wcf-tq-redirect-link' ) ),
2025 'message' => esc_html__( 'Thank you redirect settings updated successfully.', 'cartflows' ),
2026 );
2027
2028 } catch ( \Exception $e ) {
2029 return $this->error( $e );
2030 }
2031 }
2032
2033 /**
2034 * Update thank you custom text.
2035 *
2036 * @param array $input Validated input.
2037 * @return array
2038 * @throws \Exception On failure.
2039 */
2040 public function update_thankyou_custom_text( $input ) {
2041
2042 try {
2043 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-thankyou-custom-text' );
2044
2045 if ( ! function_exists( 'WC' ) ) {
2046 throw new \Exception( esc_html__( 'WooCommerce is required for thank you abilities.', 'cartflows' ) );
2047 }
2048
2049 $step_id = $this->input_get( 'step_id' );
2050 $text = $this->input_get( 'text' );
2051
2052 if ( null === $step_id || 0 === intval( $step_id ) ) {
2053 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
2054 }
2055
2056 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
2057 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
2058 }
2059
2060 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
2061 if ( 'thankyou' !== $step_type ) {
2062 throw new \Exception( esc_html__( 'Step is not a thank you type.', 'cartflows' ) );
2063 }
2064
2065 update_post_meta( $step_id, 'wcf-tq-text', sanitize_text_field( $text ) );
2066
2067 return array(
2068 'step_id' => intval( $step_id ),
2069 'text' => esc_html( get_post_meta( $step_id, 'wcf-tq-text', true ) ),
2070 'message' => esc_html__( 'Thank you custom text updated successfully.', 'cartflows' ),
2071 );
2072
2073 } catch ( \Exception $e ) {
2074 return $this->error( $e );
2075 }
2076 }
2077
2078 // ============================================================
2079 // Execute Callbacks — Optin Read
2080 // ============================================================
2081
2082 /**
2083 * Get optin step settings: product, button text, pass-fields config.
2084 *
2085 * @param array $input Validated input.
2086 * @return array
2087 * @throws \Exception On failure.
2088 */
2089 public function get_optin_settings( $input ) {
2090
2091 try {
2092 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-optin-settings' );
2093
2094 if ( ! function_exists( 'WC' ) ) {
2095 throw new \Exception( esc_html__( 'WooCommerce is required for optin abilities.', 'cartflows' ) );
2096 }
2097
2098 $step_id = $this->input_get( 'step_id' );
2099
2100 if ( null === $step_id || 0 === intval( $step_id ) ) {
2101 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
2102 }
2103
2104 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
2105 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
2106 }
2107
2108 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
2109 if ( 'optin' !== $step_type ) {
2110 throw new \Exception( esc_html__( 'Step is not an optin type.', 'cartflows' ) );
2111 }
2112
2113 // Product.
2114 $product_raw = wcf()->options->get_optin_meta_value( $step_id, 'wcf-optin-product' );
2115 $product = array(
2116 'id' => 0,
2117 'name' => '',
2118 );
2119
2120 if ( ! empty( $product_raw ) ) {
2121 $product_id = is_array( $product_raw ) ? intval( reset( $product_raw ) ) : intval( $product_raw );
2122 $product_obj = ( $product_id > 0 ) ? wc_get_product( $product_id ) : null;
2123
2124 if ( $product_obj ) {
2125 $product = array(
2126 'id' => $product_id,
2127 'name' => esc_html( $product_obj->get_name() ),
2128 );
2129 }
2130 }
2131
2132 // Button text.
2133 $button_text = wcf()->options->get_optin_meta_value( $step_id, 'wcf-submit-button-text' );
2134
2135 // Pass fields.
2136 $pass_fields = array(
2137 'enabled' => esc_html( wcf()->options->get_optin_meta_value( $step_id, 'wcf-optin-pass-fields' ) ),
2138 'specific_fields' => esc_html( wcf()->options->get_optin_meta_value( $step_id, 'wcf-optin-pass-specific-fields' ) ),
2139 );
2140
2141 return array(
2142 'step_id' => intval( $step_id ),
2143 'product' => $product,
2144 'button_text' => esc_html( $button_text ),
2145 'pass_fields' => $pass_fields,
2146 );
2147
2148 } catch ( \Exception $e ) {
2149 return $this->error( $e );
2150 }
2151 }
2152
2153 // ============================================================
2154 // Execute Callbacks — Optin Write
2155 // ============================================================
2156
2157 /**
2158 * Update optin product assignment.
2159 *
2160 * @param array $input Validated input.
2161 * @return array
2162 * @throws \Exception On failure.
2163 */
2164 public function update_optin_product( $input ) {
2165
2166 try {
2167 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-optin-product' );
2168
2169 if ( ! function_exists( 'WC' ) ) {
2170 throw new \Exception( esc_html__( 'WooCommerce is required for optin abilities.', 'cartflows' ) );
2171 }
2172
2173 $step_id = $this->input_get( 'step_id' );
2174 $product_id = $this->input_get( 'product_id' );
2175
2176 if ( null === $step_id || 0 === intval( $step_id ) ) {
2177 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
2178 }
2179
2180 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
2181 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
2182 }
2183
2184 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
2185 if ( 'optin' !== $step_type ) {
2186 throw new \Exception( esc_html__( 'Step is not an optin type.', 'cartflows' ) );
2187 }
2188
2189 if ( $product_id <= 0 ) {
2190 throw new \Exception( esc_html__( 'Invalid product ID.', 'cartflows' ) );
2191 }
2192
2193 $product_obj = wc_get_product( $product_id );
2194 if ( ! $product_obj ) {
2195 throw new \Exception( esc_html__( 'WooCommerce product not found.', 'cartflows' ) );
2196 }
2197
2198 if ( ! $product_obj->is_type( 'simple' ) ) {
2199 throw new \Exception( esc_html__( 'Product must be a Simple product type.', 'cartflows' ) );
2200 }
2201
2202 if ( ! $product_obj->is_virtual() ) {
2203 throw new \Exception( esc_html__( 'Product must be Virtual.', 'cartflows' ) );
2204 }
2205
2206 if ( $product_obj->get_price() > 0 ) {
2207 throw new \Exception( esc_html__( 'Product price must be zero (free).', 'cartflows' ) );
2208 }
2209
2210 update_post_meta( $step_id, 'wcf-optin-product', array( $product_id ) );
2211
2212 return array(
2213 'step_id' => intval( $step_id ),
2214 'product_id' => intval( $product_id ),
2215 'product_name' => esc_html( $product_obj->get_name() ),
2216 'message' => esc_html__( 'Optin product updated successfully.', 'cartflows' ),
2217 );
2218
2219 } catch ( \Exception $e ) {
2220 return $this->error( $e );
2221 }
2222 }
2223
2224 /**
2225 * Update optin submit button text.
2226 *
2227 * @param array $input Validated input.
2228 * @return array
2229 * @throws \Exception On failure.
2230 */
2231 public function update_optin_button_text( $input ) {
2232
2233 try {
2234 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-optin-button-text' );
2235
2236 if ( ! function_exists( 'WC' ) ) {
2237 throw new \Exception( esc_html__( 'WooCommerce is required for optin abilities.', 'cartflows' ) );
2238 }
2239
2240 $step_id = $this->input_get( 'step_id' );
2241 $button_text = $this->input_get( 'button_text' );
2242
2243 if ( null === $step_id || 0 === intval( $step_id ) ) {
2244 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
2245 }
2246
2247 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
2248 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
2249 }
2250
2251 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
2252 if ( 'optin' !== $step_type ) {
2253 throw new \Exception( esc_html__( 'Step is not an optin type.', 'cartflows' ) );
2254 }
2255
2256 if ( empty( $button_text ) ) {
2257 throw new \Exception( esc_html__( 'Button text cannot be empty.', 'cartflows' ) );
2258 }
2259
2260 update_post_meta( $step_id, 'wcf-submit-button-text', sanitize_text_field( $button_text ) );
2261
2262 return array(
2263 'step_id' => intval( $step_id ),
2264 'button_text' => esc_html( get_post_meta( $step_id, 'wcf-submit-button-text', true ) ),
2265 'message' => esc_html__( 'Optin button text updated successfully.', 'cartflows' ),
2266 );
2267
2268 } catch ( \Exception $e ) {
2269 return $this->error( $e );
2270 }
2271 }
2272
2273 /**
2274 * Update optin pass-fields settings.
2275 *
2276 * @param array $input Validated input.
2277 * @return array
2278 * @throws \Exception On failure.
2279 */
2280 public function update_optin_pass_fields( $input ) {
2281
2282 try {
2283 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-optin-pass-fields' );
2284
2285 if ( ! function_exists( 'WC' ) ) {
2286 throw new \Exception( esc_html__( 'WooCommerce is required for optin abilities.', 'cartflows' ) );
2287 }
2288
2289 $step_id = $this->input_get( 'step_id' );
2290
2291 if ( null === $step_id || 0 === intval( $step_id ) ) {
2292 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
2293 }
2294
2295 if ( get_post_type( $step_id ) !== CARTFLOWS_STEP_POST_TYPE ) {
2296 throw new \Exception( esc_html__( 'Step not found.', 'cartflows' ) );
2297 }
2298
2299 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
2300 if ( 'optin' !== $step_type ) {
2301 throw new \Exception( esc_html__( 'Step is not an optin type.', 'cartflows' ) );
2302 }
2303
2304 $enabled = $this->input_get( 'enabled', null );
2305 $specific_fields = $this->input_get( 'specific_fields', null );
2306 $updated = false;
2307
2308 if ( null !== $enabled && '' !== $enabled ) {
2309 update_post_meta( $step_id, 'wcf-optin-pass-fields', sanitize_text_field( $enabled ) );
2310 $updated = true;
2311 }
2312
2313 if ( null !== $specific_fields && '' !== $specific_fields ) {
2314 update_post_meta( $step_id, 'wcf-optin-pass-specific-fields', sanitize_text_field( $specific_fields ) );
2315 $updated = true;
2316 }
2317
2318 if ( ! $updated ) {
2319 throw new \Exception( esc_html__( 'No pass-fields settings provided to update.', 'cartflows' ) );
2320 }
2321
2322 return array(
2323 'step_id' => intval( $step_id ),
2324 'enabled' => esc_html( wcf()->options->get_optin_meta_value( $step_id, 'wcf-optin-pass-fields' ) ),
2325 'specific_fields' => esc_html( wcf()->options->get_optin_meta_value( $step_id, 'wcf-optin-pass-specific-fields' ) ),
2326 'message' => esc_html__( 'Optin pass-fields settings updated successfully.', 'cartflows' ),
2327 );
2328
2329 } catch ( \Exception $e ) {
2330 return $this->error( $e );
2331 }
2332 }
2333
2334 // ── Landing ─────────────────────────────────────────────────
2335
2336 /**
2337 * Return the configuration of a landing step.
2338 *
2339 * Fields returned: slug, next_step_link, disable_step.
2340 *
2341 * @param array $input Ability input containing step_id.
2342 * @return array|WP_Error
2343 * @throws \Exception On failure.
2344 */
2345 public function get_landing_settings( $input ) {
2346
2347 try {
2348
2349 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-landing-settings' );
2350
2351 $step_id = $this->input_get( 'step_id' );
2352
2353 if ( ! $step_id || 'cartflows_step' !== get_post_type( $step_id ) ) {
2354 throw new \Exception( esc_html__( 'Invalid step ID.', 'cartflows' ) );
2355 }
2356
2357 $step_type = get_post_meta( $step_id, 'wcf-step-type', true );
2358
2359 if ( 'landing' !== $step_type ) {
2360 throw new \Exception( esc_html__( 'The provided step is not a landing step.', 'cartflows' ) );
2361 }
2362
2363 $slug = get_post_field( 'post_name', $step_id );
2364 $next_step_link = wcf()->utils->get_linking_url(
2365 array( 'id' => $step_id )
2366 );
2367 $disable_step = get_post_meta( $step_id, 'wcf-disable-step', true );
2368
2369 return array(
2370 'step_id' => intval( $step_id ),
2371 'slug' => esc_html( $slug ),
2372 'next_step_link' => esc_url( $next_step_link ),
2373 'disable_step' => esc_html( ! empty( $disable_step ) ? $disable_step : 'no' ),
2374 );
2375
2376 } catch ( \Exception $e ) {
2377 return $this->error( $e );
2378 }
2379 }
2380
2381 // ============================================================
2382 // Execute Callbacks — Email Report
2383 // ============================================================
2384
2385 /**
2386 * Get email report settings: enabled status, recipient list, next scheduled time.
2387 *
2388 * @param array $input Validated input.
2389 * @return array
2390 * @throws \Exception On failure.
2391 */
2392 public function get_email_report_settings( $input ) {
2393
2394 try {
2395
2396 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-email-report-settings' );
2397
2398 $enabled = get_option( 'cartflows_stats_report_emails', 'enable' );
2399 $email_raw = get_option( 'cartflows_stats_report_email_ids', get_option( 'admin_email' ) );
2400
2401 $email_ids = array();
2402 if ( ! empty( $email_raw ) ) {
2403 $email_ids = array_values( array_filter( array_map( 'trim', preg_split( "/[\f\r\n]+/", $email_raw ) ) ) );
2404 }
2405
2406 $next_scheduled = '';
2407 if ( function_exists( 'as_next_scheduled_action' ) ) {
2408 $timestamp = as_next_scheduled_action( 'cartflows_send_report_summary_email' );
2409 if ( ! empty( $timestamp ) && false !== $timestamp ) {
2410 $next_scheduled = gmdate( 'c', $timestamp );
2411 }
2412 }
2413
2414 return array(
2415 'enabled' => esc_html( $enabled ),
2416 'email_ids' => array_map( 'sanitize_email', $email_ids ),
2417 'next_scheduled' => esc_html( $next_scheduled ),
2418 );
2419
2420 } catch ( \Exception $e ) {
2421 return $this->error( $e );
2422 }
2423 }
2424
2425 /**
2426 * Update email report settings: toggle enabled, update recipient list.
2427 *
2428 * @param array $input Validated input.
2429 * @return array
2430 * @throws \Exception On failure.
2431 */
2432 public function update_email_report_settings( $input ) {
2433
2434 try {
2435
2436 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-email-report-settings' );
2437
2438 $enabled = $this->input_get( 'enabled', '' );
2439 $email_ids = $this->input_get( 'email_ids', array() );
2440
2441 if ( ! empty( $enabled ) ) {
2442 $enabled = in_array( $enabled, array( 'enable', 'disable' ), true ) ? $enabled : 'enable';
2443 update_option( 'cartflows_stats_report_emails', $enabled );
2444 }
2445
2446 if ( ! empty( $email_ids ) && is_array( $email_ids ) ) {
2447 $sanitized = array_values( array_filter( array_map( 'sanitize_email', $email_ids ) ) );
2448 $email_str = implode( "\n", $sanitized );
2449 update_option( 'cartflows_stats_report_email_ids', $email_str );
2450 }
2451
2452 // Read back saved values.
2453 $saved_enabled = get_option( 'cartflows_stats_report_emails', 'enable' );
2454 $saved_email_raw = get_option( 'cartflows_stats_report_email_ids', get_option( 'admin_email' ) );
2455 $saved_email_ids = array();
2456 if ( ! empty( $saved_email_raw ) ) {
2457 $saved_email_ids = array_values( array_filter( array_map( 'trim', preg_split( "/[\f\r\n]+/", $saved_email_raw ) ) ) );
2458 }
2459
2460 return array(
2461 'enabled' => esc_html( $saved_enabled ),
2462 'email_ids' => array_map( 'sanitize_email', $saved_email_ids ),
2463 'message' => esc_html__( 'Email report settings updated.', 'cartflows' ),
2464 );
2465
2466 } catch ( \Exception $e ) {
2467 return $this->error( $e );
2468 }
2469 }
2470
2471 // ============================================================
2472 // Execute Callbacks — Woo Dynamic Flow
2473 // ============================================================
2474
2475 /**
2476 * Get flow mapping for a single WooCommerce product.
2477 *
2478 * @param array $input Validated input.
2479 * @return array
2480 * @throws \Exception On failure.
2481 */
2482 public function get_product_flow_mapping( $input ) {
2483
2484 try {
2485
2486 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'get-product-flow-mapping' );
2487
2488 if ( ! class_exists( 'WooCommerce' ) ) {
2489 throw new \Exception( esc_html__( 'WooCommerce is not active.', 'cartflows' ) );
2490 }
2491
2492 $product_id = $this->input_get( 'product_id' );
2493 $product = wc_get_product( $product_id );
2494
2495 if ( ! $product ) {
2496 throw new \Exception( esc_html__( 'Invalid product ID.', 'cartflows' ) );
2497 }
2498
2499 $flow_id = intval( $product->get_meta( 'cartflows_redirect_flow_id' ) );
2500 $flow_title = '';
2501 $add_to_cart_text = $product->get_meta( 'cartflows_add_to_cart_text' );
2502
2503 if ( ! empty( $flow_id ) && get_post_type( $flow_id ) === CARTFLOWS_FLOW_POST_TYPE ) {
2504 $flow_title = get_the_title( $flow_id );
2505 }
2506
2507 return array(
2508 'product_id' => intval( $product_id ),
2509 'product_title' => esc_html( $product->get_name() ),
2510 'flow_id' => $flow_id,
2511 'flow_title' => esc_html( $flow_title ),
2512 'add_to_cart_text' => esc_html( wp_unslash( $add_to_cart_text ) ),
2513 'redirect_add_to_cart' => $this->product_redirects_to_flow( $product, $flow_id ),
2514 );
2515
2516 } catch ( \Exception $e ) {
2517 return $this->error( $e );
2518 }
2519 }
2520
2521 /**
2522 * List all WooCommerce products that have a flow mapping.
2523 *
2524 * @param array $input Validated input.
2525 * @return array
2526 * @throws \Exception On failure.
2527 */
2528 public function list_product_flow_mappings( $input ) {
2529
2530 try {
2531
2532 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'list-product-flow-mappings' );
2533
2534 if ( ! class_exists( 'WooCommerce' ) ) {
2535 throw new \Exception( esc_html__( 'WooCommerce is not active.', 'cartflows' ) );
2536 }
2537
2538 $page = $this->clamp_page( $this->input_get( 'paged' ) );
2539 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
2540
2541 $query = new \WP_Query(
2542 array(
2543 'post_type' => 'product',
2544 'post_status' => 'publish',
2545 'posts_per_page' => $per_page,
2546 'paged' => $page,
2547 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
2548 array(
2549 'key' => 'cartflows_redirect_flow_id',
2550 'value' => array( '', '0' ),
2551 'compare' => 'NOT IN',
2552 ),
2553 ),
2554 'fields' => 'ids',
2555 )
2556 );
2557
2558 $mappings = array();
2559
2560 if ( ! empty( $query->posts ) ) {
2561 foreach ( $query->posts as $pid ) {
2562 $product = wc_get_product( $pid );
2563 if ( ! $product ) {
2564 continue;
2565 }
2566
2567 $flow_id = intval( $product->get_meta( 'cartflows_redirect_flow_id' ) );
2568 $flow_title = '';
2569 if ( ! empty( $flow_id ) && get_post_type( $flow_id ) === CARTFLOWS_FLOW_POST_TYPE ) {
2570 $flow_title = get_the_title( $flow_id );
2571 }
2572
2573 $mappings[] = array(
2574 'product_id' => intval( $pid ),
2575 'product_title' => esc_html( $product->get_name() ),
2576 'flow_id' => $flow_id,
2577 'flow_title' => esc_html( $flow_title ),
2578 'add_to_cart_text' => esc_html( wp_unslash( $product->get_meta( 'cartflows_add_to_cart_text' ) ) ),
2579 'redirect_add_to_cart' => $this->product_redirects_to_flow( $product, $flow_id ),
2580 );
2581 }
2582 }
2583
2584 $total = intval( $query->found_posts );
2585 $total_pages = intval( $query->max_num_pages );
2586
2587 return array(
2588 'mappings' => $mappings,
2589 'total' => $total,
2590 'total_pages' => $total_pages,
2591 'page' => $page,
2592 );
2593
2594 } catch ( \Exception $e ) {
2595 return $this->error( $e );
2596 }
2597 }
2598
2599 /**
2600 * Whether Add to Cart on this product will redirect into its mapped flow.
2601 *
2602 * Mirrors the conditions in Cartflows_Wd_Flow_Product_Actions::redirect_to_next_flow_step().
2603 *
2604 * @since x.x.x
2605 * @param \WC_Product $product Product.
2606 * @param int $flow_id Mapped flow ID.
2607 * @return bool
2608 */
2609 private function product_redirects_to_flow( $product, $flow_id ) {
2610
2611 if ( empty( $flow_id ) || 'publish' !== get_post_status( $flow_id ) || 'no' === $product->get_meta( 'cartflows_redirect_add_to_cart' ) ) {
2612 return false;
2613 }
2614
2615 $steps = get_post_meta( $flow_id, 'wcf-steps', true );
2616
2617 return is_array( $steps ) && ! empty( $steps[0]['id'] );
2618 }
2619
2620 /**
2621 * Update flow mapping on a WooCommerce product.
2622 *
2623 * @param array $input Validated input.
2624 * @return array
2625 * @throws \Exception On failure.
2626 */
2627 public function update_product_flow_mapping( $input ) {
2628
2629 try {
2630
2631 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'update-product-flow-mapping' );
2632
2633 if ( ! class_exists( 'WooCommerce' ) ) {
2634 throw new \Exception( esc_html__( 'WooCommerce is not active.', 'cartflows' ) );
2635 }
2636
2637 $product_id = $this->input_get( 'product_id' );
2638 $product = wc_get_product( $product_id );
2639
2640 if ( ! $product ) {
2641 throw new \Exception( esc_html__( 'Invalid product ID.', 'cartflows' ) );
2642 }
2643
2644 $flow_id = $this->input_get( 'flow_id', '' );
2645 $add_to_cart_text = $this->input_get( 'add_to_cart_text', '' );
2646
2647 if ( '' !== $flow_id ) {
2648 $flow_id = intval( $flow_id );
2649
2650 // Validate flow exists if non-zero.
2651 if ( $flow_id > 0 && get_post_type( $flow_id ) !== CARTFLOWS_FLOW_POST_TYPE ) {
2652 throw new \Exception( esc_html__( 'Invalid flow ID. Flow does not exist.', 'cartflows' ) );
2653 }
2654
2655 $product->update_meta_data( 'cartflows_redirect_flow_id', $flow_id );
2656 }
2657
2658 if ( '' !== $add_to_cart_text ) {
2659 $product->update_meta_data( 'cartflows_add_to_cart_text', sanitize_text_field( $add_to_cart_text ) );
2660 }
2661
2662 $product->save();
2663
2664 // Read back saved values.
2665 $saved_flow_id = intval( $product->get_meta( 'cartflows_redirect_flow_id' ) );
2666 $saved_flow_title = '';
2667 if ( ! empty( $saved_flow_id ) && get_post_type( $saved_flow_id ) === CARTFLOWS_FLOW_POST_TYPE ) {
2668 $saved_flow_title = get_the_title( $saved_flow_id );
2669 }
2670 $saved_button_text = $product->get_meta( 'cartflows_add_to_cart_text' );
2671
2672 return array(
2673 'product_id' => intval( $product_id ),
2674 'flow_id' => $saved_flow_id,
2675 'flow_title' => esc_html( $saved_flow_title ),
2676 'add_to_cart_text' => esc_html( wp_unslash( $saved_button_text ) ),
2677 'message' => esc_html__( 'Product flow mapping updated.', 'cartflows' ),
2678 );
2679
2680 } catch ( \Exception $e ) {
2681 return $this->error( $e );
2682 }
2683 }
2684
2685 // ============================================================
2686 // Execute Callbacks — Flow Creation / Import
2687 // ============================================================
2688
2689 /**
2690 * Create a new empty funnel.
2691 *
2692 * @param array $input Validated input.
2693 * @return array
2694 * @throws \Exception On failure.
2695 */
2696 public function create_flow( $input ) {
2697
2698 try {
2699 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'create-flow' );
2700
2701 $title = $this->input_get( 'title' );
2702 $status = $this->input_get( 'status' );
2703
2704 if ( empty( $title ) ) {
2705 throw new \Exception( esc_html__( 'Funnel title cannot be empty.', 'cartflows' ) );
2706 }
2707
2708 $new_flow_id = wp_insert_post(
2709 array(
2710 'post_type' => CARTFLOWS_FLOW_POST_TYPE,
2711 'post_title' => sanitize_text_field( $title ),
2712 'post_status' => $status,
2713 'post_content' => '',
2714 )
2715 );
2716
2717 if ( is_wp_error( $new_flow_id ) || ! $new_flow_id ) {
2718 throw new \Exception( esc_html__( 'Failed to create funnel.', 'cartflows' ) );
2719 }
2720
2721 update_post_meta( $new_flow_id, 'wcf-steps', array() );
2722
2723 return array(
2724 'flow_id' => intval( $new_flow_id ),
2725 'title' => esc_html( get_the_title( $new_flow_id ) ),
2726 'status' => esc_html( get_post_status( $new_flow_id ) ),
2727 'edit_url' => esc_url( admin_url( 'admin.php?page=cartflows&path=flows&action=wcf-edit-flow&flow_id=' . $new_flow_id ) ),
2728 );
2729
2730 } catch ( \Exception $e ) {
2731 return $this->error( $e );
2732 }
2733 }
2734
2735 /**
2736 * Create a new step and attach it to a funnel.
2737 *
2738 * @param array $input Validated input.
2739 * @return array
2740 * @throws \Exception On failure.
2741 */
2742 public function create_step( $input ) {
2743
2744 try {
2745 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'create-step' );
2746
2747 $flow_id = $this->input_get( 'flow_id' );
2748 $title = $this->input_get( 'title' );
2749 $type = $this->input_get( 'type' );
2750
2751 if ( 0 === $flow_id ) {
2752 throw new \Exception( esc_html__( 'Invalid funnel ID.', 'cartflows' ) );
2753 }
2754
2755 if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) {
2756 throw new \Exception( esc_html__( 'Funnel not found.', 'cartflows' ) );
2757 }
2758
2759 if ( empty( $title ) ) {
2760 throw new \Exception( esc_html__( 'Step title cannot be empty.', 'cartflows' ) );
2761 }
2762
2763 // Upsell/downsell require CartFlows Pro.
2764 if ( in_array( $type, array( 'upsell', 'downsell' ), true ) ) {
2765 throw new \Exception( esc_html__( 'Upsell and Downsell steps require CartFlows Pro.', 'cartflows' ) );
2766 }
2767
2768 $new_step_id = wp_insert_post(
2769 array(
2770 'post_type' => CARTFLOWS_STEP_POST_TYPE,
2771 'post_title' => sanitize_text_field( $title ),
2772 'post_status' => 'publish',
2773 )
2774 );
2775
2776 if ( is_wp_error( $new_step_id ) || ! $new_step_id ) {
2777 throw new \Exception( esc_html__( 'Failed to create step.', 'cartflows' ) );
2778 }
2779
2780 // Set step meta.
2781 update_post_meta( $new_step_id, 'wcf-flow-id', $flow_id );
2782 update_post_meta( $new_step_id, 'wcf-step-type', $type );
2783
2784 // Set taxonomy terms.
2785 wp_set_object_terms( $new_step_id, $type, CARTFLOWS_TAXONOMY_STEP_TYPE );
2786 wp_set_object_terms( $new_step_id, 'flow-' . $flow_id, CARTFLOWS_TAXONOMY_STEP_FLOW );
2787
2788 // Append to flow's wcf-steps meta.
2789 $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true );
2790 if ( ! is_array( $flow_steps ) ) {
2791 $flow_steps = array();
2792 }
2793
2794 $flow_steps[] = array(
2795 'id' => intval( $new_step_id ),
2796 'title' => sanitize_text_field( $title ),
2797 'type' => $type,
2798 );
2799
2800 if ( method_exists( '\Cartflows_Helper', 'get_instance' ) ) {
2801 $flow_steps = \Cartflows_Helper::get_instance()->maybe_update_flow_steps( $flow_id, $flow_steps );
2802 }
2803
2804 update_post_meta( $flow_id, 'wcf-steps', $flow_steps );
2805 AdminHelper::clear_cache();
2806
2807 return array(
2808 'step_id' => intval( $new_step_id ),
2809 'flow_id' => intval( $flow_id ),
2810 'title' => esc_html( get_the_title( $new_step_id ) ),
2811 'type' => esc_html( $type ),
2812 'edit_url' => esc_url( admin_url( 'admin.php?page=cartflows&path=flows&action=wcf-edit-flow&flow_id=' . $flow_id ) ),
2813 );
2814
2815 } catch ( \Exception $e ) {
2816 return $this->error( $e );
2817 }
2818 }
2819
2820 // ============================================================
2821 // Execute Callbacks — Templates Browse
2822 // ============================================================
2823
2824 /**
2825 * List available flow templates from the cached library.
2826 *
2827 * @param array $input Validated input.
2828 * @return array
2829 * @throws \Exception On failure.
2830 */
2831 public function list_flow_templates( $input ) {
2832
2833 try {
2834 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'list-flow-templates' );
2835
2836 $type = $this->input_get( 'type' );
2837 $page_builder = $this->input_get( 'page_builder' );
2838 $step_type = $this->input_get( 'step_type' );
2839 $category = $this->input_get( 'category' );
2840 $search = $this->input_get( 'search' );
2841 $page = $this->clamp_page( $this->input_get( 'page' ) );
2842 $per_page = $this->clamp_per_page( $this->input_get( 'per_page' ) );
2843
2844 // Use provided page builder or fall back to the default.
2845 if ( empty( $page_builder ) ) {
2846 $page_builder = \Cartflows_Helper::get_common_setting( 'default_page_builder' );
2847 }
2848
2849 $all_flows = \Cartflows_Helper::get_instance()->get_flows_and_steps( sanitize_text_field( $page_builder ) );
2850
2851 if ( ! is_array( $all_flows ) ) {
2852 $all_flows = array();
2853 }
2854
2855 $templates = array();
2856
2857 foreach ( $all_flows as $flow ) {
2858 $flow = (array) $flow;
2859
2860 $flow_id = isset( $flow['ID'] ) ? intval( $flow['ID'] ) : 0;
2861 $flow_title = isset( $flow['title'] ) ? $flow['title'] : '';
2862 $flow_type = isset( $flow['type'] ) ? $flow['type'] : '';
2863 $is_pro = 'pro' === $flow_type;
2864 $thumbnail = isset( $flow['featured_image_url'] ) ? $flow['featured_image_url'] : '';
2865 $pb = isset( $flow['page_builder'] ) ? $flow['page_builder'] : $page_builder;
2866 $categories = isset( $flow['category'] ) ? (array) $flow['category'] : array();
2867
2868 // Get step types in this flow.
2869 $flow_steps = isset( $flow['steps'] ) ? (array) $flow['steps'] : array();
2870 $flow_step_types = array();
2871 foreach ( $flow_steps as $step ) {
2872 $step = (array) $step;
2873 if ( isset( $step['type'] ) ) {
2874 $flow_step_types[] = $step['type'];
2875 }
2876 }
2877
2878 // Filter by type (free/pro) if provided.
2879 if ( ! empty( $type ) && $flow_type !== $type ) {
2880 continue;
2881 }
2882
2883 // Filter by step type if provided.
2884 if ( ! empty( $step_type ) && ! in_array( $step_type, $flow_step_types, true ) ) {
2885 continue;
2886 }
2887
2888 // Filter by category if provided.
2889 if ( ! empty( $category ) && ! in_array( $category, $categories, true ) ) {
2890 continue;
2891 }
2892
2893 // Filter by search keyword if provided.
2894 if ( ! empty( $search ) && false === stripos( $flow_title, $search ) ) {
2895 continue;
2896 }
2897
2898 $templates[] = array(
2899 'id' => $flow_id,
2900 'title' => esc_html( $flow_title ),
2901 'type' => esc_html( $flow_type ),
2902 'page_builder' => esc_html( $pb ),
2903 'is_pro' => $is_pro,
2904 'categories' => array_map( 'sanitize_text_field', $categories ),
2905 'step_count' => count( $flow_steps ),
2906 'step_types' => array_map( 'sanitize_text_field', $flow_step_types ),
2907 'thumbnail_url' => esc_url( (string) $thumbnail ),
2908 );
2909 }
2910
2911 $total = count( $templates );
2912 $offset = ( $page - 1 ) * $per_page;
2913 $paged = array_slice( $templates, $offset, $per_page );
2914
2915 return array(
2916 'templates' => $paged,
2917 'total' => $total,
2918 'page' => $page,
2919 'per_page' => $per_page,
2920 'pro_available' => _is_cartflows_pro(),
2921 );
2922
2923 } catch ( \Exception $e ) {
2924 return $this->error( $e );
2925 }
2926 }
2927
2928 // ============================================================
2929 // Execute Callbacks — Templates Import
2930 // ============================================================
2931
2932 /**
2933 * Import a flow template from the CartFlows cloud library.
2934 *
2935 * @param array $input Validated input.
2936 * @return array
2937 * @throws \Exception On failure.
2938 */
2939 public function import_flow_template( $input ) {
2940
2941 try {
2942 $this->init( $input, CARTFLOWS_ABILITY_API_NAMESPACE . 'import-flow-template' );
2943
2944 if ( ! function_exists( 'WC' ) ) {
2945 throw new \Exception( esc_html__( 'WooCommerce is required to import flow templates.', 'cartflows' ) );
2946 }
2947
2948 $template_id = $this->input_get( 'template_id' );
2949
2950 if ( 0 === $template_id ) {
2951 throw new \Exception( esc_html__( 'Invalid template ID.', 'cartflows' ) );
2952 }
2953
2954 // Fetch the flow template from the CartFlows cloud API.
2955 $response = \CartFlows_API::get_instance()->get_flow( $template_id );
2956
2957 if ( ! isset( $response['success'] ) || ! $response['success'] ) {
2958 $message = isset( $response['message'] ) ? $response['message'] : __( 'Failed to fetch template from CartFlows library.', 'cartflows' );
2959 throw new \Exception( esc_html( $message ) );
2960 }
2961
2962 // Check for API errors.
2963 $is_error = AdminHelper::has_api_error( $response['data'] );
2964 if ( $is_error['error'] ) {
2965 throw new \Exception( esc_html( $is_error['error_message'] ) );
2966 }
2967
2968 // Check licence status for Pro templates.
2969 $license_status = isset( $response['data']['licence_status'] ) ? $response['data']['licence_status'] : '';
2970
2971 if ( 'valid' !== $license_status && ! _is_cartflows_pro() ) {
2972 throw new \Exception( esc_html__( 'This template requires CartFlows Pro. Please upgrade to import premium templates.', 'cartflows' ) );
2973 }
2974
2975 // Extract the flow title.
2976 $flow_title = isset( $response['title'] ) ? sanitize_text_field( $response['title'] ) : __( 'Imported Flow', 'cartflows' );
2977
2978 // Create the flow post.
2979 $new_flow_id = wp_insert_post(
2980 array(
2981 'post_type' => CARTFLOWS_FLOW_POST_TYPE,
2982 'post_title' => $flow_title,
2983 'post_status' => 'publish',
2984 'post_content' => '',
2985 )
2986 );
2987
2988 if ( is_wp_error( $new_flow_id ) || ! $new_flow_id ) {
2989 throw new \Exception( esc_html__( 'Failed to create funnel from template.', 'cartflows' ) );
2990 }
2991
2992 update_post_meta( $new_flow_id, 'wcf-steps', array() );
2993
2994 // Import flow post meta if available.
2995 if ( ! empty( $response['post_meta'] ) ) {
2996 $exclude_meta_keys = \Cartflows_Helper::get_instance()->get_meta_keys_to_exclude_from_import( $new_flow_id );
2997 foreach ( (array) $response['post_meta'] as $meta_key => $meta_value ) {
2998 if ( in_array( $meta_key, $exclude_meta_keys, true ) ) {
2999 continue;
3000 }
3001 $meta_value = isset( $meta_value[0] ) ? $meta_value[0] : '';
3002 if ( $meta_value ) {
3003 if ( is_serialized( $meta_value, true ) ) {
3004 $raw_data = unserialize( stripslashes( $meta_value ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, PHPCompatibility.FunctionUse.NewFunctionParameters.unserialize_optionsFound
3005 if ( false === $raw_data || is_object( $raw_data ) ) {
3006 $raw_data = '';
3007 }
3008 } else {
3009 $raw_data = $meta_value;
3010 }
3011 update_post_meta( $new_flow_id, $meta_key, $raw_data );
3012 }
3013 }
3014 }
3015
3016 // Process steps from the template.
3017 // Get the steps list from the locally cached flows_and_steps data,
3018 // matching how the working AJAX import_flow() method gets steps from the frontend.
3019 // The remote API response does not contain steps in a reliably iterable format.
3020 $steps_raw = array();
3021 $all_flows_data = \Cartflows_Helper::get_instance()->get_flows_and_steps();
3022
3023 if ( ! empty( $all_flows_data ) && is_array( $all_flows_data ) ) {
3024 foreach ( $all_flows_data as $cached_flow ) {
3025 $cached_flow = (array) $cached_flow;
3026 $cached_flow_id = isset( $cached_flow['ID'] ) ? absint( $cached_flow['ID'] ) : 0;
3027
3028 if ( $cached_flow_id === $template_id ) {
3029 if ( isset( $cached_flow['steps'] ) ) {
3030 // Convert each step from stdClass to array if needed.
3031 foreach ( $cached_flow['steps'] as $cached_step ) {
3032 $steps_raw[] = (array) $cached_step;
3033 }
3034 }
3035 break;
3036 }
3037 }
3038 }
3039
3040 // Fallback: try the remote API response if cached data did not yield steps.
3041 if ( empty( $steps_raw ) ) {
3042 $template_data = isset( $response['data'] ) ? $response['data'] : array();
3043
3044 // cartflows_flow_flow may be a stdClass from json_decode, so cast it.
3045 if ( isset( $template_data['cartflows_flow_flow'] ) && ! empty( $template_data['cartflows_flow_flow'] ) ) {
3046 foreach ( (array) $template_data['cartflows_flow_flow'] as $api_step ) {
3047 $steps_raw[] = (array) $api_step;
3048 }
3049 }
3050 }
3051
3052 $imported_steps = array();
3053 $skipped_steps = array();
3054 $importer = \CartFlows_Importer::get_instance();
3055
3056 // Set the batch import flag before processing steps.
3057 \CartFlows_Batch_Process::set_is_wcf_template_import( true );
3058
3059 foreach ( $steps_raw as $step ) {
3060 $step = (array) $step;
3061
3062 $step_remote_id = isset( $step['ID'] ) ? absint( $step['ID'] ) : ( isset( $step['id'] ) ? absint( $step['id'] ) : 0 );
3063 $step_title = isset( $step['title'] ) ? sanitize_text_field( $step['title'] ) : '';
3064 $step_type = isset( $step['type'] ) ? sanitize_text_field( $step['type'] ) : '';
3065
3066 // Skip upsell/downsell if Pro is not active.
3067 if ( in_array( $step_type, array( 'upsell', 'downsell' ), true ) && ( ! _is_cartflows_pro() || is_wcf_starter_plan() ) ) {
3068 $skipped_steps[] = array(
3069 'title' => esc_html( $step_title ),
3070 'type' => esc_html( $step_type ),
3071 'reason' => esc_html__( 'Upsell and Downsell steps require CartFlows Pro.', 'cartflows' ),
3072 );
3073 continue;
3074 }
3075
3076 if ( empty( $step_remote_id ) ) {
3077 continue;
3078 }
3079
3080 // Use the importer to create the step locally.
3081 $new_step_id = $importer->create_step( $new_flow_id, $step_type, $step_title );
3082
3083 if ( ! empty( $new_step_id ) && ! is_wp_error( $new_step_id ) ) {
3084 // Fetch and import the step template content.
3085 $step_response = \CartFlows_API::get_instance()->get_template( $step_remote_id );
3086
3087 if ( isset( $step_response['success'] ) && $step_response['success'] ) {
3088 // Handle Divi content.
3089 if ( 'divi' === \Cartflows_Helper::get_common_setting( 'default_page_builder' ) ) {
3090 if ( isset( $step_response['data']['divi_content'] ) && ! empty( $step_response['data']['divi_content'] ) ) {
3091 update_post_meta( $new_step_id, 'divi_content', $step_response['data']['divi_content'] );
3092 wp_update_post(
3093 array(
3094 'ID' => $new_step_id,
3095 'post_content' => $step_response['data']['divi_content'],
3096 )
3097 );
3098 }
3099 }
3100
3101 // Handle Gutenberg content.
3102 if ( 'gutenberg' === \Cartflows_Helper::get_common_setting( 'default_page_builder' ) ) {
3103 if ( isset( $step_response['data']['divi_content'] ) && ! empty( $step_response['data']['divi_content'] ) ) {
3104 wp_update_post(
3105 array(
3106 'ID' => $new_step_id,
3107 'post_content' => $step_response['data']['divi_content'],
3108 )
3109 );
3110 }
3111 }
3112
3113 // Import step post meta.
3114 if ( ! empty( $step_response['post_meta'] ) ) {
3115 $step_exclude_keys = \Cartflows_Helper::get_instance()->get_meta_keys_to_exclude_from_import( $new_step_id );
3116 foreach ( (array) $step_response['post_meta'] as $meta_key => $meta_value ) {
3117 if ( in_array( $meta_key, $step_exclude_keys, true ) ) {
3118 continue;
3119 }
3120 $meta_value = isset( $meta_value[0] ) ? $meta_value[0] : '';
3121 if ( $meta_value ) {
3122 if ( is_serialized( $meta_value, true ) ) {
3123 $raw_data = unserialize( stripslashes( $meta_value ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, PHPCompatibility.FunctionUse.NewFunctionParameters.unserialize_optionsFound
3124 if ( false === $raw_data || is_object( $raw_data ) ) {
3125 $raw_data = '';
3126 }
3127 } elseif ( '_elementor_data' === $meta_key ) {
3128 $raw_data = is_array( $meta_value ) ? wp_slash( wp_json_encode( $meta_value ) ) : wp_slash( $meta_value );
3129 } else {
3130 $raw_data = $meta_value;
3131 }
3132 update_post_meta( $new_step_id, $meta_key, $raw_data );
3133 }
3134 }
3135 }
3136
3137 update_post_meta( $new_step_id, 'cartflows_imported_step', 'yes' );
3138
3139 do_action( 'cartflows_import_complete' );
3140 do_action( 'cartflows_after_template_import', $new_step_id, $step_response );
3141 }
3142
3143 $imported_steps[] = array(
3144 'step_id' => intval( $new_step_id ),
3145 'title' => esc_html( get_the_title( $new_step_id ) ),
3146 'type' => esc_html( $step_type ),
3147 );
3148 }
3149 }
3150
3151 // Reset the batch import flag after processing steps.
3152 \CartFlows_Batch_Process::set_is_wcf_template_import( false );
3153
3154 return array(
3155 'flow_id' => intval( $new_flow_id ),
3156 'title' => esc_html( get_the_title( $new_flow_id ) ),
3157 'steps' => $imported_steps,
3158 'skipped_steps' => $skipped_steps,
3159 );
3160
3161 } catch ( \Exception $e ) {
3162 return $this->error( $e );
3163 }
3164 }
3165
3166 // ============================================================
3167 // Private Helpers
3168 // ============================================================
3169
3170 /**
3171 * Set the post_status on a flow and all its steps.
3172 *
3173 * @param int $flow_id Flow post ID.
3174 * @param string $new_status New post status ('publish', 'draft', etc.).
3175 * @return void
3176 */
3177 private function set_flow_status( $flow_id, $new_status ) {
3178
3179 $steps = get_post_meta( $flow_id, 'wcf-steps', true );
3180
3181 if ( is_array( $steps ) ) {
3182 foreach ( $steps as $step ) {
3183 wp_update_post(
3184 array(
3185 'ID' => intval( $step['id'] ),
3186 'post_status' => $new_status,
3187 )
3188 );
3189 }
3190 }
3191
3192 wp_update_post(
3193 array(
3194 'ID' => $flow_id,
3195 'post_status' => $new_status,
3196 )
3197 );
3198 }
3199
3200 // ============================================================
3201 // Base Helpers (required by template)
3202 // ============================================================
3203
3204 /**
3205 * Initialize input parsing for the given ability.
3206 *
3207 * @param array $input Raw input.
3208 * @param string $ability_name Fully-qualified ability name.
3209 * @return void
3210 */
3211 public function init( $input, $ability_name ) {
3212 $this->input_parse( $input, $ability_name );
3213 }
3214
3215 /**
3216 * Check whether the current user has the given capability/capabilities.
3217 *
3218 * @param string|array $caps Single capability string or array (AND logic).
3219 * @return bool
3220 */
3221 public function permission_callback( $caps ) {
3222
3223 if ( empty( $caps ) ) {
3224 return false;
3225 }
3226
3227 $user = wp_get_current_user();
3228 if ( ! $user || 0 === $user->ID ) {
3229 return false;
3230 }
3231
3232 if ( is_string( $caps ) ) {
3233 return (bool) $user->has_cap( $caps );
3234 }
3235
3236 if ( is_array( $caps ) ) {
3237 foreach ( $caps as $cap ) {
3238 if ( ! $user->has_cap( $cap ) ) {
3239 return false;
3240 }
3241 }
3242 return true;
3243 }
3244
3245 return false;
3246 }
3247
3248 /**
3249 * Parse and validate input against the ability's input_schema.
3250 *
3251 * Applies defaults, coerces types, sanitizes, and validates enums.
3252 *
3253 * @param array $input Raw input.
3254 * @param string $ability_name Fully-qualified ability name.
3255 * @return array Parsed input.
3256 * @throws \Exception On failure.
3257 */
3258 public function input_parse( $input, $ability_name ) {
3259
3260 $this->input = array();
3261
3262 if ( is_a( $input, 'WP_REST_Request' ) ) {
3263 $input = $input->get_json_params();
3264 if ( ! is_array( $input ) ) {
3265 $input = array();
3266 }
3267 }
3268
3269 if ( ! is_array( $input ) ) {
3270 $input = array();
3271 }
3272
3273 $input_schema = Cartflows_Ability_Config::get_ability_input_schema( $ability_name );
3274 if ( ! is_array( $input_schema ) || empty( $input_schema ) ) {
3275 return array();
3276 }
3277
3278 if ( ! isset( $input_schema['properties'] ) || ! is_array( $input_schema['properties'] ) ) {
3279 return array();
3280 }
3281
3282 // Required fields: array at object level per JSON Schema standard.
3283 $required_fields = ( isset( $input_schema['required'] ) && is_array( $input_schema['required'] ) )
3284 ? $input_schema['required']
3285 : array();
3286
3287 foreach ( $input_schema['properties'] as $name => $prop ) {
3288
3289 $type = isset( $prop['type'] ) ? strtolower( $prop['type'] ) : 'string';
3290 $raw_value = array_key_exists( $name, $input ) ? $input[ $name ] : null;
3291
3292 // Required check BEFORE defaults.
3293 $is_required = in_array( $name, $required_fields, true );
3294 if ( $is_required && ( null === $raw_value || '' === $raw_value ) ) {
3295 throw new \Exception(
3296 sprintf(
3297 /* translators: %s: field name */
3298 esc_html__( 'Required field %s is missing.', 'cartflows' ),
3299 esc_html( $name )
3300 )
3301 );
3302 }
3303
3304 // Apply schema default if value not provided.
3305 if ( null === $raw_value && isset( $prop['default'] ) ) {
3306 $raw_value = $prop['default'];
3307 }
3308
3309 // Type-appropriate zero value if still null.
3310 if ( null === $raw_value ) {
3311 switch ( $type ) {
3312 case 'integer':
3313 $raw_value = 0;
3314 break;
3315 case 'number':
3316 $raw_value = 0.0;
3317 break;
3318 case 'boolean':
3319 $raw_value = false;
3320 break;
3321 case 'array':
3322 $raw_value = array();
3323 break;
3324 case 'object':
3325 $raw_value = array();
3326 break;
3327 default:
3328 $raw_value = '';
3329 break;
3330 }
3331 }
3332
3333 $value = $raw_value;
3334
3335 // Type coercion and sanitization.
3336 switch ( $type ) {
3337 case 'integer':
3338 $value = intval( $value );
3339 break;
3340 case 'number':
3341 $value = floatval( $value );
3342 break;
3343 case 'boolean':
3344 $value = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
3345 break;
3346 case 'string':
3347 $value = is_string( $value )
3348 ? sanitize_text_field( $value )
3349 : sanitize_text_field( strval( $value ) );
3350 break;
3351 case 'array':
3352 if ( ! is_array( $value ) ) {
3353 $value = array();
3354 }
3355 $value = $this->sanitize_recursive( $value );
3356 break;
3357 case 'object':
3358 if ( ! is_array( $value ) && ! is_object( $value ) ) {
3359 $value = array();
3360 }
3361 if ( is_object( $value ) ) {
3362 $value = (array) $value;
3363 }
3364 $value = $this->sanitize_recursive( $value );
3365 break;
3366 }
3367
3368 // Enum validation.
3369 if ( isset( $prop['enum'] ) && is_array( $prop['enum'] ) ) {
3370 if ( ! in_array( $value, $prop['enum'], true ) ) {
3371 throw new \Exception(
3372 sprintf(
3373 /* translators: %s: field name */
3374 esc_html__( 'Invalid value for %s.', 'cartflows' ),
3375 esc_html( $name )
3376 )
3377 );
3378 }
3379 }
3380
3381 $this->input[ $name ] = $value;
3382 }
3383
3384 return $this->input;
3385 }
3386
3387 /**
3388 * Get a parsed input value by name.
3389 *
3390 * @param string $name Property name.
3391 * @param mixed $default Sentinel: omit to throw on missing; pass a value to use as fallback.
3392 * @return mixed
3393 * @throws \Exception On failure.
3394 */
3395 public function input_get( $name, $default = '__CARTFLOWS_NO_DEFAULT__' ) {
3396
3397 if ( false === $this->input ) {
3398 throw new \Exception( esc_html__( 'Inputs not parsed.', 'cartflows' ) );
3399 }
3400
3401 if ( ! array_key_exists( $name, $this->input ) ) {
3402 if ( '__CARTFLOWS_NO_DEFAULT__' !== $default ) {
3403 return $default;
3404 }
3405 throw new \Exception(
3406 sprintf(
3407 /* translators: %s: property name */
3408 esc_html__( 'Property %s not found.', 'cartflows' ),
3409 esc_html( $name )
3410 )
3411 );
3412 }
3413
3414 return $this->input[ $name ];
3415 }
3416
3417 /**
3418 * Recursively sanitize an array.
3419 *
3420 * @param array $data Data to sanitize.
3421 * @return array
3422 */
3423 protected function sanitize_recursive( $data ) {
3424
3425 if ( ! is_array( $data ) ) {
3426 return $data;
3427 }
3428
3429 $sanitized = array();
3430 foreach ( $data as $key => $value ) {
3431 $key = sanitize_text_field( strval( $key ) );
3432 if ( is_array( $value ) ) {
3433 $sanitized[ $key ] = $this->sanitize_recursive( $value );
3434 } elseif ( is_string( $value ) ) {
3435 $sanitized[ $key ] = sanitize_text_field( $value );
3436 } elseif ( is_int( $value ) ) {
3437 $sanitized[ $key ] = intval( $value );
3438 } elseif ( is_float( $value ) ) {
3439 $sanitized[ $key ] = floatval( $value );
3440 } elseif ( is_bool( $value ) ) {
3441 $sanitized[ $key ] = (bool) $value;
3442 } else {
3443 $sanitized[ $key ] = $value;
3444 }
3445 }
3446
3447 return $sanitized;
3448 }
3449
3450 /**
3451 * Build a structured error response.
3452 *
3453 * Only exposes file/line info when WP_DEBUG is enabled.
3454 *
3455 * @param \Exception $e The exception.
3456 * @return array
3457 */
3458 public function error( $e ) {
3459
3460 $error = array(
3461 'error' => array(
3462 'code' => 'cartflows_error',
3463 'message' => esc_html( $e->getMessage() ),
3464 ),
3465 );
3466
3467 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
3468 $error['error']['debug'] = array(
3469 'file' => $e->getFile(),
3470 'line' => $e->getLine(),
3471 );
3472 }
3473
3474 return $error;
3475 }
3476
3477 /**
3478 * Clamp per_page to safe bounds (1–$max).
3479 *
3480 * @param int $per_page Raw per_page value.
3481 * @param int $max Maximum allowed (default 100).
3482 * @return int
3483 */
3484 protected function clamp_per_page( $per_page, $max = 100 ) {
3485 return max( 1, min( intval( $per_page ), $max ) );
3486 }
3487
3488 /**
3489 * Clamp page to minimum 1.
3490 *
3491 * @param int $page Raw page value.
3492 * @return int
3493 */
3494 protected function clamp_page( $page ) {
3495 return max( 1, intval( $page ) );
3496 }
3497 }
3498